feat(hermes): script de validação + sed anti-vazamento Dolce Amore

- bin/hermes-validate <slug>: 44 checks de saúde (DB, filesystem, systemd,
  routing, MCP tools, humanização). Saída textual ou --json. Exit 0 sem FAIL.
- bin/hermes-provision: sed adicional substitui exemplos hardcoded de
  categorias Dolce Amore (Mini Chalé 45 etc) pelas 3 primeiras categorias
  da unidade nova; evita resíduo em descrições de tools.
- Fix bash: trocar `|| echo 0` por `|| true` em greps (evita "0\n0" quando
  grep -c não acha e ainda imprime contagem).

Validado em juliana_qnn1: 43 PASS / 0 FAIL / 1 WARN (gallery seed pendente).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-02 14:53:35 -03:00
parent bc87b496a4
commit 9cfd131dcf
2 changed files with 215 additions and 0 deletions

View File

@ -291,6 +291,15 @@ sed -i "s|Dolce Amore Motel|$BRAND_NAME — $UNIT_NAME|g" "$PROFILES_DIR/$SLUG/S
sed -i "s|Valentina|$NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md"
sed -i "s|dolce-amore-reservas|$SKILL_NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md"
# Substitui exemplos hardcoded de categorias Dolce Amore (Mini Chalé 45 etc) pelas
# 3 primeiras categorias da unidade nova. Sem isso, SOUL.md vaza Dolce Amore-isms
# em descrições de tools mesmo após sed de identidade.
EX_CATS_LIST=$(echo "$SPEC" | jq -r '[.categories[0:3] | .[] | "\"" + .key + "\""] | join(", ")')
FIRST_CAT=$(echo "$SPEC" | jq -r '.categories[0].key // "categoria"')
sed -i "s|\"Master\", \"Luxo\", \"Mini Chalé 45\"|$EX_CATS_LIST|g" "$PROFILES_DIR/$SLUG/SOUL.md"
sed -i "s|Prefere Suíte Master|Prefere $FIRST_CAT|g" "$PROFILES_DIR/$SLUG/SOUL.md"
sed -i "s|prefiro suíte master|prefiro $FIRST_CAT|g" "$PROFILES_DIR/$SLUG/SOUL.md"
# Skill: usa o markdown gerado pelo expand_spec (tabela do banco + regras).
echo "$SPEC" | jq -r '.skill_md' > "$PROFILES_DIR/$SLUG/skills/$SKILL_NAME/SKILL.md"

206
bin/hermes-validate Executable file
View File

