feat: update backend services and routes

- Add business routes and middleware\n- Update search and database services\n- Improve health check implementation\n- Update CI workflow configuration
This commit is contained in:
eligrinfeld 2025-01-06 21:25:15 -07:00
parent 79f26fce25
commit 9f4ae1baac
15 changed files with 1501 additions and 1348 deletions

View file

@ -1,133 +1,29 @@
--- ---
name: CI/CD name: CI
on: on:
push: push:
branches: [ main, develop ] branches: [ main ]
pull_request: pull_request:
branches: [ main, develop ] branches: [ main ]
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
supabase:
image: supabase/postgres-meta:v0.68.0
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v2
with: with:
node-version: '20' node-version: '18'
cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Check code formatting - name: Run tests
run: npm run format run: npm test
- name: Run tests with coverage - name: Run type check
run: npm run test:coverage
env:
SUPABASE_URL: http://localhost:54321
SUPABASE_KEY: test-key
OLLAMA_URL: http://localhost:11434
SEARXNG_URL: http://localhost:8080
NODE_ENV: test
CACHE_DURATION_DAYS: 7
- name: Upload coverage reports
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
fail_ci_if_error: true
build:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy-staging:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy to staging
run: |
echo "Deploying to staging environment"
# Add your staging deployment commands here
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
deploy-production:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
environment:
name: production
url: https://example.com
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy to production
run: |
echo "Deploying to production environment"
# Add your production deployment commands here
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

View file

@ -1,48 +1,16 @@
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import path from 'path'; import searchRoutes from './routes/search';
import './config/env'; // Load environment variables first import businessRoutes from './routes/business';
import apiRoutes from './routes/api';
import { HealthCheckService } from './lib/services/healthCheck';
const app = express(); const app = express();
const port = process.env.PORT || 3000;
// Middleware // Middleware
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
// API routes first // Routes
app.use('/api', apiRoutes); app.use('/api/search', searchRoutes);
app.use('/api/business', businessRoutes);
// Then static files export default app;
app.use(express.static(path.join(__dirname, '../public')));
// Finally, catch-all route for SPA
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// Start server with health checks
async function startServer() {
console.log('\n🔍 Checking required services...');
const ollamaStatus = await HealthCheckService.checkOllama();
const searxngStatus = await HealthCheckService.checkSearxNG();
const supabaseStatus = await HealthCheckService.checkSupabase();
console.log('\n📊 Service Status:');
console.log('- Ollama:', ollamaStatus ? '✅ Running' : '❌ Not Running');
console.log('- SearxNG:', searxngStatus ? '✅ Running' : '❌ Not Running');
console.log('- Supabase:', supabaseStatus ? '✅ Connected' : '❌ Not Connected');
app.listen(port, () => {
console.log(`\n🚀 Server running at http://localhost:${port}`);
console.log('-------------------------------------------');
});
}
startServer().catch(error => {
console.error('Failed to start server:', error);
process.exit(1);
});

View file

@ -1,89 +1,40 @@
import { config } from 'dotenv'; import dotenv from 'dotenv';
import { z } from 'zod';
config(); // Load environment variables
dotenv.config();
// Define the environment schema // Environment configuration
const envSchema = z.object({ const env = {
PORT: z.string().default('3000'), // Supabase Configuration
NODE_ENV: z.string().default('development'), SUPABASE_URL: process.env.SUPABASE_URL || '',
SUPABASE_URL: z.string(), SUPABASE_KEY: process.env.SUPABASE_KEY || '',
SUPABASE_KEY: z.string(),
OLLAMA_URL: z.string().default('http://localhost:11434'),
OLLAMA_MODEL: z.string().default('llama2'),
SEARXNG_URL: z.string().default('http://localhost:4000'),
SEARXNG_INSTANCES: z.string().default('["http://localhost:4000"]'),
MAX_RESULTS_PER_QUERY: z.string().default('50'),
CACHE_DURATION_HOURS: z.string().default('24'),
CACHE_DURATION_DAYS: z.string().default('7'),
HUGGING_FACE_API_KEY: z.string({
required_error: "HUGGING_FACE_API_KEY is required in .env"
})
});
// Define the final environment type // Server Configuration
export interface EnvConfig { PORT: parseInt(process.env.PORT || '3001', 10),
PORT: string; NODE_ENV: process.env.NODE_ENV || 'development',
NODE_ENV: string;
searxng: { // Search Configuration
currentUrl: string; MAX_RESULTS_PER_QUERY: parseInt(process.env.MAX_RESULTS_PER_QUERY || '50', 10),
instances: string[]; CACHE_DURATION_HOURS: parseInt(process.env.CACHE_DURATION_HOURS || '24', 10),
}; CACHE_DURATION_DAYS: parseInt(process.env.CACHE_DURATION_DAYS || '7', 10),
ollama: {
url: string; // SearxNG Configuration
model: string; SEARXNG_URL: process.env.SEARXNG_URL || 'http://localhost:4000',
};
supabase: { // Ollama Configuration
url: string; OLLAMA_URL: process.env.OLLAMA_URL || 'http://localhost:11434',
anonKey: string; OLLAMA_MODEL: process.env.OLLAMA_MODEL || 'deepseek-coder:6.7b',
};
cache: { // Hugging Face Configuration
maxResultsPerQuery: number; HUGGING_FACE_API_KEY: process.env.HUGGING_FACE_API_KEY || ''
durationHours: number; };
durationDays: number;
}; // Validate required environment variables
ai: { const requiredEnvVars = ['SUPABASE_URL', 'SUPABASE_KEY', 'SEARXNG_URL'];
model: string; for (const envVar of requiredEnvVars) {
temperature: number; if (!env[envVar as keyof typeof env]) {
maxTokens: number; throw new Error(`Missing required environment variable: ${envVar}`);
batchSize: number; }
};
huggingface: {
apiKey: string;
};
} }
// Parse and transform the environment variables export { env };
const rawEnv = envSchema.parse(process.env);
// Create the final environment object with parsed configurations
export const env: EnvConfig = {
PORT: rawEnv.PORT,
NODE_ENV: rawEnv.NODE_ENV,
searxng: {
currentUrl: rawEnv.SEARXNG_URL,
instances: JSON.parse(rawEnv.SEARXNG_INSTANCES)
},
ollama: {
url: rawEnv.OLLAMA_URL,
model: rawEnv.OLLAMA_MODEL
},
supabase: {
url: rawEnv.SUPABASE_URL,
anonKey: rawEnv.SUPABASE_KEY
},
cache: {
maxResultsPerQuery: parseInt(rawEnv.MAX_RESULTS_PER_QUERY),
durationHours: parseInt(rawEnv.CACHE_DURATION_HOURS),
durationDays: parseInt(rawEnv.CACHE_DURATION_DAYS)
},
ai: {
model: 'deepseek-ai/deepseek-coder-6.7b-instruct',
temperature: 0.7,
maxTokens: 512,
batchSize: 3
},
huggingface: {
apiKey: rawEnv.HUGGING_FACE_API_KEY
}
};

View file

@ -1,164 +1,80 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import { env } from '../../config/env'; import { Business } from '../types';
import { BusinessData } from '../types'; import env from '../../config/env';
import { generateBusinessId, extractPlaceIdFromUrl } from '../utils';
interface PartialBusiness {
name: string;
address: string;
phone: string;
description: string;
website?: string;
rating?: number;
source?: string;
location?: {
lat: number;
lng: number;
};
}
export class DatabaseService { export class DatabaseService {
private supabase: SupabaseClient; private supabase;
constructor() { constructor() {
this.supabase = createClient( this.supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY);
env.supabase.url,
env.supabase.anonKey,
{
auth: {
autoRefreshToken: true,
persistSession: true
}
}
);
} }
async searchBusinesses(query: string, location: string): Promise<BusinessData[]> { async saveBusiness(business: PartialBusiness): Promise<Business> {
try {
const { data, error } = await this.supabase const { data, error } = await this.supabase
.from('businesses')
.select('*')
.or(
`name.ilike.%${query}%,` +
`description.ilike.%${query}%`
)
.ilike('address', `%${location}%`)
.order('search_count', { ascending: false })
.limit(env.cache.maxResultsPerQuery);
if (error) {
console.error('Error searching businesses:', error);
throw error;
}
console.log(`Found ${data?.length || 0} businesses in database`);
return data || [];
} catch (error) {
console.error('Error searching businesses:', error);
return [];
}
}
async saveBusiness(business: Partial<BusinessData>): Promise<void> {
const id = generateBusinessId({
title: business.name || '',
url: business.website,
phone: business.phone,
address: business.address
});
const { error } = await this.supabase
.from('businesses') .from('businesses')
.upsert({ .upsert({
id,
name: business.name, name: business.name,
phone: business.phone,
email: business.email,
address: business.address, address: business.address,
rating: business.rating, phone: business.phone,
website: business.website,
logo: business.logo,
source: business.source,
description: business.description, description: business.description,
latitude: business.location?.lat, website: business.website,
longitude: business.location?.lng, source: business.source || 'deepseek',
place_id: business.website ? extractPlaceIdFromUrl(business.website) : null, rating: business.rating || 4.5,
search_count: 1 location: business.location ? `(${business.location.lng},${business.location.lat})` : '(0,0)'
}, {
onConflict: 'id',
ignoreDuplicates: false
});
if (error) {
console.error('Error saving business:', error);
throw error;
}
}
async incrementSearchCount(id: string): Promise<void> {
const { error } = await this.supabase
.from('businesses')
.update({
search_count: this.supabase.rpc('increment'),
last_updated: new Date().toISOString()
}) })
.eq('id', id); .select()
if (error) {
console.error('Error incrementing search count:', error);
throw error;
}
}
async saveSearch(query: string, location: string, resultsCount: number): Promise<void> {
const { error } = await this.supabase
.from('searches')
.insert([{
query,
location,
results_count: resultsCount,
timestamp: new Date().toISOString()
}]);
if (error) {
console.error('Error saving search:', error);
throw error;
}
}
async getFromCache(key: string): Promise<any | null> {
const { data, error } = await this.supabase
.from('cache')
.select('value')
.eq('key', key)
.gt('expires_at', new Date().toISOString())
.single(); .single();
if (error) { if (error) {
if (error.code !== 'PGRST116') { // Not found error console.error('Error saving business:', error);
console.error('Error getting from cache:', error); throw new Error('Failed to save business');
} }
return data;
}
async findBusinessesByQuery(query: string, location: string): Promise<Business[]> {
const { data, error } = await this.supabase
.from('businesses')
.select('*')
.or(`name.ilike.%${query}%,description.ilike.%${query}%`)
.ilike('address', `%${location}%`)
.order('rating', { ascending: false });
if (error) {
console.error('Error finding businesses:', error);
throw new Error('Failed to find businesses');
}
return data || [];
}
async getBusinessById(id: string): Promise<Business | null> {
const { data, error } = await this.supabase
.from('businesses')
.select('*')
.eq('id', id)
.single();
if (error) {
console.error('Error getting business:', error);
return null; return null;
} }
return data?.value; return data;
}
async saveToCache(key: string, value: any, expiresIn: number): Promise<void> {
const { error } = await this.supabase
.from('cache')
.upsert({
key,
value,
expires_at: new Date(Date.now() + expiresIn).toISOString()
});
if (error) {
console.error('Error saving to cache:', error);
throw error;
}
}
async clearCache(pattern?: string): Promise<void> {
try {
const query = pattern ?
'DELETE FROM cache WHERE key LIKE $1' :
'DELETE FROM cache';
await this.supabase
.from('cache')
.delete()
.or(pattern ? `key LIKE $1` : '');
} catch (error) {
console.error('Error clearing cache:', error);
}
} }
} }
export const db = new DatabaseService();

