diff --git a/apps/citycorners/apps/backend/src/location/location-lookup.service.ts b/apps/citycorners/apps/backend/src/location/location-lookup.service.ts new file mode 100644 index 000000000..37a22e267 --- /dev/null +++ b/apps/citycorners/apps/backend/src/location/location-lookup.service.ts @@ -0,0 +1,163 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface LookupResult { + name: string; + description: string; + address?: string; + category?: string; + sources: { url: string; title: string }[]; +} + +@Injectable() +export class LocationLookupService { + private readonly logger = new Logger(LocationLookupService.name); + private readonly searchUrl: string; + + constructor(private readonly configService: ConfigService) { + this.searchUrl = this.configService.get('MANA_SEARCH_URL') || 'http://localhost:3021'; + } + + async lookup(query: string): Promise { + const searchQuery = `${query} Konstanz`; + + try { + // Search for the location + const searchRes = await fetch(`${this.searchUrl}/api/v1/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: searchQuery, + options: { categories: ['general'], language: 'de-DE', limit: 5 }, + }), + signal: AbortSignal.timeout(15000), + }); + + if (!searchRes.ok) { + this.logger.warn(`Search failed: ${searchRes.status}`); + return null; + } + + const searchData = await searchRes.json(); + const results = searchData.results || []; + + if (results.length === 0) return null; + + // Extract content from top 3 results + const topUrls = results.slice(0, 3).map((r: any) => r.url); + let extractedTexts: string[] = []; + + try { + const extractRes = await fetch(`${this.searchUrl}/api/v1/extract/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + urls: topUrls, + options: { includeMarkdown: false, maxLength: 5000 }, + concurrency: 3, + }), + signal: AbortSignal.timeout(20000), + }); + + if (extractRes.ok) { + const extractData = await extractRes.json(); + extractedTexts = (extractData.results || []) + .filter((r: any) => r.success && r.content?.text) + .map((r: any) => r.content.text.substring(0, 2000)); + } + } catch (err) { + this.logger.warn('Bulk extract failed, using search snippets', err); + } + + // Combine search snippets + extracted text for the description + const snippets = results.map((r: any) => r.snippet).filter(Boolean); + const allText = [...extractedTexts, ...snippets].join('\n\n'); + + // Try to detect address from text + const address = this.extractAddress(allText); + + // Try to guess category + const category = this.guessCategory(query, allText); + + // Build a description from the best snippet or extracted text + const description = this.buildDescription(snippets, extractedTexts); + + return { + name: query, + description, + address, + category, + sources: results.slice(0, 5).map((r: any) => ({ + url: r.url, + title: r.title, + })), + }; + } catch (err) { + this.logger.error('Lookup failed', err); + return null; + } + } + + private extractAddress(text: string): string | undefined { + // Look for German address patterns (street + number + PLZ + city) + const addressPattern = + /(\b[A-ZÄÖÜ][a-zäöüß]+(?:straße|gasse|weg|platz|allee|ring)\s+\d+[\w]*,?\s*\d{5}\s+\w+)/i; + const match = text.match(addressPattern); + if (match) return match[1]; + + // Simpler: just street + number in Konstanz + const simplePattern = + /(\b[A-ZÄÖÜ][a-zäöüß]+(?:straße|gasse|weg|platz|allee|ring)\s+\d+[\w-]*)/i; + const simpleMatch = text.match(simplePattern); + if (simpleMatch) return `${simpleMatch[1]}, 78462 Konstanz`; + + return undefined; + } + + private guessCategory(query: string, text: string): string { + const lowerQuery = query.toLowerCase(); + const lowerText = text.toLowerCase(); + + if ( + /restaurant|essen|küche|dining|speise|bistro|gasth/i.test( + lowerQuery + ' ' + lowerText.substring(0, 500) + ) + ) { + return 'restaurant'; + } + if ( + /museum|ausstellung|galerie|sammlung/i.test(lowerQuery + ' ' + lowerText.substring(0, 500)) + ) { + return 'museum'; + } + if ( + /laden|shop|geschäft|boutique|markt|einkauf|shopping/i.test( + lowerQuery + ' ' + lowerText.substring(0, 500) + ) + ) { + return 'shop'; + } + return 'sight'; + } + + private buildDescription(snippets: string[], extractedTexts: string[]): string { + // Prefer extracted text (more detailed) + if (extractedTexts.length > 0) { + const text = extractedTexts[0]; + // Take first meaningful paragraph (at least 50 chars) + const paragraphs = text.split(/\n\n+/).filter((p) => p.trim().length > 50); + if (paragraphs.length > 0) { + const desc = paragraphs[0].trim(); + return desc.length > 300 ? desc.substring(0, 297) + '...' : desc; + } + } + + // Fall back to search snippets + if (snippets.length > 0) { + const combined = snippets.slice(0, 2).join(' '); + return combined.length > 300 ? combined.substring(0, 297) + '...' : combined; + } + + return ''; + } +} diff --git a/apps/citycorners/apps/backend/src/location/location.controller.ts b/apps/citycorners/apps/backend/src/location/location.controller.ts index 768e06c70..6d197b4bb 100644 --- a/apps/citycorners/apps/backend/src/location/location.controller.ts +++ b/apps/citycorners/apps/backend/src/location/location.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { LocationService } from './location.service'; +import { LocationLookupService } from './location-lookup.service'; import { IsString, IsNotEmpty, IsOptional, IsNumber } from 'class-validator'; import { Type } from 'class-transformer'; @@ -70,7 +71,10 @@ class UpdateLocationDto { @Controller('locations') export class LocationController { - constructor(private readonly locationService: LocationService) {} + constructor( + private readonly locationService: LocationService, + private readonly lookupService: LocationLookupService + ) {} @Get() async findAll(@Query('category') category?: string) { @@ -78,6 +82,15 @@ export class LocationController { return { locations }; } + @Get('lookup') + async lookup(@Query('q') query: string) { + if (!query || query.trim().length === 0) { + return { result: null }; + } + const result = await this.lookupService.lookup(query.trim()); + return { result }; + } + @Get('search') async search(@Query('q') query: string) { if (!query || query.trim().length === 0) { diff --git a/apps/citycorners/apps/backend/src/location/location.module.ts b/apps/citycorners/apps/backend/src/location/location.module.ts index e285ce9a6..08c065c8f 100644 --- a/apps/citycorners/apps/backend/src/location/location.module.ts +++ b/apps/citycorners/apps/backend/src/location/location.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { LocationController } from './location.controller'; import { LocationService } from './location.service'; +import { LocationLookupService } from './location-lookup.service'; @Module({ controllers: [LocationController], - providers: [LocationService], + providers: [LocationService, LocationLookupService], exports: [LocationService], }) export class LocationModule {} diff --git a/apps/citycorners/apps/web/src/lib/i18n/locales/de.json b/apps/citycorners/apps/web/src/lib/i18n/locales/de.json index a441cd64d..7e9f5b0f3 100644 --- a/apps/citycorners/apps/web/src/lib/i18n/locales/de.json +++ b/apps/citycorners/apps/web/src/lib/i18n/locales/de.json @@ -86,6 +86,13 @@ "minChars": "Mindestens 10 Zeichen", "address": "Adresse (optional)", "addressPlaceholder": "z.B. Seestraße 1, 78462 Konstanz", + "searchTitle": "Ort im Web suchen", + "searchSubtitle": "Wir suchen automatisch nach Infos und füllen das Formular vor.", + "searchPlaceholder": "z.B. Café Zeitlos Konstanz", + "searchButton": "Suchen", + "skipSearch": "Überspringen und manuell eintragen", + "foundSources": "Quellen gefunden:", + "reset": "Zurück", "submit": "Ort einreichen", "submitting": "Wird eingereicht...", "loginRequired": "Melde dich an, um Orte hinzuzufügen.", diff --git a/apps/citycorners/apps/web/src/lib/i18n/locales/en.json b/apps/citycorners/apps/web/src/lib/i18n/locales/en.json index 157ec6e2a..352b0a2a7 100644 --- a/apps/citycorners/apps/web/src/lib/i18n/locales/en.json +++ b/apps/citycorners/apps/web/src/lib/i18n/locales/en.json @@ -86,6 +86,13 @@ "minChars": "At least 10 characters", "address": "Address (optional)", "addressPlaceholder": "e.g. Seestraße 1, 78462 Konstanz", + "searchTitle": "Search for a place online", + "searchSubtitle": "We'll automatically find info and pre-fill the form for you.", + "searchPlaceholder": "e.g. Café Zeitlos Konstanz", + "searchButton": "Search", + "skipSearch": "Skip and enter manually", + "foundSources": "Sources found:", + "reset": "Back", "submit": "Submit place", "submitting": "Submitting...", "loginRequired": "Sign in to add places.", diff --git a/apps/citycorners/apps/web/src/routes/(app)/add/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/add/+page.svelte index c831fab49..381e700e1 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/add/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/add/+page.svelte @@ -4,6 +4,13 @@ import { authStore } from '$lib/stores/auth.svelte'; import { api } from '$lib/api'; + // Lookup state + let searchQuery = $state(''); + let searching = $state(false); + let lookupDone = $state(false); + let sources = $state<{ url: string; title: string }[]>([]); + + // Form state let name = $state(''); let category = $state('sight'); let description = $state(''); @@ -20,6 +27,55 @@ let isValid = $derived(name.trim().length > 0 && description.trim().length > 10); + async function handleLookup() { + if (!searchQuery.trim() || searching) return; + + searching = true; + error = ''; + sources = []; + + try { + const res = await fetch(api(`/locations/lookup?q=${encodeURIComponent(searchQuery.trim())}`)); + if (!res.ok) throw new Error('Lookup failed'); + + const data = await res.json(); + + if (data.result) { + name = data.result.name || searchQuery.trim(); + description = data.result.description || ''; + address = data.result.address || ''; + category = data.result.category || 'sight'; + sources = data.result.sources || []; + } else { + name = searchQuery.trim(); + } + + lookupDone = true; + } catch { + // Fallback: just use the search query as name + name = searchQuery.trim(); + lookupDone = true; + } finally { + searching = false; + } + } + + function handleSkipLookup() { + name = searchQuery.trim(); + lookupDone = true; + } + + function handleReset() { + lookupDone = false; + searchQuery = ''; + name = ''; + description = ''; + address = ''; + category = 'sight'; + sources = []; + error = ''; + } + async function handleSubmit() { if (!isValid || submitting) return; @@ -82,7 +138,64 @@ {$_('settings.login')} +{:else if !lookupDone} + +
+
+

{$_('add.searchTitle')}

+

{$_('add.searchSubtitle')}

+ +
+ e.key === 'Enter' && handleLookup()} + /> + +
+
+ + +
{:else} + + {#if sources.length > 0} +
+

{$_('add.foundSources')}

+
+ {#each sources.slice(0, 3) as source} + + {source.title} + + {/each} +
+
+ {/if} +
{ e.preventDefault(); @@ -153,12 +266,21 @@ /> - +
+ + +
{/if} diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index fad0a2446..e84031e38 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -732,9 +732,9 @@ services: DB_PORT: 5432 DB_USER: postgres MANA_CORE_AUTH_URL: http://mana-auth:3001 + MANA_SEARCH_URL: http://mana-search:3020 CORS_ORIGINS: https://citycorners.mana.how,https://mana.how ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} - SEED_ON_START: "true" ports: - "3041:3041" healthcheck: