LangChain4j: potenciando tus aplicaciones Java con Inteligencia Artificial (IA) y Modelos de Lenguaje de Gran Escala (LLM). Function calling (tools) y chains.

0
265

LangChain4j se presenta como un aliado indispensable para integrar en nuestras aplicaciones Java, de manera muy sencilla, capacidades de Inteligencia Artificial y Modelos de Lenguaje a Gran Escala (LLM). Ya dimos unos primeros pasos con LangChain4j,  vimos después su integración con Ollama,  exploramos distintas técnicas de RAG (Retrieval-Augmented Generation) y en este cuarto tutorial vamos a jugar con tools o function calling y distintos agentes.

Contenido.

1. Introducción.

Ya hemos comentado que los LLMs, por defecto, no tienen acceso a conocimientos externos, están entrenados con gran cantidad de información, en algunos casos hasta cierta fecha y fuera de ese universo de conocimiento no tienen acceso a información actualizada, de este mismo modo, tampoco tienen la capacidad de ejecutar acciones externas.

Si para alimentar nuestro sistema con información externa podemos usar RAG (Retrieval-Augmented Generation) para ejecutar nuestras propias acciones existe el concepto de tools o function calling.

Mediante tools podemos invocar, cuando sea necesario, a una o más funciones disponibles, normalmente definidas y desarrolladas por nosotros mismos. Una tool puede ser cualquier cosa: una búsqueda, una llamada a una API, la ejecución de un fragmento de código específico, …

En realidad, los LLMs no invocan directamente a las tools por si mismos, sino que expresan la intención de llamar a una tool específica en su respuesta (en lugar de responder en texto plano). Y somos nosotros, como desarrolladores, los que debemos preparar esa tool con los argumentos adecuados e informar de los resultados de la ejecución de la tool.

El resultado de la ejecución de la tool la interpreta de nuevo el LLM para generar una respuesta textual con todo el contexto de la pregunta inicial, esto es, se ejecutan o se pueden ejecutar dos llamadas, una primera con información sobre el embedding y el listado de tools disponibles y una segunda si el LLM estima que para resolver a la pregunta debe ejecutarse una tool con la respuesta de la misma.

Quizás parece un poco complejo, pero LangChain4j oculta toda esa complejidad bajo una única anotación: @Tool 😉

2. Entorno.

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 16′ (Apple M1 Pro, 32GB DDR4).
  • Sistema Operativo: Mac OS Sonoma 14.2.1

3. Nuestra primera tool.

Siguiendo con nuestra IA de adictos al trabajo donde ya recuperamos el contenido, de una manera estructurada, de los tutoriales ingestados, el objetivo ahora podría ser intentar contestar a unas preguntas que llevamos haciendo desde el principio, sin éxito aún en las respuestas:

  • ¿cuántos tutoriales hay publicados?
  • ¿cuál es el tutorial qué más visitas tiene?
  • ¿cuál es el tutorial más reciente?

Pues, para cumplir ese objetivo, lo único que tendríamos que hacer es implementar los siguientes métodos:

@Component
public class WorkaholicsTools {

    @Tool
    public long getTotalNumberOfTutorials() {
        return 0; // TODO get num total of tutorials
    }

    @Tool
    public Map<String, String> getMostVisitedTutorial() {
        return null; // TODO get the most visited tutorial
    }

    @Tool
    public Map<String, String> getMostRecentTutorial() {
        return null; // TODO get the most recent tutorial
    }
}

 

Y modificar la configuración de nuestra IA para inyectar el bean de las tools como sigue:

    @Bean
    public WorkaholicsAgent workaholicsAgent(ChatLanguageModel chatLanguageModel,
                                                 RetrievalAugmentor retrievalAugmentor,
                                                 WorkaholicsTools workaholicsTools) {
        return AiServices.builder(WorkaholicsAgent.class)
                .chatLanguageModel(chatLanguageModel)
                .chatMemory(MessageWindowChatMemory.withMaxMessages(20))
                .retrievalAugmentor(retrievalAugmentor)
                .tools(workaholicsTools)
                .build();
    }

 

