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:
parent
79f26fce25
commit
9f4ae1baac
15 changed files with 1501 additions and 1348 deletions
|
@ -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();
|
||||
}
|
|
@ -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() : '';
|
||||
}
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue