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,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);
}
}