Construindo Lucas AI: transformando um portfólio em um produto

← Voltar ao blog

23 de abril de 202610 min lido

Construindo Lucas AI: transformando um portfólio em um produto

Desenho de sistema para Lucas AI: prompts em camadas, recuperação lexical, memória de sessão, orçamentos de tokens e um classificador pré-vôo em uma superfície de bate-papo de portfólio limitada.

produtoaiarquiteturafrontend

Este site é um portfólio, mas a superfície do Lucas AI foi intencionalmente construída como um pequeno produto: uma experiência dedicada /{locale}/ai, gasto previsível e respostas que permanecem em primeira pessoa apenas a partir de contexto estruturado — não um assistente genérico com ambição de escala web.

Este post é um mergulho técnico profundo em como esse sistema é estruturado: o que é montado por solicitação, por que é em camadas dessa forma e quais restrições são deliberadas.

O problema

Portfólios estáticos respondem bem a perguntas únicas, mas falham em profundidade. Perguntas de acompanhamento (“como você definiu isso?”, “qual foi a pilha de tecnologia?”) ou desaparecem em PDFs ou forçam um loop humano.

Integrações de bate-papo ingênuas corrigem o modelo de interação, mas criam novas falhas: contexto ilimitado, empregadores inventados, escopo silencioso e todas as perguntas cobradas contra o maior modelo que você configurou. Um portfólio não precisa de um chatbot de mundo aberto; ele precisa de uma interface estreita com uma base de fatos auditável.

Lucas AI fica nessa lacuna: UX limitada, conhecimento limitado, limites explícitos sobre o que “Lucas” pode reivindicar.

Visão geral do desenho do sistema

De ponta a ponta, uma solicitação ainda é um POST /api/chat com JSON (message, locale, sessionSummary / recentTurns opcionais). A rota possui política e gasto; o navegador possui UI de transcrição e persistência do lado do cliente (sessionStorage).

Camadas, em ordem de execução:

  1. Validação de entrada — mensagens vazias rejeitadas; texto do usuário com limite rígido de 2 000 caracteres para limitar abuso e superfície de injeção de prompt.
  2. Classificador pré-vôo (opcional) — um pequeno Groq chat completion (llama-3.1-8b-instant por padrão), não-streaming, max_tokens: 5, temperature: 0, veredito de uma palavra: OFF_TOPIC vs prosseguir. Se OFF_TOPIC, a API retorna mock SSE com uma recusa localizada para que o caminho do cliente corresponda a um fluxo normal. Em caso de erro ou tempo limite do classificador, o manipulador falha aberta e executa o modelo principal — portões baratos não devem bloquear tráfego legítimo. Os hosts podem definir CHAT_SKIP_CLASSIFIER para ignorar essa chamada inteiramente ao testar carga ou quando reduzir chamadas de provedor vale a pena perder o atalho.
  3. Montagem de promptbuildChatPrompt compõe a mensagem sistema; o modelo principal recebe sistema + mensagem do usuário atual (não um histórico de bate-papo com várias mensagens na linha).
  4. Conclusão principal — streaming POST …/v1/chat/completions (padrão llama-3.3-70b-versatile no Groq, substitua GROQ_MODEL), resposta proxy como SSE para o cliente. max_tokens limitado via CHAT_MAX_TOKENS (256–8192, padrão 2048) liga UX à economia unitária.
  5. Continuidade do cliente — a UI mantém mensagens e um resumo de sessão rolante em sessionStorage; cada solicitação pode anexar um resumo aparado e uma fatia de turnos recentes para que o servidor mantenha a continuidade sem armazenar bate-papo no lado do servidor.

Alinhamento de locale (ainda política do lado do cliente): ao enviar, franc-min classifica a digitação do visitante (comprimento mínimo ~15 caracteres). Se o idioma detectado não corresponder ao mapeamento ISO 639-3 esperado do locale do site atual, a UI não chama silenciosamente a API — ela mostra um cartão de oferta para mudar de locale (router.push para o {locale}/ai correspondente) ou continuar no idioma atual. Isso mantém o locale no corpo JSON alinhado com o idioma que o usuário está realmente escrevendo, em vez de forçar o modelo a adivinhar. Uma mensagem pendente sobrevive a uma troca de locale via sessionStorage e envio automático após a montagem.

A mudança arquitetônica importante é a separação: lei central estável (quem é Lucas AI, voz, regras de aterrissagem) vs fragmentos dinâmicos (bloco de sessão, trechos recuperados, bloco de FAQ canônico opcional). Essa divisão mantém o prompt auditável e dá ao cache de prompt uma chance de lutar porque o prefixo permanece estável enquanto apenas o contexto relevante muda por turno.

Estratégia de contexto

O desenho evita “enviar tudo sempre”. Incorporar o perfil estruturado completo em cada solicitação escala mal: o custo de token cresce com o corpus, perguntas não relacionadas pagam por fatos não relacionados e você se aproxima dos limites do provedor à medida que o perfil cresce.

