Add OCI Grok web-search chatbot example and selected validation evidence

This commit is contained in:
Marcos Lohmann
2026-10-14 11:01:59 -03:00
commit 53ebadd8de
12 changed files with 357 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.env
.venv
evidence
__pycache__
.git

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
OCI_BASE_URL=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/v1
OCI_API_KEY=replace-with-oci-generative-ai-api-key
OCI_PROJECT_ID=replace-with-active-chicago-project-id
APP_PASSWORD=replace-with-a-strong-password
APP_BASE_URL=http://localhost:8080/

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Credenciais e ambientes locais
.env
.env.*
!.env.example
.venv/
__pycache__/
*.py[cod]
# Diagnósticos e configuração específicos do ambiente
deploy.py
browser_check.py
render_check.py
# Compartilhar somente evidências selecionadas
evidence/*
!evidence/RELATORIO.md
!evidence/validation-summary.json

8
Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
COPY static static
USER 65534:65534
CMD ["uvicorn","app:app","--host","0.0.0.0","--port","8080","--root-path","/grok-poc"]

131
README.md Normal file
View File

@@ -0,0 +1,131 @@
# Grok Web Search via OCI
POC de chatbot com interface JavaScript e backend Python/FastAPI. Usa `xai.grok-4.3` pela OCI Generative AI e habilita a ferramenta nativa `web_search` na Responses API. A aplicação chama exclusivamente o endpoint OCI.
## Estrutura
```text
.
├── app.py # Backend e autenticação
├── static/
│ ├── index.html # Interface e estilos
│ └── app.js # Chat, histórico e citações
├── requirements.txt
├── Dockerfile
├── .dockerignore
├── .gitignore
├── .env.example # Variáveis sem credenciais
├── validate.py # Validação REST com inferência real
└── evidence/
├── RELATORIO.md # Resultados da POC
└── validation-summary.json # Evidências selecionadas
```
## Configuração
Requer Python 3.12 ou Docker, uma chave OCI Generative AI e um projeto GenAI ativo na região `us-chicago-1`, com permissão para inferência e Responses API.
Na raiz do repositório:
```bash
cp .env.example .env
chmod 600 .env
```
Preencha `.env`:
| Variável | Finalidade |
|---|---|
| `OCI_BASE_URL` | `https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/v1` |
| `OCI_API_KEY` | Chave OCI Generative AI |
| `OCI_PROJECT_ID` | Identificador de projeto ativo em Chicago |
| `APP_PASSWORD` | Senha forte para o usuário `poc` |
| `APP_BASE_URL` | URL pública ou local, com o prefixo de publicação; usada por validate.py |
`.env` é ignorado pelo Git e pelo build Docker. As credenciais OCI ficam no backend. O histórico de conversa permanece na página e é reenviado a cada turno.
## Executar localmente
```bash
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
set -a
. ./.env
set +a
uvicorn app:app --host 127.0.0.1 --port 8080
```
Abra `http://localhost:8080/`. A autenticação solicita usuário `poc` e a senha definida em `APP_PASSWORD`.
Com Docker:
```bash
docker build -t poc-grok-websearch:1 .
docker run -d --name poc-grok-websearch --restart unless-stopped \
--env-file .env -p 127.0.0.1:8080:8080 poc-grok-websearch:1 \
uvicorn app:app --host 0.0.0.0 --port 8080
```
## Publicar na VM
Copie o repositório e configure `.env` na VM. Faça o build com o [Dockerfile](Dockerfile) e execute o container em uma rede acessível ao proxy reverso. Publique por HTTPS, encaminhando para a porta interna `8080`.
O comando padrão da imagem configura `--root-path /grok-poc`. Para esse modo, publique em `/grok-poc/` e remova o prefixo `/grok-poc` no proxy antes de encaminhar ao backend. Para publicação na raiz do domínio, use o comando uvicorn sem `--root-path`, como no exemplo Docker acima. Ajuste `APP_BASE_URL` para a URL completa, incluindo o prefixo quando usado.
Basic Auth requer HTTPS fora do ambiente local. A POC limita a concorrência a duas inferências, mas não impõe limite financeiro por usuário nem rate limiting por cliente.
## Arquitetura
```mermaid
flowchart LR
UI[Browser / JavaScript] -->|HTTPS + Basic Auth| Proxy[Proxy reverso na VM]
Proxy --> API[Python / FastAPI]
API -->|Bearer OCI + OpenAI-Project| OCI[OCI Chicago /v1/responses]
OCI --> Grok[xai.grok-4.3 + web_search]
Grok --> Web[Pesquisa e abertura de páginas]
```
O backend envia `tools: [{"type":"web_search"}]`, `store:false` e limite de 2400 tokens de saída. O modelo decide quando pesquisar e pode reformular consultas. Não há fallback para outros provedores nem motor de busca externo implementado pela aplicação.
A Oracle informa que os modelos Grok acessados pela OCI são hospedados externamente pela xAI. A aplicação autentica e chama exclusivamente OCI. Veja a [documentação Oracle](https://docs.oracle.com/en-us/iaas/Content/generative-ai/api-keys.htm).
Nos testes de 14/10/2026, `/openai/v1/responses` rejeitou `web_search` para Grok; `/v1/responses` e `/20231130/actions/v1/responses` funcionaram com um projeto ativo de Chicago. A aplicação usa `/v1/responses`.
## API REST
`POST /api/chat` com HTTP Basic. Na publicação com prefixo, use `POST /grok-poc/api/chat`.
```json
{"message":"Pesquise uma notícia publicada hoje e cite fonte e data.","history":[]}
```
`history` aceita até 12 turnos com `role` igual a `user` ou `assistant`; cada conteúdo tem limite de 8000 caracteres. A resposta contém `answer`, `citations`, `web_search_calls`, `usage`, `response_id`, `model`, `timestamp`, `seconds` e `raw_response`.
Exemplo local; curl solicita a senha:
```bash
curl --user poc http://localhost:8080/api/chat \
-H 'Content-Type: application/json' \
--data '{"message":"Pesquise uma notícia publicada hoje e cite fonte e data."}'
```
`GET /health` retorna o estado do serviço sem autenticação. A página, JavaScript e chat exigem autenticação.
## Validar
Com o serviço em execução e `.env` preenchido:
```bash
. .venv/bin/activate
set -a
. ./.env
set +a
python validate.py
```
O [script](validate.py) faz uma chamada paga de inferência sobre notícia de hoje, verifica resposta, eventos de busca e citações, além dos códigos HTTP de autenticação e validação. Salva resultados locais em `./evidence/`, ignorados pelo Git. A confirmação da data e do conteúdo da fonte requer conferência independente; o script não a automatiza.
Na execução original, o REST retornou HTTP 200 em 36,12 segundos, com duas buscas concluídas e uma abertura de página após uma tentativa inicial falhar. A fonte foi acessada independentemente e mostrou publicação em 14/10/2026 às 08:17. Desktop e celular passaram nas verificações de JavaScript e layout. A renderização foi verificada com uma fixture da resposta REST real.
Consulte o [relatório](evidence/RELATORIO.md) e o [resumo estruturado](evidence/validation-summary.json). Apenas evidências selecionadas são compartilhadas; credenciais, dados administrativos OCI, respostas brutas, cópias integrais das fontes e scripts específicos da infraestrutura ficam locais.

50
app.py Normal file
View File

@@ -0,0 +1,50 @@
import os,time,secrets,asyncio
from datetime import datetime
from zoneinfo import ZoneInfo
from urllib.parse import urlparse
import requests
from fastapi import FastAPI,HTTPException,Depends
from fastapi.security import HTTPBasic,HTTPBasicCredentials
from fastapi.responses import FileResponse
from pydantic import BaseModel,Field
app=FastAPI(title='Grok OCI · Web Search',docs_url=None,redoc_url=None)
auth=HTTPBasic()
def authorize(c:HTTPBasicCredentials=Depends(auth)):
if not (secrets.compare_digest(c.username,'poc') and secrets.compare_digest(c.password,os.environ['APP_PASSWORD'])):
raise HTTPException(401,'Acesso restrito',headers={'WWW-Authenticate':'Basic'})
class Turn(BaseModel):
role:str
content:str=Field(max_length=8000)
class Chat(BaseModel):
message:str=Field(min_length=1,max_length=8000)
history:list[Turn]=Field(default_factory=list,max_length=12)
@app.get('/health')
def health():return {'status':'ok','provider':'OCI','model':'xai.grok-4.3','tool':'web_search'}
@app.get('/',dependencies=[Depends(authorize)])
def index():return FileResponse('static/index.html')
@app.get('/app.js',dependencies=[Depends(authorize)])
def js():return FileResponse('static/app.js',media_type='application/javascript')
lock=asyncio.Semaphore(2)
@app.post('/api/chat',dependencies=[Depends(authorize)])
async def chat(q:Chat):
if any(t.role not in ('user','assistant') for t in q.history):raise HTTPException(422,'Papel inválido')
base=os.environ['OCI_BASE_URL'].rstrip('/')
if urlparse(base).hostname!='inference.generativeai.us-chicago-1.oci.oraclecloud.com':raise HTTPException(503,'Endpoint OCI inválido')
now=datetime.now(ZoneInfo('America/Sao_Paulo'))
body={'model':'xai.grok-4.3','input':[t.model_dump() for t in q.history]+[{'role':'user','content':q.message}],
'instructions':f'Você é um assistente em português brasileiro. Hoje é {now.isoformat()}. Use a ferramenta web_search para verificar informações atuais. Cite fontes com URLs e datas. Diferencie data de publicação e data do fato. Nunca invente notícias; se não houver evidência de hoje, diga isso claramente.',
'tools':[{'type':'web_search'}],'store':False,'max_output_tokens':2400}
started=time.monotonic()
async with lock:
try:r=await asyncio.to_thread(requests.post,base+'/responses',json=body,headers={'Authorization':'Bearer '+os.environ['OCI_API_KEY'],'OpenAI-Project':os.environ['OCI_PROJECT_ID']},timeout=(10,150))
except requests.RequestException:raise HTTPException(502,'Falha de conexão com OCI')
if r.status_code!=200:raise HTTPException(502,{'message':'OCI recusou a chamada','provider_status':r.status_code})
d=r.json();texts=[];citations=[];calls=[]
for item in d.get('output',[]):
if item.get('type')=='web_search_call':calls.append(item)
for c in item.get('content',[]):
if c.get('text'):texts.append(c['text'])
for a in c.get('annotations',[]):
if a.get('type')=='url_citation':citations.append(a)
return {'answer':'\n'.join(texts),'citations':citations,'web_search_calls':calls,'usage':d.get('usage'),'response_id':d.get('id'),'model':d.get('model'),'provider':'OCI','timestamp':now.isoformat(),'seconds':round(time.monotonic()-started,2),'raw_response':d}

21
evidence/RELATORIO.md Normal file
View File

@@ -0,0 +1,21 @@
# Validação — 14/10/2026
A POC foi publicada em uma VM OCI e testada por HTTPS. O resumo compartilhável está em [validation-summary.json](validation-summary.json).
| Critério | Resultado |
|---|---|
| REST sobre notícia de hoje | HTTP 200; 36,12 segundos |
| Serviço e modelo | OCI Chicago; xai.grok-4.3 |
| Busca nativa | Duas buscas concluídas e uma abertura de página; uma tentativa inicial falhou |
| Citações | Duas citações da mesma notícia |
| Fonte verificada independentemente | HTTP 200; data visível 14/10/2026 às 08:17 |
| Acesso não autenticado | HTTP 401 |
| Histórico com papel inválido | HTTP 422 |
| Desktop e celular | Sem erros de JavaScript e sem overflow horizontal |
| Renderização | Resposta REST real usada como fixture; texto e links conferidos |
Fonte: [Agência Brasil — Confira a agenda dos presidenciáveis nesta quarta-feira (14)](https://agenciabrasil.ebc.com.br/politica/noticia/2026-10/confira-agenda-dos-presidenciaveis-nesta-quarta-feira-14). A data coincide no texto visível e no metadado; o horário difere (08:17 no texto e 11:17 no metadado). O resumo registra o horário visível.
A ferramenta executou uma busca inicial que falhou, reformulou consultas e concluiu a pesquisa. As respostas brutas, identificadores OCI, capturas e cópia integral da fonte permanecem locais e não são versionados. O SHA-256 da fonte permite conferir o artefato local.
A chamada final consumiu 15.544 tokens. Incluindo os diagnósticos bem-sucedidos, foram 34.829 tokens e nove ferramentas contabilizadas pelo serviço. A escala financeira de cost_in_usd_ticks não foi verificada; o campo é preservado sem conversão para dólares.

View File

@@ -0,0 +1,78 @@
{
"tested_at": "2026-10-14T10:34:33.339815-03:00",
"http": 200,
"seconds": 37.25,
"provider": "OCI",
"model": "xai.grok-4.3",
"web_search_calls": [
{
"type": "search",
"status": "failed"
},
{
"type": "search",
"status": "completed"
},
{
"type": "search",
"status": "completed"
},
{
"type": "open_page",
"status": "completed"
}
],
"citation_count": 2,
"source": {
"url": "https://agenciabrasil.ebc.com.br/politica/noticia/2026-10/confira-agenda-dos-presidenciaveis-nesta-quarta-feira-14",
"http": 200,
"visible_publication": "14/10/2026 - 08:17",
"html_sha256": "f7642c836aa347cf4d04ea68eb2f24d3888172669ee4be1099b8f5c5cec7d12b"
},
"usage": {
"input_tokens": 14359,
"input_tokens_details": {
"cached_tokens": 5760
},
"output_tokens": 1185,
"output_tokens_details": {
"reasoning_tokens": 798
},
"total_tokens": 15544,
"num_sources_used": 0,
"num_server_side_tools_used": 3,
"cost_in_usd_ticks": 298632500,
"server_side_tool_usage_details": {
"web_search_calls": 3,
"x_search_calls": 0,
"x_posts_fetched": 0,
"x_users_fetched": 0,
"code_interpreter_calls": 0,
"file_search_calls": 0,
"mcp_calls": 0,
"document_search_calls": 0,
"image_generation_calls": 0
},
"context_details": {
"input_tokens": 7565,
"output_tokens": 466
}
},
"http_checks": {
"index": 200,
"app.js": 200,
"health": 200,
"unauthenticated_post": 401,
"invalid_history": 422
},
"browser_checks": {
"http": 200,
"js_errors": [],
"desktop": true,
"mobile_no_overflow": true
},
"render_check": {
"answer_rendered": true,
"method": "Fixture da resposta REST real previamente coletada, sem nova inferência"
}
}

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi==0.119.0
uvicorn==0.37.0
requests==2.32.5

4
static/app.js Normal file
View File

@@ -0,0 +1,4 @@
const form=document.querySelector('#form'),box=document.querySelector('#messages'),prompt=document.querySelector('#prompt'),status=document.querySelector('#status'),send=document.querySelector('#send');let history=[];
function formatAnswer(div,text){div.textContent='';const pattern=/\*\*([^*]+)\*\*|\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;let last=0;for(const m of text.matchAll(pattern)){div.append(document.createTextNode(text.slice(last,m.index)));const el=document.createElement(m[1]?'strong':'a');el.textContent=m[1]||m[2];if(!m[1]){el.href=m[3];el.target='_blank';el.rel='noopener noreferrer';}div.append(el);last=m.index+m[0].length;}div.append(document.createTextNode(text.slice(last)));}
function turn(text,role){const div=document.createElement('div');div.className='turn '+role;div.textContent=text;box.append(div);return div;}
form.addEventListener('submit',async e=>{e.preventDefault();const message=prompt.value.trim();if(!message)return;turn(message,'user');prompt.value='';send.disabled=true;status.textContent='Consultando Grok e pesquisando na web…';try{const r=await fetch('./api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message,history:history.slice(-12)})});if(!r.ok)throw Error(`Erro HTTP ${r.status}`);const d=await r.json();const div=turn(d.answer||'Sem texto na resposta.','assistant');formatAnswer(div,d.answer||'Sem texto na resposta.');const meta=document.createElement('div');meta.className='meta';meta.textContent=`${d.seconds}s · ${d.web_search_calls.length} chamadas de busca · ${d.citations.length} citações`;div.append(meta);for(const c of d.citations){try{const url=new URL(c.url);if(!['https:','http:'].includes(url.protocol))continue;const a=document.createElement('a');a.href=url.href;a.target='_blank';a.rel='noopener noreferrer';a.textContent=c.title && c.title!=='1'?c.title:url.hostname;div.append(document.createElement('br'),a);}catch{}}const details=document.createElement('details');const summary=document.createElement('summary');summary.textContent='Evidências da ferramenta';const pre=document.createElement('pre');pre.textContent=JSON.stringify({response_id:d.response_id,usage:d.usage,web_search_calls:d.web_search_calls},null,2);details.append(summary,pre);div.append(details);history.push({role:'user',content:message},{role:'assistant',content:d.answer});status.textContent='Pesquisa concluída';}catch(err){turn(err.message,'assistant');status.textContent='Não foi possível concluir';}finally{send.disabled=false;prompt.focus();}});

3
static/index.html Normal file
View File

@@ -0,0 +1,3 @@
<!doctype html><html lang="pt-BR"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Grok · OCI Web Search</title><style>
*{box-sizing:border-box}body{margin:0;background:#0b1020;color:#e9eef8;font:16px system-ui}main{max-width:900px;margin:auto;padding:32px 20px}header{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #28324a;padding-bottom:24px}h1{font-size:26px;margin:0}small{color:#9baac4}.badge{border:1px solid #2c675c;color:#8ce5c7;border-radius:20px;padding:8px 12px;font-size:12px}#messages{min-height:50vh;padding-top:24px}.turn{padding:20px;background:#141d32;border:1px solid #27334d;border-radius:14px;margin:14px 0;white-space:pre-wrap;overflow-wrap:anywhere}.user{background:#1b2944;margin-left:12%}.meta{font-size:12px;color:#8ce5c7;margin-top:12px}a{color:#9bcaff}textarea{width:100%;background:#141d32;color:white;border:1px solid #354563;border-radius:12px;padding:16px;font:inherit;resize:vertical}button{background:#98e9d0;border:0;padding:12px 20px;border-radius:10px;font-weight:700;cursor:pointer}button:disabled{opacity:.5}.actions{display:flex;justify-content:space-between;margin-top:12px}#status{color:#adb9d0;font-size:13px}details{margin-top:12px;font-size:12px}pre{white-space:pre-wrap}footer{margin-top:24px;color:#8392ad;font-size:12px}
</style><main><header><div><h1>Grok / Web Search</h1><small>Respostas atuais, fontes verificáveis · POC</small></div><span class="badge">OCI · grok-4.3</span></header><section id="messages"><div class="turn">O que você quer descobrir hoje? Pergunte sobre notícias ou fatos recentes. A busca web nativa está habilitada.</div></section><form id="form"><textarea id="prompt" rows="3" maxlength="8000" placeholder="Quais são as notícias de hoje? Cite fontes e datas." required></textarea><div class="actions"><span id="status">Pronto para pesquisar</span><button id="send">Enviar →</button></div></form><footer>Python + JavaScript · Inferência pela OCI · Histórico apenas nesta página. As respostas podem conter erros; confira as fontes.</footer></main><script src="./app.js"></script></html>

34
validate.py Normal file
View File

@@ -0,0 +1,34 @@
"""Validação REST com uma chamada de inferência paga e verificações HTTP."""
import json
import os
import time
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import requests
root = Path(__file__).resolve().parent
url = os.environ.get("APP_BASE_URL", "http://localhost:8080/").rstrip("/") + "/"
auth = ("poc", os.environ["APP_PASSWORD"])
now = datetime.now(ZoneInfo("America/Sao_Paulo"))
question = {"message": f"Pesquise uma notícia publicada hoje ({now.date()}, horário de Brasília). Informe título, data, resumo e URL. Verifique a página; se não encontrar, diga isso claramente."}
started = time.monotonic()
response = requests.post(url + "api/chat", auth=auth, json=question, timeout=180)
report = {"tested_at": now.isoformat(), "request": question, "http": response.status_code,
"seconds": round(time.monotonic() - started, 2), "response": response.json()}
folder = root / "evidence"
folder.mkdir(exist_ok=True)
(folder / "rest-news-today.json").write_text(json.dumps(report, ensure_ascii=False, indent=2))
response.raise_for_status()
data = response.json()
assert data["answer"], "Resposta vazia"
assert any(call["status"] == "completed" for call in data["web_search_calls"]), "Busca não confirmada"
assert data["citations"], "Citações ausentes"
checks = {path or "index": requests.get(url + path, auth=auth, timeout=20).status_code
for path in ("", "app.js", "health")}
checks["unauthenticated_post"] = requests.post(url + "api/chat", json=question, timeout=20).status_code
checks["invalid_history"] = requests.post(url + "api/chat", auth=auth,
json={"message": "test", "history": [{"role": "system", "content": "test"}]}, timeout=20).status_code
(folder / "http-checks.json").write_text(json.dumps(checks, indent=2))
assert checks == {"index": 200, "app.js": 200, "health": 200, "unauthenticated_post": 401, "invalid_history": 422}
print(f"REST HTTP 200; busca e citações presentes; {report['seconds']} s. Evidências em ./evidence/")