Ahora la implementación de retrieval usará las tools como funciones disponibles para que el LLM recupere información de nuestros tutoriales cuando no sea capaz de resolver una pregunta.

[conversation_mode_on]

Reader: Pero espera,… ¿cómo sabe asociar el método al contexto de la pregunta?
Author: pues podríamos ser algo más verbosos y añadir una descripción a la tool, tipo:

    @Tool("returns the total number of tutorials as long")
    public long getTotalNumberOfTutorials() {
        return 0; // TODO get num total of tutorials
    }

pero no será necesario si el nombre del método es autodescriptivo 😉

Reader: Ok, ¿y cómo implementamos esos métodos?
Author: pues ahora te lo cuento,… pero lo más importante llegaba hasta aquí, el resto de este apartado son detalles de implementación.
[conversation_mode_off]

Pensaba en obtener esa información del EmbeddingStore, de la implementación en memoria que estamos usando; lo normal sería que esa información la tuviésemos en algún otro tipo de almacenamiento, una base de datos relacional, un servicio… pero para el objetivo de este tutorial lo único que he hecho es disponibilizar un array con los metadatos de los documentos ingestados.

Creamos un bean de este tipo:

    @Bean
    Set tutorialsStatsStorage() {
       return new HashSet<>();
    }

Y refactorizamos el web crawler, primero para que sea más óptimo en cuanto a la creación de objetos y después para almacenar los metadatos de los tutoriales, extraídos previamente a la ingesta, en ese mapa:

package com.izertis.ai.workaholics.embeddings;

import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.document.DocumentTransformer;
import dev.langchain4j.data.document.Metadata;
import dev.langchain4j.data.document.loader.UrlDocumentLoader;
import dev.langchain4j.data.document.parser.TextDocumentParser;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.document.transformer.HtmlTextExtractor;
import dev.langchain4j.model.Tokenizer;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.EmbeddingStoreIngestor;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.Optional;
import java.util.Set;

@Component
@Slf4j
public class WorkaholicsWebCrawler {

    public static final int MAX_SEGMENT_SIZE = 600;
    public static final int MAX_OVERLAP_SIZE_IN_CHARS = 5;
    private final EmbeddingStoreIngestor ingestor;
    private final DocumentTransformer transformer;

    private final Set tutorialsMetadataStorage;

    public WorkaholicsWebCrawler(EmbeddingStore embeddingStore,
                                 EmbeddingModel embeddingModel,
                                 Optional tokenizer,
                                 Set tutorialsMetadataStorage) {
        DocumentSplitter splitter = DocumentSplitters.recursive(MAX_SEGMENT_SIZE, MAX_OVERLAP_SIZE_IN_CHARS);
        if (tokenizer.isPresent()){
            splitter = DocumentSplitters.recursive(MAX_SEGMENT_SIZE, MAX_OVERLAP_SIZE_IN_CHARS, tokenizer.get());
        }
        this.transformer = new HtmlTextExtractor(".td-post-content",
                Map.of("title", "h1.entry-title",
                        "author", ".td-post-author-name",
                        "date", ".td-post-date",
                        "views", ".td-post-views"),
                true);
        this.ingestor = EmbeddingStoreIngestor.builder()
                .documentSplitter(splitter)
                // do not inject the transformer
                .embeddingModel(embeddingModel)
                .embeddingStore(embeddingStore)
                .build();
        this.tutorialsMetadataStorage = tutorialsMetadataStorage;
    }

