Add developer tools page and secure guest scans

This commit is contained in:
2026-08-02 00:10:51 +02:00
parent c9e776ae98
commit c79e7b59ff
13 changed files with 328 additions and 33 deletions

11
.gitignore vendored
View File

@@ -43,6 +43,17 @@ tmp/
ads/
.codex-temp/
# Local/generated exports
greenlens-chrome-extension.zip
create_slot02.py
assets/blogs/
# Standalone projects, versioned and published independently
greenlens-cli/
greenlens-python/
greenlens-vscode/
greenlens-chrome-extension/
# Claude / Agents (symlinks incompatible with EAS Build on Windows)
.agents/
.claude/

View File

@@ -0,0 +1,168 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import SeoFooter from '@/components/seo/SeoFooter'
import SeoNavbar from '@/components/seo/SeoNavbar'
import { siteConfig } from '@/lib/site'
const repositoryUrl = 'https://github.com/knuthtimo-lab/greenlenspro-cli'
export const metadata: Metadata = {
title: 'Developer Suite & Open Source Tools',
description: 'Official GreenLens developer tools: VS Code extension, Python SDK and CLI, Node.js CLI, Chrome extension, and GitHub Action.',
alternates: { canonical: '/developers' },
openGraph: {
title: 'Developer Suite & Open Source Tools | GreenLens',
description: 'Open-source GreenLens tools for VS Code, Python, Node.js, Chrome, and GitHub Actions.',
url: `${siteConfig.domain}/developers`,
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'Developer Suite & Open Source Tools | GreenLens',
description: 'Open-source GreenLens tools for VS Code, Python, Node.js, Chrome, and GitHub Actions.',
},
}
const tools = [
{
icon: '💻',
eyebrow: 'VS Code Marketplace',
title: 'VS Code extension',
description: 'Search plant care, diagnose leaf symptoms, and set hydration-break timers without leaving VS Code.',
command: 'Ctrl+Shift+P → GreenLens: Search Houseplant Care',
href: 'https://marketplace.visualstudio.com/items?itemName=TimoKnuth.greenlens-vscode',
linkLabel: 'VS Code Marketplace',
},
{
icon: '🐍',
eyebrow: 'Python SDK & CLI',
title: 'Python SDK and CLI',
description: 'Use the greenlens-python package in applications or call greenlens-py directly for care guides, light evaluations, and symptom diagnostics.',
command: 'pip install greenlens-python\ngreenlens-py care "Monstera"',
href: 'https://pypi.org/project/greenlens-python/',
linkLabel: 'Python SDK & CLI on PyPI',
},
{
icon: '📦',
eyebrow: 'NPM Package',
title: 'Node.js CLI and package',
description: 'A zero-dependency terminal utility and Node module for rapid plant-care lookup in any shell or application.',
command: 'npx greenlenspro-cli care "Monstera"',
href: 'https://www.npmjs.com/package/greenlenspro-cli',
linkLabel: 'Node.js package on NPM',
},
{
icon: '🧩',
eyebrow: 'Chrome Web Store',
title: 'Chrome extension (Soon)',
description: 'Search a local plant catalog, check leaf symptoms, or select a plant photo from the current browser page for AI analysis.',
command: 'Browser popup + photo scan tool',
href: 'https://greenlenspro.com/support',
linkLabel: 'Chrome extension details (Soon)',
},
{
icon: '🐙',
eyebrow: 'GitHub CI/CD',
title: 'GitHub Action',
description: 'Run plant-care and diagnosis lookups in CI/CD workflows and print results directly in build output.',
command: 'uses: knuthtimo-lab/greenlenspro-cli@v1',
href: `${repositoryUrl}#github-action`,
linkLabel: 'View GitHub Action',
},
{
icon: '📁',
eyebrow: 'Open source',
title: 'GitHub repository',
description: 'Browse the complete source code, releases, installation notes, and contribution context for greenlenspro-cli.',
command: 'github.com/knuthtimo-lab/greenlenspro-cli',
href: repositoryUrl,
linkLabel: 'Open GitHub repository',
},
]
export default function DevelopersPage() {
return (
<div className="min-h-screen bg-background text-ink font-body">
<SeoNavbar locale="de" methodologyHref="#methodology" showDevelopers />
<main>
<section className="pt-36 pb-20 md:pt-44 md:pb-28" id="advantage">
<div className="max-w-[1200px] mx-auto px-6 grid lg:grid-cols-[minmax(0,1.05fr)_minmax(360px,0.75fr)] gap-14 lg:gap-20 items-center">
<div>
<p className="text-xs font-bold tracking-[0.18em] uppercase text-primary mb-6">Developer suite &amp; open source</p>
<h1 className="font-display text-5xl md:text-6xl lg:text-7xl font-bold leading-[0.98] tracking-tight text-ink max-w-3xl">
Plant care tools for the stack you already use.
</h1>
<p className="mt-7 max-w-2xl text-base md:text-lg leading-relaxed text-on-surface-variant">
Bring GreenLens care guidance, symptom checks, and lighting evaluations into VS Code, Python, Node.js, your browser, or a GitHub workflow.
</p>
<div className="mt-9 flex flex-wrap gap-4">
<a href={repositoryUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center rounded-full bg-ink px-6 py-3 text-sm font-bold text-white transition-colors hover:bg-primary">
Explore on GitHub <span aria-hidden="true" className="ml-2"></span>
</a>
<Link href="/support" className="inline-flex items-center rounded-full border border-ink px-6 py-3 text-sm font-bold text-ink transition-colors hover:bg-surface-container">
Developer support
</Link>
</div>
</div>
<aside className="rounded-[2rem] bg-ink p-7 md:p-9 text-white shadow-[0_24px_60px_-30px_rgba(1,38,31,0.6)]" aria-label="GitHub repository overview">
<div className="flex items-center justify-between border-b border-white/15 pb-5">
<span className="text-sm font-bold tracking-wide">greenlenspro-cli</span>
<span className="text-[10px] font-bold tracking-[0.14em] uppercase text-primary-fixed">Open source</span>
</div>
<div className="py-7 font-mono text-sm leading-8 text-white/80">
<p><span className="text-primary-fixed">$</span> npx greenlenspro-cli care "Monstera"</p>
<p className="mt-5 text-white/55"># GitHub Actions</p>
<p><span className="text-primary-fixed">uses:</span> knuthtimo-lab/greenlenspro-cli@v1</p>
</div>
<a href={repositoryUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center text-sm font-bold text-primary-fixed hover:text-white transition-colors">
View repository <span aria-hidden="true" className="ml-2"></span>
</a>
</aside>
</div>
</section>
<section className="border-y border-outline-variant/60 bg-surface-container-low py-20 md:py-28" id="methodology">
<div className="max-w-[1200px] mx-auto px-6">
<div className="max-w-2xl mb-12 md:mb-16">
<p className="text-xs font-bold tracking-[0.18em] uppercase text-primary mb-5">Tools &amp; integrations</p>
<h2 className="font-display text-4xl md:text-5xl font-bold leading-tight text-ink">One plant-care toolkit, six practical entry points.</h2>
</div>
<div className="grid gap-5 md:grid-cols-2">
{tools.map((tool) => (
<article key={tool.title} className="flex flex-col rounded-3xl bg-white p-7 md:p-8 border border-outline-variant/70 transition-transform duration-300 hover:-translate-y-1 hover:shadow-[0_18px_40px_-28px_rgba(1,38,31,0.38)]">
<div className="flex items-start justify-between gap-5">
<span className="text-3xl leading-none" aria-hidden="true">{tool.icon}</span>
<span className="text-right text-[10px] font-bold tracking-[0.15em] uppercase text-primary">{tool.eyebrow}</span>
</div>
<h3 className="mt-8 font-display text-3xl font-bold leading-tight text-ink">{tool.title}</h3>
<p className="mt-4 max-w-[60ch] text-sm leading-7 text-on-surface-variant">{tool.description}</p>
<code className="mt-6 block whitespace-pre-line rounded-xl bg-ink px-4 py-3 font-mono text-xs leading-6 text-primary-fixed">{tool.command}</code>
<a href={tool.href} target="_blank" rel="noopener noreferrer" className="mt-7 inline-flex w-fit items-center text-sm font-bold text-primary hover:text-ink transition-colors">
{tool.linkLabel} <span aria-hidden="true" className="ml-2"></span>
</a>
</article>
))}
</div>
</div>
</section>
<section className="py-20 md:py-28" id="faq">
<div className="max-w-[1200px] mx-auto px-6 grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end">
<div>
<p className="text-xs font-bold tracking-[0.18em] uppercase text-primary mb-5">Need the full app?</p>
<h2 className="font-display text-4xl md:text-5xl font-bold leading-tight text-ink max-w-2xl">Use GreenLens on your phone for camera-based plant scans and ongoing care.</h2>
</div>
<a href={siteConfig.iosAppStoreUrl} target="_blank" rel="noopener noreferrer" className="inline-flex w-fit items-center rounded-full bg-ink px-6 py-3 text-sm font-bold text-white transition-colors hover:bg-primary">
Download on the App Store <span aria-hidden="true" className="ml-2"></span>
</a>
</div>
</section>
</main>
<SeoFooter locale="de" />
</div>
)
}

View File

@@ -291,6 +291,11 @@ h3 {
color: var(--cream);
}
.nav-links .nav-link--active {
color: var(--cream);
font-weight: 700;
}
.nav-cta {
background: #fff !important;
color: #162118 !important;

View File

@@ -24,6 +24,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/developers`,
lastModified: new Date('2026-08-01'),
changeFrequency: 'weekly',
priority: 0.85,
},
{
url: `${baseUrl}/press`,
lastModified: new Date('2026-07-29'),

View File

@@ -82,6 +82,16 @@ export default function Footer() {
<Link href="/es/diagnosticar-enfermedades-plantas">Diagnosticar enfermedades</Link>
<Link href="/es/comparar/google-lens">GreenLens vs Google Lens</Link>
</div>
<div className="footer-col">
<div className="footer-col-title">Developer &amp; Open Source</div>
<Link href="/developers" style={{ fontWeight: 'bold', color: '#00c853' }}>Developer Suite Hub</Link>
<a href="https://marketplace.visualstudio.com/items?itemName=TimoKnuth.greenlens-vscode" target="_blank" rel="noopener noreferrer">VS Code Extension</a>
<a href="https://pypi.org/project/greenlens-python/" target="_blank" rel="noopener noreferrer">Python SDK &amp; CLI (PyPI)</a>
<a href="https://www.npmjs.com/package/greenlenspro-cli" target="_blank" rel="noopener noreferrer">Node.js CLI (NPM)</a>
<Link href="/developers">Chrome Extension (Soon)</Link>
<a href="https://github.com/knuthtimo-lab/greenlenspro-cli" target="_blank" rel="noopener noreferrer">GitHub Action &amp; Repository</a>
</div>
</div>
<div className="footer-brand-xl" aria-hidden="true">GREENLENS</div>

View File

@@ -13,6 +13,8 @@ export default function Navbar() {
const { lang, t } = useLang()
const localeHome = lang === 'de' ? '/' : `/${lang}`
const homeHref = (hash: string) => (pathname === localeHome ? hash : `${localeHome}${hash}`)
const isSubpage = pathname !== localeHome
const isDevelopersPage = pathname === '/developers'
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 40)
@@ -21,7 +23,7 @@ export default function Navbar() {
}, [])
return (
<nav className={`navbar${scrolled ? ' scrolled' : ''}`} id="navbar" role="navigation" aria-label="Main navigation">
<nav className={`navbar${scrolled || isSubpage ? ' scrolled' : ''}`} id="navbar" role="navigation" aria-label="Main navigation">
<div className="container">
<Link href={localeHome} className="nav-logo" aria-label="GreenLens Home">
GREENLENS
@@ -32,6 +34,7 @@ export default function Navbar() {
<a href={homeHref('#intelligence')} onClick={() => setMenuOpen(false)}>{t.nav.tech}</a>
<a href={homeHref('#faq')} onClick={() => setMenuOpen(false)}>FAQ</a>
<a href={homeHref('#how')} onClick={() => setMenuOpen(false)}>{t.nav.how}</a>
<Link href="/developers" className={isDevelopersPage ? 'nav-link--active' : undefined} onClick={() => setMenuOpen(false)}>Developers</Link>
<Link href="/support" onClick={() => setMenuOpen(false)}>Support</Link>
<a href={homeHref('#cta')} onClick={() => setMenuOpen(false)}>{t.nav.download}</a>

View File

@@ -63,8 +63,8 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
: copy.useCasesTitle
return (
<footer className="bg-ink-deep text-white pt-24 pb-8 font-body overflow-hidden" id="download">
<div className="max-w-[1200px] mx-auto px-6">
<footer className="bg-ink-deep text-white pt-28 pb-10 font-body overflow-hidden" id="download">
<div className="max-w-[1280px] mx-auto px-6">
{/* CTA + Bild */}
<div className="flex flex-col lg:flex-row gap-12 mb-24 items-center">
<div className="flex-1">
@@ -101,7 +101,7 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
</a>
)}
</div>
<p className="text-xs text-white/60">
<p className="text-sm text-white/60">
{copy.storeNote}{' '}
<Link href="/support" className="underline hover:text-white">
{copy.supportLabel}
@@ -116,14 +116,14 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
</div>
{/* Marken- und Legal-Spalten */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12 mb-16 border-t border-white/10 pt-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-14 mb-20 border-t border-white/10 pt-14">
<div>
<h3 className="font-display font-bold text-xl tracking-widest mb-4">GREENLENS</h3>
<p className="text-xs text-white/70 leading-relaxed max-w-xs">{copy.brandBody}</p>
<h3 className="font-display font-bold text-2xl tracking-widest mb-5">GREENLENS</h3>
<p className="text-sm text-white/70 leading-relaxed max-w-xs">{copy.brandBody}</p>
</div>
<div>
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.productTitle}</h4>
<ul className="space-y-4 text-xs text-white/80">
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.productTitle}</h4>
<ul className="space-y-4 text-sm text-white/80">
<li><a href="#advantage" className="hover:text-white">{seoTemplateCopy[locale].nav.features}</a></li>
{showMethodology && <li><a href="#methodology" className="hover:text-white">{seoTemplateCopy[locale].nav.tech}</a></li>}
<li><a href="#download" className="hover:text-white">{seoTemplateCopy[locale].nav.download}</a></li>
@@ -131,16 +131,16 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
</ul>
</div>
<div>
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.companyTitle}</h4>
<ul className="space-y-4 text-xs text-white/80">
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.companyTitle}</h4>
<ul className="space-y-4 text-sm text-white/80">
{showMethodology && <li><a href="#methodology" className="hover:text-white">{seoTemplateCopy[locale].nav.how}</a></li>}
<li><a href="#faq" className="hover:text-white">FAQ</a></li>
<li><Link href="/support" className="hover:text-white">{copy.supportLabel}</Link></li>
</ul>
</div>
<div>
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.legalTitle}</h4>
<ul className="space-y-4 text-xs text-white/80">
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.legalTitle}</h4>
<ul className="space-y-4 text-sm text-white/80">
<li><Link href="/imprint" className="hover:text-white">{copy.imprint}</Link></li>
<li><Link href="/privacy" className="hover:text-white">{copy.privacy}</Link></li>
<li><Link href="/terms" className="hover:text-white">{copy.terms}</Link></li>
@@ -149,10 +149,10 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
</div>
{/* SEO-Link-Spalten (interne Verlinkung) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-24">
<div className="grid grid-cols-1 md:grid-cols-3 gap-14 mb-28">
<div>
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{primaryTitle}</h4>
<ul className="space-y-3 text-xs text-white/70">
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{primaryTitle}</h4>
<ul className="space-y-3 text-sm leading-6 text-white/70">
{primaryLinks.map((link) => (
<li key={link.href}>
<Link href={link.href} className="hover:text-white">{link.label}</Link>
@@ -161,8 +161,8 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
</ul>
</div>
<div>
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{languageTitles[locale]}</h4>
<ul className="space-y-3 text-xs text-white/70">
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{languageTitles[locale]}</h4>
<ul className="space-y-3 text-sm leading-6 text-white/70">
{languageHubs.map((link) => (
<li key={link.href}>
<Link href={link.href} className="hover:text-white">{link.label}</Link>
@@ -170,6 +170,18 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
))}
</ul>
</div>
<div>
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">Developer &amp; Open Source</h4>
<ul className="space-y-3 text-sm leading-6 text-white/70">
<li><Link href="/developers" className="text-[#00c853] font-bold hover:underline">Developer Suite Hub</Link></li>
<li><a href="https://marketplace.visualstudio.com/items?itemName=TimoKnuth.greenlens-vscode" target="_blank" rel="noopener noreferrer" className="hover:text-white">VS Code Extension</a></li>
<li><a href="https://pypi.org/project/greenlens-python/" target="_blank" rel="noopener noreferrer" className="hover:text-white">Python SDK &amp; CLI (PyPI)</a></li>
<li><a href="https://www.npmjs.com/package/greenlenspro-cli" target="_blank" rel="noopener noreferrer" className="hover:text-white">Node.js CLI (NPM)</a></li>
<li><Link href="/developers" className="hover:text-white">Chrome Extension (Soon)</Link></li>
<li><a href="https://github.com/knuthtimo-lab/greenlenspro-cli#github-action" target="_blank" rel="noopener noreferrer" className="hover:text-white">GitHub Action</a></li>
<li><a href="https://github.com/knuthtimo-lab/greenlenspro-cli" target="_blank" rel="noopener noreferrer" className="hover:text-white">GitHub Repository</a></li>
</ul>
</div>
</div>
{/* Riesiger Schriftzug */}
@@ -179,7 +191,7 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
</div>
</div>
<div className="flex flex-col md:flex-row justify-between items-center text-[10px] text-white/50 border-t border-white/10 pt-8">
<div className="flex flex-col md:flex-row justify-between items-center text-xs text-white/50 border-t border-white/10 pt-8">
<p>{copy.copy}</p>
<div className="mt-4 md:mt-0 opacity-50">
<span className="font-bold tracking-widest">GREENLENS</span>

View File

@@ -11,9 +11,10 @@ import { seoTemplateCopy } from '@/lib/seoTemplateCopy'
interface SeoNavbarProps {
locale: Lang
methodologyHref?: '#methodology' | '#comparison'
showDevelopers?: boolean
}
export default function SeoNavbar({ locale, methodologyHref = '#methodology' }: SeoNavbarProps) {
export default function SeoNavbar({ locale, methodologyHref = '#methodology', showDevelopers = false }: SeoNavbarProps) {
const [menuOpen, setMenuOpen] = useState(false)
const copy = seoTemplateCopy[locale]
@@ -22,6 +23,7 @@ export default function SeoNavbar({ locale, methodologyHref = '#methodology' }:
{ href: methodologyHref, label: copy.nav.tech },
{ href: '#faq', label: copy.nav.faq },
{ href: methodologyHref, label: copy.nav.how },
...(showDevelopers ? [{ href: '/developers', label: 'Developers' }] : []),
{ href: '/support', label: copy.nav.support },
]

View File

@@ -253,6 +253,14 @@
<a href="#">Datenschutz</a>
<a href="#">AGB</a>
</div>
<div class="footer-col">
<h4>Developer Suite</h4>
<a href="https://marketplace.visualstudio.com/items?itemName=TimoKnuth.greenlens-vscode" target="_blank" rel="noopener noreferrer">VS Code Extension</a>
<a href="https://pypi.org/project/greenlens-python/" target="_blank" rel="noopener noreferrer">Python SDK &amp; CLI</a>
<a href="https://www.npmjs.com/package/greenlenspro-cli" target="_blank" rel="noopener noreferrer">Node.js CLI (NPM)</a>
<a href="https://github.com/knuthtimo-lab/greenlenspro-cli" target="_blank" rel="noopener noreferrer">GitHub Action</a>
</div>
</div>
<div class="footer-brand-large">GREENLENS</div>

View File

@@ -4,6 +4,7 @@ const crypto = require('crypto');
const dotenv = require('dotenv');
const express = require('express');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const loadEnvFiles = (filePaths) => {
const mergedFileEnv = {};
@@ -68,7 +69,7 @@ const {
identifyPlant,
isConfigured: isOpenAiConfigured,
} = require('./lib/openai');
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
const { applyCatalogGrounding, enforceEnglishName, normalizeText } = require('./lib/scanGrounding');
const { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview');
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
const { prepareTiktokPhotoUrls } = require('./lib/tiktok-assets');
@@ -82,6 +83,9 @@ const {
} = require('./lib/tiktok');
const app = express();
// Caddy is the only reverse proxy in front of this service (see
// greenlns-landing/Caddyfile), so trust exactly one hop for req.ip / X-Forwarded-For.
app.set('trust proxy', 1);
const port = Number(process.env.PORT || 3000);
const plantsPublicDir = path.join(__dirname, 'public', 'plants');
@@ -756,7 +760,25 @@ app.post('/v1/billing/sync-revenuecat', async (request, response) => {
}
});
app.post('/v1/scan', async (request, response) => {
// The 'Bearer guest' token (see resolveUserId above) grants a free,
// pre-auth demo scan with no credit charge. Without a limit here, that
// path is an unmetered, unauthenticated OpenAI proxy for anyone who
// knows the header — cap it per-IP so it stays a teaser, not a leak.
const guestScanLimiter = rateLimit({
windowMs: 24 * 60 * 60 * 1000,
limit: 3,
standardHeaders: true,
legacyHeaders: false,
skip: (request) => request.header('authorization') !== 'Bearer guest',
message: {
error: {
code: 'GUEST_SCAN_LIMIT',
message: 'Daily free scan limit reached. Sign in or download the app for unlimited scans.',
},
},
});
app.post('/v1/scan', guestScanLimiter, async (request, response) => {
let userId = 'unknown';
let idempotencyKey = null;
let creditsCharged = 0;
@@ -924,6 +946,8 @@ app.post('/v1/scan', async (request, response) => {
modelPath.push('review-skipped-free-plan');
}
result = enforceEnglishName(result, language);
const payload = {
result,
lowConfidence: (result.confidence || 0) < LOW_CONFIDENCE_RESULT_THRESHOLD,

View File

@@ -29,6 +29,17 @@ const GERMAN_COMMON_NAME_HINTS = [
'glueckskastanie',
];
// Most of the catalog's German common names are compound nouns ending in one
// of these — a hardcoded name list alone (GERMAN_COMMON_NAME_HINTS) only
// catches names we've already seen slip through, so this suffix check acts
// as a general-purpose backstop for names we haven't hardcoded.
const GERMAN_NAME_SUFFIXES = [
'blume', 'blueten', 'bluten', 'baum', 'strauch', 'kraut', 'wurz', 'wurzel',
'farn', 'palme', 'pflanze', 'hanf', 'tute', 'veilchen', 'dorn', 'kaktus',
'lilie', 'birke', 'weide', 'ranke', 'stern', 'kette', 'schwanz', 'ohren',
'feige', 'moos', 'zwiebel', 'knolle', 'rohr', 'kirsche',
];
const isLikelyGermanCommonName = (value) => {
const raw = String(value || '').trim();
if (!raw) return false;
@@ -36,11 +47,27 @@ const isLikelyGermanCommonName = (value) => {
const normalized = normalizeText(raw).replace(/[^a-z0-9 ]+/g, ' ');
if (!normalized) return false;
if (/\b(der|die|das|ein|eine)\b/.test(normalized)) return true;
if (/\b(der|die|das|ein|eine|und|mit|fuer)\b/.test(normalized)) return true;
const compact = normalized.replace(/\s+/g, '');
if (GERMAN_NAME_SUFFIXES.some((suffix) => compact.endsWith(suffix))) return true;
return GERMAN_COMMON_NAME_HINTS.some((hint) => normalized.includes(hint));
};
// Last-resort safety net applied to the FINAL result regardless of whether
// it went through catalog grounding — the AI is instructed to never return a
// German name when English was requested, but doesn't always comply, so this
// catches the ones that slip through and falls back to the botanical name
// (which is language-neutral) rather than showing a mismatched German name
// alongside English description/care text.
const enforceEnglishName = (result, language) => {
if (!result || language !== 'en') return result;
if (!isLikelyGermanCommonName(result.name)) return result;
if (!result.botanicalName) return result;
return { ...result, name: result.botanicalName };
};
const isLikelyBotanicalName = (value, botanicalName) => {
const raw = String(value || '').trim();
const botanicalRaw = String(botanicalName || '').trim();
@@ -124,6 +151,7 @@ const applyCatalogGrounding = (aiResult, catalogEntries, language = 'en') => {
module.exports = {
applyCatalogGrounding,
enforceEnglishName,
findCatalogMatch,
isLikelyGermanCommonName,
normalizeText,

View File

@@ -12,6 +12,7 @@
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"express-rate-limit": "^7.5.1",
"minio": "^8.0.5",
"pg": "^8.16.3",
"sharp": "^0.34.5",
@@ -796,6 +797,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -834,6 +836,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fast-xml-builder": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",

View File

@@ -17,6 +17,7 @@
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"express-rate-limit": "^7.5.1",
"minio": "^8.0.5",
"pg": "^8.16.3",
"sharp": "^0.34.5",