# Gemini — Plano de Migração para Laravel 11/12 (v7)

> Objetivo: reconstruir **um único** serviço de IA que atenda os dois casos de uso
> (redação assistida no CMS e mascaramento LGPD no portal), removendo as armadilhas
> documentadas em `05-REGRAS-E-PORTABILIDADE.md`.

---

## 1. Decisões de arquitetura (o que muda)

| Tema | v5 | v7 (proposto) |
|---|---|---|
| Credencial | `configurations.value` (texto claro) **ou** `env()` lido em runtime | **`.env` / vault**, valor **apenas** em `config/gemini.php`; nada no banco |
| Camada HTTP | Guzzle 5 instanciado inline, 2× | `Http` facade do Laravel (`Http::withToken()`/`Http::withHeaders()`) com `timeout` e `retry` |
| Modelo | 2 modelos divergentes | 1 só (`config('gemini.model')`), com **fração do modelo** por caso de uso |
| Autenticação na API | chave na **query string** | header `x-goog-api-key` (a API do Gemini aceita) |
| Execução | síncrona no ciclo HTTP | **fila** para o mascaramento; síncrona (com cache) para a redação |
| Autorização | `authorize() => true`, rota pública | `auth` + `Policy` + `throttle` |
| Parsing título/corpo | heurística por `\n\n` / ponto / 150 bytes | `responseMimeType: application/json` + **schema** de resposta |
| Prompt do usuário | concatenado sem sanitização | `systemInstruction` fixo + `contents` com o pedido do operador; **sem** interpolação livre |
| Observabilidade | `Log::warning`/`Log::error` genéricos | métricas (latência, tokens, custo), log **sem** corpo do prompt |
| Rate limit | nenhum | `throttle` por usuário + cota diária por município |
| Chave exposta na tela | texto claro | campo `password` com máscara, gravação apenas por comando de console |

---

## 2. Configuração

`v7/config/gemini.php` (novo):

```php (ilustrativo)
return [
    'key' => env('GEMINI_API_KEY'),
    'model' => env('GEMINI_API_MODEL', 'gemini-2.5-flash'),
    'model_masking' => env('GEMINI_API_MODEL_MASKING', 'gemini-2.5-flash'),
    'base_url' => env('GEMINI_API_URL', 'https://generativelanguage.googleapis.com/v1beta'),
    'timeout' => (int) env('GEMINI_API_TIMEOUT', 10),
    'connect_timeout' => (int) env('GEMINI_API_CONNECT_TIMEOUT', 3),
    'retry' => [
        'times' => 2,
        'sleep_ms' => 500,
    ],
    'max_prompt_chars' => 5000,
    'max_title_chars' => 150,
];
```

Regras:

- `GEMINI_API_KEY` **nunca** é lido em runtime (o que elimina os bugs B1/B2/B3).
- A chave **não** vai para `configurations`. Se o negócio exigir gerenciá-la pela tela,
  o acesso passa por um model com `$hidden = ['value']` e **criptografia**
  (`Crypt::encryptString`) — e, nesse caso, o valor em claro não pode aparecer no
  `activity_log`.
- `.env.example` da v7 **deve** conter `GEMINI_API_KEY=`, `GEMINI_API_MODEL=`,
  `GEMINI_API_URL=` (a v5 não contém nenhum deles — bug B2).

---

## 3. Modelos

Nenhum model novo é necessário para o caminho A. Para o caminho B:

```php (ilustrativo)
namespace App\Models;

class AiUsageLog extends Model
{
    protected $fillable = ['feature', 'model', 'prompt_chars', 'output_chars',
                           'latency_ms', 'status', 'cost_estimate'];

    protected function casts(): array
    {
        return ['latency_ms' => 'integer', 'prompt_chars' => 'integer',
                'output_chars' => 'integer'];
    }
}
```

Objetivo: **auditar custo e cota**, algo inexistente na v5 (ver `05`, item 3.3).

Se a chave continuar no CMS, o model equivalente é:

```php (ilustrativo)
namespace App\Models;

class IntegrationCredential extends Model
{
    protected $fillable = ['key', 'secret', 'status_create'];
    protected $hidden = ['secret'];

    protected function secret(): Attribute
    {
        return Attribute::make(
            get: fn ($value) => $value ? Crypt::decryptString($value) : null,
            set: fn ($value) => $value ? Crypt::encryptString($value) : null,
        );
    }
}
```