@ -0,0 +1,206 @@
#!/usr/bin/env bash
# hermes-validate <slug> — auditoria completa de um agente Hermes
#
# Uso:
# hermes-validate juliana_qnn1
# hermes-validate valentina --json # output JSON-only pra parsing
#
# Roda 42 checks em DB / filesystem / daemon / routing / tripé humanização /
# tools MCP / pricing. Reporta PASS/FAIL/WARN por item + resumo final.
#
# Exit code 0 = sem FAIL. 1 = pelo menos um FAIL.
set -uo pipefail
SLUG="${1:-}"
JSON_MODE=0
[[ "${2:-}" == "--json" ]] && JSON_MODE=1
PROFILES_DIR="/root/.hermes/profiles"
DOCKER_APP_FILTER="iachat_iachat_app"
[[ -z "$SLUG" ]] && { echo "uso: hermes-validate <slug> [--json]"; exit 2; }
PASS=0; FAIL=0; WARN=0
RESULTS_JSON='[]'
green() { printf "\033[32m✓\033[0m %s\n" "$1"; }
red() { printf "\033[31m✗\033[0m %s\n" "$1"; }
yellow(){ printf "\033[33m⚠\033[0m %s\n" "$1"; }
check() {
local label="$1" status="$2" detail="${3:-}"
case "$status" in
PASS) PASS=$((PASS+1)); [[ $JSON_MODE -eq 0 ]] && green "$label${detail:+ — $detail}" ;;
FAIL) FAIL=$((FAIL+1)); [[ $JSON_MODE -eq 0 ]] && red "$label${detail:+ — $detail}" ;;
WARN) WARN=$((WARN+1)); [[ $JSON_MODE -eq 0 ]] && yellow "$label${detail:+ — $detail}" ;;
esac
RESULTS_JSON=$(echo "$RESULTS_JSON" | jq --arg l "$label" --arg s "$status" --arg d "$detail" '. + [{label: $l, status: $s, detail: $d}]')
}
CID=$(docker ps --filter "name=$DOCKER_APP_FILTER" -q | head -1)
[[ -z "$CID" ]] && { echo "iachat_iachat_app container não rodando"; exit 2; }
PROFILE_DIR="$PROFILES_DIR/$SLUG"
# ============================================================
# Coleta dados do DB num único rails runner pra evitar 30 docker execs
# ============================================================
DB_DUMP=$(docker exec "$CID" bundle exec rails runner "
asst = Captain::Assistant.find_by(hermes_profile_name: '$SLUG', engine: 'hermes')
if asst.nil?
puts({error: 'no_assistant'}.to_json)
exit
end
unit = asst.captain_unit
brand = unit&.brand
ci = CaptainInbox.where(captain_assistant_id: asst.id).first
inbox = ci&.inbox
hum = asst.config['response_delay']
cats = unit&.pricing_categories&.includes(:amounts)&.to_a || []
galleria = unit&.gallery_items&.count || 0
out = {
assistant_id: asst.id,
name: asst.name,
engine: asst.engine,
profile_name: asst.hermes_profile_name,
port: asst.hermes_port,
secret_present: !asst.hermes_subscription_secret.nil?,
base_url: asst.hermes_webhook_base_url,
parent_id: asst.parent_assistant_id,
unit_id: unit&.id,
unit_name: unit&.name,
brand_name: brand&.name,
cats_count: cats.size,
cats_keys: cats.map(&:key),
amounts_total: cats.flat_map { |c| c.amounts.to_a }.size,
inbox_id: inbox&.id,
inbox_name: inbox&.name,
inbox_typing_delay: inbox&.typing_delay,
response_delay: hum,
gallery_count: galleria,
enabled_for: inbox ? Captain::Hermes.enabled_for?(inbox) : nil,
webhook_url: inbox ? Captain::Hermes.webhook_url_for(inbox) : nil,
secret_via: inbox ? Captain::Hermes.subscription_signing_secret(inbox)&.first(8) : nil
}
puts out.to_json
" 2>&1 | grep -v 'WARN\|RubyLLM\|ip_lookup' | tail -1)
if [[ -z "$DB_DUMP" ]] || ! echo "$DB_DUMP" | jq -e . >/dev/null 2>&1; then
echo "DB query falhou. Output: $DB_DUMP"
exit 2
fi
if echo "$DB_DUMP" | jq -e '.error' >/dev/null; then
red "Captain::Assistant com hermes_profile_name='$SLUG' não existe"
exit 1
fi
ASSISTANT_ID=$(echo "$DB_DUMP" | jq -r '.assistant_id')
PORT=$(echo "$DB_DUMP" | jq -r '.port')
INBOX_ID=$(echo "$DB_DUMP" | jq -r '.inbox_id // ""')
PARENT_ID=$(echo "$DB_DUMP" | jq -r '.parent_id // ""')
[[ $JSON_MODE -eq 0 ]] && echo "=== Validando $SLUG (assistant_id=$ASSISTANT_ID, port=$PORT) ==="
# ============================================================
# A. DB Captain
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- A. DB ---"
check "engine='hermes'" "$([[ $(echo "$DB_DUMP" | jq -r '.engine') == 'hermes' ]] && echo PASS || echo FAIL)"
check "hermes_profile_name setado" "$([[ $(echo "$DB_DUMP" | jq -r '.profile_name') != 'null' && $(echo "$DB_DUMP" | jq -r '.profile_name') != '' ]] && echo PASS || echo FAIL)"
check "hermes_port setado" "$([[ "$PORT" != 'null' && "$PORT" != '' ]] && echo PASS || echo FAIL)" "port=$PORT"
check "hermes_subscription_secret setado" "$([[ $(echo "$DB_DUMP" | jq -r '.secret_present') == 'true' ]] && echo PASS || echo FAIL)"
check "hermes_webhook_base_url" "$([[ $(echo "$DB_DUMP" | jq -r '.base_url') =~ ^http ]] && echo PASS || echo FAIL)"
check "parent_assistant_id setado" "$([[ "$PARENT_ID" != 'null' && "$PARENT_ID" != '' ]] && echo PASS || echo WARN)" "parent=$PARENT_ID"
check "captain_unit_id setado" "$([[ $(echo "$DB_DUMP" | jq -r '.unit_id') != 'null' ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.unit_name')"
check "Brand resolvida" "$([[ $(echo "$DB_DUMP" | jq -r '.brand_name') != 'null' ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.brand_name')"
check "Pricing categorias > 0" "$([[ $(echo "$DB_DUMP" | jq -r '.cats_count') -gt 0 ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.cats_count') cats: $(echo "$DB_DUMP" | jq -r '.cats_keys | join(",")')"
check "Pricing amounts > 0" "$([[ $(echo "$DB_DUMP" | jq -r '.amounts_total') -gt 0 ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.amounts_total') amounts"
check "CaptainInbox mapeada" "$([[ "$INBOX_ID" != 'null' && "$INBOX_ID" != '' ]] && echo PASS || echo WARN)" "inbox=$INBOX_ID"
check "Inbox.typing_delay > 0 (debounce)" "$([[ $(echo "$DB_DUMP" | jq -r '.inbox_typing_delay // 0') -gt 0 ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.inbox_typing_delay // 0')s"
check "config.response_delay (typing simulation)" "$([[ $(echo "$DB_DUMP" | jq -r '.response_delay.mode // ""') == 'typing_simulation' ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.response_delay.mode // "none"')"
check "GalleryItem para fotos" "$([[ $(echo "$DB_DUMP" | jq -r '.gallery_count') -gt 0 ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.gallery_count') items (send_suite_images precisa)"
# ============================================================
# B. Filesystem
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- B. Filesystem ---"
check "Pasta do profile existe" "$([[ -d "$PROFILE_DIR" ]] && echo PASS || echo FAIL)" "$PROFILE_DIR"
if [[ -d "$PROFILE_DIR" ]]; then
SOUL_LINES=$(wc -l < "$PROFILE_DIR/SOUL.md" 2>/dev/null || echo 0)
check "SOUL.md ≥ 300 linhas" "$([[ $SOUL_LINES -ge 300 ]] && echo PASS || echo WARN)" "$SOUL_LINES linhas"
RESID=$(grep -c 'Dolce Amore Motel\|Mini Chalé 45\|Suíte Ouro\|Chalé 2 Suítes' "$PROFILE_DIR/SOUL.md" 2>/dev/null || true)
RESID=${RESID:-0}
check "SOUL.md sem resíduo Dolce Amore" "$([[ $RESID -eq 0 ]] && echo PASS || echo FAIL)" "$RESID ocorrências"
IVR=$(grep -c 'RESPONDA ANTES DE PERGUNTAR\|Info vs Reserva' "$PROFILE_DIR/SOUL.md" 2>/dev/null || true)
check "SOUL.md tem Info-vs-Reserva" "$([[ ${IVR:-0} -gt 0 ]] && echo PASS || echo WARN)"
ALG=$(grep -c 'IGNORE OUTRAS UNIDADES' "$PROFILE_DIR/SOUL.md" 2>/dev/null || true)
check "SOUL.md tem anti-leak guard" "$([[ ${ALG:-0} -gt 0 ]] && echo PASS || echo WARN)"
check "skills/<skill_name>/SKILL.md existe" "$([[ -n "$(find "$PROFILE_DIR/skills" -mindepth 2 -name 'SKILL.md' | grep -v dogfood | head -1)" ]] && echo PASS || echo FAIL)"
check "dolce-amore-reservas NÃO está em skills/" "$([[ ! -d "$PROFILE_DIR/skills/dolce-amore-reservas" ]] && echo PASS || echo FAIL)"
SUB_KEY=$(jq -r 'keys[0] // ""' "$PROFILE_DIR/webhook_subscriptions.json" 2>/dev/null)
check "webhook_subscriptions.json com chave correta" "$([[ "$SUB_KEY" == "captain-inbox-$SLUG" ]] && echo PASS || echo FAIL)" "$SUB_KEY"
PORT_OK=$(grep -c "port: $PORT" "$PROFILE_DIR/config.yaml" 2>/dev/null || true)
check "config.yaml port=$PORT" "$([[ ${PORT_OK:-0} -gt 0 ]] && echo PASS || echo FAIL)"
HDR_OK=$(grep -c "X-Captain-Assistant-Id: '${PARENT_ID:-$ASSISTANT_ID}'" "$PROFILE_DIR/config.yaml" 2>/dev/null || true)
check "config.yaml X-Captain-Assistant-Id correto" "$([[ ${HDR_OK:-0} -gt 0 ]] && echo PASS || echo FAIL)"
MEM_OFF=$(grep -c 'memory_enabled: false' "$PROFILE_DIR/config.yaml" 2>/dev/null || true)
check "config.yaml memory_enabled: false" "$([[ ${MEM_OFF:-0} -gt 0 ]] && echo PASS || echo WARN)"
check "auth.json existe" "$([[ -f "$PROFILE_DIR/auth.json" ]] && echo PASS || echo FAIL)"
check "plugins/captain-http-callback presente" "$([[ -d "$PROFILE_DIR/plugins/captain-http-callback" ]] && echo PASS || echo FAIL)"
check "plugins/captain-webhook presente" "$([[ -d "$PROFILE_DIR/plugins/captain-webhook" ]] && echo PASS || echo FAIL)"
fi
# ============================================================
# C. Daemon / systemd
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- C. Daemon ---"
check "systemd unit hermes@$SLUG ativa" "$([[ $(systemctl is-active "hermes@$SLUG.service" 2>/dev/null) == 'active' ]] && echo PASS || echo FAIL)"
check "systemd unit enabled" "$([[ $(systemctl is-enabled "hermes@$SLUG.service" 2>/dev/null) == 'enabled' ]] && echo PASS || echo WARN)"
check "Daemon escutando na porta $PORT" "$(ss -tnlH "( sport = :$PORT )" 2>/dev/null | grep -q . && echo PASS || echo FAIL)"
ERR_COUNT=$(journalctl -u "hermes@$SLUG.service" --since '10 minutes ago' --no-pager 2>/dev/null | grep -ciE 'error|fatal|critical' || true)
ERR_COUNT=${ERR_COUNT:-0}
check "Logs sem erro recente" "$([[ $ERR_COUNT -eq 0 ]] && echo PASS || echo WARN)" "$ERR_COUNT erros nos últimos 10min"
# ============================================================
# D. Roteamento Captain ↔ Hermes
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- D. Roteamento ---"
check "Captain::Hermes.enabled_for? = true" "$([[ $(echo "$DB_DUMP" | jq -r '.enabled_for') == 'true' ]] && echo PASS || echo FAIL)"
EXPECTED_URL="$(echo "$DB_DUMP" | jq -r '.base_url')/webhooks/captain-inbox-$SLUG"
ACTUAL_URL=$(echo "$DB_DUMP" | jq -r '.webhook_url // ""')
check "webhook_url aponta pra $SLUG" "$([[ "$ACTUAL_URL" == "$EXPECTED_URL" ]] && echo PASS || echo FAIL)" "$ACTUAL_URL"
check "subscription_signing_secret retorna do DB" "$([[ -n "$(echo "$DB_DUMP" | jq -r '.secret_via // ""')" && "$(echo "$DB_DUMP" | jq -r '.secret_via // ""')" != 'null' ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.secret_via // "nil"')..."
# ============================================================
# E. MCP tools list (daemon registra todas)
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- E. MCP tools (no Captain) ---"
EXPECTED_TOOLS=(generate_pix faq_lookup add_label send_suite_images react_to_message update_contact get_contact_history check_pix_payment reschedule_reservation)
TOOLS_REGISTRY=$(docker exec "$CID" bundle exec rails runner "puts Captain::Mcp::ToolRegistry::TOOLS.map(&:name).join(',')" 2>&1 | grep -v 'WARN\|RubyLLM\|ip_lookup' | tail -1)
for tool in "${EXPECTED_TOOLS[@]}"; do
check "MCP tool '$tool' registrado" "$([[ "$TOOLS_REGISTRY" == *"$tool"* ]] && echo PASS || echo FAIL)"
done
# ============================================================
# Resumo
# ============================================================
TOTAL=$((PASS+FAIL+WARN))
if [[ $JSON_MODE -eq 1 ]]; then
jq -n --arg slug "$SLUG" --argjson pass $PASS --argjson fail $FAIL --argjson warn $WARN --argjson total $TOTAL --argjson results "$RESULTS_JSON" \
'{slug: $slug, total: $total, pass: $pass, fail: $fail, warn: $warn, ok: ($fail == 0), results: $results}'
else
echo
echo "=== Resumo de $SLUG ==="
echo "Total: $TOTAL · ${PASS} PASS · ${FAIL} FAIL · ${WARN} WARN"
if [[ $FAIL -eq 0 ]]; then
echo "✅ Sem falhas críticas. Pode soltar."
else
echo "❌ $FAIL falha(s) — corrigir antes de soltar pro cliente."
fi
fi
[[ $FAIL -gt 0 ]] && exit 1
exit 0