diff --git a/src/ai-manager.ts b/src/ai-manager.ts index dfb7b40..2fdcfaf 100644 --- a/src/ai-manager.ts +++ b/src/ai-manager.ts @@ -81,10 +81,10 @@ function buildAltTextPrompt(contextText: string): string { : normalized; return [ - 'Write alt text (1-2 sentences).', + 'Write one alt text description (1-2 sentences).', 'Describe only what is visible.', - 'Use context to identify people/places/objects if relevant.', - 'Include key names for search.', + 'Use context to identify people/places/objects if relevant for search.', + 'Return only the alt text with no labels, quotes, or options.', 'No hashtags or emojis.', `Context: "${trimmed}"`, ].join(' '); diff --git a/src/index.ts b/src/index.ts index 1a9d11a..bb1ddf5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -545,6 +545,59 @@ function addTextFallbacks(text: string): string { return text.replace(/\s+$/g, '').trim(); } +function getTweetText(tweet: Tweet): string { + return tweet.full_text || tweet.text || ''; +} + +function normalizeContextText(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function addTweetsToMap(tweetMap: Map, tweets: Tweet[]): void { + for (const tweet of tweets) { + const tweetId = tweet.id_str || tweet.id; + if (!tweetId) continue; + tweetMap.set(String(tweetId), tweet); + } +} + +function buildThreadContext(tweet: Tweet, tweetMap: Map, maxHops = 8): string { + const parts: string[] = []; + const visited = new Set(); + let current: Tweet | undefined = tweet; + + for (let hops = 0; hops < maxHops; hops++) { + const parentId = current?.in_reply_to_status_id_str || current?.in_reply_to_status_id; + if (!parentId) break; + const parentKey = String(parentId); + if (visited.has(parentKey)) break; + visited.add(parentKey); + + const parentTweet = tweetMap.get(parentKey); + if (!parentTweet) break; + + const parentText = normalizeContextText(getTweetText(parentTweet)); + if (parentText) parts.push(parentText); + + current = parentTweet; + } + + if (parts.length === 0) return ''; + return parts.reverse().join(' | '); +} + +function buildAltTextContext(tweet: Tweet, tweetText: string, tweetMap: Map): string { + const threadContext = buildThreadContext(tweet, tweetMap); + const currentText = normalizeContextText(tweetText); + + if (threadContext && currentText) { + return `Thread above: ${threadContext}. Current tweet: ${currentText}`; + } + + if (threadContext) return `Thread above: ${threadContext}.`; + return currentText; +} + async function fetchSyndicationMedia(tweetUrl: string): Promise<{ images: string[] }> { try { const normalized = tweetUrl.replace('twitter.com', 'x.com'); @@ -1252,6 +1305,7 @@ async function processTweets( tweets: Tweet[], dryRun = false, sharedProcessedMap?: ProcessedTweetsMap, + sharedTweetMap?: Map, ): Promise { // Filter tweets to ensure they're actually from this user const filteredTweets = tweets.filter((t) => { @@ -1265,6 +1319,9 @@ async function processTweets( return true; }); + const tweetMap = sharedTweetMap ?? new Map(); + addTweetsToMap(tweetMap, filteredTweets); + // Maintain a local map that updates in real-time for intra-batch replies const localProcessedMap: ProcessedTweetsMap = sharedProcessedMap ?? { ...loadProcessedTweets(bskyIdentifier) }; @@ -1355,8 +1412,17 @@ async function processTweets( if (parentAuthor?.toLowerCase() === twitterUsername.toLowerCase()) { console.log(`[${twitterUsername}] 🔄 Parent is ours (@${parentAuthor}). Backfilling parent first...`); + addTweetsToMap(tweetMap, [parentTweet]); // Recursively process the parent - await processTweets(agent, twitterUsername, bskyIdentifier, [parentTweet], dryRun, localProcessedMap); + await processTweets( + agent, + twitterUsername, + bskyIdentifier, + [parentTweet], + dryRun, + localProcessedMap, + tweetMap, + ); // Check if it was saved const savedParent = dbService.getTweet(replyStatusId, bskyIdentifier); @@ -1408,6 +1474,8 @@ async function processTweets( // Removed early dryRun continue to allow verifying logic + const altTextContext = buildAltTextContext(tweet, tweetText, tweetMap); + let text = tweetText .replace(/&/g, '&') .replace(/</g, '<') @@ -1502,7 +1570,7 @@ async function processTweets( if (!altText) { console.log(`[${twitterUsername}] 🤖 Generating alt text via Gemini...`); // Use original tweet text for context, not the modified/cleaned one - altText = await generateAltText(buffer, mimeType, tweetText); + altText = await generateAltText(buffer, mimeType, altTextContext); if (altText) console.log(`[${twitterUsername}] ✅ Alt text generated: ${altText.substring(0, 50)}...`); }