Criei um Chatbot para me Ajudar a Economizar na Compra de Materiais de Construção

Introdução
O post de hoje é sobre um projeto pessoal que desenvolvi para atender à necessidade de economizar na construção de uma casa no sítio dos meus pais, localizado na cidade de Pato Branco, Paraná. Como estamos na etapa de aquisição de materiais de construção, a realização de múltiplos orçamentos se tornou indispensável. Para tornar esse processo mais eficiente, criei uma aplicação que se integra ao site Menor Preço - Nota Paraná, permitindo consultar produtos com notas fiscais emitidas no dia atual. Dessa forma, é possível identificar o estabelecimento que comercializou um determinado produto pelo menor preço.
Além de Pato Branco, posso adquirir materiais de construção em cidades próximas, como Francisco Beltrão, Vitorino, Coronel Vivida e Clevelândia. Por isso, configurei a aplicação para buscar informações dessas localidades, ampliando as opções e aumentando as chances de encontrar preços mais competitivos.
Para tornar as respostas mais amigáveis e inteligentes, conectei os dados coletados a um chatbot utilizando o modelo GPT-4o da OpenAI. O sistema foi desenvolvido com uma aplicação backend em Java 23, utilizando o framework Spring Boot com a lib Spring Boot AI, e um frontend construído com Angular 19.
Resultado final
Quero iniciar o post apresentando o resultado final da implementação e como estou utilizando a ferramenta na prática. Abaixo, compartilho alguns exemplos de perguntas e respostas realizadas no chat para ilustrar seu funcionamento.



