345 lines
11 KiB
Go
345 lines
11 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
|
|
type AnthropicResponse struct {
|
|
Content []struct {
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
func callAnthropicAPI(userMessage string, history []Message) (string, error) {
|
|
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
|
if apiKey == "" {
|
|
return "", fmt.Errorf("ANTHROPIC_API_KEY not set")
|
|
}
|
|
|
|
// Build messages array
|
|
messages := make([]AnthropicMessage, 0, len(history)+1)
|
|
for _, m := range history {
|
|
messages = append(messages, AnthropicMessage{
|
|
Role: m.Role,
|
|
Content: m.Content,
|
|
})
|
|
}
|
|
messages = append(messages, AnthropicMessage{
|
|
Role: "user",
|
|
Content: userMessage,
|
|
})
|
|
|
|
reqBody := AnthropicRequest{
|
|
Model: "claude-3-5-haiku-latest",
|
|
MaxTokens: 300,
|
|
System: ariaSystemPrompt,
|
|
Messages: messages,
|
|
}
|
|
|
|
jsonData, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return "", fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("x-api-key", apiKey)
|
|
req.Header.Set("anthropic-version", "2023-06-01")
|
|
|
|
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 AnthropicResponse
|
|
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.Content) == 0 {
|
|
return "", fmt.Errorf("empty response from API")
|
|
}
|
|
|
|
return apiResp.Content[0].Text, nil
|
|
}
|