commit 53ebadd8de2ea605f6452f105df6a18fcb7620fd Author: Marcos Lohmann Date: Wed Oct 14 11:01:59 2026 -0300 Add OCI Grok web-search chatbot example and selected validation evidence diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..916af5d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.env +.venv +evidence +__pycache__ +.git diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4349089 --- /dev/null +++ b/.env.example @@ -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/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b93aa5e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..26e33d6 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bdd0d1 --- /dev/null +++ b/README.md @@ -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. diff --git a/app.py b/app.py new file mode 100644 index 0000000..5932479 --- /dev/null +++ b/app.py @@ -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} diff --git a/evidence/RELATORIO.md b/evidence/RELATORIO.md new file mode 100644 index 0000000..1e1fc31 --- /dev/null +++ b/evidence/RELATORIO.md @@ -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. diff --git a/evidence/validation-summary.json b/evidence/validation-summary.json new file mode 100644 index 0000000..4da3461 --- /dev/null +++ b/evidence/validation-summary.json @@ -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" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c149a12 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.119.0 +uvicorn==0.37.0 +requests==2.32.5 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..5a1585b --- /dev/null +++ b/static/app.js @@ -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();}}); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..1736a3c --- /dev/null +++ b/static/index.html @@ -0,0 +1,3 @@ +Grok · OCI Web Search

Grok / Web Search

Respostas atuais, fontes verificáveis · POC
OCI · grok-4.3
O que você quer descobrir hoje? Pergunte sobre notícias ou fatos recentes. A busca web nativa está habilitada.
Pronto para pesquisar
Python + JavaScript · Inferência pela OCI · Histórico apenas nesta página. As respostas podem conter erros; confira as fontes.
diff --git a/validate.py b/validate.py new file mode 100644 index 0000000..d9455fb --- /dev/null +++ b/validate.py @@ -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/")