Projetos
IA Generativa · LLM · Desenvolvimento

UCBvet — Como foi feito

Imagina um vendedor numa fazenda, precisando saber: "essa vaca aqui dá pra inseminar agora? qual touro teve melhor resultado nessa fazenda? qual o protocolo ideal?". Hoje ele liga pro escritório e espera. O UCBvet faz isso virar conversa de chat — você pergunta em português, a IA decide qual fonte consultar, busca os dados e responde. Tudo em segundos.

Nível Avançado Stack Python · Flask · Groq · FAISS · SQLite Tipo Projeto técnico
Visão geral

O que é isso, em uma frase?

É um chatbot de IA que conhece tanto os dados estruturados da empresa (banco SQLite com 8 tabelas) quanto os documentos técnicos (PDFs de protocolos, manuais veterinários indexados com FAISS). Você faz uma pergunta e a IA:

1. Decide qual ferramenta usar (banco, documentos ou scoring). 2. Executa a consulta com argumentos validados por JSON Schema. 3. Pode encadear múltiplas ferramentas no mesmo turno. 4. Gera a resposta em português, palavra por palavra, via streaming SSE.

Por que importa? Dados bons escondidos em planilhas e PDFs ficam inacessíveis pra quem não sabe SQL ou não tem tempo de procurar no manual. Esse projeto é a ponte: a IA traduz a pergunta humana em consultas precisas e devolve a resposta na linguagem de quem perguntou.

Peça central

Tool Calling — o coração do projeto

A versão anterior usava regex para detectar se a pergunta precisava de banco de dados. Funcionava para casos simples, mas quebrava em perguntas ambíguas e não suportava múltiplas fontes no mesmo turno.

A nova arquitetura usa a Groq Function Calling API: definimos as 3 ferramentas com JSON Schema, mandamos para o modelo junto com a mensagem, e o próprio LLM declara qual ferramenta (ou sequência de ferramentas) precisa usar. Nada de regex, nada de heurística.

# backend/llm.py — definição das ferramentas
_TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "query_database",
            "description": "Execute SELECT no SQLite: fazendas, vacas, vendas, visitas...",
            "parameters": {
                "type": "object",
                "properties": {
                    "sql": {"type": "string", "description": "Query SQLite válida (SELECT)."}
                },
                "required": ["sql"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "search_knowledge_base",
            "description": "Busca semântica em documentos técnicos: protocolos IATF, veterinária...",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string"},
                    "top_k": {"type": "integer"}
                },
                "required": ["query"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_farm_potential",
            "description": "Calcula Score de Potencial e Propensão de uma fazenda.",
            "parameters": {
                "type": "object",
                "properties": {"farm_id": {"type": "integer"}},
                "required": ["farm_id"]
            }
        }
    }
]

O loop de tool calling executa até 5 rounds: o modelo chama uma ferramenta, recebe o resultado, pode chamar outra, e quando decide que tem informação suficiente, gera a resposta final em streaming com tool_choice="none" — isso bloqueia o modelo de tentar chamar ferramentas em formato texto na resposta final.

# Loop de tool calling (simplificado)
for _ in range(MAX_TOOL_ROUNDS):
    response = groq.chat.completions.create(
        model="llama-3.1-8b-instant",   # 500k TPD — ideal para tools
        messages=messages,
        tools=_TOOLS,
        tool_choice="auto",
        parallel_tool_calls=False,       # uma ferramenta por vez
    )
    msg = response.choices[0].message

    if not msg.tool_calls:
        # sem mais ferramentas: streaming da resposta final
        yield from stream_chat(messages, tools=_TOOLS, tool_choice="none")
        return

    # executa cada ferramenta e adiciona o resultado ao histórico
    for tc in msg.tool_calls:
        args = json.loads(tc.function.arguments)
        result = execute_tool(tc.function.name, args)
        messages.append({"role": "tool", "tool_call_id": tc.id,
                         "name": tc.function.name, "content": result})
Base de conhecimento

FAISS RAG — documentos técnicos com busca semântica

Além do banco de dados estruturado, o assistente precisa responder perguntas técnicas: doses de GnRH, critérios de uso de eCG, interpretação de DG positivo. Essas respostas estão em PDFs de protocolos e manuais veterinários — não em tabelas SQL.

A solução foi um índice FAISS (Facebook AI Similarity Search) com embeddings gerados pelo modelo all-MiniLM-L6-v2 (Sentence Transformers, 384 dimensões). Quando o vendedor arrasta um PDF na interface, o sistema:

1. Extrai o texto. 2. Divide em chunks de 400 palavras com 60 de overlap (sem quebrar contexto no meio de uma frase). 3. Gera um vetor de 384 dimensões para cada chunk. 4. Adiciona ao índice FAISS (IndexFlatIP — similaridade coseno). 5. Persiste em disco (faiss_index/).

