A Central de IA é um pequeno intermediário BYOK (traga sua própria chave) para o Moodle. Ela permite que os plugins da própria instituição gerem texto através de chaves de API de IA compartilhadas, sem que cada plugin reimplemente o transporte HTTP, o guard de SSRF, a escada de provedores ou um armazenamento de chaves.
👈 Use a barra lateral para pular direto a qualquer seção desta página.
✨ Funcionalidades
- 🔑 Armazenamento BYOK: chaves de site (admin) e chaves pessoais opcionais (por usuário, opt-in) para Gemini, Groq, DeepSeek e qualquer endpoint compatível com OpenAI.
- 🪜 Resolução pessoal → site: o hub tenta a chave do próprio usuário primeiro, depois a de site, expondo um único resultado ao chamador.
- 🧩 Fachada de uma chamada:
\local_aihub\ai::generate_text()eis_available()— consumidas pelos plugins irmãos por dependência soft (class_exists), sem dependência dura. - 🚫 Não embrulha o
core_ai: cada consumidor mantém o próprio fallback paracore_ai, então um site que já temcore_aiconfigurado não precisa de nada extra — o hub continua opcional. - 👁️ Chaves pessoais write-only: uma vez salva, a chave pessoal nunca é devolvida ao navegador — a página mostra só o status configurada / não configurada, fechando a leitura via Entrar como.
- 🧑💻 Página self-service: Minhas chaves de IA (nas preferências do usuário) para adicionar/substituir/remover chaves pessoais e revisar o próprio uso recente.
- 📊 Relatório de uso para o admin: todas as chamadas a provedores atendidas pelas chaves do site, de todos os usuários, com filtros de somente falhas e somente sucessos e download CSV / Excel — protegido por
local/aihub:viewusage. - 🛡️ Guard SSRF: o endpoint compatível com OpenAI configurável é forçado a HTTPS, com bloqueio de loopback / link-local / faixas privadas e anti-rebinding de DNS A/AAAA.
- 🧾 Log de uso + task de retenção: uma linha por chamada a provedor, não por requisição (usuário, componente solicitante, o que foi gerado, provedor, modelo, tier da chave, situação e — quando falhou — o motivo dado pelo próprio provedor), e uma task agendada que limpa logs além de uma retenção configurável. Uma requisição que cai para outro provedor deixa portanto uma linha para cada um, de modo que uma chave que nunca funciona não consegue se esconder atrás daquela que respondeu.
- 🔒 Privacidade completa: Privacy provider completo para o log de uso e as preferências pessoais, com os destinos externos declarados.
🧩 Como os consumidores usam
Um plugin irmão consome o hub através de um guard em runtime e mantém o próprio fallback para core_ai:
// Disponibilidade: chaves do hub primeiro, depois o fallback core_ai do consumidor.
public static function has_ai(): bool {
if (class_exists(\local_aihub\ai::class) && \local_aihub\ai::is_available()) {
return true; // BYOK via hub
}
return self::has_core_ai(); // funciona sem o hub
}
// Geração.
$result = \local_aihub\ai::generate_text(
'', // prompt de sistema (opcional)
$prompt, // prompt do usuário
false, // modo JSON
'your_frankenstyle', // 4º arg: componente chamador, para o log de uso
'Rótulo curto' // 5º arg: o que está sendo gerado, exibido no relatório de admin
);
if ($result['success']) {
// $result['data'] é texto CRU e não-confiável — valide e passe por format_text().
}
O texto devolvido é cru e não-confiável: valide sua estrutura e passe por format_text() antes de exibir ou persistir.
Duas observações sobre o retorno. O texto gerado fica em data, e o motivo da falha em
message — ler o array como objeto devolve null sem erro nenhum, o que transforma todo
sucesso num fallback silencioso. E uma chamada pode gerar várias linhas de log: o hub registra
cada provedor que tentou, sob o componente e o rótulo que você passou, então uma requisição que
caiu para um segundo provedor aparece duas vezes no relatório, uma com falha e outra com sucesso.
🔗 Resolução de chave (escada BYOK)
O hub resolve a chave tier por tier e para no primeiro tier que tiver uma chave:
| Tier | Fonte |
|---|---|
| 1 | Chave pessoal — a chave do próprio usuário, quando as chaves pessoais estão habilitadas e o usuário tem local/aihub:usepersonalkey |
| 2 | Chave de site — a chave de admin definida nas configurações do hub |
Dentro do tier escolhido, os provedores são tentados na ordem Gemini → Groq → DeepSeek → compatível com OpenAI (a primeira chave encontrada é usada; se a chamada falhar, o próximo provedor do mesmo tier é tentado). Quando nenhum tier tem chave, generate_text() retorna success = false — o hub nunca cai para o core_ai; essa decisão é do consumidor.
📦 Requisitos
| Componente | Versão |
|---|---|
| Moodle | 4.5+ |
| PHP | 8.1+ |
Nenhuma biblioteca de terceiros empacotada. Os provedores de IA são serviços externos, declarados no Privacy provider — não em thirdpartylibs.xml.
🛠️ Instalação e Configuração
- Baixe o arquivo
.zipou clone este repositório. - Extraia a pasta para o diretório
local/do seu Moodle. - Renomeie a pasta para
aihub(se necessário). Caminho final:seu-moodle/local/aihub/ - Acesse Administração do site → Notificações para concluir a instalação.
Configurar uma chave de provedor é opcional — o plugin instala e funciona sem nenhuma. Veja Como Usar logo abaixo para definir chaves de site e habilitar chaves pessoais.
📖 Como Usar
- Configurar chaves de site (admin): Administração do site → Plugins → Plugins locais → Central de IA → Configurações. Defina qualquer uma das chaves Gemini, Groq, DeepSeek ou compatível com OpenAI e, opcionalmente, habilite as chaves de API pessoais.
- Adicionar uma chave pessoal (usuário): usuários com a capability
local/aihub:usepersonalkeyganham uma entrada Minhas chaves de IA nas preferências, onde guardam a própria chave (write-only) e veem o uso recente. - Revisar o uso das chaves de site (admin): o Relatório de uso de IA do site (sob a categoria Central de IA, capability
local/aihub:viewusage) lista todas as chamadas a provedores atendidas pelas chaves do site, de todos os usuários, com download CSV e Excel. Cada tentativa aparece em sua própria linha, com a situação e o motivo da falha quando houver, e filtros de somente falhas ou somente sucessos para checar se algum provedor parou de responder, ou para reduzir a lista de volta ao que foi de fato gerado. - Consumir de um plugin (desenvolvedor): chame
\local_aihub\ai::generate_text()atrás de um guardclass_exists(), mantendo o próprio fallback paracore_ai(ver Como os consumidores usam).
🧪 Testes Automatizados
O hub vem com uma suíte PHPUnit e Behat; todo push de CI roda contra a matriz (Moodle 4.5 → 5.x, PostgreSQL & MariaDB).
PHPUnit — Testes Unitários e de Integração
| Arquivo de teste | Casos | O que cobre |
|---|---|---|
tests/local/keys_test.php |
8 | Defaults de URL/modelo OpenAI, incluindo o fallback que só é alcançado quando um admin apaga a configuração; roundtrip get/save/clear da chave pessoal; roundtrip da URL/modelo pessoal compatível com OpenAI; personal_keys_allowed respeitando o toggle e a capability; resolução pessoal → site; has_any_key entre chaves pessoal e de site |
tests/local/client_test.php |
21 | Casos de SSRF do is_safe_url (http, loopback, faixa privada, host ausente, IP público); branch de DNS rebinding simulado via dns_stub_client; resolve_openai_url anexa /chat/completions; tier pessoal vence o de site; fall-through de provedor dentro de um tier (Gemini → Groq → DeepSeek); as tentativas são devolvidas, incluindo uma falha encoberta por um sucesso posterior e tentativas abrangendo os dois tiers; a requisição que cada provedor monta — os campos próprios de instrução de sistema e modo JSON do Gemini versus o formato chat-completions, endpoint e modelo por provedor; a perna do OpenAI resolvendo URL e modelo pessoal-depois-site campo a campo; parsing de resposta por provedor, extração de erro, falha de transporte e corpo inesperado |
tests/local/usage_log_test.php |
8 | Inserção do registro (com keysource, modelo vazio nulado); uma tentativa com falha guardando seu motivo; o filtro de situação estreitando os dois leitores nas duas direções; leitores por chave de site excluindo linhas pessoais/sem tag; o leitor de export devolvendo todas as linhas, além do limite da tela; display_name nomeando uma pessoa, o sistema ou um id que não resolve |
tests/local/export_test.php |
3 | Export do uso pessoal (todas as linhas, todas as colunas); uma tentativa com falha exportando situação e motivo; export do relatório de chaves de site incluindo a coluna de usuário e excluindo o uso pessoal |
tests/ai_test.php |
6 | Estados de is_available; uma geração bem-sucedida registra o componente chamador, a descrição e o tier da chave; nenhum provedor chamado não registra nada; toda tentativa é registrada, então uma falha encoberta por um sucesso posterior deixa duas linhas; report_usage() gravando uma linha para um consumidor que resolveu a própria chave, com sucesso ou falha |
tests/db_upgrade_test.php |
3 | O passo de upgrade adiciona a coluna com o tipo, o tamanho e a nulabilidade que o install.xml declara; linhas existentes mantêm seus dados e são lidas como sucessos; uma segunda execução é no-op |
tests/lib_test.php |
4 | A entrada de navegação é adicionada na página de preferências do próprio usuário, e não na de outra pessoa, nem sem a capability, nem com as chaves pessoais desligadas no site |
tests/privacy_provider_test.php |
9 | Declaração de metadata; descoberta de contexto/usuário; export_user_data (linhas do log + valor da chave redigido); os leitores recusando um contexto que não é de usuário e um usuário sem linhas; deleção por usuário, deleção do contexto inteiro e deleção por lista aprovada, cada uma com isolamento |
tests/output/mykeys_test.php |
4 | Status de chave pessoal por provedor (definida/não definida), sem nunca colocar o valor da chave no contexto do template; linhas de uso com o ícone de provedor correto, incluindo o fallback para provedor não reconhecido; uma tentativa com falha exibindo seu motivo; a página renderizando de ponta a ponta sem chave armazenada no HTML |
tests/output/report_test.php |
9 | Linhas do relatório de chaves de site com o nome do usuário solicitante e o ícone de provedor correto, excluindo linhas de chave pessoal; estado vazio; linhas com falha carregando seu motivo; cada um dos três filtros estreitando para a própria situação, com exatamente um marcado como ativo e um filtro desconhecido caindo para a lista completa; a mensagem de vazio por filtro; uma requisição sem usuário atribuída ao sistema; uma linha gravada antes de as colunas de falha existirem ainda sendo lida como sucesso; a página renderizando de ponta a ponta |
tests/task/purge_old_logs_test.php |
3 | Linhas mais antigas que a retenção configurada são apagadas, linhas mais recentes são mantidas; retenção 0 mantém todas as linhas indefinidamente; a tarefa se nomeia a partir de uma string de idioma |
| Total | 78 |
vendor/bin/phpunit --testsuite local_aihub
Cobertura de linhas por classe (PHPUnit + Xdebug):
| Classe | Cobertura de linhas |
|---|---|
ai |
95% |
local\client |
91% |
local\export |
86% |
local\keys |
100% |
local\usage_log |
100% |
output\mykeys |
100% |
output\renderer |
100% |
output\report |
100% |
privacy\provider |
100% |
task\purge_old_logs |
100% |
lib.php |
100% |
| Total | 96% |
A camada de transporte ficava fora da suíte, sob o argumento de que faz chamadas HTTP reais. Esse
argumento cobria mais terreno do que devia: montar uma requisição e interpretar uma resposta não
são operações de rede, e deixá-las para o dublê significava que a parte do plugin que conversa com
os provedores — inclusive o código que transforma uma recusa da API na frase que o administrador
lê — era a única que nada verificava. Duas fixtures alcançam isso agora: recording_client roda os
call_* reais e captura o que eles montaram, e stub_transport_client responde a partir de um
transporte simulado, para o parsing rodar de verdade, através de uma única costura (make_curl()).
O que continua descoberto está genuinamente fora do alcance de um teste unitário:
local\export—download()edownload_site()repassam para a API de dataformat e então chamamdie(). As linhas que eles carregam estão cobertas; a resposta em si é coberta pelo Behat abaixo.local\client—resolve_dns()faz uma consulta DNS real, emake_curl()existe para ser substituído. A decisão que depende da consulta, incluindo o branch de DNS rebinding, está totalmente coberta viadns_stub_client.ai— uma linha: a guarda que recusaset_client_for_testing()fora de um teste, que por definição não pode rodar dentro de um.
Behat — Testes de Aceitação
| Arquivo de feature | Cenários | O que cobre |
|---|---|---|
tests/behat/mykeys.feature |
5 | Um provedor começa não configurado; salvar uma chave pessoal marca-a como configurada sem revelar o valor guardado; uma tentativa com falha do próprio usuário exibindo seu motivo; o histórico pessoal baixando em CSV e em Excel |
tests/behat/report.feature |
7 | As duas tentativas de uma mesma requisição listadas, com o motivo pelo qual a primeira falhou; o filtro de falhas escondendo o que funcionou, o de sucessos escondendo o que falhou, e a volta à lista completa; uma requisição feita fora de uma sessão atribuída em vez de exibida como id; o relatório baixando em CSV e em Excel |
| Total | 12 |
php admin/tool/behat/cli/init.php
vendor/bin/behat --tags=@local_aihub --profile=chrome
🔐 Segurança e Conformidade
- Chaves pessoais protegidas por capability e opt-in (
local/aihub:usepersonalkeye o toggle de site). - Chaves pessoais write-only — nunca devolvidas à página após salvas, mesmo sob Entrar como.
- Chaves de site guardadas com
admin_setting_configpasswordunmask; o guard SSRF protege o endpoint configurável. require_sesskey()no formulário de chaves; capabilitylocal/aihub:viewusageno relatório de uso.- Cobertura completa da Privacy API (export/delete do log de uso e das preferências pessoais) com os destinos externos declarados.
🔎 Divulgação de Serviço de Terceiros
O hub transmite o texto do prompt a um provedor de IA de terceiros apenas quando uma chave está configurada e uma geração é solicitada.
Uma chave de API é obrigatória?
Não. O plugin instala e funciona sem nenhuma chave — apenas informa que não há fonte disponível, e os consumidores caem para a própria integração com core_ai. Nenhuma requisição externa é feita até que uma chave de site ou pessoal seja definida.
Provedores suportados
- Google Gemini — https://ai.google.dev/ — modelo:
gemini-flash-latest(alias rolante, sempre a versão Flash atual do Google; fixo, não configurável) - Groq — https://console.groq.com/ — modelo:
openai/gpt-oss-120b(fixo, não configurável; ver Escolha do modelo Groq abaixo) - DeepSeek — https://deepseek.com/ — modelo:
deepseek-flash(fixo, não configurável) - APIs compatíveis com OpenAI — qualquer provedor que siga o formato da API OpenAI (OpenRouter, modelos auto-hospedados via LM Studio, um proxy Ollama, etc.) — modelo: configurável por chave (config de site / chave pessoal), padrão
gpt-4o-miniquando vazio
Esses serviços operam sob seus próprios termos de serviço e políticas de privacidade.
Escolha do modelo Groq
O slot da Groq chama um único modelo fixo, openai/gpt-oss-120b. A Groq descontinua periodicamente
IDs de modelo específicos (ex.: llama-3.3-70b-versatile, descomissionado em 16/08/2026) e espera
migração para um substituto nomeado. Entre as opções gratuitas recomendadas pela Groq para essa
migração, o openai/gpt-oss-120b foi escolhido em vez do qwen/qwen3.6-27b por seguir instruções
melhor e ser mais confiável em modo JSON entre os prompts variados enviados pelos diferentes
plugins consumidores.
Como obter uma chave de API
As chaves de API são criadas diretamente no site oficial do provedor. Gemini, Groq e DeepSeek atualmente oferecem camadas de uso gratuitas ou créditos de teste (as políticas de preço podem mudar). O hub não fornece chaves de API.
Onde as chaves são configuradas
- Chave pessoal — definida por cada usuário em Minhas chaves de IA (preferências), quando as chaves pessoais estão habilitadas e o usuário tem a capability.
- Chave de site — definida pelo admin em Administração do site → Plugins → Plugins locais → Central de IA.
Transmissão de Dados
Quando uma chave é resolvida para um provedor, o texto do prompt é transmitido à API desse provedor para gerar a resposta:
- Google Gemini —
generativelanguage.googleapis.com - Groq —
api.groq.com - DeepSeek —
api.deepseek.com - Compatível com OpenAI — o endpoint configurado pelo admin ou usuário (padrão
api.openai.com)
O hub guarda um log de uso (quem solicitou, qual componente, um rótulo curto do que foi gerado, provedor, modelo, tier da chave e horário), mas não guarda prompts nem respostas da IA. Todos os destinos externos estão declarados no Privacy provider do plugin.
Credenciais de demonstração
Não aplicável — nenhuma credencial é exigida para instalar ou usar o hub. Todo recurso de IA fica inerte até que uma chave de site ou pessoal seja configurada.
📄 Licença
Este projeto está licenciado sob a GNU General Public License v3 (GPLv3).
Copyright: 2026 Jean Lúcio