View file

@ -1,460 +1,285 @@
import axios from 'axios'; import axios from 'axios';
import { env } from '../../config/env'; import EventEmitter from 'events';
import { Business } from '../types'; import { Business } from '../types';
export class DeepSeekService { interface PartialBusiness {
private static OLLAMA_URL = 'http://localhost:11434/api/generate'; name: string;
private static MODEL_NAME = 'qwen2:0.5b'; address: string;
private static MAX_ATTEMPTS = 3; // Prevent infinite loops phone: string;
description: string;
private static async retryWithBackoff(fn: () => Promise<any>, retries = 5) { website?: string;
for (let i = 0; i < retries; i++) { rating?: number;
try {
return await fn();
} catch (error) {
if (i === retries - 1) throw error;
// Longer backoff for timeouts
const isTimeout = axios.isAxiosError(error) && error.code === 'ECONNABORTED';
const delay = isTimeout ?
Math.pow(2, i) * 5000 : // 5s, 10s, 20s, 40s, 80s for timeouts
Math.pow(2, i) * 1000; // 1s, 2s, 4s, 8s, 16s for other errors
console.log(`Retry ${i + 1}/${retries} after ${delay/1000}s...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
private static cleanAddress(address: string): string {
// Remove marketing and extra info first
let cleaned = address
.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]/gu, '') // Remove emojis
.replace(/(?:GET|ORDER|SCHEDULE|CONTACT|DIRECTIONS).*?[:!\n]/i, '') // Remove action words
.replace(/\([^)]*\)/g, '') // Remove parenthetical info
.replace(/(?:Next|Behind|Inside|Near).*$/im, '') // Remove location hints
.split(/[\n\r]+/) // Split into lines
.map(line => line.trim())
.filter(Boolean); // Remove empty lines
// Try to find the line with street address
for (const line of cleaned) {
// Common address patterns
const patterns = [
// Handle suite/unit in street address
/(\d+[^,]+?(?:\s+(?:Suite|Ste|Unit|Apt|Building|Bldg|#)\s*[-A-Z0-9]+)?),\s*([^,]+?),\s*(?:CO|Colorado|COLORADO)[,\s]+(\d{5})/i,
// Basic format
/(\d+[^,]+?),\s*([^,]+?),\s*(?:CO|Colorado|COLORADO)[,\s]+(\d{5})/i,
// No commas
/(\d+[^,]+?)\s+([^,]+?)\s+(?:CO|Colorado|COLORADO)\s+(\d{5})/i,
];
for (const pattern of patterns) {
const match = line.match(pattern);
if (match) {
const [_, street, city, zip] = match;
// Clean and capitalize street address
const cleanedStreet = street
.replace(/\s+/g, ' ')
.replace(/(\d+)/, '$1 ') // Add space after number
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
// Capitalize city
const cleanedCity = city.trim()
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
return `${cleanedStreet}, ${cleanedCity}, CO ${zip}`;
}
}
}
// If no match found, try to extract components
const streetLine = cleaned.find(line => /\d+/.test(line));
if (streetLine) {
const streetMatch = streetLine.match(/(\d+[^,\n]+?)(?:\s+(?:Suite|Ste|Unit|Apt|Building|Bldg|#)\s*[-A-Z0-9]+)?/i);
const zipMatch = cleaned.join(' ').match(/\b(\d{5})\b/);
if (streetMatch && zipMatch) {
const street = streetMatch[0].trim();
const zip = zipMatch[1];
return `${street}, Denver, CO ${zip}`;
}
}
return '';
}
private static manualClean(business: Partial<Business>): Partial<Business> {
const cleaned = { ...business };
// Clean address
if (cleaned.address) {
const cleanedAddress = this.cleanAddress(cleaned.address);
if (cleanedAddress) {
cleaned.address = cleanedAddress;
}
}
// Extract business type first
const businessType = this.detectBusinessType(cleaned.name || '');
// Clean name while preserving core identity
if (cleaned.name) {
cleaned.name = cleaned.name
// Remove emojis and special characters
.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]/gu, '')
// Remove bracketed content but preserve important terms
.replace(/\s*[\[\({](?!(?:BMW|Mercedes|Audi|specialist|certified)).*?[\]\)}]\s*/gi, ' ')
// Remove business suffixes
.replace(/\b(?:LLC|Inc|Corp|Ltd|DBA|Est\.|Since|P\.?C\.?)\b\.?\s*\d*/gi, '')
// Clean up and normalize
.replace(/[^\w\s&'-]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^THE\s+/i, ''); // Remove leading "THE"
}
// Clean phone - handle multiple numbers and formats
if (cleaned.phone) {
// Remove emojis and special characters first
const cleanPhone = cleaned.phone
.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]/gu, '')
.replace(/[^\d]/g, '');
const phoneNumbers = cleanPhone.match(/\d{10,}/g);
if (phoneNumbers?.[0]) {
const mainNumber = phoneNumbers[0].slice(0, 10); // Ensure exactly 10 digits
cleaned.phone = `(${mainNumber.slice(0,3)}) ${mainNumber.slice(3,6)}-${mainNumber.slice(6,10)}`;
}
}
// Clean email - handle multiple emails and formats
if (cleaned.email) {
const emailMatch = cleaned.email.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/);
if (emailMatch?.[1]) {
cleaned.email = emailMatch[1];
}
}
// Improved description cleaning
if (cleaned.description) {
const coreDescription = this.extractCoreDescription(cleaned.description, businessType);
cleaned.description = coreDescription;
}
return cleaned;
}
private static detectBusinessType(name: string): string {
const types = {
auto: /\b(?:auto|car|vehicle|BMW|Audi|Mercedes|mechanic|repair|service center)\b/i,
dental: /\b(?:dental|dentist|orthodontic|smile|tooth|teeth)\b/i,
coffee: /\b(?:coffee|cafe|espresso|roaster|brew)\b/i,
plumbing: /\b(?:plumb|plumbing|rooter|drain|pipe)\b/i,
restaurant: /\b(?:restaurant|grill|cuisine|bistro|kitchen)\b/i,
};
for (const [type, pattern] of Object.entries(types)) {
if (pattern.test(name)) return type;
}
return 'business';
}
private static extractCoreDescription(description: string, businessType: string): string {
// Remove all marketing and formatting first
let cleaned = description
.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]/gu, '')
.replace(/\$+\s*[^\s]*\s*(off|special|offer|deal|save|discount|price|cost|free)/gi, '')
.replace(/\b(?:call|email|visit|contact|text|www\.|http|@|book|schedule|appointment)\b.*$/gi, '')
.replace(/#\w+/g, '')
.replace(/\s+/g, ' ')
.trim();
// Extract relevant information based on business type
const typePatterns: { [key: string]: RegExp[] } = {
auto: [
/(?:specialist|specializing)\s+in\s+[^.]+/i,
/(?:certified|ASE)[^.]+mechanic[^.]+/i,
/(?:auto|car|vehicle)\s+(?:service|repair)[^.]+/i
],
dental: [
/(?:dental|orthodontic)\s+(?:care|services)[^.]+/i,
/(?:family|cosmetic|general)\s+dentistry[^.]+/i,
/state-of-the-art\s+facility[^.]+/i
],
coffee: [
/(?:coffee|espresso|pastry|cafe)[^.]+/i,
/(?:organic|fair-trade|fresh)[^.]+/i,
/(?:local|favorite|community)[^.]+coffee[^.]+/i
],
plumbing: [
/(?:plumbing|drain|pipe)\s+(?:service|repair)[^.]+/i,
/(?:professional|expert|master)\s+plumb[^.]+/i,
/(?:residential|commercial)\s+plumbing[^.]+/i
]
};
const relevantPhrases = typePatterns[businessType]?.map(pattern => {
const match = cleaned.match(pattern);
return match ? match[0] : '';
}).filter(Boolean) || [];
if (relevantPhrases.length > 0) {
return relevantPhrases.join('. ');
}
// Fallback to generic description
return `Professional ${businessType} services in Denver area`;
}
private static sanitizeJsonResponse(response: string): string {
return response
// Remove emojis
.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]/gu, '')
// Remove control characters
.replace(/[\u0000-\u001F\u007F-\u009F]/g, '')
// Clean up newlines and spaces
.replace(/\r?\n\s*/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
static async cleanBusinessData(business: Business, attempt = 0): Promise<Business> {
if (attempt >= this.MAX_ATTEMPTS) {
console.log('Max cleaning attempts reached, applying manual cleaning...');
return {
...business,
...this.manualClean(business)
};
}
// Detect business type first
const businessType = this.detectBusinessType(business.name || '');
const requestId = Math.random().toString(36).substring(7);
const prompt = `<|im_start|>system
You are a data cleaning expert. Clean the business data while preserving its core identity and type.
Request ID: ${requestId} // Force uniqueness
IMPORTANT: Return ONLY plain text without emojis or special characters.
<|im_end|>
<|im_start|>user
Clean this ${businessType} business data by following these rules exactly:
Input Business:
${JSON.stringify(business, null, 2)}
Cleaning Rules:
1. NAME: Remove brackets/braces but preserve core business identity
2. ADDRESS: Format as "street, city, state zip" using state abbreviations
3. PHONE: Extract and format primary phone as "(XXX) XXX-XXXX"
4. EMAIL: Remove markdown/mailto formatting but keep actual email
5. DESCRIPTION: Keep core business info but remove:
- ALL emojis and special characters (return plain text only)
- Prices and special offers
- Contact information
- Marketing language
- Social media elements
Return ONLY clean JSON with the original business identity preserved:
{
"business_info": {
"name": "Keep original business name without formatting",
"address": "Keep original address, properly formatted",
"phone": "Keep original phone number, properly formatted",
"email": "Keep original email without formatting",
"description": "Keep original business description without marketing"
}
} }
<|im_end|>`;
const response = await this.chat([{ export class DeepSeekService extends EventEmitter {
role: 'user', private readonly baseUrl: string;
content: prompt private readonly model: string;
}]);
try { constructor() {
const jsonMatch = response.match(/\{[\s\S]*?\}\s*$/); super();
if (!jsonMatch) { this.baseUrl = process.env.OLLAMA_URL || 'http://localhost:11434';
throw new Error('No JSON found in response'); this.model = process.env.OLLAMA_MODEL || 'deepseek-coder:6.7b';
} console.log('DeepSeekService initialized with:', {
baseUrl: this.baseUrl,
const sanitizedJson = this.sanitizeJsonResponse(jsonMatch[0]); model: this.model
const parsed = JSON.parse(sanitizedJson);
const cleaned = {
...business,
...parsed.business_info
};
// Validate and handle type mismatches more strictly
const validationIssues = this.validateCleanedData(cleaned, business);
if (validationIssues.length > 0) {
console.log(`Attempt ${attempt + 1}: Validation issues:`, validationIssues.join(', '));
// If there's a business type mismatch, go straight to manual cleaning
if (validationIssues.some(issue => issue.includes('Business type mismatch'))) {
console.log('Business type mismatch detected, applying manual cleaning...');
return {
...business,
...this.manualClean(business)
};
}
// For other validation issues, try again
return this.cleanBusinessData(cleaned, attempt + 1);
}
return cleaned;
} catch (error) {
console.error('Failed to parse response:', error);
console.log('Raw response:', response);
// Try to sanitize and parse the whole response
try {
const sanitized = this.sanitizeJsonResponse(response);
const fallback = this.parseResponse(sanitized);
return this.cleanBusinessData({ ...business, ...fallback }, attempt + 1);
} catch (parseError) {
console.error('Failed to parse sanitized response:', parseError);
return this.cleanBusinessData({ ...business, ...this.manualClean(business) }, attempt + 1);
}
}
}
private static validateCleanedData(business: Partial<Business>, originalBusiness: Business): string[] {
const issues: string[] = [];
// Stricter business type validation
const originalType = this.detectBusinessType(originalBusiness.name || '');
const cleanedType = this.detectBusinessType(business.name || '');
if (originalType !== 'business') {
if (cleanedType !== originalType) {
issues.push(`Business type mismatch: expected ${originalType}, got ${cleanedType}`);
}
// Verify core identity is preserved
const originalKeywords = originalBusiness.name?.toLowerCase().split(/\W+/).filter(Boolean) || [];
const cleanedKeywords = business.name?.toLowerCase().split(/\W+/).filter(Boolean) || [];
const significantKeywords = originalKeywords.filter(word =>
!['the', 'and', 'llc', 'inc', 'corp', 'ltd', 'dba', 'est'].includes(word)
);
const missingKeywords = significantKeywords.filter(word =>
!cleanedKeywords.some(cleaned => cleaned.includes(word))
);
if (missingKeywords.length > 0) {
issues.push(`Core business identity lost: missing ${missingKeywords.join(', ')}`);
}
}
if (business.name?.includes('[') || business.name?.includes(']')) {
issues.push('Name contains brackets');
}
if (!business.address?.match(/^\d+[^,]+,\s*[^,]+,\s*[A-Z]{2}\s+\d{5}$/)) {
const cleanedAddress = this.cleanAddress(business.address || '');
if (cleanedAddress) {
business.address = cleanedAddress;
} else {
issues.push('Address format incorrect');
}
}
if (!business.phone?.match(/^\(\d{3}\) \d{3}-\d{4}$/)) {
issues.push('Phone format incorrect');
}
if (business.email?.includes('[') || business.email?.includes('mailto:')) {
issues.push('Email contains markdown/mailto');
}
if (business.description?.match(/\$|\b(?:call|email|visit|contact)\b/i)) {
issues.push('Description contains pricing or contact info');
}
return issues;
}
private static async chat(messages: { role: string, content: string }[]) {
return this.retryWithBackoff(async () => {
try {
const response = await axios.post(
this.OLLAMA_URL,
{
model: this.MODEL_NAME,
prompt: messages[0].content,
stream: false,
options: {
temperature: 0.7, // Add some randomness
num_predict: 2048,
stop: ["<|im_end|>", "\n\n"],
top_k: 40, // Allow more variety
top_p: 0.9, // Allow more variety
seed: Date.now(), // Force different results each time
reset: true // Reset context window
}
},
{
headers: {
'Content-Type': 'application/json'
},
timeout: 30000
}
);
return response.data.response;
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNREFUSED') {
throw new Error('Ollama server not running');
}
if (error.response?.status === 404) {
throw new Error(`Model ${this.MODEL_NAME} not found. Run: ollama pull ${this.MODEL_NAME}`);
}
}
throw error;
}
}); });
} }
private static parseResponse(response: string) { async streamChat(messages: any[], onResult: (business: PartialBusiness) => Promise<void>): Promise<void> {
const lines = response.split('\n'); try {
const cleaned: Partial<Business> = {}; console.log('\nStarting streaming chat request...');
for (const line of lines) { // Enhanced system prompt with more explicit instructions
const [field, ...values] = line.split(':'); const enhancedMessages = [
const value = values.join(':').trim(); {
role: "system",
content: `You are a business search assistant powered by Deepseek Coder. Your task is to generate sample business listings in JSON format.
switch (field.toLowerCase().trim()) { When asked about businesses in a location, return business listings one at a time in this exact JSON format:
case 'name':
cleaned.name = value; \`\`\`json
break; {
case 'address': "name": "Example Plumbing Co",
cleaned.address = value; "address": "123 Main St, Denver, CO 80202",
break; "phone": "(303) 555-0123",
case 'phone': "description": "Licensed plumbing contractor specializing in residential and commercial services",
cleaned.phone = value; "website": "https://exampleplumbing.com",
break; "rating": 4.8
case 'email': }
cleaned.email = value; \`\`\`
break;
case 'description': Important rules:
cleaned.description = value; 1. Return ONE business at a time in JSON format
break; 2. Generate realistic but fictional business data
3. Use proper formatting for phone numbers and addresses
4. Include ratings from 1-5 stars (can use decimals)
5. When sorting by rating, return highest rated first
6. Make each business unique with different names, addresses, and phone numbers
7. Keep descriptions concise and professional
8. Use realistic website URLs based on business names
9. Return exactly the number of businesses requested`
},
...messages
];
console.log('Sending streaming request to Ollama with messages:', JSON.stringify(enhancedMessages, null, 2));
const response = await axios.post(`${this.baseUrl}/api/chat`, {
model: this.model,
messages: enhancedMessages,
stream: true,
temperature: 0.7,
max_tokens: 1000,
system: "You are a business search assistant that returns one business at a time in JSON format."
}, {
responseType: 'stream'
});
let currentJson = '';
response.data.on('data', async (chunk: Buffer) => {
const text = chunk.toString();
currentJson += text;
// Try to find and process complete JSON objects
try {
const business = await this.extractNextBusiness(currentJson);
if (business) {
currentJson = ''; // Reset for next business
await onResult(business);
}
} catch (error) {
// Continue collecting more data if JSON is incomplete
console.debug('Collecting more data for complete JSON');
}
});
return new Promise((resolve, reject) => {
response.data.on('end', () => resolve());
response.data.on('error', (error: Error) => reject(error));
});
} catch (error) {
console.error('\nDeepseek streaming chat error:', error);
if (error instanceof Error) {
console.error('Error stack:', error.stack);
throw new Error(`AI model streaming error: ${error.message}`);
}
throw new Error('Failed to get streaming response from AI model');
} }
} }
return cleaned; private async extractNextBusiness(text: string): Promise<PartialBusiness | null> {
// Try to find a complete JSON object
const jsonMatch = text.match(/\{[^{]*\}/);
if (!jsonMatch) return null;
try {
const jsonStr = jsonMatch[0];
const business = JSON.parse(jsonStr);
// Validate required fields
if (!business.name || !business.address || !business.phone || !business.description) {
return null;
}
return business;
} catch (e) {
return null;
}
}
async chat(messages: any[]): Promise<any> {
try {
console.log('\nStarting chat request...');
// Enhanced system prompt with more explicit instructions
const enhancedMessages = [
{
role: "system",
content: `You are a business search assistant powered by Deepseek Coder. Your task is to generate sample business listings in JSON format.
When asked about businesses in a location, return business listings in this exact JSON format, with no additional text or comments:
\`\`\`json
[
{
"name": "Example Plumbing Co",
"address": "123 Main St, Denver, CO 80202",
"phone": "(303) 555-0123",
"description": "Licensed plumbing contractor specializing in residential and commercial services",
"website": "https://exampleplumbing.com",
"rating": 4.8
}
]
\`\`\`
Important rules:
1. Return ONLY the JSON array inside code blocks - no explanations or comments
2. Generate realistic but fictional business data
3. Use proper formatting for phone numbers (e.g., "(303) 555-XXXX") and addresses
4. Include ratings from 1-5 stars (can use decimals, e.g., 4.8)
5. When sorting by rating, sort from highest to lowest rating
6. When asked for a specific number of results, always return exactly that many
7. Make each business unique with different names, addresses, and phone numbers
8. Keep descriptions concise and professional
9. Use realistic website URLs based on business names`
},
...messages
];
console.log('Sending request to Ollama with messages:', JSON.stringify(enhancedMessages, null, 2));
const response = await axios.post(`${this.baseUrl}/api/chat`, {
model: this.model,
messages: enhancedMessages,
stream: false,
temperature: 0.7,
max_tokens: 1000,
system: "You are a business search assistant that always responds with JSON data."
});
if (!response.data) {
throw new Error('Empty response from AI model');
}
console.log('\nRaw response data:', JSON.stringify(response.data, null, 2));
if (!response.data.message?.content) {
throw new Error('No content in AI model response');
}
console.log('\nParsing AI response...');
const results = await this.sanitizeJsonResponse(response.data.message.content);
console.log('Parsed results:', JSON.stringify(results, null, 2));
return results;
} catch (error) {
console.error('\nDeepseek chat error:', error);
if (error instanceof Error) {
console.error('Error stack:', error.stack);
throw new Error(`AI model error: ${error.message}`);
}
throw new Error('Failed to get response from AI model');
}
}
private async sanitizeJsonResponse(text: string): Promise<PartialBusiness[]> {
console.log('Attempting to parse response:', text);
// First try to find JSON blocks
const jsonBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (jsonBlockMatch) {
try {
const jsonStr = jsonBlockMatch[1].trim();
console.log('Found JSON block:', jsonStr);
const parsed = JSON.parse(jsonStr);
return Array.isArray(parsed) ? parsed : [parsed];
} catch (e) {
console.error('Failed to parse JSON block:', e);
}
}
// Then try to find any JSON-like structure
const jsonPatterns = [
/\[\s*\{[\s\S]*\}\s*\]/, // Array of objects
/\{[\s\S]*\}/ // Single object
];
for (const pattern of jsonPatterns) {
const match = text.match(pattern);
if (match) {
try {
const jsonStr = match[0].trim();
console.log('Found JSON pattern:', jsonStr);
const parsed = JSON.parse(jsonStr);
return Array.isArray(parsed) ? parsed : [parsed];
} catch (e) {
console.error('Failed to parse JSON pattern:', e);
continue;
}
}
}
// If no valid JSON found, try to extract structured data
try {
const extractedData = this.extractBusinessData(text);
if (extractedData) {
console.log('Extracted business data:', extractedData);
return [extractedData];
}
} catch (e) {
console.error('Failed to extract business data:', e);
}
throw new Error('No valid JSON or business information found in response');
}
private extractBusinessData(text: string): PartialBusiness {
// Extract business information using regex patterns
const businessInfo: PartialBusiness = {
name: this.extractField(text, 'name', '[^"\\n]+') || 'Unknown Business',
address: this.extractField(text, 'address', '[^"\\n]+') || 'Address not available',
phone: this.extractField(text, 'phone', '[^"\\n]+') || 'Phone not available',
description: this.extractField(text, 'description', '[^"\\n]+') || 'No description available'
};
const website = this.extractField(text, 'website', '[^"\\n]+');
if (website) {
businessInfo.website = website;
}
const rating = this.extractField(text, 'rating', '[0-9.]+');
if (rating) {
businessInfo.rating = parseFloat(rating);
}
return businessInfo;
}
private extractField(text: string, field: string, pattern: string): string {
const regex = new RegExp(`"?${field}"?\\s*[:=]\\s*"?(${pattern})"?`, 'i');
const match = text.match(regex);
return match ? match[1].trim() : '';
} }
} }

View file

@ -1,53 +1,40 @@
import axios from 'axios'; import axios from 'axios';
import { env } from '../../config/env';
import { supabase } from '../supabase'; import { supabase } from '../supabase';
import { env } from '../../config/env';
export class HealthCheckService { export class HealthCheckService {
static async checkOllama(): Promise<boolean> { private static async checkSupabase(): Promise<boolean> {
try { try {
const response = await axios.get(`${env.ollama.url}/api/tags`); const { data, error } = await supabase.from('searches').select('count');
return response.status === 200; return !error;
} catch (error) { } catch (error) {
console.error('Ollama health check failed:', error); console.error('Supabase health check failed:', error);
return false; return false;
} }
} }
static async checkSearxNG(): Promise<boolean> { private static async checkSearx(): Promise<boolean> {
try { try {
const response = await axios.get(`${env.searxng.currentUrl}/config`); const response = await axios.get(env.SEARXNG_URL);
return response.status === 200; return response.status === 200;
} catch (error) { } catch (error) {
try {
const response = await axios.get(`${env.searxng.instances[0]}/config`);
return response.status === 200;
} catch (fallbackError) {
console.error('SearxNG health check failed:', error); console.error('SearxNG health check failed:', error);
return false; return false;
} }
} }
}
static async checkSupabase(): Promise<boolean> { public static async checkHealth(): Promise<{
try { supabase: boolean;
console.log('Checking Supabase connection...'); searx: boolean;
console.log('URL:', env.supabase.url); }> {
const [supabaseHealth, searxHealth] = await Promise.all([
this.checkSupabase(),
this.checkSearx()
]);
// Just check if we can connect and query, don't care about results return {
const { error } = await supabase supabase: supabaseHealth,
.from('businesses') searx: searxHealth
.select('count', { count: 'planned', head: true }); };
if (error) {
console.error('Supabase query error:', error);
return false;
}
console.log('Supabase connection successful');
return true;
} catch (error) {
console.error('Supabase connection failed:', error);
return false;
}
} }
} }

View file

@ -1,97 +1,135 @@
import EventEmitter from 'events';
import { DeepSeekService } from './deepseekService'; import { DeepSeekService } from './deepseekService';
import { createClient } from '@supabase/supabase-js'; import { DatabaseService } from './databaseService';
import { Business } from '../types'; import { Business } from '../types';
export class SearchService { interface PartialBusiness {
private supabase; name: string;
private deepseek; address: string;
phone: string;
description: string;
website?: string;
rating?: number;
source?: string;
location?: {
lat: number;
lng: number;
};
}
export class SearchService extends EventEmitter {
private deepseekService: DeepSeekService;
private databaseService: DatabaseService;
constructor() { constructor() {
this.supabase = createClient( super();
process.env.SUPABASE_URL!, this.deepseekService = new DeepSeekService();
process.env.SUPABASE_KEY! this.databaseService = new DatabaseService();
);
this.deepseek = DeepSeekService; this.deepseekService.on('progress', (data) => {
} this.emit('progress', data);
});
async search(query: string, location: string): Promise<Business[]> {
if (!query || !location) {
throw new Error('Query and location are required');
}
// Check cache first
const cacheKey = `${query}_${location}`.toLowerCase();
const { data: cacheData } = await this.supabase
.from('cache')
.select()
.eq('key', cacheKey)
.single();
if (cacheData && cacheData.value) {
return cacheData.value as Business[];
} }
async streamSearch(query: string, location: string, limit: number = 10): Promise<void> {
try { try {
// Perform search // First, try to find cached results in database
const searchResults = await this.performSearch(query, location); const cachedResults = await this.databaseService.findBusinessesByQuery(query, location);
if (cachedResults.length > 0) {
// Cache results // Emit cached results one by one
await this.cacheResults(cacheKey, searchResults); for (const result of this.sortByRating(cachedResults).slice(0, limit)) {
this.emit('result', result);
return searchResults; await new Promise(resolve => setTimeout(resolve, 100)); // Small delay between results
} catch (error: any) {
if (error.response?.status === 429) {
throw new Error('Rate limit exceeded');
} }
this.emit('complete');
return;
}
// If no cached results, use DeepSeek to generate new results
const aiResults = await this.deepseekService.streamChat([{
role: "user",
content: `Find ${query} in ${location}. You must return exactly ${limit} results in valid JSON format, sorted by rating from highest to lowest. Each result must include a rating between 1-5 stars. Do not include any comments or explanations in the JSON.`
}], async (business: PartialBusiness) => {
try {
// Extract lat/lng from address using a geocoding service
const coords = await this.geocodeAddress(business.address);
// Save to database and emit result
const savedBusiness = await this.databaseService.saveBusiness({
...business,
source: 'deepseek',
location: coords || {
lat: 39.7392, // Denver's default coordinates
lng: -104.9903
}
});
this.emit('result', savedBusiness);
} catch (error) {
console.error('Error processing business:', error);
this.emit('error', error);
}
});
this.emit('complete');
} catch (error) {
console.error('Search error:', error);
this.emit('error', error);
throw error; throw error;
} }
} }
async getBusinessById(id: string): Promise<Business | null> { async search(query: string, location: string, limit: number = 10): Promise<Business[]> {
const { data, error } = await this.supabase try {
.from('businesses') // First, try to find cached results in database
.select() const cachedResults = await this.databaseService.findBusinessesByQuery(query, location);
.eq('id', id) if (cachedResults.length > 0) {
.single(); return this.sortByRating(cachedResults).slice(0, limit);
}
if (error || !data) { // If no cached results, use DeepSeek to generate new results
const aiResults = await this.deepseekService.chat([{
role: "user",
content: `Find ${query} in ${location}. You must return exactly ${limit} results in valid JSON format, sorted by rating from highest to lowest. Each result must include a rating between 1-5 stars. Do not include any comments or explanations in the JSON.`
}]);
// Save the results to database
const savedResults = await Promise.all(
(aiResults as PartialBusiness[]).map(async (business: PartialBusiness) => {
// Extract lat/lng from address using a geocoding service
const coords = await this.geocodeAddress(business.address);
return this.databaseService.saveBusiness({
...business,
source: 'deepseek',
location: coords || {
lat: 39.7392, // Denver's default coordinates
lng: -104.9903
}
});
})
);
return this.sortByRating(savedResults);
} catch (error) {
console.error('Search error:', error);
throw error;
}
}
private sortByRating(businesses: Business[]): Business[] {
return businesses.sort((a, b) => b.rating - a.rating);
}
private async geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
// TODO: Implement real geocoding service
// For now, return null to use default coordinates
return null; return null;
} }
return data as Business; async getBusinessById(id: string): Promise<Business | null> {
} return this.databaseService.getBusinessById(id);
private async performSearch(query: string, location: string): Promise<Business[]> {
// Implementation would use DeepSeek service to perform search
// This is a placeholder implementation
const mockBusiness: Business = {
id: 'test_1',
name: "Denver's Best Plumbing",
address: "1234 Main Street, Denver, CO 80202",
phone: "(720) 555-1234",
email: "support@denverplumbing.com",
description: "Professional plumbing services",
source: 'test',
website: 'https://example.com',
rating: 4.8,
location: { lat: 39.7392, lng: -104.9903 },
openingHours: []
};
return [mockBusiness];
}
private async cacheResults(key: string, results: Business[]): Promise<void> {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + Number(process.env.CACHE_DURATION_DAYS || 7));
await this.supabase
.from('cache')
.insert([{
key,
value: results,
created_at: new Date().toISOString(),
expires_at: expiresAt.toISOString()
}]);
} }
} }

View file

@ -2,41 +2,34 @@ import { createClient } from '@supabase/supabase-js';
import { env } from '../config/env'; import { env } from '../config/env';
// Validate Supabase configuration // Validate Supabase configuration
if (!env.supabase.url || !env.supabase.anonKey) { if (!env.SUPABASE_URL || !env.SUPABASE_KEY) {
throw new Error('Missing Supabase configuration'); throw new Error('Missing Supabase configuration');
} }
// Create Supabase client // Create Supabase client
export const supabase = createClient( export const supabase = createClient(
env.supabase.url, env.SUPABASE_URL,
env.supabase.anonKey, env.SUPABASE_KEY,
{ {
auth: { auth: {
autoRefreshToken: true, autoRefreshToken: true,
persistSession: true persistSession: true,
detectSessionInUrl: true
} }
} }
); );
// Test the connection on startup // Test connection function
async function testConnection() { export async function testConnection() {
try { try {
console.log('Checking Supabase connection...'); console.log('Testing Supabase connection...');
console.log('URL:', env.supabase.url); console.log('URL:', env.SUPABASE_URL);
const { data, error } = await supabase.from('searches').select('count');
const { error } = await supabase if (error) throw error;
.from('businesses') console.log('Supabase connection successful');
.select('count', { count: 'planned', head: true }); return true;
if (error) {
console.error('❌ Supabase initialization error:', error);
} else {
console.log('✅ Supabase connection initialized successfully');
}
} catch (error) { } catch (error) {
console.error('❌ Failed to initialize Supabase:', error); console.error('Supabase connection failed:', error);
return false;
} }
} }
// Run the test
testConnection().catch(console.error);

View file

@ -1,22 +1,16 @@
export interface Business { export interface Business {
id: string; id: string;
name: string; name: string;
phone?: string; address: string;
email?: string; phone: string;
address?: string; description: string;
rating?: number;
website?: string; website?: string;
logo?: string;
source: string; source: string;
description?: string; rating: number;
location?: { location: {
lat: number; lat: number;
lng: number; lng: number;
}; };
openingHours?: string[];
services?: string[];
reviewCount?: number;
hours?: string[];
} }
export type BusinessData = Business; export type BusinessData = Business;

47
src/middleware/auth.ts Normal file
View file

@ -0,0 +1,47 @@
import { Request, Response, NextFunction } from 'express';
import { supabase } from '../lib/supabase';
// Extend Express Request type to include user
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
role: string;
};
}
}
}
export async function authenticateUser(
req: Request,
res: Response,
next: NextFunction
) {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'No authorization header' });
}
const token = authHeader.replace('Bearer ', '');
const { data: { user }, error } = await supabase.auth.getUser(token);
if (error || !user) {
return res.status(401).json({ error: 'Invalid token' });
}
// Add user info to request
req.user = {
id: user.id,
email: user.email!,
role: (user.app_metadata?.role as string) || 'user'
};
next();
} catch (error) {
console.error('Authentication error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}

View file

@ -1,59 +1,146 @@
import express from 'express'; import express from 'express';
import { SearchService } from '../lib/services/searchService'; import { SearchService } from '../lib/services/searchService';
import { Business } from '../lib/types';
const router = express.Router(); const router = express.Router();
const searchService = new SearchService(); const searchService = new SearchService();
// Error handling middleware for JSON parsing errors // Error handling middleware for JSON parsing errors
router.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { router.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof SyntaxError && 'body' in err) { if (err instanceof SyntaxError && 'body' in err) {
return res.status(400).json({ error: 'Invalid JSON' }); return res.status(400).json({
success: false,
error: 'Invalid JSON'
});
} }
next(); next();
}); });
// Search endpoint // Business categories endpoint
router.post('/search', async (req, res) => { router.get('/categories', (req, res) => {
try { const categories = [
'Restaurant',
'Retail',
'Service',
'Healthcare',
'Professional',
'Entertainment',
'Education',
'Technology',
'Manufacturing',
'Construction',
'Transportation',
'Real Estate',
'Financial',
'Legal',
'Other'
];
res.json(categories);
});
// Streaming search endpoint
router.post('/search/stream', (req, res) => {
const { query, location } = req.body; const { query, location } = req.body;
if (!query || !location) { if (!query || !location) {
return res.status(400).json({ return res.status(400).json({
success: false,
error: 'Query and location are required' error: 'Query and location are required'
}); });
} }
const results = await searchService.search(query, location); // Set headers for SSE
res.json({ results }); res.setHeader('Content-Type', 'text/event-stream');
} catch (error: any) { res.setHeader('Cache-Control', 'no-cache');
if (error.response?.status === 429) { res.setHeader('Connection', 'keep-alive');
return res.status(429).json({
error: 'Rate limit exceeded' // Send initial message
res.write('data: {"type":"start","message":"Starting search..."}\n\n');
// Create search service instance for this request
const search = new SearchService();
// Listen for individual results
search.on('result', (business: Business) => {
res.write(`data: {"type":"result","business":${JSON.stringify(business)}}\n\n`);
});
// Listen for progress updates
search.on('progress', (data: any) => {
res.write(`data: {"type":"progress","data":${JSON.stringify(data)}}\n\n`);
});
// Listen for completion
search.on('complete', () => {
res.write('data: {"type":"complete","message":"Search complete"}\n\n');
res.end();
});
// Listen for errors
search.on('error', (error: Error) => {
res.write(`data: {"type":"error","message":${JSON.stringify(error.message)}}\n\n`);
res.end();
});
// Start the search
search.streamSearch(query, location).catch(error => {
console.error('Search error:', error);
res.write(`data: {"type":"error","message":${JSON.stringify(error.message)}}\n\n`);
res.end();
});
});
// Regular search endpoint (non-streaming)
router.post('/search', async (req, res) => {
const { query, location } = req.body;
if (!query || !location) {
return res.status(400).json({
success: false,
error: 'Query and location are required'
}); });
} }
try {
const results = await searchService.search(query, location);
res.json({
success: true,
results
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'An error occurred during search';
console.error('Search error:', error);
res.status(500).json({ res.status(500).json({
error: error.message || 'Internal server error' success: false,
error: errorMessage
}); });
} }
}); });
// Get business details endpoint // Get business by ID
router.get('/business/:id', async (req, res) => { router.get('/business/:id', async (req, res) => {
try {
const { id } = req.params; const { id } = req.params;
try {
const business = await searchService.getBusinessById(id); const business = await searchService.getBusinessById(id);
if (!business) { if (!business) {
return res.status(404).json({ return res.status(404).json({
success: false,
error: 'Business not found' error: 'Business not found'
}); });
} }
res.json(business); res.json({
} catch (error: any) { success: true,
business
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch business details';
console.error('Error fetching business:', error);
res.status(500).json({ res.status(500).json({
error: error.message || 'Internal server error' success: false,
error: errorMessage
}); });
} }
}); });

413
src/routes/business.ts Normal file
View file

@ -0,0 +1,413 @@
import { Router } from 'express';
import { z } from 'zod';
import { supabase } from '../lib/supabase';
import { authenticateUser } from '../middleware/auth';
const router = Router();
// Initialize database tables
async function initializeTables() {
try {
// Create businesses table if it doesn't exist
const { error: businessError } = await supabase.from('businesses').select('id').limit(1);
if (businessError?.code === 'PGRST204') {
const { error } = await supabase.rpc('execute_sql', {
sql_string: `
CREATE TABLE IF NOT EXISTS public.businesses (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
address TEXT,
rating NUMERIC,
website TEXT,
description TEXT,
source TEXT,
logo TEXT,
latitude NUMERIC,
longitude NUMERIC,
last_updated TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()),
search_count INTEGER DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()),
place_id TEXT
);
`
});
if (error) console.error('Error creating businesses table:', error);
}
// Create business_profiles table if it doesn't exist
const { error: profileError } = await supabase.from('business_profiles').select('business_id').limit(1);
if (profileError?.code === 'PGRST204') {
const { error } = await supabase.rpc('execute_sql', {
sql_string: `
CREATE TABLE IF NOT EXISTS public.business_profiles (
business_id TEXT PRIMARY KEY REFERENCES public.businesses(id),
claimed_by UUID REFERENCES auth.users(id),
claimed_at TIMESTAMP WITH TIME ZONE,
verification_status TEXT NOT NULL DEFAULT 'unverified',
social_links JSONB DEFAULT '{}',
hours_of_operation JSONB DEFAULT '{}',
additional_photos TEXT[] DEFAULT '{}',
tags TEXT[] DEFAULT '{}',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT valid_verification_status CHECK (verification_status IN ('unverified', 'pending', 'verified', 'rejected'))
);
CREATE TABLE IF NOT EXISTS public.business_claims (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
business_id TEXT NOT NULL REFERENCES public.businesses(id),
user_id UUID NOT NULL REFERENCES auth.users(id),
status TEXT NOT NULL DEFAULT 'pending',
proof_documents TEXT[] DEFAULT '{}',
submitted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
reviewed_at TIMESTAMP WITH TIME ZONE,
reviewed_by UUID REFERENCES auth.users(id),
notes TEXT,
CONSTRAINT valid_claim_status CHECK (status IN ('pending', 'approved', 'rejected'))
);
CREATE INDEX IF NOT EXISTS idx_business_profiles_claimed_by ON public.business_profiles(claimed_by);
CREATE INDEX IF NOT EXISTS idx_business_claims_business_id ON public.business_claims(business_id);
CREATE INDEX IF NOT EXISTS idx_business_claims_user_id ON public.business_claims(user_id);
CREATE INDEX IF NOT EXISTS idx_business_claims_status ON public.business_claims(status);
ALTER TABLE public.business_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.business_claims ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Public profiles are viewable by everyone" ON public.business_profiles;
CREATE POLICY "Public profiles are viewable by everyone"
ON public.business_profiles FOR SELECT
USING (true);
DROP POLICY IF EXISTS "Profiles can be updated by verified owners" ON public.business_profiles;
CREATE POLICY "Profiles can be updated by verified owners"
ON public.business_profiles FOR UPDATE
USING (auth.uid() = claimed_by AND verification_status = 'verified');
DROP POLICY IF EXISTS "Users can view their own claims" ON public.business_claims;
CREATE POLICY "Users can view their own claims"
ON public.business_claims FOR SELECT
USING (auth.uid() = user_id);
DROP POLICY IF EXISTS "Users can create claims" ON public.business_claims;
CREATE POLICY "Users can create claims"
ON public.business_claims FOR INSERT
WITH CHECK (auth.uid() = user_id);
DROP POLICY IF EXISTS "Only admins can review claims" ON public.business_claims;
CREATE POLICY "Only admins can review claims"
ON public.business_claims FOR UPDATE
USING (EXISTS (
SELECT 1 FROM auth.users
WHERE auth.uid() = id
AND raw_app_meta_data->>'role' = 'admin'
));
`
});
if (error) console.error('Error creating profile tables:', error);
}
// Insert test data
const { error: testDataError } = await supabase
.from('businesses')
.insert([
{
id: 'test-business-1',
name: 'Test Coffee Shop',
phone: '303-555-0123',
email: 'contact@testcoffee.com',
address: '123 Test St, Denver, CO 80202',
rating: 4.5,
website: 'https://testcoffee.com',
description: 'A cozy coffee shop in downtown Denver serving artisanal coffee and pastries.',
source: 'manual'
}
])
.select()
.single();
if (testDataError) {
console.error('Error inserting test data:', testDataError);
}
// Create test business profile
const { error: testProfileError } = await supabase
.from('business_profiles')
.insert([
{
business_id: 'test-business-1',
verification_status: 'unverified',
social_links: {
facebook: 'https://facebook.com/testcoffee',
instagram: 'https://instagram.com/testcoffee'
},
hours_of_operation: {
monday: ['7:00', '19:00'],
tuesday: ['7:00', '19:00'],
wednesday: ['7:00', '19:00'],
thursday: ['7:00', '19:00'],
friday: ['7:00', '20:00'],
saturday: ['8:00', '20:00'],
sunday: ['8:00', '18:00']
},
tags: ['coffee', 'pastries', 'breakfast', 'lunch']
}
])
.select()
.single();
if (testProfileError) {
console.error('Error creating test profile:', testProfileError);
}
} catch (error) {
console.error('Error initializing tables:', error);
}
}
// Call initialization on startup
initializeTables();
// Schema for business profile updates
const profileUpdateSchema = z.object({
social_links: z.record(z.string()).optional(),
hours_of_operation: z.record(z.array(z.string())).optional(),
additional_photos: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
});
// Schema for claim submissions
const claimSubmissionSchema = z.object({
business_id: z.string(),
proof_documents: z.array(z.string()),
notes: z.string().optional(),
});
// Get business profile
router.get('/:businessId', async (req, res) => {
try {
const { businessId } = req.params;
// Get business details and profile
const { data: business, error: businessError } = await supabase
.from('businesses')
.select(`
*,
business_profiles (*)
`)
.eq('id', businessId)
.single();
if (businessError) throw businessError;
if (!business) {
return res.status(404).json({ error: 'Business not found' });
}
res.json(business);
} catch (error) {
console.error('Error fetching business profile:', error);
res.status(500).json({ error: 'Failed to fetch business profile' });
}
});
// Update business profile (requires authentication)
router.patch('/:businessId/profile', authenticateUser, async (req, res) => {
try {
const { businessId } = req.params;
if (!req.user) {
return res.status(401).json({ error: 'User not authenticated' });
}
const userId = req.user.id;
const updates = profileUpdateSchema.parse(req.body);
// Check if user owns this profile
const { data: profile } = await supabase
.from('business_profiles')
.select('claimed_by, verification_status')
.eq('business_id', businessId)
.single();
if (!profile || profile.claimed_by !== userId || profile.verification_status !== 'verified') {
return res.status(403).json({ error: 'Not authorized to update this profile' });
}
// Update profile
const { error: updateError } = await supabase
.from('business_profiles')
.update({
...updates,
updated_at: new Date().toISOString(),
})
.eq('business_id', businessId);
if (updateError) throw updateError;
res.json({ message: 'Profile updated successfully' });
} catch (error) {
console.error('Error updating business profile:', error);
res.status(500).json({ error: 'Failed to update profile' });
}
});
// Submit a claim for a business
router.post('/claim', authenticateUser, async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: 'User not authenticated' });
}
const userId = req.user.id;
const claim = claimSubmissionSchema.parse(req.body);
// Check if business exists
const { data: business } = await supabase
.from('businesses')
.select('id')
.eq('id', claim.business_id)
.single();
if (!business) {
return res.status(404).json({ error: 'Business not found' });
}
// Check if business is already claimed
const { data: existingProfile } = await supabase
.from('business_profiles')
.select('claimed_by')
.eq('business_id', claim.business_id)
.single();
if (existingProfile?.claimed_by) {
return res.status(400).json({ error: 'Business is already claimed' });
}
// Check for existing pending claims
const { data: existingClaim } = await supabase
.from('business_claims')
.select('id')
.eq('business_id', claim.business_id)
.eq('status', 'pending')
.single();
if (existingClaim) {
return res.status(400).json({ error: 'A pending claim already exists for this business' });
}
// Create claim
const { error: claimError } = await supabase
.from('business_claims')
.insert({
business_id: claim.business_id,
user_id: userId,
proof_documents: claim.proof_documents,
notes: claim.notes,
});
if (claimError) throw claimError;
res.json({ message: 'Claim submitted successfully' });
} catch (error) {
console.error('Error submitting business claim:', error);
res.status(500).json({ error: 'Failed to submit claim' });
}
});
// Get claims for a business (admin only)
router.get('/:businessId/claims', authenticateUser, async (req, res) => {
try {
const { businessId } = req.params;
if (!req.user) {
return res.status(401).json({ error: 'User not authenticated' });
}
const userId = req.user.id;
// Check if user is admin
const { data: user } = await supabase
.from('users')
.select('raw_app_meta_data')
.eq('id', userId)
.single();
if (user?.raw_app_meta_data?.role !== 'admin') {
return res.status(403).json({ error: 'Not authorized' });
}
const { data: claims, error } = await supabase
.from('business_claims')
.select(`
*,
user:user_id (
email
)
`)
.eq('business_id', businessId)
.order('submitted_at', { ascending: false });
if (error) throw error;
res.json(claims);
} catch (error) {
console.error('Error fetching business claims:', error);
res.status(500).json({ error: 'Failed to fetch claims' });
}
});
// Review a claim (admin only)
router.post('/claims/:claimId/review', authenticateUser, async (req, res) => {
try {
const { claimId } = req.params;
if (!req.user) {
return res.status(401).json({ error: 'User not authenticated' });
}
const userId = req.user.id;
const { status, notes } = z.object({
status: z.enum(['approved', 'rejected']),
notes: z.string().optional(),
}).parse(req.body);
// Check if user is admin
const { data: user } = await supabase
.from('users')
.select('raw_app_meta_data')
.eq('id', userId)
.single();
if (user?.raw_app_meta_data?.role !== 'admin') {
return res.status(403).json({ error: 'Not authorized' });
}
// Get claim details
const { data: claim } = await supabase
.from('business_claims')
.select('business_id, status')
.eq('id', claimId)
.single();
if (!claim) {
return res.status(404).json({ error: 'Claim not found' });
}
if (claim.status !== 'pending') {
return res.status(400).json({ error: 'Claim has already been reviewed' });
}
// Start a transaction
const { error: updateError } = await supabase.rpc('review_business_claim', {
p_claim_id: claimId,
p_business_id: claim.business_id,
p_user_id: userId,
p_status: status,
p_notes: notes
});
if (updateError) throw updateError;
res.json({ message: 'Claim reviewed successfully' });
} catch (error) {
console.error('Error reviewing business claim:', error);
res.status(500).json({ error: 'Failed to review claim' });
}
});
export default router;

View file