# backend/rag.py — indexar um documento
def add_document(text: str, source: str = "") -> int:
    chunks = _chunk(text)           # 400 palavras, 60 overlap
    index, meta = _load()           # carrega FAISS do disco
    vecs = _embed(chunks)           # all-MiniLM-L6-v2, 384 dim
    index.add(vecs)                 # adiciona ao índice FAISS
    for i, chunk in enumerate(chunks):
        meta.append({"source": source, "text": chunk, "chunk_idx": i})
    _save(index, meta)              # persiste em faiss_index/rag.faiss
    return len(chunks)

# buscar nos documentos
def search(query: str, top_k: int = 4) -> list[dict]:
    qvec = _embed([query])
    scores, ids = index.search(qvec, min(top_k, index.ntotal))
    return [
        {"score": float(s), "source": meta[i]["source"], "text": meta[i]["text"]}
        for s, i in zip(scores[0], ids[0])
        if float(s) >= 0.10           # filtra chunks irrelevantes
    ]

Na interface há um modal com drag & drop, contador de documentos e chunks indexados. O modelo all-MiniLM-L6-v2 (~80 MB) é baixado automaticamente no primeiro uso e roda inteiramente em CPU — sem API externa, sem custo adicional.

Inteligência comercial

Scoring de Potencial & Propensão por fazenda

Além de responder perguntas, o assistente calcula dois scores para cada fazenda — ajudando o vendedor a priorizar quem visitar primeiro.

Score de Potencial (tamanho estratégico do cliente, 0–100):

Tamanho do rebanho    → até 35 pts  (máx: 300+ animais)
Receita histórica     → até 30 pts  (máx: R$ 60.000+)
Frequência de visitas → até 20 pts  (máx: 12+ visitas/ano)
Taxa de conversão     → até 15 pts  (máx: 75%+ com venda)

Score de Propensão (probabilidade de fechar agora, 0–100):

Recência da visita    → até 30 pts  (máx: ≤ 30 dias)
Taxa de prenhez       → até 35 pts  (máx: ≥ 70%)
Volume de IATFs       → até 20 pts  (máx: 100+ IATFs)
ECC médio do rebanho  → até 15 pts  (máx: ≥ 3.5)

Exibidos como gauges SVG circulares animados (verde ≥ 70, amarelo ≥ 40, vermelho < 40), com análise narrativa do LLM e recomendação de ação. Acessível pelo botão 📈 no header ou diretamente no chat: "qual o potencial da Fazenda Alegria?".

Extras

Voz, CRM e streaming

Transcrição de voz: microfone integrado no chat. O áudio é gravado no navegador, enviado como webm/ogg para o Flask, que repassa ao Groq Whisper (whisper-large-v3-turbo). O texto transcrito aparece automaticamente no campo de mensagem — o vendedor só confirma e envia.

Adapter Pattern de CRM: em vez de integrar direto com um CRM específico, criei uma interface abstrata. O LLM extrai campos estruturados da conversa (fazenda, vendedor, protocolo, taxa de prenhez, valor), o usuário revisa num modal e clica enviar. Trocar de CRM é mudar uma variável no .env.

# backend/crm.py — adicionar novo CRM
class HubSpotCRM(CRMAdapter):
    name = "hubspot"
    def send(self, data: dict) -> dict: ...

_ADAPTERS["hubspot"] = HubSpotCRM
# .env: CRM_ADAPTER=hubspot

Streaming SSE: para respostas sem tool calling, o texto é entregue diretamente do modelo sem segunda chamada à API. Para respostas após ferramentas, o Flask faz streaming real do Groq com tool_choice="none". O frontend renderiza Markdown progressivo via marked.js.

Sumarização de histórico: conversas com mais de 16 mensagens: o LLM comprime as mais antigas em 4 frases, preserva as 8 mais recentes intactas. Latência constante independente do tamanho da conversa — sem explodir o budget de tokens.

Modelos de IA

Quatro modelos, um assistente

O projeto usa modelos diferentes para cada tarefa, balanceando qualidade e cota de API:

llama-3.1-8b-instant — loop de tool calling (SQL, RAG, scoring). Rápido, 500k tokens/dia no Groq free tier. Bom em seguir instruções estruturadas.

llama-3.3-70b-versatile — respostas diretas, sumarização de histórico, geração de título. Maior qualidade para texto livre. 100k tokens/dia.

llama-3.2-11b-vision-preview — análise de imagens. O vendedor tira foto de um laudo veterinário e a IA interpreta.

whisper-large-v3-turbo — transcrição de áudio em português, incluindo termos técnicos veterinários.

all-MiniLM-L6-v2 (local, Sentence Transformers) — embeddings para o FAISS RAG. 384 dimensões, ~20ms por query em CPU. Sem API externa.

O que dá pra levar daqui

Em uma linha: o que esse projeto ensina

Que LLM sozinho não resolve nada — ele precisa de contexto (banco + documentos) e processo (tool calling loop, chunking, scoring). A IA brilhante é a que tem boa engenharia em volta.

Coisas técnicas que apareceram aqui: Groq Function Calling API, FAISS + Sentence Transformers (RAG local), Text-to-SQL com JSON Schema, streaming SSE (Flask), sumarização automática de contexto, modelo de visão para imagens, transcrição de voz com Whisper, scoring multi-critério, adapter pattern de CRM, autenticação com bcrypt.