Em vez disso:

  • systemPrompt (corePromptText.ts) — regras estáveis: primeira pessoa, transparência (“não digitando ao vivo”), lei de aterrissagem, regras de matemática de duração, comportamento de locale. Mantido mínimo e fixo para que o cache de prefixo (onde o provedor deduplica o texto do sistema de abertura) tenha uma chance de ajudar.
  • Fragmentos dinâmicos de CONTEXT — anexados após o prefixo compartilhado, claramente delimitados (--- CONTEXT (canônico / FAQ) ---, memória de sessão, trechos recuperados) e fechados com --- Fim dos fragmentos de CONTEXT --- mais uma linha Locale do visitante: … para que o modelo responda no idioma do site.

Separação de domínio é aplicada no momento da criação do trecho, não apenas na prosa. Um único objeto lucasPersonalContext é a fonte da verdade; buildKnowledgeChunkIndex deriva trechos com rótulos KnowledgeDomain (core_bio, mindset, working_style, strengths, experience, duration, portfolio_*, boundaries, etc.). Isso dá à recuperação uma superfície tipada para pontuar contra em vez de uma parede de texto indiferenciada.

O que o modelo é permitido tratar como fato é sempre o que os fragmentos inseriram nessa solicitação — escopo testável, não uma vibe.

Roteamento de intenção (barato) vs porta de escopo (LLM)

Existem duas ideias diferentes de “roteamento” na pilha; confundi-las perde o desenho.

Intenção heurística (routeIntent em router.ts) não é uma chamada LLM. É uma classificação conduzida por palavras-chave e regex em baldes grosseiros (recruiter, engineering, project, blog, portfolio_meta, navigation_contact, conversational, general). Essa intenção apenas influencia a pontuação de trechos lexicais — a latência permanece plana, o custo é zero.

Exemplos:

  • “Como entro em contato / contratação / CV” → navigation_contact — impulsiona indiretamente o comportamento adjacente ao contato por meio de audiências de trecho.
  • “Next.js / i18n / Groq / SSE / Lucas AI API” → portfolio_meta — impulsiona domínios portfolio_* em selectChunks.
  • “Anos de experiência / tempo de serviço / tamanho da equipe” → recruiter — impulsiona duration, strengths, trechos marcados por recrutador.
  • “Olá / obrigado” curto → conversational — evita sobre-buscar blocos de experiência pesados.

Compromisso: heurísticas erram na rota. A recuperação compensa com um fallback: se nada pontuar acima de zero, injeta core-bio + boundaries para que o modelo ainda tenha aterrissagem mínima.

Classificador de tópico pré-vôo é a porta LLM: CAREER vs OFF_TOPIC com um prompt de sistema dedicado (buildClassifierPrompt). Isso é política (“isso é realmente o produto certo?”), não ajuste de recuperação. É mais flexível do que regex, mas custa uma viagem extra; portanto, ignora sinalizadores e comportamento de falha aberta.

Recuperação seletiva (não incorporação RAG)

“RAG” geralmente implica incorporações e um banco de dados vetorial. Esta implementação não usa esses. A recuperação é lexical: tokeniza a mensagem do usuário em um conjunto de palavras, pontua cada trecho por hits de palavras-chave + sobreposição de substring no texto do trecho, adiciona impulsos baseados em intenção (por exemplo, tags de audiência de recrutador, impulsiona domínios portfolio_* em selectChunks), classifica, então pega os principais trechos sob limites rígidos.

Trechos são unidades semânticas pequenas em relação ao perfil completo: por exemplo, um trecho por empregador (compactExperience), trechos separados para pipeline i18n vs recurso de bate-papo vs fatos de monorepo. Trechos pequenos importam porque:

  • a pontuação é local — empregadores irrelevantes não diluem uma correspondência em outro nome de empresa;
  • a compressão trunca por trecho (maxChunkChars) sem descartar o perfil inteiro.

Consultas em forma de blog são despriorizadas na recuperação (intent === "blog" aplica um viés negativo) porque o MDX do blog não é indexado como trechos de conhecimento aqui. O sistema não finge que a recuperação de artigo completo existe quando o corpus não está presente.

Relevância é código transparente: peso de palavra-chave 3, peso de token-no-corpo 0.35, impulsos de audiência/intenção por cima. Isso é fácil de auditar e barato de executar; o compromisso é sinonímia e paráfrase — se o usuário nunca sobrepõe lexicalmente o conteúdo do trecho, você depende de trechos de fallback.

Otimização de token

Implementações ingênuas reproduzem todo o histórico ou incorporam todo o corpus. Ambos explodem custo e latência.

