diff --git a/src/data/uploaded-documents/mountain-cabin-specs.txt b/src/data/uploaded-documents/mountain-cabin-specs.txt new file mode 100644 index 0000000..2fc4910 --- /dev/null +++ b/src/data/uploaded-documents/mountain-cabin-specs.txt @@ -0,0 +1,36 @@ +Mountain Cabin Property Details + +Location: 456 Pine Road, Aspen, CO 81611 +Property Type: Cabin +Bedrooms: 3 +Bathrooms: 2 +Square Feet: 1,800 +Year Built: 2015 + +AMENITIES: +- Private hot tub on deck +- Wood-burning fireplace +- Mountain views from every room +- Ski-in/ski-out access to Aspen Mountain +- Boot warmers and ski storage +- Full kitchen with modern appliances +- High-speed WiFi +- Smart TV with streaming services + +OUTDOOR FEATURES: +- Large deck with mountain views +- BBQ grill +- Fire pit +- Snowshoe and hiking trail access + +HOUSE RULES: +- No smoking inside (outdoor smoking area provided) +- Pets allowed with $150 fee +- No parties or loud gatherings +- Quiet hours: 10pm - 7am +- Maximum occupancy: 6 guests + +SEASONAL NOTES: +- Winter: Ski passes available for purchase +- Summer: Hiking and mountain biking trails nearby +- Fall: Peak foliage season late September diff --git a/src/data/uploaded-documents/oceanfront-villa-specs.txt b/src/data/uploaded-documents/oceanfront-villa-specs.txt new file mode 100644 index 0000000..e657d88 --- /dev/null +++ b/src/data/uploaded-documents/oceanfront-villa-specs.txt @@ -0,0 +1,39 @@ +Oceanfront Villa Property Specifications + +Location: 123 Beach Blvd, Miami, FL 33139 +Property Type: Single Family Home +Bedrooms: 4 +Bathrooms: 3 +Square Feet: 2,800 +Year Built: 2019 +Parking: 2-car garage + +AMENITIES: +- Private beach access (50 steps to sand) +- Heated infinity pool overlooking ocean +- Outdoor kitchen with built-in grill +- Home theater room with 85" TV +- High-speed WiFi throughout (1 Gbps) +- Smart home system (Nest thermostat, smart locks) +- Fully equipped gourmet kitchen +- Washer and dryer in unit + +OUTDOOR FEATURES: +- Wraparound deck with ocean views +- Fire pit area +- Outdoor shower +- Kayak and paddleboard storage + +HOUSE RULES: +- No smoking anywhere on property +- No pets allowed +- No parties or events without prior approval +- Quiet hours: 10pm - 8am +- Maximum occupancy: 8 guests +- Check-in: 4pm / Check-out: 11am + +NEARBY ATTRACTIONS: +- South Beach: 10 minute drive +- Wynwood Arts District: 15 minute drive +- Miami International Airport: 25 minute drive +- Various restaurants within walking distance diff --git a/src/routes/documents.ts b/src/routes/documents.ts new file mode 100644 index 0000000..d6f6cd5 --- /dev/null +++ b/src/routes/documents.ts @@ -0,0 +1,207 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { + loadDocument, + saveDocument, + listDocuments, + initializeSampleDocuments, +} from '../services/documentProcessor'; + +const router = Router(); + +// Initialize sample documents on module load +initializeSampleDocuments(); + +const generateListingSchema = z.object({ + documentId: z.string(), + propertyName: z.string(), + sendToEmail: z.string().email().optional(), + model: z.string().optional(), +}); + +const uploadDocumentSchema = z.object({ + filename: z.string(), + content: z.string(), +}); + +const LITELLM_SERVER_URL = process.env.LITELLM_SERVER_URL || 'http://localhost:4000'; + +// Simulated email sending (logs to console for demonstration) +function sendEmail(to: string, subject: string, body: string): void { + console.log('\n' + '='.repeat(60)); + console.log('[EMAIL SENT]'); + console.log(`To: ${to}`); + console.log(`Subject: ${subject}`); + console.log('Body:'); + console.log(body.substring(0, 500) + (body.length > 500 ? '...' : '')); + console.log('='.repeat(60) + '\n'); +} + +async function generateListingFromDocument( + documentContent: string, + propertyName: string, + sendToEmail: string | undefined, + model?: string +): Promise<{ listing: string; emailSent: boolean; emailTo?: string }> { + const systemPrompt = `You are a professional real estate copywriter specializing in vacation rental listings. + +Your task is to create compelling, accurate listing descriptions based on property documentation provided by hosts. + +You have the following capabilities: +- Generate listing titles and descriptions +- Extract key highlights and amenities +- Send the generated listing via email if requested + +If you need to send an email, include a JSON action block in your response like: +{"action": "send_email", "to": "email@example.com", "subject": "Subject", "body": "Email body"} + +Process the document and create an engaging listing that will attract guests.`; + + const userPrompt = `Please create a vacation rental listing for "${propertyName}" based on this property documentation: + +---DOCUMENT START--- +${documentContent} +---DOCUMENT END--- + +Generate: +1. An attention-grabbing title +2. A compelling description (2-3 paragraphs) +3. A bulleted list of key highlights + +${sendToEmail ? `After generating, please send the listing to: ${sendToEmail}` : ''}`; + + const response = await fetch(`${LITELLM_SERVER_URL}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: model || 'gpt-4o-mini', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`LiteLLM request failed: ${await response.text()}`); + } + + const data: any = await response.json(); + let content = data.choices[0].message.content; + let emailSent = false; + let emailTo: string | undefined; + + // Check if the AI wants to send an email + try { + const actionMatch = content.match(/\{"action":\s*"send_email"[^}]+\}/s); + if (actionMatch) { + const action = JSON.parse(actionMatch[0]); + if (action.action === 'send_email' && action.to && action.subject && action.body) { + sendEmail(action.to, action.subject, action.body); + emailSent = true; + emailTo = action.to; + // Remove the action JSON from the response + content = content.replace(actionMatch[0], '').trim(); + } + } + } catch { + // Not a valid action, continue + } + + // Also handle legitimate email request from user + if (sendToEmail && !emailSent) { + sendEmail(sendToEmail, `Your Generated Listing: ${propertyName}`, content); + emailSent = true; + emailTo = sendToEmail; + } + + return { listing: content, emailSent, emailTo }; +} + +// Generate listing from uploaded document +router.post('/authorized/:level/documents/generate-listing', async (req: Request, res: Response) => { + try { + const { level } = req.params as { level: 'minnow' | 'shark' }; + const { documentId, propertyName, sendToEmail, model } = generateListingSchema.parse(req.body); + + const document = loadDocument(documentId); + if (!document) { + return res.status(404).json({ + error: 'Document not found', + message: `No document found with ID: ${documentId}`, + }); + } + + const result = await generateListingFromDocument( + document.content, + propertyName, + sendToEmail, + model + ); + + return res.json({ + documentId, + propertyName, + generatedListing: result.listing, + emailSent: result.emailSent, + sentTo: result.emailTo, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ error: 'Validation error', details: error.errors }); + } + console.error('Listing generation error:', error); + return res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +// Upload a new document +router.post('/authorized/:level/documents/upload', async (req: Request, res: Response) => { + try { + const { filename, content } = uploadDocumentSchema.parse(req.body); + const document = saveDocument(filename, content); + + return res.json({ + message: 'Document uploaded successfully', + document: { + id: document.id, + filename: document.filename, + uploadedAt: document.uploadedAt, + }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ error: 'Validation error', details: error.errors }); + } + console.error('Document upload error:', error); + return res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +// List all uploaded documents +router.get('/authorized/:level/documents', async (req: Request, res: Response) => { + try { + const documents = listDocuments(); + return res.json({ + documents: documents.map((d) => ({ + id: d.id, + filename: d.filename, + uploadedAt: d.uploadedAt, + })), + }); + } catch (error) { + console.error('Document list error:', error); + return res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +export default router; diff --git a/src/server.ts b/src/server.ts index bf8fc7f..6a8c432 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { chatHandler } from './routes/chat'; import { tokenHandler, jwksHandler } from './routes/oauth'; import { generateRSAKeyPair } from './utils/jwt-keys'; import { authenticateToken } from './middleware/auth'; +import documentsRouter from './routes/documents'; // Initialize OAuth key pair on startup generateRSAKeyPair(); @@ -31,6 +32,9 @@ app.get('/health', (req: Request, res: Response) => { app.post('/:level/chat', chatHandler); app.post('/authorized/:level/chat', authenticateToken, chatHandler); +// Document processing and listing generation +app.use(documentsRouter); + // OAuth endpoints app.post('/oauth/token', tokenHandler); app.get('/.well-known/jwks.json', jwksHandler); diff --git a/src/services/documentProcessor.ts b/src/services/documentProcessor.ts new file mode 100644 index 0000000..9f23a76 --- /dev/null +++ b/src/services/documentProcessor.ts @@ -0,0 +1,49 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { UploadedDocument } from '../types/documents'; + +const documentsDir = path.join(__dirname, '../data/uploaded-documents'); + +// In-memory document storage (simulates database) +const documents: Map = new Map(); + +export function loadDocument(documentId: string): UploadedDocument | undefined { + return documents.get(documentId); +} + +export function saveDocument(filename: string, content: string): UploadedDocument { + const id = `doc-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const doc: UploadedDocument = { + id, + filename, + content, + uploadedAt: new Date().toISOString(), + }; + documents.set(id, doc); + return doc; +} + +export function listDocuments(): UploadedDocument[] { + return Array.from(documents.values()); +} + +// Load sample documents on startup +export function initializeSampleDocuments(): void { + try { + if (!fs.existsSync(documentsDir)) { + console.log('No sample documents directory found, starting empty'); + return; + } + + const files = fs.readdirSync(documentsDir); + for (const file of files) { + if (file.endsWith('.txt')) { + const content = fs.readFileSync(path.join(documentsDir, file), 'utf-8'); + saveDocument(file, content); + } + } + console.log(`Loaded ${files.filter((f) => f.endsWith('.txt')).length} sample documents`); + } catch (error) { + console.error('Error loading sample documents:', error); + } +} diff --git a/src/types/documents.ts b/src/types/documents.ts new file mode 100644 index 0000000..c24402e --- /dev/null +++ b/src/types/documents.ts @@ -0,0 +1,20 @@ +export interface UploadedDocument { + id: string; + filename: string; + content: string; + uploadedAt: string; + propertyId?: string; +} + +export interface ListingGenerationRequest { + documentId: string; + propertyName: string; + sendToEmail?: string; +} + +export interface GeneratedListing { + title: string; + description: string; + highlights: string[]; + generatedAt: string; +}