// deep-dive · engenharia · DuxBot K1

Anatomia do cérebro de um robô humanoide

Um mergulho de engenharia no DuxBot K1: do socket do microfone ao tool-call do LLM, da segurança anti-queda em domínio puro ao deploy em docker-compose num Jetson de 8 GB.

2026-06-25 · ~22 min · Go / C++ / Python / pgvector / Ansible
arquitetura-hexagonalrobótica-embarcadasafety-ssotragrealtime-llmjetson-orindocker-compose

Este é o documento técnico longo. Se você quer a versão narrativa, ela existe em outro lugar. Aqui o objetivo é diferente: abrir o capô e mostrar como cada subsistema do DuxBot — o cérebro de voz do robô humanoide Booster K1 Education — é construído, por que cada decisão foi tomada, e quais armadilhas custaram horas de campo até virarem regra.

O hardware: K1 Education, 22 graus de liberdade, Jetson Orin NX (aarch64, 8 GB de RAM unificada CPU+GPU, 6 núcleos, kernel RT-Tegra), Ubuntu 22.04. Um robô que ouve, conversa, pesquisa na web, dança, chuta, levanta do chão e reconhece quem está na frente dele.

Vamos da borda física para dentro.


1. O mapa: hexagonal, três camadas, uma direção de dependência

O K1 de verdade: a tela admin do DUX_BOT — Booster K1, firmware v1.6.2.2.
O K1 de verdade: a tela admin do DUX_BOT — Booster K1, firmware v1.6.2.2.

Todo o agente é Go, em arquitetura hexagonal (Ports & Adapters). A regra é só uma e é absoluta: a dependência sempre aponta para dentro.

adapters/  ──►  ports/  ──►  domain/
(I/O, SDKs)    (interfaces)  (stdlib pura)
domain/        lógica pura, ZERO deps externas (só stdlib)
  command.go       enum Action, superfície de Tool, Choreography(action,args) → []Step
  safety.go        SSOT anti-queda: SafeMode/ClampLinear/ClampYaw/IsSafeTrajectory
  segment.go       VADSegmenter — máquina de estados de fala por energia (sem bytes)
  conversation.go  ConversationGuard — half-duplex, double-talk, echo-guard, reset
  wake.go          WakeGate — máquina sleep/wake "hey duxbot", tempo injetado
  guardrails.go    ScreenInput/ScreenOutput — filtro de palavrão/código (robô fala com crianças)
ports/         interfaces: AudioCapture, AudioPlayback, RealtimeLLM/Session,
               RobotController, Transcriber/StreamTranscriber, WakeDetector,
               PerceptionInput, VisionInput, IdentityInput, EventInfo, FleetRegistry...
adapters/
  driven/        backends plugáveis (LLM, STT, áudio, robô, percepção, RAG...)
  driving/       orquestradores (voiceloop, webremote)
sidecar/       C++ sobre unix socket (robot_bridge, audio_bridge)
cmd/           11 entrypoints — duxbot, duxvoice, duxchat, duxbench, admin...
Arquitetura hexagonal: adapters → ports → domain, com a dependência sempre apontando para dentro.

Por que tanto rigor? Porque o domain/ carrega a lógica que não pode falhar — sobretudo a segurança física — e por importar apenas a biblioteca-padrão, ele é testável a ~98% de cobertura em milissegundos, sem hardware, sem GPU, sem rede. A física de queda do robô passa por go test.

Há um detalhe de build que parece trivial e não é: GOWORK=off é obrigatório. O módulo duxbot vive fora do go.work da raiz do workspace; sem essa variável, o go build resolve contra o workspace errado e falha. E o cross-compile para o robô é GOOS=linux GOARCH=arm64 CGO_ENABLED=0 — ELF estático, zero dependências de runtime no Jetson.


2. Por que dois binários (e só um é Go)