---

## 4. Serviço

`v7/app/Services/Gemini/GeminiClient.php` (novo) — **único** ponto de integração:

```php (ilustrativo)
namespace App\Services\Gemini;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class GeminiClient
{
    public function __construct(
        private readonly string $model = 'gemini-2.5-flash',
    ) {}

    private function http(): PendingRequest
    {
        $key = config('gemini.key');

        if (! $key) {
            throw new GeminiNotConfiguredException();
        }

        return Http::baseUrl(rtrim(config('gemini.base_url'), '/'))
            ->withHeaders(['x-goog-api-key' => $key, 'Content-Type' => 'application/json'])
            ->timeout(config('gemini.timeout'))
            ->connectTimeout(config('gemini.connect_timeout'))
            ->retry(config('gemini.retry.times'), config('gemini.retry.sleep_ms'),
                    fn ($exception) => $exception instanceof ConnectionException
                                     || $exception->response?->status() === 429
                                     || $exception->response?->serverError());
    }

    public function generate(string $text, ?string $model = null): string
    {
        $response = $this->http()->post("/models/{$model ?? $this->model}:generateContent", [
            'systemInstruction' => ['parts' => [['text' => self::SYSTEM_INSTRUCTION]]],
            'contents'          => [['parts' => [['text' => $text]]]],
            'generationConfig'  => ['temperature' => 0.7, 'maxOutputTokens' => 2048],
        ]);

        $response->throw();

        return $response->json('candidates.0.content.parts.0.text')
            ?? throw new \RuntimeException('Resposta inesperada da API Gemini.');
    }
}
```

Pontos que o serviço **corrige** em relação à v5:

| Correção | Origem |
|---|---|
| Chave no **header**, não na query string | A6 |
| `timeout` + `connectTimeout` explícitos | B9 |
| `retry` com backoff em 429/5xx | item 3.3 |
| `systemInstruction` separado do texto do operador | A10 / B8 |
| `generationConfig` com `maxOutputTokens` | B8 |
| Leitura do texto por **caminho JSON** (`json('candidates.0.content.parts.0.text')`) | B7 (falha vira exceção explícita) |
| Resposta **estruturada** para o caminho A | B5/B6 (ver `4.1`) |

### 4.1 Resposta estruturada para a redação assistida

Em vez de a IA inventar o separador, pedir JSON:

```php (ilustrativo)
'generationConfig' => [
    'responseMimeType' => 'application/json',
    'responseSchema'   => [
        'type' => 'object',
        'properties' => [
            'title' => ['type' => 'string'],
            'body'  => ['type' => 'string'],
        ],
        'required' => ['title', 'body'],
    ],
],
```

Isso elimina a heurística de 150 bytes (B5) e o `\n\n` (B6). Se a IA ainda assim
devolver texto solto, o fallback é `Str::limit(mb_substr($text, 0, 150), 150)` — com
**`mb_*`**, nunca `substr`/`strlen`.

---

## 5. Form Requests

`v7/app/Http/Requests/Gemini/GenerateContentRequest.php`:

```php (ilustrativo)
public function authorize(): bool
{
    return $this->user()->can('gerar-conteudo-com-ia');
}

public function rules(): array
{
    return [
        'prompt' => ['required', 'string', 'min:4', 'max:' . config('gemini.max_prompt_chars')],
    ];
}
```

Mudanças em relação à v5:

- `authorize()` deixa de ser `true` e passa a consultar uma **Policy**.
- `min:4` / `max:5000` **preservados** (equivalência funcional) — mas o número vem de
  `config()` e não de uma constante mágica.

`v7/app/Http/Requests/Gemini/MaskTextRequest.php` (novo, para o job de fila) — regras
mínimas: `text` string, tamanho máximo definido pelo negócio.

---

## 6. Rotas e autorização

```php (ilustrativo)
// routes/web.php — portal público (somente leitura)
Route::middleware(['check.site.manutention'])->group(function () {
    // nada de IA aqui
});

// routes/cms.php — painel administrativo
Route::middleware(['auth:sentinel', 'can:gerar-conteudo-com-ia', 'throttle:20,1'])
    ->prefix('cms')
    ->group(function () {
        Route::post('ia/gerar-conteudo', [GeminiController::class, 'preencher'])
            ->name('gemini.ia');
    });
```