    @PostConstruct
    void init(){
        ingestByUrl("https://www.adictosaltrabajo.com/2023/08/21/devops-eso-es-cosa-del-pasado-conoce-mlops/");
        ingestByUrl("https://www.adictosaltrabajo.com/2023/07/27/nltk-python/");
        ingestByUrl("https://www.adictosaltrabajo.com/2023/05/10/como-ia-puede-mejorar-eficiencia-programador/");
        ingestByUrl("https://www.adictosaltrabajo.com/2023/05/06/diagramas-de-arquitectura-con-c4-model/");
        ingestByUrl("https://www.adictosaltrabajo.com/2019/01/08/haciendo-bdd-en-microservicios-hexagonales-spring-boot/");
        ingestByUrl("https://www.adictosaltrabajo.com/2023/05/12/structurizr-para-generar-diagramas-de-arquitectura-con-c4-model/");
        ingestByUrl("https://www.adictosaltrabajo.com/2020/12/09/ejecucion-de-tareas-efimeras-en-una-arquitectura-de-microservicios-en-cloud/");
    }

    private void ingestByUrl(String url) {
        log.info("ingesting {} ", url);
        final Document document = UrlDocumentLoader.load(url, new TextDocumentParser());
        final Document documentTransformed = transformer.transform(document);
        documentTransformed.metadata().add("url", url);
        ingestor.ingest(documentTransformed);

        // adding the metadata in an additional storage
        tutorialsMetadataStorage.add(documentTransformed.metadata());
    }

}

 

Con ello, al realizar la ingesta también estamos almacenando los metadatos aprovechando el parseo y disponemos de ese array para inyectarlo en la clase que implementa las tools.

package com.izertis.ai.workaholics.tools;

import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.data.document.Metadata;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

@Component
public class WorkaholicsTools {

    @Autowired
    private Set tutorialsStatsStorage;

    @Tool
    public long getTotalNumberOfTutorials() {
        return tutorialsStatsStorage.size();
    }

    @Tool
    public Map<String, String> getMostVisitedTutorial() {
        return tutorialsStatsStorage.stream().max(Comparator.comparingInt( m -> Integer.valueOf(m.get("views")))).orElseThrow().asMap();
    }

    @Tool
    public Map<String, String> getMostRecentTutorial() {
        final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("d MMMM, yyyy", new Locale("es"));

        return tutorialsStatsStorage.stream()
                .max(Comparator.comparing(m -> LocalDate.parse(m.get("date"), formatter) ))
                .orElseThrow()
                .asMap();
    }
    
}

 

Esto es solo para el ámbito de este tutorial, lo más interesante es la declaración de las tools, cómo obtener la información es lo menos importante y recuerdo, no hagáis en memoria lo que puede hacer alguien por vosotros, véase una base de datos.

Los métodos de las tools devuelven un mapa con los mismos metadatos que se recuperan del embedding, no tenemos objeto de dominio, pero es capaz de obtener la información del mapa y parsearla en las respuestas.

Vamos a probar a lanzar las preguntas:

User: cuántos tutoriales tenemos?
Agent: Tenemos un total de 7 tutoriales en el sitio "adictos al trabajo".

