From 6f026b846e65fac80fd0149ea56846b2799bd76b Mon Sep 17 00:00:00 2001 From: Scott Rose Date: Wed, 17 Sep 2025 04:43:00 -0600 Subject: [PATCH 1/3] feat: add sentiment summaries generation for PostGroups and update logging functionality --- src/generateTitle.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++ src/postGroup.ts | 42 ++++++++++++++++++++++++----- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/src/generateTitle.ts b/src/generateTitle.ts index b50d676..4239c50 100644 --- a/src/generateTitle.ts +++ b/src/generateTitle.ts @@ -14,6 +14,20 @@ const TitleSchema = z.object({ title: z.string().min(5, "Title must be at least 5 characters").max(100, "Title must not exceed 100 characters") }); +// Sentiment summaries result type +export type SentimentSummaries = { + bullishSummary: string; + bearishSummary: string; + neutralSummary: string; +}; + +// Zod schema for sentiment summaries validation +const SentimentSummariesSchema = z.object({ + bullishSummary: z.string().min(10, "Bullish summary must be at least 10 characters").max(500, "Bullish summary must not exceed 500 characters"), + bearishSummary: z.string().min(10, "Bearish summary must be at least 10 characters").max(500, "Bearish summary must not exceed 500 characters"), + neutralSummary: z.string().min(10, "Neutral summary must be at least 10 characters").max(500, "Neutral summary must not exceed 500 characters") +}); + // Generate title for a single post export async function generateTitleForPost(post: string, clientOverride?: OpenAI): Promise { const usedClient: OpenAI = clientOverride ?? openai; @@ -51,6 +65,55 @@ Be strict: return only raw JSON with exactly that shape; no code fences or prose } } +// Generate sentiment-based summaries for a group of posts +export async function generateSentimentSummariesForGroup(posts: any[], clientOverride?: OpenAI): Promise { + const usedClient: OpenAI = clientOverride ?? openai; + + const systemPrompt = `You are a sentiment analysis and summary generation system for social media posts, particularly crypto and tech-related content. + +Instructions: +1. Analyze the provided posts and their sentiments (BULLISH, BEARISH, NEUTRAL) +2. Generate three distinct summaries based on sentiment analysis: + - bullishSummary: Summarize the key bullish points, optimistic outlook, and positive sentiment from the posts + - bearishSummary: Summarize the key bearish points, concerns, and negative sentiment from the posts + - neutralSummary: Summarize the balanced, factual, or neutral observations from the posts +3. Each summary should be 10-500 characters +4. Focus on the main themes, key insights, and overall sentiment trends +5. Make summaries informative and actionable +6. Return only valid JSON in this format: +{ + "bullishSummary": "Your bullish summary here", + "bearishSummary": "Your bearish summary here", + "neutralSummary": "Your neutral summary here" +} + +Be strict: return only raw JSON with exactly that shape; no code fences or prose.`; + + const postsJson = JSON.stringify(posts); + + try { + const validated = await callOpenAIWithValidation({ + client: usedClient, + systemPrompt, + userPrompt: postsJson, + schema: SentimentSummariesSchema, + retryCount: 3 + }); + + if (!validated?.bullishSummary || !validated?.bearishSummary || !validated?.neutralSummary) { + throw new Error(`Sentiment summaries generation failed for posts: ${postsJson}`); + } + return { + bullishSummary: validated.bullishSummary, + bearishSummary: validated.bearishSummary, + neutralSummary: validated.neutralSummary + }; + } catch (e) { + console.error("Error generating sentiment summaries for posts:", postsJson, e); + throw e; + } +} + // Generate titles for multiple posts export async function generateTitlesForPosts(posts: string[]): Promise { const results: TitleResult[] = []; diff --git a/src/postGroup.ts b/src/postGroup.ts index cdcb680..23ecde3 100644 --- a/src/postGroup.ts +++ b/src/postGroup.ts @@ -1,6 +1,6 @@ import cron from 'node-cron'; -import { generateTitleForPost } from './generateTitle'; +import { generateTitleForPost, generateSentimentSummariesForGroup, type SentimentSummaries } from './generateTitle'; import { initRedis, getRedisClient } from './redisClient'; export type Post = { @@ -19,6 +19,9 @@ export type PostGroup = { id: string; posts: Post[]; title?: string; + bullishSummary?: string; + bearishSummary?: string; + neutralSummary?: string; }; // Generate a title for a PostGroup by aggregating its posts' content @@ -27,6 +30,11 @@ export async function generateTitleForPostGroup(postGroup: PostGroup): Promise { + return await generateSentimentSummariesForGroup(postGroup.posts); +} + // Fetch all PostGroups from Redis (expects a key 'PostGroup' with a JSON array, or adapt as needed) export async function fetchPostGroupsFromRedis(): Promise { @@ -45,7 +53,7 @@ export async function fetchPostGroupsFromRedis(): Promise { -// Reusable function to generate and log the title for all PostGroups from Redis +// Reusable function to generate and log the title and sentiment summaries for all PostGroups from Redis export async function logTitlesForAllPostGroups(context: 'CRON' | 'MANUAL' = 'MANUAL') { const postGroups = await fetchPostGroupsFromRedis(); if (!postGroups.length) { @@ -56,11 +64,28 @@ export async function logTitlesForAllPostGroups(context: 'CRON' | 'MANUAL' = 'MA const postGroupsWithOrderedKeys = []; for (const group of postGroups) { let title = group.title; + let bullishSummary = group.bullishSummary; + let bearishSummary = group.bearishSummary; + let neutralSummary = group.neutralSummary; + try { + // Generate title title = await generateTitleForPostGroup(group); if (group.title !== title) { updated = true; } + + // Generate sentiment summaries + const summaries = await generateSentimentSummariesForPostGroup(group); + if (group.bullishSummary !== summaries.bullishSummary || + group.bearishSummary !== summaries.bearishSummary || + group.neutralSummary !== summaries.neutralSummary) { + updated = true; + bullishSummary = summaries.bullishSummary; + bearishSummary = summaries.bearishSummary; + neutralSummary = summaries.neutralSummary; + } + if (context === 'CRON') { console.log(`[CRON] Generated Title for PostGroup (id: ${group.id}) at ${new Date().toISOString()}:`, title); } else { @@ -68,26 +93,29 @@ export async function logTitlesForAllPostGroups(context: 'CRON' | 'MANUAL' = 'MA } } catch (e) { if (context === 'CRON') { - console.error(`[CRON] Error generating title for PostGroup (id: ${group.id}):`, e); + console.error(`[CRON] Error generating title/summaries for PostGroup (id: ${group.id}):`, e); } else { - console.error(`Error generating title for PostGroup (id: ${group.id}):`, e); + console.error(`Error generating title/summaries for PostGroup (id: ${group.id}):`, e); } } postGroupsWithOrderedKeys.push({ id: group.id, title, + bullishSummary, + bearishSummary, + neutralSummary, posts: group.posts }); } - // Save updated PostGroups with titles back to Redis + // Save updated PostGroups with titles and summaries back to Redis if (updated) { await initRedis(); const redis = getRedisClient(); await redis.set('post-groups', JSON.stringify(postGroupsWithOrderedKeys)); if (context === 'CRON') { - console.log('[CRON] Updated post-groups with titles saved to Redis.'); + console.log('[CRON] Updated post-groups with titles and sentiment summaries saved to Redis.'); } else { - console.log('Updated post-groups with titles saved to Redis.'); + console.log('Updated post-groups with titles and sentiment summaries saved to Redis.'); } } } From 1f7b516aca563ca093fa62e0c00944d8228160b8 Mon Sep 17 00:00:00 2001 From: Scott Rose Date: Wed, 17 Sep 2025 04:58:57 -0600 Subject: [PATCH 2/3] feat: enhance sentiment summaries generation with improved error logging and maxTokens parameter --- src/generateTitle.ts | 10 +++++++--- src/openaiValidationUtil.ts | 5 +++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/generateTitle.ts b/src/generateTitle.ts index 4239c50..9a1e039 100644 --- a/src/generateTitle.ts +++ b/src/generateTitle.ts @@ -97,11 +97,12 @@ Be strict: return only raw JSON with exactly that shape; no code fences or prose systemPrompt, userPrompt: postsJson, schema: SentimentSummariesSchema, - retryCount: 3 + retryCount: 3, + maxTokens: 800 }); if (!validated?.bullishSummary || !validated?.bearishSummary || !validated?.neutralSummary) { - throw new Error(`Sentiment summaries generation failed for posts: ${postsJson}`); + throw new Error(`Sentiment summaries generation failed for posts.`); } return { bullishSummary: validated.bullishSummary, @@ -109,7 +110,10 @@ Be strict: return only raw JSON with exactly that shape; no code fences or prose neutralSummary: validated.neutralSummary }; } catch (e) { - console.error("Error generating sentiment summaries for posts:", postsJson, e); + console.error("Error generating sentiment summaries.", { + postCount: Array.isArray(posts) ? posts.length : undefined, + error: e instanceof Error ? e.message : String(e) + }); throw e; } } diff --git a/src/openaiValidationUtil.ts b/src/openaiValidationUtil.ts index f15b9af..112fa9a 100644 --- a/src/openaiValidationUtil.ts +++ b/src/openaiValidationUtil.ts @@ -12,7 +12,8 @@ export async function callOpenAIWithValidation(params: { systemPrompt: string, userPrompt: string, schema: ZodSchema, - retryCount?: number + retryCount?: number, + maxTokens?: number }): Promise { let lastError: unknown = null; const baseDelayMs = 500; @@ -37,7 +38,7 @@ export async function callOpenAIWithValidation(params: { model: "gpt-4o-mini", messages, response_format: { type: "json_object" } as const, - max_tokens: 200 + max_tokens: params.maxTokens ?? 200 }; try { const response = await params.client.chat.completions.create(chatParams); From 092e9b5de72f4631fffce5fd7909c3a2256ee1b4 Mon Sep 17 00:00:00 2001 From: Scott Rose Date: Wed, 17 Sep 2025 05:16:25 -0600 Subject: [PATCH 3/3] feat: update sentiment summaries to be optional and improve title generation logic for PostGroups --- src/generateTitle.ts | 48 +++++++++++++++++------------------ src/postGroup.ts | 59 +++++++++++++++----------------------------- 2 files changed, 43 insertions(+), 64 deletions(-) diff --git a/src/generateTitle.ts b/src/generateTitle.ts index 9a1e039..9546b9a 100644 --- a/src/generateTitle.ts +++ b/src/generateTitle.ts @@ -16,23 +16,23 @@ const TitleSchema = z.object({ // Sentiment summaries result type export type SentimentSummaries = { - bullishSummary: string; - bearishSummary: string; - neutralSummary: string; + bullishSummary?: string; + bearishSummary?: string; + neutralSummary?: string; }; // Zod schema for sentiment summaries validation const SentimentSummariesSchema = z.object({ - bullishSummary: z.string().min(10, "Bullish summary must be at least 10 characters").max(500, "Bullish summary must not exceed 500 characters"), - bearishSummary: z.string().min(10, "Bearish summary must be at least 10 characters").max(500, "Bearish summary must not exceed 500 characters"), - neutralSummary: z.string().min(10, "Neutral summary must be at least 10 characters").max(500, "Neutral summary must not exceed 500 characters") + bullishSummary: z.string().min(10).max(500).optional(), + bearishSummary: z.string().min(10).max(500).optional(), + neutralSummary: z.string().min(10).max(500).optional(), }); // Generate title for a single post export async function generateTitleForPost(post: string, clientOverride?: OpenAI): Promise { const usedClient: OpenAI = clientOverride ?? openai; - const systemPrompt = `You are a title generation system for social media posts, particularly crypto and tech-related content. + const systemPrompt = `You are a title generation system for social media posts, particularly finance and tech-related content. Instructions: 1. Generate a concise, engaging title that captures the essence of the post @@ -69,22 +69,19 @@ Be strict: return only raw JSON with exactly that shape; no code fences or prose export async function generateSentimentSummariesForGroup(posts: any[], clientOverride?: OpenAI): Promise { const usedClient: OpenAI = clientOverride ?? openai; - const systemPrompt = `You are a sentiment analysis and summary generation system for social media posts, particularly crypto and tech-related content. + const systemPrompt = `You are a sentiment analysis and summary generation system for social media posts, particularly finance and tech-related content. Instructions: -1. Analyze the provided posts and their sentiments (BULLISH, BEARISH, NEUTRAL) -2. Generate three distinct summaries based on sentiment analysis: - - bullishSummary: Summarize the key bullish points, optimistic outlook, and positive sentiment from the posts - - bearishSummary: Summarize the key bearish points, concerns, and negative sentiment from the posts - - neutralSummary: Summarize the balanced, factual, or neutral observations from the posts -3. Each summary should be 10-500 characters -4. Focus on the main themes, key insights, and overall sentiment trends -5. Make summaries informative and actionable -6. Return only valid JSON in this format: +1. Analyze the provided posts and their sentiments (BULLISH, BEARISH, NEUTRAL). +2. For each sentiment (bullish, bearish, neutral) that is present in the posts, generate a summary. If there are no posts for a sentiment, do not generate or include a summary for it. +3. Each summary should be 10-500 characters. +4. Focus on the main themes, key insights, and overall sentiment trends for each sentiment present. +5. Make summaries informative and actionable, but do not make up content for sentiments not present in the posts. +6. Return only valid JSON in this format, including only the summaries for sentiments that exist in the posts: { - "bullishSummary": "Your bullish summary here", - "bearishSummary": "Your bearish summary here", - "neutralSummary": "Your neutral summary here" + "bullishSummary"?: "Your bullish summary here (optional)", + "bearishSummary"?: "Your bearish summary here (optional)", + "neutralSummary"?: "Your neutral summary here (optional)" } Be strict: return only raw JSON with exactly that shape; no code fences or prose.`; @@ -101,13 +98,14 @@ Be strict: return only raw JSON with exactly that shape; no code fences or prose maxTokens: 800 }); - if (!validated?.bullishSummary || !validated?.bearishSummary || !validated?.neutralSummary) { - throw new Error(`Sentiment summaries generation failed for posts.`); + if (!validated?.bullishSummary && !validated?.bearishSummary && !validated?.neutralSummary) { + throw new Error(`Sentiment summaries generation failed for posts: no summaries returned.`); } + // Only return summaries for sentiments present in the posts return { - bullishSummary: validated.bullishSummary, - bearishSummary: validated.bearishSummary, - neutralSummary: validated.neutralSummary + ...(validated.bullishSummary ? { bullishSummary: validated.bullishSummary } : {}), + ...(validated.bearishSummary ? { bearishSummary: validated.bearishSummary } : {}), + ...(validated.neutralSummary ? { neutralSummary: validated.neutralSummary } : {}) }; } catch (e) { console.error("Error generating sentiment summaries.", { diff --git a/src/postGroup.ts b/src/postGroup.ts index 23ecde3..77dd3b3 100644 --- a/src/postGroup.ts +++ b/src/postGroup.ts @@ -60,37 +60,28 @@ export async function logTitlesForAllPostGroups(context: 'CRON' | 'MANUAL' = 'MA console.log('No PostGroups found in Redis.'); return; } - let updated = false; const postGroupsWithOrderedKeys = []; for (const group of postGroups) { - let title = group.title; - let bullishSummary = group.bullishSummary; - let bearishSummary = group.bearishSummary; - let neutralSummary = group.neutralSummary; - try { - // Generate title - title = await generateTitleForPostGroup(group); - if (group.title !== title) { - updated = true; - } - - // Generate sentiment summaries + // Always generate and update title + const title = await generateTitleForPostGroup(group); + // Always generate and update sentiment summaries const summaries = await generateSentimentSummariesForPostGroup(group); - if (group.bullishSummary !== summaries.bullishSummary || - group.bearishSummary !== summaries.bearishSummary || - group.neutralSummary !== summaries.neutralSummary) { - updated = true; - bullishSummary = summaries.bullishSummary; - bearishSummary = summaries.bearishSummary; - neutralSummary = summaries.neutralSummary; - } if (context === 'CRON') { console.log(`[CRON] Generated Title for PostGroup (id: ${group.id}) at ${new Date().toISOString()}:`, title); } else { console.log(`Title for PostGroup (id: ${group.id}):`, title); } + + postGroupsWithOrderedKeys.push({ + id: group.id, + title, + bullishSummary: summaries.bullishSummary, + bearishSummary: summaries.bearishSummary, + neutralSummary: summaries.neutralSummary, + posts: group.posts + }); } catch (e) { if (context === 'CRON') { console.error(`[CRON] Error generating title/summaries for PostGroup (id: ${group.id}):`, e); @@ -98,25 +89,15 @@ export async function logTitlesForAllPostGroups(context: 'CRON' | 'MANUAL' = 'MA console.error(`Error generating title/summaries for PostGroup (id: ${group.id}):`, e); } } - postGroupsWithOrderedKeys.push({ - id: group.id, - title, - bullishSummary, - bearishSummary, - neutralSummary, - posts: group.posts - }); } - // Save updated PostGroups with titles and summaries back to Redis - if (updated) { - await initRedis(); - const redis = getRedisClient(); - await redis.set('post-groups', JSON.stringify(postGroupsWithOrderedKeys)); - if (context === 'CRON') { - console.log('[CRON] Updated post-groups with titles and sentiment summaries saved to Redis.'); - } else { - console.log('Updated post-groups with titles and sentiment summaries saved to Redis.'); - } + // Always save updated PostGroups with titles and summaries back to Redis + await initRedis(); + const redis = getRedisClient(); + await redis.set('post-groups', JSON.stringify(postGroupsWithOrderedKeys)); + if (context === 'CRON') { + console.log('[CRON] Updated post-groups with titles and sentiment summaries saved to Redis.'); + } else { + console.log('Updated post-groups with titles and sentiment summaries saved to Redis.'); } }