| Ponto | v5 | v7 |
|---|---|---|
| Autenticação | **nenhuma** | `auth` obrigatório |
| Permissão | **nenhuma** | `Policy` `gerar-conteudo-com-ia` |
| Rate limit | **nenhum** | `throttle:20,1` por usuário |
| Local | `v5/routes/web.php` (portal) | `routes/cms.php` (`/cms`) |
| Nome da rota | `gemini.ia` | `gemini.ia` (mantido — o JS e as views referenciam por nome) |

> **Decisão de equivalência:** manter o **nome** `gemini.ia` evita reescrever todas as
> views; mas mudar o **URI** e o grupo de middleware é obrigatório. O JS passa a ler a
> URL de um `@json`/propriedade do layout, e não mais de `route('gemini.ia')` inline.

### 6.1 Endpoint de mascaramento (novo)

Hoje o mascaramento acontece dentro do `store` do pedido. Na v7:

- a gravação do pedido grava o texto **original** e enfileira o job;
- o job `MaskOmbudsmanDescription` / `MaskInformationRequest` chama o
  `GeminiClient::generate()` com o *prompt* de anonimização e atualiza
  `mask_description` / `mask_request`.

```php (ilustrativo)
class MaskOmbudsmanDescription implements ShouldQueue
{
    public function handle(GeminiClient $gemini): void
    {
        $request = OmbudsmanRequest::findOrFail($this->ombudsmanRequestId);

        $masked = $gemini->generate(
            $request->description,
            config('gemini.model_masking'),
        );

        $request->update(['mask_description' => $masked]);
    }
}
```

**Equivalência funcional obrigatória:** o texto mascarado precisa aparecer **no mesmo
lugar** (relatórios públicos). Se o job for assíncrono, o relatório precisa tratar o
caso "ainda não mascarado" (ex.: exibir a versão determinística até o job concluir) —
**é preciso definir esse comportamento com o negócio** (pergunta aberta em `08`).

---

## 7. Views e front-end

| Item | v5 | v7 |
|---|---|---|
| Modal | include estático com `id="modal-banner"` | componente Blade `<x-ia-modal />` com `id` único (`ia-modal`) |
| JS | `ia-modal.js` global com seletores `#title`/`#text` | componente Alpine.js ou Vue com `props` (`titleSelector`, `textSelector`, `routeUrl`) — a função já aceita seletores, é só usá-los |
| Botão | sempre visível | visível apenas se `auth()->user()->can('gerar-conteudo-com-ia')` **e** `config('gemini.key')` |
| Erro | mensagem genérica (bug B11) | exibir `responseJSON.message` do servidor; mensagem de "chave não configurada" clara |
| Timeout | nenhum | `$.ajax({ timeout: 30000 })` ou `AbortController` |
| Chamada | `route('gemini.ia')` inline | rota injetada pelo layout |

Exemplo de componente (ilustrativo):

```blade
<x-ia-modal
    title-selector="#title"
    text-selector="#text"
    :route-url="route('gemini.ia')"
/>
```

E o tratamento de erro no JS:

```js (ilustrativo)
.fail(function (jqXHR) {
    const message = jqXHR.responseJSON?.message
        ?? 'Ocorreu um erro ao gerar o conteúdo. Tente novamente.';
    $('#ia-flash-message').html(
        `<div class="alert alert-danger mt-2" role="alert">${message}</div>`
    );
});
```

---

## 8. Fila, cache e storage

| Recurso | Uso na v7 |
|---|---|
| **Fila** (`jobs`) | Mascaramento de e-SIC/Ouvidoria (não bloqueia o cidadão) e, opcionalmente, a redação (com polling de status). |
| **Cache** (`Redis`) | Cache do resultado do mascaramento por hash do texto original (`sha256`), com TTL curto (ex.: 24 h) — evita repetir a chamada paga para textos idênticos. Chave: `gemini:mask:{sha256}`. |
| **Cache de config** | Nada a cachear: a credencial vem do `.env`/vault. |
| **Rate limiting** | `RateLimiter::for('gemini', ...)` por usuário **e** por município (cota diária). |
| **Storage** | Se houver gravação de áudio/arquivo no futuro, `Storage::disk('s3')` — irrelevante para o módulo atual. |
| **Métricas** | `AiUsageLog` + log estruturado (`Log::info('gemini.generate', [...])`) **sem** o corpo do prompt (LGPD). |