O SDK da Booster é uma biblioteca estática C++ (libbooster_robotics_sdk.a) com binding Python, mas sem binding Go. Para manter o agente Go puro (cross-compila no Mac, sem cgo), o controle do robô vive num sidecar C++ minúsculo compilado no próprio robô. A comunicação Go ↔︎ sidecar é um unix socket com JSON delimitado por newline.

Pipeline de voz: do microfone ao corpo. A tool-call é o único caminho que move o robô; o transcript é só diagnóstico.

São, na verdade, dois sidecars:

Um detalhe que economiza horas de debugging: os FIFOs são abertos O_RDWR (leitura e escrita) para que o open() nunca bloqueie esperando um peer e as leituras não deem EOF quando o bridge cicla. É o mesmo truque que o código Python original usava.

E uma jóia de observabilidade barata: o Go carimba o header W3C traceparent do span ativo em cada linha de op. O sidecar C++ não fala OpenTelemetry — ele só extrai o trace_id (32 hex) e o ecoa no stderr. Resultado: grep num trace_id correlaciona o journal do sidecar com o trace no Jaeger, sem arrastar a dependência pesada do OTel para o C++.


3. O caminho do áudio — e a surdez recorrente

O Booster K1 durante o bring-up, conectado por cabo de rede.
O Booster K1 durante o bring-up, conectado por cabo de rede.

O microfone é um array USB único (card Speaker, capture node /dev/snd/pcmC1D0c). O fluxo:

mic USB → booster-audio (NAEC, abre hw:1,0 EXCLUSIVO) → audio_bridge → /tmp/duxbot_cap → duxbot-go

O sintoma mais recorrente do projeto foi a surdez: “falo e ele fica mudo”. O agente fala normal, mas não escuta; o peakRMS no log fica ~5 mesmo gritando; o FIFO /tmp/duxbot_cap fica em 0 byte.

A investigação foi um caso de escola, porque a causa parecia óbvia e era um red herring:

A cura foi em três camadas de defesa: eliminar o spawner rogue; um bring-up de áudio de fonte única (audio-up-coordinated.sh) que garante dono único do device antes de subir (mata rogue, confirma o device livre com fuser, espera o backend init success, e só então sobe os consumidores na ordem áudio→controle→go); e um watchdog de host que detecta a ausência de heartbeat de captura e roda a mesma rotina sozinho, cobrindo boot e runtime sem operador.

Lição transferível: o sinal autoritativo de “pronto” é o journal da unit (backend init success), não a auth nem os bytes do FIFO durante a subida. Quando um sistema tem uma dependência ruidosa (a auth que sempre falha), é fácil culpar o ruidoso. Medir o que de fato gate o recurso é o que fecha o caso.


4. O barge-in — falar por cima do robô

Conversa natural exige barge-in: o usuário interrompe e o robô para na hora. Há dois modos, e a diferença é instrutiva.

Full-duplex (robusto, recomendado). O mic flui sempre para o LLM realtime; o VAD do servidor ouve a fala por cima do bot e dispara um abort total (cancela a resposta, descarta o áudio pendente, player.Stop em ~1–2 ms). O truque fino é o gate de over-talk dinâmico: ele rastreia uma média móvel (EMA) do piso de ruído/eco ambiente enquanto o bot fala, e só encaminha frames que saltam um fator K (default 3,0) acima desse piso. Isso auto-calibra entre ambientes: fone no Mac (ambiente ~150–300 → threshold ~750) e robô (eco NAEC ~5–9k → threshold cap em 11000), sem ajuste manual.

Há uma sutileza de UX escondida aí: ao confirmar a interrupção, abre-se uma janela “sticky” de 0,4 s e o áudio flui contínuo. Por quê? A fala humana normal tem micro-quedas entre sílabas que picotariam o fluxo, e o VAD do servidor nunca veria fala contígua. A janela cobre o tempo até o VAD disparar.

