From 2e549c0020f2a396100121ffdb2f7c9086e9aca4 Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Sun, 23 Aug 2026 23:01:38 +0530 Subject: [PATCH] feat: add company namer with domain checks Signed-off-by: Arnab Nandy --- README.md | 25 +++++ examples-java/README.md | 25 ++++- .../example/companynamer/CompanyNamer.java | 88 +++++++++++++++++ .../companynamer/DomainAvailability.java | 28 ++++++ .../DomainAvailabilityService.java | 95 +++++++++++++++++++ .../companynamer/CompanyNamerTest.java | 61 ++++++++++++ .../DomainAvailabilityServiceTest.java | 78 +++++++++++++++ 7 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 examples-java/src/main/java/com/embabel/example/companynamer/CompanyNamer.java create mode 100644 examples-java/src/main/java/com/embabel/example/companynamer/DomainAvailability.java create mode 100644 examples-java/src/main/java/com/embabel/example/companynamer/DomainAvailabilityService.java create mode 100644 examples-java/src/test/java/com/embabel/example/companynamer/CompanyNamerTest.java create mode 100644 examples-java/src/test/java/com/embabel/example/companynamer/DomainAvailabilityServiceTest.java diff --git a/README.md b/README.md index 47dace7..3cea4c7 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,31 @@ class StarNewsFinder { --- +### 🌐 **Beginner: Company Namer** + +> **Available in:** Java | **Concept:** Combining generative and deterministic actions + +Suggests company names from a business brief, then checks each matching `.com` domain using Java's DNS resolver. +The check requires no third-party availability API or API key: domains with DNS records are reported as in use, while +domains without DNS records are reported as potentially available and must be confirmed with a registrar. + +**What It Teaches:** + +- Generating structured candidate data with an LLM +- Passing generated data to a deterministic action +- Keeping external checks behind an injected service +- Caching DNS results and representing inconclusive checks honestly + +**Try It:** + +```bash +x "Suggest company names for a sustainable accounting platform" +``` + +**Location:** `examples-java/src/main/java/com/embabel/example/companynamer/` + +--- + ### 🔬 **Expert: Multi-LLM Research Agent** > **Available in:** Java, Kotlin | **Concept:** Self-Improving AI Workflows diff --git a/examples-java/README.md b/examples-java/README.md index 2f6f353..4d2f069 100644 --- a/examples-java/README.md +++ b/examples-java/README.md @@ -44,7 +44,7 @@ examples-java/ └── application.yml # Configuration ``` -## 🎯 Available Example +## 🎯 Available Examples ### **Horoscope News Finder** (Beginner-Friendly) @@ -92,6 +92,27 @@ shell:> execute "Find horoscope news for Bob who is a Scorpio" shell:> execute "What's in the stars for Alice today? She's a Gemini" ``` +### **Company Namer** (Beginner-Friendly) + +Suggest company names from a business brief and perform a deterministic DNS check for each matching `.com` domain. + +```text +shell:> execute "Suggest company names for a sustainable accounting platform" +``` + +The example deliberately uses the JDK DNS resolver instead of a domain-availability API, so it needs no additional API +key and does not consume a provider quota. A domain with DNS records is reported as `IN_USE`. A domain with no DNS +records is only `POTENTIALLY_AVAILABLE`, because registered domains do not have to publish DNS records; always confirm +the final choice with a registrar. + +The workflow demonstrates structured generation followed by deterministic processing: + +1. `CompanyNamer.suggestNames` creates eight names, domains, and rationales. +2. `CompanyNamer.checkDomains` delegates each lookup to `DomainAvailabilityService`. +3. The service validates and normalizes domains, caches results, and distinguishes conclusive from inconclusive checks. + +**Location:** `src/main/java/com/embabel/example/companynamer/` + ## 🛠️ Configuration ### API Keys @@ -311,4 +332,4 @@ When adding new Java examples: ## 📄 License -Licensed under Apache License 2.0. See [LICENSE](../../LICENSE) for details. \ No newline at end of file +Licensed under Apache License 2.0. See [LICENSE](../../LICENSE) for details. diff --git a/examples-java/src/main/java/com/embabel/example/companynamer/CompanyNamer.java b/examples-java/src/main/java/com/embabel/example/companynamer/CompanyNamer.java new file mode 100644 index 0000000..366f2c2 --- /dev/null +++ b/examples-java/src/main/java/com/embabel/example/companynamer/CompanyNamer.java @@ -0,0 +1,88 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.example.companynamer; + +import com.embabel.agent.api.annotation.AchievesGoal; +import com.embabel.agent.api.annotation.Action; +import com.embabel.agent.api.annotation.Agent; +import com.embabel.agent.api.annotation.Export; +import com.embabel.agent.api.common.Ai; +import com.embabel.agent.domain.io.UserInput; + +import java.util.List; + +record CompanyNameIdea(String name, String domain, String rationale) { +} + +record CompanyNameIdeas(List ideas) { +} + +record CheckedCompanyName(String name, String rationale, DomainAvailability domainAvailability) { +} + +record CompanyNamingReport(List suggestions) { +} + +/** + * Suggests company names and performs quota-free DNS checks for their domains. + */ +@Agent( + name = "JavaCompanyNamer", + description = "Suggest company names and check whether their domain names have DNS records", + beanName = "javaCompanyNamer") +public class CompanyNamer { + + private final DomainAvailabilityService domainAvailabilityService; + + public CompanyNamer(DomainAvailabilityService domainAvailabilityService) { + this.domainAvailabilityService = domainAvailabilityService; + } + + @Action + CompanyNameIdeas suggestNames(UserInput userInput, Ai ai) { + return ai.withDefaultLlm().createObject( + """ + Suggest 8 memorable company names for the following brief: + %s + + For each suggestion, provide: + - the company name + - a concise rationale + - one matching fully qualified .com domain name, using only letters, digits, and hyphens + + Make every company name and domain distinct. Domain availability will be checked separately, + so do not claim that a domain is available. + """.formatted(userInput.getContent()), + CompanyNameIdeas.class); + } + + @AchievesGoal( + description = "Suggest company names with matching domain-name checks", + export = @Export( + remote = true, + name = "nameCompanyJava", + startingInputTypes = {UserInput.class})) + @Action + CompanyNamingReport checkDomains(CompanyNameIdeas companyNameIdeas) { + var checkedNames = companyNameIdeas.ideas().stream() + .map(idea -> new CheckedCompanyName( + idea.name(), + idea.rationale(), + domainAvailabilityService.check(idea.domain()))) + .toList(); + return new CompanyNamingReport(checkedNames); + } +} diff --git a/examples-java/src/main/java/com/embabel/example/companynamer/DomainAvailability.java b/examples-java/src/main/java/com/embabel/example/companynamer/DomainAvailability.java new file mode 100644 index 0000000..df388a7 --- /dev/null +++ b/examples-java/src/main/java/com/embabel/example/companynamer/DomainAvailability.java @@ -0,0 +1,28 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.example.companynamer; + +public record DomainAvailability( + String domain, + Status status, + String explanation) { + + public enum Status { + IN_USE, + POTENTIALLY_AVAILABLE, + UNKNOWN + } +} diff --git a/examples-java/src/main/java/com/embabel/example/companynamer/DomainAvailabilityService.java b/examples-java/src/main/java/com/embabel/example/companynamer/DomainAvailabilityService.java new file mode 100644 index 0000000..0ba8518 --- /dev/null +++ b/examples-java/src/main/java/com/embabel/example/companynamer/DomainAvailabilityService.java @@ -0,0 +1,95 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.example.companynamer; + +import org.springframework.stereotype.Service; + +import java.net.IDN; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +@Service +public class DomainAvailabilityService { + + private static final Pattern DOMAIN_PATTERN = Pattern.compile( + "(?=.{1,253}\\.?$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"); + + private final DnsResolver dnsResolver; + private final Map cache = new ConcurrentHashMap<>(); + + public DomainAvailabilityService() { + this(InetAddress::getAllByName); + } + + DomainAvailabilityService(DnsResolver dnsResolver) { + this.dnsResolver = dnsResolver; + } + + public DomainAvailability check(String domain) { + String normalizedDomain; + try { + normalizedDomain = normalize(domain); + } catch (IllegalArgumentException ex) { + return new DomainAvailability( + domain, + DomainAvailability.Status.UNKNOWN, + "The proposed domain name is invalid: " + ex.getMessage()); + } + return cache.computeIfAbsent(normalizedDomain, this::resolve); + } + + private DomainAvailability resolve(String domain) { + try { + dnsResolver.resolve(domain); + return new DomainAvailability( + domain, + DomainAvailability.Status.IN_USE, + "The domain has DNS records and is already in use."); + } catch (UnknownHostException ex) { + return new DomainAvailability( + domain, + DomainAvailability.Status.POTENTIALLY_AVAILABLE, + "No DNS records were found. Confirm availability with a registrar before purchasing."); + } catch (Exception ex) { + return new DomainAvailability( + domain, + DomainAvailability.Status.UNKNOWN, + "The DNS lookup could not be completed. Try again or check with a registrar."); + } + } + + private String normalize(String domain) { + if (domain == null || domain.isBlank()) { + throw new IllegalArgumentException("domain must not be blank"); + } + var normalized = IDN.toASCII(domain.strip()) + .toLowerCase(Locale.ROOT) + .replaceFirst("\\.$", ""); + if (!DOMAIN_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("expected a fully qualified domain name"); + } + return normalized; + } + + @FunctionalInterface + interface DnsResolver { + InetAddress[] resolve(String domain) throws Exception; + } +} diff --git a/examples-java/src/test/java/com/embabel/example/companynamer/CompanyNamerTest.java b/examples-java/src/test/java/com/embabel/example/companynamer/CompanyNamerTest.java new file mode 100644 index 0000000..d3dac0b --- /dev/null +++ b/examples-java/src/test/java/com/embabel/example/companynamer/CompanyNamerTest.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.example.companynamer; + +import com.embabel.agent.domain.io.UserInput; +import com.embabel.agent.test.unit.FakeOperationContext; +import org.junit.jupiter.api.Test; + +import java.net.UnknownHostException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CompanyNamerTest { + + @Test + void namingPromptContainsTheBriefAndDoesNotAssumeAvailability() { + var context = new FakeOperationContext(); + context.expectResponse(new CompanyNameIdeas(List.of( + new CompanyNameIdea("Bright Forge", "brightforge.com", "Memorable")))); + var companyNamer = new CompanyNamer(new DomainAvailabilityService()); + + companyNamer.suggestNames(new UserInput("A sustainable accounting platform"), context.ai()); + + var prompt = context.getLlmInvocations().getFirst().getMessages().getFirst().getContent(); + assertTrue(prompt.contains("A sustainable accounting platform")); + assertTrue(prompt.contains("do not claim that a domain is available")); + } + + @Test + void checksEverySuggestedDomain() { + var checkedDomains = new java.util.ArrayList(); + var service = new DomainAvailabilityService(domain -> { + checkedDomains.add(domain); + throw new UnknownHostException(domain); + }); + var companyNamer = new CompanyNamer(service); + var ideas = new CompanyNameIdeas(List.of( + new CompanyNameIdea("Bright Forge", "brightforge.com", "Memorable"), + new CompanyNameIdea("Green Ledger", "greenledger.com", "Descriptive"))); + + var report = companyNamer.checkDomains(ideas); + + assertEquals(List.of("brightforge.com", "greenledger.com"), checkedDomains); + assertEquals(2, report.suggestions().size()); + } +} diff --git a/examples-java/src/test/java/com/embabel/example/companynamer/DomainAvailabilityServiceTest.java b/examples-java/src/test/java/com/embabel/example/companynamer/DomainAvailabilityServiceTest.java new file mode 100644 index 0000000..25d0fd4 --- /dev/null +++ b/examples-java/src/test/java/com/embabel/example/companynamer/DomainAvailabilityServiceTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.example.companynamer; + +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DomainAvailabilityServiceTest { + + @Test + void reportsDomainWithDnsRecordsAsInUse() { + var service = new DomainAvailabilityService(domain -> new InetAddress[]{ + InetAddress.getLoopbackAddress() + }); + + var result = service.check("Example.COM."); + + assertEquals("example.com", result.domain()); + assertEquals(DomainAvailability.Status.IN_USE, result.status()); + } + + @Test + void reportsMissingDnsRecordsAsPotentiallyAvailable() { + var service = new DomainAvailabilityService(domain -> { + throw new UnknownHostException(domain); + }); + + var result = service.check("possible-example.com"); + + assertEquals(DomainAvailability.Status.POTENTIALLY_AVAILABLE, result.status()); + } + + @Test + void cachesDnsResults() { + var resolutions = new AtomicInteger(); + var service = new DomainAvailabilityService(domain -> { + resolutions.incrementAndGet(); + return new InetAddress[]{InetAddress.getLoopbackAddress()}; + }); + + service.check("example.com"); + service.check("EXAMPLE.COM"); + + assertEquals(1, resolutions.get()); + } + + @Test + void rejectsInvalidDomainsWithoutDnsLookup() { + var resolutions = new AtomicInteger(); + var service = new DomainAvailabilityService(domain -> { + resolutions.incrementAndGet(); + return new InetAddress[]{InetAddress.getLoopbackAddress()}; + }); + + var result = service.check("not a domain"); + + assertEquals(DomainAvailability.Status.UNKNOWN, result.status()); + assertEquals(0, resolutions.get()); + } +}