mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 18:48:27 +00:00
Feat: change space assignment from statement to episode
This commit is contained in:
parent
27f8740691
commit
f28c8ae2d1
49
apps/webapp/app/routes/api.v1.spaces.$spaceId.episodes.ts
Normal file
49
apps/webapp/app/routes/api.v1.spaces.$spaceId.episodes.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { z } from "zod";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { SpaceService } from "~/services/space.server";
|
||||
import { json } from "@remix-run/node";
|
||||
import { getSpaceEpisodeCount } from "~/services/graphModels/space";
|
||||
|
||||
const spaceService = new SpaceService();
|
||||
|
||||
// Schema for space ID parameter
|
||||
const SpaceParamsSchema = z.object({
|
||||
spaceId: z.string(),
|
||||
});
|
||||
|
||||
const { loader } = createActionApiRoute(
|
||||
{
|
||||
params: SpaceParamsSchema,
|
||||
allowJWT: true,
|
||||
authorization: {
|
||||
action: "search",
|
||||
},
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ authentication, params }) => {
|
||||
const userId = authentication.userId;
|
||||
const { spaceId } = params;
|
||||
|
||||
// Verify space exists and belongs to user
|
||||
const space = await spaceService.getSpace(spaceId, userId);
|
||||
if (!space) {
|
||||
return json({ error: "Space not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get episodes in the space
|
||||
const episodes = await spaceService.getSpaceEpisodes(spaceId, userId);
|
||||
const episodeCount = await getSpaceEpisodeCount(spaceId, userId);
|
||||
|
||||
return json({
|
||||
episodes,
|
||||
space: {
|
||||
uuid: space.uuid,
|
||||
name: space.name,
|
||||
description: space.description,
|
||||
episodeCount,
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export { loader };
|
||||
@ -1,16 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createActionApiRoute,
|
||||
createHybridActionApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { SpaceService } from "~/services/space.server";
|
||||
import { json } from "@remix-run/node";
|
||||
import {
|
||||
createSpace,
|
||||
deleteSpace,
|
||||
updateSpace,
|
||||
} from "~/services/graphModels/space";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
|
||||
|
||||
@ -33,45 +24,26 @@ const { loader, action } = createHybridActionApiRoute(
|
||||
const { spaceId } = params;
|
||||
const spaceService = new SpaceService();
|
||||
|
||||
// Verify space exists and belongs to user
|
||||
const space = await prisma.space.findUnique({
|
||||
where: {
|
||||
id: spaceId,
|
||||
},
|
||||
});
|
||||
if (!space) {
|
||||
return json({ error: "Space not found" }, { status: 404 });
|
||||
}
|
||||
// Reset the space (clears all assignments, summary, and metadata)
|
||||
const space = await spaceService.resetSpace(spaceId, userId);
|
||||
|
||||
// Get statements in the space
|
||||
await deleteSpace(spaceId, userId);
|
||||
logger.info(`Reset space ${space.id} successfully`);
|
||||
|
||||
await createSpace(
|
||||
space.id,
|
||||
space.name.trim(),
|
||||
space.description?.trim(),
|
||||
userId,
|
||||
);
|
||||
|
||||
await spaceService.updateSpace(space.id, { status: "pending" }, userId);
|
||||
|
||||
logger.info(`Created space ${space.id} successfully`);
|
||||
|
||||
// Trigger automatic LLM assignment for the new space
|
||||
// Trigger automatic episode assignment for the reset space
|
||||
try {
|
||||
await triggerSpaceAssignment({
|
||||
userId: userId,
|
||||
workspaceId: space.workspaceId,
|
||||
mode: "new_space",
|
||||
newSpaceId: space.id,
|
||||
batchSize: 25, // Analyze recent statements for the new space
|
||||
batchSize: 20, // Analyze recent episodes for reassignment
|
||||
});
|
||||
|
||||
logger.info(`Triggered LLM space assignment for new space ${space.id}`);
|
||||
logger.info(`Triggered space assignment for reset space ${space.id}`);
|
||||
} catch (error) {
|
||||
// Don't fail space creation if LLM assignment fails
|
||||
// Don't fail space reset if assignment fails
|
||||
logger.warn(
|
||||
`Failed to trigger LLM assignment for space ${space.id}:`,
|
||||
`Failed to trigger assignment for space ${space.id}:`,
|
||||
error as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
@ -33,6 +33,9 @@ const { loader } = createActionApiRoute(
|
||||
const statements = await spaceService.getSpaceStatements(spaceId, userId);
|
||||
|
||||
return json({
|
||||
deprecated: true,
|
||||
deprecationMessage: "This endpoint is deprecated. Use /api/v1/spaces/{spaceId}/episodes instead. Spaces now work with episodes directly.",
|
||||
newEndpoint: `/api/v1/spaces/${spaceId}/episodes`,
|
||||
statements,
|
||||
space: {
|
||||
uuid: space.uuid,
|
||||
|
||||
@ -57,12 +57,11 @@ export async function getSpace(
|
||||
MATCH (s:Space {uuid: $spaceId, userId: $userId})
|
||||
WHERE s.isActive = true
|
||||
|
||||
// Count statements in this space using optimized approach
|
||||
OPTIONAL MATCH (stmt:Statement {userId: $userId})
|
||||
WHERE stmt.spaceIds IS NOT NULL AND $spaceId IN stmt.spaceIds AND stmt.invalidAt IS NULL
|
||||
// Count episodes assigned to this space using direct relationship
|
||||
OPTIONAL MATCH (s)-[:HAS_EPISODE]->(e:Episode {userId: $userId})
|
||||
|
||||
WITH s, count(stmt) as statementCount
|
||||
RETURN s, statementCount
|
||||
WITH s, count(e) as episodeCount
|
||||
RETURN s, episodeCount
|
||||
`;
|
||||
|
||||
const result = await runQuery(query, { spaceId, userId });
|
||||
@ -71,7 +70,7 @@ export async function getSpace(
|
||||
}
|
||||
|
||||
const spaceData = result[0].get("s").properties;
|
||||
const statementCount = result[0].get("statementCount") || 0;
|
||||
const episodeCount = result[0].get("episodeCount") || 0;
|
||||
|
||||
return {
|
||||
uuid: spaceData.uuid,
|
||||
@ -81,7 +80,7 @@ export async function getSpace(
|
||||
createdAt: new Date(spaceData.createdAt),
|
||||
updatedAt: new Date(spaceData.updatedAt),
|
||||
isActive: spaceData.isActive,
|
||||
statementCount: Number(statementCount),
|
||||
contextCount: Number(episodeCount), // Episode count = context count
|
||||
};
|
||||
}
|
||||
|
||||
@ -151,28 +150,45 @@ export async function deleteSpace(
|
||||
}
|
||||
|
||||
// 2. Clean up statement references (remove spaceId from spaceIds arrays)
|
||||
const cleanupQuery = `
|
||||
const cleanupStatementsQuery = `
|
||||
MATCH (s:Statement {userId: $userId})
|
||||
WHERE s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds
|
||||
SET s.spaceIds = [id IN s.spaceIds WHERE id <> $spaceId]
|
||||
RETURN count(s) as updatedStatements
|
||||
`;
|
||||
|
||||
const cleanupResult = await runQuery(cleanupQuery, { userId, spaceId });
|
||||
const updatedStatements = cleanupResult[0]?.get("updatedStatements") || 0;
|
||||
const cleanupStatementsResult = await runQuery(cleanupStatementsQuery, { userId, spaceId });
|
||||
const updatedStatements = cleanupStatementsResult[0]?.get("updatedStatements") || 0;
|
||||
|
||||
// 3. Delete the space node
|
||||
// 3. Clean up episode references (remove spaceId from spaceIds arrays)
|
||||
const cleanupEpisodesQuery = `
|
||||
MATCH (e:Episode {userId: $userId})
|
||||
WHERE e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
|
||||
SET e.spaceIds = [id IN e.spaceIds WHERE id <> $spaceId]
|
||||
RETURN count(e) as updatedEpisodes
|
||||
`;
|
||||
|
||||
const cleanupEpisodesResult = await runQuery(cleanupEpisodesQuery, { userId, spaceId });
|
||||
const updatedEpisodes = cleanupEpisodesResult[0]?.get("updatedEpisodes") || 0;
|
||||
|
||||
// 4. Delete the space node and all its relationships
|
||||
const deleteQuery = `
|
||||
MATCH (space:Space {uuid: $spaceId, userId: $userId})
|
||||
DELETE space
|
||||
DETACH DELETE space
|
||||
RETURN count(space) as deletedSpaces
|
||||
`;
|
||||
|
||||
await runQuery(deleteQuery, { userId, spaceId });
|
||||
|
||||
logger.info(`Deleted space ${spaceId}`, {
|
||||
userId,
|
||||
statementsUpdated: updatedStatements,
|
||||
episodesUpdated: updatedEpisodes,
|
||||
});
|
||||
|
||||
return {
|
||||
deleted: true,
|
||||
statementsUpdated: Number(updatedStatements),
|
||||
statementsUpdated: Number(updatedStatements) + Number(updatedEpisodes),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@ -319,127 +335,6 @@ export async function getSpaceStatementCount(
|
||||
return Number(result[0]?.get("statementCount") || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a space should trigger pattern analysis based on growth thresholds
|
||||
*/
|
||||
export async function shouldTriggerSpacePattern(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
): Promise<{
|
||||
shouldTrigger: boolean;
|
||||
isNewSpace: boolean;
|
||||
currentCount: number;
|
||||
}> {
|
||||
try {
|
||||
// Get current statement count from Neo4j
|
||||
const currentCount = await getSpaceStatementCount(spaceId, userId);
|
||||
|
||||
// Get space data from PostgreSQL
|
||||
const space = await prisma.space.findUnique({
|
||||
where: { id: spaceId },
|
||||
select: {
|
||||
lastPatternTrigger: true,
|
||||
statementCountAtLastTrigger: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!space) {
|
||||
logger.warn(`Space ${spaceId} not found when checking pattern trigger`);
|
||||
return { shouldTrigger: false, isNewSpace: false, currentCount };
|
||||
}
|
||||
|
||||
const isNewSpace = !space.lastPatternTrigger;
|
||||
const previousCount = space.statementCountAtLastTrigger || 0;
|
||||
const growth = currentCount - previousCount;
|
||||
|
||||
// Trigger if: new space OR growth >= 100 statements
|
||||
const shouldTrigger = isNewSpace || growth >= 100;
|
||||
|
||||
logger.info(`Space pattern trigger check`, {
|
||||
spaceId,
|
||||
currentCount,
|
||||
previousCount,
|
||||
growth,
|
||||
isNewSpace,
|
||||
shouldTrigger,
|
||||
});
|
||||
|
||||
return { shouldTrigger, isNewSpace, currentCount };
|
||||
} catch (error) {
|
||||
logger.error(`Error checking space pattern trigger:`, {
|
||||
error,
|
||||
spaceId,
|
||||
userId,
|
||||
});
|
||||
return { shouldTrigger: false, isNewSpace: false, currentCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically update pattern trigger timestamp and statement count to prevent race conditions
|
||||
*/
|
||||
export async function atomicUpdatePatternTrigger(
|
||||
spaceId: string,
|
||||
currentCount: number,
|
||||
): Promise<{ updated: boolean; isNewSpace: boolean } | null> {
|
||||
try {
|
||||
// Use a transaction to atomically check and update
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Get current state
|
||||
const space = await tx.space.findUnique({
|
||||
where: { id: spaceId },
|
||||
select: {
|
||||
lastPatternTrigger: true,
|
||||
statementCountAtLastTrigger: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!space) {
|
||||
throw new Error(`Space ${spaceId} not found`);
|
||||
}
|
||||
|
||||
const isNewSpace = !space.lastPatternTrigger;
|
||||
const previousCount = space.statementCountAtLastTrigger || 0;
|
||||
const growth = currentCount - previousCount;
|
||||
|
||||
// Double-check if we still need to trigger (race condition protection)
|
||||
const shouldTrigger = isNewSpace || growth >= 100;
|
||||
|
||||
if (!shouldTrigger) {
|
||||
return { updated: false, isNewSpace: false };
|
||||
}
|
||||
|
||||
// Update the trigger timestamp and count atomically
|
||||
await tx.space.update({
|
||||
where: { id: spaceId },
|
||||
data: {
|
||||
lastPatternTrigger: new Date(),
|
||||
statementCountAtLastTrigger: currentCount,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Atomically updated pattern trigger for space`, {
|
||||
spaceId,
|
||||
previousCount,
|
||||
currentCount,
|
||||
growth,
|
||||
isNewSpace,
|
||||
});
|
||||
|
||||
return { updated: true, isNewSpace };
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Error in atomic pattern trigger update:`, {
|
||||
error,
|
||||
spaceId,
|
||||
currentCount,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize spaceIds array for existing statements (migration helper)
|
||||
*/
|
||||
@ -463,3 +358,153 @@ export async function initializeStatementSpaceIds(
|
||||
const result = await runQuery(query, userId ? { userId } : {});
|
||||
return Number(result[0]?.get("updated") || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign episodes to a space using intent-based matching
|
||||
*/
|
||||
export async function assignEpisodesToSpace(
|
||||
episodeIds: string[],
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
): Promise<SpaceAssignmentResult> {
|
||||
try {
|
||||
// Verify space exists and belongs to user
|
||||
const space = await getSpace(spaceId, userId);
|
||||
if (!space) {
|
||||
return {
|
||||
success: false,
|
||||
statementsUpdated: 0,
|
||||
error: "Space not found or access denied",
|
||||
};
|
||||
}
|
||||
|
||||
// Update episodes with spaceIds array AND create HAS_EPISODE relationships
|
||||
// This hybrid approach enables both fast array lookups and graph traversal
|
||||
const query = `
|
||||
MATCH (space:Space {uuid: $spaceId, userId: $userId})
|
||||
MATCH (e:Episode {userId: $userId})
|
||||
WHERE e.uuid IN $episodeIds
|
||||
SET e.spaceIds = CASE
|
||||
WHEN e.spaceIds IS NULL THEN [$spaceId]
|
||||
WHEN $spaceId IN e.spaceIds THEN e.spaceIds
|
||||
ELSE e.spaceIds + [$spaceId]
|
||||
END,
|
||||
e.lastSpaceAssignment = datetime(),
|
||||
e.spaceAssignmentMethod = CASE
|
||||
WHEN e.spaceAssignmentMethod IS NULL THEN 'intent_based'
|
||||
ELSE e.spaceAssignmentMethod
|
||||
END
|
||||
WITH e, space
|
||||
MERGE (space)-[r:HAS_EPISODE]->(e)
|
||||
ON CREATE SET
|
||||
r.assignedAt = datetime(),
|
||||
r.assignmentMethod = 'intent_based'
|
||||
RETURN count(e) as updated
|
||||
`;
|
||||
|
||||
const result = await runQuery(query, { episodeIds, spaceId, userId });
|
||||
const updatedCount = result[0]?.get("updated") || 0;
|
||||
|
||||
logger.info(`Assigned ${updatedCount} episodes to space ${spaceId}`, {
|
||||
episodeIds: episodeIds.length,
|
||||
userId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
statementsUpdated: Number(updatedCount),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error assigning episodes to space:`, {
|
||||
error,
|
||||
spaceId,
|
||||
episodeIds: episodeIds.length,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
statementsUpdated: 0,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove episodes from a space
|
||||
*/
|
||||
export async function removeEpisodesFromSpace(
|
||||
episodeIds: string[],
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
): Promise<SpaceAssignmentResult> {
|
||||
try {
|
||||
// Remove from both spaceIds array and HAS_EPISODE relationship
|
||||
const query = `
|
||||
MATCH (e:Episode {userId: $userId})
|
||||
WHERE e.uuid IN $episodeIds AND e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
|
||||
SET e.spaceIds = [id IN e.spaceIds WHERE id <> $spaceId]
|
||||
WITH e
|
||||
MATCH (space:Space {uuid: $spaceId, userId: $userId})-[r:HAS_EPISODE]->(e)
|
||||
DELETE r
|
||||
RETURN count(e) as updated
|
||||
`;
|
||||
|
||||
const result = await runQuery(query, { episodeIds, spaceId, userId });
|
||||
const updatedCount = result[0]?.get("updated") || 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
statementsUpdated: Number(updatedCount),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
statementsUpdated: 0,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all episodes in a space
|
||||
*/
|
||||
export async function getSpaceEpisodes(spaceId: string, userId: string) {
|
||||
const query = `
|
||||
MATCH (space:Space {uuid: $spaceId, userId: $userId})-[:HAS_EPISODE]->(e:Episode {userId: $userId})
|
||||
RETURN e
|
||||
ORDER BY e.createdAt DESC
|
||||
`;
|
||||
|
||||
const result = await runQuery(query, { spaceId, userId });
|
||||
|
||||
return result.map((record) => {
|
||||
const episode = record.get("e").properties;
|
||||
return {
|
||||
uuid: episode.uuid,
|
||||
content: episode.content,
|
||||
originalContent: episode.originalContent,
|
||||
source: episode.source,
|
||||
createdAt: new Date(episode.createdAt),
|
||||
validAt: new Date(episode.validAt),
|
||||
metadata: JSON.parse(episode.metadata || "{}"),
|
||||
sessionId: episode.sessionId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get episode count for a space
|
||||
*/
|
||||
export async function getSpaceEpisodeCount(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
// Use spaceIds array for faster lookup instead of relationship traversal
|
||||
const query = `
|
||||
MATCH (e:Episode {userId: $userId})
|
||||
WHERE e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
|
||||
RETURN count(e) as episodeCount
|
||||
`;
|
||||
|
||||
const result = await runQuery(query, { spaceId, userId });
|
||||
return Number(result[0]?.get("episodeCount") || 0);
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import { type Space } from "@prisma/client";
|
||||
|
||||
import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
|
||||
import {
|
||||
assignEpisodesToSpace,
|
||||
assignStatementsToSpace,
|
||||
createSpace,
|
||||
deleteSpace,
|
||||
@ -182,13 +183,6 @@ export class SpaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
updates.description !== undefined &&
|
||||
updates.description.length > 1000
|
||||
) {
|
||||
throw new Error("Space description too long (max 1000 characters)");
|
||||
}
|
||||
|
||||
const space = await prisma.space.update({
|
||||
where: {
|
||||
id: spaceId,
|
||||
@ -232,6 +226,58 @@ export class SpaceService {
|
||||
return space;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a space by clearing all episode assignments, summary, and metadata
|
||||
*/
|
||||
async resetSpace(spaceId: string, userId: string): Promise<Space> {
|
||||
logger.info(`Resetting space ${spaceId} for user ${userId}`);
|
||||
|
||||
// Get the space first to verify it exists and get its details
|
||||
const space = await prisma.space.findUnique({
|
||||
where: {
|
||||
id: spaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!space) {
|
||||
throw new Error("Space not found");
|
||||
}
|
||||
|
||||
if (space.name === "Profile") {
|
||||
throw new Error("Cannot reset Profile space");
|
||||
}
|
||||
|
||||
// Delete all relationships in Neo4j (episodes, statements, etc.)
|
||||
await deleteSpace(spaceId, userId);
|
||||
|
||||
// Recreate the space in Neo4j (clean slate)
|
||||
await createSpace(
|
||||
space.id,
|
||||
space.name.trim(),
|
||||
space.description?.trim(),
|
||||
userId,
|
||||
);
|
||||
|
||||
// Reset all summary and metadata fields in PostgreSQL
|
||||
const resetSpace = await prisma.space.update({
|
||||
where: {
|
||||
id: spaceId,
|
||||
},
|
||||
data: {
|
||||
summary: null,
|
||||
themes: [],
|
||||
contextCount: null,
|
||||
status: "pending",
|
||||
summaryGeneratedAt: null,
|
||||
lastPatternTrigger: null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Reset space ${spaceId} successfully`);
|
||||
|
||||
return resetSpace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign statements to a space
|
||||
*/
|
||||
@ -310,12 +356,60 @@ export class SpaceService {
|
||||
|
||||
/**
|
||||
* Get all statements in a space
|
||||
* @deprecated Use getSpaceEpisodes instead - spaces now work with episodes
|
||||
*/
|
||||
async getSpaceStatements(spaceId: string, userId: string) {
|
||||
logger.info(`Fetching statements for space ${spaceId} for user ${userId}`);
|
||||
return await getSpaceStatements(spaceId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all episodes in a space
|
||||
*/
|
||||
async getSpaceEpisodes(spaceId: string, userId: string) {
|
||||
logger.info(`Fetching episodes for space ${spaceId} for user ${userId}`);
|
||||
const { getSpaceEpisodes } = await import("./graphModels/space");
|
||||
return await getSpaceEpisodes(spaceId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign episodes to a space
|
||||
*/
|
||||
async assignEpisodesToSpace(
|
||||
episodeIds: string[],
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
) {
|
||||
logger.info(
|
||||
`Assigning ${episodeIds.length} episodes to space ${spaceId} for user ${userId}`,
|
||||
);
|
||||
|
||||
await assignEpisodesToSpace(episodeIds,spaceId, userId);
|
||||
|
||||
logger.info(
|
||||
`Successfully assigned ${episodeIds.length} episodes to space ${spaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove episodes from a space
|
||||
*/
|
||||
async removeEpisodesFromSpace(
|
||||
episodeIds: string[],
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
) {
|
||||
logger.info(
|
||||
`Removing ${episodeIds.length} episodes from space ${spaceId} for user ${userId}`,
|
||||
);
|
||||
|
||||
await this.removeEpisodesFromSpace(episodeIds, spaceId, userId);
|
||||
|
||||
logger.info(
|
||||
`Successfully removed ${episodeIds.length} episodes from space ${spaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search spaces by name
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -10,7 +10,7 @@ import { triggerSpacePattern } from "./space-pattern";
|
||||
import { getSpace, updateSpace } from "../utils/space-utils";
|
||||
|
||||
import { EpisodeType } from "@core/types";
|
||||
import { getSpaceStatementCount } from "~/services/graphModels/space";
|
||||
import { getSpaceEpisodeCount } from "~/services/graphModels/space";
|
||||
import { addToQueue } from "../utils/queue";
|
||||
|
||||
interface SpaceSummaryPayload {
|
||||
@ -35,7 +35,7 @@ interface SpaceSummaryData {
|
||||
spaceId: string;
|
||||
spaceName: string;
|
||||
spaceDescription?: string;
|
||||
statementCount: number;
|
||||
contextCount: number;
|
||||
summary: string;
|
||||
keyEntities: string[];
|
||||
themes: string[];
|
||||
@ -55,7 +55,7 @@ const SummaryResultSchema = z.object({
|
||||
const CONFIG = {
|
||||
maxEpisodesForSummary: 20, // Limit episodes for performance
|
||||
minEpisodesForSummary: 1, // Minimum episodes to generate summary
|
||||
summaryPromptTokenLimit: 4000, // Approximate token limit for prompt
|
||||
summaryEpisodeThreshold: 10, // Minimum new episodes required to trigger summary (configurable)
|
||||
};
|
||||
|
||||
export const spaceSummaryQueue = queue({
|
||||
@ -85,7 +85,7 @@ export const spaceSummaryTask = task({
|
||||
});
|
||||
|
||||
// Generate summary for the single space
|
||||
const summaryResult = await generateSpaceSummary(spaceId, userId);
|
||||
const summaryResult = await generateSpaceSummary(spaceId, userId, triggerSource);
|
||||
|
||||
if (summaryResult) {
|
||||
// Store the summary
|
||||
@ -98,36 +98,24 @@ export const spaceSummaryTask = task({
|
||||
metadata: {
|
||||
triggerSource,
|
||||
phase: "completed_summary",
|
||||
statementCount: summaryResult.statementCount,
|
||||
contextCount: summaryResult.contextCount,
|
||||
confidence: summaryResult.confidence,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Generated summary for space ${spaceId}`, {
|
||||
statementCount: summaryResult.statementCount,
|
||||
statementCount: summaryResult.contextCount,
|
||||
confidence: summaryResult.confidence,
|
||||
themes: summaryResult.themes.length,
|
||||
triggerSource,
|
||||
});
|
||||
|
||||
// Ingest summary as document if it exists and continue with patterns
|
||||
if (!summaryResult.isIncremental && summaryResult.statementCount > 0) {
|
||||
await processSpaceSummarySequentially({
|
||||
userId,
|
||||
workspaceId,
|
||||
spaceId,
|
||||
spaceName: summaryResult.spaceName,
|
||||
summaryContent: summaryResult.summary,
|
||||
triggerSource: "summary_complete",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
spaceId,
|
||||
triggerSource,
|
||||
summary: {
|
||||
statementCount: summaryResult.statementCount,
|
||||
statementCount: summaryResult.contextCount,
|
||||
confidence: summaryResult.confidence,
|
||||
themesCount: summaryResult.themes.length,
|
||||
},
|
||||
@ -186,6 +174,7 @@ export const spaceSummaryTask = task({
|
||||
async function generateSpaceSummary(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
triggerSource?: "assignment" | "manual" | "scheduled",
|
||||
): Promise<SpaceSummaryData | null> {
|
||||
try {
|
||||
// 1. Get space details
|
||||
@ -197,6 +186,35 @@ async function generateSpaceSummary(
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Check episode count threshold (skip for manual triggers)
|
||||
if (triggerSource !== "manual") {
|
||||
const currentEpisodeCount = await getSpaceEpisodeCount(spaceId, userId);
|
||||
const lastSummaryEpisodeCount = space.contextCount || 0;
|
||||
const episodeDifference = currentEpisodeCount - lastSummaryEpisodeCount;
|
||||
|
||||
if (episodeDifference < CONFIG.summaryEpisodeThreshold) {
|
||||
logger.info(
|
||||
`Skipping summary generation for space ${spaceId}: only ${episodeDifference} new episodes (threshold: ${CONFIG.summaryEpisodeThreshold})`,
|
||||
{
|
||||
currentEpisodeCount,
|
||||
lastSummaryEpisodeCount,
|
||||
episodeDifference,
|
||||
threshold: CONFIG.summaryEpisodeThreshold,
|
||||
}
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Proceeding with summary generation for space ${spaceId}: ${episodeDifference} new episodes (threshold: ${CONFIG.summaryEpisodeThreshold})`,
|
||||
{
|
||||
currentEpisodeCount,
|
||||
lastSummaryEpisodeCount,
|
||||
episodeDifference,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Check for existing summary
|
||||
const existingSummary = await getExistingSummary(spaceId);
|
||||
const isIncremental = existingSummary !== null;
|
||||
@ -296,14 +314,14 @@ async function generateSpaceSummary(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the actual current statement count from Neo4j
|
||||
const currentStatementCount = await getSpaceStatementCount(spaceId, userId);
|
||||
// Get the actual current counts from Neo4j
|
||||
const currentEpisodeCount = await getSpaceEpisodeCount(spaceId, userId);
|
||||
|
||||
return {
|
||||
spaceId: space.uuid,
|
||||
spaceName: space.name,
|
||||
spaceDescription: space.description as string,
|
||||
statementCount: currentStatementCount,
|
||||
contextCount: currentEpisodeCount,
|
||||
summary: summaryResult.summary,
|
||||
keyEntities: summaryResult.keyEntities || [],
|
||||
themes: summaryResult.themes,
|
||||
@ -400,38 +418,48 @@ function createUnifiedSummaryPrompt(
|
||||
return [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an expert at analyzing and summarizing structured knowledge within semantic spaces. Your task is to ${isUpdate ? "update an existing summary by integrating new episodes" : "create a comprehensive summary of episodes"}.
|
||||
content: `You are an expert at analyzing and summarizing episodes within semantic spaces based on the space's intent and purpose. Your task is to ${isUpdate ? "update an existing summary by integrating new episodes" : "create a comprehensive summary of episodes"}.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Base your summary ONLY on insights derived from the actual content/episodes provided
|
||||
2. Use the space description only as contextual guidance, never copy or paraphrase it
|
||||
2. Use the space's INTENT/PURPOSE (from description) to guide what to summarize and how to organize it
|
||||
3. Write in a factual, neutral tone - avoid promotional language ("pivotal", "invaluable", "cutting-edge")
|
||||
4. Be specific and concrete - reference actual content, patterns, and themes found in the episodes
|
||||
4. Be specific and concrete - reference actual content, patterns, and insights found in the episodes
|
||||
5. If episodes are insufficient for meaningful insights, state that more data is needed
|
||||
|
||||
INTENT-DRIVEN SUMMARIZATION:
|
||||
Your summary should SERVE the space's intended purpose. Examples:
|
||||
- "Learning React" → Summarize React concepts, patterns, techniques learned
|
||||
- "Project X Updates" → Summarize progress, decisions, blockers, next steps
|
||||
- "Health Tracking" → Summarize metrics, trends, observations, insights
|
||||
- "Guidelines for React" → Extract actionable patterns, best practices, rules
|
||||
- "Evolution of design thinking" → Track how thinking changed over time, decision points
|
||||
The intent defines WHY this space exists - organize content to serve that purpose.
|
||||
|
||||
INSTRUCTIONS:
|
||||
${
|
||||
isUpdate
|
||||
? `1. Review the existing summary and themes carefully
|
||||
2. Analyze the new episodes for patterns and insights
|
||||
2. Analyze the new episodes for patterns and insights that align with the space's intent
|
||||
3. Identify connecting points between existing knowledge and new episodes
|
||||
4. Update the summary to seamlessly integrate new information while preserving valuable existing insights
|
||||
5. Evolve themes by adding new ones or refining existing ones based on connections found
|
||||
6. Update the markdown summary to reflect the enhanced themes and new insights`
|
||||
5. Evolve themes by adding new ones or refining existing ones based on the space's purpose
|
||||
6. Organize the summary to serve the space's intended use case`
|
||||
: `1. Analyze the semantic content and relationships within the episodes
|
||||
2. Identify the main themes and patterns across all episodes (themes must have at least 3 supporting episodes)
|
||||
3. Create a coherent summary that captures the essence of this knowledge domain
|
||||
4. Generate a well-structured markdown summary organized by the identified themes`
|
||||
2. Identify topics/sections that align with the space's INTENT and PURPOSE
|
||||
3. Create a coherent summary that serves the space's intended use case
|
||||
4. Organize the summary based on the space's purpose (not generic frequency-based themes)`
|
||||
}
|
||||
${isUpdate ? "7" : "6"}. Assess your confidence in the ${isUpdate ? "updated" : ""} summary quality (0.0-1.0)
|
||||
${isUpdate ? "7" : "5"}. Assess your confidence in the ${isUpdate ? "updated" : ""} summary quality (0.0-1.0)
|
||||
|
||||
THEME IDENTIFICATION RULES:
|
||||
- A theme must be supported by AT LEAST 3 related episodes to be considered valid
|
||||
- Themes should represent substantial, meaningful patterns rather than minor occurrences
|
||||
- Each theme must capture a distinct semantic domain or conceptual area
|
||||
- Only identify themes that have sufficient evidence in the data
|
||||
- If fewer than 3 episodes support a potential theme, do not include it
|
||||
- Themes will be used to organize the markdown summary into logical sections
|
||||
INTENT-ALIGNED ORGANIZATION:
|
||||
- Organize sections based on what serves the space's purpose
|
||||
- Topics don't need minimum episode counts - relevance to intent matters most
|
||||
- Each section should provide value aligned with the space's intended use
|
||||
- For "guidelines" spaces: focus on actionable patterns
|
||||
- For "tracking" spaces: focus on temporal patterns and changes
|
||||
- For "learning" spaces: focus on concepts and insights gained
|
||||
- Let the space's intent drive the structure, not rigid rules
|
||||
|
||||
${
|
||||
isUpdate
|
||||
@ -484,7 +512,7 @@ ${
|
||||
role: "user",
|
||||
content: `SPACE INFORMATION:
|
||||
Name: "${spaceName}"
|
||||
Description (for context only): ${spaceDescription || "No description provided"}
|
||||
Intent/Purpose: ${spaceDescription || "No specific intent provided - organize naturally based on content"}
|
||||
|
||||
${
|
||||
isUpdate
|
||||
@ -508,8 +536,8 @@ ${topEntities.join(", ")}`
|
||||
|
||||
${
|
||||
isUpdate
|
||||
? "Please identify connections between the existing summary and new episodes, then update the summary to integrate the new insights coherently. Remember: only summarize insights from the actual episode content, not the space description."
|
||||
: "Please analyze the episodes and provide a comprehensive summary that captures insights derived from the episode content provided. Use the description only as context. If there are too few episodes to generate meaningful insights, indicate that more data is needed rather than falling back on the description."
|
||||
? "Please identify connections between the existing summary and new episodes, then update the summary to integrate the new insights coherently. Organize the summary to SERVE the space's intent/purpose. Remember: only summarize insights from the actual episode content."
|
||||
: "Please analyze the episodes and provide a comprehensive summary that SERVES the space's intent/purpose. Organize sections based on what would be most valuable for this space's intended use case. If the intent is unclear, organize naturally based on content patterns. Only summarize insights from actual episode content."
|
||||
}`,
|
||||
},
|
||||
];
|
||||
@ -519,7 +547,7 @@ async function getExistingSummary(spaceId: string): Promise<{
|
||||
summary: string;
|
||||
themes: string[];
|
||||
lastUpdated: Date;
|
||||
statementCount: number;
|
||||
contextCount: number;
|
||||
} | null> {
|
||||
try {
|
||||
const existingSummary = await getSpace(spaceId);
|
||||
@ -528,8 +556,8 @@ async function getExistingSummary(spaceId: string): Promise<{
|
||||
return {
|
||||
summary: existingSummary.summary,
|
||||
themes: existingSummary.themes,
|
||||
lastUpdated: existingSummary.lastPatternTrigger || new Date(),
|
||||
statementCount: existingSummary.statementCount || 0,
|
||||
lastUpdated: existingSummary.summaryGeneratedAt || new Date(),
|
||||
contextCount: existingSummary.contextCount || 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -547,24 +575,18 @@ async function getSpaceEpisodes(
|
||||
userId: string,
|
||||
sinceDate?: Date,
|
||||
): Promise<SpaceEpisodeData[]> {
|
||||
// Build query to get distinct episodes that have statements in the space
|
||||
let whereClause =
|
||||
"s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds AND s.invalidAt IS NULL";
|
||||
// Query episodes directly using Space-[:HAS_EPISODE]->Episode relationships
|
||||
const params: any = { spaceId, userId };
|
||||
|
||||
// Store the sinceDate condition separately to apply after e is defined
|
||||
let dateCondition = "";
|
||||
if (sinceDate) {
|
||||
dateCondition = "e.createdAt > $sinceDate";
|
||||
dateCondition = "AND e.createdAt > $sinceDate";
|
||||
params.sinceDate = sinceDate.toISOString();
|
||||
}
|
||||
|
||||
const query = `
|
||||
MATCH (s:Statement{userId: $userId})
|
||||
WHERE ${whereClause}
|
||||
OPTIONAL MATCH (e:Episode{userId: $userId})-[:HAS_PROVENANCE]->(s)
|
||||
WITH e
|
||||
WHERE e IS NOT NULL ${dateCondition ? `AND ${dateCondition}` : ""}
|
||||
MATCH (space:Space {uuid: $spaceId, userId: $userId})-[:HAS_EPISODE]->(e:Episode {userId: $userId})
|
||||
WHERE e IS NOT NULL ${dateCondition}
|
||||
RETURN DISTINCT e
|
||||
ORDER BY e.createdAt DESC
|
||||
`;
|
||||
@ -654,7 +676,7 @@ async function storeSummary(summaryData: SpaceSummaryData): Promise<void> {
|
||||
space.keyEntities = $keyEntities,
|
||||
space.themes = $themes,
|
||||
space.summaryConfidence = $confidence,
|
||||
space.summaryStatementCount = $statementCount,
|
||||
space.summaryContextCount = $contextCount,
|
||||
space.summaryLastUpdated = datetime($lastUpdated)
|
||||
RETURN space
|
||||
`;
|
||||
@ -665,7 +687,7 @@ async function storeSummary(summaryData: SpaceSummaryData): Promise<void> {
|
||||
keyEntities: summaryData.keyEntities,
|
||||
themes: summaryData.themes,
|
||||
confidence: summaryData.confidence,
|
||||
statementCount: summaryData.statementCount,
|
||||
contextCount: summaryData.contextCount,
|
||||
lastUpdated: summaryData.lastUpdated.toISOString(),
|
||||
});
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ export const updateSpace = async (summaryData: {
|
||||
spaceId: string;
|
||||
summary: string;
|
||||
themes: string[];
|
||||
statementCount: number;
|
||||
contextCount: number;
|
||||
}) => {
|
||||
return await prisma.space.update({
|
||||
where: {
|
||||
@ -40,7 +40,8 @@ export const updateSpace = async (summaryData: {
|
||||
data: {
|
||||
summary: summaryData.summary,
|
||||
themes: summaryData.themes,
|
||||
statementCount: summaryData.statementCount,
|
||||
contextCount: summaryData.contextCount,
|
||||
summaryGeneratedAt: new Date().toISOString()
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `statementCount` on the `Space` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `statementCountAtLastTrigger` on the `Space` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Space" DROP COLUMN "statementCount",
|
||||
DROP COLUMN "statementCountAtLastTrigger",
|
||||
ADD COLUMN "contextCount" INTEGER,
|
||||
ADD COLUMN "contextCountAtLastTrigger" INTEGER;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Space" ADD COLUMN "summaryGeneratedAt" TIMESTAMP(3);
|
||||
@ -472,20 +472,21 @@ model RecallLog {
|
||||
}
|
||||
|
||||
model Space {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
autoMode Boolean @default(false)
|
||||
summary String?
|
||||
themes String[]
|
||||
statementCount Int?
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
autoMode Boolean @default(false)
|
||||
summary String?
|
||||
themes String[]
|
||||
contextCount Int? // Count of context items in this space (episodes, statements, etc.)
|
||||
|
||||
status String?
|
||||
|
||||
icon String?
|
||||
|
||||
lastPatternTrigger DateTime?
|
||||
statementCountAtLastTrigger Int?
|
||||
lastPatternTrigger DateTime?
|
||||
summaryGeneratedAt DateTime?
|
||||
contextCountAtLastTrigger Int? // Context count when pattern was last triggered
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id])
|
||||
|
||||
@ -6,7 +6,7 @@ export interface SpaceNode {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
isActive: boolean;
|
||||
statementCount?: number; // Computed field
|
||||
contextCount?: number; // Computed field - count of episodes assigned to this space
|
||||
embedding?: number[]; // For future space similarity
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user