Half-duplex + DTD (fallback). Enquanto o bot fala, o mic fica mudo; um double-talk detector compara o RMS do mic mudo contra o eco do playback. Heurística frágil — quase nunca dispara no Mac sem AEC nativo. O full-duplex é a escolha.


5. A orquestração de um turno — turn.go

Como o robô faz, no mesmo turno, pesquisar + conversar + se mexer? A resposta está num laço aparentemente simples e numa divisão de trabalho rígida.

Capacidade Quem executa Mecanismo
Conversa brain (nuvem) streaming de texto
Pesquisa (search_web) inline no turn.go resultado vira mensagem role:tool no histórico
Movimento / identidade delegado ao voiceloop evento → executor + safety SSOT

A pesquisa é inline porque é só texto — volta para o histórico e o brain segue raciocinando. O movimento é delegado porque quem detém o robô físico e a SSOT anti-queda é o voiceloop; o turn.go nunca toca no metal — ele emite um evento e bloqueia num canal até o corpo terminar. Busca e movimento nunca se atropelam.

O laço de hops do turn.go: pesquisa inline, movimento delegado, e o brain re-chamado com os resultados até produzir texto final.

A razão estrutural de os três caberem num turno é um laço de hops:

for hop := 0; hop < 6; hop++ {
    // chama o brain 1×: devolve TEXTO (fim) OU tool-calls
    // re-chama o brain com os resultados das tools
}

Cada hop chama o brain uma vez. O brain devolve texto (fim) ou tool-calls. Como o laço re-alimenta o brain com os resultados, um turno encadeia: search_web (hop 0) → resultado injetado → wave + fala (hop 1) → fim (hop 2). Multi-tool por hop + multi-hop = pesquisa, conversa e movimento coexistindo num turno.

Um handshake de delegação fecha o ciclo: ao delegar um movimento, o turn.go drena o canal de continuação, emite o evento de tool-call (o voiceloop executa a coreografia → sidecar → SDK), emite o evento de resposta concluída, e bloqueia até o corpo terminar. O próximo hop só roda depois do aceno real acontecer.

Há um fix aqui que destravou “mexer junto” e custou caro: coreografias de braço chamavam kWaveHand sem garantir o modo do robô; kWaveHand falha em modo damping com rc=400. A correção foi prepender ChangeMode(kWalking) em toda coreografia de braço. O que nos leva à regra que mais economizou tempo no projeto inteiro.

rc=0 significa comando ACEITO/enfileirado, NÃO executado. Custou mais de 20 horas aprender isso. O SDK aceita um comando e ainda assim o corpo pode nunca se mover (rc=501 “low battery stop moving”, rc=400 reject genérico, balanceador de marcha suprimindo a faixa de tronco). A máquina de estados deve transicionar em rc==0 do lado físico, nunca no enfileiramento. No código, esse rc viaja como o erro da primitiva:

// RobotRCError carrega o código de retorno do SDK de uma primitiva RECUSADA.
// A lição de campo a duras penas: o "result:ok" do lado Go é apenas o
// ENFILEIRAMENTO — o sucesso físico é o rc==0 do sidecar.
type RobotRCError struct {
    Op string // a primitiva recusada (change_mode/move/wave_hand/...)
    RC int    // código do SDK (501 ServerRefused, 400 rejected, ...)
}

6. A segurança como SSOT — defense in depth

Esta é a peça inegociável. A segurança física é uma única fonte da verdade, mora no domínio puro, e nenhum backend consegue contorná-la.

O K1 fica em pé sempre. Três comandos poderiam derrubá-lo: cortar o torque (modo damping), velocidade alta o bastante para tombar, ou replay de uma trajetória que não é um andar projetado. Os três são limitados ou bloqueados — em profundidade:

Risco Regra Aplicado em (3 camadas)
ChangeMode(kDamping) kPrepare/kWalking passam domain → adapter Go → sidecar C++
Move rápido demais clampa \|vx\|,\|vy\|≤0.5 m/s, \|vyaw\|≤1.2 rad/s domain → adapter Go → sidecar C++
ReplayTrajectory non-gait whitelist de arquivos domain → adapter Go → sidecar C++
const (
    MaxLinearSpeed = 0.5 // |vx|,|vy| (m/s)
    MaxYawSpeed    = 1.2 // |vyaw| (rad/s)
)

func ClampLinear(v float64) float64 { return clampF(v, -MaxLinearSpeed, MaxLinearSpeed) }
func ClampYaw(v float64) float64    { return clampF(v, -MaxYawSpeed, MaxYawSpeed) }
func SafeMode(m RobotMode) bool      { return m == ModePrepare || m == ModeWalking }
Segurança em profundidade: a mesma SSOT do domínio é re-aplicada em três fronteiras, de modo que nenhum caminho consegue comandar uma queda.

A mesma regra é chamada três vezes: no orquestrador, no adaptador, e no sidecar C++ na fronteira do SDK. De modo que até um orquestrador com bug ou um socket fabricado à mão nunca consegue comandar uma queda. E não é muleta — um teste (TestChoreographyVelocitiesWithinSafeEnvelope) garante que toda coreografia que o sistema possa emitir já nasce dentro do envelope.

Tem ainda os gates nascidos de tombo real. A bateria:

// Em 13/06/2026 o K1 caiu de cara quando "Dançar" foi apertado a ~2% de
// bateria — o SDK recusou o ChangeMode (rc=501) e os motores perderam torque
// no meio da rotina. Um pack morrendo não segura o corpo durante a dança.
const (
    DanceMinSOCDefault = 20.0 // dança
    KickMinSOCDefault  = 40.0 // chute é mais violento → piso mais alto
    GetUpMinSOCDefault = 20.0 // levantar é recuperação essencial → piso mínimo seguro
)

func CanDanceAtSOC(soc float64, seen bool, minSOC float64) bool {
    if minSOC <= 0 { minSOC = DanceMinSOCDefault }
    if !seen { return true } // carga desconhecida → não dá pra provar inseguro; permite (honesto, logado)
    return soc >= minSOC
}

Repare no if !seen: o sistema nunca finge saber a bateria que não mediu. O mesmo princípio vale para o obstáculo — frame velho demais proíbe o avanço em vez de adivinhar:

func ClampForObstacle(vx, frontDistM float64, stale bool) float64 {
    if vx <= 0 { return vx }                       // ré/parado: nada a frear
    if stale || frontDistM <= ObstacleStopM { return 0 } // cego ou colisão → não avança
    if frontDistM >= ObstacleSlowM { return vx }   // livre → velocidade cheia
    f := (frontDistM - ObstacleStopM) / (ObstacleSlowM - ObstacleStopM)
    return vx * f                                   // zona de freada linear
}

K1 ≠ T1 — o boot check que recusa ligar errado

O modelo canônico é o K1 (22 DOF). Há um primo, o T1 (23 DOF), que compartilha quase todos os nomes de junta — mas o K1 não tem a junta da cintura, usa prefixo A no ombro (ALeft_Shoulder_Pitch) e tem o Ankle_Cross passivo. Se um nome de junta T1 vazar para o caminho de controle do K1, você comanda uma topologia errada de corpo: queda garantida. Um teste de domínio (TestJointKeysAreK1Members) e um boot check falham alto se qualquer nome não-K1 aparecer. O robô se recusa a ligar errado.

Honestidade de débito: não existe política RL de caminhada nativa do K1. As 12 juntas das pernas têm nomes idênticos ao T1, então a política do T1 (T1.onnx) é reusada para andar e girar — com um aviso explícito no boot, nunca silenciosamente.


7. Um binário, três corpos — wscore e o simulador