User: qué tutorial tiene más visitas?
Agent: El tutorial con más visitas es el siguiente:
- Título: Haciendo BDD en microservicios hexagonales Spring Boot
- Autor: Por David García Gil
- Vistas: 12327
- Fecha: 8 de enero de 2019
- URL: [Ver tutorial](https://www.adictosaltrabajo.com/2019/01/08/haciendo-bdd-en-microservicios-hexagonales-spring-boot/)

User: cuál es el tutorial más reciente?
Agent: El tutorial más reciente es el siguiente:
- Título: ¿DevOps? Eso es cosa del pasado, conoce MLOps
- Autor: Por Luis Merino Ulizarna
- Vistas: 1172
- Fecha: 21 de agosto de 2023
- URL: [Ver tutorial](https://www.adictosaltrabajo.com/2023/08/21/devops-eso-es-cosa-del-pasado-conoce-mlops/)

Et voilà.

Deseando probarlo, verdad?,… pues con langChain4j para Ollama aún no podemos 🙁

java.lang.IllegalArgumentException: Tools are currently not supported by this model
	at dev.langchain4j.model.chat.ChatLanguageModel.generate(ChatLanguageModel.java:65) ~[langchain4j-core-0.27.1.jar:na]

Todas estas pruebas las he tenido que hacer con OpenAI, 0,36$ llevo gastados en total, en este tutorial más que en todos los anteriores, porque recordad que ahora haremos más llamadas, si el LLM decide que para resolver una consulta se ejecute una función. Aunque también subí el tamaño de los segmentos y estoy enviando más tokens.

4. Chains of AI Services.

A diferencia de langChain y el uso que hace de chains, la propuesta de langChain4j es usar AI Services. La idea es ocultar la complejidad de la interacción con los LLMs y otros componentes tras una API sencilla.

Si bien, cuanto más compleja sea la lógica de tu aplicación potenciada por LLMs, más importante será dividirla en partes más pequeñas, ya sabéis, bajo acoplamiento y alta cohesión. Por ejemplo, incluir muchas instrucciones en el prompt del sistema para cubrir todos los escenarios posibles es propenso a errores e ineficiencia. Si hay demasiadas tools en una llamada, los LLMs pueden pasar por alto algunas. Además, la secuencia en la que se presentan las instrucciones es importante, lo que dificulta aún más el proceso.

Vamos a configurar un segundo agente, que tenga información únicamente sobre el contexto de publicación de tutoriales en adictos, es decir, sobre cómo escribir o publicar tutoriales:

package com.izertis.ai.workaholics;

import dev.langchain4j.service.UserMessage;

public interface WorkaholicsAuthorsAgent {

    @UserMessage("Is the following text a question about how to publish or write? Text: {{it}}")
    boolean isQuestionAboutMyDomain(String text);

    @UserMessage("Answer the following question providing information to the authors. Question: {{it}}")
    String chat(String userMessage);

}

 

El primer método será la clave para decidir si una preguntar debe ser manejada por este agente o no, al segundo método le estamos dando algo más de contexto, basado en la pregunta del usuario para que conteste a la medida del autor que quiere publicar tutoriales.

A continuación vamos a modificar el chat para condicionar si la pregunta puede ser contestada por un agente u otro:

    @Bean
    ApplicationRunner interactiveChatRunner(WorkaholicsAgent agent, WorkaholicsAuthorsAgent authorsAgent) {
        return args -> {
            Scanner scanner = new Scanner(System.in);

            while (true) {
                System.out.print("User: ");
                String userMessage = scanner.nextLine();

                if ("exit".equalsIgnoreCase(userMessage)) {
                    break;
                }
                String agentMessage = "";
                if (authorsAgent.isQuestionAboutMyDomain(userMessage)){
                    agentMessage = authorsAgent.chat(userMessage);
                    System.out.print("[Authors] ");
                } else {
                    agentMessage = agent.chat(userMessage);
                    System.out.print("[Generic] ");
                }
                System.out.println("Agent: " + agentMessage);
            }

            scanner.close();
        };
    }

 

Lo siguiente es la configuración del nuevo agente, muy similar a la primera configuración que hicimos del agente actual:

  • un AI Service
  • un retrieves y
  • un embedding store propios.
package com.izertis.ai.workaholics.config;

import com.izertis.ai.workaholics.WorkaholicsAuthorsAgent;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class WorkaholicsAuthorsAIConfig {

    @Bean
    public WorkaholicsAuthorsAgent workaholicsAuthorsAgent(ChatLanguageModel chatLanguageModel,
                                                              ContentRetriever authorsContentRetriever) {
        return AiServices.builder(WorkaholicsAuthorsAgent.class)
                .chatLanguageModel(chatLanguageModel)
                .chatMemory(MessageWindowChatMemory.withMaxMessages(20))
                .contentRetriever(authorsContentRetriever)
                .build();
    }

    @Bean
    ContentRetriever authorsContentRetriever(EmbeddingStore authorsEmbeddingStore, EmbeddingModel embeddingModel) {

        // You will need to adjust these parameters to find the optimal setting, which will depend on two main factors:
        // - The nature of your data
        // - The embedding model you are using
        int maxResults = 1;
        double minScore = 0.6;

        return EmbeddingStoreContentRetriever.builder()
                .embeddingStore(authorsEmbeddingStore)
                .embeddingModel(embeddingModel)
                .maxResults(maxResults)
                .minScore(minScore)
                .build();
    }

    @Bean
    EmbeddingStore authorsEmbeddingStore() {
        return new InMemoryEmbeddingStore();
    }

}

 

Por último, vamos a configurar un crawler de la página de cómo participar en adictos:

package com.izertis.ai.workaholics.embeddings;

import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.document.DocumentTransformer;
import dev.langchain4j.data.document.loader.UrlDocumentLoader;
import dev.langchain4j.data.document.parser.TextDocumentParser;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.document.transformer.HtmlTextExtractor;
import dev.langchain4j.model.Tokenizer;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.EmbeddingStoreIngestor;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.Optional;

@Component
@Slf4j
public class WorkaholicsAuthorsWebCrawler {

    public static final int MAX_SEGMENT_SIZE = 100;
    public static final int MAX_OVERLAP_SIZE_IN_CHARS = 5;
    private final EmbeddingStoreIngestor ingestor;
    private final DocumentTransformer transformer;


    public WorkaholicsAuthorsWebCrawler(EmbeddingStore authorsEmbeddingStore,
                                        EmbeddingModel embeddingModel,
                                        Optional tokenizer) {
        DocumentSplitter splitter = DocumentSplitters.recursive(MAX_SEGMENT_SIZE, MAX_OVERLAP_SIZE_IN_CHARS);
        if (tokenizer.isPresent()){
            splitter = DocumentSplitters.recursive(MAX_SEGMENT_SIZE, MAX_OVERLAP_SIZE_IN_CHARS, tokenizer.get());
        }
        this.transformer = new HtmlTextExtractor("#td-outer-wrap",
                Map.of(),
                true);
        this.ingestor = EmbeddingStoreIngestor.builder()
                .documentSplitter(splitter)
                .embeddingModel(embeddingModel)
                .embeddingStore(authorsEmbeddingStore)
                .build();
    }

    @PostConstruct
    void init(){
        ingestByUrl("https://www.adictosaltrabajo.com/participa/");
    }

    private void ingestByUrl(String url) {
        log.info("ingesting {} ", url);
        final Document document = UrlDocumentLoader.load(url, new TextDocumentParser());
        final Document documentTransformed = transformer.transform(document);
        documentTransformed.metadata().add("url", url);
        ingestor.ingest(documentTransformed);
    }

}

 

Y ahora vamos a empezar a hacer algunas preguntas a ver qué agente nos contesta de los dos:

User: hola, quién eres?
[Generic] Agent: ¡Hola! Soy un asistente virtual aquí para ayudarte. Si tienes alguna pregunta o necesitas información, ¡no dudes en decírmelo!

User: quién puede publicar tutoriales en adictos al trabajo?
[Authors] Agent: En Adictosaltrabajo.com cualquier persona puede aportar conocimiento a la Comunidad tecnológica. Ya somos más de 150 autores compartiendo conocimiento. ¿Te animas?

User: qué pasos hay que dar para participar en adictos al trabajo?
[Authors] Agent: Para participar en Adictosaltrabajo.com y aportar conocimiento a la Comunidad tecnológica, simplemente envía tu tutorial. Cualquier persona puede contribuir, y actualmente somos más de 150 autores compartiendo conocimiento. ¿Te animas a participar? Puedes enviar tu tutorial a través del enlace: Participa <https://www.adictosaltrabajo.com/participa/>. ¡Esperamos tu aportación! © 2023. Adictos al trabajo powered by Autentia.

User: me puedes enumerar los pasos para compartir conocimiento en adictos al trabajo?
[Authors] Agent: Para compartir conocimiento en Adictosaltrabajo.com, sigue estos pasos:

Paso 1: Prepara tu tutorial o contenido educativo.
Paso 2: Selecciona el formato de entrega, como un zip que incluya imágenes y archivos de texto en Markdown, HTML o texto plano.
Paso 3: Envía tu entrada a través del enlace: Participa <https://www.adictosaltrabajo.com/participa/>.

Recuerda que en Adictosaltrabajo.com cualquier persona puede contribuir a la Comunidad tecnológica. ¡Anímate a participar junto a más de 150 autores compartiendo conocimiento!


 

Quizás la página de «participa» de adictos no sea el mejor ejemplo de contenido semántico, no hace referencia expresa a tutoriales, sino al concepto de entrada… no menciona pre-requisitos y mezcla el contenido de los pasos, quizás no da un contexto semántico suficiente, aún así haciendo las preguntas adecuadas podemos llegar a cierto nivel de detalle.

User: puedo adjuntar enlaces personales a mi ficha de autor si escribo un tutorial en adictos al trabajo?
[Authors] Agent: Sí, puedes adjuntar enlaces personales a tu ficha de autor si escribes un tutorial en Adictosaltrabajo.com. En Adictosaltrabajo.com, cualquier persona puede aportar conocimiento a la Comunidad tecnológica. Si te animas a participar, puedes enviar tu tutorial a través del enlace: Participa <https://www.adictosaltrabajo.com/participa/>. ¡Gracias por compartir conocimiento!

User: y puedo adjuntar una fotografía?
[Authors] Agent: Sí, puedes adjuntar una fotografía a tu entrada de contenido tecnológico. A la hora de escribirla, es recomendable que sea lo más descriptiva posible. Además de la fotografía, puedes incluir código, imágenes o vídeos embebidos. Se requerirá una imagen destacada para el post, una ficha con una breve descripción sobre ti y una fotografía de proporción 1:1’5 (tamaño carné). ¡Gracias por tu interés en compartir contenido tecnológico!

 

En cualquier momento podemos volver a preguntar sobre el contenido de tutoriales y nos contestará el agente con el que veníamos chateando.

User: qué información tienes sobre diagramas y c4m?
[Generic] Agent: La información sobre diagramas y C4M se encuentra en el siguiente enlace:
- **Título:** Diagramas de arquitectura con C4 model
- **Autor:** Por Jose Manuel Sánchez Suárez
- **Vistas:** 7683
- **Fecha:** 6 de mayo de 2023
- **URL:** [Diagramas de arquitectura con C4 model](https://www.adictosaltrabajo.com/2023/05/06/diagramas-de-arquitectura-con-c4-model/)

 

La verdad es que no se en qué momento ha empezado a usar markdown en la respuesta, ni por qué ¿?

5. Referencias.

6. Conclusiones.

Ya habíamos visto cómo utilizar nuestro propio universo de datos para interactuar en modo pregunta respuesta, vía chat, con nuestra propia IA, y con las tools además podemos ejecutar código o recuperar información de otras fuentes propias o externas de una manera extremadamente sencilla. Si además añadimos especialización a nuestros agentes estarán más desacoplados y menos costosos en su ejecución.

Se abre un gran abanico de posibilidades no solo para recuperar información sino también para ejecutar comandos, imagina solicitar a un agente en modo chat los datos de tu reserva de vuelo, pero ¿y ejecutar una cancelación de la reserva si está dentro del plazo establecido en las cláusulas? Te invito a ver esta charla How to build a retrieval-augmented generation (RAG) AI system in Java (Spring Boot + LangChain4j) y a echar un vistazo al código fuente de este repo https://github.com/marcushellberg/java-ai-playground.

Yo intentaré completar la saga con un último tutorial, o no, recopilando todo lo visto para intentar materializar una aplicación práctica de IA con todo lo aprendido.

De momento el código fuente de todos estos tutoriales lo he dejado por aquí: https://github.com/sanchezjm/workaholics-ai.

Stay tuned, again!

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad