Feat: change space assignment from statement to episode

This commit is contained in:
Manoj 2025-10-08 00:30:18 +05:30
parent 27f8740691
commit f28c8ae2d1
12 changed files with 752 additions and 543 deletions

View 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 };

View File

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

View File

@ -32,7 +32,10 @@ const { loader } = createActionApiRoute(
// Get statements in the space
const statements = await spaceService.getSpaceStatements(spaceId, userId);
return json({
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,

View File

@ -56,13 +56,12 @@ export async function getSpace(
const query = `
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
WITH s, count(stmt) as statementCount
RETURN s, statementCount
// Count episodes assigned to this space using direct relationship
OPTIONAL MATCH (s)-[:HAS_EPISODE]->(e:Episode {userId: $userId})
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);
}

View File

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

View File

@ -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(),
});

View File

@ -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()
},
});
};

View File

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

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Space" ADD COLUMN "summaryGeneratedAt" TIMESTAMP(3);

View File

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

View File

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