mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
fix(C225): fecha guard e runbook da ingestão web
This commit is contained in:
parent
4be1c0923b
commit
b5db019c73
8 changed files with 777 additions and 8 deletions
|
|
@ -180,6 +180,11 @@ item inválido para o C215 não se resolve por `aprovar`):
|
|||
fora do escopo do C215);
|
||||
- metadados que o agente não conseguiu confirmar.
|
||||
|
||||
Para `youtube` e `instagram`, confirme sempre `externalId` no lote curado. A
|
||||
identidade do C215 usa `web:<plataforma>:<externalId|url canônica>`; sem esse
|
||||
campo, a mesma URL pode abrir uma chave diferente em outra rodada. Confira o
|
||||
`sourceKey` no relatório do `--dry-run` antes de ingerir.
|
||||
|
||||
O que está em `review` não é ingerido até confirmação (`aprovar`).
|
||||
|
||||
Modes (numeração única da listagem de `revisar` — `pending` primeiro, depois
|
||||
|
|
|
|||
1
docs/changelog/2026-09-25-c225.md
Normal file
1
docs/changelog/2026-09-25-c225.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C225 — operação de ingestão das falas da internet no acervo de produção (2026-09-25):** a escrita não-local de `pnpm falas-web:import` passou a recusar, antes de abrir o Payload, a ausência de mídia espelhada: o guard reutiliza a decisão de alvo de `scripts/lib/cli.mjs` e exige as quatro variáveis `S3_*` quando o alvo exige confirmação; `--dry-run` e o caminho local seguem inalterados. O runbook `docs/ops/teqo-1313-deploy.md` agora documenta a rodada manual no homeserver: workspace separado, estado durável fora do clone, cookies revogáveis e wrapper de `yt-dlp`, reescritas de Postgres/Garage, preflight, smoke limitado, lote, idempotência, inventário, honestidade de falhas e rollback pelo Payload. A curadoria C218 passou a exigir `externalId` para YouTube/Instagram e conferência do `sourceKey` no dry-run. Esta entrega não executou escrita em produção, não adicionou migration/UI/agendamento e o resultado da primeira rodada permanece para o operador registrar.
|
||||
|
|
@ -107,11 +107,11 @@ aprovação, produção.
|
|||
|
||||
## Onde roda cada coisa
|
||||
|
||||
| Máquina | Papel |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. |
|
||||
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313 e `teqo-staging` na 127.0.0.1:1314 (OPS103; DB `teqo_staging`, env `~/stack/teqo-staging.env`, bucket `teqo-media-staging`); **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado 2026-08-19 — Issue #113 OPS71-INFRA; `~/actions-runner` v2.336.0, systemd user `actions.runner.fsolla-teqo.teqo-1313-runner.service` — `systemctl --user status/restart`); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público) |
|
||||
| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) |
|
||||
| Máquina | Papel |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. |
|
||||
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313 e `teqo-staging` na 127.0.0.1:1314 (OPS103; DB `teqo_staging`, env `~/stack/teqo-staging.env`, bucket `teqo-media-staging`); **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado 2026-08-19 — Issue #113 OPS71-INFRA; `~/actions-runner` v2.336.0, systemd user `actions.runner.fsolla-teqo.teqo-1313-runner.service` — `systemctl --user status/restart`); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público); o clone operacional da C225 `~/teqo-falas-web` é separado e guarda somente código/dependências, com estado durável em `~/falas-web` |
|
||||
| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) |
|
||||
|
||||
Segredos **não** ficam no GitHub: o script sourceia o env file do ambiente
|
||||
(`~/stack/teqo-1313.env` em produção, `~/stack/teqo-staging.env` em staging) e
|
||||
|
|
@ -733,6 +733,371 @@ DELETE FROM "speech" WHERE "legislature" = '55';
|
|||
Para desfazer o acervo inteiro: `DELETE FROM "speech_segment"; DELETE FROM "speech";`
|
||||
(a cobertura `--coverage` volta a zero). Reexecutar `--all` reconstrói.
|
||||
|
||||
## C225 — rodada de falas da internet no acervo de produção
|
||||
|
||||
A fonte **Falas na internet** do acervo (C216) já tem busca, player, transcrição e
|
||||
origem, mas a produção só recebe fala web quando alguém executa esta operação.
|
||||
A ingestão é a esteira do C215 (`pnpm falas-web:import`); a descoberta e a
|
||||
curadoria são do C218. Esta seção é o procedimento manual, repetível e
|
||||
reversível para rodar um lote contra `teqo_1313` e o bucket privado
|
||||
`teqo-media`. O guard `FALAS_WEB_IMPORT_CONFIRM=1` continua sendo um ato
|
||||
humano: nenhum agente, workflow ou skill o exporta.
|
||||
|
||||
**Estado desta entrega (2026-09-25):** o guard e este runbook foram entregues;
|
||||
esta entrega não executou a rodada de produção. A data, o lote, os criados,
|
||||
pulados, falhas, custos e o caminho dos relatórios são preenchidos por quem
|
||||
operar depois do merge.
|
||||
|
||||
### Responsabilidades e limites
|
||||
|
||||
- A descoberta/curadoria pode ser feita na workstation, mas a cópia do lote e o
|
||||
estado operacional devem terminar no diretório durável do homeserver. O
|
||||
`last-run.json` do host é a fonte de verdade da próxima janela.
|
||||
- O CLI é a única etapa que baixa, transcreve, classifica, espelha e deduplica.
|
||||
Não crie cron, fila, collection de estado, migration ou uma segunda esteira.
|
||||
- O acervo da Câmara (`origin = camara`, 997 discursos no baseline registrado em
|
||||
2026-09-13/14) não é reprocessado por esta rodada.
|
||||
- O download temporário pode existir durante o processamento em `/tmp`; o
|
||||
destino da mídia espelhada é o bucket privado. O temp é removido pelo `finally`
|
||||
do pipeline. Não use um diretório local como fila de upload.
|
||||
- `internetSpeechMedia` continua privada e servida pelo acervo autenticado. Não
|
||||
copie a URL do bucket para um canal público.
|
||||
|
||||
### Pré-requisitos
|
||||
|
||||
1. O commit que contém C215, C216, C218, C223, C224 e C225 está em produção.
|
||||
2. No homeserver há Node 24 (`source ~/.nvm/nvm.sh`), `pnpm`, acesso ao GitHub,
|
||||
`flock`, `curl`, `psql` e espaço para o clone e os temporários do download.
|
||||
3. `~/stack/.env` contém `DEEPINFRA_API_KEY` e `DEEPSEEK_API_KEY`; a primeira
|
||||
chave faz a transcrição e a segunda a classificação. `~/stack/teqo-1313.env`
|
||||
contém `DATABASE_URL`, `PAYLOAD_SECRET`, `NODE_ENV=production` e as quatro
|
||||
variáveis `S3_*` do bucket. Não copie esses arquivos para o repositório.
|
||||
4. O `yt-dlp` instalado funciona no host e tem `yt-dlp-ejs` disponível. A
|
||||
resolução do CLI usa `YTDLP_PATH → PATH`; o CLI também valida `ffmpeg` e usa
|
||||
o binário empacotado do projeto como fallback.
|
||||
5. A pessoa responsável tem um arquivo de cookies Netscape revogável para as
|
||||
plataformas que exigirem sessão. O arquivo nunca é commitado, impresso nem
|
||||
enviado ao Issue.
|
||||
|
||||
### Workspace e estado durável
|
||||
|
||||
Use dois diretórios diferentes:
|
||||
|
||||
- `~/teqo-falas-web`: clone descartável, pinado no SHA que será executado; não é
|
||||
o workspace do runner (`~/teqo-deploy`) e não guarda o watermark.
|
||||
- `~/falas-web`: estado, lotes, descobertas, relatórios e logs duráveis da
|
||||
operação, fora do clone e do diretório gitignored da workstation.
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
export WORKTREE="$HOME/teqo-falas-web"
|
||||
export FALAS_WEB_DIR="$HOME/falas-web"
|
||||
export EXPECTED_SHA="<SHA-do-merge>"
|
||||
install -d -m 700 "$FALAS_WEB_DIR" "$FALAS_WEB_DIR/reports" "$FALAS_WEB_DIR/runs"
|
||||
if [ ! -d "$WORKTREE/.git" ]; then
|
||||
git clone https://github.com/fsolla/teqo.git "$WORKTREE"
|
||||
fi
|
||||
cd "$WORKTREE"
|
||||
git fetch origin
|
||||
git checkout --detach "$EXPECTED_SHA"
|
||||
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Se o C218 for executado dentro desse clone, aponte o diretório gitignored para o
|
||||
estado durável uma única vez, sem apagar um diretório existente sem inspeção:
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
if [ -L "$WORKTREE/data/falas-web" ]; then
|
||||
test "$(readlink -f "$WORKTREE/data/falas-web")" = "$(readlink -f "$FALAS_WEB_DIR")"
|
||||
elif [ -e "$WORKTREE/data/falas-web" ]; then
|
||||
printf '%s\n' 'data/falas-web existe e não é o symlink durável esperado.' >&2
|
||||
exit 1
|
||||
else
|
||||
mkdir -p "$WORKTREE/data"
|
||||
ln -s "$FALAS_WEB_DIR" "$WORKTREE/data/falas-web"
|
||||
fi
|
||||
```
|
||||
|
||||
Se a descoberta ocorrer na workstation, copie para `~/falas-web` apenas o
|
||||
artefato `discovery-*.json` e o lote `batch-*.json`. O `last-run.json` do host é
|
||||
o dono do estado: não sobrescreva o watermark dele com uma cópia mais antiga da
|
||||
workstation. Se o host ainda não tiver estado, a primeira rodada é a varredura
|
||||
inicial; caso já exista, o operador incorpora somente o lote pendente ao estado
|
||||
durável. Não use `data/falas-web` local da workstation como estado de produção.
|
||||
O contrato do estado é o do C218: `lastRunAt` só avança depois de uma importação
|
||||
com exit 0; falhas ficam em `pending` com `attempts`; itens duvidosos ficam em
|
||||
`review`. A gravação é atômica (arquivo temporário + `mv`) e deve ocorrer por
|
||||
último, depois de ler o relatório JSON do C215.
|
||||
|
||||
### Cookies e yt-dlp
|
||||
|
||||
O C215 não recebe cookies por argumento. Crie um wrapper fora do repo que
|
||||
aponta para o binário real e habilita somente o runtime JavaScript disponível
|
||||
na máquina. `--no-js-runtimes` evita que um Deno instalado, mas não preparado,
|
||||
vença o Node; `--cookies` aplica a sessão somente à rodada. O arquivo deve ter
|
||||
permissão `600` e ser removível sem tocar no clone.
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
install -d -m 700 "$FALAS_WEB_DIR/bin"
|
||||
YTDLP_REAL="$(command -v yt-dlp)"
|
||||
test -n "$YTDLP_REAL"
|
||||
test -r "$FALAS_WEB_DIR/cookies.txt"
|
||||
cat > "$FALAS_WEB_DIR/bin/yt-dlp" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec "$YTDLP_REAL" --no-js-runtimes --js-runtimes node --cookies "$FALAS_WEB_DIR/cookies.txt" "\$@"
|
||||
EOF
|
||||
chmod 700 "$FALAS_WEB_DIR/bin/yt-dlp"
|
||||
chmod 600 "$FALAS_WEB_DIR/cookies.txt"
|
||||
export YTDLP_PATH="$FALAS_WEB_DIR/bin/yt-dlp"
|
||||
```
|
||||
|
||||
O preflight usa uma URL real do lote, sem baixar o vídeo:
|
||||
|
||||
```bash
|
||||
"$YTDLP_PATH" --dump-json --no-playlist --no-warnings --skip-download '<url-do-lote>'
|
||||
```
|
||||
|
||||
A saída deve ser JSON e o log não deve indicar runtime JavaScript ausente,
|
||||
`yt-dlp-ejs` ausente ou cookie inválido. Para revogar a sessão, apague apenas
|
||||
`~/falas-web/cookies.txt` e `~/falas-web/bin/yt-dlp`; não apague o lote nem o
|
||||
histórico de relatórios. A skill C218 continua sem receber `YTDLP_PATH`,
|
||||
cookies ou a flag de produção.
|
||||
|
||||
### Sessão de produção e preflight
|
||||
|
||||
O env file preserva os hosts da rede do compose; a sessão no host precisa dos
|
||||
endpoints loopback. Não edite `~/stack/teqo-1313.env`: reescreva as variáveis
|
||||
somente depois de `source`.
|
||||
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh
|
||||
cd "$WORKTREE"
|
||||
set -a
|
||||
source "$HOME/stack/.env"
|
||||
source "$HOME/stack/teqo-1313.env"
|
||||
set +a
|
||||
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
|
||||
export S3_ENDPOINT="${S3_ENDPOINT/host.docker.internal/127.0.0.1}"
|
||||
export FALAS_WEB_DIR
|
||||
export YTDLP_PATH="$FALAS_WEB_DIR/bin/yt-dlp"
|
||||
```
|
||||
|
||||
Antes de qualquer escrita, confira o alvo exato, as dependências e o acesso ao bucket:
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
test "$NODE_ENV" = production
|
||||
case "$DATABASE_URL" in
|
||||
*@127.0.0.1:5433/teqo_1313) ;;
|
||||
*) printf '%s\n' 'DATABASE_URL não é o alvo de produção esperado.' >&2; exit 1 ;;
|
||||
esac
|
||||
test "${S3_ENDPOINT%/}" = 'http://127.0.0.1:3900'
|
||||
test "$S3_BUCKET" = 'teqo-media'
|
||||
for name in PAYLOAD_SECRET DEEPINFRA_API_KEY DEEPSEEK_API_KEY S3_BUCKET S3_ENDPOINT S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
|
||||
test -n "${!name:-}" || { printf 'falta %s\n' "$name" >&2; exit 1; }
|
||||
done
|
||||
test "$(psql "$DATABASE_URL" -Atqc 'select current_database()')" = 'teqo_1313'
|
||||
node --input-type=module <<'NODE'
|
||||
import { HeadBucketCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
|
||||
const client = new S3Client({
|
||||
region: process.env.S3_REGION || 'garage',
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
})
|
||||
await client.send(new HeadBucketCommand({ Bucket: process.env.S3_BUCKET }))
|
||||
NODE
|
||||
```
|
||||
|
||||
O `HeadBucket` precisa responder com sucesso; não imprima a resposta nem as
|
||||
credenciais. O proxy de build `teqo-1313-build-proxy` em `127.0.0.1:5433` deve
|
||||
existir. Se o preflight de download, o Postgres ou o Garage falhar, pare: não
|
||||
troque o alvo para um banco remoto da workstation e não contorne as guardas.
|
||||
|
||||
### Rodada manual
|
||||
|
||||
Escolha um lote curado em `~/falas-web/batch-<stamp>.json`. A primeira execução
|
||||
é um smoke; não rode a varredura inteira de uma vez. Mantenha o lock pelo
|
||||
processo inteiro, inclusive leitura do relatório e gravação de `last-run.json`:
|
||||
uma segunda invocação do C218 ou do import não pode começar enquanto este
|
||||
shell estiver aberto.
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
export RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
exec 9>"$FALAS_WEB_DIR/.lock"
|
||||
flock -n 9 || { printf '%s\n' 'já existe uma rodada web' >&2; exit 1; }
|
||||
```
|
||||
|
||||
1. **Planejar, sem escrita e sem flag:**
|
||||
|
||||
```bash
|
||||
export FINDINGS="$FALAS_WEB_DIR/batch-<stamp>.json"
|
||||
pnpm falas-web:import --findings "$FINDINGS" --out "$FALAS_WEB_DIR" --dry-run
|
||||
```
|
||||
|
||||
Leia o relatório em `~/falas-web/reports/`. `invalid` e `duplicates` devem
|
||||
ser zero. Confira cada `plannedEntries[].sourceKey` e `action`; um `create`
|
||||
para uma URL que já entrou exige corrigir a curadoria antes de ingerir.
|
||||
|
||||
2. **Smoke, dentro do lock:**
|
||||
|
||||
```bash
|
||||
if FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import \
|
||||
--findings "$FINDINGS" --limit 1 --out "$FALAS_WEB_DIR" 2>&1 |
|
||||
tee -a "$FALAS_WEB_DIR/runs/$RUN_ID.log"; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
O relatório deve mostrar a entrada processada, o estágio e o resultado. A
|
||||
mídia espelhada deve aparecer no bucket `teqo-media`; o player e a rota de
|
||||
mídia do acervo são a prova de leitura, não uma URL pública. A cauda do lote
|
||||
permanece no próprio `batch-<stamp>.json`.
|
||||
|
||||
3. **Lote curado completo:** rode o mesmo comando sem `--limit` e no mesmo lock:
|
||||
|
||||
```bash
|
||||
if FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import \
|
||||
--findings "$FINDINGS" --out "$FALAS_WEB_DIR" 2>&1 |
|
||||
tee -a "$FALAS_WEB_DIR/runs/$RUN_ID.log"; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
Exit 0 pode representar sucesso total ou parcial; leia `created`, `updated`,
|
||||
`skipped`, `failures`, `asrSeconds`, `asrCostUsd`, `llmCostUsd` e
|
||||
`durationMs` no JSON. Exit 1 significa que nenhum achado entrou e exige
|
||||
retentativa, não um recibo verde.
|
||||
|
||||
4. **Prova de idempotência:** repita o lote completo sem `--reprocess`, ainda
|
||||
com o lock:
|
||||
|
||||
```bash
|
||||
if FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import --findings "$FINDINGS" --out "$FALAS_WEB_DIR" 2>&1 |
|
||||
tee -a "$FALAS_WEB_DIR/runs/$RUN_ID.log"; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
O relatório deve mostrar `skipped` para itens completos, nenhum download/ASR
|
||||
novo e nenhum objeto adicional no bucket. A segunda rodada deve usar a
|
||||
mesma identidade `web:<plataforma>:<externalId|url canônica>`; não use uma
|
||||
URL alternativa para "forçar" o resultado.
|
||||
|
||||
5. **Inventário e fonte da rodada:** registre o relatório, o lote e o estado em
|
||||
`~/falas-web`; atualize `last-run.json` por último. Uma falha vira `pending`
|
||||
com o estágio e a tentativa, e só uma importação com exit 0 avança
|
||||
`lastRunAt`. Não marque a janela como coberta por memória da sessão. Mantenha
|
||||
o FD 9 aberto até essa gravação; só depois execute `exec 9>&-`.
|
||||
|
||||
6. **Ensaio de rollback:** antes de encerrar a primeira rodada, escolha uma
|
||||
fala de teste, registre o `id`/`sourceKey`, peça confirmação humana para a
|
||||
exclusão e remova pelo admin. Confirme no inventário e no Garage que a fala,
|
||||
os segmentos e o objeto espelhado sumiram. Remova o `sourceKey` de qualquer
|
||||
`pending`/`review` que ainda o referencie. Se essa prova não for feita, a
|
||||
rodada fica bloqueada para aprovação operacional.
|
||||
|
||||
### Inventário read-only
|
||||
|
||||
As consultas abaixo não escrevem no banco:
|
||||
|
||||
```bash
|
||||
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
|
||||
SELECT origin, platform, count(*) AS total
|
||||
FROM speech
|
||||
GROUP BY origin, platform
|
||||
ORDER BY origin, platform;
|
||||
|
||||
SELECT id, "sourceKey", "speechAt", platform, origin
|
||||
FROM speech
|
||||
WHERE origin = 'web'
|
||||
ORDER BY "speechAt" DESC;
|
||||
|
||||
SELECT count(*) AS web_total,
|
||||
count(*) FILTER (WHERE "mirroredMedia" IS NOT NULL) AS web_with_media
|
||||
FROM speech
|
||||
WHERE origin = 'web';
|
||||
SQL
|
||||
```
|
||||
|
||||
Compare a contagem de `origin = 'camara'` com o baseline antes da rodada e
|
||||
confirme que ela não diminuiu. Para o bucket, use o cliente S3/Garage aprovado
|
||||
pela operação e confira o objeto pelo identificador da mídia, sem imprimir a
|
||||
chave de acesso ou o segredo. Depois abra
|
||||
`/campanha/comunicacao/acervo` → **Falas na internet** com uma sessão de
|
||||
campanha e confirme player, transcrição, data e origem de uma fala da rodada.
|
||||
|
||||
### Rollback
|
||||
|
||||
Não copie o `DELETE` SQL do C155 para esta entrega. A remoção de uma fala web
|
||||
deve passar pelo Payload, porque o hook `deleteSpeechAssets` remove segmentos e
|
||||
registros de `internetSpeechMedia`, e o plugin de storage remove o objeto do
|
||||
bucket:
|
||||
|
||||
1. Faça o inventário read-only e anote o `id`/`sourceKey` exato.
|
||||
2. No admin autenticado, exclua uma fala `origin = web` da rodada de teste.
|
||||
3. Confirme no inventário que a linha e os segmentos sumiram e, no Garage,
|
||||
que o objeto associado sumiu.
|
||||
4. Repita para outras falas somente com identificação e confirmação humanas.
|
||||
|
||||
Não apague diretamente a tabela `speech`, `speech_segment` ou
|
||||
`internet_speech_media`; isso pode deixar relações e objetos órfãos. Remoção em
|
||||
massa é uma operação destrutiva nova e precisa de item, flag, teste e runbook
|
||||
próprios.
|
||||
|
||||
### Falhas e decisões
|
||||
|
||||
- **Runtime JS/YouTube:** o wrapper usa Node; se o binário não tiver
|
||||
`yt-dlp-ejs`, a aquisição falha no estágio `acquisition`. Atualize o yt-dlp e
|
||||
reinstale o componente pela via oficial; não coloque arquivos remotos no repo.
|
||||
- **Cookies expirados:** a falha fica no relatório e em `pending`; atualize o
|
||||
arquivo revogável e repita o lote. Nunca registre o conteúdo do cookie.
|
||||
- **Egress do homeserver:** a rota para APIs/CDNs pode ser intermitente. O
|
||||
preflight é um gate: se `--dump-json` ou o Garage falhar, a rodada não começa.
|
||||
Não silencie a falha nem troque o banco de produção por um alvo remoto.
|
||||
- **S3 ausente ou endpoint errado:** o novo guard recusa escrita não-local sem
|
||||
as quatro `S3_*`; um erro de conectividade aparece no relatório. O env file do
|
||||
container não deve ser editado para acomodar o host.
|
||||
- **Espaço ou lock:** o `flock` serializa rodadas; um lock ocupado encerra o
|
||||
comando. O diretório do clone e o diretório durável precisam de espaço para
|
||||
dependências, lotes, relatórios e o temporário de cada download.
|
||||
- **Fim da rodada:** registrar o resultado medido no changelog e o
|
||||
`last-run.json` durável é parte da operação, não uma tarefa opcional.
|
||||
|
||||
### Resultado registrado
|
||||
|
||||
Preencher após uma execução real, sem apagar o histórico anterior:
|
||||
|
||||
```text
|
||||
data/hora:
|
||||
lote:
|
||||
sourceKeys/resultados:
|
||||
criados/atualizados/pulados/falhas:
|
||||
custo/tempo:
|
||||
bucket e estado durável:
|
||||
idempotência:
|
||||
falhas/pendências:
|
||||
```
|
||||
|
||||
O resultado da primeira rodada e a prova da segunda são a evidência de que a
|
||||
fonte web foi realmente carregada em produção; o merge deste runbook, sozinho,
|
||||
não representa uma rodada concluída.
|
||||
|
||||
## C160 — reparo do link oficial do acervo (PDF direto do Diário)
|
||||
|
||||
Operação de dados do acervo de falas (C153/C154/C155): troca o
|
||||
|
|
|
|||
272
docs/plans/acervo-falas-internet-producao-impl.md
Normal file
272
docs/plans/acervo-falas-internet-producao-impl.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# Impl: Falas na internet no acervo de produção (operação de ingestão)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-25
|
||||
Issue: #1339
|
||||
Intenção: docs/plans/acervo-falas-internet-producao.md
|
||||
Appetite restante: herdado (~1 dia de operação + providências no homeserver). A engenharia desta entrega é ~1/3 do dia: **um guard fail-closed pequeno + o runbook + o changelog**. O resto do dia é operação humana no homeserver (F0/F4), que não é trabalho de agente.
|
||||
|
||||
> **Modo `--auto`.** Este plano nasce `aprovado` (Passo 3c vira apresentação). As quatro recomendações A da intenção (§Questões em aberto) são **assumidas** e estão registradas abaixo com a avaliação de divergência material. **Nenhuma fase deste plano escreve em produção a partir do agente** — o agente não recebe `DATABASE_URL`/credencial do `teqo_1313`, e a operação (F0/F4) é ato humano, com a flag de intenção exportada por mão.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
**Outcome.** Em produção, `/campanha/comunicacao/acervo` → "Falas na internet" mostra as falas da rodada com player, transcrição, data e origem, com a mídia no bucket privado de produção; a segunda rodada não duplica nem reprocessa o que já está completo; e `docs/ops/teqo-1313-deploy.md` traz o suficiente (onde roda, pré-requisitos, guardas, como provar idempotência, como reverter) para outra pessoa repetir sem memória de sessão.
|
||||
|
||||
**O que NÃO negociar.**
|
||||
|
||||
- **Fronteira fail-closed do C218:** a skill `catalogo-falas-web` continua sem poder de produção — nunca exporta `FALAS_WEB_IMPORT_CONFIRM`, nunca contorna `assertLocalDatabase`, nunca escreve no `teqo_1313`. A promoção da skill é item próprio, fora daqui.
|
||||
- **Bandeira de intenção humana:** `FALAS_WEB_IMPORT_CONFIRM=1` só existe na linha de comando de quem opera. Nenhum arquivo do repo, script, `.env`, workflow ou skill define essa flag.
|
||||
- **`sourceKey` é a identidade:** `web:<plataforma>:<externalId|url canônica>`; o acervo da Câmara (`origin = camara`, chaves da API) fica intocado; dedupe semântico cross-URL continua fora de escopo.
|
||||
- **Mídia só no bucket privado** (`internetSpeechMedia` → Garage `teqo-media`), servida pelo acervo autenticado; nada de disco local e nada de URL pública.
|
||||
- **Estado durável fora do banco** e fora do diretório gitignored da workstation; relatório honesto (sucesso parcial ≠ sucesso), falhas em `pending` retentável.
|
||||
- **Sem automação:** nada de cron, fila, watcher, agendador, collection de estado, migration, UI nova nem tela de rodada.
|
||||
- **Agente não executa escrita em produção** (política de `AGENTS.md` + §Modo autônomo do `work-issue`).
|
||||
|
||||
**O que reavaliar (da "Direção no codebase" da intenção).**
|
||||
|
||||
- _"Rodar no homeserver"_ — confirmado como **recomendação da intenção (assumida)**, mas a hipótese "lá dá para baixar do YouTube/Instagram" **não é verificável daqui** e há evidência contrária parcial: a rota do homeserver é intermitente para a API de IA (medido 2026-09-25, 1/3 de connect), o CDN do Instagram em IPv4 é instável nessa rede (post-mortem `2026-09-25-instagram-cdn-ipv4-instavel.md`) e `objects.githubusercontent.com` expira (nota do `pnpm-workspace.yaml`). Isso vira **gate de preflight da operação** (F0.4), não feature.
|
||||
- _"Prefiro `data/falas-web/` (gitignored) no clone"_ — **errado para produção**: o clone é descartável por definição e o watermark morre com ele. O estado durável é um diretório dedicado fora do repo (D5).
|
||||
- _"`~/stack/teqo-1313.env` é o ponto de configuração"_ — os hosts do env file (`postgres`, `host.docker.internal`) **só resolvem na rede do compose**; do host eles precisam de reescrita para loopback, e essa reescrita é **de sessão**, não persistida (D5/F2).
|
||||
- _"Rollback no mesmo formato do C155 (SQL direto)"_ — **inseguro aqui**: o C155 não tinha mídia no S3. Fala da web tem `internet_speech_media` e o cascade `Speech → speechSegment → internetSpeechMedia` só roda pelo Payload (D4).
|
||||
- _"Cookies: opção do yt-dlp já existente"_ — o C215 **não passa `--cookies`** em nenhum dos dois comandos (`readYtDlpMetadata`, `downloadWithYtDlp`). Não existe caminho hoje sem wrapper ou sem código (D2).
|
||||
|
||||
## Recomendações A da intenção: como ficam
|
||||
|
||||
| Questão (intenção) | Recomendação assumida | Como esta entrega implementa | Divergência material? |
|
||||
| ------------------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Onde a rodada roda | A — no homeserver, `yt-dlp` provisionado + um arquivo de cookies revogável | F0 cria um clone pinado dedicado no host; F2 documenta o procedimento com as reescritas de DB/S3 e o preflight de aquisição; a rodada para se o egress falhar | **Não.** É a própria recomendação da intenção, registrada como assumida. O _ato_ continua humano (F4) |
|
||||
| Onde vive o estado incremental | A — diretório durável no homeserver, dono único | F0.5 cria `~/falas-web/` (lote, descoberta, relatórios, `last-run.json`); `--out` aponta para lá; lock por `flock` | **Não.** Mover o estado do clone gitignored para o host é o que o aceite exige ("o acervo de produção não depende de estado na workstation") |
|
||||
| Quem conduz a rodada | A — procedimento manual documentado, skill C218 fail-closed | F2 escreve o procedimento; nenhuma linha de código toca a skill além de **um bullet de curadoria** (D3) que reforça a fronteira; a flag continua humana | **Não.** O bullet na skill é documentação de curadoria, não capacidade nova |
|
||||
| O que entra na primeira rodada | A — lote curado com `--limit`, depois o volume | F4.3 smoke `--limit 1` → F4.4 lote curado → F4.5 repetição idempotente | **Não.** É a redução de blast radius que a própria intenção pede |
|
||||
|
||||
**Conclusão de divergência:** nenhuma material. O plano pode seguir em `--auto`. As duas incertezas técnicas reais (egress do host e runtime JS do yt-dlp) são **gates de preflight com parada documentada**, não decisões de produto — se o F0.4 falhar, a operação para para correção do host, nunca o agente.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
**A menor entrega que fecha o aceite:** (1) **um guard fail-closed** no dono dos guards de escrita, para que "escrita em produção espelha no bucket" deixe de depender de o operador ter as 4 envs `S3_*` na sessão; (2) **o runbook** `## C225` no `docs/ops/teqo-1313-deploy.md`, com pré-requisitos, provisionamento de `yt-dlp`/cookies/`ffmpeg`, reescritas de host, preflight, dry-run, smoke, idempotência, estado, inventário e rollback; (3) **um bullet** na curadoria do C218 sobre a identidade do lote; (4) o changelog. Sem migration, sem collection, sem UI, sem automação, sem segunda esteira — a fase de escrita continua sendo exatamente `pnpm falas-web:import` do C215.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph COD["PR (agente) — sem tocar produção"]
|
||||
G["guard: escrita não-local exige S3<br/>scripts/lib/cli.mjs + import-web-speeches.mjs"]
|
||||
T["unit cliEnvFlags + int webSpeechIngest"]
|
||||
R["runbook docs/ops §C225"]
|
||||
K["bullet de curadoria (identidade do lote)<br/>SKILL.md C218"]
|
||||
L["changelog 2026-09-25-c225.md"]
|
||||
end
|
||||
subgraph HOST["Homeserver — ato humano (F0/F4)"]
|
||||
W["clone pinado ~/teqo-falas-web + pnpm install"]
|
||||
B["wrapper YTDLP_PATH + cookies revogável"]
|
||||
E["env do ambiente + DB 127.0.0.1:5433 + S3 127.0.0.1:3900"]
|
||||
P["preflight: aquisição (--dump-json) + Garage + proxy"]
|
||||
S1["dry-run (read-only, sem flag)"]
|
||||
S2["smoke --limit 1 (flag) → relatório"]
|
||||
S3["lote curado (flag) → relatório honesto"]
|
||||
S4["repetição = skipped / ASR 0 (idempotência)"]
|
||||
ST[("~/falas-web/<br/>lote · relatórios · last-run.json")]
|
||||
end
|
||||
PROD[("produção: teqo_1313 + bucket teqo-media")]
|
||||
UI["/campanha/comunicacao/acervo<br/>→ Falas na internet (C216)"]
|
||||
G --> T --> R
|
||||
R --> L
|
||||
K --> L
|
||||
W --> B --> E --> P
|
||||
P --> S1 --> S2 --> S3 --> S4
|
||||
S2 -.->|flock + estado| ST
|
||||
S3 -->|upsert transacional| PROD
|
||||
S4 -->|skip idempotente| PROD
|
||||
S4 -.->|inventário read-only| ST
|
||||
PROD --> UI
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) guard de mídia + runbook + changelog, com provisionamento no host | B) só runbook, sem código | C) comando novo de rollback em massa + collection de estado da rodada.
|
||||
|
||||
**Recomendação: A** — porque o aceite tem duas partes que documentação sozinha não entrega: (i) _"a mídia está no bucket e nunca em disco local"_ é hoje uma promessa silenciosa: sem as 4 envs `S3_*`, `resolveS3StorageEnv` devolve `{ enabled: false }` e o Payload grava em disco local **com exit 0 e relatório verde** — exatamente a falha que o anti-goal "não deixa mídia em disco local" proíbe; (ii) _"é repetível e segura"_ precisa da pré-condição de ambiente verificada antes da escrita, não de memória do operador. Um guard de ~6 linhas no dono (`scripts/lib/cli.mjs`) fecha (i); o runbook fecha (ii).
|
||||
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B (só runbook):** deixa o descumprimento do anti-goal de mídia depender de disciplina; o relatório de uma rodada com S3 ausente é indistinguível de uma rodada bem-sucedida — o oposto de "falha não é mascarada" e de "honesta". Custo: um bug de ambiente vira lixo silencioso no disco efêmero do clone.
|
||||
- **C (rollback em massa + collection de estado):** fora de escopo explícito da intenção ("schema/migration/collection nova: o estado operacional não vai para o banco"; "não invente uma segunda esteira"). O rollback documentado pelo caminho do Payload (D4) cobre o caso real; um comando destrutivo novo exigiria guard, teste e runbook próprios — item de Issue própria, com gatilho declarado em §Gatilhos de revisitação.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — Onde a rodada executa: clone pinado no host do homeserver.**
|
||||
|
||||
Opções: A) host do homeserver, clone dedicado `~/teqo-falas-web` no SHA, `pnpm install`, env do ambiente na sessão | B) workstation com alvo explícito (`ALLOW_REMOTE_DB=true` + flag) | C) container de manutenção do compose (`docker compose --profile maintenance run … teqo-1313-migrate`), como o C160.
|
||||
|
||||
Recomendação: **A** — porque o dado de produção não sai de onde vive, o único material sensível transportado é um arquivo de cookies revogável, e o precedente é literal: o C155 e o C160 rodaram exatamente assim (`~/teqo-backfill` + `source ~/stack/teqo-1313.env` + `DATABASE_URL` reescrita para `127.0.0.1:5433` + flag). Também mantém o estado durável ao lado do dado.
|
||||
|
||||
Rejeitadas: **B** porque a fronteira que o C215 impõe (`assertLocalDatabase` recusa host não-local) afrouxa no momento em que `ALLOW_REMOTE_DB=true` é aceito, e o precedente mostra o resultado: rodadas de ingestão que não deixaram registro em produção. **C** porque a imagem do migrator é `node:24-alpine` sem Python/yt-dlp e sem `ffmpeg` (`apk add ffmpeg` só existe no estágio `runner`), e o estado durável + cookies exigiriam volume no compose: trocaria uma dependência externa (`yt-dlp` do host, revisável e revogável) por uma mudança no build da imagem de produção.
|
||||
|
||||
**D2 — Como o yt-dlp recebe cookies e runtime JS sem tocar a esteira.**
|
||||
|
||||
Opções: A) wrapper em `~/falas-web/bin/yt-dlp` com `exec` do binário real mais `--cookies` e `--js-runtimes node`, apontado por `YTDLP_PATH` só na rodada | B) config global do yt-dlp (`~/.config/yt-dlp/config`) | C) código da aplicação: flag de cookies/ambiente no dono `src/utilities/media/ytdlp.ts`.
|
||||
|
||||
Recomendação: **A** — porque `ytDlpBinary()` já resolve `YTDLP_PATH → PATH` e `defaultRunner` usa `execFile(bin, args)`: um wrapper executável funciona hoje, sem diff no C215, e é _escopado à rodada_, _reversível em um `rm`_ e _não versiona credencial_. E porque duas opções do yt-dlp são requisito de ambiente, não de domínio: `--cookies FILE` (formato Netscape) porque a sessão do operador (YouTube/Instagram) é o que o C215 pressupõe e nunca teve, e `--js-runtimes node` porque a documentação do yt-dlp diz que **só `deno` vem habilitado por padrão** (`deno, node, quickjs, bun` por prioridade) — sem essa flag o host depende de um runtime que não está instalado, e o `yt-dlp-ejs` (necessário para suporte pleno ao YouTube) precisa vir empacotado na instalação, nunca buscado remotamente (`--remote-components` é negado por default, e buscar do GitHub é rota que expira nesse host).
|
||||
|
||||
Rejeitadas: **B** porque a config global vaza a sessão do operador para _todo_ yt-dlp daquele usuário no host (inclusive o pipeline de reels/C219), e revogação/auditoria ficam implícitas. **C** porque adiciona ao owner da esteira uma variável de ambiente de operador sem dono no domínio, e porque o C215 já fixa o contrato de aquisição (720p/mp4, sem cookies) — mexer nisso é decisão de domínio, não providência de ambiente.
|
||||
|
||||
**D3 — A divergência de identidade (`externalId` presente × ausente muda a `sourceKey`).**
|
||||
|
||||
Opções: A) regra procedural no runbook + conferência obrigatória no dry-run + um bullet na curadoria do C218 | B) normalizar a identidade no código (`webSpeechSourceKey` passa a extrair sempre o id da URL em `youtube`/`instagram`) | C) `findSpeechImportState` passando a aceitar uma segunda chave candidata (lookup por URL canônica).
|
||||
|
||||
Recomendação: **A** — porque o contrato do C215 é explícito (`web:<plataforma>:<externalId|url canônica>`) e a C223/C224 não o mexeram; a correção é de _uso_ (a curadoria sempre leva `externalId` em `youtube`/`instagram`, que é trivial de extrair da própria URL) e o dry-run já expõe a `sourceKey` planejada de cada entrada — o operador vê `create` para uma URL que reconhece de outra rodada. Mudar identidade de uma chave já persistida é "caro de reverter" e merece item próprio.
|
||||
|
||||
Rejeitadas: **B** porque troca a semântica de identidade do dono (contrato citado por skills e testes pinados em `tests/unit/webSpeech.unit.spec.ts`) para tapar um desvio de curadoria, e porque `externalId` também é campo gravado na fala — a mudança mexe em identidade _e_ em metadado. **C** porque é pass-through que deixa o problema no momento errado (o `sourceKey` já foi gravado em `pending`/relatório) e nasce de um índice novo.
|
||||
|
||||
**D4 — Rollback: remoção pelo Payload, nunca SQL cru.**
|
||||
|
||||
Opções: A) apagar o discurso no admin (ou por um `payload.delete` pontual): o hook `deleteSpeechAssets` remove `speechSegment` e `internet_speech_media`, e o plugin S3 apaga o objeto do bucket | B) `DELETE FROM speech_segment; DELETE FROM speech;` no formato do C155 | C) novo comando `--rollback` scoped por `sourceKey`, com flag própria.
|
||||
|
||||
Recomendação: **A** — porque o cascade é do Payload por construção ("Payload relationships do not cascade" → hook), e o B deixa exatamente o que o runbook proíbe: linhas de mídia órfãs no bucket e rels inconsistentes. Documento também um **inventário read-only** (`SELECT` das falas `web:%`) para saber exatamente o que existe antes de decidir.
|
||||
|
||||
Rejeitadas: **B** por copiar o rollback do C155 sem adaptar a diferença real (lá não havia mídia no S3; o próprio runbook do C155 diz "nenhum objeto de mídia vai para o S3"). **C** por ser um caminho destrutivo novo: guard + teste + runbook próprios, e não exigido pelo aceite (a operação é aditiva e idempotente; remoção é caso raro e manual, pelo admin, com confirmação humana).
|
||||
|
||||
**D5 — Workspace, estado durável e isolamento.**
|
||||
|
||||
Opções: A) `~/teqo-falas-web` (clone pinado, descartável) + `~/falas-web/` (diretório durável, dono único, `chmod 700`), com `--out "$FALAS_WEB_DIR"` e `flock` no diretório | B) `data/falas-web/` do próprio clone (gitignored) | C) `~/teqo-deploy` (workspace do runner).
|
||||
|
||||
Recomendação: **A** — porque o accept exige que o acervo de produção não dependa de estado na workstation, e o inverso também vale: o estado operacional não pode morrer com um `git checkout`/`git clean` de um clone descartável; `~/teqo-deploy` é reescrito pelo deploy (imagem, tag, revisão), e nenhum dado operacional pode morar ali. `--out` aceita caminho absoluto (a validação recusa `..`), e o `flock` é o single-flight que o C218 declara não existir ("uma rodada por vez: o estado não tem lock"), com o precedente do próprio repo (`/tmp/teqo-deploy.lock`, `/tmp/teqo-unblock.lock`).
|
||||
|
||||
Rejeitadas: **B** porque o `data/falas-web/` é gitignored _e_ destruído junto com o clone — watermark perdido significa varredura inicial de novo, que é exatamente o que a rodada incremental evita. **C** porque mistura dado operacional com workspace de CI, o que já produziu desvios de revisão no passado ("prod regride para um SHA antigo" na §Falhas conhecidas).
|
||||
|
||||
**Armadilha registrada (barata, mas custa a rodada se o operador errar):** `--out ~/falas-web` **não** expande o til no Node — o relatório cairia em `./~/falas-web/reports/…` dentro do clone. O runbook usa sempre `"$FALAS_WEB_DIR"` expandido pela shell.
|
||||
|
||||
**D6 — O único guard de código: escrita não-local exige mídia espelhada.**
|
||||
|
||||
Opções: A) predicado puro no dono dos guards (`scripts/lib/cli.mjs`) + `die` no CLI, reusando `requiresWriteConfirm()` e `resolveS3StorageEnv()` | B) checagem ad-hoc dentro do script | C) novo script de preflight à parte.
|
||||
|
||||
Recomendação: **A** — porque `requiresWriteConfirm()` já define "escrita não provadamente local" (é a mesma condição que exige a flag, incluindo o `NODE_ENV=production` do host, que é o "sinal honesto" do C155) e `resolveS3StorageEnv` já é o dono da decisão S3. O predicado fica testável como unidade, o `die` fica no CLI (a semântica de erro pertence ao CLI, como o resto), e o guard é **fail-closed**: sem `S3_*`, escrita não-local não roda. O alvo exato de produção (`teqo_1313`/`teqo-media`) é uma precondição do procedimento C225, não uma política global do CLI, para não bloquear usos legítimos de staging. Alvo local (dev/test/worktree, e portanto toda a skill C218) continua com disco local — a fronteira do C218 fica idêntica.
|
||||
|
||||
Rejeitadas: **B** porque lógica de decisão sem dono fica órfã e não há como testá-la sem subir o CLI inteiro. **C** porque um "preflight" separado é mais um comando para lembrar — fail-closed no caminho da escrita não depende de o operador lembrar de rodar o passo anterior.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`scripts/lib/cli.mjs`** (dono dos guards de escrita): +1 predicado puro `mirroredMediaRequired({ nodeEnv, databaseUrl, allowRemoteDb, s3Enabled })` delegando a `requiresWriteConfirm` (sem reescrever a semântica de "não-local") + testes em `tests/unit/cliEnvFlags.unit.spec.ts` e `tests/unit/importWebSpeechesCli.unit.spec.ts` (incluindo valor S3 não booleano e recusa antes da leitura do lote).
|
||||
- **`scripts/import-web-speeches.mjs`** (`src` do comando): no ramo de **escrita** (o ramo `--dry-run` é read-only e fica intacto), após `assertWriteConfirm` + `assertLocalDatabase`, um `die` quando o predicado for verdadeiro — mensagem nomeando as 4 envs e o motivo ("sem elas o Payload gravaria em disco local"). `pnpm falas-web:import` (o `cross-env` de `package.json:92`) **não** é tocado: não passa a exportar `NODE_ENV=production` e a fronteira da skill C218 fica igual.
|
||||
- **`docs/ops/teqo-1313-deploy.md`**: nova seção `## C225 — rodada de falas da internet no acervo de produção` (§Fases/F2 especifica o conteúdo exigido) + uma linha na tabela "Onde roda cada coisa" para o clone operacional (ao lado de `~/teqo-deploy`, explicitando que **não** é o workspace do runner).
|
||||
- **`.agents/skills/catalogo-falas-web/SKILL.md`**: **um** bullet em §Curadoria — identidade do lote: `youtube`/`instagram` sempre com `externalId`; sem ele a chave vira a URL canônica e a mesma fala pode abrir duas linhas; e a conferência no dry-run antes de ingerir. Nenhuma capacidade nova, nenhuma flag, nenhum caminho de produção.
|
||||
- **`docs/changelog/2026-09-25-c225.md`**: entrada curta no padrão do repo. **Declara o guard, o runbook e o status da operação** — se a rodada ainda não rodou, o changelog diz isso; o resultado medido é acrescentado depois pela pessoa, sem reescrever o que o agente afirmou.
|
||||
- **Migration:** sem migration. **Collection/global/campo:** nenhum. **Access/Consent:** intocado — `internetSpeechMedia` segue `canReadSpeech`/`payloadAdminOnly`, o acervo da Câmara segue com `origin = camara` e suas chaves. **UI:** Impeccable A — N/A (sem UI; nenhuma superfície visual muda).
|
||||
- **Fora do repo (ato humano, não no PR):** `~/falas-web/`, `~/teqo-falas-web`, `~/falas-web/bin/yt-dlp`, `~/falas-web/cookies.txt`, `yt-dlp` instalado no host, e as reescritas de sessão de `DATABASE_URL`/`S3_ENDPOINT` — **`~/stack/teqo-1313.env` não é editado** (o container precisa dos hosts da rede do compose; o host precisa do loopback; as duas coisas convivem por reescrita de sessão).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
**F0 — Providências de ambiente no homeserver (ato humano, ~2h; bloqueia a operação, não o PR).**
|
||||
|
||||
- F0.1 `~/teqo-falas-web`: `git clone`/`fetch` + `checkout <SHA>` + `pnpm install` (molde do `~/teqo-backfill` do C155). Verificação: `git -C ~/teqo-falas-web rev-parse HEAD` = SHA do merge; `pnpm falas-web:import --help` roda.
|
||||
- F0.2 `yt-dlp` com `yt-dlp-ejs` empacotado e um runtime JS no PATH (`source ~/.nvm/nvm.sh` já entrega o node 24 que o C155 usa). Verificação: `yt-dlp --version` e `yt-dlp --verbose --simulate <url> 2>&1 | grep -i runtime` sem tentativa de download remoto.
|
||||
- F0.3 `~/falas-web/` (`chmod 700`) + wrapper `~/falas-web/bin/yt-dlp` (chmod 700) que faz `exec` do binário real com `--cookies "$FALAS_WEB_DIR/cookies.txt" --no-js-runtimes --js-runtimes node`; `cookies.txt` (Netscape, `chmod 600`, fora do repo, **nunca versionado**). Verificação: `"$YTDLP_PATH" --dump-json --no-playlist --no-warnings --skip-download <url-do-lote>` responde JSON. Revogação: apagar `~/falas-web/bin` e `~/falas-web/cookies.txt`.
|
||||
- F0.4 Preflight de rede/acquisição: o mesmo `--dump-json` de F0.3; `psql` confirma `current_database() = teqo_1313`; `HeadBucket` confirma `S3_BUCKET = teqo-media` no endpoint loopback; o proxy socat de produção responde em `127.0.0.1:5433`. Qualquer falha interrompe a rodada; não há troca automática de alvo.
|
||||
- F0.5 `flock` disponível e `~/falas-web/.lock` criado na primeira rodada.
|
||||
|
||||
**F1 — Guard de mídia espelhada (código, ~1h; quota máxima do plano).**
|
||||
|
||||
1. Predicado em `scripts/lib/cli.mjs` delegando a `requiresWriteConfirm`, sem reimplementar "não-local".
|
||||
2. `die` no ramo de escrita de `scripts/import-web-speeches.mjs`; `--dry-run` intocado.
|
||||
3. Unit em `tests/unit/cliEnvFlags.unit.spec.ts` (5 casos, incluindo `NODE_ENV=production` com host local e entrada S3 não booleana) + teste de processo em `tests/unit/importWebSpeechesCli.unit.spec.ts` que prova a recusa antes da leitura do lote. RED antes do fix.
|
||||
4. `pnpm vitest run tests/unit/cliEnvFlags.unit.spec.ts tests/unit/importWebSpeechesCli.unit.spec.ts` verde + `tests/int/webSpeechIngest.int.spec.ts` e `tests/int/webSpeechImport.int.spec.ts` verdes (o caminho de ingestão não muda; a cobertura existente continua sendo a dele).
|
||||
|
||||
**F2 — Runbook (docs, ~2h).** Escrever `## C225` com o conteúdo exigido em §Runbook exigido abaixo, mais a linha em "Onde roda cada coisa". Verificação: seguir o runbook de ponta a ponta **a seco** numa sessão limpa (sem escrever) — todo comando listado existe, toda variável está definida antes do uso, nenhum passo depende de memória de sessão.
|
||||
|
||||
**F3 — Gates e fechamento (agente).** `pnpm gate:fast`; `pnpm format:check`; `pnpm knip`; `pnpm check:cycles`; `pnpm push` (changelog + branch `C225-…`); PR com `Closes #1339`. Sem e2e novo: a mudança é docs + um predicado testado, e nenhuma superfície renderizada muda.
|
||||
|
||||
**F4 — A operação (ato humano no homeserver, depois do merge; ~2–4h de máquina na primeira rodada).**
|
||||
|
||||
- F4.1 Sessão: `ssh homeserver` → `source ~/.nvm/nvm.sh` → `cd ~/teqo-falas-web` → `set -a; source ~/stack/.env; set +a` (`DEEPINFRA_API_KEY` **e** `DEEPSEEK_API_KEY` — a ausência do segundo é a falha que o C224 registrou na workstation) → `set -a; source ~/stack/teqo-1313.env; set +a` (`DATABASE_URL`, `NODE_ENV=production`, `PAYLOAD_SECRET`, `S3_*`) → reescritas de host (`DATABASE_URL` → `127.0.0.1:5433`; `S3_ENDPOINT` → `http://127.0.0.1:3900`) → `export YTDLP_PATH="$HOME/falas-web/bin/yt-dlp"`.
|
||||
- F4.2 Planejar (read-only, **sem** flag): `pnpm falas-web:import --findings "$FALAS_WEB_DIR/batch-<stamp>.json" --out "$FALAS_WEB_DIR" --dry-run`. Conferir: `invalid = 0`, `duplicates = 0`, e **nenhum `create` para uma URL já ingerida** (D3).
|
||||
- F4.3 Smoke: adquirir uma vez o lock externo `~/falas-web/.lock` e mantê-lo até F4.8; `FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import --findings … --limit 1 --out "$FALAS_WEB_DIR" 2>&1 | tee -a ~/falas-web/runs/<stamp>.log`, em `tmux`. Conferir no relatório JSON: `created = 1`, estágio por campo, e o objeto no bucket.
|
||||
- F4.4 Lote curado: repetir o comando com a flag explícita e **sem** `--limit`, ainda sob o mesmo lock; a cauda do `--limit` do F4.3 não é `pending` — ela fica no `batch-<stamp>.json` e entra nesta passada. Registrar o código de saída: exit 0 = sucesso total/parcial (falhas no relatório), exit 1 = nenhum achado entrou.
|
||||
- F4.5 Idempotência: repetir F4.4 com o mesmo lote e a mesma flag explícita, ainda sob o lock → relatório com `skipped`, `asrSeconds = 0` e nenhum objeto novo no bucket. É a prova de aceite do "não duplica/não reprocessa".
|
||||
- F4.6 Inventário e verificação (read-only): `SELECT` das falas `web:%` (contagem, `sourceKey`, `speechAt`, plataforma) e conferência do bucket; abrir `/campanha/comunicacao/acervo` → "Falas na internet" como confirmação (usuária de campanha) e confirmar que a fonte da Câmara segue com os **997** discursos.
|
||||
- F4.7 Estado: ainda com o lock, gravar `~/falas-web/last-run.json` no contrato do C218 (escrita atômica `tmp` + `mv`), com `lastRunAt` advanced **só** com exit 0, falhas → `pending` com `attempts`, revisão → `review`. Acrescentar o resultado medido ao changelog.
|
||||
- F4.8 Rollback ensaiado uma vez antes de liberar o lock (a prova de que o caminho é reversível): apagar **um** discurso da rodada no admin e conferir que segmento, linha de mídia e objeto no bucket foram junto; remover o `sourceKey` de qualquer `pending`/`review` que ainda o referencie.
|
||||
|
||||
### Runbook exigido (F2 — o conteúdo, não o texto)
|
||||
|
||||
`## C225 — rodada de falas da internet no acervo de produção`, na voz do documento (comando, motivo, medido). Seções:
|
||||
|
||||
1. **O que é** — a fonte C216 abre vazia porque nenhuma rodada web rodou contra o `teqo_1313`; esta seção é o procedimento para encher, repetir e reverter. Operação, não feature; a esteira é o C215 e a descoberta é o C218.
|
||||
2. **Pré-requisitos** — deploy com o C216/C217/C223/C224 em produção; `~/teqo-falas-web` pinado no SHA + `pnpm install`; `~/stack/.env` com as duas chaves; `~/stack/teqo-1313.env`; `~/falas-web/` (durable, `chmod 700`); `yt-dlp` com `yt-dlp-ejs` + runtime JS; `ffmpeg` (o CLI resolve `FFMPEG_PATH` → `PATH` → binário empacotado do `pnpm install`, e imprime a origem).
|
||||
3. **Estado durável e isolamento** — `~/falas-web/{batch,discovery,reports}` + `last-run.json`; contrato do C218 copiado com o link; `--out "$FALAS_WEB_DIR"` (nunca `~/…` literal); lock externo mantido do início da rodada até a gravação de `last-run.json`; por que **não** `~/teqo-deploy` nem `data/falas-web/` do clone.
|
||||
4. **Cookies** — um arquivo Netscape revogável, o wrapper, `YTDLP_PATH` só na rodada, refresh documentado, revogação = apagar os dois arquivos. Fronteira: a skill C218 continua sem produção e sem flag.
|
||||
5. **Hosts na sessão** — as duas reescritas e **por que** (`postgres`/`host.docker.internal` só resolvem na rede do compose); o env file não é editado; o proxy socat de produção (`teqo-1313-build-proxy`, `127.0.0.1:5433`) e o endpoint do Garage no host.
|
||||
6. **Guardas** — `FALAS_WEB_IMPORT_CONFIRM=1` exigida sempre que a escrita não é provadamente local (`NODE_ENV=production`, host remoto ou `ALLOW_REMOTE_DB`); `--dry-run` é read-only; e o guard novo: escrita em produção/override/alvo não-local **recusa** sem as 4 `S3_*` (mídia em disco local é proibida). O runbook também exige banco `teqo_1313` e bucket `teqo-media` antes da operação. `ALLOW_REMOTE_DB` não faz parte do caminho normal.
|
||||
7. **Rodada** — o bloco de comandos numerado (F4.1→F4.5) com `tmux` + `tee`, exit codes, `--limit` e a cauda, e onde está o relatório JSON.
|
||||
8. **Provar idempotência** — repetir o lote e esperar `skipped`/`asrSeconds 0`/nenhum objeto novo.
|
||||
9. **Inventário** — os `SELECT` read-only (falas `web:%`, contagem por plataforma) para responder "o que já entrou".
|
||||
10. **Rollback** — apagar no admin (cascade `Speech → speechSegment → internetSpeechMedia` + objeto no bucket) com a **ressalva explícita** de que o SQL direto do C155 é inseguro aqui; remoção em massa é item próprio (gatilho declarado).
|
||||
11. **Falhas conhecidas da operação** — egress intermitente do host (IA 1/3 em 2026-09-25; CDN do Instagram IPv4 instável, com IPv6 do compose como histórico), `yt-dlp` sem runtime JS, cookies expirados, `S3_ENDPOINT` errado, `~/falas-web` em disco cheio (o temp é por achado e sai no `finally`, mas o clone precisa de espaço para `node_modules`), e a decisão de segurança: se o preflight de aquisição falhar no host, corrigir o host tem precedência; não existe troca automática para um banco remoto da workstation.
|
||||
12. **Decisão de segurança** — se o preflight falhar, a rodada para para correção do host; não abrir uma segunda esteira nem trocar o alvo de produção.
|
||||
13. **Resultado medido** — a ser preenchido por quem rodar (data, lote, criados/pulados/falhas, ASR/LLM, onde ficaram os artefatos), com o mesmo espírito do "Resultado registrado" do C155.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Não** criar collection/tabela de estado, migration, campo de rodada, ou "última execução" no banco. O estado é o arquivo, como o C218 define.
|
||||
- **Não** dar à skill C218 modo de produção, flag, lock próprio ou escrita fora do CLI.
|
||||
- **Não** mexer em `sourceKey`, no contrato do lote, no relatório, nos estágios ou nos exit codes do C215.
|
||||
- **Não** tocar a ingestão da Câmara (`origin = camara`, 997 discursos), as URLs do acervo, o C216/C217 nem o C219.
|
||||
- **Não** agendar, pôr cron, bot, watcher ou monitoring; a rodada é manual.
|
||||
- **Não** versionar cookies, config de navegador ou qualquer credencial; **não** replicar o perfil do navegador.
|
||||
- **Não** deixar mídia em disco local como "destino intermediário para subir depois" (o mirror é direto no bucket; o temp é por achado e sai no `finally`).
|
||||
- **Não** espelhar a sessão do navegador inteiro; no máximo o arquivo de cookies.
|
||||
- **Não** fazer uma "sweep" inicial em bloco na primeira rodada.
|
||||
- **Não** tocar Dockerfile/compose/env files para instalar `yt-dlp` na imagem (D1C).
|
||||
- **Não** "limpar" o runbook do C155 ao reescrever o rollback — a diferença (mídia no S3) fica explícita nas duas seções.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
| Risco | Evidência | Mitigação |
|
||||
| ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| O host não baixa do YouTube/Instagram (egress intermitente; CDN do Instagram instável em IPv4) | runbook §Egress (1/3 de connect em 2026-09-25); post-mortem `2026-09-25-instagram-cdn-ipv4-instavel.md` | F0.4 é **gate**: se o `--dump-json` falha, a operação não começa; o relatório honesto transforma a falha em `pending` e o contorno está declarado no runbook. Instagram entra depois do YouTube na primeira rodada |
|
||||
| `yt-dlp` sem runtime JS habilitada (só `deno` vem por default) e sem `yt-dlp-ejs` empacotado | docs do yt-dlp (`--js-runtimes`, `--remote-components`) | Wrapper com `--js-runtimes node` + instalação com o ejs empacotado; verificado no F0.2 antes de qualquer escrita |
|
||||
| Sessão de cookies expirada/revogada | C215 pressupõe sessão que ele nunca teve | Falha vira `pending` com estágio `acquisition`; refresh documentado; revogação = apagar `bin/` + `cookies.txt` |
|
||||
| Mídia caindo em disco local sem ninguém perceber (S3 ausente) | `resolveS3StorageEnv` degrada para `{ enabled: false }` em silêncio | **Guard D6** (fail-closed na escrita não-local) + preflight F0.4 + mensagem de erro nomeando as 4 envs |
|
||||
| Estado durável perdido (clone limpo, `~/teqo-deploy` reescrito) | `data/falas-web/` é gitignored; deploy reescreve o workspace do runner | Diretório dedicado fora do repo (D5) + `flock`; o runbook diz onde está a fonte da verdade |
|
||||
| Duplicata silenciosa por `externalId` presente × ausente | `webSpeechSourceKey` (`src/lib/webSpeech.ts:163-174`) não tem lookup alternativo; sem teste para o caso cruzado | Regra de curadoria + conferência no dry-run (D3) + `sourceKey` unique como rede de segurança; **gatilho**: se a F4.2 apontar `create` para URL já ingerida, item próprio de normalização |
|
||||
| Rollback por SQL deixa mídia órfã no bucket | cascade só pelo Payload (`src/collections/Speech.ts:65-98`) | Rollback documentado pelo admin (D4) + inventário read-only antes de decidir |
|
||||
| Custo de ASR/LLM maior do que o esperado em fala longa | C223 liberou transcrição completa; C155 mediu ~US$1,23/997 discursos (~45h) | Rodada com `--limit` primeiro, relatório com `asrCostUsd`/`llmCostUsd`/`durationMs` lido **antes** de ampliar; nada de varredura inicial em bloco |
|
||||
| `S3_ENDPOINT` reescrito errado grava em outro bucket/nada | hosts do env file só resolvem no compose | Preflight F0.4 (Garage responde) + conferência do objeto no bucket em F4.3 antes de seguir |
|
||||
|
||||
## Gatilhos de revisitação
|
||||
|
||||
- **Normalizar a identidade (`sourceKey`)** — quando um dry-run apontar `create` para uma URL já ingerida, ou quando a curadoria virar rotina automática. Item próprio (mudança de contrato do C215).
|
||||
- **Comando de rollback em massa** — quando uma rodada precisar ser removida em bloco. Exige flag, teste e runbook próprios.
|
||||
- **Promover a skill C218 a modo de produção** — quando a operação manual se provar repetível e o custo de coordenação pesar. Item de produto, com a flag ainda sob ato humano.
|
||||
- **Rodar a ingestão dentro do container de manutenção** — se o host deixar de ter `yt-dlp` viável (o que muda o D1 para a opção C e toca o build da imagem).
|
||||
- **`--out` com state dir relativo/expansão de `~`** — hoje só `..` é recusado; se alguém pedir `--out` com validação mais estrita, é item pequeno e isolado.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto — a fonte abre com falas, player, transcrição, data e origem; a segunda rodada não duplica nem reprocessa; a mídia está no bucket privado e servida só ao acervo; o runbook permite repetir sem memória de sessão; as falas da Câmara ficam intactas.
|
||||
- [x] Invariantes `AGENTS.md`/engineering-standards — nada de migration/collection nova; nenhuma escrita em produção pelo agente; o estado operacional fora do banco; a fronteira fail-closed do C218 intacta (a skill não ganha flag, lock nem caminho de escrita); `sourceKey` preservado; identificadores em inglês, textos de operação em pt-BR; o rollback documentado passa pelo dono (Payload), não por SQL cru.
|
||||
- [x] `FALAS_WEB_IMPORT_CONFIRM` permanece exportada só por mão, na linha de comando de quem opera; nenhum arquivo do repo a define.
|
||||
- [x] Testes de domínio previstos — unit novo para o predicado do guard (5 casos, RED antes do fix) em `tests/unit/cliEnvFlags.unit.spec.ts`; teste de processo do CLI em `tests/unit/importWebSpeechesCli.unit.spec.ts` prova a recusa antes da leitura do lote; `tests/int/webSpeechIngest.int.spec.ts` e `tests/int/webSpeechImport.int.spec.ts` continuam cobrindo a ingestão; unit de `webSpeech`/`webSpeeches`/`ytdlp`/`catalogoFalasWebSkill` intocados (nenhum contrato mudou). Sem teste de CLI contra Garage/cookies/proxy/rollback — limitação declarada, verificada pelo preflight e pelo smoke humano.
|
||||
- [x] Gates — `pnpm gate:fast`, `pnpm format:check`, `pnpm knip`, `pnpm check:cycles`; changelog curto e honesto (declara o que é guard e runbook, e o status real da operação).
|
||||
- [x] Verificável sem a memória da sessão — o runbook lista workspace, envs, reescritas, cookies, guardas, comandos, prova de idempotência, inventário e rollback; cada fase tem um comando de conferência.
|
||||
|
||||
## Triage da revisão
|
||||
|
||||
### Já resolvido no simplify/critique (não reabrir)
|
||||
|
||||
- O guard de mídia espelhada, o teste do predicado, o teste de processo do CLI e o smoke de recusa antes da leitura do lote estão neste diff.
|
||||
- O runbook agora é fail-closed no alvo, nas chaves, no bucket, no clone, no symlink, no lock e no estado; o branch foi rebased em `origin/main`.
|
||||
- A curadoria C218 agora fixa `externalId` para YouTube/Instagram; a identidade `sourceKey` não foi alterada.
|
||||
|
||||
### Explicitamente fora
|
||||
|
||||
- Não registrar um hardcode global de `teqo_1313`/`teqo-media` no CLI: o guard de alvo exato pertence ao procedimento C225 para preservar usos legítimos de staging; gatilho: se a CLI ganhar um modo de produção próprio.
|
||||
- Não criar segunda esteira, cron, collection de estado, migration, modo de produção para C218 ou dedupe cross-URL; esses itens permanecem nos gatilhos de revisitação acima.
|
||||
- Não registrar a query de duplicatas: `sourceKey` já é único; o inventário agora conta mídia e lista `id` para rollback.
|
||||
|
||||
## Self-score (decision-quality, gate ≥4)
|
||||
|
||||
1. **Decisões caras têm rejeitadas?** — **5/5.** D1 (onde executa), D2 (cookies/runtime sem código), D3 (identidade), D4 (rollback), D5 (workspace/estado) e D6 (guard) trazem Opções/Recomendação/Rejeitadas com o motivo de cada rejeição.
|
||||
2. **Cabe no appetite?** — **5/5.** Um predicado + um `die` + um bullet de skill + um runbook + changelog; sem migration, sem collection, sem UI, sem cron. Fases F0/F4 são operação, como a intenção compte (~1 dia).
|
||||
3. **Rabbit holes nomeados?** — **5/5.** Doze bullets de não escopo (collection de estado, skill em produção, mexer em `sourceKey`, varrer em bloco, cookies versionados, replicar perfil, mirror em disco, tocar Dockerfile, "limpar" o C155) + cinco gatilhos de revisitação.
|
||||
4. **Depth check (reuso, sem módulo raso)?** — **5/5.** Reusa `requiresWriteConfirm`/`assertWriteConfirm`/`dieWithLabel` (o próprio dono declara que reescrever essas formas quebra o build), `resolveS3StorageEnv`, `resolveFfmpeg`, `assertLocalDatabase`, o `flock` já usado pelo repo, o clone pinado do C155 e o formato de seção do runbook existente. Nenhum arquivo/utility/componente novo — o único código novo é um predicado no módulo que já é o dono do conhecimento.
|
||||
5. **Intenção permanece satisfeita?** — **5/5.** O outcome de produto é o mesmo da intenção; a engenharia só torna explícitos o binário de aquisição, o bucket e o rollback. As quatro recomendações A ficam registradas como assumidas, e nenhuma foi convertida em feature ou automação.
|
||||
|
||||
**Total: 25/25.**
|
||||
|
||||
**Status: aprovado** (auto-aprovado no modo `--auto`: abordagem técnica, corte de escopo previsto na skill e literais de dados que o próprio plano recomenda, todos registrados como assumidos). **Continua parando, sem exceção:** qualquer execução de escrita em produção, aprovação humana, migration, `DEGRADED` visual, e divergência material de produto → o orquestrador para, comenta na Issue e flipa para `blocked`.
|
||||
|
|
@ -26,7 +26,13 @@ import { dirname, join } from 'node:path'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import { assertLocalDatabase } from './assert-local-database.mjs'
|
||||
import { assertWriteConfirm, databaseTarget, dieWithLabel, loadCliEnv } from './lib/cli.mjs'
|
||||
import {
|
||||
assertWriteConfirm,
|
||||
databaseTarget,
|
||||
dieWithLabel,
|
||||
loadCliEnv,
|
||||
mirroredMediaRequired,
|
||||
} from './lib/cli.mjs'
|
||||
import { resolveFfmpeg } from './lib/mediaBinaries.mjs'
|
||||
import {
|
||||
formatWebSpeechReport,
|
||||
|
|
@ -42,6 +48,7 @@ loadCliEnv()
|
|||
const die = dieWithLabel('falas-web:import')
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts')
|
||||
const { parseWebSpeechBatch } = await import('../src/lib/webSpeech.ts')
|
||||
const { findSpeechImportState } = await import('../src/utilities/speech/speechImport.ts')
|
||||
const { ingestWebSpeech } = await import('../src/utilities/speech/webSpeechIngest.ts')
|
||||
|
|
@ -105,8 +112,9 @@ Opções:
|
|||
Sucesso parcial sai com código 0 (as falhas ficam no relatório); código 1
|
||||
quando nenhum achado entrou no catálogo.
|
||||
|
||||
Escrita em alvo não-local ou com NODE_ENV=production exige
|
||||
${WRITE_CONFIRM_FLAG}=1. O binário do yt-dlp vem de YTDLP_PATH ou do PATH;
|
||||
Escrita em alvo não-local, com NODE_ENV=production ou com ALLOW_REMOTE_DB exige
|
||||
${WRITE_CONFIRM_FLAG}=1 e as quatro variáveis S3_* para que a mídia seja
|
||||
espelhada no bucket. O binário do yt-dlp vem de YTDLP_PATH ou do PATH;
|
||||
instale com \`pipx install yt-dlp\` (o ffmpeg empacotado é usado se não houver
|
||||
no PATH).
|
||||
`
|
||||
|
|
@ -153,6 +161,12 @@ async function main() {
|
|||
'falas-web:import',
|
||||
`A ingestão escreve no acervo; produção exige ${WRITE_CONFIRM_FLAG}=1.`,
|
||||
)
|
||||
const storage = resolveS3StorageEnv(process.env)
|
||||
if (mirroredMediaRequired({ s3Enabled: storage.enabled })) {
|
||||
die(
|
||||
`escrita em produção, override ou alvo não-local (${databaseTarget()}) exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas o Payload usaria disco local.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[falas-web:import] alvo: ${databaseTarget()} | modo: ${options.dryRun ? 'dry-run (read-only)' : options.reprocess ? 'import (--reprocess)' : 'import'}`,
|
||||
|
|
|
|||
|
|
@ -194,6 +194,13 @@ export const requiresWriteConfirm = ({
|
|||
allowRemoteDb = isRemoteDbOverrideSet(),
|
||||
} = {}) => nodeEnv === 'production' || allowRemoteDb || !isLocalDatabaseUrl(databaseUrl)
|
||||
|
||||
export const mirroredMediaRequired = ({
|
||||
nodeEnv = process.env.NODE_ENV,
|
||||
databaseUrl = process.env.DATABASE_URL,
|
||||
allowRemoteDb = isRemoteDbOverrideSet(),
|
||||
s3Enabled = false,
|
||||
} = {}) => requiresWriteConfirm({ nodeEnv, databaseUrl, allowRemoteDb }) && s3Enabled !== true
|
||||
|
||||
/** Human label of the write target for the guard messages (`host/name`). */
|
||||
export const databaseTarget = () => {
|
||||
const url = process.env.DATABASE_URL
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
isLocalDatabaseUrl,
|
||||
isRemoteDbOverrideSet,
|
||||
isTruthyEnv,
|
||||
mirroredMediaRequired,
|
||||
requiresWriteConfirm,
|
||||
} from '../../scripts/lib/cli.mjs'
|
||||
|
||||
|
|
@ -80,6 +81,72 @@ describe('databaseHostname / isLocalDatabaseUrl (C155)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('mirroredMediaRequired (C225 production media guard)', () => {
|
||||
it('requires mirrored media for a production target without S3', () => {
|
||||
expect(
|
||||
mirroredMediaRequired({
|
||||
nodeEnv: 'production',
|
||||
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5433/teqo_1313',
|
||||
allowRemoteDb: false,
|
||||
s3Enabled: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('allows a production target when S3 is enabled', () => {
|
||||
expect(
|
||||
mirroredMediaRequired({
|
||||
nodeEnv: 'production',
|
||||
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5433/teqo_1313',
|
||||
allowRemoteDb: false,
|
||||
s3Enabled: true,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not require mirrored media for a local development target', () => {
|
||||
expect(
|
||||
mirroredMediaRequired({
|
||||
nodeEnv: 'development',
|
||||
databaseUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_wt155',
|
||||
allowRemoteDb: false,
|
||||
s3Enabled: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not trust a non-boolean S3 capability value', () => {
|
||||
const options = {
|
||||
nodeEnv: 'production' as const,
|
||||
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5433/teqo_1313',
|
||||
allowRemoteDb: false,
|
||||
s3Enabled: false,
|
||||
}
|
||||
Object.assign(options, { s3Enabled: 'false' })
|
||||
|
||||
expect(mirroredMediaRequired(options)).toBe(true)
|
||||
})
|
||||
|
||||
it('requires mirrored media for remote and override targets', () => {
|
||||
expect(
|
||||
mirroredMediaRequired({
|
||||
nodeEnv: 'development',
|
||||
databaseUrl: 'postgresql://teqo:teqo@db.example.com:5432/teqo_1313',
|
||||
allowRemoteDb: false,
|
||||
s3Enabled: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(
|
||||
mirroredMediaRequired({
|
||||
nodeEnv: 'development',
|
||||
databaseUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_wt155',
|
||||
allowRemoteDb: true,
|
||||
s3Enabled: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requiresWriteConfirm (C155 write guard)', () => {
|
||||
it('stays quiet for a local target in dev/test', () => {
|
||||
expect(
|
||||
|
|
|
|||
38
tests/unit/importWebSpeechesCli.unit.spec.ts
Normal file
38
tests/unit/importWebSpeechesCli.unit.spec.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { spawnSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const repoRoot = process.cwd()
|
||||
const scriptPath = join(repoRoot, 'scripts', 'import-web-speeches.mjs')
|
||||
const missingFindingsPath = join(repoRoot, 'data', 'falas-web', 'missing-test-batch.json')
|
||||
|
||||
const runWithoutS3 = () =>
|
||||
spawnSync(process.execPath, [scriptPath, '--findings', missingFindingsPath], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs',
|
||||
NODE_ENV: 'production',
|
||||
DATABASE_URL: 'postgresql://teqo:teqo@127.0.0.1:5433/teqo_1313',
|
||||
FALAS_WEB_IMPORT_CONFIRM: '1',
|
||||
PAYLOAD_SECRET: 'test-secret',
|
||||
S3_BUCKET: '',
|
||||
S3_ENDPOINT: '',
|
||||
S3_ACCESS_KEY_ID: '',
|
||||
S3_SECRET_ACCESS_KEY: '',
|
||||
},
|
||||
})
|
||||
|
||||
describe('import-web-speeches production media guard', () => {
|
||||
it('refuses a non-local write without S3 before reading the findings file', () => {
|
||||
const result = runWithoutS3()
|
||||
const output = `${result.stdout}${result.stderr}`
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output).toContain('S3_BUCKET')
|
||||
expect(output).not.toContain('ENOENT')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue