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:
push:
branches: [ main, develop ]
branches: [ main ]
pull_request:
branches: [ main, develop ]
branches: [ main ]
jobs:
test:
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:
- 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: Check code formatting
run: npm run format
- name: Run tests with coverage
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')
- uses: actions/checkout@v2
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Run tests
run: npm test
- name: Install dependencies
run: npm ci
- name: 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 }}
- name: Run type check
run: npm run build

View file

@ -1,48 +1,16 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import './config/env'; // Load environment variables first
import apiRoutes from './routes/api';
import { HealthCheckService } from './lib/services/healthCheck';
import searchRoutes from './routes/search';
import businessRoutes from './routes/business';
const app = express();
const port = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// API routes first
app.use('/api', apiRoutes);
// Routes
app.use('/api/search', searchRoutes);
app.use('/api/business', businessRoutes);
// Then static files
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);
});
export default app;

View file

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

View file

@ -1,164 +1,80 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { env } from '../../config/env';
import { BusinessData } from '../types';
import { generateBusinessId, extractPlaceIdFromUrl } from '../utils';
import { createClient } from '@supabase/supabase-js';
import { Business } from '../types';
import env from '../../config/env';
interface PartialBusiness {
name: string;
address: string;
phone: string;
description: string;
website?: string;
rating?: number;
source?: string;
location?: {
lat: number;
lng: number;
};
}
export class DatabaseService {
private supabase: SupabaseClient;
private supabase;
constructor() {
this.supabase = createClient(
env.supabase.url,
env.supabase.anonKey,
{
auth: {
autoRefreshToken: true,
persistSession: true
}
}
);
this.supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY);
}
async searchBusinesses(query: string, location: string): Promise<BusinessData[]> {
try {
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
async saveBusiness(business: PartialBusiness): Promise<Business> {
const { data, error } = await this.supabase
.from('businesses')
.upsert({
id,
name: business.name,
phone: business.phone,
email: business.email,
address: business.address,
rating: business.rating,
website: business.website,
logo: business.logo,
source: business.source,
phone: business.phone,
description: business.description,
latitude: business.location?.lat,
longitude: business.location?.lng,
place_id: business.website ? extractPlaceIdFromUrl(business.website) : null,
search_count: 1
}, {
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()
website: business.website,
source: business.source || 'deepseek',
rating: business.rating || 4.5,
location: business.location ? `(${business.location.lng},${business.location.lat})` : '(0,0)'
})
.eq('id', id);
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())
.select()
.single();
if (error) {
if (error.code !== 'PGRST116') { // Not found error
console.error('Error getting from cache:', error);
}
console.error('Error saving business:', 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 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 { env } from '../../config/env';
import EventEmitter from 'events';
import { Business } from '../types';
export class DeepSeekService {
private static OLLAMA_URL = 'http://localhost:11434/api/generate';
private static MODEL_NAME = 'qwen2:0.5b';
private static MAX_ATTEMPTS = 3; // Prevent infinite loops
private static async retryWithBackoff(fn: () => Promise<any>, retries = 5) {
for (let i = 0; i < retries; i++) {
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"
}
interface PartialBusiness {
name: string;
address: string;
phone: string;
description: string;
website?: string;
rating?: number;
}
<|im_end|>`;
const response = await this.chat([{
role: 'user',
content: prompt
}]);
try {
const jsonMatch = response.match(/\{[\s\S]*?\}\s*$/);
if (!jsonMatch) {
throw new Error('No JSON found in response');
}
const sanitizedJson = this.sanitizeJsonResponse(jsonMatch[0]);
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;
}
export class DeepSeekService extends EventEmitter {
private readonly baseUrl: string;
private readonly model: string;
constructor() {
super();
this.baseUrl = process.env.OLLAMA_URL || 'http://localhost:11434';
this.model = process.env.OLLAMA_MODEL || 'deepseek-coder:6.7b';
console.log('DeepSeekService initialized with:', {
baseUrl: this.baseUrl,
model: this.model
});
}
private static parseResponse(response: string) {
const lines = response.split('\n');
const cleaned: Partial<Business> = {};
for (const line of lines) {
const [field, ...values] = line.split(':');
const value = values.join(':').trim();
async streamChat(messages: any[], onResult: (business: PartialBusiness) => Promise<void>): Promise<void> {
try {
console.log('\nStarting streaming chat request...');
switch (field.toLowerCase().trim()) {
case 'name':
cleaned.name = value;
break;
case 'address':
cleaned.address = value;
break;
case 'phone':
cleaned.phone = value;
break;
case 'email':
cleaned.email = value;
break;
case 'description':
cleaned.description = value;
break;
// 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 one at a time in this exact JSON format:
\`\`\`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 ONE business at a time in JSON format
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');
}
}
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);
}
}
return cleaned;
// 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 { env } from '../../config/env';
import { supabase } from '../supabase';
import { env } from '../../config/env';
export class HealthCheckService {
static async checkOllama(): Promise<boolean> {
private static async checkSupabase(): Promise<boolean> {
try {
const response = await axios.get(`${env.ollama.url}/api/tags`);
return response.status === 200;
const { data, error } = await supabase.from('searches').select('count');
return !error;
} catch (error) {
console.error('Ollama health check failed:', error);
console.error('Supabase health check failed:', error);
return false;
}
}
static async checkSearxNG(): Promise<boolean> {
private static async checkSearx(): Promise<boolean> {
try {
const response = await axios.get(`${env.searxng.currentUrl}/config`);
const response = await axios.get(env.SEARXNG_URL);
return response.status === 200;
} 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);
return false;
}
console.error('SearxNG health check failed:', error);
return false;
}
}
static async checkSupabase(): Promise<boolean> {
try {
console.log('Checking Supabase connection...');
console.log('URL:', env.supabase.url);
public static async checkHealth(): Promise<{
supabase: boolean;
searx: boolean;
}> {
const [supabaseHealth, searxHealth] = await Promise.all([
this.checkSupabase(),
this.checkSearx()
]);
// Just check if we can connect and query, don't care about results
const { error } = await supabase
.from('businesses')
.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;
}
return {
supabase: supabaseHealth,
searx: searxHealth
};
}
}

View file

@ -1,97 +1,135 @@
import EventEmitter from 'events';
import { DeepSeekService } from './deepseekService';
import { createClient } from '@supabase/supabase-js';
import { DatabaseService } from './databaseService';
import { Business } from '../types';
export class SearchService {
private supabase;
private deepseek;
constructor() {
this.supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_KEY!
);
this.deepseek = DeepSeekService;
}
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[];
}
try {
// Perform search
const searchResults = await this.performSearch(query, location);
// Cache results
await this.cacheResults(cacheKey, searchResults);
return searchResults;
} catch (error: any) {
if (error.response?.status === 429) {
throw new Error('Rate limit exceeded');
}
throw error;
}
}
async getBusinessById(id: string): Promise<Business | null> {
const { data, error } = await this.supabase
.from('businesses')
.select()
.eq('id', id)
.single();
if (error || !data) {
return null;
}
return data as Business;
}
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: []
interface PartialBusiness {
name: string;
address: string;
phone: string;
description: string;
website?: string;
rating?: number;
source?: string;
location?: {
lat: number;
lng: number;
};
}
return [mockBusiness];
}
export class SearchService extends EventEmitter {
private deepseekService: DeepSeekService;
private databaseService: DatabaseService;
private async cacheResults(key: string, results: Business[]): Promise<void> {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + Number(process.env.CACHE_DURATION_DAYS || 7));
constructor() {
super();
this.deepseekService = new DeepSeekService();
this.databaseService = new DatabaseService();
this.deepseekService.on('progress', (data) => {
this.emit('progress', data);
});
}
await this.supabase
.from('cache')
.insert([{
key,
value: results,
created_at: new Date().toISOString(),
expires_at: expiresAt.toISOString()
}]);
}
async streamSearch(query: string, location: string, limit: number = 10): Promise<void> {
try {
// First, try to find cached results in database
const cachedResults = await this.databaseService.findBusinessesByQuery(query, location);
if (cachedResults.length > 0) {
// Emit cached results one by one
for (const result of this.sortByRating(cachedResults).slice(0, limit)) {
this.emit('result', result);
await new Promise(resolve => setTimeout(resolve, 100)); // Small delay between results
}
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;
}
}
async search(query: string, location: string, limit: number = 10): Promise<Business[]> {
try {
// First, try to find cached results in database
const cachedResults = await this.databaseService.findBusinessesByQuery(query, location);
if (cachedResults.length > 0) {
return this.sortByRating(cachedResults).slice(0, limit);
}
// 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;
}
async getBusinessById(id: string): Promise<Business | null> {
return this.databaseService.getBusinessById(id);
}
}

View file

@ -2,41 +2,34 @@ import { createClient } from '@supabase/supabase-js';
import { env } from '../config/env';
// Validate Supabase configuration
if (!env.supabase.url || !env.supabase.anonKey) {
if (!env.SUPABASE_URL || !env.SUPABASE_KEY) {
throw new Error('Missing Supabase configuration');
}
// Create Supabase client
export const supabase = createClient(
env.supabase.url,
env.supabase.anonKey,
env.SUPABASE_URL,
env.SUPABASE_KEY,
{
auth: {
autoRefreshToken: true,
persistSession: true
persistSession: true,
detectSessionInUrl: true
}
}
);
// Test the connection on startup
async function testConnection() {
// Test connection function
export async function testConnection() {
try {
console.log('Checking Supabase connection...');
console.log('URL:', env.supabase.url);
const { error } = await supabase
.from('businesses')
.select('count', { count: 'planned', head: true });
if (error) {
console.error('❌ Supabase initialization error:', error);
} else {
console.log('✅ Supabase connection initialized successfully');
}
console.log('Testing Supabase connection...');
console.log('URL:', env.SUPABASE_URL);
const { data, error } = await supabase.from('searches').select('count');
if (error) throw error;
console.log('Supabase connection successful');
return true;
} 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 {
id: string;
name: string;
phone?: string;
email?: string;
address?: string;
rating?: number;
address: string;
phone: string;
description: string;
website?: string;
logo?: string;
source: string;
description?: string;
location?: {
rating: number;
location: {
lat: number;
lng: number;
};
openingHours?: string[];
services?: string[];
reviewCount?: number;
hours?: string[];
}
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,61 +1,148 @@
import express from 'express';
import { SearchService } from '../lib/services/searchService';
import { Business } from '../lib/types';
const router = express.Router();
const searchService = new SearchService();
// Error handling middleware for JSON parsing errors
router.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof SyntaxError && 'body' in err) {
return res.status(400).json({ error: 'Invalid JSON' });
}
next();
router.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof SyntaxError && 'body' in err) {
return res.status(400).json({
success: false,
error: 'Invalid JSON'
});
}
next();
});
// Search endpoint
router.post('/search', async (req, res) => {
try {
// Business categories endpoint
router.get('/categories', (req, res) => {
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;
if (!query || !location) {
return res.status(400).json({
error: 'Query and location are required'
});
return res.status(400).json({
success: false,
error: 'Query and location are required'
});
}
const results = await searchService.search(query, location);
res.json({ results });
} catch (error: any) {
if (error.response?.status === 429) {
return res.status(429).json({
error: 'Rate limit exceeded'
});
}
// Set headers for SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.status(500).json({
error: error.message || 'Internal server error'
// 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();
});
}
});
// Get business details endpoint
router.get('/business/:id', async (req, res) => {
try {
const { id } = req.params;
const business = await searchService.getBusinessById(id);
if (!business) {
return res.status(404).json({
error: 'Business not found'
});
// 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'
});
}
res.json(business);
} catch (error: any) {
res.status(500).json({
error: error.message || 'Internal server error'
});
}
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({
success: false,
error: errorMessage
});
}
});
// Get business by ID
router.get('/business/:id', async (req, res) => {
const { id } = req.params;
try {
const business = await searchService.getBusinessById(id);
if (!business) {
return res.status(404).json({
success: false,
error: 'Business not found'
});
}
res.json({
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({
success: false,
error: errorMessage
});
}
});
export default router;

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 logger from '../utils/logger';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { Embeddings } from '@langchain/core/embeddings';
import { ChatOpenAI } from '@langchain/openai';
import {
getAvailableChatModelProviders,
getAvailableEmbeddingModelProviders,
} from '../lib/providers';
import { searchHandlers } from '../websocket/messageHandler';
import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages';
import { MetaSearchAgentType } from '../search/metaSearchAgent';
import { Router, Response as ExpressResponse } from 'express';
import { z } from 'zod';
import fetch from 'node-fetch';
import { Response as FetchResponse } from 'node-fetch';
import { supabase } from '../lib/supabase';
import { env } from '../config/env';
const router = express.Router();
const router = Router();
interface chatModel {
provider: string;
model: string;
customOpenAIBaseURL?: string;
customOpenAIKey?: string;
const searchSchema = z.object({
query: z.string().min(1),
});
interface Business {
id: string;
name: string;
description: string;
website: string;
phone: string | null;
address: string | null;
}
interface embeddingModel {
provider: string;
model: string;
interface SearxResult {
url: string;
title: string;
content: string;
engine: string;
score: number;
}
interface ChatRequestBody {
optimizationMode: 'speed' | 'balanced';
focusMode: string;
chatModel?: chatModel;
embeddingModel?: embeddingModel;
interface SearxResponse {
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 {
const body: ChatRequestBody = req.body;
const params = new URLSearchParams({
q: `${query} denver business`,
format: 'json',
language: 'en',
time_range: '',
safesearch: '1',
engines: 'google,bing,duckduckgo'
});
if (!body.focusMode || !body.query) {
return res.status(400).json({ message: 'Missing focus mode or query' });
}
const searchUrl = `${env.SEARXNG_URL}/search?${params.toString()}`;
console.log('Searching SearxNG at URL:', searchUrl);
body.history = body.history || [];
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 response: FetchResponse = await fetch(searchUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
}
});
const [chatModelProviders, embeddingModelProviders] = await Promise.all([
getAvailableChatModelProviders(),
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({
modelName: body.chatModel.model,
openAIApiKey: body.chatModel.customOpenAIKey,
temperature: 0.7,
configuration: {
baseURL: body.chatModel.customOpenAIBaseURL,
},
}) as unknown as BaseChatModel;
} else if (
chatModelProviders[chatModelProvider] &&
chatModelProviders[chatModelProvider][chatModel]
) {
llm = chatModelProviders[chatModelProvider][chatModel]
.model as unknown as BaseChatModel | undefined;
if (!response.ok) {
throw new Error(`SearxNG search failed: ${response.statusText} (${response.status})`);
}
if (
embeddingModelProviders[embeddingModelProvider] &&
embeddingModelProviders[embeddingModelProvider][embeddingModel]
) {
embeddings = embeddingModelProviders[embeddingModelProvider][
embeddingModel
].model as Embeddings | undefined;
const data = await response.json() as SearxResponse;
console.log(`Got ${data.results?.length || 0} raw results from SearxNG`);
console.log('Sample result:', data.results?.[0]);
if (!data.results || data.results.length === 0) {
return [];
}
if (!llm || !embeddings) {
return res.status(400).json({ message: 'Invalid model selected' });
}
const filteredResults = data.results
.filter(result =>
result.title &&
result.url &&
!result.url.includes('yelp.com/search') &&
!result.url.includes('google.com/search') &&
!result.url.includes('bbb.org/search') &&
!result.url.includes('thumbtack.com/search') &&
!result.url.includes('angi.com/search') &&
!result.url.includes('yellowpages.com/search')
);
const searchHandler: MetaSearchAgentType = searchHandlers[body.focusMode];
console.log(`Filtered to ${filteredResults.length} relevant results`);
console.log('Sample filtered result:', filteredResults[0]);
if (!searchHandler) {
return res.status(400).json({ message: 'Invalid focus mode' });
}
const searchTerms = query.toLowerCase().split(' ');
const businesses = filteredResults
.map(result => {
const business = {
id: result.url,
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);
const emitter = await searchHandler.searchAndAnswer(
body.query,
history,
llm,
embeddings,
body.optimizationMode,
[],
);
console.log(`Transformed into ${businesses.length} business entries`);
return businesses;
} catch (error) {
console.error('SearxNG search error:', error);
return [];
}
}
let message = '';
let sources = [];
async function cacheResults(query: string, results: Business[]): Promise<void> {
if (!results.length) return;
emitter.on('data', (data) => {
const parsedData = JSON.parse(data);
if (parsedData.type === 'response') {
message += parsedData.data;
} else if (parsedData.type === 'sources') {
sources = parsedData.data;
}
});
console.log(`Caching ${results.length} results for query:`, query);
const normalizedQuery = query.toLowerCase().trim();
const { data: existing } = await supabase
.from('search_cache')
.select('id, results')
.eq('query', normalizedQuery)
.single();
emitter.on('end', () => {
res.status(200).json({ message, sources });
});
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());
emitter.on('error', (data) => {
const parsedData = JSON.parse(data);
res.status(500).json({ message: parsedData.data });
});
} catch (err: any) {
logger.error(`Error in getting search results: ${err.message}`);
res.status(500).json({ message: 'An error has occurred.' });
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
});
}
}
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;

View file

@ -1,202 +1,43 @@
import { DeepSeekService } from '../lib/services/deepseekService';
import { Business } from '../lib/types';
import axios from 'axios';
import dotenv from 'dotenv';
async function testOllamaConnection() {
console.log('🔍 Testing Ollama connection...\n');
dotenv.config();
async function testDeepseekService() {
const service = new DeepSeekService();
try {
// Test simple connection
console.log('Testing Qwen model...');
const response = await DeepSeekService['chat']([{
role: 'user',
content: 'Say "Hello, testing Qwen model!"'
}]);
console.log('Starting DeepSeek test...');
console.log('Base URL:', process.env.OLLAMA_URL || 'http://localhost:11434');
const testQuery = {
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) {
console.error('\nTest failed!');
if (error instanceof Error) {
console.error('❌ Connection test failed:', error.message);
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNREFUSED') {
console.error('❌ Make sure Ollama is running (ollama serve)');
} else {
console.error('API Error details:', error.response?.data);
}
}
console.error('Error message:', error.message);
console.error('Stack trace:', error.stack);
} 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');
console.error('Unknown error:', error);
}
}
}
async function runTests() {
console.log('🚀 Starting Qwen model tests...\n');
const connectionSuccess = await testOllamaConnection();
if (!connectionSuccess) {
console.log('❌ Stopping tests due to connection failure');
return;
}
await testDataCleaning();
}
// Run tests if this file is executed directly
if (require.main === module) {
runTests().catch(console.error);
}
// Run the test
console.log('=== Starting DeepSeek Service Test ===\n');
testDeepseekService().then(() => {
console.log('\n=== Test Complete ===');
}).catch(error => {
console.error('\n=== Test Failed ===');
console.error(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);
});