dealspace/api/chat.go

349 lines
12 KiB
Go

package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
)
// ChatRequest is the incoming chat message from the client.
type ChatRequest struct {
SessionID string `json:"session_id"`
Message string `json:"message"`
History []Message `json:"history"`
}
// Message represents a chat message.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// ChatResponse is returned to the client.
type ChatResponse struct {
Reply string `json:"reply"`
SessionID string `json:"session_id"`
LeadCaptured bool `json:"lead_captured,omitempty"`
}
// Lead represents a captured lead.
type Lead struct {
Email string `json:"email"`
SessionID string `json:"session_id"`
Timestamp string `json:"timestamp"`
Context string `json:"context"`
}
// Rate limiter for chat endpoint (20 requests/IP/hour)
var (
chatRateMu sync.Mutex
chatRateMap = make(map[string]*chatRateEntry)
emailRegex = regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`)
leadsMu sync.Mutex
)
type chatRateEntry struct {
windowStart time.Time
count int
}
// Aria's system prompt with embedded knowledge
const ariaSystemPrompt = `You are Aria, the Dealspace product assistant. Dealspace is an M&A deal workflow platform for investment banks and advisors.
Answer ONLY questions about Dealspace — its features, pricing, security, onboarding, use cases, and how it compares to alternatives like email-based data rooms or SharePoint.
If asked anything outside Dealspace (personal advice, coding help, current events, competitor products, etc.), respond: "That's outside my expertise, but I'd love to connect you with our team. What's your email address?"
If a user provides an email, respond: "Thanks! Someone from the Dealspace team will reach out to you shortly." Then stop.
Be concise. 2-3 sentences max unless a detailed answer is genuinely needed. You are on a marketing website — your goal is to inform and convert, not to write essays.
--- DEALSPACE KNOWLEDGE ---
OVERVIEW:
Dealspace is an M&A workflow platform trusted by investment banks. It's request-centric, secure, and intelligently simple. Unlike traditional VDRs that are document-centric (500-folder hierarchies nobody can navigate), Dealspace makes the Request the unit of work.
CORE FEATURES:
- Request-Centric Workflow: Every question, every answer, every status update is tracked, routed, and resolved. Structured request lists, status at a glance (open, assigned, answered, vetted, published), threaded communication.
- Role-Based Simplicity: Your accountant sees their 3 tasks. Your CFO sees the big picture. Workstream-based access (Finance sees Finance, Legal sees Legal). Task inbox for contributors.
- AI Matching: When a buyer submits a question, AI searches for existing answers. Human confirms, answer broadcasts to everyone who asked. Semantic search understands "revenue breakdown" and "sales by segment" are the same. Human in the loop - AI suggests, human confirms. Zero retention - deal data never trains AI models.
- Work Where You Are: Email, Slack, Teams integration. No login required for basic responses.
- Complete Audit Trail: Every access, every download, every routing hop logged. Access logs, download tracking, workflow history.
HOW IT WORKS:
1. IB Creates Request List - Configure workstreams, invite participants, issue structured requests
2. Seller Responds - Internal routing, upload documents, mark complete
3. IB Vets & Approves - Quality control, approve to publish or reject with feedback
4. Buyers Access Data Room - Submit questions, AI matches to existing answers
SECURITY:
- SOC 2 Type II certified
- FIPS 140-3 validated encryption (AES-256-GCM)
- Per-deal encryption keys - one deal's compromise doesn't affect others
- Encryption at rest and in transit (TLS 1.3)
- ISO 27001 certified ISMS
- GDPR compliant
- Dynamic watermarking - every document watermarked with viewer identity, timestamp, and deal ID at serve time
- SSO/SAML support, MFA required, IP allowlisting, session management
- 99.99% uptime SLA, 24/7 security monitoring, <15min incident response
PRICING:
- Starter: $2,500/month - 1 concurrent deal, up to 10 participants, 10GB storage, request workflow, dynamic watermarking, audit trail, email support. No AI matching or SSO.
- Professional (Most Popular): $7,500/month - 5 concurrent deals, unlimited participants, 100GB storage, everything in Starter plus AI matching and priority support.
- Enterprise: Custom pricing - Unlimited deals, unlimited participants, unlimited storage, everything in Professional plus SSO/SAML, custom watermarks, dedicated support, 99.99% SLA, on-premise option.
- Additional storage: $0.10/GB/month (no markups, actual cost)
- 14-day free trial with full Professional features, no credit card required
- Annual billing: 15% discount
COMPARISON (50GB deal, 100 participants):
- Dealspace Professional: $7,500/month total
- Competitor A: $25,500/month ($5k base + $15k storage + $2.5k participants + $3k AI)
- Competitor B: $17,500/month ($8k base + $8k storage + $1.5k participants)
FAQ:
- "Concurrent deal" = active deal not archived. Archived deals don't count toward limit.
- Free trial: 14 days, full Professional features, no credit card
- Storage overage: $0.10/GB, notified before billing
- Upgrades prorated immediately, downgrades at next billing cycle
COMPANY:
- Operated by Muskepo B.V., Amsterdam
- Offices: Amsterdam, New York, London
- Contact: sales@dealspace.io, security@dealspace.io`
// ChatHandler handles POST /api/chat requests
func (h *Handlers) ChatHandler(w http.ResponseWriter, r *http.Request) {
// CORS for muskepo.com
origin := r.Header.Get("Origin")
if origin == "https://muskepo.com" || origin == "http://localhost:8080" || strings.HasPrefix(origin, "http://82.24.174.112") {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
// Rate limiting: 20 requests/IP/hour
ip := realIP(r)
if !checkChatRateLimit(ip) {
ErrorResponse(w, http.StatusTooManyRequests, "rate_limited", "Too many requests. Please try again later.")
return
}
// Parse request
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
if req.Message == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_message", "Message is required")
return
}
// Limit history to last 6 messages
if len(req.History) > 6 {
req.History = req.History[len(req.History)-6:]
}
// Check for email in user message
leadCaptured := false
if emailRegex.MatchString(req.Message) {
email := emailRegex.FindString(req.Message)
if err := saveLead(email, req.SessionID, req.Message); err != nil {
log.Printf("Failed to save lead: %v", err)
} else {
leadCaptured = true
}
}
// Call Anthropic API
reply, err := callAnthropicAPI(req.Message, req.History)
if err != nil {
log.Printf("Anthropic API error: %v", err)
ErrorResponse(w, http.StatusInternalServerError, "api_error", "Sorry, I'm having trouble responding right now. Please try again.")
return
}
// Check if assistant reply asks for email and user already provided one
if leadCaptured && strings.Contains(strings.ToLower(reply), "email") {
reply = "Thanks! Someone from the Dealspace team will reach out to you shortly."
}
JSONResponse(w, http.StatusOK, ChatResponse{
Reply: reply,
SessionID: req.SessionID,
LeadCaptured: leadCaptured,
})
}
func checkChatRateLimit(ip string) bool {
chatRateMu.Lock()
defer chatRateMu.Unlock()
now := time.Now()
entry, exists := chatRateMap[ip]
// Clean up old entries
for k, v := range chatRateMap {
if now.Sub(v.windowStart) > time.Hour {
delete(chatRateMap, k)
}
}
if !exists || now.Sub(entry.windowStart) > time.Hour {
chatRateMap[ip] = &chatRateEntry{windowStart: now, count: 1}
return true
}
entry.count++
return entry.count <= 20
}
func saveLead(email, sessionID, context string) error {
leadsMu.Lock()
defer leadsMu.Unlock()
lead := Lead{
Email: email,
SessionID: sessionID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Context: context,
}
// Ensure directory exists
if err := os.MkdirAll("/opt/dealspace/data", 0755); err != nil {
return fmt.Errorf("create data dir: %w", err)
}
f, err := os.OpenFile("/opt/dealspace/data/leads.jsonl", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("open leads file: %w", err)
}
defer f.Close()
data, err := json.Marshal(lead)
if err != nil {
return fmt.Errorf("marshal lead: %w", err)
}
if _, err := f.Write(append(data, '\n')); err != nil {
return fmt.Errorf("write lead: %w", err)
}
log.Printf("Lead captured: %s (session: %s)", email, sessionID)
return nil
}
// AnthropicRequest is the request body for Anthropic API
type AnthropicRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system"`
Messages []AnthropicMessage `json:"messages"`
}
// AnthropicMessage represents a message in Anthropic format
type AnthropicMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// AnthropicResponse is the response from Anthropic API
// OpenAI-compatible structs (Fireworks uses OpenAI API format)
type OAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type OAIRequest struct {
Model string `json:"model"`
Messages []OAIMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
}
type OAIResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
func callAnthropicAPI(userMessage string, history []Message) (string, error) {
apiKey := os.Getenv("FIREWORKS_API_KEY")
if apiKey == "" {
return "", fmt.Errorf("FIREWORKS_API_KEY not set")
}
// System prompt first, then history, then new message
messages := []OAIMessage{{Role: "system", Content: ariaSystemPrompt}}
for _, m := range history {
messages = append(messages, OAIMessage{Role: m.Role, Content: m.Content})
}
messages = append(messages, OAIMessage{Role: "user", Content: userMessage})
reqBody := OAIRequest{
Model: "accounts/fireworks/models/llama-v3p3-70b-instruct",
MaxTokens: 300,
Messages: messages,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequest("POST", "https://api.fireworks.ai/inference/v1/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("API request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}
var apiResp OAIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
if apiResp.Error != nil {
return "", fmt.Errorf("API error: %s", apiResp.Error.Message)
}
if len(apiResp.Choices) == 0 {
return "", fmt.Errorf("empty response from API")
}
return apiResp.Choices[0].Message.Content, nil
}