diff --git a/platforms/dreamsync-api/src/database/entities/Wishlist.ts b/platforms/dreamsync-api/src/database/entities/Wishlist.ts index 3bbc40226..f5192d134 100644 --- a/platforms/dreamsync-api/src/database/entities/Wishlist.ts +++ b/platforms/dreamsync-api/src/database/entities/Wishlist.ts @@ -22,6 +22,12 @@ export class Wishlist { @Column({ type: "text" }) content: string; // Markdown content + @Column({ type: "text", nullable: true }) + summaryWants: string | null; + + @Column({ type: "text", nullable: true }) + summaryOffers: string | null; + @Column({ type: "boolean", default: true }) isActive: boolean; diff --git a/platforms/dreamsync-api/src/database/migrations/1765187375413-migration.ts b/platforms/dreamsync-api/src/database/migrations/1765187375413-migration.ts new file mode 100644 index 000000000..1ac81286b --- /dev/null +++ b/platforms/dreamsync-api/src/database/migrations/1765187375413-migration.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1765187375413 implements MigrationInterface { + name = 'Migration1765187375413' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "wishlists" ADD "summaryWants" text`); + await queryRunner.query(`ALTER TABLE "wishlists" ADD "summaryOffers" text`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "wishlists" DROP COLUMN "summaryOffers"`); + await queryRunner.query(`ALTER TABLE "wishlists" DROP COLUMN "summaryWants"`); + } + +} diff --git a/platforms/dreamsync-api/src/index.ts b/platforms/dreamsync-api/src/index.ts index 728426b6d..c9a225f72 100644 --- a/platforms/dreamsync-api/src/index.ts +++ b/platforms/dreamsync-api/src/index.ts @@ -13,6 +13,7 @@ import { authMiddleware, authGuard } from "./middleware/auth"; import { adapter } from "./web3adapter/watchers/subscriber"; import { MatchingJob } from "./services/MatchingJob"; import { PlatformEVaultService } from "./services/PlatformEVaultService"; +import { WishlistSummaryService } from "./services/WishlistSummaryService"; config({ path: path.resolve(__dirname, "../../../.env") }); @@ -42,6 +43,14 @@ AppDataSource.initialize() // Don't exit the process, just log the error } + // Backfill wishlist summaries for existing records + try { + const wishlistSummaryService = WishlistSummaryService.getInstance(); + await wishlistSummaryService.backfillMissingSummaries(); + } catch (error) { + console.error("❌ Failed to backfill wishlist summaries:", error); + } + // Start AI matching job (disabled automatic startup) const matchingJob = new MatchingJob(); // matchingJob.start(60); // Run every 60 minutes - DISABLED diff --git a/platforms/dreamsync-api/src/services/AIMatchingService.ts b/platforms/dreamsync-api/src/services/AIMatchingService.ts index e306cf0d5..56c85a5e7 100644 --- a/platforms/dreamsync-api/src/services/AIMatchingService.ts +++ b/platforms/dreamsync-api/src/services/AIMatchingService.ts @@ -7,6 +7,7 @@ import { MatchNotificationService } from "./MatchNotificationService"; import { MatchingService, MatchResult, WishlistData, GroupData } from "./MatchingService"; import { withOperationContext } from "../context/OperationContext"; import OpenAI from "openai"; +import { WishlistSummaryService } from "./WishlistSummaryService"; export class AIMatchingService { private matchingService: MatchingService; @@ -15,6 +16,7 @@ export class AIMatchingService { private groupRepository: Repository; private notificationService: MatchNotificationService; private openai: OpenAI; + private wishlistSummaryService: WishlistSummaryService; constructor() { this.matchingService = new MatchingService(); @@ -25,6 +27,7 @@ export class AIMatchingService { this.openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, }); + this.wishlistSummaryService = WishlistSummaryService.getInstance(); } async findMatches(): Promise { @@ -36,6 +39,7 @@ export class AIMatchingService { return withOperationContext('AIMatchingService', operationId, async () => { const wishlists = await this.getWishlistsForMatching(); console.log(`📋 Found ${wishlists.length} wishlists to analyze`); + await this.ensureWishlistSummaries(wishlists); // Get existing groups for context const existingGroups = await this.getExistingGroups(); @@ -45,6 +49,8 @@ export class AIMatchingService { const wishlistData: WishlistData[] = wishlists.map(wishlist => ({ id: wishlist.id, content: wishlist.content, + summaryWants: wishlist.summaryWants || "", + summaryOffers: wishlist.summaryOffers || "", userId: wishlist.userId, user: { id: wishlist.user.id, @@ -216,6 +222,18 @@ export class AIMatchingService { })); } + private async ensureWishlistSummaries(wishlists: Wishlist[]): Promise { + for (const wishlist of wishlists) { + if (!wishlist.summaryWants || !wishlist.summaryOffers) { + try { + await this.wishlistSummaryService.ensureSummaries(wishlist); + } catch (error) { + console.error(`Failed to ensure summary for wishlist ${wishlist.id}`, error); + } + } + } + } + private extractActivityCategory(groupName: string): string { const name = groupName.toLowerCase(); if (name.includes('chess')) return 'chess'; diff --git a/platforms/dreamsync-api/src/services/MatchingService.ts b/platforms/dreamsync-api/src/services/MatchingService.ts index e59acad01..1f1ea239a 100644 --- a/platforms/dreamsync-api/src/services/MatchingService.ts +++ b/platforms/dreamsync-api/src/services/MatchingService.ts @@ -15,6 +15,8 @@ export interface MatchResult { export interface WishlistData { id: string; content: string; + summaryWants: string; + summaryOffers: string; userId: string; user: { id: string; @@ -64,12 +66,18 @@ export class MatchingService { } private buildAllMatchesPrompt(wishlists: WishlistData[], existingGroups?: GroupData[]): string { - const wishlistTexts = wishlists.map((wishlist, index) => - `Wishlist ${index + 1} (User: ${wishlist.user.name}, ID: ${wishlist.userId}): -${wishlist.content} - ----` - ).join('\n\n'); + const delimiter = "<|>"; + const header = `userEname${delimiter}userName${delimiter}wants${delimiter}offers`; + const rows = wishlists.map((wishlist) => { + const wants = wishlist.summaryWants || wishlist.content; + const offers = wishlist.summaryOffers || wishlist.content; + return [ + this.sanitizeField(wishlist.user.ename), + this.sanitizeField(wishlist.user.name || wishlist.user.ename), + this.sanitizeField(wants), + this.sanitizeField(offers), + ].join(delimiter); + }).join("\n"); let existingGroupsText = ''; if (existingGroups && existingGroups.length > 0) { @@ -102,7 +110,13 @@ However, if users want to collaborate on a DIFFERENT activity than existing grou return ` You are an AI matching assistant. Analyze ALL the wishlists below and find meaningful connections between users. -${wishlistTexts}${existingGroupsText} +The wishlists are provided as delimiter-separated rows (delimiter: "${delimiter}"). +Columns: userEname${delimiter}userName${delimiter}wants${delimiter}offers + +${header} +${rows} + +Use ONLY the summary rows above (not full prose) to infer matches.${existingGroupsText} TASK: Find ALL meaningful matches between these users based on their wishlists. @@ -154,6 +168,14 @@ Be thorough and find ALL potential matches! `.trim(); } + private sanitizeField(value?: string): string { + if (!value) return ""; + return value + .replace(/<\|>/g, " ") + .replace(/\s+/g, " ") + .trim(); + } + private async analyzeAllMatches(wishlists: WishlistData[], existingGroups?: GroupData[]): Promise { const prompt = this.buildAllMatchesPrompt(wishlists, existingGroups); diff --git a/platforms/dreamsync-api/src/services/SharedMatchingService.ts b/platforms/dreamsync-api/src/services/SharedMatchingService.ts index 8b7cff0ea..aa42134d0 100644 --- a/platforms/dreamsync-api/src/services/SharedMatchingService.ts +++ b/platforms/dreamsync-api/src/services/SharedMatchingService.ts @@ -13,6 +13,8 @@ export interface MatchResult { export interface WishlistData { id: string; content: string; + summaryWants: string; + summaryOffers: string; userId: string; user: { id: string; diff --git a/platforms/dreamsync-api/src/services/WishlistService.ts b/platforms/dreamsync-api/src/services/WishlistService.ts index 06f85925e..8ded8f96b 100644 --- a/platforms/dreamsync-api/src/services/WishlistService.ts +++ b/platforms/dreamsync-api/src/services/WishlistService.ts @@ -2,14 +2,17 @@ import { AppDataSource } from "../database/data-source"; import { Wishlist } from "../database/entities/Wishlist"; import { User } from "../database/entities/User"; import { Repository } from "typeorm"; +import { WishlistSummaryService } from "./WishlistSummaryService"; export class WishlistService { private wishlistRepository: Repository; private userRepository: Repository; + private wishlistSummaryService: WishlistSummaryService; constructor() { this.wishlistRepository = AppDataSource.getRepository(Wishlist); this.userRepository = AppDataSource.getRepository(User); + this.wishlistSummaryService = WishlistSummaryService.getInstance(); } async createWishlist( @@ -35,7 +38,7 @@ export class WishlistService { } }); - return await this.wishlistRepository.save(wishlist); + return await this.wishlistSummaryService.summarizeAndPersist(wishlist); } async updateWishlist( @@ -58,7 +61,7 @@ export class WishlistService { } Object.assign(wishlist, updates); - return await this.wishlistRepository.save(wishlist); + return await this.wishlistSummaryService.summarizeAndPersist(wishlist); } async getUserWishlists(userId: string): Promise { diff --git a/platforms/dreamsync-api/src/services/WishlistSummaryService.ts b/platforms/dreamsync-api/src/services/WishlistSummaryService.ts new file mode 100644 index 000000000..2e2e69fc8 --- /dev/null +++ b/platforms/dreamsync-api/src/services/WishlistSummaryService.ts @@ -0,0 +1,155 @@ +import OpenAI from "openai"; +import { Repository, IsNull } from "typeorm"; +import { AppDataSource } from "../database/data-source"; +import { Wishlist } from "../database/entities/Wishlist"; + +type WishlistSummary = { + summaryWants: string; + summaryOffers: string; +}; + +const DELIMITER = "<|>"; +const MAX_FALLBACK_LENGTH = 140; + +export class WishlistSummaryService { + private static instance: WishlistSummaryService | null = null; + private wishlistRepository: Repository; + private openai: OpenAI; + + private constructor() { + this.wishlistRepository = AppDataSource.getRepository(Wishlist); + this.openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + }); + } + + static getInstance(): WishlistSummaryService { + if (!this.instance) { + this.instance = new WishlistSummaryService(); + } + return this.instance; + } + + async summarizeWishlistContent(content: string): Promise { + const prompt = ` +Summarize the wishlist into two ultra-short single-line fragments (<=120 chars each). +Return EXACTLY in this format, nothing else: +wants: +offers: +- Be terse, remove filler, prefer keywords over sentences. +- Avoid newlines, commas, and the delimiter "${DELIMITER}". +- If explicit wants/offers are missing, infer concise intent/skills from context. +`.trim(); + + try { + const response = await this.openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { + role: "system", + content: "You are a terse summarizer that emits exactly two lines: wants and offers.", + }, + { + role: "user", + content: `${prompt}\n\nWishlist content:\n${content}`, + }, + ], + temperature: 0.2, + max_tokens: 180, + }); + + const raw = response.choices[0]?.message?.content || ""; + const parsed = this.parseSummary(raw); + if (parsed) { + return parsed; + } + } catch (error) { + console.error("WishlistSummaryService: OpenAI summarization failed, using fallback", error); + } + + // Fallback: truncate raw content into two identical concise lines + const fallback = this.buildFallback(content); + return { + summaryWants: fallback, + summaryOffers: fallback, + }; + } + + async summarizeAndPersist(wishlist: Wishlist): Promise { + const { summaryWants, summaryOffers } = await this.summarizeWishlistContent(wishlist.content); + wishlist.summaryWants = summaryWants; + wishlist.summaryOffers = summaryOffers; + return this.wishlistRepository.save(wishlist); + } + + async ensureSummaries(wishlist: Wishlist): Promise { + if (wishlist.summaryWants && wishlist.summaryOffers) { + return wishlist; + } + return this.summarizeAndPersist(wishlist); + } + + async backfillMissingSummaries(batchSize: number = 25): Promise { + const missing = await this.wishlistRepository.find({ + where: [ + { summaryWants: IsNull() }, + { summaryOffers: IsNull() }, + ], + order: { updatedAt: "DESC" }, + take: batchSize, + }); + + if (missing.length === 0) { + console.log("WishlistSummaryService: No wishlists missing summaries"); + return; + } + + console.log(`WishlistSummaryService: Backfilling summaries for ${missing.length} wishlists...`); + + for (const wishlist of missing) { + try { + await this.summarizeAndPersist(wishlist); + } catch (error) { + console.error(`WishlistSummaryService: Failed to summarize wishlist ${wishlist.id}`, error); + } + } + + console.log("WishlistSummaryService: Backfill completed"); + } + + private parseSummary(raw: string): WishlistSummary | null { + const cleaned = raw.replace(/\r/g, "").trim(); + const wantsMatch = cleaned.match(/wants:\s*(.*)/i); + const offersMatch = cleaned.match(/offers:\s*(.*)/i); + + if (!wantsMatch && !offersMatch) { + return null; + } + + const summaryWants = this.cleanFragment(wantsMatch?.[1] || ""); + const summaryOffers = this.cleanFragment(offersMatch?.[1] || ""); + + if (!summaryWants && !summaryOffers) { + return null; + } + + return { + summaryWants: summaryWants || summaryOffers || "", + summaryOffers: summaryOffers || summaryWants || "", + }; + } + + private cleanFragment(fragment: string): string { + return fragment + .replace(new RegExp(DELIMITER, "g"), " ") + .replace(/\s+/g, " ") + .replace(/[,\n\r]+/g, " ") + .trim() + .slice(0, MAX_FALLBACK_LENGTH); + } + + private buildFallback(content: string): string { + return this.cleanFragment(content).slice(0, MAX_FALLBACK_LENGTH); + } +} +