O emulador: o mesmo binário rodando o corpo MuJoCo do K1 no Mac.
O emulador: o mesmo binário rodando o corpo MuJoCo do K1 no Mac.

A arquitetura hexagonal dá um presente: o mesmo binário que deploya no robô roda no Mac como emulador. Um switch de ambiente (DUXBOT_ROBOT) escolhe o corpo, todos satisfazendo a mesma interface RobotController:

O wscore merece nota. Ele é o caminho primário de comunicação de movimento e a spec fonte-da-verdade: hexagonal igual ao Go (ports/robot.pybooster_sdk.py → ops em ws_server.py), com uma regra de “dono único do DDS” — só um B1LocoClient ativo por vez, então quando o wscore é o dono, o sidecar C++ não roda, e vice-versa. A regra de processo que daí decorre é inviolável no projeto: capacidade nova de corpo nasce no core Python (locomoção, gesto, pan/tilt de cabeça, dança); o sidecar C++ é caminho legado de fallback, não onde coisa nova nasce.

Isso aterra um princípio de desenvolvimento: testar e fazer funcionar no simulador é obrigatório antes de tocar o hardware. A ordem é fixa — teste unitário determinístico → prova do “fio” real contra o ws_server.py (ou o backend Go sim) → build e testes verdes → só então bump de versão e deploy. “Quebrou o sim = quebrou o robô.” Hardware nunca é o primeiro teste.


8. Os protocolos de IA realtime não são intercambiáveis

Esta seção é um aviso pago em produção. xAI (grok-voice-preview-1.0), OpenAI (gpt-realtime) e outros são “compatíveis com o realtime da OpenAI” no papel. Na prática, cada um aceita e emite um subconjunto diferente de campos no session.update e de tipos de evento.

Os fatos concretos que viraram regra:

A regra final ficou: com qualquer realtime audio-native, transcrição de texto desligada, sempre. Ele já faz STT+LLM+TTS no mesmo socket; transcrição em paralelo é redundante e, no grok, fatal. E a regra de processo: nunca portar um campo de um adapter para outro às cegas — mudança específica de provedor entra gated, com default seguro, e só liga onde não é provada após um probe controlado que mede o efeito completo.

Lição transferível: “compatível com a API X” raramente significa “intercambiável com X”. E um teste que mede aceitação sem medir sobrevivência do comportamento é um falso verde perigoso.


9. Internet é o modo oficial — e por que o offline foi congelado

Decisão final, medida no robô, não em teoria: o modo oficial de produção é realtime na nuvem (OpenAI/xAI duplex). O caminho 100% offline (ollama no Orin) está congelado — fallback documentado, sem evolução.

Por quê? O Orin NX tem 8 GB unificados (CPU e GPU dividem a mesma RAM) e 6 núcleos. Enquanto a stack de movimento roda (motion realtime ~713 MB + ~30 nós ROS2 + 7 containers), não sobra orçamento para hospedar um LLM. As medidas com um modelo pequeno (qwen2.5:1.5b):

Tentativa Latência/turno Offload GPU
keep_alive=5m (modelo fixo na CPU) ~45 s 0/29 camadas
keep_alive=0, frio 27,8 s 93% CPU / 7% GPU
keep_alive=0, quente 100 s 93% CPU
num_gpu=99 (forçar offload) 61 s não offloadou

O ollama olha “VRAM livre”, vê pouco (a memória unificada já está tomada por motion+ROS), decide 0 camadas na GPU e cai 100% para CPU — que está faminta de núcleos. Não é problema de config; é a máquina saturada. O que é fluido: realtime na nuvem, sub-segundo. Só o cérebro vai para a nuvem; o motion fica 100% local, com latência de controle intacta.

Mas o caminho offline existe e é engenhosamente honesto, então vale documentar a engenharia mesmo congelada: mic → whisper.cpp (STT) → Qwen2.5-3B (decide) → XTTS (TTS) → playback. Tudo no Orin, sem nuvem nem chave. O tool-calling sem function-calling nativo é resolvido por Structured Outputs: as tools são forçadas por um JSON Schema que o ollama compila numa gramática GBNF (decoding restrito), o que fisicamente impede o modelo q4 de degenerar (string não-terminada, vazamento de token de template → loop). A RAM de 8 GB é gerida em sequência, nunca simultâneo: whisper transcreve e libera → Qwen carrega, decide, descarrega via keep_alive:0 → XTTS carrega e toca.


10. RAG de eventos — de 93,5% a 100% de recall

O robô fica num evento real (uma feira de carros antigos, um colégio) e precisa responder com o conhecimento daquele evento — que o LLM não conhece. Isso é um RAG sobre um corpus curado. A métrica que importa é o Context Recall: o trecho certo caiu entre os top-k recuperados? Recall é o teto de tudo — se o pedaço certo nem foi buscado, o LLM alucina ou se desculpa.

Princípio zero: uma régua que reproduz produção

Construí um harness (cmd/rageval) que roda um conjunto dourado (perguntas + trechos esperados) pelo mesmo caminho do robô e mede o recall. Toda mudança foi A/B-testada nela antes de entrar em produção. Foi como descobri que o reranker “de mercado” (um cross-encoder padrão de tutorial) piorou o recall no corpus em português: 93,5% → 90,3%. Medir matou um mito.

As três camadas independentes

pergunta
   ├─[QUERY]   normaliza → (HyDE: + resposta hipotética)
   ├─[DENSE]   embed(query) <=> embedding   (bge-m3, 1024d, cosseno)
   ├─[SPARSE]  to_tsvector @@ tsquery        (full-text Postgres, pt)
   └─ RRF (funde dense+sparse por rank) → top-k
As três camadas independentes do retrieval — query (HyDE), dense (bge-m3) e sparse (full-text) — fundidas por RRF. Cada passo da jornada atacou uma camada distinta.

Cada passo da jornada atacou uma camada diferente — por isso somaram.

Passo A — o sparse engolia termos exatos (93,5% → 96,8%). Diagnóstico nos logs de score: “qual a metodologia da escola” trazia o chunk errado. O tsquery montava AND ('metodologia' & 'escola'), mas o chunk-alvo fala de “metodologia” e “Colégio DUX” e não contém “escola” → não casava → o sinal léxico zerava. Correção: tsquery para OR (casar qualquer termo basta), peso léxico 1.4 no RRF, top-k de 4 para 6. Custo: uma constante e uma mudança server-side.

Passo B — denominador honesto (a verdade era 97,3%). 96,8% em cima de 31 perguntas é 30/31; não existe “99%” nesse denominador, e 31/31 seria overfit. Expandi o conjunto dourado de 31 para 76 perguntas. A baseline real era 97,3% (73/75) — e os 2 misses tinham o conteúdo existindo no corpus, só mal-rankeado. Não era falta de dado: era ranking.

Passo C — HyDE, perguntar como se já soubesse (97,3% → 100%). A pergunta curta tem vetor ambíguo. O HyDE (Hypothetical Document Embeddings) gera primeiro uma resposta hipotética curta e embeda essa — o vetor de uma resposta plausível cai muito mais perto do chunk certo (que também é uma resposta). A sutileza de implementação:

// semanticChunks:
embText := q
if queryExpandMode() == "hyde" {            // DUXBOT_QUERY_EXPAND=hyde
    embText = expandHyDE(ctx, hydeClient, question, s.log)
}
vec := embed(embText)                         // DENSE usa query + hipótese
hybridChunks(eventID, q, vec, poolK)          // LEXICAL usa q (termos reais)

A hipótese entra só no braço dense; o léxico continua com os termos reais da pergunta (a hipótese inventada poderia não conter a palavra-chave). Melhor dos dois mundos. E fail-safe: qualquer erro (sem gateway, timeout) degrada para a query crua, nunca derruba a busca.