Estratégias concretas no código:

  • estimateTokens — heurística de comprimento de caractere / 4; nenhuma dependência de tokenizer; bom o suficiente para orçamento, não para contabilização de nível de cobrança.
  • Teto de bloco de sessãomaxSessionBlockTokens: o resumo rolante + turnos recentes formatados são aparados iterativamente (fatia até 85% até abaixo do limite).
  • Turnos recentes — apenas os últimos maxRecentTurnPairs pares (padrão 3), cada mensagem limitada a maxCharsPerTurn (padrão 700). O cliente envia até oito mensagens anteriores; o servidor impõe o orçamento mais rigoroso.
  • Resumo de sessão — o cliente constrói uma linha de log rolante por turno (snippet do Usuário + snippet do Assistente), limitado pelo cliente (~2500 caracteres), então o servidor sanitizeSessionSummary (maxSessionSummaryChars, padrão 900). Nenhuma sumarização LLM no lado do servidor — custo previsível, mas barulhento se os usuários colarem paredes de texto (mitigado por limites).
  • Recuperação de topo-kmaxRetrievedChunks (padrão 5), orçamento maxRetrievedTokens (padrão 2200 estimado), parada antecipada quando adicionar outro trecho excederia o orçamento (com um piso de pelo menos dois trechos quando possível).
  • Deduplicação — trechos escolhidos por id em um Set para que o mesmo bloco de empregador não seja duplicado.
  • CompressãocompressChunkText trunca com reticências quando um trecho excede maxChunkChars.
  • Último recursomaxSystemChars trunca a string do sistema montada com um marcador visível [sistema truncado].

A tensão do produto é sempre riqueza de contexto vs custo. Os padrões intencionalmente favorecem prompt menores; os operadores ajustam por meio de variáveis de ambiente CHAT_MAX_*. CHAT_DEBUG_METRICS=1 registra uma linha JSON por solicitação (intenção, contagens de trecho, detalhamento aproximado de token) para que você possa ver o orçamento em produção sem adivinhar.

Memória de sessão

A reprodução completa do bate-papo no sistema não escala: o custo de token cresce linearmente com o comprimento da conversa e threads longas afogam o sinal recuperado.

Esta pilha usa um híbrido:

  1. Resumo rolante — perda, criado pelo cliente, barato; preserva “o que já discutimos” em alguns kilobytes no máximo.
  2. Turnos recentes — uma pequena cauda de diálogo real para referência imediata (“aquele papel”, “a última resposta”).

A continuidade é portanto melhor esforço, não reprodução literal — apropriada para uma superfície de perguntas e respostas de portfólio, não programação em par.

Detalhes de design de prompt

Circuito curto de FAQ canônico (tryCanonicalAnswer) — uma pequena biblioteca de padrões para perguntas recorrentes (“quem é você”, “por que Lucas AI”, “principais pontos fortes”, experiência pesada em frontend). Em caso de acerto, um bloco denso ### Fatos canônicos é injetado e a recuperação é limitada com mais força (fatia para no máximo três trechos) para evitar tokens redundantes.

Estatuto vs dinâmico é explícito na ordem de montagem: systemPrompt + data, então seções dinâmicas opcionais, então marcadores de fim + linha de locale. Essa ordem é deliberada para cache e para diffing humano quando o CONTEXT muda.

Classificador vs principal — o prompt do classificador é estritamente limitado (saída de uma palavra); o prompt principal carrega a lei comportamental completa. Manter esses separados evita a “deriva do classificador útil” e mantém a mensagem do sistema do grande modelo focada em voz e aterrissagem.

Compromissos e não-objetivos

Não construído intencionalmente:

  • Recuperação de incorporação / banco de dados vetorial para o perfil ou blog.
  • Chamadas de ferramenta, anexos ou memória no lado do servidor entre sessões.
  • Reordenação de segundo estágio (recodificador cruzado / pequeno LLM) em trechos recuperados.

Simplificado de propósito:

  • Recuperação lexical sobre recuperação neural — aceitável para um corpus limitado onde as palavras-chave do trecho espelham como recrutadores e engenheiros realmente fazem perguntas.
  • Resumo criado pelo cliente sobre sumarização LLM — economiza uma chamada e evita que alucinações de resumo poluam a verdade básica.

Extensões futuras (se necessário): incorporações para conteúdo de blog uma vez indexado; armazenamento de sessão no lado do servidor se a continuidade entre guias deve ser confiável sem o cliente; reordenador se a cardinalidade do trecho crescer o suficiente para que a pontuação lexical se sinta frágil.

Conclusão

Lucas AI é melhor lido como uma superfície de produto com uma especificação: o objeto lucasPersonalContext e trechos derivados são o contrato, o sistema é a lei, os orçamentos de token são guardrails e o classificador mais o caminho de fluxo de tópico fora definem o que o produto se recusa a ser.

Se você está construindo algo semelhante, a alavanca não é o maior modelo geral — é decidir o que entra no prompt a cada turno e medir. Um portfólio não precisa de contexto infinito; ele precisa de respostas honestas e limitadas — e uma arquitetura chata o suficiente para mantê-las dessa forma.