chore(captain): PoC Codex OAuth device flow + Responses streaming
PoC validado com conta ChatGPT Plus e client_id do Hermes. Device flow
OAuth funciona, gera access_token + refresh_token auto-refresh. Chat e
function calling funcionaram em gpt-5.4, gpt-5.4-mini, gpt-5.2 e
gpt-5.3-codex.
Descobertas pro adapter final:
- Endpoint: /responses (não /chat/completions)
- Streaming obrigatório (stream: true)
- store: false obrigatório
- Sem temperature/top_p (modelos reasoning)
- input[] no lugar de messages[]
- instructions top-level no lugar de system role
- Tools sem wrapping function: {}
- Output via events response.output_item.done (não response.completed)
Pasta scripts/captain_codex_poc/ está excluída do Rubocop (scripts
standalone, não rodam em contexto Rails).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c512e3e5f6
commit
df56ee8115
@ -240,6 +240,7 @@ AllCops:
|
||||
- 'reference/**/*'
|
||||
- '.aios-core/**/*'
|
||||
- '.claude/**/*'
|
||||
- 'scripts/captain_codex_poc/**/*'
|
||||
|
||||
FactoryBot/SyntaxMethods:
|
||||
Enabled: false
|
||||
|
||||
1
scripts/captain_codex_poc/.gitignore
vendored
Normal file
1
scripts/captain_codex_poc/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
tokens.json
|
||||
65
scripts/captain_codex_poc/README.md
Normal file
65
scripts/captain_codex_poc/README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Captain Codex OAuth PoC
|
||||
|
||||
Proof-of-concept para validar se a assinatura do ChatGPT Plus pode ser usada no Captain AI via OAuth device flow, reutilizando o `client_id` do Hermes.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
1. **Assinatura ChatGPT Plus ativa** na conta `borbamachadoo@gmail.com`
|
||||
2. Ruby 3.x instalado (não precisa do bundle do Chatwoot — só stdlib)
|
||||
|
||||
## Passos
|
||||
|
||||
### 1) Login (device flow)
|
||||
|
||||
```bash
|
||||
ruby scripts/captain_codex_poc/login.rb
|
||||
```
|
||||
|
||||
- Vai imprimir uma URL + um código
|
||||
- Abra a URL no browser, faça login com `borbamachadoo@gmail.com`, cole o código
|
||||
- Script detecta a autorização e salva tokens em `scripts/captain_codex_poc/tokens.json`
|
||||
|
||||
### 2) Teste de chat simples
|
||||
|
||||
```bash
|
||||
ruby scripts/captain_codex_poc/test_chat.rb
|
||||
```
|
||||
|
||||
Faz uma chamada simples `POST /chat/completions` com `gpt-5.4` e imprime a resposta.
|
||||
|
||||
**Critério de sucesso:** resposta HTTP 200 com conteúdo coerente em português.
|
||||
|
||||
### 3) Teste de function calling
|
||||
|
||||
```bash
|
||||
ruby scripts/captain_codex_poc/test_tools.rb
|
||||
```
|
||||
|
||||
Faz chamada com uma tool `gerar_pix` simulada e verifica se o modelo:
|
||||
- Reconhece que precisa chamar a tool
|
||||
- Retorna `tool_calls` com `function.name` e `function.arguments` corretos
|
||||
|
||||
**Critério de sucesso:** `tool_calls` não-nulo e JSON de argumentos válido.
|
||||
|
||||
**Critério de go/no-go do projeto Codex OAuth:** se este teste falhar (modelo não suporta function calling via endpoint Codex), **abortamos a implementação**. Os tools do Captain (Pix, reservas, labels) são pré-requisito não-negociável.
|
||||
|
||||
### 4) Comparação de qualidade (manual)
|
||||
|
||||
```bash
|
||||
ruby scripts/captain_codex_poc/test_jasmine_like.rb
|
||||
```
|
||||
|
||||
Simula uma conversa estilo Jasmine — cliente pedindo reserva. Compare subjetivamente a resposta com o que a Jasmine faz hoje em produção com `gpt-4o`.
|
||||
|
||||
## Arquivos
|
||||
|
||||
- `login.rb` — device flow
|
||||
- `test_chat.rb` — smoke test /chat/completions
|
||||
- `test_tools.rb` — function calling
|
||||
- `test_jasmine_like.rb` — qualidade conversacional
|
||||
- `tokens.json` — access_token + refresh_token (git-ignored)
|
||||
- `codex_client.rb` — helper compartilhado (refresh + HTTP)
|
||||
|
||||
## Segurança
|
||||
|
||||
O arquivo `tokens.json` contém credenciais OAuth reais e **NUNCA deve ser commitado**. Já está no `.gitignore` desta pasta.
|
||||
193
scripts/captain_codex_poc/codex_client.rb
Executable file
193
scripts/captain_codex_poc/codex_client.rb
Executable file
@ -0,0 +1,193 @@
|
||||
require 'net/http'
|
||||
require 'uri'
|
||||
require 'json'
|
||||
require 'time'
|
||||
|
||||
module CodexPoc
|
||||
CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'.freeze
|
||||
ISSUER = 'https://auth.openai.com'.freeze
|
||||
TOKEN_URL = "#{ISSUER}/oauth/token".freeze
|
||||
API_BASE = 'https://chatgpt.com/backend-api/codex'.freeze
|
||||
TOKENS_PATH = File.expand_path('tokens.json', __dir__).freeze
|
||||
REFRESH_SKEW_SECONDS = 120
|
||||
|
||||
class Error < StandardError; end
|
||||
|
||||
class Client
|
||||
def initialize
|
||||
load_tokens!
|
||||
end
|
||||
|
||||
# Chama a Responses API do Codex em modo streaming (obrigatório nesse endpoint).
|
||||
# Agrega o texto e tool_calls dos eventos SSE e retorna como objeto consolidado.
|
||||
def responses(model:, system_prompt: nil, user_messages:, tools: nil, max_tokens: nil)
|
||||
refresh_if_expiring!
|
||||
|
||||
input = user_messages.is_a?(String) ? [{ role: 'user', content: user_messages }] : user_messages
|
||||
|
||||
body = {
|
||||
model: model,
|
||||
input: input,
|
||||
store: false,
|
||||
stream: true
|
||||
}
|
||||
body[:instructions] = system_prompt if system_prompt
|
||||
body[:tools] = tools if tools
|
||||
body[:tool_choice] = 'auto' if tools
|
||||
body[:max_output_tokens] = max_tokens if max_tokens
|
||||
|
||||
stream_responses("#{API_BASE}/responses", body)
|
||||
end
|
||||
|
||||
# Extrai texto + tool_calls do output consolidado.
|
||||
def self.extract(resp)
|
||||
output = resp['output'] || []
|
||||
text_parts = []
|
||||
tool_calls = []
|
||||
|
||||
output.each do |item|
|
||||
case item['type']
|
||||
when 'message'
|
||||
Array(item['content']).each do |part|
|
||||
text_parts << part['text'] if part['type'] == 'output_text' && part['text']
|
||||
end
|
||||
when 'function_call'
|
||||
tool_calls << {
|
||||
name: item['name'],
|
||||
arguments: item['arguments'],
|
||||
call_id: item['call_id'] || item['id']
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
{ text: text_parts.join("\n"), tool_calls: tool_calls, raw_output: output }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_tokens!
|
||||
unless File.exist?(TOKENS_PATH)
|
||||
raise Error, "Tokens não encontrados em #{TOKENS_PATH}. Rode primeiro: ruby scripts/captain_codex_poc/login.rb"
|
||||
end
|
||||
data = JSON.parse(File.read(TOKENS_PATH))
|
||||
@access_token = data.fetch('access_token')
|
||||
@refresh_token = data.fetch('refresh_token')
|
||||
@expires_at = data['expires_at'] ? Time.parse(data['expires_at']) : nil
|
||||
end
|
||||
|
||||
def refresh_if_expiring!
|
||||
return unless @expires_at
|
||||
return if Time.now < @expires_at - REFRESH_SKEW_SECONDS
|
||||
|
||||
puts "[codex_client] Refrescando token (expira em #{@expires_at})..."
|
||||
resp = post_form(TOKEN_URL, {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: @refresh_token,
|
||||
client_id: CLIENT_ID
|
||||
})
|
||||
|
||||
@access_token = resp.fetch('access_token')
|
||||
@refresh_token = resp['refresh_token'] || @refresh_token
|
||||
@expires_at = Time.now + Integer(resp['expires_in'] || 3600)
|
||||
persist_tokens!
|
||||
end
|
||||
|
||||
def persist_tokens!
|
||||
File.write(TOKENS_PATH, JSON.pretty_generate({
|
||||
access_token: @access_token,
|
||||
refresh_token: @refresh_token,
|
||||
expires_at: @expires_at.iso8601
|
||||
}))
|
||||
end
|
||||
|
||||
# Consome o stream SSE da Responses API.
|
||||
# Agrega os items finalizados (output_item.done) + o usage do response.completed.
|
||||
# Retorna { "output" => [...items...], "usage" => {...} }
|
||||
def stream_responses(url, body)
|
||||
uri = URI(url)
|
||||
items = []
|
||||
usage = nil
|
||||
completed = false
|
||||
final_error = nil
|
||||
|
||||
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 120) do |http|
|
||||
req = Net::HTTP::Post.new(uri)
|
||||
req['Content-Type'] = 'application/json'
|
||||
req['Authorization'] = "Bearer #{@access_token}"
|
||||
req['Accept'] = 'text/event-stream'
|
||||
req.body = JSON.generate(body)
|
||||
|
||||
http.request(req) do |resp|
|
||||
unless resp.is_a?(Net::HTTPSuccess)
|
||||
err_body = +''
|
||||
resp.read_body { |chunk| err_body << chunk }
|
||||
raise Error, "HTTP #{resp.code} em #{url}: #{err_body[0, 800]}"
|
||||
end
|
||||
|
||||
buffer = +''
|
||||
resp.read_body do |chunk|
|
||||
buffer << chunk
|
||||
while (idx = buffer.index("\n\n"))
|
||||
event = buffer.slice!(0, idx + 2)
|
||||
parsed = parse_sse_event(event)
|
||||
next unless parsed
|
||||
|
||||
case parsed[:event]
|
||||
when 'response.output_item.done'
|
||||
items << parsed[:data]['item'] if parsed[:data]['item']
|
||||
when 'response.completed'
|
||||
usage = parsed[:data].dig('response', 'usage')
|
||||
completed = true
|
||||
when 'response.failed', 'error'
|
||||
final_error = parsed[:data]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
raise Error, "Stream falhou: #{final_error.inspect[0, 500]}" if final_error
|
||||
raise Error, 'Stream terminou sem response.completed' unless completed
|
||||
|
||||
{ 'output' => items, 'usage' => usage }
|
||||
end
|
||||
|
||||
# Parseia um bloco SSE ("event: foo\ndata: {...}\n\n")
|
||||
def parse_sse_event(raw)
|
||||
event_name = nil
|
||||
data_lines = []
|
||||
raw.each_line do |line|
|
||||
line = line.chomp
|
||||
next if line.empty?
|
||||
if line.start_with?('event:')
|
||||
event_name = line.sub('event:', '').strip
|
||||
elsif line.start_with?('data:')
|
||||
data_lines << line.sub('data:', '').strip
|
||||
end
|
||||
end
|
||||
return nil if data_lines.empty?
|
||||
data_str = data_lines.join("\n")
|
||||
return nil if data_str == '[DONE]'
|
||||
parsed = JSON.parse(data_str) rescue nil
|
||||
return nil unless parsed
|
||||
{ event: event_name || parsed['type'] || 'message', data: parsed }
|
||||
end
|
||||
|
||||
def post_form(url, params)
|
||||
uri = URI(url)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
http.read_timeout = 30
|
||||
|
||||
req = Net::HTTP::Post.new(uri)
|
||||
req['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
req.body = URI.encode_www_form(params)
|
||||
|
||||
resp = http.request(req)
|
||||
unless resp.is_a?(Net::HTTPSuccess)
|
||||
raise Error, "HTTP #{resp.code} em #{url}: #{resp.body}"
|
||||
end
|
||||
JSON.parse(resp.body)
|
||||
end
|
||||
end
|
||||
end
|
||||
114
scripts/captain_codex_poc/login.rb
Executable file
114
scripts/captain_codex_poc/login.rb
Executable file
@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Device code flow OAuth com OpenAI — reutiliza client_id do Hermes.
|
||||
# Salva tokens em scripts/captain_codex_poc/tokens.json.
|
||||
|
||||
require 'net/http'
|
||||
require 'uri'
|
||||
require 'json'
|
||||
require 'time'
|
||||
|
||||
CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'.freeze
|
||||
ISSUER = 'https://auth.openai.com'.freeze
|
||||
TOKENS_PATH = File.expand_path('tokens.json', __dir__).freeze
|
||||
|
||||
def http_json(method, url, body: nil, headers: {}, form: false)
|
||||
uri = URI(url)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
http.read_timeout = 30
|
||||
|
||||
req_class = method == :get ? Net::HTTP::Get : Net::HTTP::Post
|
||||
req = req_class.new(uri)
|
||||
headers.each { |k, v| req[k] = v }
|
||||
|
||||
if body
|
||||
if form
|
||||
req['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
req.body = URI.encode_www_form(body)
|
||||
else
|
||||
req['Content-Type'] = 'application/json'
|
||||
req.body = JSON.generate(body)
|
||||
end
|
||||
end
|
||||
|
||||
resp = http.request(req)
|
||||
[resp.code.to_i, (resp.body.nil? || resp.body.empty? ? {} : (JSON.parse(resp.body) rescue { 'raw' => resp.body }))]
|
||||
end
|
||||
|
||||
puts '=== Captain Codex OAuth — Device Flow ==='
|
||||
puts
|
||||
|
||||
# Passo 1: Pedir device code
|
||||
puts '[1/4] Solicitando device code...'
|
||||
status, data = http_json(:post, "#{ISSUER}/api/accounts/deviceauth/usercode", body: { client_id: CLIENT_ID })
|
||||
abort "Falhou no device code (HTTP #{status}): #{data}" unless status == 200
|
||||
|
||||
user_code = data.fetch('user_code')
|
||||
device_auth_id = data.fetch('device_auth_id')
|
||||
poll_interval = [3, (data['interval'] || 5).to_i].max
|
||||
|
||||
puts
|
||||
puts '[2/4] Abra o browser na URL abaixo e cole o código:'
|
||||
puts
|
||||
puts " URL: \e[36m#{ISSUER}/codex/device\e[0m"
|
||||
puts " Code: \e[93m#{user_code}\e[0m"
|
||||
puts
|
||||
puts "Aguardando autorização (timeout 15min, Ctrl+C para cancelar)..."
|
||||
|
||||
# Passo 2: Polling
|
||||
auth_code = nil
|
||||
verifier = nil
|
||||
deadline = Time.now + (15 * 60)
|
||||
|
||||
loop do
|
||||
abort "\nTimeout de 15min atingido." if Time.now > deadline
|
||||
sleep poll_interval
|
||||
code, resp = http_json(:post, "#{ISSUER}/api/accounts/deviceauth/token", body: {
|
||||
device_auth_id: device_auth_id,
|
||||
user_code: user_code
|
||||
})
|
||||
|
||||
case code
|
||||
when 200
|
||||
auth_code = resp.fetch('authorization_code')
|
||||
verifier = resp.fetch('code_verifier')
|
||||
break
|
||||
when 403, 404
|
||||
print '.'
|
||||
next
|
||||
else
|
||||
abort "\nErro no polling (HTTP #{code}): #{resp}"
|
||||
end
|
||||
end
|
||||
|
||||
puts "\n[3/4] Autorização recebida, trocando por tokens..."
|
||||
|
||||
# Passo 3: Exchange code -> tokens
|
||||
redirect_uri = "#{ISSUER}/deviceauth/callback"
|
||||
status, tokens = http_json(:post, "#{ISSUER}/oauth/token", body: {
|
||||
grant_type: 'authorization_code',
|
||||
code: auth_code,
|
||||
redirect_uri: redirect_uri,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: verifier
|
||||
}, form: true)
|
||||
|
||||
abort "Token exchange falhou (HTTP #{status}): #{tokens}" unless status == 200
|
||||
|
||||
access_token = tokens.fetch('access_token')
|
||||
refresh_token = tokens.fetch('refresh_token')
|
||||
expires_in = Integer(tokens['expires_in'] || 3600)
|
||||
expires_at = Time.now + expires_in
|
||||
|
||||
File.write(TOKENS_PATH, JSON.pretty_generate({
|
||||
access_token: access_token,
|
||||
refresh_token: refresh_token,
|
||||
expires_at: expires_at.iso8601
|
||||
}))
|
||||
|
||||
puts
|
||||
puts "[4/4] Sucesso!"
|
||||
puts " Tokens salvos em: #{TOKENS_PATH}"
|
||||
puts " Access token expira em: #{expires_at}"
|
||||
puts
|
||||
puts "Próximo passo: ruby scripts/captain_codex_poc/test_chat.rb"
|
||||
31
scripts/captain_codex_poc/test_chat.rb
Executable file
31
scripts/captain_codex_poc/test_chat.rb
Executable file
@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Smoke test: /responses com vários modelos via Codex OAuth.
|
||||
|
||||
require_relative 'codex_client'
|
||||
|
||||
# Testa modelos na ordem do que o Rodrigo quer (gpt-5.4 primeiro).
|
||||
# Inclui gpt-5.3-codex porque é o que o Hermes usa com sucesso.
|
||||
MODELS_TO_TRY = %w[gpt-5.4 gpt-5.4-mini gpt-5.2 gpt-5.3-codex].freeze
|
||||
|
||||
client = CodexPoc::Client.new
|
||||
|
||||
MODELS_TO_TRY.each do |model|
|
||||
puts "=== Testando modelo: #{model} ==="
|
||||
begin
|
||||
resp = client.responses(
|
||||
model: model,
|
||||
system_prompt: 'Você é um recepcionista dos Hoteis 1001 Noites. Responda em português do Brasil, de forma breve e direta.',
|
||||
user_messages: 'Oi, boa tarde. Queria saber se tem diária disponível para esse fim de semana.'
|
||||
)
|
||||
|
||||
out = CodexPoc::Client.extract(resp)
|
||||
puts "Resposta: #{out[:text]}"
|
||||
puts "Usage: #{resp['usage']}"
|
||||
puts
|
||||
rescue CodexPoc::Error => e
|
||||
warn "FALHOU para #{model}: #{e.message[0, 300]}"
|
||||
puts
|
||||
end
|
||||
end
|
||||
|
||||
puts '=== Fim do teste de chat ==='
|
||||
49
scripts/captain_codex_poc/test_debug.rb
Executable file
49
scripts/captain_codex_poc/test_debug.rb
Executable file
@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Debug: imprime os eventos SSE crus pra entender o formato exato que a Codex API devolve.
|
||||
|
||||
require 'net/http'
|
||||
require 'uri'
|
||||
require 'json'
|
||||
require_relative 'codex_client'
|
||||
|
||||
tokens = JSON.parse(File.read(CodexPoc::TOKENS_PATH))
|
||||
access_token = tokens.fetch('access_token')
|
||||
|
||||
uri = URI("#{CodexPoc::API_BASE}/responses")
|
||||
body = {
|
||||
model: 'gpt-5.4',
|
||||
input: [{ role: 'user', content: 'Diga em uma frase curta: qual a capital do Brasil?' }],
|
||||
instructions: 'Seja breve.',
|
||||
store: false,
|
||||
stream: true
|
||||
}
|
||||
|
||||
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60) do |http|
|
||||
req = Net::HTTP::Post.new(uri)
|
||||
req['Content-Type'] = 'application/json'
|
||||
req['Authorization'] = "Bearer #{access_token}"
|
||||
req['Accept'] = 'text/event-stream'
|
||||
req.body = JSON.generate(body)
|
||||
|
||||
http.request(req) do |resp|
|
||||
puts "Status: #{resp.code}"
|
||||
if resp.code.to_i != 200
|
||||
err = +''
|
||||
resp.read_body { |c| err << c }
|
||||
puts "Erro: #{err}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
buffer = +''
|
||||
resp.read_body do |chunk|
|
||||
buffer << chunk
|
||||
while (idx = buffer.index("\n\n"))
|
||||
event = buffer.slice!(0, idx + 2)
|
||||
puts '--- SSE EVENT ---'
|
||||
puts event
|
||||
end
|
||||
end
|
||||
puts '--- FIM (buffer remanescente) ---'
|
||||
puts buffer
|
||||
end
|
||||
end
|
||||
66
scripts/captain_codex_poc/test_jasmine_like.rb
Executable file
66
scripts/captain_codex_poc/test_jasmine_like.rb
Executable file
@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Qualidade conversacional: simula a Jasmine via /responses.
|
||||
|
||||
require_relative 'codex_client'
|
||||
|
||||
JASMINE_SYSTEM = <<~PROMPT.freeze
|
||||
Você é a Jasmine, recepcionista dos Hoteis 1001 Noites. Atende no WhatsApp.
|
||||
|
||||
Regras:
|
||||
- Sempre em português do Brasil, tom acolhedor mas direto, frases curtas
|
||||
- Se souber o nome do cliente, SEMPRE começa com "Oi, {primeiro_nome}!"
|
||||
- Nunca invente preços, diárias ou disponibilidade — se não souber, diga que vai verificar
|
||||
- Identifique o cenário em 1-2 mensagens: pré-reserva, check-in, reclamação, dúvida geral
|
||||
- Se for pré-reserva, colete: datas, número de hóspedes, tipo de quarto, forma de pagamento
|
||||
|
||||
Unidades:
|
||||
- 1001 Noites Prime (Brasília)
|
||||
- 1001 Noites Express (Águas Lindas)
|
||||
- Dolce Amore (Natal)
|
||||
PROMPT
|
||||
|
||||
SCENARIOS = [
|
||||
{
|
||||
name: 'Pré-reserva com nome',
|
||||
contact_name: 'Rodrigo Borba Machado',
|
||||
user: 'Oi, boa tarde. Queria ver uma diária para esse fim de semana'
|
||||
},
|
||||
{
|
||||
name: 'Cliente anônimo',
|
||||
contact_name: nil,
|
||||
user: 'vcs tem quarto pra essa sexta?'
|
||||
},
|
||||
{
|
||||
name: 'Reclamação',
|
||||
contact_name: 'Maria Silva',
|
||||
user: 'Acabei de sair do hotel e o ar condicionado não funcionou a noite toda. Paguei R$400 por noite, isso é uma falta de respeito.'
|
||||
}
|
||||
].freeze
|
||||
|
||||
MODEL = ENV.fetch('CODEX_MODEL', 'gpt-5.4')
|
||||
|
||||
client = CodexPoc::Client.new
|
||||
puts "Modelo: #{MODEL}\n\n"
|
||||
|
||||
SCENARIOS.each do |scenario|
|
||||
puts "=== #{scenario[:name]} ==="
|
||||
puts "Contato: #{scenario[:contact_name] || '(desconhecido)'}"
|
||||
puts "Cliente: #{scenario[:user]}"
|
||||
puts
|
||||
|
||||
system_prompt = JASMINE_SYSTEM.dup
|
||||
system_prompt += "\n\nDados do contato: nome = #{scenario[:contact_name]}." if scenario[:contact_name]
|
||||
|
||||
begin
|
||||
resp = client.responses(
|
||||
model: MODEL,
|
||||
system_prompt: system_prompt,
|
||||
user_messages: scenario[:user]
|
||||
)
|
||||
out = CodexPoc::Client.extract(resp)
|
||||
puts "Jasmine: #{out[:text]}"
|
||||
rescue CodexPoc::Error => e
|
||||
warn "FALHOU: #{e.message[0, 300]}"
|
||||
end
|
||||
puts "\n---\n\n"
|
||||
end
|
||||
66
scripts/captain_codex_poc/test_tools.rb
Executable file
66
scripts/captain_codex_poc/test_tools.rb
Executable file
@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Go/no-go crítico: function calling via /responses.
|
||||
|
||||
require_relative 'codex_client'
|
||||
|
||||
# Formato Responses API: tools sem wrapping `function: {...}`
|
||||
TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'gerar_pix',
|
||||
description: 'Gera um Pix para pagamento de reserva de hotel. Use apenas após ter CPF e nome do hóspede.',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cpf: { type: 'string', description: 'CPF do hóspede, apenas dígitos' },
|
||||
nome: { type: 'string', description: 'Nome completo do hóspede' },
|
||||
valor: { type: 'number', description: 'Valor em reais' },
|
||||
descricao: { type: 'string', description: 'Descrição da reserva' }
|
||||
},
|
||||
required: %w[cpf nome valor descricao]
|
||||
}
|
||||
}
|
||||
].freeze
|
||||
|
||||
SYSTEM = 'Você é um recepcionista de hotel. Quando tiver CPF e nome do hóspede, chame a tool gerar_pix para emitir o pagamento. Nunca invente dados.'.freeze
|
||||
USER = 'Oi, quero fechar a reserva. Meu nome é Rodrigo Borba Machado, CPF 123.456.789-00. O valor era R$ 320 por uma diária no Prime.'.freeze
|
||||
|
||||
MODELS_TO_TRY = %w[gpt-5.4 gpt-5.4-mini gpt-5.2 gpt-5.3-codex].freeze
|
||||
|
||||
client = CodexPoc::Client.new
|
||||
|
||||
MODELS_TO_TRY.each do |model|
|
||||
puts "=== Function calling — modelo: #{model} ==="
|
||||
begin
|
||||
resp = client.responses(
|
||||
model: model,
|
||||
system_prompt: SYSTEM,
|
||||
user_messages: USER,
|
||||
tools: TOOLS
|
||||
)
|
||||
|
||||
out = CodexPoc::Client.extract(resp)
|
||||
|
||||
if out[:tool_calls].empty?
|
||||
warn "[FAIL] #{model} NÃO chamou a tool. Texto retornado:"
|
||||
warn " #{out[:text][0, 400]}"
|
||||
else
|
||||
call = out[:tool_calls].first
|
||||
begin
|
||||
args = JSON.parse(call[:arguments])
|
||||
puts "[PASS] #{model} chamou tool '#{call[:name]}' com args:"
|
||||
puts JSON.pretty_generate(args)
|
||||
rescue JSON::ParserError => e
|
||||
warn "[FAIL] #{model} chamou tool mas args não são JSON: #{call[:arguments].inspect} (#{e.message})"
|
||||
end
|
||||
end
|
||||
puts
|
||||
rescue CodexPoc::Error => e
|
||||
warn "FALHOU para #{model}: #{e.message[0, 300]}"
|
||||
puts
|
||||
end
|
||||
end
|
||||
|
||||
puts '=== Fim do teste de function calling ==='
|
||||
puts 'GO/NO-GO: se ao menos gpt-5.4 (ou gpt-5.3-codex) passou com args JSON válidos, seguimos.'
|
||||
Loading…
Reference in New Issue
Block a user