Passo Mudança Camada Recall
0 bge-m3 dense + híbrido base 93,5%
A tsquery AND→OR + peso + top-k sparse 96,8%
B golden 31→76 medição 97,3% (real)
C HyDE (gated) dense/query 100%

O HyDE custa uma chamada LLM por pergunta (~300–800 ms), então fica desligado por padrão e só liga onde precisão importa mais que latência (quiosque, FAQ). O modelo de embedding (bge-m3, via Cloudflare Workers AI, sem OpenAI) não foi trocado — o recall subiu 6,5 pontos mexendo em tudo menos na peça que a maioria troca primeiro.

Um invariante de engenharia que vale ouro: query e ingestão usam o MESMO modelo de embedding. Divergir = cosseno lixo. E o corpus vive em seeds versionados (a fonte da verdade), não em scripts soltos — os scripts ad-hoc de enriquecimento foram declarados obsoletos quando bagunçaram um re-ingest.


11. Deploy: docker-compose num Jetson, e por que K8s foi descartado

Deploy em campo: o robô #10 na quadra enquanto os logs do deploy sobem no laptop.
Deploy em campo: o robô #10 na quadra enquanto os logs do deploy sobem no laptop.

O runtime no robô é docker-compose, definitivo. Quatro serviços, network_mode: host, ipc: host, env_file montado: jaeger, robot-bridge, audio-bridge, duxbot-go.

A escolha de host network não é preguiça — é necessidade: 1. O SDK Booster usa FastDDS (descoberta de peers por multicast + transporte por memória compartilhada); uma rede bridge isolaria os dois. 2. O kernel L4T (RT-Tegra) não tem o módulo iptable_rawcompose up em bridge falha.

Houve a intenção de migrar para MicroK8s. Foi abandonada, e o racional é um bom estudo de “a ferramenta certa para o nó certo”:

Dimensão docker-compose MicroK8s/K8s
RAM de overhead ~0 700 MB–1 GB (num Orin com ~571 Mi livres)
Roda neste kernel? ❌ snap-confine exige apparmor; o kernel RT não tem
Nº de nós que justifica 1+ 2+
/dev, sockets, FIFO, GPU, host-net 1 linha fricção (privileged, hostPath, device-plugin)

O bloqueio é físico de kernel: o RT-Tegra foi compilado sem apparmor, e o MicroK8s (snap) não sobe sem ele. Forçar significaria recompilar o kernel de controle de motor do humanoide — risco alto, ganho ~zero. Além disso, K8s resolve problemas que um robô single-node de dono único não tem (escalar entre nós, drenar, multi-tenant) e atrapalha o que ele precisa (acesso direto a /dev, sockets, GPU). O bloqueio de apparmor não foi obstáculo a vencer — foi um sinal de que K8s não pertence a este nó. (Se um dia houver um segundo robô, o desvio limpo é k3s — binário systemd, sem snap/apparmor — não MicroK8s.)

Entrega de frota: o CI builda arm64 uma vez, publica uma tag imutável no registry, e cada robô puxa a mesma imagem — rodando docker-compose local, provisionado por Ansible.

Importante não confundir: “robôs precisam de imagens no registry” ≠ “robôs precisam de K8s no robô”. A entrega de uma frota é uma camada central (CI builda arm64 → registry → cada robô puxa a mesma tag imutável), enquanto cada robô segue rodando docker-compose local. E todo provisionamento — apt, Docker, ollama, sidecars, units, config.env — passa por Ansible. Edição ad-hoc por SSH é proibida; SSH manual só para diagnóstico read-only.

Por fim, o versionamento como SSOT: a versão de tudo vem de um arquivo só (robot/VERSION), carimbada em três lugares que devem concordar — o binário Go (via -ldflags), o label OCI da imagem, e a navegação do admin. Um comando (task version) cruza os três e responde “o que está no ar?”. (Histórico: o robô rodou uma imagem :latest velha por dias sem ninguém perceber — :latest esconde código antigo. O label OCI mata isso.)


