mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 00:19:39 +02:00
feat(citycorners): add web lookup for new locations via mana-search
Backend: GET /locations/lookup?q= endpoint that searches via mana-search, extracts content from top results, auto-detects address and category, returns pre-filled data with source links. Frontend: /add page now has a two-step flow: 1. Search step: user enters a place name, backend scrapes the web 2. Edit step: form pre-filled with found data (name, description, address, category), user can review/edit before submitting. Shows source links. Also fixed all API paths to use /api/v1/ prefix via centralized api() helper. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
416e031f69
commit
0f93496364
7 changed files with 323 additions and 10 deletions
|
|
@ -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<string>('MANA_SEARCH_URL') || 'http://localhost:3021';
|
||||
}
|
||||
|
||||
async lookup(query: string): Promise<LookupResult | null> {
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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<string>('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')}
|
||||
</a>
|
||||
</div>
|
||||
{:else if !lookupDone}
|
||||
<!-- Step 1: Search for the location online -->
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-xl border border-border bg-background-card p-5">
|
||||
<h2 class="mb-1 text-lg font-semibold text-foreground">{$_('add.searchTitle')}</h2>
|
||||
<p class="mb-4 text-sm text-foreground-secondary">{$_('add.searchSubtitle')}</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder={$_('add.searchPlaceholder')}
|
||||
class="flex-1 rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleLookup()}
|
||||
/>
|
||||
<button
|
||||
onclick={handleLookup}
|
||||
disabled={!searchQuery.trim() || searching}
|
||||
class="rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if searching}
|
||||
<div
|
||||
class="h-5 w-5 border-2 border-white border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
{:else}
|
||||
{$_('add.searchButton')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={handleSkipLookup}
|
||||
class="w-full text-center text-sm text-foreground-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
{$_('add.skipSearch')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Step 2: Edit and submit -->
|
||||
{#if sources.length > 0}
|
||||
<div class="mb-5 rounded-lg bg-primary/5 border border-primary/20 p-3">
|
||||
<p class="mb-2 text-xs font-medium text-primary">{$_('add.foundSources')}</p>
|
||||
<div class="space-y-1">
|
||||
{#each sources.slice(0, 3) as source}
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block truncate text-xs text-foreground-secondary hover:text-primary"
|
||||
>
|
||||
{source.title}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -153,12 +266,21 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || submitting}
|
||||
class="w-full rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{submitting ? $_('add.submitting') : $_('add.submit')}
|
||||
</button>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleReset}
|
||||
class="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
|
||||
>
|
||||
{$_('add.reset')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || submitting}
|
||||
class="flex-1 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{submitting ? $_('add.submitting') : $_('add.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue