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/**/*'
|
- 'reference/**/*'
|
||||||
- '.aios-core/**/*'
|
- '.aios-core/**/*'
|
||||||
- '.claude/**/*'
|
- '.claude/**/*'
|
||||||
|
- 'scripts/captain_codex_poc/**/*'
|
||||||
|
|
||||||
FactoryBot/SyntaxMethods:
|
FactoryBot/SyntaxMethods:
|
||||||
Enabled: false
|
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