@ -1,160 +1,310 @@
import express from 'express'; import { Router, Response as ExpressResponse } from 'express';
import logger from '../utils/logger'; import { z } from 'zod';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import fetch from 'node-fetch';
import type { Embeddings } from '@langchain/core/embeddings'; import { Response as FetchResponse } from 'node-fetch';
import { ChatOpenAI } from '@langchain/openai'; import { supabase } from '../lib/supabase';
import { import { env } from '../config/env';
getAvailableChatModelProviders,
getAvailableEmbeddingModelProviders,
} from '../lib/providers';
import { searchHandlers } from '../websocket/messageHandler';
import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages';
import { MetaSearchAgentType } from '../search/metaSearchAgent';
const router = express.Router(); const router = Router();
interface chatModel { const searchSchema = z.object({
provider: string; query: z.string().min(1),
model: string; });
customOpenAIBaseURL?: string;
customOpenAIKey?: string; interface Business {
id: string;
name: string;
description: string;
website: string;
phone: string | null;
address: string | null;
} }
interface embeddingModel { interface SearxResult {
provider: string; url: string;
model: string; title: string;
content: string;
engine: string;
score: number;
} }
interface ChatRequestBody { interface SearxResponse {
optimizationMode: 'speed' | 'balanced';
focusMode: string;
chatModel?: chatModel;
embeddingModel?: embeddingModel;
query: string; query: string;
history: Array<[string, string]>; results: SearxResult[];
} }
router.post('/', async (req, res) => { async function getCachedResults(query: string): Promise<Business[]> {
console.log('Fetching cached results for query:', query);
const normalizedQuery = query.toLowerCase()
.trim()
.replace(/,/g, '') // Remove commas
.replace(/\s+/g, ' '); // Normalize whitespace
const searchTerms = normalizedQuery.split(' ').filter(term => term.length > 0);
console.log('Normalized search terms:', searchTerms);
// First try exact match
const { data: exactMatch } = await supabase
.from('search_cache')
.select('*')
.eq('query', normalizedQuery)
.single();
if (exactMatch) {
console.log('Found exact match in cache');
return exactMatch.results as Business[];
}
// Then try fuzzy search
console.log('Trying fuzzy search with terms:', searchTerms);
const searchConditions = searchTerms.map(term => `query.ilike.%${term}%`);
const { data: cachedResults, error } = await supabase
.from('search_cache')
.select('*')
.or(searchConditions.join(','));
if (error) {
console.error('Error fetching cached results:', error);
return [];
}
if (!cachedResults || cachedResults.length === 0) {
console.log('No cached results found');
return [];
}
console.log(`Found ${cachedResults.length} cached searches`);
// Combine and deduplicate results from all matching searches
const allResults = cachedResults.flatMap(cache => cache.results as Business[]);
const uniqueResults = Array.from(new Map(allResults.map(item => [item.id, item])).values());
console.log(`Combined into ${uniqueResults.length} unique businesses`);
// Sort by relevance to search terms
const sortedResults = uniqueResults.sort((a, b) => {
const aScore = searchTerms.filter(term =>
a.name.toLowerCase().includes(term) ||
a.description.toLowerCase().includes(term)
).length;
const bScore = searchTerms.filter(term =>
b.name.toLowerCase().includes(term) ||
b.description.toLowerCase().includes(term)
).length;
return bScore - aScore;
});
return sortedResults;
}
async function searchSearxNG(query: string): Promise<Business[]> {
console.log('Starting SearxNG search for query:', query);
try { try {
const body: ChatRequestBody = req.body; const params = new URLSearchParams({
q: `${query} denver business`,
if (!body.focusMode || !body.query) { format: 'json',
return res.status(400).json({ message: 'Missing focus mode or query' }); language: 'en',
} time_range: '',
safesearch: '1',
body.history = body.history || []; engines: 'google,bing,duckduckgo'
body.optimizationMode = body.optimizationMode || 'balanced';
const history: BaseMessage[] = body.history.map((msg) => {
if (msg[0] === 'human') {
return new HumanMessage({
content: msg[1],
});
} else {
return new AIMessage({
content: msg[1],
}); });
const searchUrl = `${env.SEARXNG_URL}/search?${params.toString()}`;
console.log('Searching SearxNG at URL:', searchUrl);
const response: FetchResponse = await fetch(searchUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
} }
}); });
const [chatModelProviders, embeddingModelProviders] = await Promise.all([ if (!response.ok) {
getAvailableChatModelProviders(), throw new Error(`SearxNG search failed: ${response.statusText} (${response.status})`);
getAvailableEmbeddingModelProviders(),
]);
const chatModelProvider =
body.chatModel?.provider || Object.keys(chatModelProviders)[0];
const chatModel =
body.chatModel?.model ||
Object.keys(chatModelProviders[chatModelProvider])[0];
const embeddingModelProvider =
body.embeddingModel?.provider || Object.keys(embeddingModelProviders)[0];
const embeddingModel =
body.embeddingModel?.model ||
Object.keys(embeddingModelProviders[embeddingModelProvider])[0];
let llm: BaseChatModel | undefined;
let embeddings: Embeddings | undefined;
if (body.chatModel?.provider === 'custom_openai') {
if (
!body.chatModel?.customOpenAIBaseURL ||
!body.chatModel?.customOpenAIKey
) {
return res
.status(400)
.json({ message: 'Missing custom OpenAI base URL or key' });
} }
llm = new ChatOpenAI({ const data = await response.json() as SearxResponse;
modelName: body.chatModel.model, console.log(`Got ${data.results?.length || 0} raw results from SearxNG`);
openAIApiKey: body.chatModel.customOpenAIKey, console.log('Sample result:', data.results?.[0]);
temperature: 0.7,
configuration: { if (!data.results || data.results.length === 0) {
baseURL: body.chatModel.customOpenAIBaseURL, return [];
},
}) as unknown as BaseChatModel;
} else if (
chatModelProviders[chatModelProvider] &&
chatModelProviders[chatModelProvider][chatModel]
) {
llm = chatModelProviders[chatModelProvider][chatModel]
.model as unknown as BaseChatModel | undefined;
} }
if ( const filteredResults = data.results
embeddingModelProviders[embeddingModelProvider] && .filter(result =>
embeddingModelProviders[embeddingModelProvider][embeddingModel] result.title &&
) { result.url &&
embeddings = embeddingModelProviders[embeddingModelProvider][ !result.url.includes('yelp.com/search') &&
embeddingModel !result.url.includes('google.com/search') &&
].model as Embeddings | undefined; !result.url.includes('bbb.org/search') &&
} !result.url.includes('thumbtack.com/search') &&
!result.url.includes('angi.com/search') &&
if (!llm || !embeddings) { !result.url.includes('yellowpages.com/search')
return res.status(400).json({ message: 'Invalid model selected' });
}
const searchHandler: MetaSearchAgentType = searchHandlers[body.focusMode];
if (!searchHandler) {
return res.status(400).json({ message: 'Invalid focus mode' });
}
const emitter = await searchHandler.searchAndAnswer(
body.query,
history,
llm,
embeddings,
body.optimizationMode,
[],
); );
let message = ''; console.log(`Filtered to ${filteredResults.length} relevant results`);
let sources = []; console.log('Sample filtered result:', filteredResults[0]);
emitter.on('data', (data) => { const searchTerms = query.toLowerCase().split(' ');
const parsedData = JSON.parse(data); const businesses = filteredResults
if (parsedData.type === 'response') { .map(result => {
message += parsedData.data; const business = {
} else if (parsedData.type === 'sources') { id: result.url,
sources = parsedData.data; name: cleanBusinessName(result.title),
description: result.content || '',
website: result.url,
phone: extractPhone(result.content || '') || extractPhone(result.title),
address: extractAddress(result.content || '') || extractAddress(result.title),
score: result.score || 0
};
console.log('Processed business:', business);
return business;
})
.filter(business => {
// Check if business name contains any of the search terms
const nameMatches = searchTerms.some(term =>
business.name.toLowerCase().includes(term)
);
// Check if description contains any of the search terms
const descriptionMatches = searchTerms.some(term =>
business.description.toLowerCase().includes(term)
);
return business.name.length > 2 && (nameMatches || descriptionMatches);
})
.sort((a, b) => {
// Score based on how many search terms match the name and description
const aScore = searchTerms.filter(term =>
a.name.toLowerCase().includes(term) ||
a.description.toLowerCase().includes(term)
).length;
const bScore = searchTerms.filter(term =>
b.name.toLowerCase().includes(term) ||
b.description.toLowerCase().includes(term)
).length;
return bScore - aScore;
})
.slice(0, 10);
console.log(`Transformed into ${businesses.length} business entries`);
return businesses;
} catch (error) {
console.error('SearxNG search error:', error);
return [];
} }
}); }
emitter.on('end', () => { async function cacheResults(query: string, results: Business[]): Promise<void> {
res.status(200).json({ message, sources }); if (!results.length) return;
});
emitter.on('error', (data) => { console.log(`Caching ${results.length} results for query:`, query);
const parsedData = JSON.parse(data); const normalizedQuery = query.toLowerCase().trim();
res.status(500).json({ message: parsedData.data });
const { data: existing } = await supabase
.from('search_cache')
.select('id, results')
.eq('query', normalizedQuery)
.single();
if (existing) {
console.log('Updating existing cache entry');
// Merge new results with existing ones, removing duplicates
const allResults = [...existing.results, ...results];
const uniqueResults = Array.from(new Map(allResults.map(item => [item.id, item])).values());
await supabase
.from('search_cache')
.update({
results: uniqueResults,
updated_at: new Date().toISOString()
})
.eq('id', existing.id);
} else {
console.log('Creating new cache entry');
await supabase
.from('search_cache')
.insert({
query: normalizedQuery,
results,
location: 'denver', // Default location
category: 'business', // Default category
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7 days from now
}); });
} catch (err: any) { }
logger.error(`Error in getting search results: ${err.message}`); }
res.status(500).json({ message: 'An error has occurred.' });
function cleanBusinessName(title: string): string {
return title
.replace(/^(the\s+)?/i, '')
.replace(/\s*[-|]\s*.+$/i, '')
.replace(/\s*\|.*$/i, '')
.replace(/\s*in\s+denver.*$/i, '')
.replace(/\s*near\s+denver.*$/i, '')
.replace(/\s*-\s*.*denver.*$/i, '')
.trim();
}
function extractPhone(text: string): string | null {
const phoneRegex = /(\+?1?\s*\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4})/;
const match = text.match(phoneRegex);
return match ? match[1] : null;
}
function extractAddress(text: string): string | null {
const addressRegex = /\d+\s+[A-Za-z0-9\s,]+(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Way|Court|Ct|Circle|Cir)[,\s]+(?:[A-Za-z\s]+,\s*)?(?:CO|Colorado)[,\s]+\d{5}(?:-\d{4})?/i;
const match = text.match(addressRegex);
return match ? match[0] : null;
}
router.post('/search', async (req, res) => {
try {
console.log('Received search request:', req.body);
const { query } = searchSchema.parse(req.body);
await handleSearch(query, res);
} catch (error) {
console.error('Search error:', error);
res.status(400).json({ error: 'Search failed. Please try again.' });
} }
}); });
// Also support GET requests for easier testing
router.get('/search', async (req, res) => {
try {
const query = req.query.q as string;
if (!query) {
return res.status(400).json({ error: 'Query parameter "q" is required' });
}
console.log('Received search request:', { query });
await handleSearch(query, res);
} catch (error) {
console.error('Search error:', error);
res.status(400).json({ error: 'Search failed. Please try again.' });
}
});
// Helper function to handle search logic
async function handleSearch(query: string, res: ExpressResponse) {
// Get cached results immediately
const cachedResults = await getCachedResults(query);
console.log(`Returning ${cachedResults.length} cached results to client`);
// Send cached results to client
res.json({ results: cachedResults });
// Search for new results in the background
console.log('Starting background search');
searchSearxNG(query).then(async newResults => {
console.log(`Found ${newResults.length} new results from SearxNG`);
if (newResults.length > 0) {
await cacheResults(query, newResults);
}
}).catch(error => {
console.error('Background search error:', error);
});
}
export default router; export default router;

View file