12. As bordas: percepção, identidade, wake word

O controle remoto web (DUXBOT Remote): telemetria ao vivo + andar / acenar / dançar / parar.
O controle remoto web (DUXBOT Remote): telemetria ao vivo + andar / acenar / dançar / parar.

O DuxBot é mais que voz. As capacidades opcionais, todas atrás de ports e nil-guarded (ausentes, degradam graciosamente):

Todas seguem a mesma forma de três camadas e o mesmo fail-safe: sinal velho ou nó morto degrada para o estado seguro, nunca para a adivinhação.


13. Como tudo é provado — a pirâmide de testes

A confiança no sistema vem de quatro camadas complementares de prova, do determinístico ao físico:

  1. Cola determinística (domain/routing_e2e_test.go, ~100+ casos, offline, sempre-verde): pina o roteamento do domínio — quando a busca ao vivo dispara/segura, quando o guard bloqueia palavrão/código, quando uma tool de movimento vira coreografia. É o gate de CI.

  2. Bench ao vivo (cmd/duxbench, 219 falas de criança): dirige o binário real um disparo por caso e mede o que só o cérebro real responde — precisão de roteamento (216–217/219 ≈ 98,6–99,1%, estável em 4 rodadas), concisão (mediana 10 palavras) e idioma (zero vazamento de script não-latino). Sai com erro se a precisão cai abaixo do mínimo — pode gatear release.

  3. Bench multi-turno (cmd/duxevolve, 51 checks): prova a evolução de contexto que o one-shot não vê — carry de pronome (“quem é o Elon Musk” → “quantos filhos ele teve”), follow-up aditivo (“a capital da França” → “e a da Alemanha” → Berlim), estado do braço mantido, memória de nome, reset por silêncio. 50/51 = 98%.

  4. União-corpo (choreography_sim_e2e_test.go, -tags sim, MuJoCo, sem robô): fecha a solda entre roteamento e física. Dirige a coreografia pelo orquestrador real contra o corpo MuJoCo K1 e afirma que cada intenção move o corpo e o robô nunca cai (TrunkZ ≥ 0.4). 15/15 — incluindo um teste que roda os 10 movimentos em sequência sem uma queda.

E uma regra de linguagem de código que parece pedante mas mantém a base coerente: comentários em pt-BR (quem lê o fonte é brasileiro), mensagens de diagnóstico (fmt.Sprintf de log/erro/OTel) em inglês, e — intocável — tudo que o robô fala ou que o LLM (system prompt, descrições de tool, resultados de busca) em pt-BR.


O fio condutor

Personalidade: a camisa 10 e um chapéu do Brasil.
Personalidade: a camisa 10 e um chapéu do Brasil.

São treze seções e uma dúzia de subsistemas, mas o princípio é um só, repetido em cada decisão: disciplina sobre esperteza, e honestidade sobre o que não se sabe.

O corpo, o SDK da Booster já tinha resolvido. A camada fina, pura, testável e honesta entre o “oi” de uma criança e um humanoide de 22 articulações em movimento — essa foi a engenharia.


Stack: Go (agente hexagonal, cross-compile arm64 estático, GOWORK=off CGO_ENABLED=0) · C++ (sidecars robot_bridge/audio_bridge sobre o SDK Booster, unix sockets) · Python (wscore — core de movimento hexagonal + simulador MuJoCo de referência) · Postgres/pgvector (RAG híbrido dense+sparse+RRF) · Cloudflare Workers AI (embeddings bge-m3 1024d) · OpenAI/xAI realtime (cérebro duplex audio-native) · Jaeger/OTel (tracing correlacionado nuvem→robô) · Ansible (provisionamento 100%) · docker-compose (runtime no Jetson Orin NX).