Exemplo de cache (ilustrativo):

```php (ilustrativo)
$cacheKey = 'gemini:mask:' . hash('sha256', $text);

$masked = Cache::remember($cacheKey, now()->addDay(), function () use ($text, $gemini) {
    return $gemini->generate($text, config('gemini.model_masking'));
});
```

---

## 9. Observabilidade e sanidade

- **Nunca** logar o corpo do prompt ou da resposta da IA (podem conter dado pessoal).
  A v5 faz isso em `Log::error('Gemini generateContent non-200', ['body' => $body])`.
- Logar: modelo, status, latência, tamanho do prompt, tamanho da saída, id da requisição.
- Alerta quando a cota diária ultrapassar o limite (evita fatura inesperada).
- **Sanitização do prompt:** no caminho A, o texto do operador é **conteúdo**, não
  instrução. Com `systemInstruction` separado e `contents` isolado, o operador não
  consegue sobrescrever as regras do sistema. No caminho B, o texto **já vem mascarado**
  e a entrada é fechada por um heredoc sem delimitadores variáveis.

---

## 10. Riscos e decisões de equivalência funcional

| # | Risco / decisão | Mitigação |
|---|---|---|
| 1 | **Trocar a URL da rota** quebra as views que usam `route('gemini.ia')` | Manter o **nome** da rota; atualizar as views no mesmo deploy |
| 2 | **Exigir autenticação** quebra qualquer uso anônimo legítimo | Confirmar com o negócio que a função é exclusiva do CMS — na v5 ela **nunca** foi usada pelo portal |
| 3 | **Mascaramento assíncrono** muda o timing de exibição nos relatórios | Definir o comportamento para "ainda não mascarado" (fallback determinístico) |
| 4 | **Mudar o modelo** (`2.0-flash` → `2.5-flash`) muda o texto gerado | Considerar isso **melhoria**, não regressão; o texto é sempre revisado pelo operador antes de publicar |
| 5 | **Resposta estruturada (JSON)** pode falhar em modelos que não suportam schema | Manter o parser de texto como fallback de segunda ordem |
| 6 | **Rotacionar a chave** exposta no repositório | Obrigatório antes de qualquer deploy da v7 — a chave da v5 está no histórico do Git |
7. **Remover `configurations.gemini_api_key`** quebra instalações que já a preencheram | Migration para migrar o valor para o `.env`/vault, ou manter a leitura da tabela **somente** como fallback de migração, com depreciação anunciada |
| 8 | **Cache do mascaramento** pode servir texto stale se as regras de anonimização mudarem | Versionar a chave do cache (`gemini:mask:v2:{hash}`) a cada mudança de prompt |

---

## 11. Checklist de migração

1. Rotacionar a chave da API do Gemini (a da v5 está versionada).
2. Criar `config/gemini.php` + `.env.example` com `GEMINI_API_KEY`, `GEMINI_API_MODEL`,
   `GEMINI_API_URL`.
3. Criar `GeminiClient` + `GeminiService` (redação) + `MaskingService` (anonimização).
4. Mover a rota para `routes/cms.php` com `auth` + `can` + `throttle`.
5. Criar `GenerateContentRequest` com `authorize()` real e `Policy`.
6. Converter `ia-modal.js` em componente com seletores parametrizados e timeout.
7. Criar `MaskTextJob` e despachá-lo no `store` do e-SIC/Ouvidoria.
8. Criar `AiUsageLog` + métricas + limite de cota.
9. Remover `gemini_api_key` de `configurations` (ou marcar como deprecada).
10. Escrever os testes de `07-TESTES.md`.

---

## 12. Referências

- `v5/app/Http/Controllers/Prefecture/GeminiController.php`
- `v5/app/Http/Traits/DataMaskTrait.php:213-287`
- `v5/app/Http/Requests/GeminiPromptRequest.php`
- `v5/routes/web.php:203`
- `v5/public/assets/js/custom/utilities/helpers/ia-modal.js`
- `v5/app/Http/Controllers/Prefecture/ConfigurationController.php:200`