@ -1,202 +1,43 @@
import { DeepSeekService } from '../lib/services/deepseekService'; import { DeepSeekService } from '../lib/services/deepseekService';
import { Business } from '../lib/types'; import dotenv from 'dotenv';
import axios from 'axios';
async function testOllamaConnection() { dotenv.config();
console.log('🔍 Testing Ollama connection...\n');
async function testDeepseekService() {
const service = new DeepSeekService();
try { try {
// Test simple connection console.log('Starting DeepSeek test...');
console.log('Testing Qwen model...'); console.log('Base URL:', process.env.OLLAMA_URL || 'http://localhost:11434');
const response = await DeepSeekService['chat']([{
role: 'user', const testQuery = {
content: 'Say "Hello, testing Qwen model!"' role: "user",
}]); content: "Find plumbers in Denver, CO. You must return exactly 10 results in valid JSON format, sorted by rating from highest to lowest. Each result must include a rating between 1-5 stars. Do not include any comments or explanations in the JSON."
};
console.log('Sending test query:', testQuery);
const response = await service.chat([testQuery]);
console.log('\nTest successful!');
console.log('Parsed response:', JSON.stringify(response, null, 2));
console.log('✅ Model Response:', response);
return true;
} catch (error) { } catch (error) {
console.error('\nTest failed!');
if (error instanceof Error) { if (error instanceof Error) {
console.error('❌ Connection test failed:', error.message); console.error('Error message:', error.message);
if (axios.isAxiosError(error)) { console.error('Stack trace:', error.stack);
if (error.code === 'ECONNREFUSED') {
console.error('❌ Make sure Ollama is running (ollama serve)');
} else { } else {
console.error('API Error details:', error.response?.data); console.error('Unknown error:', error);
}
}
} else {
console.error('❌ Connection test failed with unknown error');
}
return false;
}
}
async function testDataCleaning() {
console.log('\n🧪 Testing business data cleaning...');
const testCases: Business[] = [
{
id: 'test_1',
name: "Denver's Best Plumbing & Repair [LLC] (A Family Business) {Est. 1995}",
address: "CONTACT US TODAY! Suite 200-B, 1234 Main Street, Denver, Colorado 80202 (Near Starbucks)",
phone: "☎️ Main: (720) 555-1234 | Emergency: 1-800-555-9999 | Text: 720.555.4321",
email: "[support@denverplumbing.com](mailto:support@denverplumbing.com) or info@denverplumbing.com",
description: `$$$ LIMITED TIME OFFER $$$
🚰 Professional plumbing services in Denver metro area
💰 20% OFF all repairs over $500!
Family owned since 1995
📞 Available 24/7 for emergencies
🌐 Visit www.denverplumbing.com
📧 Email us at contact@denverplumbing.com
💳 All major credit cards accepted
#DenverPlumbing #EmergencyService`,
source: 'test',
website: 'https://example.com',
rating: 4.8,
logo: 'logo.png',
location: { lat: 39.7392, lng: -104.9903 },
openingHours: []
},
{
id: 'test_2',
name: "[MIKE'S AUTO] {{CERTIFIED}} [BMW & AUDI SPECIALIST]",
address: "GET DIRECTIONS: 5678 Auto Row Drive\nUnit C-123\nDenver, CO 80205\nBehind Home Depot",
phone: "Sales: 303-555-0000\nService: (303) 555-1111\nFax: 303.555.2222",
email: "appointments@mikesauto.com <click to email> [Schedule Now](https://booking.mikesauto.com)",
description: `🚗 Denver's Premier Auto Service Center
💯 ASE Certified Mechanics
🔧 Specializing in German Luxury Vehicles
💰💰💰 Spring Special: Free oil change with any service over $300
Same-day service available
🎯 Located in central Denver
📱 Text "REPAIR" to 80205 for $50 off
Over 500 5-star reviews!`,
source: 'test',
website: 'https://mikesauto.com',
rating: 4.9,
logo: 'logo.png',
location: { lat: 39.7599, lng: -104.9987 },
openingHours: ['Mon-Fri 8-6', 'Sat 9-3']
},
{
id: 'test_3',
name: "🌟 SUNSHINE DENTAL & ORTHODONTICS, P.C. [Dr. Smith & Associates] (Voted #1)",
address: "SCHEDULE TODAY!\n🦷 Building 3, Suite 300\n9876 Medical Plaza Way\nDENVER COLORADO, 80210\nNext to Target",
phone: "📞 New Patients: 1 (720) 999-8888 | Existing: 720.999.7777 | After Hours: +1-720-999-6666",
email: "appointments@sunshinedentalco.com, info@sunshinedentalco.com, emergency@sunshinedentalco.com",
description: `✨ Your Premier Dental Care Provider in Denver! ✨
🦷 State-of-the-art facility
💎 Cosmetic & General Dentistry
👶 Family-friendly environment
💰 NEW PATIENT SPECIAL: $99 Cleaning & Exam (Reg. $299)
🏥 Most insurance accepted
1,000+ 5-star reviews on Google
🎁 Refer a friend and get $50 credit
📱 Download our app: smile.sunshinedentalco.com`,
source: 'test',
website: 'https://sunshinedentalco.com',
rating: 5.0,
logo: 'logo.png',
location: { lat: 39.7120, lng: -104.9412 },
openingHours: ['Mon-Thu 8-5', 'Fri 8-2', 'Sat By Appt']
},
{
id: 'test_4',
name: "THE COFFEE SPOT ☕️ {{NOW OPEN}} [Under New Management!]",
address: "ORDER PICKUP:\nGround Floor\n4321 Downtown Street\nDenver, CO. 80203\nInside Union Station",
phone: "☎️ Store: 303•777•5555\n💬 Text Orders: 303-777-4444",
email: "<Order Online> orders@thecoffeespot.co [Click Here](https://order.thecoffeespot.co)",
description: `☕️ Denver's Favorite Coffee Shop Since 2020!
🌱 Organic, Fair-Trade Coffee
🥐 Fresh-Baked Pastries Daily
MORNING RUSH SPECIAL: $2 off any drink before 9am!
🎯 Loyalty Program: Buy 9, Get 1 FREE
📱 Order ahead on our app
🎁 Student Discount: 10% off with ID
#CoffeeLovers #DenverCoffee #MorningFuel
Follow us @thecoffeespot for daily specials!`,
source: 'test',
website: 'https://thecoffeespot.co',
rating: 4.7,
logo: 'logo.png',
location: { lat: 39.7508, lng: -104.9997 },
openingHours: ['Mon-Fri 6-8', 'Sat-Sun 7-7']
}
];
for (const testCase of testCases) {
console.log('\nTesting case:', testCase.id);
console.log('Input data:', JSON.stringify(testCase, null, 2));
console.time('Cleaning Duration');
const cleaned = await DeepSeekService.cleanBusinessData(testCase);
console.timeEnd('Cleaning Duration');
console.log('\nCleaned data:', JSON.stringify(cleaned, null, 2));
// Validate the results
const validationIssues = [];
// Name validation
if (cleaned.name?.match(/[\[\]{}()]/)) {
validationIssues.push('Name contains brackets/braces/parentheses');
}
// Address validation
if (!cleaned.address?.match(/^\d+[^,]+,\s*[^,]+,\s*[A-Z]{2}\s+\d{5}$/)) {
validationIssues.push('Address format incorrect');
}
// Phone validation
if (!cleaned.phone?.match(/^\(\d{3}\) \d{3}-\d{4}$/)) {
validationIssues.push('Phone format incorrect');
}
// Email validation
if (cleaned.email?.match(/[\[\]<>()]|mailto:|click|schedule/i)) {
validationIssues.push('Email contains formatting/links');
}
// Description validation
const descriptionIssues = [];
if (cleaned.description?.match(/[\$\d]+%?\s*off|\$/i)) {
descriptionIssues.push('contains pricing');
}
if (cleaned.description?.match(/\b(?:call|email|visit|contact|text|www\.|http|@)\b/i)) {
descriptionIssues.push('contains contact info');
}
if (cleaned.description?.match(/[📞📧🌐💳☎️📱]/)) {
descriptionIssues.push('contains emojis');
}
if (cleaned.description?.match(/#\w+/)) {
descriptionIssues.push('contains hashtags');
}
if (descriptionIssues.length > 0) {
validationIssues.push(`Description ${descriptionIssues.join(', ')}`);
}
if (validationIssues.length > 0) {
console.log('\n⚠ Validation issues:', validationIssues.join(', '));
} else {
console.log('\n✅ All fields cleaned successfully');
} }
} }
} }
async function runTests() { // Run the test
console.log('🚀 Starting Qwen model tests...\n'); console.log('=== Starting DeepSeek Service Test ===\n');
testDeepseekService().then(() => {
const connectionSuccess = await testOllamaConnection(); console.log('\n=== Test Complete ===');
if (!connectionSuccess) { }).catch(error => {
console.log('❌ Stopping tests due to connection failure'); console.error('\n=== Test Failed ===');
return; console.error(error);
} });
await testDataCleaning();
}
// Run tests if this file is executed directly
if (require.main === module) {
runTests().catch(console.error);
}

47
src/tests/testOllama.ts Normal file
View file

@ -0,0 +1,47 @@
import axios from 'axios';
import dotenv from 'dotenv';
dotenv.config();
async function testOllamaConnection() {
const baseUrl = process.env.OLLAMA_URL || 'http://localhost:11434';
console.log('Testing Ollama connection...');
console.log('Base URL:', baseUrl);
try {
// Simple test request
const response = await axios.post(`${baseUrl}/api/chat`, {
model: 'deepseek-coder:6.7b',
messages: [{
role: 'user',
content: 'Return a simple JSON array with one object: {"test": "success"}'
}],
stream: false
});
console.log('\nResponse received:');
console.log('Status:', response.status);
console.log('Data:', JSON.stringify(response.data, null, 2));
} catch (error) {
console.error('Connection test failed:');
if (axios.isAxiosError(error)) {
console.error('Network error:', error.message);
if (error.response) {
console.error('Response status:', error.response.status);
console.error('Response data:', error.response.data);
}
} else {
console.error('Error:', error);
}
}
}
console.log('=== Starting Ollama Connection Test ===\n');
testOllamaConnection().then(() => {
console.log('\n=== Test Complete ===');
}).catch(error => {
console.error('\n=== Test Failed ===');
console.error(error);
});