Repositório GIT
O código-fonte dos projetos está disponível no GitHub e pode ser acessado nos seguintes repositórios:
-
Backend (Spring Boot): https://github.com/gasparbarancelli/menor-preco-server
-
Frontend (Angular): https://github.com/gasparbarancelli/menor-preco-client
Direto ao ponto
A ideia deste post é ser o mais objetivo e técnico possível, demostrando o código-fonte e as partes relevantes da implementação, sem dar voltas ou prolongar explicações.
Projeto Backend
Arquivo pom.xml
No arquivo pom.xml, definimos as dependências do Spring Boot e do spring-ai-openai-spring-boot-starter, que possibilitam a conexão com a API da OpenAI:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.gasparbarancelli</groupId>
<artifactId>menor-preco</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>menor-preco</name>
<description>Demo project for Spring Boot</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>23</java.version>
<spring-ai.version>1.0.0-M4</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
</project>
As configurações mais importantes aqui são:
-
A dependência
spring-ai-openai-spring-boot-starter
. -
O BOM (
spring-ai-bom
) para gerenciar versões de dependência.
Arquivo application.properties
Neste arquivo, configuramos a chave da API (spring.ai.openai.api-key) e definimos o modelo do ChatGPT e as funções que serão usadas:
spring.application.name=menor-preco
spring.ai.openai.api-key=${API_KEY}
spring.ai.openai.chat.options.model=gpt-4o
spring.ai.openai.chat.options.functions=functionSearchCategories,functionSearchProducts
Observação: Substitua ${API_KEY} pela sua chave secreta da OpenAI, ou informe como variável de ambiente.
MenorPrecoApplication.java
Classe principal que inicia a aplicação Spring Boot:
package com.gasparbarancelli;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MenorPrecoApplication {
public static void main(String[] args) {
SpringApplication.run(MenorPrecoApplication.class, args);
}
}
ChatbotApi.java
Cria um endpoint HTTP que recebe a mensagem do usuário (/) e interage com o modelo da OpenAI via Spring AI. A mensagem do usuário é adicionada à lista de mensagens, enviamos a prompt para o ChatGPT e retornamos a resposta em texto puro.
package com.gasparbarancelli.transport.http;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping("/")
public class ChatbotApi {
private final OpenAiChatModel openAiChatClient;
private List<Message> messages = new ArrayList<>();
public ChatbotApi(OpenAiChatModel openAiChatClient) {
this.openAiChatClient = openAiChatClient;
messages.add(new SystemMessage("""
Você é um assistente especialista em compras, dedicado a ajudar os usuários a economizar.
Sua missão é fornecer sugestões detalhadas e precisas sobre os produtos mais econômicos disponíveis.
Diretrizes para suas respostas:
1. Sempre priorize listar os produtos com os menores preços e ofereça detalhes relevantes, como:
- Nome do produto
- Preço
- Estabelecimento
- Endereço (se disponível)
2. Responda sempre no formato Markdown, garantindo que suas mensagens sejam claras e visualmente organizadas.
3. Utilize os seguintes elementos do Markdown:
- Títulos para organizar a informação.
- Texto em negrito e itálico para destacar informações importantes.
- Listas ordenadas ou não ordenadas para apresentar múltiplas opções de forma legível.
- Blocos de código, se necessário, para apresentar dados técnicos.
- Links para direcionar o usuário a mais informações ou compras online.
4. Certifique-se de que o Markdown gerado seja bem estruturado e fácil de entender.
Sua prioridade é ajudar o usuário a economizar de forma eficiente e fornecer respostas que transmitam confiança e profissionalismo.
"""));
}
@GetMapping
public String hello(@RequestParam("message") String message) {
UserMessage userMessage = new UserMessage(message);
messages.add(userMessage);
ChatResponse response = openAiChatClient.call(new Prompt(messages));
var content = response.getResult().getOutput().getContent();
messages.add(new AssistantMessage(content));
return content;
}
}
Pontos de destaque:
-
messages
mantém o histórico do chat, começando com umaSystemMessage
que configura o papel do assistente. -
A cada requisição GET, o parâmetro
message
é adicionado como umaUserMessage
. -
O
openAiChatClient.call()
envia esse contexto para a OpenAI e retorna a resposta do modelo.
ProductService.java
Classe responsável por chamar a API do Menor Preço e buscar informações de categorias e produtos em lote.
package com.gasparbarancelli.interactors;
import com.gasparbarancelli.entities.CategoryResponse;
import com.gasparbarancelli.entities.Location;
import com.gasparbarancelli.entities.ProductResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.ArrayList;
import java.util.List;
@Service
public class ProductService {
private static final String CATEGORY_API_URL = "https://menorpreco.notaparana.pr.gov.br/api/v1/categorias";
private static final String PRODUCT_API_URL = "https://menorpreco.notaparana.pr.gov.br/api/v1/produtos";
private static final int BATCH_SIZE = 50;
private final RestClient restClient;
public ProductService() {
this.restClient = RestClient.builder().build();
}
public List<CategoryResponse.Category> fetchCategories(String product) {
try {
String url = String.format("%s?local=%s&termo=%s&raio=2",
CATEGORY_API_URL, Location.PATO_BRANCO.getCode(), product);
CategoryResponse response = restClient.method(HttpMethod.GET)
.uri(url)
.header(HttpHeaders.ACCEPT, "application/json")
.retrieve()
.body(CategoryResponse.class);
return response.categories();
} catch (Exception e) {
throw new RuntimeException("Error fetching categories for product: " + product, e);
}
}
public List<ProductResponse.Product> searchProductsInLocation(String location, String product, String categoryId) {
List<ProductResponse.Product> allProducts = new ArrayList<>();
int offset = 0;
while (true) {
try {
String url = String.format("%s?local=%s&termo=%s&categoria=%s&offset=%d&raio=2&data=-1&ordem=0",
PRODUCT_API_URL, location, product, categoryId, offset);
ProductResponse response = restClient.method(HttpMethod.GET)
.uri(url)
.header(HttpHeaders.ACCEPT, "application/json")
.retrieve()
.body(ProductResponse.class);
if (response.products() != null && !response.products().isEmpty()) {
allProducts.addAll(response.products());
}
if (response.products() == null || response.products().size() < BATCH_SIZE) {
break;
}
offset += BATCH_SIZE;
} catch (Exception e) {
throw new RuntimeException("Error fetching products for category: " + categoryId, e);
}
}
return allProducts;
}
}
Detalhes:
-
fetchCategories(…)
: faz a busca de categorias no Menor Preço, sempre iniciando por Pato Branco (conforme exemplo). -
searchProductsInLocation(…)
: pagina os resultados (utilizando offset) até não haver mais dados de produtos.
FunctionSearchCategories.java
Implementa a primeira função que o ChatGPT chama, para descobrir a categoria de um determinado produto.
package com.gasparbarancelli.interactors;
import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.gasparbarancelli.entities.CategoryResponse;
import org.springframework.context.annotation.Description;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.function.Function;
@Service
@Description("Antes de conseguirmos as informações dos materiais de construções precisamos identificar a sua categoria")
public class FunctionSearchCategories implements Function<FunctionSearchCategories.Request, FunctionSearchCategories.Response> {
private final ProductService productService;
public FunctionSearchCategories(ProductService productService) {
this.productService = productService;
}
@JsonClassDescription("Informações referentes as categorias de um material de construção, como quantidade, nome e seu identificador")
public record Request(String product) {}
public record Response(List<CategoryResponse.Category> categories) {}
@Override
public Response apply(Request request) {
var product = request.product();
return new Response(productService.fetchCategories(product));
}
}
O fluxo é:
-
Dado um
product
, chamarproductService.fetchCategories(…)
. -
Retornar a lista de categorias encontradas.
FunctionSearchProducts.java
Implementa a segunda função que o ChatGPT pode chamar, para buscar produtos dentro de uma categoria e em determinadas cidades. Aqui usamos Java Virtual Threads (criados no Java 21) para fazer consultas paralelas.
package com.gasparbarancelli.interactors;
import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.gasparbarancelli.entities.Location;
import com.gasparbarancelli.entities.ProductResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Description;
import org.springframework.stereotype.Service;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Stream;
@Service
@Description("Recupera as notas fiscais de produtos comercializados no dia atual, podendo identificar qual foi o preco mais alto ou baixo pago por um produto")
public class FunctionSearchProducts implements Function<FunctionSearchProducts.Request, FunctionSearchProducts.Response> {
private static final Logger LOGGER = LoggerFactory.getLogger(FunctionSearchProducts.class);
private final ProductService productService;
public FunctionSearchProducts(ProductService productService) {
this.productService = productService;
}
@JsonClassDescription("Informações referentes ao material de construção, como descrição, preço e estabelecimento em qual o material foi comercializado")
public record Request(String product, String categoryId) {}
public record Response(List<ProductResponse.Product> products) {}
@Override
public Response apply(Request request) {
var product = request.product();
var categoryId = request.categoryId();
List<String> locationCodes = Location.getCodes();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<List<ProductResponse.Product>>> futures = locationCodes.stream()
.map(location -> executor.submit(() -> productService.searchProductsInLocation(location, product, categoryId)))
.toList();
return new Response(futures.stream()
.flatMap(future -> {
try {
return future.get(30, TimeUnit.SECONDS).stream();
} catch (Exception e) {
LOGGER.info("Erro ou timeout para a localização: " + e.getMessage());
return Stream.empty();
}
})
.sorted(Comparator.comparing(ProductResponse.Product::price))
.toList());
}
}
}
Fluxo:
-
Recebe
product
ecategoryId
como parâmetros. -
Recupera os códigos das cidades (
Location.getCodes()
). -
Para cada código de cidade, chama
productService.searchProductsInLocation(…)
em paralelo. -
Ordena todos os produtos obtidos pelo menor preço e retorna ao usuário.
Entidades
ProductResponse.java
package com.gasparbarancelli.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import java.util.List;
public record ProductResponse(
@JsonProperty("produtos") List<Product> products,
@JsonProperty("total") int total,
@JsonProperty("precos") ProductPrice price
) {
public record ProductPrice(
String min,
String max
) {}
public record Product(
@JsonProperty("desc") String description,
@JsonProperty("valor") String price,
@JsonProperty("valor_desconto") String discount,
@JsonProperty("estabelecimento") Company company
) {
public record Company(
@JsonPropertyDescription("Nome do estabelecimento em que o produto foi vendido")
@JsonProperty("nm_fan") String name,
@JsonProperty("tp_logr") String addressType,
@JsonProperty("nm_logr") String addressName,
@JsonPropertyDescription("Cidade em que o produto foi comercializado")
@JsonProperty("mun") String city,
String uf
) {}
}
}
Guarda a estrutura JSON que recebemos do Menor Preço: lista de produtos, quantidade total e uma faixa de preço (mínimo e máximo).
Location.java
package com.gasparbarancelli.entities;
import java.util.Arrays;
import java.util.List;
public enum Location {
PATO_BRANCO("6g6dc3jcw"),
CLEVELANDIA("6g6cb9qch"),
VITORINO("6g66wfwkw"),
CORONEL_VIVIDA("6g6s5y7j2"),
FRANCISCO_BELTRAO("6g678srwp");
private String code;
Location(String code) {
this.code = code;
}
public String getCode() {
return code;
}
public static List<String> getCodes() {
return Arrays.stream(Location.values()).map(Location::getCode).toList();
}
}
Enum que mapeia as cidades para seus respectivos códigos na API do Menor Preço.
CategoryResponse.java
package com.gasparbarancelli.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public record CategoryResponse(
@JsonProperty("categorias") List<Category> categories
) {
public record Category(
String id,
@JsonProperty("qtd") Integer qtd,
@JsonProperty("desc") String name
) {}
}
Modelo para representar as categorias retornadas pela API.
Configuração de CORS
Para permitir que o frontend Angular acesse nossa API em outro endereço (porta 8080 vs 4200), precisamos liberar o CORS:
package com.gasparbarancelli.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:4200")
.allowedMethods("GET", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
};
}
}
Projeto Frontend
chat.component.ts
Aqui, enviamos o texto digitado para o ChatService e, em seguida, adicionamos a mensagem de resposta do servidor no array messages.
import { Component } from '@angular/core';
import { ChatService } from './chat.service';
export interface ChatMessage {
message: string;
send: boolean;
}
@Component({
selector: 'app-chat',
standalone: false,
templateUrl: './chat.component.html',
styleUrls: ['./chat.component.scss']
})
export class ChatComponent {
messages: ChatMessage[] = [];
inputMessage: string = '';
constructor(private chatService: ChatService) {}
sendMessage(): void {
if (this.inputMessage.trim()) {
const userMessage: ChatMessage = { message: this.inputMessage, send: true };
this.messages.push(userMessage);
this.chatService.send(this.inputMessage).subscribe({
next: response => {
const botMessage: ChatMessage = { message: response, send: false };
this.messages.push(botMessage);
},
error: error => {
console.error('Erro ao enviar mensagem:', error);
}});
this.inputMessage = '';
}
}
}
chat.component.html
Percorremos o array de mensagens para exibir as mensagens do chat enviadas pelo usuário e pelo chatbot, bem como adicionar um input onde o usuário entra com a mensagens e um botão para enviar a mensagem.
<div class="chat-container">
<div class="chat-messages">
<div *ngFor="let msg of messages"
[ngClass]="{ 'message-send': msg.send, 'message-receive': !msg.send }"
class="message">
<markdown
[data]="msg.message"
[disableSanitizer]="true">
</markdown>
</div>
</div>
<div class="chat-input">
<input
type="text"
[(ngModel)]="inputMessage"
placeholder="Digite uma mensagem"
(keydown.enter)="sendMessage()"/>
<button (click)="sendMessage()">Enviar</button>
</div>
</div>
chat.component.scss
Definimos um estilo para o nosso componente de Chat.
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid #ccc;
border-radius: 8px;
padding: 10px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.message {
padding: 10px;
border-radius: 8px;
max-width: 60%;
word-wrap: break-word;
}
.message-send {
align-self: flex-end;
background-color: #007bff;
color: white;
text-align: left;
}
.message-receive {
align-self: flex-start;
background-color: #f1f0f0;
color: black;
text-align: left;
}
.chat-input {
display: flex;
gap: 5px;
}
.chat-input input {
flex: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 8px;
}
.chat-input button {
padding: 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.chat-input button:hover {
background-color: #0056b3;
}
chat.service.ts
Serviço que se comunica com nosso backend (localhost:8080) para enviar a mensagem.
import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
@Injectable()
export class ChatService {
constructor(private httpClient: HttpClient) {
}
send(message: string): Observable<string> {
const url = `http://localhost:8080/?message=${message}`;
return this.httpClient.get(url, {responseType: 'text'});
}
}
chat.module.ts
Este módulo registra o ChatComponent, ChatService e o suporte a Markdown.
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ChatComponent} from './chat.component';
import {ChatService} from './chat.service';
import {FormsModule} from '@angular/forms';
import {MarkdownComponent, provideMarkdown} from 'ngx-markdown';
@NgModule({
declarations: [
ChatComponent
],
exports: [
ChatComponent
],
imports: [
CommonModule,
FormsModule,
MarkdownComponent
],
providers: [
ChatService,
provideMarkdown()
]
})
export class ChatModule {
}
Instalação de pacote
O único componente necessário para instalação é aquele responsável por renderizar Markdown. Para isso, execute o seguinte comando:
npm install ngx-markdown marked@^15.0.0 --save
app.component.html
Adicionamos apenas o componente de chat.
<app-chat></app-chat>
app.component.ts
Seguimos com a implementação padrão.
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
standalone: false,
})
export class AppComponent {
}
app.module.ts
Declaramos o modulo do componente de Chat bem como do HttpClientModule, para que possamos realizar chamadas http para o nosso backend.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import {ChatModule} from './chat/chat.module';
import {HttpClientModule} from '@angular/common/http';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
AppRoutingModule,
ChatModule,
HttpClientModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Por fim, basta rodar o ng serve
no frontend e mvn spring-boot:run
no backend.
Conclusão
A ideia central deste projeto foi demonstrar o poder que temos ao integrar uma fonte de dados ao chatbot, no meu caso agora eu tenho maior chance de economizar na compra de materiais de construção, integrando o ChatGPT (via funções específicas) com a API do Menor Preço do Nota Paraná.
De forma que:
-
Usuário pergunta: “Qual o menor preço para cimento hoje?”
-
ChatGPT recebe a pergunta e chama internamente a função
functionSearchCategories
para descobrir a categoria de “cimento”. -
Em seguida, chama a função
functionSearchProducts
informando ocategoryId
retornado. -
O sistema retorna, em formato
Markdown
, a lista de estabelecimentos e preços (do menor para o maior) para o produto “cimento” nas cidades de interesse (Pato Branco, Clevelandia, Vitorino, Coronel Vivida e Francisco Beltrão).
Com isso, posso entrar em contato com o estabelecimento que está oferecendo o melhor custo e efetuar a compra!