Projetos
visão por trilha de trabalho · agrega daily/weekly/meetings/commitsAtividade Git
últimos 14 dias
7 commits
1 push
1 repo
2026-06-08 VPS 1 commit · 1 push
-
00:22 push mainrefs/heads/main → refs/heads/mainhttps://github.com/eleotherium/VPS.git
-
00:21
ef73586main redesenha esqueleto do dashboard segundo-cerebro: nav lateral + composição assimétrica +554 -420 · 5 arq.segundo-cerebro/app/templates/base.htmlsegundo-cerebro/app/templates/calendario.htmlsegundo-cerebro/app/templates/hoje.htmlsegundo-cerebro/app/templates/projetos.htmlsegundo-cerebro/app/templates/semana.htmldiff (50031 chars)
commit ef73586554676ea03f05728fca1047ca0bb46676 Author: Gabriel Eleoterio <gabrieleleoterioptc@gmail.com> Date: Sun Jun 7 21:21:52 2026 -0300 redesenha esqueleto do dashboard segundo-cerebro: nav lateral + composição assimétrica Troca a barra de abas horizontal por uma rail lateral fixa (compartilhada em todas as páginas), reconstrói a aba Hoje saindo do grid de stat-cards idênticos para um pulso-do-dia + painel de atenção com barra segmentada por app + timeline com espinha vertical, e troca o grid de stats da Semana por uma tira de 7 dias navegável. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> diff --git a/segundo-cerebro/app/templates/base.html b/segundo-cerebro/app/templates/base.html index 67e5bc0..b2fec4c 100644 --- a/segundo-cerebro/app/templates/base.html +++ b/segundo-cerebro/app/templates/base.html @@ -1,2 +1,2 @@ -{# base.html — header + nav compartilhado entre todas as abas. #} +{# base.html — shell + nav lateral compartilhados entre todas as abas. #} <!doctype html> @@ -10,55 +10,112 @@ <script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@600;700&family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <style> - body { font-family: -apple-system, "Segoe UI", system-ui, sans-serif; } + :root { + --bg-deep: #05060c; + --bg-rail: #07080f; + --accent: #22d3ee; + --accent-ink: #67e8f9; + --line: rgb(148 163 184 / 0.09); + --line-strong: rgb(148 163 184 / 0.18); + --surface: rgb(255 255 255 / 0.025); + --surface-raised: rgb(255 255 255 / 0.045); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --rail-w: 15.5rem; + } + html, body { height: 100%; } + body { font-family: "Inter", -apple-system, "Segoe UI", system-ui, sans-serif; background: var(--bg-deep); overflow: hidden; } + .font-brand { font-family: "Space Grotesk", "Inter", sans-serif; letter-spacing: -0.01em; } + .font-mono, code, pre { font-family: "JetBrains Mono", ui-monospace, monospace; } + + /* ─── fundo: leve atmosfera, não decoração ─── */ + .bg-aurora { position: fixed; inset: 0; z-index: -1; pointer-events: none; overflow: hidden; } + .bg-aurora::before { + content: ""; position: absolute; width: 1100px; height: 620px; top: -420px; left: var(--rail-w); + border-radius: 50%; filter: blur(150px); opacity: 0.08; + background: radial-gradient(circle, var(--accent), transparent 70%); + } + .bg-grid { position: fixed; inset: 0; z-index: -1; pointer-events: none; + background-image: + linear-gradient(rgb(148 163 184 / 0.022) 1px, transparent 1px), + linear-gradient(90deg, rgb(148 163 184 / 0.022) 1px, transparent 1px); + background-size: 56px 56px; + mask-image: radial-gradient(ellipse 60% 45% at 65% 0%, black 35%, transparent 100%); + } + .scrollbar-thin::-webkit-scrollbar { width: 6px; height: 6px; } - .scrollbar-thin::-webkit-scrollbar-thumb { background: rgb(51 65 85 / 0.6); border-radius: 3px; } + .scrollbar-thin::-webkit-scrollbar-thumb { background: rgb(148 163 184 / 0.18); border-radius: 3px; } - details summary { cursor: pointer; list-style: none; transition: background-color 160ms ease; } + details summary { cursor: pointer; list-style: none; transition: background-color 180ms var(--ease-out); } details summary::-webkit-details-marker { display: none; } - details > summary:hover { background-color: rgb(30 41 59 / 0.45); } + details > summary:hover { background-color: rgb(148 163 184 / 0.05); } details[open] > summary .lucide-chevron-down { transform: rotate(180deg); } - .lucide-chevron-down { transition: transform 200ms ease; } - @keyframes details-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } - details[open] > *:not(summary) { animation: details-in 180ms ease-out; } + .lucide-chevron-down { transition: transform 200ms var(--ease-out); } + @keyframes details-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: translateY(0); } } + details[open] > *:not(summary) { animation: details-in 180ms var(--ease-out); } - a, button { transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease, opacity 160ms ease; } + a, button { transition: background-color 180ms var(--ease-out), color 180ms var(--ease-out), border-color 180ms var(--ease-out), opacity 180ms var(--ease-out), box-shadow 180ms var(--ease-out); } #loading { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; - background: rgb(2 6 23 / 0.6); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); - transition: opacity 280ms ease; } + background: rgb(2 4 12 / 0.7); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); + transition: opacity 220ms var(--ease-out); } #loading.hide { opacity: 0; pointer-events: none; } - #loading .card { background: rgb(15 23 42); border: 1px solid rgb(51 65 85); border-radius: 14px; + #loading .card { background: rgb(13 16 28 / 0.92); border: 1px solid var(--line-strong); border-radius: 14px; padding: 16px 22px; display: flex; align-items: center; gap: 12px; - box-shadow: 0 30px 60px rgb(0 0 0 / 0.5); } - #loading .spinner { width: 22px; height: 22px; border: 2.5px solid rgb(59 130 246 / 0.25); - border-top-color: rgb(96 165 250); border-radius: 50%; animation: spin 700ms linear infinite; } + box-shadow: 0 30px 80px rgb(0 0 0 / 0.6); } + #loading .spinner { width: 20px; height: 20px; border: 2.5px solid rgb(34 211 238 / 0.2); + border-top-color: var(--accent); border-radius: 50%; animation: spin 750ms linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } - main { animation: page-fade 280ms ease-out; } - @keyframes page-fade { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } } + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } + } .led-pulse { animation: led-pulse 2.4s ease-in-out infinite; } - @keyframes led-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } + @keyframes led-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } - .stat-card { transition: transform 160ms ease, border-color 160ms ease; } - .stat-card:hover { transform: translateY(-1px); border-color: rgb(71 85 105); } + /* ─── superfície elevada — cartão padrão (sólido, não vidro) ─── */ + .surface { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 0.85rem; + transition: border-color 200ms var(--ease-out), background-color 200ms var(--ease-out); + } + .surface-hover:hover { border-color: var(--line-strong); background: var(--surface-raised); } + + .stat-card { transition: border-color 180ms var(--ease-out), background-color 180ms var(--ease-out); } + .stat-card:hover { border-color: var(--line-strong); background: var(--surface-raised); } + + /* badge de ícone — superfície neutra, cor só quando semântica */ + .icon-badge { position: relative; display: inline-flex; align-items: center; justify-content: center; + width: 1.85rem; height: 1.85rem; border-radius: 0.55rem; flex-shrink: 0; + background: var(--surface); border: 1px solid var(--line); } + /* ─── menu flutuante — único lugar onde blur é justificado (overlay real) ─── */ [data-menu] { position: relative; } - [data-menu] > [data-menu-panel] { display: none; position: absolute; right: 0; top: calc(100% + 6px); min-width: 240px; z-index: 30; } + [data-menu] > [data-menu-panel] { + display: none; position: absolute; left: 0; bottom: calc(100% + 8px); min-width: 240px; z-index: 30; + background: rgb(13 16 28 / 0.94); border: 1px solid var(--line-strong); border-radius: 0.85rem; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + transform-origin: bottom left; animation: menu-in 140ms var(--ease-out); + } [data-menu].open > [data-menu-panel] { display: block; } + @keyframes menu-in { from { opacity: 0; transform: scale(0.97) translateY(4px); } to { opacity: 1; transform: scale(1) translateY(0); } } - /* Tab nav */ - .tab-link { position: relative; padding: 0.55rem 0.9rem; border-radius: 0.5rem; font-size: 0.78rem; - font-weight: 500; color: rgb(148 163 184); display: inline-flex; align-items: center; gap: 0.4rem; } - .tab-link:hover { color: rgb(226 232 240); background: rgb(30 41 59 / 0.6); } - .tab-link.active { color: rgb(96 165 250); background: rgb(30 58 138 / 0.25); - box-shadow: inset 0 0 0 1px rgb(30 64 175 / 0.5); } - .tab-link.active::after { content: ""; position: absolute; bottom: -1px; left: 0.6rem; right: 0.6rem; - height: 2px; background: rgb(96 165 250); border-radius: 1px; } + /* ─── nav lateral (rail) ─── */ + .rail-link { position: relative; display: flex; align-items: center; gap: 0.65rem; + padding: 0.5rem 0.7rem; border-radius: 0.6rem; font-size: 0.825rem; font-weight: 500; + color: rgb(148 163 184); } + .rail-link:hover { color: rgb(226 232 240); background: rgb(148 163 184 / 0.06); } + .rail-link.active { color: var(--accent-ink); background: rgb(34 211 238 / 0.08); + box-shadow: inset 0 0 0 1px rgb(34 211 238 / 0.2); } + .rail-link.active i { color: var(--accent-ink); } + .rail-link i { color: rgb(100 116 139); width: 1rem; height: 1rem; flex-shrink: 0; } /* Markdown rendering — usado em [data-md] */ - .md { color: rgb(226 232 240); line-height: 1.55; } - .md h1 { font-size: 1.15rem; font-weight: 700; color: rgb(248 250 252); margin: 1rem 0 0.5rem; padding-bottom: 0.25rem; border-bottom: 1px solid rgb(51 65 85); } - .md h2 { font-size: 1rem; font-weight: 600; color: rgb(96 165 250); margin: 0.9rem 0 0.4rem; } - .md h3 { font-size: 0.9rem; font-weight: 600; color: rgb(165 180 252); margin: 0.7rem 0 0.3rem; } + .md { color: rgb(226 232 240); line-height: 1.6; } + .md h1 { font-size: 1.1rem; font-weight: 700; color: rgb(248 250 252); margin: 1rem 0 0.5rem; padding-bottom: 0.3rem; border-bottom: 1px solid var(--line); } + .md h2 { font-size: 1rem; font-weight: 700; color: var(--accent-ink); margin: 0.9rem 0 0.4rem; } + .md h3 { font-size: 0.9rem; font-weight: 600; color: rgb(203 213 225); margin: 0.7rem 0 0.3rem; } .md h4 { font-size: 0.85rem; font-weight: 600; color: rgb(203 213 225); margin: 0.6rem 0 0.25rem; } @@ -70,12 +127,12 @@ .md strong { color: rgb(248 250 252); font-weight: 600; } - .md em { color: rgb(253 224 71); font-style: italic; } - .md code { background: rgb(15 23 42); border: 1px solid rgb(51 65 85); padding: 0.05rem 0.3rem; border-radius: 0.25rem; font-size: 0.85em; color: rgb(196 181 253); } - .md pre { background: rgb(2 6 23); border: 1px solid rgb(51 65 85); padding: 0.6rem; border-radius: 0.4rem; overflow-x: auto; margin: 0.5rem 0; font-size: 0.8rem; } + .md em { color: rgb(34 211 238); font-style: italic; } + .md code { background: rgb(8 10 20); border: 1px solid var(--line); padding: 0.05rem 0.3rem; border-radius: 0.3rem; font-size: 0.85em; color: rgb(196 181 253); } + .md pre { background: rgb(5 6 14); border: 1px solid var(--line); padding: 0.6rem; border-radius: 0.5rem; overflow-x: auto; margin: 0.5rem 0; font-size: 0.8rem; } .md pre code { background: transparent; border: 0; padding: 0; color: rgb(226 232 240); } - .md blockquote { border-left: 3px solid rgb(71 85 105); padding-left: 0.7rem; color: rgb(148 163 184); margin: 0.5rem 0; } - .md a { color: rgb(96 165 250); text-decoration: underline; } - .md hr { border: 0; border-top: 1px solid rgb(51 65 85); margin: 0.8rem 0; } + .md blockquote { border-left: 2px solid var(--line-strong); padding-left: 0.7rem; color: rgb(148 163 184); margin: 0.5rem 0; font-style: italic; } + .md a { color: var(--accent-ink); text-decoration: underline; text-underline-offset: 2px; } + .md hr { border: 0; border-top: 1px solid var(--line); margin: 0.8rem 0; } .md table { border-collapse: collapse; margin: 0.5rem 0; font-size: 0.85rem; } - .md th, .md td { border: 1px solid rgb(51 65 85); padding: 0.3rem 0.5rem; text-align: left; } - .md th { background: rgb(15 23 42); } + .md th, .md td { border: 1px solid var(--line); padding: 0.3rem 0.5rem; text-align: left; } + .md th { background: rgb(255 255 255 / 0.03); font-weight: 600; } .md.md-compact h1 { font-size: 1rem; margin-top: 0.6rem; } @@ -87,3 +144,5 @@ </head> -<body class="min-h-screen bg-slate-950 text-slate-100"> +<body class="text-slate-100"> +<div class="bg-aurora" aria-hidden="true"></div> +<div class="bg-grid" aria-hidden="true"></div> @@ -93,24 +152,42 @@ -<header class="border-b border-slate-800 px-6 py-2.5 sticky top-0 bg-slate-950/95 backdrop-blur z-20"> - <div class="max-w-7xl mx-auto flex items-center gap-3 flex-wrap"> - <div class="flex items-center gap-2"> - <i data-lucide="brain" class="w-5 h-5 text-blue-400"></i> - <span class="font-bold">segundo-cerebro</span> - </div> - <span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded - {% if mode == 'local' %}bg-emerald-950 text-emerald-300{% else %}bg-blue-950 text-blue-300{% endif %}">{{ mode }}</span> +<div class="flex h-screen"> - {% if mode == 'global' %} - <span class="text-slate-400 text-sm flex items-center gap-1"> - <i data-lucide="laptop" class="w-3.5 h-3.5"></i><strong class="text-slate-200">{{ device_filter or 'todos devices' }}</strong> + {# ───────────────────────── RAIL — nav lateral ───────────────────────── #} + <aside class="shrink-0 flex flex-col h-screen border-r border-white/[0.06]" style="width: var(--rail-w); background: var(--bg-rail);"> + <div class="px-5 pt-5 pb-4 flex items-center gap-2.5"> + <span class="icon-badge w-8 h-8 bg-cyan-400/[0.08] border-cyan-400/20"> + <i data-lucide="brain" class="w-4 h-4 text-cyan-300"></i> </span> - <div class="flex items-center gap-0.5 text-xs"> - <a href="?{% if request.query_params.get('d') %}d={{ request.query_params.get('d') }}{% endif %}" - class="px-1.5 py-0.5 rounded hover:text-blue-400 {% if not device_filter %}text-blue-400 font-semibold{% else %}text-slate-400{% endif %}">todos</a> - {% for dev in devices %} - <a href="?{% if request.query_params.get('d') %}d={{ request.query_params.get('d') }}&{% endif %}device={{ dev }}" - class="px-1.5 py-0.5 rounded hover:text-blue-400 {% if device_filter == dev %}text-blue-400 font-semibold{% else %}text-slate-400{% endif %}">{{ dev }}</a> - {% endfor %} + <div class="min-w-0"> + <div class="font-brand font-bold text-[14px] tracking-tight text-slate-100 leading-tight truncate">segundo-cérebro</div> + <span class="text-[9px] uppercase tracking-[0.16em] font-medium + {% if mode == 'local' %}text-emerald-400/80{% else %}text-cyan-400/80{% endif %}">{{ mode }}</span> </div> - {% endif %} + </div> + + <nav class="flex-1 px-3 pt-2 space-y-0.5 overflow-y-auto scrollbar-thin"> + {% set qs_device = (('&device=' ~ device_filter) if device_filter else '') %} + <a href="/{{ qs_device.replace('&', '?', 1) if qs_device }}" + class="rail-link {% if tab == 'hoje' %}active{% endif %}"><i data-lucide="sparkles"></i>Hoje</a> + <a href="/semana{{ qs_device.replace('&', '?', 1) if qs_device }}" + class="rail-link {% if tab == 'semana' %}active{% endif %}"><i data-lucide="layers"></i>Semana</a> + <a href="/calendario{{ qs_device.replace('&', '?', 1) if qs_device }}" + class="rail-link {% if tab == 'calendario' %}active{% endif %}"><i data-lucide="calendar"></i>Calendário</a> + <a href="/projetos{{ qs_device.replace('&', '?', 1) if qs_device }}" + class="rail-link {% if tab == 'projetos' %}active{% endif %}"><i data-lucide="kanban"></i>Projetos</a> + + {% if mode == 'global' %} + <div class="pt-4 mt-3 border-t border-[color:var(--line)]"> + <div class="px-2.5 pb-1.5 text-[10px] uppercase tracking-[0.14em] text-slate-600 font-medium flex items-center gap-1.5"> + <i data-lucide="laptop" class="w-3 h-3"></i>device + </div> + <a href="?{% if request.query_params.get('d') %}d={{ request.query_params.get('d') }}{% endif %}" + class="rail-link {% if not device_filter %}active{% endif %}"><i data-lucide="globe"></i>todos</a> + {% for dev in devices %} + <a href="?{% if request.query_params.get('d') %}d={{ request.query_params.get('d') }}&{% endif %}device={{ dev }}" + class="rail-link {% if device_filter == dev %}active{% endif %}"><i data-lucide="monitor"></i>{{ dev }}</a> + {% endfor %} + </div> + {% endif %} + </nav> @@ -118,21 +195,24 @@ {% if mode == 'local' %} - <div class="ml-auto flex items-center gap-2" data-controller="http://localhost:8766"> - <span id="col-queue" class="hidden text-[11px] px-2 py-0.5 rounded-md border border-amber-800 bg-amber-950/60 text-amber-300 flex items-center gap-1" title=""> - <i data-lucide="cloud-off" class="w-3 h-3"></i><span></span> - </span> - <span id="col-uploads" class="text-[11px] text-slate-500 hidden sm:flex items-center gap-1" title=""></span> + <div class="px-3 pb-4 pt-3 border-t border-[color:var(--line)]" data-controller="http://localhost:8766"> + <div class="flex items-center gap-1.5 mb-2 px-1"> + <span id="col-queue" class="hidden text-[10px] px-2 py-1 rounded-md border border-amber-400/25 bg-amber-400/[0.08] text-amber-300 items-center gap-1.5" title=""> + <i data-lucide="cloud-off" class="w-3 h-3"></i><span></span> + </span> + <span id="col-uploads" class="text-[10px] text-slate-500 hidden items-center gap-1.5 ml-auto" title=""></span> + </div> <div data-menu class="relative"> <button id="col-pill" type="button" onclick="toggleMenu(event)" - class="text-xs px-2.5 py-1 rounded-full font-medium border border-slate-700 bg-slate-900 text-slate-300 flex items-center gap-1.5 hover:border-slate-600"> - <span id="col-led" class="w-1.5 h-1.5 rounded-full bg-slate-600"></span> - <span id="col-status">…</span> + class="w-full text-xs px-3 py-2 rounded-lg font-medium border border-white/10 bg-white/[0.03] text-slate-300 flex items-center gap-2 hover:border-white/20"> + <span id="col-led" class="w-1.5 h-1.5 rounded-full bg-slate-600 shrink-0"></span> + <span id="col-status" class="font-mono">…</span> + <i data-lucide="chevron-up" class="w-3.5 h-3.5 ml-auto text-slate-500"></i> </button> - <div data-menu-panel class="rounded-xl border border-slate-800 bg-slate-900 shadow-2xl p-3 text-xs space-y-2"> + <div data-menu-panel class="shadow-2xl p-3 text-xs space-y-2"> <div id="col-detail" class="text-slate-400 space-y-1"></div> <div class="flex gap-1.5"> - <button onclick="collectorCall('start')" class="flex-1 px-2 py-1.5 rounded-md border border-emerald-800 text-emerald-300 hover:bg-emerald-950 flex items-center justify-center gap-1"><i data-lucide="play" class="w-3 h-3"></i>start</button> - <button onclick="collectorCall('stop')" class="flex-1 px-2 py-1.5 rounded-md border border-red-800 text-red-300 hover:bg-red-950 flex items-center justify-center gap-1"><i data-lucide="square" class="w-3 h-3"></i>stop</button> - <button onclick="collectorCall('restart')" class="flex-1 px-2 py-1.5 rounded-md border border-slate-700 text-slate-300 hover:bg-slate-800 flex items-center justify-center gap-1"><i data-lucide="refresh-cw" class="w-3 h-3"></i>restart</button> + <button onclick="collectorCall('start')" class="flex-1 px-2 py-1.5 rounded-lg border border-emerald-400/20 text-emerald-300 hover:bg-emerald-400/10 flex items-center justify-center gap-1"><i data-lucide="play" class="w-3 h-3"></i>start</button> + <button onclick="collectorCall('stop')" class="flex-1 px-2 py-1.5 rounded-lg border border-red-400/20 text-red-300 hover:bg-red-400/10 flex items-center justify-center gap-1"><i data-lucide="square" class="w-3 h-3"></i>stop</button> + <button onclick="collectorCall('restart')" class="flex-1 px-2 py-1.5 rounded-lg border border-white/10 text-slate-300 hover:bg-white/[0.06] flex items-center justify-center gap-1"><i data-lucide="refresh-cw" class="w-3 h-3"></i>restart</button> </div> - <button onclick="drainQueue()" class="w-full px-2 py-1.5 rounded-md border border-slate-700 text-slate-300 hover:bg-slate-800 flex items-center justify-center gap-1"><i data-lucide="cloud-upload" class="w-3 h-3"></i>forçar drain da fila</button> + <button onclick="drainQueue()" class="w-full px-2 py-1.5 rounded-lg border border-white/10 text-slate-300 hover:bg-white/[0.06] flex items-center justify-center gap-1"><i data-lucide="cloud-upload" class="w-3 h-3"></i>forçar drain da fila</button> </div> @@ -140,23 +220,12 @@ </div> - {% else %} - <span class="ml-auto"></span> {% endif %} - </div> - - <nav class="max-w-7xl mx-auto mt-2 flex items-center gap-1 -mb-2 overflow-x-auto scrollbar-thin"> - {% set qs_device = (('&device=' ~ device_filter) if device_filter else '') %} - <a href="/{{ qs_device.replace('&', '?', 1) if qs_device }}" - class="tab-link {% if tab == 'hoje' %}active{% endif %}"><i data-lucide="sparkles" class="w-3.5 h-3.5"></i>Hoje</a> - <a href="/semana{{ qs_device.replace('&', '?', 1) if qs_device }}" - class="tab-link {% if tab == 'semana' %}active{% endif %}"><i data-lucide="layers" class="w-3.5 h-3.5"></i>Semana</a> - <a href="/calendario{{ qs_device.replace('&', '?', 1) if qs_device }}" - class="tab-link {% if tab == 'calendario' %}active{% endif %}"><i data-lucide="calendar" class="w-3.5 h-3.5"></i>Calendário</a> - <a href="/projetos{{ qs_device.replace('&', '?', 1) if qs_device }}" - class="tab-link {% if tab == 'projetos' %}active{% endif %}"><i data-lucide="kanban" class="w-3.5 h-3.5"></i>Projetos</a> - </nav> -</header> + </aside> -<main class="max-w-7xl mx-auto px-6 py-5 space-y-5"> - {% block content %}{% endblock %} -</main> + {# ───────────────────────── CONTEÚDO ───────────────────────── #} + <div class="flex-1 h-screen overflow-y-auto scrollbar-thin"> + <main class="max-w-6xl mx-auto px-8 md:px-12 py-9 space-y-7"> + {% block content %}{% endblock %} + </main> + </div> +</div> @@ -187,9 +256,9 @@ async function collectorRefresh() { if (j.running) { - led.className = "w-1.5 h-1.5 rounded-full bg-emerald-400 led-pulse"; + led.className = "w-1.5 h-1.5 rounded-full bg-emerald-400 led-pulse shrink-0"; status.textContent = fmtDur(j.uptime_sec); - pill.className = "text-xs px-2.5 py-1 rounded-full font-medium border border-emerald-900 bg-emerald-950/40 text-emerald-300 flex items-center gap-1.5 hover:border-emerald-700"; + pill.className = "w-full text-xs px-3 py-2 rounded-lg font-medium border border-emerald-400/25 bg-emerald-400/[0.08] text-emerald-300 flex items-center gap-2 hover:border-emerald-400/40"; } else { - led.className = "w-1.5 h-1.5 rounded-full bg-red-400"; + led.className = "w-1.5 h-1.5 rounded-full bg-red-400 shrink-0"; status.textContent = "parado"; - pill.className = "text-xs px-2.5 py-1 rounded-full font-medium border border-red-900 bg-red-950/40 text-red-300 flex items-center gap-1.5 hover:border-red-700"; + pill.className = "w-full text-xs px-3 py-2 rounded-lg font-medium border border-red-400/25 bg-red-400/[0.08] text-red-300 flex items-center gap-2 hover:border-red-400/40"; } @@ -207,2 +276,3 @@ async function collectorRefresh() { queue.classList.remove("hidden"); + queue.classList.add("flex"); queue.title = `${q.pending} eventos na fila offline (sem conexão com Supabase)`; @@ -210,2 +280,3 @@ async function collectorRefresh() { queue.classList.add("hidden"); + queue.classList.remove("flex"); } @@ -214,2 +285,4 @@ async function collectorRefresh() { uploads.title = `${u.events_ok||0} eventos, ${u.screenshots_ok||0} screenshots hoje`; + uploads.classList.remove("hidden"); + uploads.classList.add("flex"); lucide.createIcons(); @@ -217,7 +290,7 @@ async function collectorRefresh() { } catch (e) { - led.className = "w-1.5 h-1.5 rounded-full bg-slate-600"; + led.className = "w-1.5 h-1.5 rounded-full bg-slate-600 shrink-0"; status.textContent = "offline"; - pill.className = "text-xs px-2.5 py-1 rounded-full font-medium border border-slate-700 bg-slate-900 text-slate-400 flex items-center gap-1.5"; + pill.className = "w-full text-xs px-3 py-2 rounded-lg font-medium border border-white/10 bg-white/[0.03] text-slate-400 flex items-center gap-2"; detail.innerHTML = `<div class="text-slate-400">controller não responde em ${CTRL}</div><div class="text-slate-600">rode <code class="text-slate-300">replicate\\start.ps1</code></div>`; - if (queue) queue.classList.add("hidden"); + if (queue) { queue.classList.add("hidden"); queue.classList.remove("flex"); } if (uploads) uploads.textContent = ""; diff --git a/segundo-cerebro/app/templates/calendario.html b/segundo-cerebro/app/templates/calendario.html index 57e751e..e835f9d 100644 --- a/segundo-cerebro/app/templates/calendario.html +++ b/segundo-cerebro/app/templates/calendario.html @@ -6,14 +6,15 @@ .cal-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; } - .cal-cell { border: 1px solid rgb(30 41 59 / 0.7); border-radius: 8px; padding: 6px 8px; - background: rgb(15 23 42 / 0.45); min-height: 96px; - display: flex; flex-direction: column; gap: 4px; transition: border-color 160ms ease, background-color 160ms ease; } - .cal-cell:hover { border-color: rgb(71 85 105); background: rgb(15 23 42 / 0.8); } + .cal-cell { border: 1px solid var(--line); border-radius: 0.6rem; padding: 6px 8px; + background: var(--surface); min-height: 96px; + display: flex; flex-direction: column; gap: 4px; + transition: border-color 160ms var(--ease-out), background-color 160ms var(--ease-out); } + .cal-cell:hover { border-color: var(--line-strong); background: var(--surface-raised); } .cal-cell.outside { opacity: 0.35; } - .cal-cell.today { border-color: rgb(59 130 246); box-shadow: 0 0 0 1px rgb(59 130 246 / 0.5) inset; } - .cal-cell.has-data { background: rgb(15 23 42 / 0.85); } + .cal-cell.today { border-color: rgb(34 211 238 / 0.35); box-shadow: inset 0 0 0 1px rgb(34 211 238 / 0.2); } + .cal-cell.has-data { background: var(--surface-raised); } .cal-cell .num { font-size: 0.78rem; color: rgb(148 163 184); font-weight: 600; } - .cal-cell .num.today { color: rgb(96 165 250); } + .cal-cell .num.today { color: var(--accent-ink); } .cal-cell .pv { font-size: 0.65rem; color: rgb(148 163 184); line-height: 1.15; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; } - .cal-cell .dot { display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: rgb(96 165 250); margin-left: 4px; } + .cal-cell .dot { display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); margin-left: 4px; } .cal-dow { font-size: 0.62rem; text-transform: uppercase; color: rgb(100 116 139); @@ -26,3 +27,3 @@ <h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2"> - <i data-lucide="calendar" class="w-3.5 h-3.5 text-amber-400"></i>{{ month_label }} + <i data-lucide="calendar" class="w-3.5 h-3.5 text-slate-500"></i>{{ month_label }} </h2> @@ -32,3 +33,3 @@ <a href="?{% if device_filter %}device={{ device_filter }}&{% endif %}m={{ prev_month }}" - class="px-2 py-1 rounded border border-slate-800 text-slate-300 hover:bg-slate-800/60 flex items-center gap-1"> + class="px-2 py-1 rounded-md border border-[color:var(--line)] text-slate-300 hover:border-[color:var(--line-strong)] hover:bg-white/[0.02] flex items-center gap-1 transition-colors"> <i data-lucide="chevron-left" class="w-3 h-3"></i>{{ prev_month_label }} @@ -37,3 +38,3 @@ <a href="?{% if device_filter %}device={{ device_filter }}{% endif %}" - class="px-2 py-1 rounded border border-blue-800 text-blue-300 hover:bg-blue-950 flex items-center gap-1"> + class="px-2 py-1 rounded-md border border-[color:var(--line-strong)] text-[color:var(--accent-ink)] hover:bg-cyan-400/[0.07] flex items-center gap-1 transition-colors"> <i data-lucide="rewind" class="w-3 h-3"></i>mês atual @@ -43,3 +44,3 @@ <a href="?{% if device_filter %}device={{ device_filter }}&{% endif %}m={{ next_month }}" - class="px-2 py-1 rounded border border-slate-800 text-slate-300 hover:bg-slate-800/60 flex items-center gap-1"> + class="px-2 py-1 rounded-md border border-[color:var(--line)] text-slate-300 hover:border-[color:var(--line-strong)] hover:bg-white/[0.02] flex items-center gap-1 transition-colors"> {{ next_month_label }}<i data-lucide="chevron-right" class="w-3 h-3"></i> @@ -85,3 +86,3 @@ <h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2"> - <i data-lucide="sparkles" class="w-3.5 h-3.5 text-amber-400"></i>Resumo do mês + <i data-lucide="sparkles" class="w-3.5 h-3.5 text-slate-500"></i>Resumo do mês </h3> @@ -94,3 +95,3 @@ <button data-loading onclick="fetch('/api/monthly/run',{method:'POST'}).then(()=>location.reload());" - class="ml-auto text-[10px] px-2 py-0.5 rounded-md border border-blue-800 text-blue-300 hover:bg-blue-950 flex items-center gap-1"> + class="ml-auto text-[10px] px-2 py-0.5 rounded-md border border-[color:var(--line-strong)] text-[color:var(--accent-ink)] hover:bg-cyan-400/[0.07] flex items-center gap-1 transition-colors"> <i data-lucide="play" class="w-2.5 h-2.5"></i>gerar @@ -100,3 +101,3 @@ {% if not monthly %} - <div class="rounded-lg border border-dashed border-slate-800 bg-slate-900/30 px-3 py-3 text-slate-500 text-xs"> + <div class="surface rounded-lg border-dashed px-3 py-3 text-slate-500 text-xs"> Sem resumo mensal ainda. @@ -105,5 +106,5 @@ {% else %} - <div class="rounded-lg border border-amber-900/40 bg-amber-950/10 p-3"> + <div class="surface rounded-lg p-3"> <div data-md class="text-[12px] leading-snug">{{ monthly.summary }}</div> - <div class="mt-2 pt-2 border-t border-slate-800 text-[10px] text-slate-500 flex items-center justify-between"> + <div class="mt-2 pt-2 border-t border-[color:var(--line)] text-[10px] text-slate-500 flex items-center justify-between"> <span>{{ monthly.model }}</span> @@ -120,3 +121,3 @@ {% if not weeklies %} - <div class="rounded-lg border border-dashed border-slate-800 bg-slate-900/30 px-3 py-2 text-slate-500 text-xs"> + <div class="surface rounded-lg border-dashed px-3 py-2 text-slate-500 text-xs"> Nenhum resumo semanal arquivado neste mês. @@ -126,4 +127,4 @@ {% for w in weeklies %} - <details class="rounded-lg border border-slate-800 bg-slate-900/40"> - <summary class="px-3 py-1.5 flex items-baseline gap-2 list-none"> + <details class="surface surface-hover rounded-lg"> + <summary class="px-3 py-1.5 flex items-baseline gap-2 list-none cursor-pointer"> <span class="font-mono text-[10px] text-slate-400 shrink-0 w-20">→ {{ w.week_end }}</span> @@ -150,3 +151,3 @@ {% for m in recent_monthlies %} - <div class="flex items-baseline gap-2 px-3 py-1.5 rounded border border-slate-800 bg-slate-900/30 hover:bg-slate-800/60 text-[11px]"> + <div class="surface surface-hover flex items-baseline gap-2 px-3 py-1.5 rounded-md text-[11px]"> <a href="?{% if device_filter %}device={{ device_filter }}&{% endif %}m={{ m.month_start[:7] }}" diff --git a/segundo-cerebro/app/templates/hoje.html b/segundo-cerebro/app/templates/hoje.html index 25c578a..2b597f7 100644 --- a/segundo-cerebro/app/templates/hoje.html +++ b/segundo-cerebro/app/templates/hoje.html @@ -1,3 +1,3 @@ {% extends "base.html" %} -{# Tab Hoje — substitui o antigo ontime.html. Estrutura idêntica, herdando base. #} +{# Tab Hoje — visão do dia: pulso do dia, composição de atenção, resumos, timeline em espinha. #} @@ -24,52 +24,98 @@ +{% set density_color = { + 'high': 'var(--accent)', + 'mid': 'rgb(34 211 238 / 0.45)', + 'low': 'rgb(148 163 184 / 0.4)', + 'none': 'rgb(148 163 184 / 0.14)' +} %} +{% set app_palette = ['var(--accent)', 'rgb(125 211 252 / 0.6)', 'rgb(148 163 184 / 0.5)', 'rgb(148 163 184 / 0.32)', 'rgb(148 163 184 / 0.18)'] %} + {% block content %} - {# ─────────────────────────────── HOJE — STATS ─────────────────────────────── #} - <section> - <div class="flex items-baseline gap-3 mb-3"> - <h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2"> - <i data-lucide="sparkles" class="w-3.5 h-3.5 text-amber-400"></i>Hoje · {{ date }} - </h2> - <span class="text-slate-600 text-xs">snapshot ao vivo · atualiza ao recarregar</span> + {# ─────────────────────────── CABEÇALHO + PULSO DO DIA ─────────────────────────── #} + <header class="space-y-3"> + <div class="flex items-baseline gap-3 flex-wrap"> + <h1 class="font-brand text-xl font-bold text-slate-100 tracking-tight">{{ date }}</h1> + <span class="text-xs text-slate-500 flex items-center gap-1.5"> + <span class="w-1.5 h-1.5 rounded-full bg-emerald-400 led-pulse"></span>snapshot ao vivo + </span> </div> - {% set s = stats %} - {% set has_any = (s.sessions or s.wa_count or s.transcripts or s.keystrokes_chars) %} - - {% if not has_any %} - <div class="rounded-lg border border-dashed border-slate-800 bg-slate-900/30 px-4 py-3 text-slate-500 text-xs flex items-center gap-2"> - <i data-lucide="moon" class="w-3.5 h-3.5"></i> - Nada capturado ainda hoje. - {% if mode == 'local' %}<span class="ml-auto">→ verifique o pill do coletor</span>{% endif %} - </div> - {% else %} - <div class="grid grid-cols-3 lg:grid-cols-6 gap-1.5"> - {% macro stat(icon, label, value, sub, icon_class='') %} - <div class="stat-card rounded-lg border border-slate-800 bg-slate-900/60 px-2.5 py-1.5"> - <div class="flex items-center gap-1 text-[10px] text-slate-500"><i data-lucide="{{ icon }}" class="w-3 h-3 {{ icon_class }}"></i>{{ label }}</div> - <div class="font-mono text-base text-slate-100 leading-tight">{{ value }}</div> - <div class="text-[9px] text-slate-600 leading-tight h-3">{{ sub or '' }}</div> - </div> - {% endmacro %} - {{ stat('clock', 'atenção', fmt_dur(s.attention_sec), '+ ' ~ fmt_dur(s.idle_sec) ~ ' ocioso' if s.idle_sec else '') }} - {{ stat('app-window', 'janelas', s.sessions, s.unique_apps ~ ' apps') }} - {{ stat('keyboard', 'digitado', fmt_num(s.keystrokes_chars), s.clipboards ~ ' cópia' ~ ('s' if s.clipboards != 1 else '')) }} - {{ stat('image', 'prints', s.screenshots, '') }} - {{ stat('message-circle', 'whatsapp', s.wa_count, s.wa_chats ~ ' chat' ~ ('s' if s.wa_chats != 1 else ''), 'text-emerald-400') }} - {{ stat('mic', 'áudios', s.transcripts, 'transcritos' if s.transcripts else '', 'text-purple-400') }} + {% if buckets %} + <div> + <div class="flex gap-[3px] h-6"> + {% for b in buckets %} + <a href="#b-{{ b.label_start | replace(':','-') }}" + class="flex-1 rounded-[3px] surface-hover" + style="background: {{ density_color[b.density] }}; opacity: {{ '1' if b.density == 'high' else ('0.8' if b.density == 'mid' else ('0.55' if b.density == 'low' else '0.3')) }};" + title="{{ b.label_start }}–{{ b.label_end }} · {{ b.top_app }} · densidade {{ b.density }}"></a> + {% endfor %} + </div> + <div class="flex justify-between mt-1 font-mono text-[9px] text-slate-600"> + <span>{{ buckets[0].label_start }}</span> + <span class="text-slate-700">pulso do dia · {{ buckets|length }} janelas de {{ ((bucket_min // 60 ~ 'h') if bucket_min >= 60 and bucket_min % 60 == 0 else (bucket_min ~ 'm')) }}</span> + <span>{{ buckets[-1].label_end }}</span> + </div> </div> + {% endif %} + </header> + + {# ─────────────────────────── COMPOSIÇÃO DE ATENÇÃO ─────────────────────────── #} + {% set s = stats %} + {% set has_any = (s.sessions or s.wa_count or s.transcripts or s.keystrokes_chars) %} - {% if s.top_apps %} - <div class="mt-1.5 rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2 space-y-0.5"> - <div class="flex items-center gap-1 text-[10px] text-slate-500 mb-1"> - <i data-lucide="trending-up" class="w-3 h-3"></i>onde gastou tempo + {% if not has_any %} + <div class="surface rounded-xl border-dashed px-4 py-3 text-slate-500 text-xs flex items-center gap-2"> + <i data-lucide="moon" class="w-3.5 h-3.5"></i> + Nada capturado ainda hoje. + {% if mode == 'local' %}<span class="ml-auto">→ verifique o pill do coletor</span>{% endif %} + </div> + {% else %} + <section class="grid lg:grid-cols-[1.35fr_1fr] gap-3"> + + {# painel principal: tempo de atenção + composição por app (substitui o grid de 6 caixinhas) #} + <div class="surface rounded-xl p-5"> + <div class="flex items-baseline gap-2.5 flex-wrap"> + <span class="font-mono text-[2.25rem] leading-none font-semibold text-slate-100 tracking-tight">{{ fmt_dur(s.attention_sec) }}</span> + <span class="text-xs text-slate-500">de atenção ativa hoje</span> + {% if s.idle_sec %}<span class="ml-auto text-[11px] text-slate-600 font-mono">+{{ fmt_dur(s.idle_sec) }} ocioso</span>{% endif %} </div> - {% for app, sec in s.top_apps %} - {% set pct = (sec * 100 // s.attention_sec) if s.attention_sec else 0 %} - <div class="flex items-center gap-2 text-[11px]"> - <span class="w-24 truncate {% if loop.first %}text-slate-100 font-semibold{% else %}text-slate-400{% endif %}" title="{{ app }}">{{ app }}</span> - <div class="flex-1 h-1 rounded-full bg-slate-800 overflow-hidden"> - <div class="h-full {% if loop.first %}bg-blue-500{% else %}bg-slate-600{% endif %}" style="width: {{ pct }}%"></div> + + {% if s.top_apps %} + <div class="mt-5"> + <div class="flex h-[7px] rounded-full overflow-hidden bg-white/[0.04] gap-px"> + {% for app, sec in s.top_apps %} + {% set pct = (sec * 100 / s.attention_sec) if s.attention_sec else 0 %} + <div style="width: {{ pct }}%; background: {{ app_palette[loop.index0] if loop.index0 < app_palette|length else app_palette[-1] }};" + title="{{ app }} · {{ fmt_dur(sec) }}"></div> + {% endfor %} + </div> + <div class="mt-3 flex flex-wrap gap-x-5 gap-y-1.5"> + {% for app, sec in s.top_apps %} + <div class="flex items-center gap-1.5 text-[11px]"> + <span class="w-2 h-2 rounded-full shrink-0" style="background: {{ app_palette[loop.index0] if loop.index0 < app_palette|length else app_palette[-1] }};"></span> + <span class="{% if loop.first %}text-slate-200 font-medium{% else %}text-slate-400{% endif %} truncate max-w-[140px]" title="{{ app }}">{{ app }}</span> + <span class="font-mono text-slate-600">{{ fmt_dur(sec) }}</span> + </div> + {% endfor %} </div> - <span class="font-mono text-slate-500 w-12 text-right">{{ fmt_dur(sec) }}</span> + </div> + {% endif %} + </div> + + {# painel secundário: métricas como linhas, não como caixas repetidas #} + <div class="surface rounded-xl divide-y divide-[color:var(--line)] flex flex-col"> + {% set rows = [ + ('app-window', 'janelas abertas', s.sessions, (s.unique_apps ~ ' apps') if s.unique_apps else ''), + ('keyboard', 'caracteres digitados', fmt_num(s.keystrokes_chars), (s.clipboards ~ ' cópia' ~ ('s' if s.clipboards != 1 else '')) if s.clipboards else ''), + ('image', 'screenshots', s.screenshots, ''), + ('message-circle', 'mensagens whatsapp', s.wa_count, (s.wa_chats ~ ' conversa' ~ ('s' if s.wa_chats != 1 else '')) if s.wa_chats else ''), + ('mic', 'áudios transcritos', s.transcripts, ''), + ] %} + {% for icon, label, value, sub in rows %} + <div class="flex items-center gap-3 px-4 py-2.5 first:pt-3.5 last:pb-3.5"> + <i data-lucide="{{ icon }}" class="w-3.5 h-3.5 text-slate-500 shrink-0"></i> + <span class="text-[12px] text-slate-400 truncate">{{ label }}</span> + {% if sub %}<span class="text-[10px] text-slate-600 truncate">{{ sub }}</span>{% endif %} + <span class="ml-auto font-mono text-[15px] text-slate-100 shrink-0">{{ value }}</span> </div> @@ -77,5 +123,4 @@ </div> - {% endif %} - {% endif %} - </section> + </section> + {% endif %} @@ -86,3 +131,3 @@ <h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2"> - <i data-lucide="sparkles" class="w-3.5 h-3.5 text-emerald-400"></i>Resumo de hoje + <i data-lucide="sparkles" class="w-3.5 h-3.5 text-slate-500"></i>Resumo de hoje </h2> @@ -93,3 +138,3 @@ <button data-loading onclick="fetch('/api/pipeline/run',{method:'POST'}).then(()=>location.reload());" - class="ml-auto text-[11px] px-2.5 py-1 rounded-md border border-blue-800 text-blue-300 hover:bg-blue-950 flex items-center gap-1"> + class="ml-auto text-[11px] px-2.5 py-1 rounded-md border border-[color:var(--line-strong)] text-[color:var(--accent-ink)] hover:bg-cyan-400/[0.07] flex items-center gap-1.5 transition-colors"> <i data-lucide="play" class="w-3 h-3"></i>regerar @@ -97,3 +142,3 @@ </div> - <div class="rounded-xl border border-emerald-900/40 bg-emerald-950/10 px-4 py-3"> + <div class="surface rounded-xl px-4 py-3"> <div data-md class="text-[12px] leading-snug">{{ today_summary.summary }}</div> @@ -107,3 +152,3 @@ <h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2"> - <i data-lucide="book-open" class="w-3.5 h-3.5 text-amber-400"></i>Resumos recentes + <i data-lucide="book-open" class="w-3.5 h-3.5 text-slate-500"></i>Resumos recentes </h2> @@ -112,3 +157,3 @@ <button data-loading onclick="fetch('/api/pipeline/run',{method:'POST'}).then(()=>location.reload());" - class="ml-auto text-[11px] px-2.5 py-1 rounded-md border border-blue-800 text-blue-300 hover:bg-blue-950 flex items-center gap-1"> + class="ml-auto text-[11px] px-2.5 py-1 rounded-md border border-[color:var(--line-strong)] text-[color:var(--accent-ink)] hover:bg-cyan-400/[0.07] flex items-center gap-1.5 transition-colors"> <i data-lucide="play" class="w-3 h-3"></i>rodar pipeline agora @@ -119,3 +164,3 @@ {% if not recent_summaries %} - <div class="rounded-xl border border-dashed border-slate-800 bg-slate-900/30 px-4 py-3 text-slate-500 text-xs"> + <div class="surface rounded-xl border-dashed px-4 py-3 text-slate-500 text-xs"> Ainda sem resumos anteriores. O primeiro será gerado às 22h. @@ -125,4 +170,4 @@ {% for r in recent_summaries %} - <details class="rounded-lg border border-slate-800 bg-slate-900/40"> - <summary class="px-3 py-1.5 flex items-baseline gap-2 list-none"> + <details class="surface surface-hover rounded-lg"> + <summary class="px-3 py-1.5 flex items-baseline gap-2 list-none cursor-pointer"> <span class="font-mono text-[11px] text-slate-400 shrink-0 w-16">{{ r.date }}</span> @@ -141,7 +186,7 @@ - {# ─────────────────────────────── LINHA DO TEMPO ─────────────────────────────── #} + {# ─────────────────────────────── LINHA DO TEMPO — espinha vertical ─────────────────────────────── #} <section> - <div class="flex items-center gap-3 mb-3 flex-wrap"> + <div class="flex items-center gap-3 mb-4 flex-wrap"> <h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2"> - <i data-lucide="git-branch" class="w-3.5 h-3.5 text-blue-400"></i>Linha do tempo + <i data-lucide="git-branch" class="w-3.5 h-3.5 text-slate-500"></i>Linha do tempo <span class="text-slate-600 normal-case tracking-normal font-normal">· {{ buckets|length }} janela{% if buckets|length != 1 %}s{% endif %}</span> @@ -152,5 +197,5 @@ <a href="?{% if mode == 'global' %}d={{ date }}&{% if device_filter %}device={{ device_filter }}&{% endif %}{% endif %}bucket={{ m }}" - class="px-2 py-0.5 rounded transition - {% if m == bucket_min %}bg-blue-600 text-white font-medium - {% else %}text-slate-500 hover:bg-slate-800/60 hover:text-slate-200{% endif %}">{{ label }}</a> + class="px-2 py-0.5 rounded-md transition-colors + {% if m == bucket_min %}text-[color:var(--accent-ink)] bg-cyan-400/[0.09] shadow-[inset_0_0_0_1px_rgb(34_211_238_/_0.22)] font-medium + {% else %}text-slate-500 hover:text-slate-300{% endif %}">{{ label }}</a> {% endfor %} @@ -160,3 +205,3 @@ {% if not buckets %} - <div class="rounded-xl border border-dashed border-slate-800 bg-slate-900/30 p-8 text-center text-slate-500 text-sm"> + <div class="surface rounded-xl border-dashed p-8 text-center text-slate-500 text-sm"> Sem eventos capturados ainda{% if mode == 'local' %} — verifique o pill do coletor{% endif %}. @@ -164,188 +209,197 @@ {% else %} - <div class="space-y-1.5"> + <div> {% for b in buckets %} - <details id="b-{{ b.label_start | replace(':','-') }}" - class="group rounded-xl border - {% if loop.index0 is odd %}bg-slate-900/30{% else %}bg-slate-900/60{% endif %} - {% if b.density == 'high' %}border-blue-900{% elif b.density == 'mid' %}border-slate-700{% else %}border-slate-800{% endif %}"> - - <summary class="flex items-start gap-3 px-4 py-3 list-none"> - <div class="text-right shrink-0 w-16"> - <div class="font-mono text-sm text-slate-200">{{ b.label_start }}</div> - <div class="font-mono text-[10px] text-slate-500">→ {{ b.label_end }}</div> + {% if b.has_gap_before %} + <div class="flex gap-4 py-1"> + <div class="w-14 shrink-0"></div> + <div class="w-3 shrink-0 flex justify-center"><span class="w-px h-full border-l border-dashed border-[color:var(--line)]"></span></div> + <div class="flex-1 flex items-center gap-2 text-[11px] text-slate-600 py-1.5"> + <i data-lucide="moon" class="w-3 h-3"></i>gap de {{ fmt_gap(b.gap_minutes) }} </div> + </div> + {% endif %} - <div class="flex-1 min-w-0"> - <div class="flex items-center gap-2 flex-wrap"> - <span class="text-sm font-semibold text-slate-100 truncate">{{ b.top_app }}</span> - {% if b.titles %} - <span class="text-xs text-slate-400 truncate">· {{ b.titles[0][0][:80] }}{% if b.titles[0][0]|length > 80 %}…{% endif %}</span> - {% endif %} - </div> - - <div class="flex items-center gap-3 mt-1 text-xs text-slate-500 flex-wrap"> - <span class="flex items-center gap-1"><i data-lucide="activity" class="w-3 h-3"></i>{{ b.events_count }}</span> - {% if b.duration_sec %}<span class="flex items-center gap-1"><i data-lucide="clock" class="w-3 h-3"></i>{{ fmt_dur(b.duration_sec) }}</span>{% endif %} - {% if b.screenshots %}<span class="flex items-center gap-1"><i data-lucide="image" class="w-3 h-3"></i>{{ b.screenshots|length }}</span>{% endif %} - {% if b.clipboards %}<span class="flex items-center gap-1 text-amber-500/80"><i data-lucide="clipboard" class="w-3 h-3"></i>{{ b.clipboards|length }}</s ... [truncated at 50000 chars]https://github.com/eleotherium/VPS.git
2026-06-07 VPS 3 commits versiona .env do segundo-cerebro pra facilitar replicação multi-device
-
23:05
38798b4main versiona .env do segundo-cerebro pra facilitar replicação multi-device +31 -6 · 2 arq..gitignoresegundo-cerebro/.envdiff (1947 chars)
commit 38798b46877b6f529de855d06f03ce1ff394f634 Author: Gabriel Eleotério <gabrieleleoterioptc@gmail.com> Date: Sun Jun 7 20:05:34 2026 -0300 versiona .env do segundo-cerebro pra facilitar replicação multi-device Repo privado e uso pessoal — trade-off aceito pra simplificar setup em novas máquinas (clone traz as credenciais direto). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> diff --git a/.gitignore b/.gitignore index be2d136..5a69550 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1 @@ -# segredos -.env -**/.env -!.env.example -!**/.env.example - # python diff --git a/segundo-cerebro/.env b/segundo-cerebro/.env new file mode 100644 index 0000000..6803472 --- /dev/null +++ b/segundo-cerebro/.env @@ -0,0 +1,31 @@ +# segundo-cerebro — secrets (NÃO commitar) +# Gerado em 2026-06-05 + +# Identificador desta máquina (sobrescreve hostname) +DEVICE_ID=notebook + +# Supabase (VPS já provisionado) +SUPABASE_URL=https://supabase.eleotherium.tech +SUPABASE_SERVICE_ROLE_KEY=***[REDACTED]***.***[REDACTED]***.8n_gQlSUWpUcn5kUUC8b52199FKGpMJ4lHUj7SfGcQo + +# Claude (https://console.anthropic.com/settings/keys) +ANTHROPIC_API_KEY=***[REDACTED]*** + +# Evolution API (WhatsApp) +EVOLUTION_URL=https://evolution.eleotherium.tech +EVOLUTION_API_KEY=***[REDACTED]*** +EVOLUTION_INSTANCE=gab + +# Pipeline noturno (modo local apenas) +PIPELINE_HOUR=22 +PIPELINE_MINUTE=0 + +# Tesseract OCR (binário instalado pelo winget; traineddata em pasta do usuário) +TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe +TESSDATA_PREFIX=C:\Users\Landa\AppData\Local\segundo-cerebro\tessdata + +# Para o docker-compose.local.yml expor essas vars no container, +# basta rodar `docker compose -f deploy/docker-compose.local.yml --env-file ../.env up -d` +# (ou copiar este arquivo para deploy/.env). + +GIT_WATCH_DIRS=C:\Users\Landa\Desktop\coisinhas\projetinhos;C:\Users\Landa\Desktop\coisinhas\tnlhttps://github.com/eleotherium/VPS.git -
17:41
2871c5dmain refina summarizer + render markdown no dashboard + fix OCR +711 -209 · 9 arq.segundo-cerebro/app/main.pysegundo-cerebro/app/templates/base.htmlsegundo-cerebro/app/templates/calendario.htmlsegundo-cerebro/app/templates/hoje.htmlsegundo-cerebro/app/templates/semana.htmlsegundo-cerebro/collectors/ocr.pysegundo-cerebro/pipeline/summarizer.pysegundo-cerebro/resumo-2026-06-06.md +1diff (50046 chars)
commit 2871c5d41f19f561be385257cabd3a4c7cfe7ee3 Author: Gabriel Eleotério <gabrieleleoterioptc@gmail.com> Date: Sun Jun 7 14:41:49 2026 -0300 refina summarizer + render markdown no dashboard + fix OCR summarizer: - jornadas geradas APENAS de window_session (PC), clampadas no dia local - ENQUANTO ISSO estritamente entre jornadas; items dentro de jornada viram contexto da jornada, sem duplicar - frente = tema/projeto (não grupo de chat) - persona 3ª pessoa: "o Gabriel fez X" (nunca 1ª pessoa) - heurística de identidade: quote, mentionedJid, @número (***[REDACTED]***), nome (Gabriel/Gab/Gabinho), excluindo "Pedro Gabriel"; tag [↳ FALANDO COM GABRIEL] no contexto bruto - clipboard agora chega ao prompt - regra de cadeia causal trigger→ação→resultado - filtra reactionMessage/stickerMessage/secretEncryptedMessage - seções finais trocadas pra FBP (Feitos/Bloqueios/Próximos passos) + Temas + Insights - cap 500 chars por bloco, max_tokens 4096 → 16384 ocr: TESSDATA_PREFIX setado via env var (pytesseract não escapa --tessdata-dir). Fallback default %LOCALAPPDATA%/segundo-cerebro/tessdata (onde por.traineddata é instalado). Bug do OCR=null no Supabase resolvido. dashboard: marked.js + CSS .md em base.html; <pre> dos summaries trocados por <div data-md> em hoje/semana/calendario; renderização tipo Notion. main.py: em modo local, _summary_scope agora sempre retorna 'all' (não força device atual) — summarizer salva como 'all', dashboard local achava None. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> diff --git a/segundo-cerebro/app/main.py b/segundo-cerebro/app/main.py index bf12ce2..292c17f 100644 --- a/segundo-cerebro/app/main.py +++ b/segundo-cerebro/app/main.py @@ -82,3 +82,10 @@ def _devices_for_header(target_date: str) -> list[str]: def _summary_scope(device_filter: str | None) -> str: - """Para tabelas de summary (daily/weekly/monthly): 'all' agrega todos os devices.""" + """Para tabelas de summary (daily/weekly/monthly): 'all' agrega todos os devices. + + Em modo local, sempre usa 'all' — o summarizer salva por 'all' (consolidado de + todos os devices) e tentar buscar por device específico daria None mesmo o + summary existindo. + """ + if APP_MODE == "local": + return "all" return device_filter or "all" diff --git a/segundo-cerebro/app/templates/base.html b/segundo-cerebro/app/templates/base.html index 42b4641..67e5bc0 100644 --- a/segundo-cerebro/app/templates/base.html +++ b/segundo-cerebro/app/templates/base.html @@ -9,2 +9,3 @@ <script src="https://unpkg.com/lucide@latest"></script> + <script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script> <style> @@ -57,2 +58,28 @@ + /* Markdown rendering — usado em [data-md] */ + .md { color: rgb(226 232 240); line-height: 1.55; } + .md h1 { font-size: 1.15rem; font-weight: 700; color: rgb(248 250 252); margin: 1rem 0 0.5rem; padding-bottom: 0.25rem; border-bottom: 1px solid rgb(51 65 85); } + .md h2 { font-size: 1rem; font-weight: 600; color: rgb(96 165 250); margin: 0.9rem 0 0.4rem; } + .md h3 { font-size: 0.9rem; font-weight: 600; color: rgb(165 180 252); margin: 0.7rem 0 0.3rem; } + .md h4 { font-size: 0.85rem; font-weight: 600; color: rgb(203 213 225); margin: 0.6rem 0 0.25rem; } + .md p { margin: 0.4rem 0; } + .md ul, .md ol { padding-left: 1.25rem; margin: 0.4rem 0; } + .md ul { list-style: disc; } + .md ol { list-style: decimal; } + .md li { margin: 0.15rem 0; } + .md strong { color: rgb(248 250 252); font-weight: 600; } + .md em { color: rgb(253 224 71); font-style: italic; } + .md code { background: rgb(15 23 42); border: 1px solid rgb(51 65 85); padding: 0.05rem 0.3rem; border-radius: 0.25rem; font-size: 0.85em; color: rgb(196 181 253); } + .md pre { background: rgb(2 6 23); border: 1px solid rgb(51 65 85); padding: 0.6rem; border-radius: 0.4rem; overflow-x: auto; margin: 0.5rem 0; font-size: 0.8rem; } + .md pre code { background: transparent; border: 0; padding: 0; color: rgb(226 232 240); } + .md blockquote { border-left: 3px solid rgb(71 85 105); padding-left: 0.7rem; color: rgb(148 163 184); margin: 0.5rem 0; } + .md a { color: rgb(96 165 250); text-decoration: underline; } + .md hr { border: 0; border-top: 1px solid rgb(51 65 85); margin: 0.8rem 0; } + .md table { border-collapse: collapse; margin: 0.5rem 0; font-size: 0.85rem; } + .md th, .md td { border: 1px solid rgb(51 65 85); padding: 0.3rem 0.5rem; text-align: left; } + .md th { background: rgb(15 23 42); } + .md.md-compact h1 { font-size: 1rem; margin-top: 0.6rem; } + .md.md-compact h2 { font-size: 0.9rem; margin-top: 0.6rem; } + .md.md-compact h3 { font-size: 0.82rem; margin-top: 0.5rem; } + {% block extra_styles %}{% endblock %} @@ -224,2 +251,12 @@ lucide.createIcons(); +// ─── markdown render ─── +if (window.marked) { + marked.use({ gfm: true, breaks: true }); + document.querySelectorAll("[data-md]").forEach(el => { + const src = el.textContent.trim(); + if (src) el.innerHTML = marked.parse(src); + el.classList.add("md"); + }); +} + // ─── rating widget ─── diff --git a/segundo-cerebro/app/templates/calendario.html b/segundo-cerebro/app/templates/calendario.html index 0819722..57e751e 100644 --- a/segundo-cerebro/app/templates/calendario.html +++ b/segundo-cerebro/app/templates/calendario.html @@ -106,3 +106,3 @@ <div class="rounded-lg border border-amber-900/40 bg-amber-950/10 p-3"> - <pre class="whitespace-pre-wrap text-[12px] leading-snug text-slate-100 font-sans">{{ monthly.summary }}</pre> + <div data-md class="text-[12px] leading-snug">{{ monthly.summary }}</div> <div class="mt-2 pt-2 border-t border-slate-800 text-[10px] text-slate-500 flex items-center justify-between"> @@ -135,3 +135,3 @@ </summary> - <pre class="px-3 pb-2 whitespace-pre-wrap text-[11px] leading-snug text-slate-200 font-sans">{{ w.summary }}</pre> + <div data-md class="px-3 pb-2 md-compact text-[11px] leading-snug">{{ w.summary }}</div> </details> diff --git a/segundo-cerebro/app/templates/hoje.html b/segundo-cerebro/app/templates/hoje.html index 9f17993..25c578a 100644 --- a/segundo-cerebro/app/templates/hoje.html +++ b/segundo-cerebro/app/templates/hoje.html @@ -98,3 +98,3 @@ <div class="rounded-xl border border-emerald-900/40 bg-emerald-950/10 px-4 py-3"> - <pre class="whitespace-pre-wrap text-[12px] leading-snug text-slate-200 font-sans">{{ today_summary.summary }}</pre> + <div data-md class="text-[12px] leading-snug">{{ today_summary.summary }}</div> </div> @@ -134,3 +134,3 @@ </summary> - <pre class="px-3 pb-2 whitespace-pre-wrap text-[12px] leading-snug text-slate-200 font-sans">{{ r.summary }}</pre> + <div data-md class="px-3 pb-2 md-compact text-[12px] leading-snug">{{ r.summary }}</div> </details> diff --git a/segundo-cerebro/app/templates/semana.html b/segundo-cerebro/app/templates/semana.html index a97b9f3..0cbeb12 100644 --- a/segundo-cerebro/app/templates/semana.html +++ b/segundo-cerebro/app/templates/semana.html @@ -77,3 +77,3 @@ <div class="rounded-xl border border-amber-900/40 bg-amber-950/10 p-5"> - <pre class="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-100 font-sans">{{ weekly.summary }}</pre> + <div data-md class="text-[13px] leading-relaxed">{{ weekly.summary }}</div> <div class="mt-3 pt-3 border-t border-slate-800 flex items-center justify-between text-[10px] text-slate-500"> @@ -112,3 +112,3 @@ </summary> - <pre class="px-3 pb-3 whitespace-pre-wrap text-[12px] leading-snug text-slate-200 font-sans">{{ d.summary }}</pre> + <div data-md class="px-3 pb-3 md-compact text-[12px] leading-snug">{{ d.summary }}</div> </details> diff --git a/segundo-cerebro/collectors/ocr.py b/segundo-cerebro/collectors/ocr.py index be2ef7f..84409d2 100644 --- a/segundo-cerebro/collectors/ocr.py +++ b/segundo-cerebro/collectors/ocr.py @@ -10,5 +10,11 @@ pytesseract.pytesseract.tesseract_cmd = os.environ.get( -# Diretório de traineddata (override via TESSDATA_PREFIX). -_TESSDATA = os.environ.get("TESSDATA_PREFIX") -_TESS_CONFIG = f'--tessdata-dir "{_TESSDATA}"' if _TESSDATA else "" +# Diretório de traineddata. Default: %LOCALAPPDATA%\segundo-cerebro\tessdata +# (onde por.traineddata é instalado pelo installer). Setado como env var +# porque pytesseract não escapa o path corretamente no --tessdata-dir. +_DEFAULT_TESSDATA = os.path.join( + os.environ.get("LOCALAPPDATA", ""), "segundo-cerebro", "tessdata" +) +if not os.environ.get("TESSDATA_PREFIX") and os.path.isdir(_DEFAULT_TESSDATA): + os.environ["TESSDATA_PREFIX"] = _DEFAULT_TESSDATA +_TESS_CONFIG = "" diff --git a/segundo-cerebro/pipeline/summarizer.py b/segundo-cerebro/pipeline/summarizer.py index 87a723c..1a2878c 100644 --- a/segundo-cerebro/pipeline/summarizer.py +++ b/segundo-cerebro/pipeline/summarizer.py @@ -14,2 +14,3 @@ import json import os +import re from datetime import date, datetime, time, timedelta, timezone @@ -36,2 +37,59 @@ _DIFF_BUDGET_PER_COMMIT = 4000 _TOTAL_DIFF_BUDGET = 60_000 +_WA_SKIP_TYPES = {"reactionMessage", "stickerMessage", "secretEncryptedMessage"} + +# Identidade do Gabriel — para detectar mensagens que falam COM ele +_GABRIEL_JID = "***[REDACTED]***@s.whatsapp.net" +_GABRIEL_PHONE_DIGITS = "***[REDACTED]***" +# Nomes que podem se referir ao Gabriel (case-insensitive), com @ opcional. +# IMPORTANTE: "Pedro Gabriel" é OUTRA pessoa (também membro de stack). +_GABRIEL_NAME_RE = re.compile(r"@?\b(gabriel|gabinho|gab)\b", re.IGNORECASE) +_PEDRO_GABRIEL_RE = re.compile(r"pedro\s+gabriel", re.IGNORECASE) + + +def _get_context_info(msg: dict | None) -> dict | None: + if not isinstance(msg, dict): + return None + for k in ("extendedTextMessage", "imageMessage", "videoMessage", + "audioMessage", "documentMessage"): + v = msg.get(k) + if isinstance(v, dict) and isinstance(v.get("contextInfo"), dict): + return v["contextInfo"] + ci = msg.get("messageContextInfo") + return ci if isinstance(ci, dict) else None + + +def _addressed_to_gabriel(text: str | None, msg: dict | None) -> bool: + """Heurística pra decidir se uma mensagem é direcionada ao Gabriel: + - quote de mensagem do Gabriel (contextInfo.participant == jid dele) + - menção formal no @list (contextInfo.mentionedJid contém jid dele) + - menção ao número (***[REDACTED]*** ou variações com +, espaço, hífen) + - menção ao nome (Gabriel, Gabinho, Gab) — exceto se vier de 'Pedro Gabriel' + """ + ci = _get_context_info(msg) + if ci: + if ci.get("participant") == _GABRIEL_JID: + return True + mentioned = ci.get("mentionedJid") or [] + if isinstance(mentioned, list) and _GABRIEL_JID in mentioned: + return True + + if not text: + return False + + # Telefone: normaliza dígitos e checa substring + digits_only = re.sub(r"\D", "", text) + if _GABRIEL_PHONE_DIGITS in digits_only: + return True + # Variante curta sem DDI (82999137239) + if digits_only and _GABRIEL_PHONE_DIGITS[2:] in digits_only: + # só conta se tiver pelo menos 11 dígitos seguidos (evita falso positivo + # com strings pequenas tipo "82") + if len(re.search(r"\d+", text).group()) >= 10: # noqa: pelo menos número longo + return True + + # Nome: primeiro remove "Pedro Gabriel" pra não dar falso positivo + cleaned = _PEDRO_GABRIEL_RE.sub("PEDROGABRIEL", text) + if _GABRIEL_NAME_RE.search(cleaned): + return True + return False @@ -77,2 +135,4 @@ def _fetch_whatsapp_messages(target_date: str, tracked: dict[str, str]) -> list[ for r in rows: + if r.get("message_type") in _WA_SKIP_TYPES: + continue ts = _parse_ts(r.get("ts")) @@ -80,2 +140,4 @@ def _fetch_whatsapp_messages(target_date: str, tracked: dict[str, str]) -> list[ continue + msg = r.get("message") + text = _extract_text(msg) out.append({ @@ -87,3 +149,4 @@ def _fetch_whatsapp_messages(target_date: str, tracked: dict[str, str]) -> list[ "type": r.get("message_type"), - "text": _extract_text(r.get("message")), + "text": text, + "to_gabriel": _addressed_to_gabriel(text, msg) and not r.get("from_me"), }) @@ -95,13 +158,13 @@ def _fetch_whatsapp_messages(target_date: str, tracked: dict[str, str]) -> list[ -def _segment_journeys(events: list[dict], target_date: str) -> tuple[list[dict], list[dict]]: - """Recebe raw_events do dia e devolve (jornadas, intervalos). +def _segment_journeys(events: list[dict], target_date: str) -> list[dict]: + """Devolve jornadas baseado APENAS em window_session. - Cada jornada: {"start": dt, "end": dt, "events": [...]}. - Intervalos: gaps entre jornadas + antes/depois (limitados ao dia local). + Cada jornada: {"start": dt, "end": dt, "events": [...], "kind": "journey", + "wa_during", "tr_during", "git_during"}. + Eventos fora do dia local são descartados; ts_end é clampado para 23:59:59. """ day = datetime.strptime(target_date, "%Y-%m-%d").date() - day_start = datetime.combine(day, time.min, tzinfo=_LOCAL_TZ) - day_end = datetime.combine(day, time.max, tzinfo=_LOCAL_TZ) + day_start_local = datetime.combine(day, time.min, tzinfo=_LOCAL_TZ) + day_end_local = datetime.combine(day, time.max, tzinfo=_LOCAL_TZ) - # Só window_session contam pra jornada (clipboard isolado não conta). win_events = [] @@ -114,2 +177,6 @@ def _segment_journeys(events: list[dict], target_date: str) -> tuple[list[dict], continue + if ts_s < day_start_local or ts_s > day_end_local: + continue + if ts_e and ts_e > day_end_local: + ts_e = day_end_local e = dict(e) @@ -121,61 +188,90 @@ def _segment_journeys(events: list[dict], target_date: str) -> tuple[list[dict], journeys: list[dict] = [] - if win_events: - current_events = [win_events[0]] - gap_delta = timedelta(minutes=_JOURNEY_GAP_MIN) - for ev in win_events[1:]: - last_end = current_events[-1]["_ts_end"] or current_events[-1]["_ts_start"] - if ev["_ts_start"] - last_end > gap_delta: - journeys.append({ - "start": current_events[0]["_ts_start"], - "end": current_events[-1]["_ts_end"] or current_events[-1]["_ts_start"], - "events": current_events, - }) - current_events = [ev] - else: - current_events.append(ev) - journeys.append({ - "start": current_events[0]["_ts_start"], - "end": current_events[-1]["_ts_end"] or current_events[-1]["_ts_start"], - "events": current_events, - }) + if not win_events: + return journeys + + current = [win_events[0]] + gap_delta = timedelta(minutes=_JOURNEY_GAP_MIN) + for ev in win_events[1:]: + last_end = current[-1]["_ts_end"] or current[-1]["_ts_start"] + if ev["_ts_start"] - last_end > gap_delta: + journeys.append({ + "kind": "journey", + "start": current[0]["_ts_start"], + "end": current[-1]["_ts_end"] or current[-1]["_ts_start"], + "events": current, + }) + current = [ev] + else: + current.append(ev) + journeys.append({ + "kind": "journey", + "start": current[0]["_ts_start"], + "end": current[-1]["_ts_end"] or current[-1]["_ts_start"], + "events": current, + }) + return journeys + + +def _split_items_journeys_vs_ei(target_date: str, journeys: list[dict], + wa_msgs: list[dict], transcripts: list[dict], + git_events: list[dict]) -> list[dict]: + """Distribui WA/áudio/git em dois destinos: + - Items DENTRO da janela de uma jornada → anexados à própria jornada como + contexto (campos wa_during, tr_during, git_during). + - Items ENTRE jornadas (ou antes da 1ª/depois da última) → vão para blocos + EI strictly entre jornadas. Range de exibição = gap real (sem expansão). + + Devolve apenas a lista de blocos EI (a mutação das jornadas é in-place). + Bloco EI vazio é dropado. + """ + day = datetime.strptime(target_date, "%Y-%m-%d").date() + day_start = datetime.combine(day, time.min, tzinfo=_LOCAL_TZ) + day_end = datetime.combine(day, time.max, tzinfo=_LOCAL_TZ) - # Intervalos = complemento das jornadas dentro do dia local. - intervals: list[dict] = [] - cursor = day_start for j in journeys: - if j["start"] > cursor + timedelta(minutes=5): - intervals.append({"start": cursor, "end": j["start"]}) - cursor = j["end"] - if day_end > cursor + timedelta(minutes=5): - intervals.append({"start": cursor, "end": day_end}) - - return journeys, intervals - - -def _in_range(ts: datetime, start: datetime, end: datetime) -> bool: - return start <= ts <= end - + j.setdefault("wa_during", []) + j.setdefault("tr_during", []) + j.setdefault("git_during", []) + + if journeys: + gaps = [(day_start, journeys[0]["start"])] + for i in range(len(journeys) - 1): + gaps.append((journeys[i]["end"], journeys[i+1]["start"])) + gaps.append((journeys[-1]["end"], day_end)) + else: + gaps = [(day_start, day_end)] + bucket: list[dict] = [{"wa": [], "tr": [], "git": []} for _ in gaps] + + items: list[tuple[datetime, str, dict]] = [] + for m in wa_msgs: + if m.get("_ts"): items.append((m["_ts"], "wa", m)) + for t in transcripts: + if t.get("_ts"): items.append((t["_ts"], "tr", t)) + for g in git_events: + if g.get("_ts"): items.append((g["_ts"], "git", g)) + items.sort(key=lambda x: x[0]) -def _distribute(blocks_journeys: list[dict], blocks_intervals: list[dict], - items: list[dict], ts_key: str = "_ts") -> tuple[list[list[dict]], list[list[dict]]]: - """Distribui items por block usando o timestamp em `ts_key`. Retorna - (alocações_jornadas, alocações_intervalos) — cada uma na mesma ordem - dos blocks correspondentes. - """ - j_buckets: list[list[dict]] = [[] for _ in blocks_journeys] - i_buckets: list[list[dict]] = [[] for _ in blocks_intervals] - for it in items: - ts = it.get(ts_key) - if ts is None: + for ts, kind, payload in items: + if ts < day_start or ts > day_end: continue placed = False - for idx, j in enumerate(blocks_journeys): - if _in_range(ts, j["start"], j["end"]): - j_buckets[idx].append(it); placed = True; break + for j in journeys: + if j["start"] <= ts <= j["end"]: + j[f"{kind}_during"].append(payload) + placed = True + break if placed: continue - for idx, i in enumerate(blocks_intervals): - if _in_range(ts, i["start"], i["end"]): - i_buckets[idx].append(it); break - return j_buckets, i_buckets + for idx, (s, e) in enumerate(gaps): + if s <= ts <= e: + bucket[idx][kind].append(payload) + break + + blocks: list[dict] = [] + for (s, e), content in zip(gaps, bucket): + real_wa = [m for m in content["wa"] if (m.get("text") or "").strip()] + if not (real_wa or content["tr"] or content["git"]): + continue + blocks.append({"kind": "ei", "start": s, "end": e, **content}) + return blocks @@ -214,2 +310,5 @@ def _render_event_lines(events: list[dict]) -> list[str]: ocr = e.get("ocr_text", "") or "" + clip = e.get("clipboard") or "" + if isinstance(clip, list): + clip = " | ".join(str(c) for c in clip if c) lines.append(f"- {ts_hm} [{app}] {title} ({dur}s)") @@ -219,2 +318,4 @@ def _render_event_lines(events: list[dict]) -> list[str]: lines.append(f" tela: {ocr[:300]}") + if clip: + lines.append(f" copiado: {clip[:300]}") return lines @@ -277,3 +378,3 @@ def _render_wa(items: list[dict]) -> list[str]: for m in msgs: - who = "eu" if m["from_me"] else (m.get("push_name") or name) + who = "GABRIEL" if m["from_me"] else (m.get("push_name") or name) text = (m.get("text") or "").strip() @@ -281,3 +382,4 @@ def _render_wa(items: list[dict]) -> list[str]: text = f"[{m['type']}]" - lines.append(f" · {_hm_local(m['_ts'])} {who}: {text[:400]}") + tag = " [↳ FALANDO COM GABRIEL]" if m.get("to_gabriel") else "" + lines.append(f" · {_hm_local(m['_ts'])} {who}{tag}: {text[:400]}") return lines @@ -286,10 +388,8 @@ def _render_wa(items: list[dict]) -> list[str]: def _build_context(target_date: str, events, transcripts, wa_msgs, git_events, tracked) -> str: - journeys, intervals = _segment_journeys(events, target_date) - + journeys = _segment_journeys(events, target_date) norm_transcripts = _norm_transcripts(transcripts) norm_git = _norm_git(git_events) - - j_t, i_t = _distribute(journeys, intervals, norm_transcripts) - j_w, i_w = _distribute(journeys, intervals, wa_msgs) - j_g, i_g = _distribute(journeys, intervals, norm_git) + ei_blocks = _split_items_journeys_vs_ei( + target_date, journeys, wa_msgs, norm_transcripts, norm_git + ) @@ -298,3 +398,3 @@ def _build_context(target_date: str, events, transcripts, wa_msgs, git_events, t - if not journeys and not intervals: + if not journeys and not ei_blocks: lines.append("(sem dados capturados)") @@ -302,20 +402,8 @@ def _build_context(target_date: str, events, transcripts, wa_msgs, git_events, t - # Compõe blocos por ordem cronológica intercalando jornadas e intervalos - composite = [] - for j_idx, j in enumerate(journeys): - composite.append(("journey", j_idx, j)) - for i_idx, i in enumerate(intervals): - composite.append(("interval", i_idx, i)) - composite.sort(key=lambda x: x[2]["start"]) + composite = sorted(journeys + ei_blocks, key=lambda b: b["start"]) - for kind, idx, block in composite: + for block in composite: dur_min = int((block["end"] - block["start"]).total_seconds() // 60) - header = ( - f"## JORNADA {_hm_local(block['start'])} → {_hm_local(block['end'])} ({dur_min} min)" - if kind == "journey" else - f"## INTERVALO {_hm_local(block['start'])} → {_hm_local(block['end'])} ({dur_min} min)" - ) - lines.append(header) - - if kind == "journey": + if block["kind"] == "journey": + lines.append(f"## JORNADA {_hm_local(block['start'])} → {_hm_local(block['end'])} ({dur_min} min)") ev_lines = _render_event_lines(block["events"]) @@ -324,30 +412,27 @@ def _build_context(target_date: str, events, transcripts, wa_msgs, git_events, t lines.extend(ev_lines) - commits = [g for g in j_g[idx] if g.get("type") == "commit"] - pushes = [g for g in j_g[idx] if g.get("type") == "push"] - git_lines = _render_git_lines(commits, pushes, diff_budget) - if git_lines: - lines.append("### Git") - lines.extend(git_lines) - tr_lines = _render_transcripts(j_t[idx], tracked) - if tr_lines: - lines.append("### Áudios WhatsApp transcritos durante a jornada") - lines.extend(tr_lines) - wa_lines = _render_wa(j_w[idx]) + tr_d = _render_transcripts(block.get("tr_during") or [], tracked) + wa_d = _render_wa(block.get("wa_during") or []) + commits_d = [g for g in (block.get("git_during") or []) if g.get("type") == "commit"] + pushes_d = [g for g in (block.get("git_during") or []) if g.get("type") == "push"] + git_d = _render_git_lines(commits_d, pushes_d, diff_budget) + if tr_d or wa_d or git_d: + lines.append("### Contexto durante a jornada (WhatsApp/áudio/Git)") + if wa_d: lines.extend(wa_d) + if tr_d: lines.extend(tr_d) + if git_d: lines.extend(git_d) + else: # ei + lines.append(f"## ENQUANTO ISSO {_hm_local(block['start'])} → {_hm_local(block['end'])} ({dur_min} min)") + wa_lines = _render_wa(block["wa"]) if wa_lines: - lines.append("### WhatsApp durante a jornada") + lines.append("### WhatsApp") lines.extend(wa_lines) - else: - tr_lines = _render_transcripts(i_t[idx], tracked) + tr_lines = _render_transcripts(block["tr"], tracked) if tr_lines: - lines.append("### Áudios WhatsApp transcritos no intervalo") + lines.append("### Áudios WhatsApp") lines.extend(tr_lines) - wa_lines = _render_wa(i_w[idx]) - if wa_lines: - lines.append("### WhatsApp no intervalo") - lines.extend(wa_lines) - commits = [g for g in i_g[idx] if g.get("type") == "commit"] - pushes = [g for g in i_g[idx] if g.get("type") == "push"] - if commits or pushes: - git_lines = _render_git_lines(commits, pushes, diff_budget) - lines.append("### Git no intervalo") + commits = [g for g in block["git"] if g.get("type") == "commit"] + pushes = [g for g in block["git"] if g.get("type") == "push"] + git_lines = _render_git_lines(commits, pushes, diff_budget) + if git_lines: + lines.append("### Git") lines.extend(git_lines) @@ -387,26 +472,83 @@ def _build_fewshot(device_id: str, exclude_key: str) -> str: -_INSTRUCTION = """Você é o assistente pessoal do usuário. Com base nos dados brutos do dia abaixo, -gere um RESUMO ESTRUTURADO seguindo estas regras: - -1. **NÃO divida por "manhã/tarde/noite"**. Use as JORNADAS e INTERVALOS já segmentados nos - dados brutos como base. Cada bloco rotulado `## JORNADA HH:MM → HH:MM` é um período - contíguo de trabalho — descreva-o como uma seção dedicada. Cada `## INTERVALO HH:MM → HH:MM` - é um período sem atividade no PC — descreva o que aconteceu no WhatsApp nesse gap - (concentração de assuntos, principais contatos, situações e soluções discutidas). - -2. **Para cada JORNADA**, produza algo como: - `## Jornada HH:MM → HH:MM` + parágrafo descrevendo as atividades, problemas que - foram tratados/resolvidos e contexto. Liste especificamente problemas + soluções - quando aparecerem (ex.: "X problema → resolvido com Y"). - -3. **Para cada INTERVALO** com atividade WhatsApp, produza: - `## Intervalo HH:MM → HH:MM (WhatsApp)` + parágrafo sobre concentração de - assuntos, com quem falou e principais situações/soluções. - Se o intervalo não tiver atividade WhatsApp relevante, OMITA a seção. - -4. **Ao final**, mantenha as três seções consolidadas do formato anterior: - `## 👥 Com quem interagiu`, `## 🧠 Temas trabalhados`, `## 💡 Insights relevantes`. - -5. Tom direto, em português do Brasil. Use as informações específicas (nomes, projetos, - ferramentas, decisões). Não invente nada — só sintetize o que está nos dados. +_INSTRUCTION = """Você é um **observador externo** narrando o dia do **Gabriel** +(Gabriel Eleotério Rosa Inácio). Os dados brutos abaixo são do dia de trabalho +dele. **NUNCA use 1ª pessoa** ("eu fiz", "respondi", "decidi") — o relatório +não pode soar como o Gabriel falando de si mesmo. Refira-se a ele SEMPRE em +3ª pessoa: "o Gabriel fez X", "ele decidiu Y", "o Gabriel respondeu Z". + +**REGRAS PARA ATRIBUIR FALA/AÇÃO AO GABRIEL (em GRUPOS, leia com atenção):** + +- Linhas WhatsApp marcadas com `GABRIEL:` (em maiúsculas no início) são DEFINITIVAMENTE + ditas pelo Gabriel — pode atribuir a ele com segurança. +- Linhas marcadas com `[↳ FALANDO COM GABRIEL]` são mensagens de OUTRAS pessoas + DIRIGIDAS ao Gabriel (reply a uma msg dele, @ menção ao @Gabriel/@gab/@gabinho, + menção ao número ***[REDACTED]***, ou nome). Use essas para reconstruir o que + pediram/perguntaram a ele. +- **TODAS AS OUTRAS** mensagens em grupos (sem `GABRIEL:` no início e sem + `[↳ FALANDO COM GABRIEL]`) são conversas ENTRE outras pessoas — o Gabriel + estava só observando ou nem viu. **NÃO atribua essas interações ao Gabriel.** + Ex.: se Lucas diz "vou te colocar como admin" para outra pessoa do grupo, NÃO + conclua que Lucas falou isso ao Gabriel. +- "Pedro Gabriel" é OUTRA pessoa (também membro do time stack), NÃO confunda com + o Gabriel principal. + +Os outros nomes (Lucas, Vitória, Pedro, Thiago, Pedro Gabriel, Pedro Abib, etc.) +são interlocutores pelo WhatsApp e NÃO operam o PC. + +Gere um RESUMO ESTRUTURADO seguindo estas regras: + +1. **NÃO divida por "manhã/tarde/noite"**. Use os blocos já segmentados: + - `## JORNADA HH:MM → HH:MM` = período contíguo de trabalho no PC. + - `## ENQUANTO ISSO HH:MM → HH:MM` = APENAS gaps ENTRE jornadas (ou antes + da primeira / depois da última). NUNCA gere EI que cubra horário de uma + jornada — se aparecer WA/áudio durante uma jornada, eles vêm dentro do + bloco da própria jornada na sub-seção "Contexto durante a jornada" + dos dados brutos, e devem ser narrados COMO parte da jornada (gatilho, + interrupção, conversa paralela), NÃO como EI separado. + +2. **Para cada JORNADA**: `## Jornada HH:MM → HH:MM` + narrativa do que foi + feito no PC. Quando houver "Contexto durante a jornada" nos dados, + INTEGRE como parte da narrativa (ex.: "enquanto trabalhava no n8n, + Vitória mandou print da sessão conectada → respondi que ia configurar + o webhook → fiz exatamente isso"). NÃO repita o mesmo evento em EI. + Liste problemas+soluções quando aparecerem. + +3. **Para cada ENQUANTO ISSO** com conteúdo relevante: `## Enquanto isso + HH:MM → HH:MM` + parágrafo. Se o bloco for ruidoso (só "ok", "kkk"), + OMITA. EIs descrevem coisas que aconteceram FORA do horário de jornada. + + **Frentes:** uma "frente" é um TEMA/PROJETO de trabalho (ex.: "Operação + PE", "Segundo-cérebro", "Chatwoot"), NÃO um grupo de WhatsApp. Um mesmo + grupo pode ter múltiplas frentes; grupos diferentes podem dividir uma + frente. Use `### Frente <tema>` SÓ quando o bloco tem 2+ temas claramente + distintos. Bloco coeso = parágrafo único, sem frente. + +4. **Identifique cadeias causais**. Quando uma msg/áudio imediatamente + anterior (≤10min) a uma ação no PC for o gatilho, descreva como sequência: + "FULANO mandou X → abri Y → fiz Z → confirmei". NÃO liste isoladamente + quando há trigger→ação→resultado claro. + +5. **Ao final do resumo**, gere exatamente estas seções, nesta ordem: + + `## ✅ Feitos` — bullets do que foi efetivamente entregue/concluído no dia. + `## 🚧 Bloqueios` — o que travou, dependências não resolvidas, problemas + levantados sem solução. + `## 🔜 Próximos passos` — promessas feitas, direcionamentos pedidos por + alguém (ou pelo Gabriel) e ainda não concretizados, próximos passos + naturais das tarefas em andamento. + `## 🧠 Temas trabalhados` — lista breve dos temas/projetos do dia. + `## 💡 Insights relevantes` — observações de alto valor (decisões + arquiteturais, padrões notados, ideias que surgiram). + +6. Tom direto, em português do Brasil, 3ª pessoa sempre. Use os termos + específicos (nomes, projetos, ferramentas, decisões). Não invente nada — + só sintetize o que está nos dados. + +7. **LIMITE DE 500 CARACTERES POR BLOCO.** Cada `## Jornada`, cada + `## Enquanto isso`, cada `### Frente`, e cada uma das seções finais + (`✅ Feitos`, `🚧 Bloqueios`, `🔜 Próximos passos`, `🧠 Temas`, + `💡 Insights`) deve ter NO MÁXIMO 500 caracteres de conteúdo (sem + contar o próprio cabeçalho). Seja conciso — destile o essencial, + corte detalhes secundários. Se um bloco tem muito conteúdo, escolha o + que mais importa e omita o resto. """ @@ -442,3 +584,3 @@ def run(target_date: str | None = None, device_id: str | None = None) -> None: model=_MODEL, - max_tokens=4096, + max_tokens=16384, messages=[{"role": "user", "content": user_content}], diff --git a/segundo-cerebro/resumo-2026-06-06.md b/segundo-cerebro/resumo-2026-06-06.md index 7ed8f54..58c7568 100644 --- a/segundo-cerebro/resumo-2026-06-06.md +++ b/segundo-cerebro/resumo-2026-06-06.md @@ -1,19 +1,10 @@ -# Resumo do Dia — 2026-06-06 - ---- - ## Jornada 00:00 → 12:13 -A jornada começou na madrugada com trabalho intenso no projeto **segundo-cerebro** (VPS via VS Code). A maior parte do tempo foi dedicada a identificar e reportar falhas no sistema de captura de eventos do PC — especificamente: +### Frente Segundo-Cérebro + +De madrugada, o Gabriel trabalhou intensamente no sistema de segundo-cérebro via VSCode conectado à VPS. Ele identificou e reportou uma série de bugs: prints de tela sem label de aba específica (só "chrome.exe" sem indicar qual aba), mudanças de aba dentro do Chrome não disparando printscreen, timestamps chegando do Supabase em UTC sem conversão para GMT-3, eventos de mensagens agrupados separadamente dos eventos de janela, e ordem da linha do tempo exibindo "oldest" em vez de "latest". Também levantou preocupação com armazenamento — VPS tem 100 GB, já em 16 GB — e propôs política de retenção de 7 dias para dados brutos, mantendo apenas resumos diários. Consultou a Hostinger para checar o plano. Fez perguntas sobre resiliência do sistema (o que acontece se o PC desligar, se ficar sem internet, como replicar no desktop). -- **Prints defasados ou ausentes** → problema: prints não eram gerados ao trocar de aba no Chrome, nem ao mudar de janela; solução encaminhada: ajustar o trigger de printscreen para qualquer troca de aba/janela ativa. -- **Labels genéricas nos prints** (ex.: `chrome.exe` sem indicar a aba) → solução: prints devem registrar a URL/título da aba ativa. -- **Horário incorreto nos eventos** → problema: timestamps chegavam do Supabase em UTC sem conversão para o fuso +3; encaminhado para correção. -- **Agrupamento errado de mensagens** → mensagens de WhatsApp apareciam separadas do bloco de janelas; deveriam ficar aninhadas ao evento de janela correspondente. -- **Armazenamento da VPS** → VPS tem 100 GB, já em 16 GB; decisão: manter dados brutos por 7 dias e, após isso, guardar apenas os resumos diários. -- **Dashboard geral** → levantamento do que já existe; adicionada à lista de tarefas a construção de um dashboard com visualização principal de microjornadas, similar ao dashboard local. -- **Linha do tempo local** → eventos ainda aparecendo em ordem oldest→latest em vez de latest; reportado para correção. -- **Dúvidas operacionais** → questionou o que acontece se o PC desligar, se ficar sem internet, e como replicar o sistema no desktop; encaminhado para um executável único em `/replicate` com Docker Desktop. +### Frente Operação PE -Por volta das 12h, o foco mudou para o **projeto CE Pernambuco**: verificação das sessões no Evolution Manager, acesso ao n8n (workflow "Central Webhook"), checagem do encurtador (link `engaja.pro/joaocampos40` com +90 acessos), e início da configuração do webhook da sessão Evolution para capturar eventos de entradas/saídas nas comunidades regionais. +Por volta de 12:08, o Gabriel acordou/desbloqueou o PC e imediatamente acessou o Evolution Manager e o n8n. Vitória havia postado print de uma sessão conectada na Evolution com áudio perguntando se, agora que estava conectada, daria para acompanhar o painel. O Gabriel respondeu: **"Ja to configurando o webhook dessa sessão pra gente ter os eventos de entradas e saídas"** — e fez exatamente isso: abriu o workflow "Central Webhook" no n8n, copiou o webhook URL, voltou ao Evolution Manager para configurar. Também acessou o Encurtador Mestre e registrou que o link `engaja.pro/joaocampos40` já tinha **+90 acessos** naquele dia. No chat [interno] Engaja, Lucas registrou que Gabriel, Abib, Thiago e Vitória trabalharam desde as 4h para entregar a operação a tempo. @@ -21,5 +12,5 @@ Por volta das 12h, o foco mudou para o **projeto CE Pernambuco**: verificação -## Intervalo 12:13 → 13:03 (WhatsApp) +## Enquanto isso 12:13 → 13:03 -Durante o intervalo, a conversa no **[interno] Engaja** seguiu a euforia do lançamento do dashboard integrado ao campo em Pernambuco. Lucas comentou sobre os insumos políticos no dash (o Marroquim havia deixado explícito que queria dados para João falar nos eventos), e Luiz Gallo sugeriu formalizar uma entrega mais robusta — um relatório por fora do dashboard para "oficializar o produto". Leonardo R. e outros elogiaram o trabalho da equipe. No **CE Pernambuco**, Vitória reagiu às mensagens; sem novos problemas nesse gap. +Conversa de celebração no grupo interno Engaja — Lucas, Thiago, Luiz Gallo, Leonardo R. elogiando a entrega da Central ligada ao campo em PE. Sem ação do Gabriel registrada nesse intervalo. @@ -27,30 +18,38 @@ Durante o intervalo, a conversa no **[interno] Engaja** seguiu a euforia do lan -## Jornada 13:03 → 21:01 +## Jornada 13:03 → 23:59 + +### Frente Operação PE + +O Gabriel retomou o PC verificando Zoho Forms e Tally. Ele acessou a lista de submissões do Tally, exportou dados, usou o ChatGPT para extrair e mesclar nomes únicos (tratando inconsistências como "Rhuanna/Rhyanna"), e cruzou com os dados do Zoho Forms para construir uma lista consolidada de pessoal da operação. + +Às 13:09, ele perguntou no grupo CE Pernambuco se Lucas conseguia confirmar 71 membros no celular; respondeu "15" a seguir (contexto de contagem). -Jornada longa e densa, com múltiplas frentes simultâneas. +Às 19:11, após a reunião do time de campo, o Gabriel comentou que os QRs haviam sido testados na véspera e estavam funcionando, e propôs testar o comportamento offline do Zoho mais rigorosamente — colocar o app em modo offline explicitamente, não só desligar a internet, para verificar se o cache do QR persiste. -**Gestão de pessoal e formulários (CE Pernambuco):** -Logo ao retornar, houve trabalho intenso no Zoho Forms e Tally — baixando submissions, usando ChatGPT para padronizar lista de nomes dos entrevistadores (mesclando CSVs com inconsistências de grafia), e adicionando/removendo usuários na organização Zoho. Confirmação dos números das comunidades regionais: Sertão do Moxotó saiu de 202 para 206, Mata Sul de 292 para 306 entre o dia 5 e as 10h30 do dia 6. +Às 21:18, quando Vitória reportou que 3 gmails de voluntários foram banidos, o Gabriel reagiu com humor ("Aí você me deu boa noite com carinho demais") — a solução foi encaminhada por Lucas (usar e-mails pessoais dos coordenadores). -**Segundo-cerebro — refinamento do dashboard local:** -- Dashboard local estava verboso, sem transmitir o potencial de inteligência coletada → reconstrução solicitada ao Claude. -- Problemas corrigidos: mensagem de gap aparecia acima do último bloco em vez de abaixo; formato de exibição de gaps >1h ajustado para "Xh Ym"; wrappers de dados muito grandes para pouco texto → UI tornada mais responsiva. -- Supabase inspecionado (tabelas: `audio_transcripts`, `daily_summaries`, `raw_events`, `weekly_summaries`, `whatsapp_messages`; buckets de storage verificados). -- Rastreio de commits e diffs Git do notebook acordado para entrar no resumo diário — repositórios ficam em `coisinhas/` no desktop. -- Pipeline de resumo diário: identificado que rodava dependendo do PC local → decisão de migrar para a VPS para garantir execução independente. +### Frente Segundo-Cérebro / VPS -**Chatwoot — integração com Evolution (VPS):** -Sessão Claude Code aberta no terminal para configurar o Chatwoot self-hosted na VPS. Trabalho extenso (~2h de processo iterativo). Resultado: **commit `961fed8`** entregou a stack completa — Chatwoot v4.14.1-ce (Rails + Sidekiq + pgvector pg16 + Redis) em `chatwoot.eleotherium.tech`, conectado à instância Evolution. Scripts `chatwoot-backfill.js` e `chatwoot-fix-names.js` criados para copiar histórico do `evo-db` direto para o `chatwoot-pg` (o import nativo da Evolution só funciona com history sync do Baileys no QR scan inicial). Conta criada com account_id=2, inbox_id=1 ("evolution-gab"). +**Pipeline e dashboard local:** O Gabriel retomou o Claude Code via VPS e continuou refinando o dashboard local — identificou que estava verboso demais, que o "resumo do dia" nunca aparecia (só exibe no final do dia), que o gap entre blocos aparecia acima do bloco errado, e que os wrappers de dados estavam grandes demais. Solicitou reconstrução completa do dashboard. Também pediu que gaps maiores que 1h fossem exibidos no formato "Xh Ym". + +**Multi-device:** O Gabriel discutiu como adicionar o desktop ao sistema. A conclusão: nenhuma mudança no Supabase é necessária — a coluna `device_id` já separa os dados por dispositivo. Pediu um executável único em `/replicate` com buffer local e Docker Desktop para inicializar o sistema em qualquer máquina. + +**Rastreio de commits/Git:** Identificou os repositórios locais (`projetinhos` e `tnl` em `coisinhas/`). Solicitou que commits e pushes desses repos ficassem registrados e integrados ao resumo diário. + +**Chatwoot:** Por volta de 18:53, o Gabriel abriu um terminal, navegou até a pasta VPS e instruiu o Claude Code: **"configura o chatwoot pra integrar com a minha instância da Evolution na VPS"**. O Claude trabalhou por ~4 minutos fazendo o setup. O Gabriel acessou o superadmin do Chatwoot (`chatwoot.eleotherium.tech`), criou conta com credencial gerada pelo LastPass, obteve o token de acesso (`kiaEnTVruGqdWDEynPR3XQFa`), e passou o user ID (2, super admin) para o Claude. Às 20:16, o commit `961fed8` foi pushado: stack Chatwoot v4.14.1-ce completa (rails + sidekiq + pgvector pg16 + redis), integrada com Evolution, com scripts de backfill do histórico (`chatwoot-backfill.js` / `chatwoot-fix-names.js`). O Gabriel verificou as conversas no Chatwoot e tentou localizar contatos ("daniellee", "danielle"). + +**Pipeline noturno na VPS:** O Gabriel discutiu onde rodar o resumo diário (VPS vs. PC local) — problema: não pode garantir que o PC estará ligado às 22h. Solução: mover o pipeline para a VPS. Criou uma API key de service account na OpenAI (`sk-svcacct-...`) nomeada "segundo-cerebro". Às 20:38, pediu que todos os áudios do dia fossem analisados independente de quantidade (com paginação se necessário) e solicitou geração imediata do resumo sem esperar as 22h. Às 21:01, o resumo foi gerado — o Gabriel o leu e ficou satisfeito com o potencial de inteligência coletada, mas identificou ajustes de formato: divisão por jornadas de PC (não manhã/tarde/noite), blocos "Enquanto isso" para WhatsApp entre jornadas, e renderização Markdown real no dashboard (não .md cru). Às 21:42, instruiu: **"não execute nada agora, absorva, crie tarefas em tasks.md, e comita as mudanças"**. Às 21:45, commit `b08f8f5` foi pushado: migração completa do pipeline noturno para a VPS, segmentação por jornadas, rating de resumos com estrelas, summarizers semanal/mensal, retenção de 7 dias, e troca do faster-whisper local pelo OpenAI gpt-4o-transcribe (imagem Docker encolheu de 1,48 GB para 281 MB). + +**Reunião tldv / processamento local de áudio:** Às 22:08, o Gabriel abriu o Claude desktop e o ChatGPT para discutir uma ideia: gravar e transcrever reuniões localmente, sem depender de serviços como tldv. Raciocinou que todo áudio de uma reunião Meet já passa pelo computador, e que a informação de quem está falando (círculo animado na tela) também está acessível. Explorou viabilidade técnica — integração com agenda, mas funcionando mesmo em reuniões marcadas de última hora. Navegou pelo segundo-cérebro (Hoje, Semana, Projetos, Calendário) para avaliar o estado atual. + +--- -- Problema: limite de histórico importado (3 semanas) era insatisfatório → solicitado importar tudo, com paginação se necessário. -- OpenAI API Key criada no projeto `segundo-cerebro` / projeto `eleotherium-dev` para o pipeline de resumos rodar na VPS. -- Resumo do dia gerado manualmente às ~21h para validar o pipeline sem esperar as 22h — tempo médio de geração verificado com dados do dia. +## ✅ Feitos -**Reunião time de campo (CE Pernambuco — 18:30 a 19:30):** -Google Meet com a equipe de campo. Feedbacks do primeiro dia de operação no Sertão do Moxotó e Mata Sul: -- QR codes falharam offline (funcionavam inicialmente por cache, mas paravam após algum tempo) → workaround discutido: salvar imagens dos QRs no telefone ao invés de renderizar. -- Formulário: campo de telefone não obrigatório foi solicitado; pergunta sobre vídeo do João pode ter saído do form (verificar); sugestão de reorganizar ordem dos campos começando pela pergunta sobre presidente. -- Voluntários não venderam bem a entrada na comunidade (objetivo principal da iniciativa); não usaram os vídeos de João Campos. -- Dashboard com notas para plano de governo e comunicação recebeu elogios do Marroquim e da equipe política; vídeo de apresentação teve ~40 visualizações e foi mandado apagar por Gilson por "rodar demais nos grupos da política". -- Modo offline do Zoho Form: combinado testar colocar o Zoho efetivamente em modo offline (não só desligar internet) para verificar comportamento do cache do QR. +- Webhook da sessão Evolution configurado no n8n para capturar entradas/saídas das comunidades regionais PE +- Commit `961fed8`: Chatwoot v4.14.1-ce self-hosted integrado com Evolution na VPS (scripts de backfill incluídos) +- Commit `b08f8f5`: pipeline noturno migrado para VPS, segmentação por jornadas, rating de resumos, troca faster-whisper → OpenAI gpt-4o-transcribe, imagem -1,2 GB +- Lista consolidada de pessoal da operação PE gerada (nomes mesclados do Tally + Zoho) +- API key OpenAI service account criada ("segundo-cerebro") +- Resumo diário gerado manualmente às ~21h para validação @@ -58,7 +57,8 @@ Google Meet com a equipe de campo. Feedbacks do primeiro dia de operação no Se -## Intervalo 21:01 → 23:59 (WhatsApp) +## 🚧 Bloqueios -Novo problema no **CE Pernambuco**: 3 voluntários relataram GMails banidos pelo Google. Lucas e Vitória discutiram solução: -- **Criar novos GMails → arriscado** (muitos criados na véspera já foram desativados). -- **Solução adotada:** usar e-mails pessoais dos coordenadores → incluir na organização Zoho e no formulário correspondente; excluir e-mails inativos da organização; manter base no Notion com os e-mails ativos para não perder o controle. Vitória ficou responsável por resolver direto com os voluntários. +- QR codes offline no Zoho se comportam de forma inconsistente (cache temporário que expira — sem solução definitiva ainda) +- Gmails de voluntários banidos pelo Google — solução paliativa (e-mails pessoais dos coordenadores) não implementada pelo Gabriel +- Resumo diário dependia do PC estar ligado às 22h — mitigado com migração para VPS, mas pipeline local ainda não desacoplado completamente +- Bugs do segundo-cérebro (prints sem label de aba, timestamps em UTC, gap na posição errada) — tarefas criadas, não resolvidas no dia @@ -66,12 +66,11 @@ Novo problema no **CE Pernambuco**: 3 voluntários relataram GMails banidos pelo -## 👥 Com quem interagiu +## 🔜 Próximos passos -- **Lucas** (CE Pernambuco / Engaja) — principal interlocutor do dia, coordenação geral do campo -- **Vitória Estênio** — gestão operacional CE Pernambuco (Zoho, voluntários, comunidades) -- **Pedro Abib** — infraestrutura (bot Telegram de monitoramento de links, Mapbox/Leaflet, leaflet tiles) -- **Thiago Paiva** — suporte ao time, feedbacks da reunião de campo -- **Luiz Gallo** — [interno] Engaja, sugestão de relatório formal -- **Leonardo R., Pedro Cardoso, Olga Francino** — elogios e reações ao trabalho entregue -- **Mirella Avelino** — reação no CE Pernambuco -- **José Fernando** — reação no Política sem filtro +- Testar comportamento do Zoho em modo offline explícito (hipótese do Gabriel sobre cache do QR) +- Executável `/replicate` com Docker Desktop para replicar o sistema no desktop +- Rastreio de commits/pushes dos repos locais integrado ao resumo diário +- Renderização Markdown real dos resumos no dashboard (não .md cru) +- Authelia como SSO/2FA central para todos os subdomínios (registrado no tasks.md) +- Explorar gravação/transcrição local de reuniões (projeto secundário, conceito validado) +- Replicar setup de segundo-cérebro no desktop @@ -81,9 +80,6 @@ Novo problema no **CE Pernambuco**: 3 voluntários relataram GMails banidos pelo -- Sistema **segundo-cerebro**: correção de bugs (prints, timestamps, agrupamento de eventos, labels), refinamento do dashboard local, rastreio de commits Git, pipeline de resumo diário migrado para VPS -- **Chatwoot self-hosted**: deploy completo na VPS, integração bidirecional com Evolution, scripts de backfill de histórico, configuração de inbox e conta -- **CE Pernambuco — operação de campo**: gestão de contas (GMails desativados, Zoho, Tally), comunidades regionais (monitoramento de entradas), QR codes offline, formulário Zoho Forms -- **Dashboard Escalada**: integrado ao campo de Pernambuco, entregue com Mapbox, dados provisórios via Sheets (migração para Supabase planejada para segunda) -- **Mapbox**: substituição do Leaflet, conta criada (`admintnl / @dminTnl2026`) -- **n8n**: workflows "Entradas e Saídas" e "Central Webhook" verificados -- **OpenAI API**: chave criada para pipeline de resu ... [truncated at 50000 chars]https://github.com/eleotherium/VPS.git -
00:45
b08f8f5main migra pipeline noturno do segundo-cerebro pra VPS + jornadas + rating +3340 -477 · 31 arq.segundo-cerebro/CLAUDE.mdsegundo-cerebro/app/main.pysegundo-cerebro/app/templates/_rating.htmlsegundo-cerebro/app/templates/base.htmlsegundo-cerebro/app/templates/calendario.htmlsegundo-cerebro/app/templates/hoje.htmlsegundo-cerebro/app/templates/projetos.htmlsegundo-cerebro/app/templates/semana.html +23diff (50031 chars)
commit b08f8f5b5597f852bc230f1b70242d0d0a6609c6 Author: Gabriel Eleotério <gabrieleleoterioptc@gmail.com> Date: Sat Jun 6 21:45:25 2026 -0300 migra pipeline noturno do segundo-cerebro pra VPS + jornadas + rating Pipeline: - transcrição troca faster-whisper local por OpenAI gpt-4o-transcribe (paralelizado, 4 workers, retry exponencial em 429) - audio_puller pagina todas as páginas da Evolution e filtra pelo dia local, deduplica contra audio_transcripts.status='done' - scheduler desacoplado de APP_MODE — passa a depender só de PIPELINE_ENABLED. VPS roda nightly 22h + weekly sex 20h; container local (Windows) fica só com dashboard + coletores - imagem encolhe 1.48GB → 281MB com saída de faster-whisper/ffmpeg Summarizer diário: - segmenta o dia em JORNADAS (window_session contíguos com gap < 45min) e INTERVALOS, distribuindo wa_msgs/transcripts/git por timestamp - prompt instrui Claude a usar essas seções em vez de manhã/tarde/noite, com problemas → soluções explícitos por jornada - few-shot: anexa top-5 daily com maior + top-5 com menor rating Rating: - schema_ratings.sql: coluna rating smallint 0..5 em daily/weekly/monthly - POST /api/rating no app/main.py, sem auth (Authelia entra depois) - widget de estrelas (_rating.html) renderizado em Hoje, Semana, Calendário; dashboard agora também mostra o resumo do dia corrente - weekly e monthly summarizers ganham mesmo few-shot tasks.md: - Authelia como SSO/2FA central pra todos os subdomínios - 3 ajustes pendentes no resumo: jornadas só com PC + "Enquanto isso" pra WhatsApp, cross-correlação semântica de eventos, clipboard + multimodal de imagens - renderização Markdown dos resumos no dashboard Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> diff --git a/segundo-cerebro/CLAUDE.md b/segundo-cerebro/CLAUDE.md index 03c507a..e4d7707 100644 --- a/segundo-cerebro/CLAUDE.md +++ b/segundo-cerebro/CLAUDE.md @@ -54,7 +54,16 @@ segundo-cerebro/ │ ├── transcriber.py # faster-whisper medium (CPU, int8) -│ ├── summarizer.py # claude-sonnet-4-6 -│ └── scheduler.py # APScheduler 22h +│ ├── summarizer.py # daily — claude-sonnet-4-6 +│ ├── weekly_summarizer.py # semanal — sexta 20h +│ ├── monthly_summarizer.py # mensal — último dia do mês (via weekly) +│ ├── retention.py # apaga raw_events/screenshots/transcripts > 7 dias +│ └── scheduler.py # APScheduler — nightly 22h, weekly sexta 20h ├── app/ # FastAPI dashboard (container) -│ ├── main.py # rotas / /api/* /healthz -│ ├── templates/ontime.html +│ ├── main.py # rotas / /semana /calendario /projetos /api/* /healthz +│ ├── buckets.py # agrega raw_events em janelas configuráveis +│ ├── templates/ +│ │ ├── base.html # header + tab nav compartilhado +│ │ ├── hoje.html # timeline do dia em buckets +│ │ ├── semana.html # weekly_summary + dailies da semana +│ │ ├── calendario.html # grid mensal navegável + monthly_summary +│ │ └── projetos.html # placeholder, schema já criado │ └── static/style.css @@ -87,2 +96,8 @@ segundo-cerebro/ | `daily_summaries` | resumo por `(date, device_id)`. `device_id='all'` agrega todos os devices. | +| `weekly_summaries` | resumo por `(week_end, device_id)`. Gerado sex 20h. | +| `monthly_summaries` | resumo por `(month_start, device_id)`. Gerado no último dia do mês (via weekly). | +| `projects` | trilhas de trabalho. Status: active/paused/done/abandoned. `repo_urls` jsonb. | +| `project_events` | eventos vinculados a projeto. Type ∈ daily, weekly, meeting, commit, manual. | +| `meetings` | reuniões — `ts_start/end`, `attendees jsonb`, `transcript`, `important`, `project_id` nullable. | +| `git_events` | commits/pushes capturados por hooks globais. `device_id`, `repo_path`, `commit_hash`, `diff`, `stats` (insertions/deletions/files), `files_changed jsonb`. Único: `(device_id, type, commit_hash, repo_path)`. | | view `public.whatsapp_messages` | FDW para `evo-db.evolution_api.Message` (PostgREST não expõe schemas externos) | @@ -101,9 +116,15 @@ Migrations em `storage/*.sql`. Aplicar como `supabase_admin` no container `supab - `local` (Windows Docker, default): dashboard sempre filtrado por `DEVICE_ID` atual + dia de hoje. Sobe o APScheduler do pipeline noturno. -- `global` (VPS Docker): aceita `?d=YYYY-MM-DD&device=<id>`. Lista todos os devices ativos no dia. **Não roda pipeline.** +- `global` (VPS Docker em https://segundo-cerebro.eleotherium.tech): aceita querystrings (`?device=<id>`, `?w=YYYY-MM-DD`, `?m=YYYY-MM`, `?d=YYYY-MM-DD`). Lista todos os devices ativos. **Não roda pipeline.** Rotas: -- `GET /` — dashboard HTML -- `GET /api/ontime` — JSON do dia +- `GET /` — tab Hoje (timeline em buckets) +- `GET /semana` — tab Semana (`?w=YYYY-MM-DD` âncora qualquer dia da semana) +- `GET /calendario` — tab Calendário (`?m=YYYY-MM`) +- `GET /projetos` — tab Projetos (placeholder, schema pronto) +- `GET /api/ontime` — JSON do dia - `GET /api/devices?d=...` — devices ativos numa data -- `POST /api/pipeline/run` — só no modo `local`, dispara o pipeline na hora +- `POST /api/pipeline/run` — só no modo `local` +- `POST /api/weekly/run` — só no modo `local` +- `POST /api/monthly/run` — só no modo `local` +- `POST /api/retention/run` — só no modo `local` - `GET /healthz` diff --git a/segundo-cerebro/app/main.py b/segundo-cerebro/app/main.py index a91c260..bf12ce2 100644 --- a/segundo-cerebro/app/main.py +++ b/segundo-cerebro/app/main.py @@ -2,12 +2,23 @@ -Dois modos: - APP_MODE=local (Docker no Windows): - - filtra dashboard por DEVICE_ID atual + dia de hoje - - sobe scheduler do pipeline noturno (Whisper, summarizer) - APP_MODE=global (Docker na VPS): - - dashboard mostra todos os devices + qualquer data via querystring - - scheduler desligado (pipeline já roda no local) +Dois eixos independentes: + APP_MODE controla o DASHBOARD: + - local (Docker no Windows): filtra por DEVICE_ID atual + dia de hoje + - global (Docker na VPS): aceita ?device, ?d, ?w, ?m; lista todos os devices + PIPELINE_ENABLED controla o SCHEDULER: + - 1: sobe APScheduler (nightly 22h, weekly sexta 20h) + - 0: dashboard puro, sem pipeline + +Hoje o pipeline vive na VPS (PIPELINE_ENABLED=1 + APP_MODE=global). O container +local fica só com dashboard local + coletores nativos. + +Rotas: + GET / → tab Hoje + GET /semana → tab Semana + GET /calendario → tab Calendário + GET /projetos → tab Projetos """ +import json import os -from datetime import date, datetime, timezone +from calendar import monthrange +from datetime import date, datetime, timedelta, timezone @@ -30,2 +41,13 @@ from storage.uploader import ( fetch_summary, + fetch_daily_summaries_in_range, + fetch_weekly_summary, + fetch_weekly_summaries_in_range, + fetch_recent_weekly_summaries, + fetch_monthly_summary, + fetch_monthly_summaries_in_range, + fetch_recent_monthly_summaries, + fetch_projects, + fetch_recent_project_events, + fetch_recent_git_events, + set_summary_rating, signed_screenshot_url, @@ -35,2 +57,3 @@ from app.buckets import build_buckets, compute_day_stats, ALLOWED_BUCKET_MINS, D APP_MODE = os.environ.get("APP_MODE", "local").lower() +PIPELINE_ENABLED = os.environ.get("PIPELINE_ENABLED", "1") == "1" THIS_DEVICE = get_device_id() @@ -49,3 +72,26 @@ def _resolve_scope(request_device: str | None, request_date: str | None) -> tupl d = request_date or date.today().isoformat() - return d, request_device # None = todos os devices + return d, request_device + + +def _devices_for_header(target_date: str) -> list[str]: + if APP_MODE == "global": + return fetch_devices_active_on(target_date) + return [THIS_DEVICE] + + +def _summary_scope(device_filter: str | None) -> str: + """Para tabelas de summary (daily/weekly/monthly): 'all' agrega todos os devices.""" + return device_filter or "all" + + +_TRACKED_CACHE: dict | None = None + +def _tracked() -> dict[str, str]: + global _TRACKED_CACHE + if _TRACKED_CACHE is None: + cfg_path = os.path.join(os.path.dirname(__file__), "..", "config", "tracked_contacts.json") + with open(cfg_path) as f: + cfg = json.load(f) + _TRACKED_CACHE = {c["jid"]: c["name"] for c in cfg["groups"] + cfg["contacts"]} + return _TRACKED_CACHE @@ -54,8 +100,10 @@ def _resolve_scope(request_device: str | None, request_date: str | None) -> tupl def _on_startup(): - if APP_MODE == "local" and os.environ.get("PIPELINE_ENABLED", "1") == "1": + if PIPELINE_ENABLED: from pipeline.scheduler import start as start_scheduler app.state.scheduler = start_scheduler() - print(f"[app] mode={APP_MODE} device={THIS_DEVICE}") + print(f"[app] mode={APP_MODE} device={THIS_DEVICE} pipeline={PIPELINE_ENABLED}") +# ───────────────────────────── HOJE ───────────────────────────── + @app.get("/", response_class=HTMLResponse) @@ -69,3 +117,3 @@ def root( bucket_min = bucket if bucket in ALLOWED_BUCKET_MINS else DEFAULT_BUCKET_MIN - devices = fetch_devices_active_on(target_date) if APP_MODE == "global" else [THIS_DEVICE] + devices = _devices_for_header(target_date) @@ -74,8 +122,3 @@ def root( - # WhatsApp: mesmo no local mostramos (não é por device, é do usuário). - import json - cfg_path = os.path.join(os.path.dirname(__file__), "..", "config", "tracked_contacts.json") - with open(cfg_path) as f: - cfg = json.load(f) - tracked = {c["jid"]: c["name"] for c in cfg["groups"] + cfg["contacts"]} + tracked = _tracked() wa_msgs = fetch_day_whatsapp_messages(target_date, list(tracked)) @@ -92,3 +135,2 @@ def root( ) - # Latest-first: bucket mais recente no topo da timeline. buckets = list(reversed(buckets)) @@ -97,11 +139,13 @@ def root( - # Resumos recentes: até 7 últimos, sempre EXCLUINDO hoje (o de hoje só sai depois das 22h). - recent_summary_device = device_filter or THIS_DEVICE + recent_scope = _summary_scope(device_filter) + today_summary = fetch_summary(target_date, device_id=recent_scope) recent_summaries = [ - s for s in fetch_recent_daily_summaries(days=8, device_id=recent_summary_device) + s for s in fetch_recent_daily_summaries(days=8, device_id=recent_scope) if s.get("date") != target_date ][-7:] - recent_summaries.reverse() # mais recente primeiro + recent_summaries.reverse() - return templates.TemplateResponse(request, "ontime.html", { + return templates.TemplateResponse(request, "hoje.html", { + "tab": "hoje", + "page_title": "Hoje", "mode": APP_MODE, @@ -119,2 +163,4 @@ def root( "stats": day_stats, + "today_summary": today_summary, + "rating_device": recent_scope, "recent_summaries": recent_summaries, @@ -123,2 +169,248 @@ def root( +# ───────────────────────────── SEMANA ───────────────────────────── + +_WEEKDAY_PT = ["seg", "ter", "qua", "qui", "sex", "sáb", "dom"] + + +def _week_bounds(any_day: date) -> tuple[date, date]: + """Retorna (segunda, domingo) da semana de any_day. ISO: segunda = 0.""" + monday = any_day - timedelta(days=any_day.weekday()) + sunday = monday + timedelta(days=6) + return monday, sunday + + +@app.get("/semana", response_class=HTMLResponse) +def page_semana(request: Request, device: str | None = Query(None), w: str | None = Query(None)): + target_device = device if APP_MODE == "global" else THIS_DEVICE + device_filter = device if APP_MODE == "global" else THIS_DEVICE + + if w: + try: + anchor = date.fromisoformat(w) + except ValueError: + anchor = date.today() + else: + anchor = date.today() + + monday, sunday = _week_bounds(anchor) + today = date.today() + is_current_week = monday <= today <= sunday + + scope = _summary_scope(device_filter) + weekly = fetch_weekly_summary(sunday.isoformat(), device_id=scope) + dailies = fetch_daily_summaries_in_range(monday.isoformat(), sunday.isoformat(), device_id=scope) + recent_weeklies = [ + ww for ww in fetch_recent_weekly_summaries(weeks=8, device_id=scope) + if ww.get("week_end") != sunday.isoformat() + ] + recent_weeklies.reverse() + + days_with_data = {d["date"] for d in dailies} + weekday_label = { + (monday + timedelta(days=i)).isoformat(): _WEEKDAY_PT[i] + for i in range(7) + } + + week_stats = { + "days_with_data": len(days_with_data), + "daily_count": len(dailies), + } + weekly_when = (weekly or {}).get("created_at", "")[:10] if weekly else "—" + weekly_chars = len((weekly or {}).get("summary") or "") if weekly else 0 + + prev_week_end = (monday - timedelta(days=1)).isoformat() + next_week_end = (sunday + timedelta(days=7)).isoformat() + can_next = sunday < today + + devices = _devices_for_header(today.isoformat()) + + return templates.TemplateResponse(request, "semana.html", { + "tab": "semana", + "page_title": "Semana", + "mode": APP_MODE, + "device_filter": device_filter, + "devices": devices, + "week_start": monday.isoformat(), + "week_end": sunday.isoformat(), + "week_label": f"{monday.strftime('%d/%m')} → {sunday.strftime('%d/%m')}", + "is_current_week": is_current_week, + "prev_week_end": prev_week_end, + "next_week_end": next_week_end, + "can_next": can_next, + "weekly": weekly, + "dailies": dailies, + "weekdays": weekday_label, + "recent_weeklies": recent_weeklies, + "week_stats": week_stats, + "weekly_when": weekly_when, + "weekly_chars": weekly_chars, + "rating_device": scope, + }) + + +# ───────────────────────────── CALENDÁRIO ───────────────────────────── + +_MONTH_PT = ["", "Janeiro", "Fevereiro", "Março", "Abril", "Maio", "Junho", + "Julho", "Agosto", "Setembro", "Outubro", "Novembro", "Dezembro"] +_MONTH_PT_SHORT = ["", "Jan", "Fev", "Mar", "Abr", "Mai", "Jun", + "Jul", "Ago", "Set", "Out", "Nov", "Dez"] + + +def _parse_month(m: str | None) -> date: + if not m: + today = date.today() + return today.replace(day=1) + try: + return datetime.strptime(m + "-01", "%Y-%m-%d").date() + except ValueError: + today = date.today() + return today.replace(day=1) + + +def _shift_month(d: date, delta: int) -> date: + """Soma `delta` meses preservando dia=1.""" + y, m = d.year, d.month + delta + while m < 1: + y -= 1 + m += 12 + while m > 12: + y += 1 + m -= 12 + return date(y, m, 1) + + +@app.get("/calendario", response_class=HTMLResponse) +def page_calendario(request: Request, device: str | None = Query(None), m: str | None = Query(None)): + device_filter = device if APP_MODE == "global" else THIS_DEVICE + first = _parse_month(m) + last = first.replace(day=monthrange(first.year, first.month)[1]) + + today = date.today() + is_current_month = first.year == today.year and first.month == today.month + + scope = _summary_scope(device_filter) + dailies = fetch_daily_summaries_in_range(first.isoformat(), last.isoformat(), device_id=scope) + weeklies = fetch_weekly_summaries_in_range(first.isoformat(), last.isoformat(), device_id=scope) + monthly = fetch_monthly_summary(first.isoformat(), device_id=scope) + recent_monthlies = [ + mm for mm in fetch_recent_monthly_summaries(months=6, device_id=scope) + if mm.get("month_start") != first.isoformat() + ] + recent_monthlies.reverse() + + by_date = {d["date"]: d for d in dailies} + + # Monta grid 7 colunas começando na segunda. + # weekday(): 0=segunda, 6=domingo. Já alinhado. + cells: list[dict] = [] + start_weekday = first.weekday() + # Dias do mês anterior pra preencher o início da grid + for i in range(start_weekday): + d = first - timedelta(days=start_weekday - i) + cells.append({ + "day": d.day, + "date": d.isoformat(), + "outside": True, + "today": d == today, + "summary": (by_date.get(d.isoformat()) or {}).get("summary"), + }) + days_in_month = monthrange(first.year, first.month)[1] + for day_num in range(1, days_in_month + 1): + d = first.replace(day=day_num) + cells.append({ + "day": day_num, + "date": d.isoformat(), + "outside": False, + "today": d == today, + "summary": (by_date.get(d.isoformat()) or {}).get("summary"), + }) + # Completa até múltiplo de 7 + while len(cells) % 7 != 0: + d = date.fromisoformat(cells[-1]["date"]) + timedelta(days=1) + cells.append({ + "day": d.day, + "date": d.isoformat(), + "outside": True, + "today": d == today, + "summary": (by_date.get(d.isoformat()) or {}).get("summary"), + }) + + prev_first = _shift_month(first, -1) + next_first = _shift_month(first, 1) + can_next = next_first <= today.replace(day=1) + + devices = _devices_for_header(today.isoformat()) + + return templates.TemplateResponse(request, "calendario.html", { + "tab": "calendario", + "page_title": "Calendário", + "mode": APP_MODE, + "device_filter": device_filter, + "devices": devices, + "month_label": f"{_MONTH_PT[first.month]} {first.year}", + "month_start": first.isoformat(), + "is_current_month": is_current_month, + "cells": cells, + "weeklies": weeklies, + "monthly": monthly, + "recent_monthlies": recent_monthlies, + "days_with_data": len(dailies), + "prev_month": prev_first.strftime("%Y-%m"), + "prev_month_label": f"{_MONTH_PT_SHORT[prev_first.month]} {prev_first.year}", + "next_month": next_first.strftime("%Y-%m"), + "next_month_label": f"{_MONTH_PT_SHORT[next_first.month]} {next_first.year}", + "can_next": can_next, + "rating_device": scope, + }) + + +# ───────────────────────────── PROJETOS ───────────────────────────── + +@app.get("/projetos", response_class=HTMLResponse) +def page_projetos(request: Request, device: str | None = Query(None)): + device_filter = device if APP_MODE == "global" else THIS_DEVICE + projects = fetch_projects() + events_by_project: dict[str, list[dict]] = {} + if projects: + all_events = fetch_recent_project_events(limit=100) + for ev in all_events: + events_by_project.setdefault(ev.get("project_id"), []).append(ev) + + # Commits + pushes recentes (últimos 14 dias, sem filtro de projeto) + git_events = fetch_recent_git_events(days=14, device_id=device_filter, limit=300) + + # Agrupa por (repo, dia) pra rendering + from collections import OrderedDict + git_by_repo_day: OrderedDict[tuple, list[dict]] = OrderedDict() + for ev in git_events: + ts = ev.get("ts", "") + day = ts[:10] if ts else "?" + repo = ev.get("repo_name") or "?" + git_by_repo_day.setdefault((day, repo), []).append(ev) + + # Stats agregadas dos últimos 14 dias + commit_count = sum(1 for g in git_events if g.get("type") == "commit") + push_count = sum(1 for g in git_events if g.get("type") == "push") + repos_active = sorted({g.get("repo_name") for g in git_events if g.get("repo_name")}) + + devices = _devices_for_header(date.today().isoformat()) + + return templates.TemplateResponse(request, "projetos.html", { + "tab": "projetos", + "page_title": "Projetos", + "mode": APP_MODE, + "device_filter": device_filter, + "devices": devices, + "projects": projects, + "events_by_project": events_by_project, + "git_by_repo_day": git_by_repo_day, + "git_commit_count": commit_count, + "git_push_count": push_count, + "git_repos_active": repos_active, + "git_window_days": 14, + }) + + +# ───────────────────────────── API ───────────────────────────── + @app.get("/api/ontime") @@ -132,3 +424,3 @@ def api_ontime(device: str | None = Query(None), d: str | None = Query(None)): "device": device_filter, - "summary": fetch_summary(target_date, device_id=(device_filter or "all")), + "summary": fetch_summary(target_date, device_id=_summary_scope(device_filter)), "stats": compute_day_stats(events, None, transcripts), @@ -145,6 +437,12 @@ def api_devices(d: str | None = Query(None)): +def _require_pipeline(): + if not PIPELINE_ENABLED: + return JSONResponse({"error": "pipeline desabilitado neste host (PIPELINE_ENABLED=0)"}, status_code=400) + return None + + @app.post("/api/pipeline/run") def trigger_pipeline(): - if APP_MODE != "local": - return JSONResponse({"error": "pipeline só roda no modo local"}, status_code=400) + err = _require_pipeline() + if err: return err from pipeline.scheduler import run_pipeline @@ -156,4 +454,4 @@ def trigger_pipeline(): def trigger_weekly(): - if APP_MODE != "local": - return JSONResponse({"error": "weekly só roda no modo local"}, status_code=400) + err = _require_pipeline() + if err: return err from pipeline.scheduler import run_weekly @@ -163,6 +461,15 @@ def trigger_weekly(): +@app.post("/api/monthly/run") +def trigger_monthly(): + err = _require_pipeline() + if err: return err + from pipeline.scheduler import run_monthly + run_monthly() + return {"ok": True} + + @app.post("/api/retention/run") def trigger_retention(): - if APP_MODE != "local": - return JSONResponse({"error": "retention só roda no modo local"}, status_code=400) + err = _require_pipeline() + if err: return err from pipeline.retention import run as retention_run @@ -171,2 +478,37 @@ def trigger_retention(): +_VALID_KINDS = {"daily", "weekly", "monthly"} + + +@app.post("/api/rating") +async def api_set_rating(request: Request): + """Aceita JSON {kind, key, device, rating}. kind ∈ daily|weekly|monthly. + key é date / week_end / month_start (YYYY-MM-DD). device default 'all'. + rating ∈ 0..5 ou null (limpar).""" + body = await request.json() + kind = (body.get("kind") or "").strip().lower() + key = (body.get("key") or "").strip() + device = (body.get("device") or "all").strip() + rating = body.get("rating") + + if kind not in _VALID_KINDS: + return JSONResponse({"error": f"kind inválido (use {sorted(_VALID_KINDS)})"}, status_code=400) + if not key: + return JSONResponse({"error": "key vazia"}, status_code=400) + if rating is not None: + try: + rating = int(rating) + except (TypeError, ValueError): + return JSONResponse({"error": "rating não-numérico"}, status_code=400) + if not (0 <= rating <= 5): + return JSONResponse({"error": "rating deve estar entre 0 e 5"}, status_code=400) + + try: + updated = set_summary_rating(kind, key, device, rating) + except Exception as e: + return JSONResponse({"error": str(e)[:300]}, status_code=500) + if not updated: + return JSONResponse({"error": "registro não encontrado"}, status_code=404) + return {"ok": True, "rating": updated.get("rating")} + + @app.get("/healthz") diff --git a/segundo-cerebro/app/templates/_rating.html b/segundo-cerebro/app/templates/_rating.html new file mode 100644 index 0000000..0074db5 --- /dev/null +++ b/segundo-cerebro/app/templates/_rating.html @@ -0,0 +1,24 @@ +{# Widget de estrelas reutilizável. + Uso: {% include "_rating.html" with context %} dentro de um bloco que define + kind (daily|weekly|monthly), key (date/week_end/month_start ISO), + device, current (rating atual int|None), size (opcional 'sm'|'md'). #} +{%- set _size = size or 'md' -%} +{%- set _star_cls = 'w-3 h-3' if _size == 'sm' else 'w-4 h-4' -%} +<span class="rating inline-flex items-center gap-1 text-amber-400" + data-kind="{{ kind }}" data-key="{{ key }}" data-device="{{ device }}" + data-current="{{ current if current is not none else '' }}" + title="avalie de 0 a 5"> + <button type="button" data-rate="0" + class="text-slate-600 hover:text-slate-400 leading-none px-0.5" + title="limpar avaliação">·</button> + {% for n in range(1, 6) %} + <button type="button" data-rate="{{ n }}" + class="leading-none focus:outline-none" + title="{{ n }} de 5"> + <i data-lucide="star" class="{{ _star_cls }} transition-all + {% if current is not none and current >= n %}fill-amber-400 text-amber-400 + {% else %}text-slate-700 hover:text-amber-400{% endif %}"></i> + </button> + {% endfor %} + <span class="rating-status text-[10px] text-slate-500 ml-1"></span> +</span> diff --git a/segundo-cerebro/app/templates/base.html b/segundo-cerebro/app/templates/base.html new file mode 100644 index 0000000..42b4641 --- /dev/null +++ b/segundo-cerebro/app/templates/base.html @@ -0,0 +1,296 @@ +{# base.html — header + nav compartilhado entre todas as abas. #} +<!doctype html> +<html lang="pt-br"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>segundo-cerebro · {{ page_title or tab|capitalize }}{% if device_filter %} · {{ device_filter }}{% endif %}</title> + <script src="https://cdn.tailwindcss.com"></script> + <script src="https://unpkg.com/lucide@latest"></script> + <style> + body { font-family: -apple-system, "Segoe UI", system-ui, sans-serif; } + .scrollbar-thin::-webkit-scrollbar { width: 6px; height: 6px; } + .scrollbar-thin::-webkit-scrollbar-thumb { background: rgb(51 65 85 / 0.6); border-radius: 3px; } + + details summary { cursor: pointer; list-style: none; transition: background-color 160ms ease; } + details summary::-webkit-details-marker { display: none; } + details > summary:hover { background-color: rgb(30 41 59 / 0.45); } + details[open] > summary .lucide-chevron-down { transform: rotate(180deg); } + .lucide-chevron-down { transition: transform 200ms ease; } + @keyframes details-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } + details[open] > *:not(summary) { animation: details-in 180ms ease-out; } + + a, button { transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease, opacity 160ms ease; } + + #loading { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; + background: rgb(2 6 23 / 0.6); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + transition: opacity 280ms ease; } + #loading.hide { opacity: 0; pointer-events: none; } + #loading .card { background: rgb(15 23 42); border: 1px solid rgb(51 65 85); border-radius: 14px; + padding: 16px 22px; display: flex; align-items: center; gap: 12px; + box-shadow: 0 30px 60px rgb(0 0 0 / 0.5); } + #loading .spinner { width: 22px; height: 22px; border: 2.5px solid rgb(59 130 246 / 0.25); + border-top-color: rgb(96 165 250); border-radius: 50%; animation: spin 700ms linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } + + main { animation: page-fade 280ms ease-out; } + @keyframes page-fade { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } } + + .led-pulse { animation: led-pulse 2.4s ease-in-out infinite; } + @keyframes led-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } + + .stat-card { transition: transform 160ms ease, border-color 160ms ease; } + .stat-card:hover { transform: translateY(-1px); border-color: rgb(71 85 105); } + + [data-menu] { position: relative; } + [data-menu] > [data-menu-panel] { display: none; position: absolute; right: 0; top: calc(100% + 6px); min-width: 240px; z-index: 30; } + [data-menu].open > [data-menu-panel] { display: block; } + + /* Tab nav */ + .tab-link { position: relative; padding: 0.55rem 0.9rem; border-radius: 0.5rem; font-size: 0.78rem; + font-weight: 500; color: rgb(148 163 184); display: inline-flex; align-items: center; gap: 0.4rem; } + .tab-link:hover { color: rgb(226 232 240); background: rgb(30 41 59 / 0.6); } + .tab-link.active { color: rgb(96 165 250); background: rgb(30 58 138 / 0.25); + box-shadow: inset 0 0 0 1px rgb(30 64 175 / 0.5); } + .tab-link.active::after { content: ""; position: absolute; bottom: -1px; left: 0.6rem; right: 0.6rem; + height: 2px; background: rgb(96 165 250); border-radius: 1px; } + + {% block extra_styles %}{% endblock %} + </style> +</head> +<body class="min-h-screen bg-slate-950 text-slate-100"> + +<div id="loading" aria-live="polite" aria-busy="true"> + <div class="card"><div class="spinner"></div><span class="text-slate-200 text-sm font-medium">carregando…</span></div> +</div> + +<header class="border-b border-slate-800 px-6 py-2.5 sticky top-0 bg-slate-950/95 backdrop-blur z-20"> + <div class="max-w-7xl mx-auto flex items-center gap-3 flex-wrap"> + <div class="flex items-center gap-2"> + <i data-lucide="brain" class="w-5 h-5 text-blue-400"></i> + <span class="font-bold">segundo-cerebro</span> + </div> + <span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded + {% if mode == 'local' %}bg-emerald-950 text-emerald-300{% else %}bg-blue-950 text-blue-300{% endif %}">{{ mode }}</span> + + {% if mode == 'global' %} + <span class="text-slate-400 text-sm flex items-center gap-1"> + <i data-lucide="laptop" class="w-3.5 h-3.5"></i><strong class="text-slate-200">{{ device_filter or 'todos devices' }}</strong> + </span> + <div class="flex items-center gap-0.5 text-xs"> + <a href="?{% if request.query_params.get('d') %}d={{ request.query_params.get('d') }}{% endif %}" + class="px-1.5 py-0.5 rounded hover:text-blue-400 {% if not device_filter %}text-blue-400 font-semibold{% else %}text-slate-400{% endif %}">todos</a> + {% for dev in devices %} + <a href="?{% if request.query_params.get('d') %}d={{ request.query_params.get('d') }}&{% endif %}device={{ dev }}" + class="px-1.5 py-0.5 rounded hover:text-blue-400 {% if device_filter == dev %}text-blue-400 font-semibold{% else %}text-slate-400{% endif %}">{{ dev }}</a> + {% endfor %} + </div> + {% endif %} + + {# Controller pills só fazem sentido no local. #} + {% if mode == 'local' %} + <div class="ml-auto flex items-center gap-2" data-controller="http://localhost:8766"> + <span id="col-queue" class="hidden text-[11px] px-2 py-0.5 rounded-md border border-amber-800 bg-amber-950/60 text-amber-300 flex items-center gap-1" title=""> + <i data-lucide="cloud-off" class="w-3 h-3"></i><span></span> + </span> + <span id="col-uploads" class="text-[11px] text-slate-500 hidden sm:flex items-center gap-1" title=""></span> + <div data-menu class="relative"> + <button id="col-pill" type="button" onclick="toggleMenu(event)" + class="text-xs px-2.5 py-1 rounded-full font-medium border border-slate-700 bg-slate-900 text-slate-300 flex items-center gap-1.5 hover:border-slate-600"> + <span id="col-led" class="w-1.5 h-1.5 rounded-full bg-slate-600"></span> + <span id="col-status">…</span> + </button> + <div data-menu-panel class="rounded-xl border border-slate-800 bg-slate-900 shadow-2xl p-3 text-xs space-y-2"> + <div id="col-detail" class="text-slate-400 space-y-1"></div> + <div class="flex gap-1.5"> + <button onclick="collectorCall('start')" class="flex-1 px-2 py-1.5 rounded-md border border-emerald-800 text-emerald-300 hover:bg-emerald-950 flex items-center justify-center gap-1"><i data-lucide="play" class="w-3 h-3"></i>start</button> + <button onclick="collectorCall('stop')" class="flex-1 px-2 py-1.5 rounded-md border border-red-800 text-red-300 hover:bg-red-950 flex items-center justify-center gap-1"><i data-lucide="square" class="w-3 h-3"></i>stop</button> + <button onclick="collectorCall('restart')" class="flex-1 px-2 py-1.5 rounded-md border border-slate-700 text-slate-300 hover:bg-slate-800 flex items-center justify-center gap-1"><i data-lucide="refresh-cw" class="w-3 h-3"></i>restart</button> + </div> + <button onclick="drainQueue()" class="w-full px-2 py-1.5 rounded-md border border-slate-700 text-slate-300 hover:bg-slate-800 flex items-center justify-center gap-1"><i data-lucide="cloud-upload" class="w-3 h-3"></i>forçar drain da fila</button> + </div> + </div> + </div> + {% else %} + <span class="ml-auto"></span> + {% endif %} + </div> + + <nav class="max-w-7xl mx-auto mt-2 flex items-center gap-1 -mb-2 overflow-x-auto scrollbar-thin"> + {% set qs_device = (('&device=' ~ device_filter) if device_filter else '') %} + <a href="/{{ qs_device.replace('&', '?', 1) if qs_device }}" + class="tab-link {% if tab == 'hoje' %}active{% endif %}"><i data-lucide="sparkles" class="w-3.5 h-3.5"></i>Hoje</a> + <a href="/semana{{ qs_device.replace('&', '?', 1) if qs_device }}" + class="tab-link {% if tab == 'semana' %}active{% endif %}"><i data-lucide="layers" class="w-3.5 h-3.5"></i>Semana</a> + <a href="/calendario{{ qs_device.replace('&', '?', 1) if qs_device }}" + class="tab-link {% if tab == 'calendario' %}active{% endif %}"><i data-lucide="calendar" class="w-3.5 h-3.5"></i>Calendário</a> + <a href="/projetos{{ qs_device.replace('&', '?', 1) if qs_device }}" + class="tab-link {% if tab == 'projetos' %}active{% endif %}"><i data-lucide="kanban" class="w-3.5 h-3.5"></i>Projetos</a> + </nav> +</header> + +<main class="max-w-7xl mx-auto px-6 py-5 space-y-5"> + {% block content %}{% endblock %} +</main> + +<script> +const CTRL = "http://localhost:8766"; + +function fmtDur(sec) { + sec = sec || 0; + if (sec >= 3600) return `${Math.floor(sec/3600)}h${String(Math.floor((sec%3600)/60)).padStart(2,'0')}`; + if (sec >= 60) return `${Math.floor(sec/60)}m${String(sec%60).padStart(2,'0')}s`; + return `${sec}s`; +} + +async function collectorRefresh() { + const pill = document.getElementById("col-pill"); + const led = document.getElementById("col-led"); + const status = document.getElementById("col-status"); + const detail = document.getElementById("col-detail"); + const queue = document.getElementById("col-queue"); + const uploads= document.getElementById("col-uploads"); + if (!pill) return; + try { + const r = await fetch(`${CTRL}/metrics`); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const j = await r.json(); + const u = j.uploads || {}; + const q = j.queue || {}; + if (j.running) { + led.className = "w-1.5 h-1.5 rounded-full bg-emerald-400 led-pulse"; + status.textContent = fmtDur(j.uptime_sec); + pill.className = "text-xs px-2.5 py-1 rounded-full font-medium border border-emerald-900 bg-emerald-950/40 text-emerald-300 flex items-center gap-1.5 hover:border-emerald-700"; + } else { + led.className = "w-1.5 h-1.5 rounded-full bg-red-400"; + status.textContent = "parado"; + pill.className = "text-xs px-2.5 py-1 rounded-full font-medium border border-red-900 bg-red-950/40 text-red-300 flex items-center gap-1.5 hover:border-red-700"; + } + detail.innerHTML = ` + <div><span class="text-slate-500">device</span> · <span class="text-slate-200">${j.device || '?'}</span></div> + <div><span class="text-slate-500">uploads hoje</span> · ${u.events_ok||0} eventos / ${u.screenshots_ok||0} prints</div> + ${(u.events_err||u.screenshots_err) ? `<div class="text-red-400">${u.events_err||0}/${u.screenshots_err||0} falhas</div>` : ''} + <div><span class="text-slate-500">fila offline</span> · ${q.pending||0} pendente${(q.pending||0)===1?'':'s'}</div> + ${u.last_event_at ? `<div class="text-slate-600">último: ${new Date(u.last_event_at).toLocaleTimeString('pt-BR')}</div>` : ''} + ${j.last_error ? `<div class="text-red-400 break-all">err: ${j.last_error}</div>` : ''} + ${q.last_drain_err ? `<div class="text-amber-400 break-all">queue err: ${q.last_drain_err}</div>` : ''} + `; + if (q.pending > 0) { + queue.querySelector("span").textContent = `${q.pending} pend`; + queue.classList.remove("hidden"); + queue.title = `${q.pending} eventos na fila offline (sem conexão com Supabase)`; + } else { + queue.classList.add("hidden"); + } + if (uploads) { + uploads.innerHTML = `<i data-lucide="upload-cloud" class="w-3 h-3"></i>${u.events_ok||0}+${u.screenshots_ok||0}`; + uploads.title = `${u.events_ok||0} eventos, ${u.screenshots_ok||0} screenshots hoje`; + lucide.createIcons(); + } + } catch (e) { + led.className = "w-1.5 h-1.5 rounded-full bg-slate-600"; + status.textContent = "offline"; + pill.className = "text-xs px-2.5 py-1 rounded-full font-medium border border-slate-700 bg-slate-900 text-slate-400 flex items-center gap-1.5"; + detail.innerHTML = `<div class="text-slate-400">controller não responde em ${CTRL}</div><div class="text-slate-600">rode <code class="text-slate-300">replicate\\start.ps1</code></div>`; + if (queue) queue.classList.add("hidden"); + if (uploads) uploads.textContent = ""; + } +} +async function collectorCall(action) { + try { await fetch(`${CTRL}/${action}`, {method: "POST"}); } catch (e) {} + setTimeout(collectorRefresh, 200); +} +async function drainQueue() { + try { await fetch(`${CTRL}/queue/drain`, {method: "POST"}); } catch (e) {} + setTimeout(collectorRefresh, 200); +} +function toggleMenu(ev) { + ev.stopPropagation(); + document.querySelectorAll("[data-menu].open").forEach(m => { + if (!m.contains(ev.target)) m.classList.remove("open"); + }); + ev.target.closest("[data-menu]").classList.toggle("open"); +} +document.addEventListener("click", () => { + document.querySelectorAll("[data-menu].open").forEach(m => m.classList.remove("open")); +}); + +if (document.getElementById("col-pill")) { + collectorRefresh(); + setInterval(collectorRefresh, 5000); +} + +lucide.createIcons(); + +// ─── rating widget ─── +(function () { + function paintStars(widget, rating) { + widget.querySelectorAll("button[data-rate]").forEach(btn => { + const n = parseInt(btn.dataset.rate, 10); + const icon = btn.querySelector("i[data-lucide='star']"); + if (n === 0) return; + if (!icon) return; + const filled = rating != null && rating >= n; + icon.classList.toggle("fill-amber-400", filled); + icon.classList.toggle("text-amber-400", filled); + icon.classList.toggle("text-slate-700", !filled); + }); + widget.dataset.current = rating == null ? "" : String(rating); + } + async function send(widget, rating) { + const status = widget.querySelector(".rating-status"); + status.textContent = "…"; + try { + const res = await fetch("/api/rating", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + kind: widget.dataset.kind, + key: widget.dataset.key, + device: widget.dataset.device, + rating: rating === 0 ? null : rating, + }), + }); + const j = await res.json(); + if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); + paintStars(widget, j.rating); + status.textContent = "salvo"; + setTimeout(() => { status.textContent = ""; }, 1200); + } catch (e) { + status.textContent = "erro"; + status.title = String(e); + } + } + document.addEventListener("click", (ev) => { + const btn = ev.target.closest(".rating button[data-rate]"); + if (!btn) return; + ev.preventDefault(); + ev.stopPropagation(); + const widget = btn.closest(".rating"); + const rating = parseInt(btn.dataset.rate, 10); + send(widget, rating); + }); +})(); + +(function () { + const el = document.getElementById("loading"); + if (!el) return; + function hide() { el.classList.add("hide"); } + function show() { el.classList.remove("hide"); } + if (document.readyState === "complete") { requestAnimationFrame(hide); } + else { window.addEventListener("load", () => requestAnimationFrame(hide), { once: true }); } + document.addEventListener("click", (ev) => { + const a = ev.target.closest("a[href]"); + if (!a) return; + if (a.target === "_blank" || a.hasAttribute("download")) return; + if (a.host && a.host !== location.host) return; + show(); + }); + document.addEventListener("submit", show); + document.querySelectorAll("[data-loading]").forEach(b => b.addEventListener("click", show)); + window.addEventListener("pageshow", (e) => { if (e.persisted) hide(); }); +})(); +{% block extra_scripts %}{% endblock %} +</script> +</body> +</html> diff --git a/segundo-cerebro/app/templates/calendario.html b/segundo-cerebro/app/templates/calendario.html new file mode 100644 index 0000000..0819722 --- /dev/null +++ b/segundo-cerebro/app/templates/calendario.html @@ -0,0 +1,168 @@ +{% extends "base.html" %} +{# Tab Calendário — grid mensal com células densas (daily summary preview) + #} +{# painel lateral mostrando o mês selecionado (monthly summary + weeklies). #} + +{% block extra_styles %} + .cal-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; } + .cal-cell { border: 1px solid rgb(30 41 59 / 0.7); border-radius: 8px; padding: 6px 8px; + background: rgb(15 23 42 / 0.45); min-height: 96px; + display: flex; flex-direction: column; gap: 4px; transition: border-color 160ms ease, background-color 160ms ease; } + .cal-cell:hover { border-color: rgb(71 85 105); background: rgb(15 23 42 / 0.8); } + .cal-cell.outside { opacity: 0.35; } + .cal-cell.today { border-color: rgb(59 130 246); box-shadow: 0 0 0 1px rgb(59 130 246 / 0.5) inset; } + .cal-cell.has-data { background: rgb(15 23 42 / 0.85); } + .cal-cell .num { font-size: 0.78rem; color: rgb(148 163 184); font-weight: 600; } + .cal-cell .num.today { color: rgb(96 165 250); } + .cal-cell .pv { font-size: 0.65rem; color: rgb(148 163 184); line-height: 1.15; + overflow: hidden; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; } + .cal-cell .dot { display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: rgb(96 165 250); margin-left: 4px; } + .cal-dow { font-size: 0.62rem; text-transform: uppercase; color: rgb(100 116 139); + text-align: center; padding: 2px; letter-spacing: 0.05em; } +{% endblock %} + +{% block content %} + + <section class="flex items-center gap-3 flex-wrap"> + <h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2"> + <i data-lucide="calendar" class="w-3.5 h-3.5 text-amber-400"></i>{{ month_label }} + </h2> + <span class="text-slate-600 text-xs">{{ days_with_data }} dia{{ 's' if days_with_data != 1 else '' }} com dados · {{ weeklies|length }} semana{% if weeklies|length != 1 %}s{% endif %} · {% if monthly %}1 mensal{% else %}sem mensal{% endif %}</span> + + <div class="ml-auto flex items-center gap-1 text-[11px]"> + <a href="?{% if device_filter %}device={{ device_filter }}&{% endif %}m={{ prev_month }}" + class="px-2 py-1 rounded border border-slate-800 text-slate-300 hover:bg-slate-800/60 flex items-center gap-1"> + <i data-lucide="chevron-left" class="w-3 h-3"></i>{{ prev_month_label }} + </a> + {% if not is_current_month %} + <a href="?{% if device_filter %}device={{ device_filter }}{% endif %}" + class="px-2 py-1 rounded border border-blue-800 text-blue-300 hover:bg-blue-950 flex items-center gap-1"> + <i data-lucide="rewind" class="w-3 h-3"></i>mês atual + </a> + {% endif %} + {% if can_next %} + <a href="?{% if device_filter %}device={{ device_filter }}&{% endif %}m={{ next_month }}" + class="px-2 py-1 rounded border border-slate-800 text-slate-300 hover:bg-slate-800/60 flex items-center gap-1"> + {{ next_month_label }}<i data-lucide="chevron-right" class="w-3 h-3"></i> + </a> + {% endif %} + </div> + </section> + + <section class="grid lg:grid-cols-[2fr_1fr] gap-5"> + + {# Grid mensal #} + <div> + <div class="cal-grid mb-1"> + {% for d in ['Seg','Ter','Qua','Qui','Sex','Sáb','Dom'] %} + <div class="cal-dow">{{ d }}</div> + {% endfor %} + </div> + <div class="cal-grid"> + {% for cell in cells %} + {% if cell.day %} + <a href="/?{% if device_filter %}device={{ device_filter }}&{% endif %}d={{ cell.date }}" + class="cal-cell {% if cell.outside %}outside{% endif %} {% if cell.today %}today{% endif %} {% if cell.summary %}has-data{% endif %}" + title="{{ cell.date }}"> + <div class="flex items-baseline justify-between"> + <span class="num {% if cell.today %}today{% endif %}">{{ cell.day }}</span> + {% if cell.summary %}<span class="dot"></span>{% endif %} + </div> + {% if cell.summary %} + <div class="pv">{{ (cell.summary or '')[:140] }}</div> + {% endif %} + </a> + {% else %} + <div></div> + {% endif %} + {% endfor %} + </div> + </div> + + {# Painel lateral: mensal + semanais do mês #} + <aside class="space-y-3"> + <div> + <div class="flex items-baseline gap-2 mb-2 flex-wrap"> + <h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2"> + <i data-lucide="sparkles" class="w-3.5 h-3.5 text-amber-400"></i>Resumo do mês + </h3> + {% if monthly %} + {% with kind='monthly', key=monthly.month_start, device=rating_device, current=monthly.rating, size='sm' %} + {% include "_rating.html" %} + {% endwith %} + {% endif %} + {% if is_current_month %} + <button data-loading onclick="fetch('/api/monthly/run',{method:'POST'}).then(()=>location.reload());" + class="ml-auto text-[10px] px-2 py-0.5 rounded-md border border-blue-800 text-blue-300 hover:bg-blue-950 flex items-center gap-1"> + <i data-lucide="play" class="w-2.5 h-2.5"></i>gerar + </button> + {% endif %} + </div> + {% if not monthly %} + <div class="rounded-lg border border-dashed border-slate-800 bg-slate-900/30 px-3 py-3 text-slate-500 text-xs"> + Sem resumo mensal ainda. + {% if is_current_month %}<br><span class="text-[10px] text-slate-600">Gerado no fim do mês ou sob demanda.</span>{% endif %} + </div> + {% else %} + <div class="rounded-lg border border-amber-900/40 bg-amber-950/10 p-3"> + <pre class="whitespace-pre-wrap text-[12px] leading-snug text-slate-100 font-sans">{{ monthly.summary }}</pre> + <div class="mt-2 pt-2 border-t border-slate-800 text-[10px] text-slate-500 flex items-center justify-between"> + <span>{{ monthly.model }}</span> + <span>{{ monthly.created_at[:10] if monthly.created_at else '' }}</span> + </div> + </div> + {% endif %} + </div> + + <div> + <h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-2"> + <i data-lucide="layers" class="w-3.5 h-3.5 text-slate-500"></i>Semanas do mês + </h3> + {% if not weeklies %} + <div class="rounded-lg border border-dashed border-slate-800 bg-slate-900/30 px-3 py-2 text-slate-500 text-xs"> + Nenhum resumo semanal arquivado neste mês. + </div> + {% else %} + <div class="space-y-1"> + {% for w in weeklies %} + <details class="rounded-lg border border-slate-800 bg-slate-900/40"> + <summary class="px-3 py-1.5 flex items-baseline gap-2 list-none"> + <span class="font-mono text-[10px] text-slate-400 shrink-0 w-20">→ {{ w.week_end }}</span> + {% with kind='weekly', key=w.week_end, device=rating_device, current=w.rating, size='sm' %} + {% include "_rating.html" %} + {% endwith %} + <span class="text-[11px] text-slate-300 truncate flex-1">{{ (w.summary or '')[:100] }}{% if (w.summary or '')|length > 100 %}…{% endif %}</span> + <i data-lucide="chevron-down" class="w-3 h-3 text-slate-600 shrink-0"></i> + </summary> + <pre class="px-3 pb-2 whitespace-pre-wrap text-[11px] leading-snug text-slate-200 font-sans">{{ w.summary }}</pre> + </details> + {% endfor %} + </div> + {% endif %} + </div> + + {# Atalho pra meses recentes #} + {% if recent_monthlies %} + <div> + <h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-2"> + <i data-lucide="archive" class="w-3.5 h-3.5 text-slate-500"></i>Meses ant ... [truncated at 50000 chars]https://github.com/eleotherium/VPS.git
2026-06-06 VPS 2 commits adiciona chatwoot self-hosted integrado com Evolution (gab)
-
23:16
961fed8main adiciona chatwoot self-hosted integrado com Evolution (gab) +675 · 6 arq..envCLAUDE.mdchatwoot-backfill.jschatwoot-compose.ymlchatwoot-fix-names.jsevolution-compose.ymldiff (28561 chars)
commit 961fed8ae75a8dd15c42b4575ef8c8ab197592c6 Author: Gabriel Eleotério <gabrieleleoterioptc@gmail.com> Date: Sat Jun 6 20:16:47 2026 -0300 adiciona chatwoot self-hosted integrado com Evolution (gab) - stack chatwoot v4.14.1-ce (rails+sidekiq+pgvector pg16+redis) em chatwoot.eleotherium.tech - envs CHATWOOT_* na Evolution + conexão à rede chatwoot_chatwoot-net pro import poder alcançar chatwoot-pg - scripts chatwoot-backfill.js / chatwoot-fix-names.js que copiam histórico do evo-db direto pro chatwoot-pg (o import nativo da Evolution só funciona com history sync do Baileys no QR scan inicial) - CLAUDE.md documenta o stack, fluxo bidirecional e gotchas (resolver=letsencrypt, db:chatwoot_prepare manual, etc.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> diff --git a/.env b/.env index 8f83bce..75025a8 100644 --- a/.env +++ b/.env @@ -77 +77,26 @@ SUPABASE_KONG_PASSWORD=***[REDACTED]*** SUPABASE_PG_META_CRYPTO_KEY=T5nknUgjDkcRbCytxOLqL2MG2o_OshjxzwbXZz9a-GY + +# ── Chatwoot ────────────────────────────────────────────────────── +# URL: https://chatwoot.eleotherium.tech +# Stack Portainer id=8 — chatwoot/chatwoot:v4.14.1-ce +# Account_id=2 (admin: Gabriel Eleoterio Rosa Inacio / gabrieleleoterioptc@gmail.com) +# Inbox_id=1 (API channel, nome "evolution-gab") +CHATWOOT_URL=https://chatwoot.eleotherium.tech +CHATWOOT_ACCOUNT_ID=2 +CHATWOOT_INBOX_ID=1 +CHATWOOT_USER_ACCESS_TOKEN=***[REDACTED]*** + +# Rails secrets +CHATWOOT_SECRET_KEY_BASE=02a0b1e2ceedf70efd7358861749bc36ee9476da5d7dc3cbe4cd018cc7ca63158c45d612f557a093e2d8b21c5cbef917b2863896aee7e7ff8b214a29039893e8 + +# Chatwoot PostgreSQL (container chatwoot-pg, pgvector/pgvector:pg16) +CHATWOOT_DB_HOST=chatwoot-pg +CHATWOOT_DB_PORT=5432 +CHATWOOT_DB_NAME=chatwoot +CHATWOOT_DB_USER=chatwoot +CHATWOOT_DB_PASSWORD=***[REDACTED]*** +CHATWOOT_DB_URI=postgresql://chatwoot:Kah1y2ThG7ZH2Xt3sxNc4gaZSCbZL5fY@chatwoot-pg:5432/chatwoot + +# Chatwoot Redis (container chatwoot-redis) +CHATWOOT_REDIS_PASSWORD=***[REDACTED]*** +CHATWOOT_REDIS_URI=redis://:oVnfsFGqHPqpLaU4GDOPzbHa124Iybo1@chatwoot-redis:6379 diff --git a/CLAUDE.md b/CLAUDE.md index bb7314e..b9bb7a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,2 +42,3 @@ Subdomínios ativos (todos → 2.25.174.226): - `supabase.eleotherium.tech` +- `chatwoot.eleotherium.tech` @@ -74,2 +75,6 @@ Portainer CE 2.39.3 → gerencia todas as stacks via UI | supabase-imgproxy | darthsim/imgproxy:v3.30.1 | 0.3 CPU / 256M | +| chatwoot-rails | chatwoot/chatwoot:v4.14.1-ce | 0.8 CPU / 1280M | +| chatwoot-sidekiq | chatwoot/chatwoot:v4.14.1-ce | 0.5 CPU / 768M | +| chatwoot-pg | pgvector/pgvector:pg16 | 0.4 CPU / 512M | +| chatwoot-redis | redis:7-alpine | 0.1 CPU / 128M | @@ -82,2 +87,3 @@ Portainer CE 2.39.3 → gerencia todas as stacks via UI - `supabase_supa-net` — interna da stack Supabase +- `chatwoot_chatwoot-net` — interna da stack Chatwoot (Evolution também tá conectada nela pra alcançar `chatwoot-pg` durante imports) @@ -90,2 +96,3 @@ Portainer CE 2.39.3 → gerencia todas as stacks via UI /opt/portainer/data/compose/6/ # Stack supabase (gerenciado pelo Portainer) +/opt/portainer/data/compose/8/ # Stack chatwoot (gerenciado pelo Portainer) /opt/supabase/volumes/api/kong.yml # Config Kong (editado manualmente) @@ -105,2 +112,3 @@ Portainer CE 2.39.3 → gerencia todas as stacks via UI | Supabase Studio | https://supabase.eleotherium.tech | — (auth via Supabase) | +| Chatwoot | https://chatwoot.eleotherium.tech | gabrieleleoterioptc@gmail.com / senha definida no signup; account_id=2 | @@ -167,2 +175,89 @@ Webhook para segundo-cerebro: `http://host.docker.internal:9000/webhook/evolutio +### Integração com Chatwoot (envs no compose da Evolution) + +```yaml +CHATWOOT_ENABLED: "true" +CHATWOOT_MESSAGE_READ: "true" +CHATWOOT_MESSAGE_DELETE: "true" +CHATWOOT_IMPORT_DATABASE_CONNECTION_URI: postgresql://chatwoot:...@chatwoot-pg:5432/chatwoot?sslmode=disable +CHATWOOT_IMPORT_PLACEHOLDER_MEDIA_MESSAGE: "false" +``` + +E a Evolution também está conectada à rede `chatwoot_chatwoot-net` pra alcançar `chatwoot-pg` durante history syncs do Baileys. Sem essa env, qualquer call `/chatwoot/set/{instance}` retorna `Chatwoot is disabled`. + +Instância atual em produção: **`gab`** (Gabriel, ***[REDACTED]***), integrada ao Chatwoot account 2 / inbox `evolution-gab` (id=1). + +--- + +## Chatwoot + +- **Imagem:** `chatwoot/chatwoot:v4.14.1-ce` (rails + sidekiq separados) +- **DB:** `pgvector/pgvector:pg16` (precisa de pgvector pro Captain AI) — `postgresql://chatwoot:Kah1y2ThG7ZH2Xt3sxNc4gaZSCbZL5fY@chatwoot-pg:5432/chatwoot` +- **Redis:** `redis://:oVnf...@chatwoot-redis:6379` (auth obrigatória, redis-server --requirepass via env) +- **URL:** https://chatwoot.eleotherium.tech +- **Account/Inbox em uso:** account_id=2, inbox_id=1 (`evolution-gab`, channel API) +- **Token de API do admin (Gabriel):** `kiaEnTVruGqdWDEynPR3XQFa` +- **SECRET_KEY_BASE:** 128 chars hex (gerado, ver `.env`). Trocar = desloga todo mundo. + +### Fluxo bidirecional WhatsApp ↔ Chatwoot + +``` +WhatsApp → Evolution (gab) → POST chatwoot REST API → Chatwoot inbox API +Chatwoot agent reply → webhook → POST evolution.eleotherium.tech/chatwoot/webhook/gab → WhatsApp +``` + +Configurar a integração na Evolution (idempotente; `autoCreate: true` reusa inbox existente): + +```bash +curl -X POST https://evolution.eleotherium.tech/chatwoot/set/gab \ + -H "apikey=***[REDACTED]*** -H "Content-Type: application/json" \ + -d '{ + "enabled": true, "accountId": "2", + "token": "kiaEnTVruGqdWDEynPR3XQFa", + "url": "https://chatwoot.eleotherium.tech", + "signMsg": true, "reopenConversation": true, + "conversationPending": false, "nameInbox": "evolution-gab", + "mergeBrazilContacts": true, + "importContacts": true, "importMessages": true, + "daysLimitImportMessages": 800, + "autoCreate": true + }' +``` + +### Setup inicial (entrypoint NÃO roda db:chatwoot_prepare automaticamente!) + +Após primeiro deploy do stack, rodar a migração manualmente: + +```bash +docker exec chatwoot-rails bundle exec rake db:chatwoot_prepare +docker restart chatwoot-rails chatwoot-sidekiq +``` + +Depois, abrir https://chatwoot.eleotherium.tech/installation/onboarding pelo navegador, criar o admin (primeiro user vira super_admin) e gerar o Access Token em Profile Settings. + +### Backfill histórico de mensagens + +A Evolution v2.3.7 só importa pro Chatwoot mensagens que o Baileys recebe via `messaging-history.set` (no QR scan inicial). Pra instâncias já conectadas, o buffer está vazio → import nativo é no-op. + +Solução: scripts `chatwoot-backfill.js` + `chatwoot-fix-names.js` (na raiz do repo) leem direto do `evo-db.Message` table e escrevem em `chatwoot-pg` (contacts, contact_inboxes, conversations, messages). Rodam de dentro do container `evolution` (único com acesso às duas redes): + +```bash +docker cp chatwoot-backfill.js evolution:/tmp/ +docker exec -e NODE_PATH=/evolution/node_modules evolution node /tmp/chatwoot-backfill.js +docker cp chatwoot-fix-names.js evolution:/tmp/ +docker exec -e NODE_PATH=/evolution/node_modules evolution node /tmp/chatwoot-fix-names.js +``` + +São idempotentes — usam `ON CONFLICT` em (account_id, identifier) e checagem de `source_id = 'WAID:...'` pra deduplicar. + +### Gotchas + +| Problema | Causa | Fix | +|---|---|---| +| `Router chatwoot uses a nonexistent resolver: le` | Resolver do Traefik se chama `letsencrypt`, não `le` | Use `certresolver=letsencrypt` nos labels | +| `chatwoot/set` → `Chatwoot is disabled` | Env `CHATWOOT_ENABLED=true` faltando no compose da Evolution | Adicionar no stack 3 e fazer redeploy | +| Tabelas não existem | Entrypoint do Chatwoot v4 NÃO roda `db:chatwoot_prepare` no boot | Rodar manual após o primeiro deploy | +| Import nativo da Evolution não traz histórico | Só funciona com history sync do Baileys (QR scan inicial) | Usar `chatwoot-backfill.js` | +| Nomes de contato saem como "Você" | `MAX(pushName)` pega o pushName das msgs outgoing também | Script `chatwoot-fix-names.js` corrige usando `Chat.name` pra grupos e pushName de msgs incoming pra contatos | + --- diff --git a/chatwoot-backfill.js b/chatwoot-backfill.js new file mode 100644 index 0000000..6dce142 --- /dev/null +++ b/chatwoot-backfill.js @@ -0,0 +1,221 @@ +// Backfill historical WhatsApp messages from Evolution evo-db → Chatwoot chatwoot-pg +// Run inside the evolution container (has access to both networks) + +const { Client } = require('pg'); +const crypto = require('crypto'); + +const EVO_URL = 'postgresql://evolution:HCRpDOT4cjC38rimM71mSWCyHlc@evo-db:5432/evolutiondb'; +const CW_URL = 'postgresql://chatwoot:Kah1y2ThG7ZH2Xt3sxNc4gaZSCbZL5fY@chatwoot-pg:5432/chatwoot'; + +const ACCOUNT_ID = 2; +const INBOX_ID = 1; +const SENDER_USER = 2; // Gabriel +const INSTANCE_ID = 'a8b74fa9-b017-495a-8510-18ce62b2b686'; // gab + +function uuid() { return crypto.randomUUID(); } +function randToken(n=24) { return crypto.randomBytes(Math.ceil(n*3/4)).toString('base64').replace(/[+/=]/g,'').slice(0,n); } + +function extractContent(msg) { + if (!msg) return null; + if (msg.conversation) return msg.conversation; + if (msg.extendedTextMessage?.text) return msg.extendedTextMessage.text; + if (msg.imageMessage) return msg.imageMessage.caption || '[image]'; + if (msg.videoMessage) return msg.videoMessage.caption || '[video]'; + if (msg.audioMessage) return '[audio]'; + if (msg.documentMessage) return `[document: ${msg.documentMessage.fileName || 'file'}]`; + if (msg.stickerMessage) return '[sticker]'; + if (msg.locationMessage) return '[location]'; + if (msg.contactMessage) return `[contact: ${msg.contactMessage.displayName || ''}]`; + if (msg.contactsArrayMessage) return '[contacts]'; + if (msg.pollCreationMessage || msg.pollCreationMessageV3) return '[poll]'; + if (msg.ephemeralMessage?.message) return extractContent(msg.ephemeralMessage.message); + if (msg.viewOnceMessage?.message) return extractContent(msg.viewOnceMessage.message); + if (msg.viewOnceMessageV2?.message) return extractContent(msg.viewOnceMessageV2.message); + if (msg.protocolMessage || msg.reactionMessage || msg.senderKeyDistributionMessage) return null; + return null; +} + +async function main() { + const evo = new Client({ connectionString: EVO_URL }); + const cw = new Client({ connectionString: CW_URL }); + await evo.connect(); + await cw.connect(); + await evo.query("SET search_path TO evolution_api, public"); + + console.log('Connected to both DBs.'); + + // 1) unique remoteJids + const { rows: jids } = await evo.query(` + SELECT + m.key->>'remoteJid' AS remote_jid, + MAX(m."pushName") AS push_name, + MAX(m."messageTimestamp") AS last_ts + FROM "Message" m + WHERE m."instanceId" = $1 + AND m.key->>'remoteJid' IS NOT NULL + AND m.key->>'remoteJid' NOT LIKE '%@broadcast' + AND m.key->>'remoteJid' != 'status@broadcast' + GROUP BY m.key->>'remoteJid' + ORDER BY MAX(m."messageTimestamp") DESC + `, [INSTANCE_ID]); + + console.log(`Distinct remoteJids: ${jids.length}`); + + // 2) upsert contacts, contact_inboxes, conversations + const contactByJid = new Map(); // jid -> contact_id + const conversationByJid = new Map(); // jid -> conversation_id + + // current display_id offset + const { rows: [{ max_display }] } = await cw.query( + `SELECT COALESCE(MAX(display_id), 0) AS max_display FROM conversations WHERE account_id = $1`, + [ACCOUNT_ID] + ); + let nextDisplayId = parseInt(max_display) + 1; + + for (const j of jids) { + const jid = j.remote_jid; + const isGroup = jid.endsWith('@g.us'); + const e164 = jid.split('@')[0]; + const phone = isGroup ? null : '+' + e164; + const baseName = j.push_name && j.push_name.trim() ? j.push_name.trim() : e164; + const name = isGroup ? `${baseName} (GROUP)` : baseName; + + // upsert contact (unique on (account_id, identifier)) + const { rows: [c] } = await cw.query(` + INSERT INTO contacts (name, phone_number, account_id, identifier, additional_attributes, custom_attributes, contact_type, created_at, updated_at) + VALUES ($1, $2, $3, $4, '{}', '{}', 0, NOW(), NOW()) + ON CONFLICT (account_id, identifier) DO UPDATE SET name = EXCLUDED.name, updated_at = NOW() + RETURNING id + `, [name, phone, ACCOUNT_ID, jid]); + contactByJid.set(jid, c.id); + + // lookup-or-create contact_inbox + let { rows: ciRows } = await cw.query( + `SELECT id FROM contact_inboxes WHERE contact_id = $1 AND inbox_id = $2 LIMIT 1`, + [c.id, INBOX_ID] + ); + let contactInboxId; + if (ciRows.length) { + contactInboxId = ciRows[0].id; + } else { + const { rows: [ci] } = await cw.query(` + INSERT INTO contact_inboxes (contact_id, inbox_id, source_id, pubsub_token, hmac_verified, created_at, updated_at) + VALUES ($1, $2, $3, $4, false, NOW(), NOW()) + RETURNING id + `, [c.id, INBOX_ID, uuid(), randToken(24)]); + contactInboxId = ci.id; + } + + // lookup-or-create conversation + let { rows: convRows } = await cw.query( + `SELECT id FROM conversations WHERE contact_inbox_id = $1 ORDER BY id DESC LIMIT 1`, + [contactInboxId] + ); + let conversationId; + if (convRows.length) { + conversationId = convRows[0].id; + } else { + const { rows: [cv] } = await cw.query(` + INSERT INTO conversations + (account_id, inbox_id, status, contact_id, display_id, contact_inbox_id, uuid, additional_attributes, custom_attributes, last_activity_at, created_at, updated_at) + VALUES ($1, $2, 0, $3, $4, $5, $6, '{}', '{}', to_timestamp($7), NOW(), NOW()) + RETURNING id + `, [ACCOUNT_ID, INBOX_ID, c.id, nextDisplayId, contactInboxId, uuid(), j.last_ts]); + conversationId = cv.id; + nextDisplayId++; + } + conversationByJid.set(jid, conversationId); + } + console.log(`Contacts ready: ${contactByJid.size}, conversations ready: ${conversationByJid.size}`); + + // 3) existing source_ids to skip dupes + const { rows: existRows } = await cw.query( + `SELECT source_id FROM messages WHERE inbox_id = $1 AND source_id LIKE 'WAID:%'`, + [INBOX_ID] + ); + const existing = new Set(existRows.map(r => r.source_id)); + console.log(`Existing WAID source_ids: ${existing.size}`); + + // 4) stream messages, batch insert + const BATCH = 400; + let offset = 0, totalRead = 0, totalInserted = 0, totalSkipped = 0; + + while (true) { + const { rows: msgs } = await evo.query(` + SELECT key, message, "messageTimestamp" AS ts, "pushName" + FROM "Message" + WHERE "instanceId" = $1 + AND key->>'remoteJid' IS NOT NULL + AND key->>'id' IS NOT NULL + AND key->>'remoteJid' NOT LIKE '%@broadcast' + AND key->>'remoteJid' != 'status@broadcast' + ORDER BY "messageTimestamp" ASC + LIMIT $2 OFFSET $3 + `, [INSTANCE_ID, BATCH, offset]); + + if (msgs.length === 0) break; + totalRead += msgs.length; + + const params = []; + const valueClauses = []; + + for (const m of msgs) { + const jid = m.key.remoteJid; + const msgId = m.key.id; + const fromMe = !!m.key.fromMe; + const sourceId = `WAID:${msgId}`; + + if (existing.has(sourceId)) { totalSkipped++; continue; } + existing.add(sourceId); // protect against same-batch dupes + + const conversationId = conversationByJid.get(jid); + const contactId = contactByJid.get(jid); + if (!conversationId || !contactId) { totalSkipped++; continue; } + + const content = extractContent(m.message); + if (!content) { totalSkipped++; continue; } + + const messageType = fromMe ? 1 : 0; + const senderType = fromMe ? 'User' : 'Contact'; + const senderId = fromMe ? SENDER_USER : contactId; + const ts = parseInt(m.ts); + + const b = params.length; + params.push(content, ACCOUNT_ID, INBOX_ID, conversationId, messageType, senderType, senderId, sourceId, ts); + valueClauses.push( + `($${b+1}, $${b+1}, $${b+2}, $${b+3}, $${b+4}, $${b+5}, false, 0, $${b+6}, $${b+7}, $${b+8}, to_timestamp($${b+9}), to_timestamp($${b+9}))` + ); + } + + if (valueClauses.length) { + await cw.query(` + INSERT INTO messages + (content, processed_message_content, account_id, inbox_id, conversation_id, message_type, private, content_type, sender_type, sender_id, source_id, created_at, updated_at) + VALUES ${valueClauses.join(',')} + `, params); + totalInserted += valueClauses.length; + } + + offset += BATCH; + if (offset % 2000 === 0 || msgs.length < BATCH) { + console.log(` progress: read=${totalRead} inserted=${totalInserted} skipped=${totalSkipped}`); + } + } + + // 5) refresh conversation.last_activity_at + last message info + await cw.query(` + UPDATE conversations c + SET last_activity_at = sub.last_ts, + updated_at = NOW() + FROM (SELECT conversation_id, MAX(created_at) AS last_ts FROM messages WHERE inbox_id = $1 GROUP BY conversation_id) sub + WHERE c.id = sub.conversation_id + `, [INBOX_ID]); + + console.log(`\nDONE.`); + console.log(` read=${totalRead} inserted=${totalInserted} skipped=${totalSkipped}`); + + await evo.end(); + await cw.end(); +} + +main().catch(e => { console.error('FATAL', e); process.exit(1); }); diff --git a/chatwoot-compose.yml b/chatwoot-compose.yml new file mode 100644 index 0000000..22046dd --- /dev/null +++ b/chatwoot-compose.yml @@ -0,0 +1,151 @@ +services: + chatwoot-rails: + image: chatwoot/chatwoot:v4.14.1-ce + container_name: chatwoot-rails + restart: unless-stopped + depends_on: + chatwoot-pg: + condition: service_healthy + chatwoot-redis: + condition: service_started + networks: + - proxy + - chatwoot-net + volumes: + - chatwoot_storage:/app/storage + environment: + RAILS_ENV: production + NODE_ENV: production + INSTALLATION_ENV: docker + SECRET_KEY_BASE: 02a0b1e2ceedf70efd7358861749bc36ee9476da5d7dc3cbe4cd018cc7ca63158c45d612f557a093e2d8b21c5cbef917b2863896aee7e7ff8b214a29039893e8 + FRONTEND_URL: https://chatwoot.eleotherium.tech + DEFAULT_LOCALE: pt_BR + FORCE_SSL: 'true' + ENABLE_ACCOUNT_SIGNUP: 'true' + POSTGRES_HOST: chatwoot-pg + POSTGRES_PORT: '5432' + POSTGRES_DATABASE: chatwoot + POSTGRES_USERNAME: chatwoot + POSTGRES_PASSWORD=***[REDACTED]*** + REDIS_URL: redis://:oVnfsFGqHPqpLaU4GDOPzbHa124Iybo1@chatwoot-redis:6379 + REDIS_PASSWORD=***[REDACTED]*** + ACTIVE_STORAGE_SERVICE: local + RAILS_LOG_TO_STDOUT: 'true' + LOG_LEVEL: info + LOG_SIZE: '500' + entrypoint: docker/entrypoints/rails.sh + command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"] + labels: + - traefik.enable=true + - traefik.docker.network=proxy + - traefik.http.routers.chatwoot.rule=Host(`chatwoot.eleotherium.tech`) + - traefik.http.routers.chatwoot.entrypoints=websecure + - traefik.http.routers.chatwoot.tls.certresolver=letsencrypt + - traefik.http.services.chatwoot.loadbalancer.server.port=3000 + deploy: + resources: + limits: + cpus: '0.8' + memory: 1280M + reservations: + cpus: '0.1' + memory: 256M + + chatwoot-sidekiq: + image: chatwoot/chatwoot:v4.14.1-ce + container_name: chatwoot-sidekiq + restart: unless-stopped + depends_on: + chatwoot-pg: + condition: service_healthy + chatwoot-redis: + condition: service_started + chatwoot-rails: + condition: service_started + networks: + - chatwoot-net + volumes: + - chatwoot_storage:/app/storage + environment: + RAILS_ENV: production + NODE_ENV: production + INSTALLATION_ENV: docker + SECRET_KEY_BASE: 02a0b1e2ceedf70efd7358861749bc36ee9476da5d7dc3cbe4cd018cc7ca63158c45d612f557a093e2d8b21c5cbef917b2863896aee7e7ff8b214a29039893e8 + FRONTEND_URL: https://chatwoot.eleotherium.tech + DEFAULT_LOCALE: pt_BR + POSTGRES_HOST: chatwoot-pg + POSTGRES_PORT: '5432' + POSTGRES_DATABASE: chatwoot + POSTGRES_USERNAME: chatwoot + POSTGRES_PASSWORD=***[REDACTED]*** + REDIS_URL: redis://:oVnfsFGqHPqpLaU4GDOPzbHa124Iybo1@chatwoot-redis:6379 + REDIS_PASSWORD=***[REDACTED]*** + ACTIVE_STORAGE_SERVICE: local + RAILS_LOG_TO_STDOUT: 'true' + LOG_LEVEL: info + command: ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"] + deploy: + resources: + limits: + cpus: '0.5' + memory: 768M + reservations: + cpus: '0.05' + memory: 128M + + chatwoot-pg: + image: pgvector/pgvector:pg16 + container_name: chatwoot-pg + restart: unless-stopped + networks: + - chatwoot-net + volumes: + - chatwoot_pg:/var/lib/postgresql/data + environment: + POSTGRES_USER: chatwoot + POSTGRES_PASSWORD=***[REDACTED]*** + POSTGRES_DB: chatwoot + healthcheck: + test: ["CMD-SHELL", "pg_isready -U chatwoot -d chatwoot"] + interval: 10s + timeout: 5s + retries: 10 + deploy: + resources: + limits: + cpus: '0.4' + memory: 512M + reservations: + cpus: '0.05' + memory: 128M + + chatwoot-redis: + image: redis:7-alpine + container_name: chatwoot-redis + restart: unless-stopped + networks: + - chatwoot-net + volumes: + - chatwoot_redis:/data + environment: + REDIS_PASSWORD=***[REDACTED]*** + command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""] + deploy: + resources: + limits: + cpus: '0.1' + memory: 128M + reservations: + cpus: '0.02' + memory: 32M + +networks: + proxy: + external: true + chatwoot-net: + driver: bridge + +volumes: + chatwoot_storage: + chatwoot_pg: + chatwoot_redis: diff --git a/chatwoot-fix-names.js b/chatwoot-fix-names.js new file mode 100644 index 0000000..9f6c17e --- /dev/null +++ b/chatwoot-fix-names.js @@ -0,0 +1,59 @@ +// Fix contact names: groups use Chat.name; 1-on-1 use pushName from latest incoming message +const { Client } = require('pg'); + +const EVO_URL = 'postgresql://evolution:HCRpDOT4cjC38rimM71mSWCyHlc@evo-db:5432/evolutiondb'; +const CW_URL = 'postgresql://chatwoot:Kah1y2ThG7ZH2Xt3sxNc4gaZSCbZL5fY@chatwoot-pg:5432/chatwoot'; +const ACCOUNT_ID = 2; +const INSTANCE_ID = 'a8b74fa9-b017-495a-8510-18ce62b2b686'; + +async function main() { + const evo = new Client({ connectionString: EVO_URL }); + const cw = new Client({ connectionString: CW_URL }); + await evo.connect(); + await cw.connect(); + await evo.query("SET search_path TO evolution_api, public"); + + // 1) Group names from Chat table + const { rows: groups } = await evo.query( + `SELECT "remoteJid", name FROM "Chat" WHERE "remoteJid" LIKE '%@g.us' AND "instanceId" = $1 AND name IS NOT NULL`, + [INSTANCE_ID] + ); + let updGroups = 0; + for (const g of groups) { + if (!g.name || !g.name.trim()) continue; + const r = await cw.query( + `UPDATE contacts SET name = $1, updated_at = NOW() WHERE account_id = $2 AND identifier = $3`, + [`${g.name.trim()} (GROUP)`, ACCOUNT_ID, g.remoteJid] + ); + updGroups += r.rowCount; + } + console.log(`Groups renamed: ${updGroups}`); + + // 2) 1-on-1: pushName from latest incoming message + const { rows: persons } = await evo.query(` + SELECT DISTINCT ON (m.key->>'remoteJid') + m.key->>'remoteJid' AS jid, + m."pushName" AS push_name + FROM "Message" m + WHERE m."instanceId" = $1 + AND (m.key->>'fromMe')::boolean = false + AND m.key->>'remoteJid' LIKE '%@s.whatsapp.net' + AND m."pushName" IS NOT NULL + AND m."pushName" != '' + ORDER BY m.key->>'remoteJid', m."messageTimestamp" DESC + `, [INSTANCE_ID]); + let updPersons = 0; + for (const p of persons) { + if (!p.push_name || !p.push_name.trim()) continue; + const r = await cw.query( + `UPDATE contacts SET name = $1, updated_at = NOW() WHERE account_id = $2 AND identifier = $3`, + [p.push_name.trim(), ACCOUNT_ID, p.jid] + ); + updPersons += r.rowCount; + } + console.log(`Persons renamed: ${updPersons}`); + + await evo.end(); + await cw.end(); +} +main().catch(e => { console.error(e); process.exit(1); }); diff --git a/evolution-compose.yml b/evolution-compose.yml new file mode 100644 index 0000000..379f3b9 --- /dev/null +++ b/evolution-compose.yml @@ -0,0 +1,124 @@ +services: + evolution: + image: evoapicloud/evolution-api:v2.3.7 + container_name: evolution + restart: unless-stopped + networks: + - proxy + - evo-net + - chatwoot-net + environment: + SERVER_URL: https://evolution.eleotherium.tech + SERVER_PORT: "8080" + TELEMETRY_ENABLED: "false" + CORS_ORIGIN: "*" + CORS_METHODS: GET,POST,PUT,DELETE + CORS_CREDENTIALS: "true" + LOG_LEVEL: ERROR,WARN + LOG_COLOR: "true" + LOG_BAILEYS: error + DEL_INSTANCE: "false" + DATABASE_PROVIDER: postgresql + DATABASE_CONNECTION_URI: postgresql://evolution:HCRpDOT4cjC38rimM71mSWCyHlc@evo-db:5432/evolutiondb?schema=evolution_api + DATABASE_CONNECTION_CLIENT_NAME: evolution_segundo_cerebro + DATABASE_SAVE_DATA_INSTANCE: "true" + DATABASE_SAVE_DATA_NEW_MESSAGE: "true" + DATABASE_SAVE_MESSAGE_UPDATE: "true" + DATABASE_SAVE_DATA_CONTACTS: "true" + DATABASE_SAVE_DATA_CHATS: "true" + DATABASE_SAVE_DATA_LABELS: "true" + DATABASE_SAVE_DATA_HISTORIC: "true" + DATABASE_SAVE_IS_ON_WHATSAPP: "true" + DATABASE_SAVE_IS_ON_WHATSAPP_DAYS: "7" + DATABASE_DELETE_MESSAGE: "true" + CACHE_REDIS_ENABLED: "true" + CACHE_REDIS_URI: redis://evo-redis:6379/6 + CACHE_REDIS_PREFIX_KEY: evolution + CACHE_REDIS_TTL: "604800" + CACHE_REDIS_SAVE_INSTANCES: "false" + CACHE_LOCAL_ENABLED: "false" + WEBHOOK_GLOBAL_ENABLED: "false" + WEBHOOK_EVENTS_MESSAGES_UPSERT: "true" + WEBHOOK_EVENTS_MESSAGES_UPDATE: "true" + WEBHOOK_EVENTS_MESSAGES_DELETE: "true" + WEBHOOK_EVENTS_CONNECTION_UPDATE: "true" + WEBHOOK_EVENTS_QRCODE_UPDATED: "true" + WEBHOOK_REQUEST_TIMEOUT_MS: "60000" + WEBHOOK_RETRY_MAX_ATTEMPTS: "10" + AUTHENTICATION_API_KEY=***[REDACTED]*** + AUTHENTICATION_EXPOSE_IN_FETCH_INSTANCES: "true" + QRCODE_LIMIT: "30" + LANGUAGE: en + CHATWOOT_ENABLED: "true" + CHATWOOT_MESSAGE_READ: "true" + CHATWOOT_MESSAGE_DELETE: "true" + CHATWOOT_IMPORT_DATABASE_CONNECTION_URI: postgresql://chatwoot:Kah1y2ThG7ZH2Xt3sxNc4gaZSCbZL5fY@chatwoot-pg:5432/chatwoot?sslmode=disable + CHATWOOT_IMPORT_PLACEHOLDER_MEDIA_MESSAGE: "false" + volumes: + - evo_instances:/evolution/instances + labels: + - traefik.enable=true + - traefik.http.routers.evolution.rule=Host(`evolution.eleotherium.tech`) + - traefik.http.routers.evolution.entrypoints=websecure + - traefik.http.services.evolution.loadbalancer.server.port=8080 + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + reservations: + cpus: '0.1' + memory: 128M + + evo-db: + image: postgres:16-alpine + container_name: evo-db + restart: unless-stopped + networks: + - evo-net + environment: + POSTGRES_DB: evolutiondb + POSTGRES_USER: evolution + POSTGRES_PASSWORD=***[REDACTED]*** + volumes: + - evo_db:/var/lib/postgresql/data + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.05' + memory: 64M + + evo-redis: + image: redis:7-alpine + container_name: evo-redis + restart: unless-stopped + networks: + - evo-net + command: redis-server --appendonly yes + volumes: + - evo_redis:/data + deploy: + resources: + limits: + cpus: '0.2' + memory: 128M + reservations: + cpus: '0.05' + memory: 32M + +networks: + proxy: + external: true + evo-net: + driver: bridge + chatwoot-net: + external: true + name: chatwoot_chatwoot-net + +volumes: + evo_instances: + evo_db: + evo_redis:https://github.com/eleotherium/VPS.git -
17:45
e0f3422main implementa segundo-cerebro: coletores nativos + dashboard + buffer offline +3157 -204 · 37 arq..gitignoresegundo-cerebro/.env.examplesegundo-cerebro/CLAUDE.mdsegundo-cerebro/app/__init__.pysegundo-cerebro/app/buckets.pysegundo-cerebro/app/main.pysegundo-cerebro/app/static/style.csssegundo-cerebro/app/templates/ontime.html +29diff (50050 chars)
commit e0f3422c8b67d81bd9bb9dd12326be6e72ddcb0c Author: Gabriel Eleotério <gabrieleleoterioptc@gmail.com> Date: Sat Jun 6 14:45:32 2026 -0300 implementa segundo-cerebro: coletores nativos + dashboard + buffer offline - coletores Windows nativos (keylogger, screenshot+OCR, clipboard, janelas) unificados em Session dataclass; detecta troca de aba por mudança de título - controller HTTP (localhost:8766) com start/stop/restart + telemetria de uploads e fila offline; drainer em thread escoa a fila quando volta rede - buffer offline SQLite (data/queue.sqlite): events/screenshots/transcripts são enfileirados em falha de rede (timeout, 5xx, DNS, SSL) e re-enviados no próximo ciclo do drainer - pipeline noturno 22h: pull áudios → Whisper transcribe → Claude summarize → retention (7 dias raw, summaries permanentes); semanal sexta 20h - dashboard FastAPI (app/) em dois modos: local (este device, hoje) e global (qualquer device, qualquer data via querystring). cards "Hoje" mostram atenção (excluindo LockApp/Unknown), janelas, digitado, prints, whatsapp, áudios; top apps por tempo; resumos recentes (últimos 7 dias); timeline em buckets configuráveis 15min..24h, latest-first - multi-device via DEVICE_ID; bucket screenshots prefixa por device - replicate/ com scripts PowerShell (install/start/stop/status/uninstall) pra rodar o sistema em qualquer Windows com Docker Desktop em 1 comando - schemas Supabase: raw_events com device_id, audio_transcripts, daily_summaries, weekly_summaries + view whatsapp_messages via FDW pra evo-db (acesso transparente às mensagens Evolution) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be2d136 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# segredos +.env +**/.env +!.env.example +!**/.env.example + +# python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# coletor — dados locais não vão pro git +segundo-cerebro/data/ + +# kong backups +*.bak-* + +# editor +.vscode/ +.idea/ +*.swp diff --git a/segundo-cerebro/.env.example b/segundo-cerebro/.env.example index ade6b86..3676566 100644 --- a/segundo-cerebro/.env.example +++ b/segundo-cerebro/.env.example @@ -1,4 +1,12 @@ +# Identificador desta máquina (sobrescreve hostname). Ex.: notebook, desktop, vps +DEVICE_ID=notebook + +# Supabase (VPS já provisionado) SUPABASE_URL=https://supabase.eleotherium.tech -SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here -ANTHROPIC_API_KEY=***[REDACTED]*** +SUPABASE_SERVICE_ROLE_KEY= + +# Claude (https://console.anthropic.com/settings/keys) +ANTHROPIC_API_KEY=***[REDACTED]*** +# Evolution API (WhatsApp) EVOLUTION_URL=https://evolution.eleotherium.tech @@ -6,4 +14,5 @@ EVOLUTION_API_KEY=***[REDACTED]*** EVOLUTION_INSTANCE=gab -VPS_HOST=2.25.174.226 -VPS_USER=root -VPS_SSH_KEY=~/.ssh/vps_segundo_cerebro + +# Schedule do pipeline noturno (modo local apenas) +PIPELINE_HOUR=22 +PIPELINE_MINUTE=0 diff --git a/segundo-cerebro/CLAUDE.md b/segundo-cerebro/CLAUDE.md index 5b26d49..03c507a 100644 --- a/segundo-cerebro/CLAUDE.md +++ b/segundo-cerebro/CLAUDE.md @@ -2,3 +2,4 @@ -Projeto de captura passiva de contexto diário. Registra o que foi digitado, visto na tela e dito no WhatsApp, e sumariza tudo às 22h via Claude API. +Captura passiva de contexto diário (keystrokes, OCR, clipboard, janela, WhatsApp) com +sumarização noturna pelo Claude. Multi-device. @@ -9,21 +10,30 @@ Projeto de captura passiva de contexto diário. Registra o que foi digitado, vis ``` -[Windows local] [VPS — Supabase] -keylogger ──── sanitizer ──────────────► tabela: raw_events -ocr / screenshots ─────────────────────► bucket: screenshots -window_monitor ── sanitizer ───────────► tabela: raw_events - -[VPS — Evolution API] -WhatsApp msgs/áudios ──────────────────► tabela: evolution_api.Message - arquivos: /data/audio/*.ogg - -[22h — Windows local] - ├── puxa .ogg da VPS via SSH/SCP - ├── roda Whisper medium (local, CPU) - ├── push transcrições ─────────────────► tabela: audio_transcripts - └── Claude API - lê raw_events + audio_transcripts + WhatsApp msgs do Supabase - ─────────────────────────────────► tabela: daily_summaries +┌─────────────────────────── WINDOWS HOST ───────────────────────────┐ +│ │ +│ main.py (controller HTTP nativo, Task Scheduler @ login) │ +│ └─ FastAPI em http://localhost:8766 │ +│ /status /start /stop /restart /metrics │ +│ └─ quando ativo: pynput / mss / pywin32 / win32clipboard │ +│ └─ sanitize → Supabase (raw_events, bucket screenshots) │ +│ │ +│ ┌─── Docker Desktop (WSL2) ────────────────────────┐ │ +│ │ segundo-cerebro:local (deploy/Dockerfile) │ │ +│ │ - FastAPI dash → http://localhost:8765 │ │ +│ │ - APScheduler 22h: │ │ +│ │ audio_puller → transcriber → summarizer │ │ +│ └──────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────────┘ + │ + ▼ (Supabase REST + FDW evolution_api) +┌──────────────────────────────── VPS ──────────────────────────────┐ +│ supabase-db ◀── postgres_fdw ──▶ evo-db (evolution_api.Message)│ +│ │ +│ Docker stack "segundo-cerebro": │ +│ - FastAPI dash GLOBAL → segundo-cerebro.eleotherium.tech │ +│ (Traefik + Let's Encrypt; PIPELINE_ENABLED=0) │ +└───────────────────────────────────────────────────────────────────┘ ``` -**Regra:** dados brutos (keystrokes, OCR) passam pelo sanitizer *antes* de sair da máquina. Nunca subir texto não-sanitizado para o Supabase. +**Coletores rodam nativos** (precisam de APIs Windows que container não vê). +**Dash + pipeline rodam em Docker** (isolamento, reprodutibilidade). @@ -31,3 +41,3 @@ WhatsApp msgs/áudios ──────────────────► -## Estrutura de pastas +## Estrutura @@ -35,24 +45,33 @@ WhatsApp msgs/áudios ──────────────────► segundo-cerebro/ -├── collectors/ -│ ├── window_monitor.py # SetWinEventHook — janela ativa + título + processo -│ ├── keylogger.py # pynput — agrupa keystrokes por sessão de janela -│ ├── ocr.py # mss + pytesseract — screenshot na troca de janela -│ └── clipboard_monitor.py # win32clipboard — mudanças em background thread -├── pipeline/ -│ ├── audio_puller.py # SSH/SCP — baixa .ogg pendentes da VPS -│ ├── transcriber.py # faster-whisper medium — processa áudios localmente -│ ├── summarizer.py # Claude API — sumariza eventos do dia -│ └── scheduler.py # APScheduler — dispara pipeline às 22h +├── main.py # controller HTTP nativo (localhost:8766) +├── collectors/ # importado por main.py +│ ├── window_monitor.py # polling 1s, fecha sessão na troca de janela +│ ├── keylogger.py # pynput, buffer thread-safe +│ ├── ocr.py # mss + pytesseract (pt-BR) +│ └── clipboard_monitor.py # win32clipboard +├── pipeline/ # importado pelo container +│ ├── audio_puller.py # Evolution REST → /chat/getBase64FromMediaMessage +│ ├── transcriber.py # faster-whisper medium (CPU, int8) +│ ├── summarizer.py # claude-sonnet-4-6 +│ └── scheduler.py # APScheduler 22h +├── app/ # FastAPI dashboard (container) +│ ├── main.py # rotas / /api/* /healthz +│ ├── templates/ontime.html +│ └── static/style.css ├── storage/ -│ └── uploader.py # push de eventos para Supabase (tabelas + bucket) +│ ├── uploader.py # cliente Supabase (compartilhado) +│ ├── schema.sql # tabelas + view whatsapp_messages +│ ├── schema_fdw.sql # postgres_fdw evo-db +│ └── schema_devices.sql # device_id em raw_events / daily_summaries ├── utils/ -│ └── sanitizer.py # regex — mascara senhas, tokens, cartões antes do upload -├── config/ -│ └── tracked_contacts.json # JIDs do WhatsApp monitorados (grupos + contatos) -├── data/ -│ └── audio/ # .ogg temporários baixados da VPS (apagados após transcrição) -├── tasks.md # histórico de tarefas e mudanças -├── CLAUDE.md # este arquivo -├── requirements.txt -└── main.py # entrypoint — sobe coletores + scheduler +│ ├── sanitizer.py # regex: senha, token, JWT, cartão, CPF, CNPJ +│ └── device.py # get_device_id() → env DEVICE_ID || hostname +├── deploy/ +│ ├── Dockerfile +│ ├── requirements.app.txt +│ ├── docker-compose.local.yml # Windows: dash + pipeline (localhost:8765) +│ └── docker-compose.vps.yml # VPS: dash global (Traefik) +├── config/tracked_contacts.json +├── requirements.txt # apenas o coletor nativo +└── .env # ver .env.example ``` @@ -63,11 +82,13 @@ segundo-cerebro/ -| Tabela / Bucket | Conteúdo | +| Objeto | Conteúdo | |---|---| -| `raw_events` | keystrokes, OCR, clipboard, trocas de janela — sanitizados | -| `audio_transcripts` | transcrições dos áudios WhatsApp geradas pelo Whisper | -| `daily_summaries` | resumo diário gerado pelo Claude | -| `evolution_api.Message` | mensagens WhatsApp (gerenciado pela Evolution API) | -| bucket `screenshots` | prints de troca de janela | +| `raw_events` | keystrokes, OCR, clipboard, sessões de janela. `device_id` obrigatório. | +| `audio_transcripts` | transcrições Whisper dos áudios WhatsApp (status: pending/done/error) | +| `daily_summaries` | resumo por `(date, device_id)`. `device_id='all'` agrega todos os devices. | +| view `public.whatsapp_messages` | FDW para `evo-db.evolution_api.Message` (PostgREST não expõe schemas externos) | +| bucket `screenshots` | path `<device_id>/<YYYY-MM-DD>/<HHhMM>_<app>.png` | -Conexão: usar `SUPABASE_URL` e `SUPABASE_SERVICE_ROLE_KEY` do `.env`. +Migrations em `storage/*.sql`. Aplicar como `supabase_admin` no container `supabase-db`. + +`supabase-db` está em duas redes: `supabase_supa-net` (própria) + `evolution_evo-net` (para o FDW alcançar `evo-db`). @@ -75,7 +96,15 @@ Conexão: usar `SUPABASE_URL` e `SUPABASE_SERVICE_ROLE_KEY` do `.env`. -## WhatsApp — contatos monitorados +## Modos do app + +`APP_MODE` define o comportamento do FastAPI: -Definidos em `config/tracked_contacts.json`. O filtro é aplicado no pipeline das 22h ao ler `evolution_api.Message` — só processa mensagens cujo `remoteJid` está na lista. +- `local` (Windows Docker, default): dashboard sempre filtrado por `DEVICE_ID` atual + dia de hoje. Sobe o APScheduler do pipeline noturno. +- `global` (VPS Docker): aceita `?d=YYYY-MM-DD&device=<id>`. Lista todos os devices ativos no dia. **Não roda pipeline.** -Whisper transcribe áudios (`messageType = audioMessage`) dos JIDs rastreados. +Rotas: +- `GET /` — dashboard HTML +- `GET /api/ontime` — JSON do dia +- `GET /api/devices?d=...` — devices ativos numa data +- `POST /api/pipeline/run` — só no modo `local`, dispara o pipeline na hora +- `GET /healthz` @@ -83,36 +112,42 @@ Whisper transcribe áudios (`messageType = audioMessage`) dos JIDs rastreados. -## Stack +## Variáveis de ambiente -| Função | Biblioteca | -|---|---| -| Detectar troca de janela | `pywin32` — `SetWinEventHook` | -| Keylogger global | `pynput` | -| Screenshot | `mss` | -| OCR | `pytesseract` + Tesseract (pt-BR) | -| Clipboard | `win32clipboard` | -| Transcrição de áudio | `faster-whisper` modelo `medium` | -| Scheduler | `APScheduler` | -| Upload Supabase | `supabase-py` | -| Download áudios VPS | `paramiko` (SSH/SCP) | -| Sumarização | `anthropic` SDK | - -Tesseract instalado separadamente. Configurar no `ocr.py`: -```python -pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" +``` +DEVICE_ID=notebook # ou desktop, vps, etc. fallback: hostname +SUPABASE_URL=https://supabase.eleotherium.tech +SUPABASE_SERVICE_ROLE_KEY=... +ANTHROPIC_API_KEY=***[REDACTED]*** +EVOLUTION_URL=https://evolution.eleotherium.tech +EVOLUTION_API_KEY=***[REDACTED]*** +EVOLUTION_INSTANCE=gab +PIPELINE_HOUR=22 +PIPELINE_MINUTE=0 +APP_MODE=local|global # setado pelo compose +PIPELINE_ENABLED=1|0 # 0 no VPS ``` +Anthropic key: https://console.anthropic.com/settings/keys + --- -## Variáveis de ambiente (.env) +## Subindo +### Coletor nativo (Windows host) +```powershell +pip install -r requirements.txt +# Tesseract pt-BR: https://github.com/UB-Mannheim/tesseract/wiki +python main.py ``` -SUPABASE_URL=https://supabase.eleotherium.tech -SUPABASE_SERVICE_ROLE_KEY=... -ANTHROPIC_API_KEY=***[REDACTED]*** -VPS_HOST=2.25.174.226 -VPS_USER=root -VPS_SSH_KEY=~/.ssh/vps_segundo_cerebro -EVOLUTION_AUDIO_PATH=/opt/portainer/data/compose/3/instances/gab/store/media +Registrar no Task Scheduler para startup. + +### Container local (Windows + Docker Desktop) +```powershell +docker compose -f deploy/docker-compose.local.yml up -d --build +# http://localhost:8765 ``` +### Container VPS +Subir como stack no Portainer (`deploy/docker-compose.vps.yml`). +Antes: criar DNS `segundo-cerebro.eleotherium.tech → 2.25.174.226` via Hostinger API. + --- @@ -121,5 +156,5 @@ EVOLUTION_AUDIO_PATH=/opt/portainer/data/compose/3/instances/gab/store/media -- Coletores rodam no **Windows nativo** — sem WSL. -- Storage é **append-only** no Supabase — nunca sobrescreve eventos brutos. -- Áudios `.ogg` são temporários: baixados, transcritos e apagados localmente. -- `main.py` deve rodar na inicialização do Windows via Task Scheduler. +- **Sanitizer roda antes de qualquer upload** — keystrokes saem da máquina, dados sensíveis não podem chegar ao Supabase. +- Áudios `.ogg` são **temporários** dentro do container (apagados após transcrição). +- Pipeline noturno só dispara se o container `local` estiver ligado às 22h — para garantir, deixar Docker Desktop iniciando com o Windows. +- O modo `global` (VPS) é **read-only sobre Supabase** — não escreve nada. diff --git a/segundo-cerebro/app/__init__.py b/segundo-cerebro/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/segundo-cerebro/app/buckets.py b/segundo-cerebro/app/buckets.py new file mode 100644 index 0000000..8d6ec2a --- /dev/null +++ b/segundo-cerebro/app/buckets.py @@ -0,0 +1,349 @@ +"""Agrupa raw_events em buckets de 15min e produz agregados pra timeline.""" +from __future__ import annotations + +import os +from collections import Counter +from datetime import datetime, timedelta, time, timezone +from typing import Iterable +from zoneinfo import ZoneInfo + +DEFAULT_BUCKET_MIN = 15 +ALLOWED_BUCKET_MINS = [15, 30, 45, 60, 180, 360, 720, 1440] +LOCAL_TZ = ZoneInfo(os.environ.get("TZ", "America/Maceio")) + +# Mapa exe → nome amigável (lookup case-insensitive). +APP_PRETTY = { + "chrome.exe": "Chrome", + "msedge.exe": "Edge", + "firefox.exe": "Firefox", + "code.exe": "VSCode", + "cursor.exe": "Cursor", + "windowsterminal.exe": "Windows Terminal", + "wt.exe": "Windows Terminal", + "cmd.exe": "CMD", + "powershell.exe": "PowerShell", + "pwsh.exe": "PowerShell", + "explorer.exe": "Explorador", + "discord.exe": "Discord", + "whatsapp.exe": "WhatsApp", + "slack.exe": "Slack", + "spotify.exe": "Spotify", + "notion.exe": "Notion", + "obsidian.exe": "Obsidian", + "telegram.exe": "Telegram", + "zoom.exe": "Zoom", + "teams.exe": "Teams", + "ms-teams.exe": "Teams", + "python.exe": "Python", + "pythonw.exe": "Python", + "docker desktop.exe": "Docker Desktop", + "applicationframehost.exe": "Win Store App", +} + + +def _pretty_app(name: str | None) -> str: + if not name: + return "?" + key = name.lower() + if key in APP_PRETTY: + return APP_PRETTY[key] + base = name.rsplit(".", 1)[0] + return base[:1].upper() + base[1:] if base else name + + +# Apps que representam tela bloqueada / processo idle — não contam como atenção. +IDLE_APPS = {"unknown", "unknown.exe", "lockapp", "lockapp.exe", "?", ""} + + +def _is_idle_app(name: str | None) -> bool: + return (name or "").lower() in IDLE_APPS + + +def compute_day_stats(events: list[dict], wa_msgs: list[dict] | None, transcripts: list[dict] | None) -> dict: + """Agregados do dia inteiro pra exibição em cards na home. + + Filtra `IDLE_APPS` (LockApp, Unknown) do tempo ativo — assim "atenção" reflete + tempo real diante de uma janela conhecida. O tempo ignorado vira `idle_sec`. + + Retorna: + attention_sec — soma de duration_sec apenas de apps ativos + idle_sec — soma de duration_sec de IDLE_APPS (tela bloqueada etc.) + sessions — quantidade total de window_session + unique_apps — apps distintos (ativos) com janela + top_apps — [(app_pretty, total_sec), ...] top 5 ativos por tempo + screenshots — eventos que produziram print + keystrokes_chars — total de chars digitados + clipboards — eventos com clipboard preenchido + wa_count — mensagens WhatsApp do dia + wa_chats — chats distintos contemplados + transcripts — áudios transcritos + """ + wa_msgs = wa_msgs or [] + transcripts = transcripts or [] + window_evs = [e for e in events if e.get("type") == "window_session"] + active_evs = [e for e in window_evs if not _is_idle_app(e.get("app"))] + + attention = sum((e.get("duration_sec") or 0) for e in active_evs) + idle = sum((e.get("duration_sec") or 0) for e in window_evs if _is_idle_app(e.get("app"))) + + by_app: Counter[str] = Counter() + for e in active_evs: + by_app[e.get("app") or "?"] += int(e.get("duration_sec") or 0) + top_apps_raw = by_app.most_common(5) + top_apps = [(_pretty_app(a), sec) for a, sec in top_apps_raw] + + unique_apps = len({e.get("app") for e in active_evs if e.get("app")}) + screenshots = sum(1 for e in window_evs if e.get("screenshot_path") or e.get("screenshot_url")) + keystrokes_chars = sum(len(e.get("keystrokes") or "") for e in events) + clipboards = sum(1 for e in events if e.get("clipboard")) + wa_chats = len({m.get("remote_jid") for m in wa_msgs if m.get("remote_jid")}) + + return { + "attention_sec": int(attention), + "idle_sec": int(idle), + "sessions": len(window_evs), + "unique_apps": unique_apps, + "top_apps": top_apps, + "screenshots": screenshots, + "keystrokes_chars": keystrokes_chars, + "clipboards": clipboards, + "wa_count": len(wa_msgs), + "wa_chats": wa_chats, + "transcripts": len(transcripts), + } + + +def _parse_ts(ts: str | datetime) -> datetime: + if isinstance(ts, datetime): + return ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc) + # Supabase devolve ISO com offset + return datetime.fromisoformat(ts.replace("Z", "+00:00")) + + +def _bucket_key(dt: datetime, bucket_min: int) -> datetime: + """Arredonda pra início do bucket no fuso local. Suporta qualquer valor em min.""" + local = dt.astimezone(LOCAL_TZ) + total = local.hour * 60 + local.minute + floored = (total // bucket_min) * bucket_min + return local.replace(hour=floored // 60, minute=floored % 60, second=0, microsecond=0) + + +def _label(dt: datetime) -> str: + """HH:MM — só mostra MM se não for 00 (pra janelas de hora cheia).""" + return dt.strftime("%H:%M") + + +def _short_text(s: str | None, limit: int) -> str: + if not s: + return "" + s = s.strip() + return (s[:limit] + "…") if len(s) > limit else s + + +def build_buckets( + events: list[dict], + date_iso: str, + wa_msgs: list[dict] | None = None, + tracked: dict[str, str] | None = None, + bucket_min: int = DEFAULT_BUCKET_MIN, +) -> list[dict]: + """Buckets de 15 min combinando raw_events + mensagens WhatsApp. + + Cada bucket tem: + - start, end datetime local + - label_start, label_end HH:MM + - events raw_events do bucket + - apps [(app, count), ...] top 5 + - top_app str + - titles [(window_title, count), ...] top 3 + - keystrokes str (join) + - keystrokes_preview str (preview curto) + - ocr_text str (join, truncado) + - clipboards list[str] + - screenshots [{url, title, ts}, ...] + - wa_msgs [{name, from_me, push_name, type, text, ts_hm}] + - duration_sec soma + - density idle/low/mid/high + - has_gap_before bool + - gap_minutes int + """ + wa_msgs = wa_msgs or [] + tracked = tracked or {} + + grouped: dict[datetime, list[dict]] = {} + for e in events: + ts = _parse_ts(e["ts_start"]) + grouped.setdefault(_bucket_key(ts, bucket_min), []).append(e) + + wa_grouped: dict[datetime, list[dict]] = {} + for m in wa_msgs: + ts_str = m.get("ts") + if not ts_str: + continue + try: + ts = _parse_ts(ts_str) + except Exception: + continue + wa_grouped.setdefault(_bucket_key(ts, bucket_min), []).append(m) + + all_keys = set(grouped) | set(wa_grouped) + if not all_keys: + return [] + + day = datetime.strptime(date_iso, "%Y-%m-%d").date() + day_start = datetime.combine(day, time.min, tzinfo=LOCAL_TZ) + day_end = datetime.combine(day, time.max, tzinfo=LOCAL_TZ) + + def _norm_event(e: dict) -> dict: + """Garante ts_hm local + ts_start/ts_end como datetime + app_pretty.""" + try: + ts_s = _parse_ts(e["ts_start"]) + e["_ts_start"] = ts_s + e["ts_hm"] = ts_s.astimezone(LOCAL_TZ).strftime("%H:%M") + except Exception: + e["_ts_start"] = None + e["ts_hm"] = "" + try: + e["_ts_end"] = _parse_ts(e["ts_end"]) if e.get("ts_end") else e["_ts_start"] + except Exception: + e["_ts_end"] = e["_ts_start"] + e["app_pretty"] = _pretty_app(e.get("app")) + return e + + def _msg_chats(messages: list[dict]) -> list[dict]: + """Agrupa lista de wa_msgs (já normalizadas) por jid.""" + if not messages: + return [] + by_jid: dict[str, list[dict]] = {} + for m in messages: + by_jid.setdefault(m["_jid"], []).append(m) + chats = [] + for jid, msgs in sorted(by_jid.items(), key=lambda kv: -len(kv[1])): + senders = Counter("eu" if m["from_me"] else (m["push_name"] or "?") for m in msgs) + chats.append({ + "jid": jid, + "name": tracked.get(jid, jid), + "count": len(msgs), + "senders": senders.most_common(), + "first_hm": msgs[0]["ts_hm"], + "last_hm": msgs[-1]["ts_hm"], + "messages": msgs, + }) + return chats + + out: list[dict] = [] + prev_end: datetime | None = None + keys = sorted(all_keys) + + for k in keys: + if k < day_start or k > day_end: + continue + # Cronológico ascendente para matching com wa_msgs; invertido no fim pra latest-first na UI. + evs = [_norm_event(e) for e in sorted(grouped.get(k, []), key=lambda e: e["ts_start"])] + wams_raw = sorted(wa_grouped.get(k, []), key=lambda m: m.get("ts") or "") + end = k + timedelta(minutes=bucket_min) + + # Normaliza wa_msgs com ts já parseada + wams: list[dict] = [] + for m in wams_raw: + try: + ts = _parse_ts(m["ts"]) + except Exception: + continue + wams.append({ + "_jid": m.get("remote_jid") or "", + "_ts": ts, + "from_me": bool(m.get("from_me")), + "push_name": m.get("push_name") or "", + "type": m.get("message_type") or "", + "text": _short_text(m.get("text") or "", 600), + "ts_hm": ts.astimezone(LOCAL_TZ).strftime("%H:%M"), + }) + + # Anexa cada msg à window_session cujo [ts_start, ts_end] contém o ts. + orphan_wams: list[dict] = [] + for e in evs: + e["wa_msgs"] = [] + e["wa_chats"] = [] + for m in wams: + host = None + for e in evs: + if e.get("type") != "window_session": + continue + s, en = e.get("_ts_start"), e.get("_ts_end") + if s and en and s <= m["_ts"] <= en: + host = e + break + if host: + host["wa_msgs"].append(m) + else: + orphan_wams.append(m) + for e in evs: + e["wa_chats"] = _msg_chats(e["wa_msgs"]) + e["wa_count"] = len(e["wa_msgs"]) + + # Latest-first dentro do bucket: eventos do PC e mensagens em ordem decrescente. + evs.reverse() + orphan_wams.reverse() + + apps = Counter(e.get("app") or "?" for e in evs).most_common(5) + titles = Counter( + (e.get("window_title") or "").strip() + for e in evs if e.get("window_title") + ).most_common(3) + + keystrokes_parts = [e["keystrokes"] for e in evs if e.get("keystrokes")] + keystrokes = " | ".join(keystrokes_parts) + + ocr_parts = [e["ocr_text"] for e in evs if e.get("ocr_text")] + ocr_text = "\n".join(ocr_parts)[:6000] + + clips = [e["clipboard"] for e in evs if e.get("clipboard")] + + shots = [ + { + "url": e.get("screenshot_url"), + "title": (e.get("window_title") or e.get("app") or "")[:80], + "ts": e["ts_start"], + } + for e in evs if e.get("screenshot_url") + ] + + duration = sum((e.get("duration_sec") or 0) for e in evs) + n_evs = len(evs) + n_wa = len(wams) + n_total = n_evs + n_wa + density = "high" if n_total >= 15 else ("mid" if n_total >= 6 else ("low" if n_total >= 2 else "idle")) + + # Chats no nível do bucket = só os órfãos (msgs cuja hora não caiu em nenhuma janela). + wa_chats = _msg_chats(orphan_wams) + + has_gap = prev_end is not None and (k - prev_end) > timedelta(hours=1) + + out.append({ + "start": k, + "end": end, + "label_start": k.strftime("%H:%M"), + "label_end": end.strftime("%H:%M"), + "events": evs, + "events_count": n_evs, + "apps": apps, + "top_app": _pretty_app(apps[0][0]) if apps else ("WhatsApp" if wa_chats else "?"), + "titles": titles, + "keystrokes": keystrokes, + "keystrokes_preview": _short_text(keystrokes, 220), + "ocr_text": ocr_text, + "clipboards": clips, + "screenshots": shots, + "wa_chats": wa_chats, + "wa_count": n_wa, + "wa_orphan_count": len(orphan_wams), + "wa_chats_count": len(wa_chats), + "duration_sec": duration, + "density": density, + "has_gap_before": has_gap, + "gap_minutes": int((k - prev_end).total_seconds() // 60) if has_gap else 0, + }) + prev_end = end + + return out diff --git a/segundo-cerebro/app/main.py b/segundo-cerebro/app/main.py new file mode 100644 index 0000000..a91c260 --- /dev/null +++ b/segundo-cerebro/app/main.py @@ -0,0 +1,174 @@ +"""FastAPI app — dashboard + entrypoint do container. + +Dois modos: + APP_MODE=local (Docker no Windows): + - filtra dashboard por DEVICE_ID atual + dia de hoje + - sobe scheduler do pipeline noturno (Whisper, summarizer) + APP_MODE=global (Docker na VPS): + - dashboard mostra todos os devices + qualquer data via querystring + - scheduler desligado (pipeline já roda no local) +""" +import os +from datetime import date, datetime, timezone + +from dotenv import load_dotenv + +load_dotenv() + +from fastapi import FastAPI, Query, Request +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from utils.device import get_device_id +from storage.uploader import ( + fetch_day_events, + fetch_day_transcripts, + fetch_day_whatsapp_messages, + fetch_devices_active_on, + fetch_recent_daily_summaries, + fetch_summary, + signed_screenshot_url, +) +from app.buckets import build_buckets, compute_day_stats, ALLOWED_BUCKET_MINS, DEFAULT_BUCKET_MIN + +APP_MODE = os.environ.get("APP_MODE", "local").lower() +THIS_DEVICE = get_device_id() + +app = FastAPI(title=f"segundo-cerebro · {APP_MODE}") + +_here = os.path.dirname(__file__) +templates = Jinja2Templates(directory=os.path.join(_here, "templates")) +app.mount("/static", StaticFiles(directory=os.path.join(_here, "static")), name="static") + + +def _resolve_scope(request_device: str | None, request_date: str | None) -> tuple[str, str | None]: + """Retorna (date_iso, device_filter). No modo local, força hoje + este device.""" + if APP_MODE == "local": + return date.today().isoformat(), THIS_DEVICE + d = request_date or date.today().isoformat() + return d, request_device # None = todos os devices + + +@app.on_event("startup") +def _on_startup(): + if APP_MODE == "local" and os.environ.get("PIPELINE_ENABLED", "1") == "1": + from pipeline.scheduler import start as start_scheduler + app.state.scheduler = start_scheduler() + print(f"[app] mode={APP_MODE} device={THIS_DEVICE}") + + +@app.get("/", response_class=HTMLResponse) +def root( + request: Request, + device: str | None = Query(None), + d: str | None = Query(None), + bucket: int | None = Query(None), +): + target_date, device_filter = _resolve_scope(device, d) + bucket_min = bucket if bucket in ALLOWED_BUCKET_MINS else DEFAULT_BUCKET_MIN + devices = fetch_devices_active_on(target_date) if APP_MODE == "global" else [THIS_DEVICE] + + events = fetch_day_events(target_date, device_id=device_filter) + transcripts = fetch_day_transcripts(target_date) + + # WhatsApp: mesmo no local mostramos (não é por device, é do usuário). + import json + cfg_path = os.path.join(os.path.dirname(__file__), "..", "config", "tracked_contacts.json") + with open(cfg_path) as f: + cfg = json.load(f) + tracked = {c["jid"]: c["name"] for c in cfg["groups"] + cfg["contacts"]} + wa_msgs = fetch_day_whatsapp_messages(target_date, list(tracked)) + + enriched_events = [] + for e in events: + path = e.get("screenshot_path") + e["screenshot_url"] = signed_screenshot_url(path) if path else None + enriched_events.append(e) + + buckets = build_buckets( + enriched_events, target_date, + wa_msgs=wa_msgs, tracked=tracked, bucket_min=bucket_min, + ) + # Latest-first: bucket mais recente no topo da timeline. + buckets = list(reversed(buckets)) + + day_stats = compute_day_stats(enriched_events, wa_msgs, transcripts) + + # Resumos recentes: até 7 últimos, sempre EXCLUINDO hoje (o de hoje só sai depois das 22h). + recent_summary_device = device_filter or THIS_DEVICE + recent_summaries = [ + s for s in fetch_recent_daily_summaries(days=8, device_id=recent_summary_device) + if s.get("date") != target_date + ][-7:] + recent_summaries.reverse() # mais recente primeiro + + return templates.TemplateResponse(request, "ontime.html", { + "mode": APP_MODE, + "date": target_date, + "device_filter": device_filter, + "this_device": THIS_DEVICE, + "devices": devices, + "events": enriched_events, + "buckets": buckets, + "bucket_min": bucket_min, + "bucket_options": ALLOWED_BUCKET_MINS, + "transcripts": transcripts, + "wa_msgs": wa_msgs, + "tracked": tracked, + "stats": day_stats, + "recent_summaries": recent_summaries, + }) + + +@app.get("/api/ontime") +def api_ontime(device: str | None = Query(None), d: str | None = Query(None)): + target_date, device_filter = _resolve_scope(device, d) + events = fetch_day_events(target_date, device_id=device_filter) + transcripts = fetch_day_transcripts(target_date) + return { + "mode": APP_MODE, + "date": target_date, + "device": device_filter, + "summary": fetch_summary(target_date, device_id=(device_filter or "all")), + "stats": compute_day_stats(events, None, transcripts), + "events_count": len(events), + "transcripts_count": len(transcripts), + } + + +@app.get("/api/devices") +def api_devices(d: str | None = Query(None)): + target_date = d or date.today().isoformat() + return {"date": target_date, "devices": fetch_devices_active_on(target_date)} + + +@app.post("/api/pipeline/run") +def trigger_pipeline(): + if APP_MODE != "local": + return JSONResponse({"error": "pipeline só roda no modo local"}, status_code=400) + from pipeline.scheduler import run_pipeline + run_pipeline() + return {"ok": True} + + +@app.post("/api/weekly/run") +def trigger_weekly(): + if APP_MODE != "local": + return JSONResponse({"error": "weekly só roda no modo local"}, status_code=400) + from pipeline.scheduler import run_weekly + run_weekly() + return {"ok": True} + + +@app.post("/api/retention/run") +def trigger_retention(): + if APP_MODE != "local": + return JSONResponse({"error": "retention só roda no modo local"}, status_code=400) + from pipeline.retention import run as retention_run + return {"ok": True, "removed": retention_run()} + + +@app.get("/healthz") +def healthz(): + return {"ok": True, "mode": APP_MODE, "device": THIS_DEVICE, "ts": datetime.now(timezone.utc).isoformat()} diff --git a/segundo-cerebro/app/static/style.css b/segundo-cerebro/app/static/style.css new file mode 100644 index 0000000..0168539 --- /dev/null +++ b/segundo-cerebro/app/static/style.css @@ -0,0 +1 @@ +/* Tailwind via CDN cobre o restante. Mantemos só hooks pequenos opcionais aqui. */ diff --git a/segundo-cerebro/app/templates/ontime.html b/segundo-cerebro/app/templates/ontime.html new file mode 100644 index 0000000..88613cd --- /dev/null +++ b/segundo-cerebro/app/templates/ontime.html @@ -0,0 +1,596 @@ +{# ontime.html — dashboard local (e global no futuro). + Layout principal: + 1. Header compacto com pills do coletor / fila offline. + 2. "Hoje" — stats agregadas + top apps por tempo (sensação de inteligência absorvida). + 3. Resumos recentes — últimos N daily_summaries (exclui hoje, que só fecha às 22h). + 4. Linha do tempo — buckets configuráveis (mesma estrutura, header limpo). + 5. Áudios WhatsApp. +#} +{%- macro fmt_dur(sec) -%} + {%- if sec >= 3600 -%}{{ (sec // 3600) }}h{{ '%02d' % ((sec % 3600) // 60) }} + {%- elif sec >= 60 -%}{{ (sec // 60) }}m{{ '%02d' % (sec % 60) }}s + {%- else -%}{{ sec }}s + {%- endif -%} +{%- endmacro -%} +{%- macro fmt_num(n) -%} + {%- if n >= 1000 -%}{{ '%.1f' % (n / 1000) }}k + {%- else -%}{{ n }} + {%- endif -%} +{%- endmacro -%} +{%- macro fmt_gap(m) -%} + {%- if m >= 60 -%} + {%- set h = m // 60 -%} + {%- set r = m % 60 -%} + {{ h }} hora{% if h != 1 %}s{% endif %}{% if r %} e {{ r }} minuto{% if r != 1 %}s{% endif %}{% endif %} + {%- else -%} + {{ m }} minuto{% if m != 1 %}s{% endif %} + {%- endif -%} +{%- endmacro -%} +<!doctype html> +<html lang="pt-br"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>segundo-cerebro · {{ date }}{% if device_filter %} · {{ device_filter }}{% endif %}</title> + <script src="https://cdn.tailwindcss.com"></script> + <script src="https://unpkg.com/lucide@latest"></script> + <style> + body { font-family: -apple-system, "Segoe UI", system-ui, sans-serif; } + .scrollbar-thin::-webkit-scrollbar { width: 6px; height: 6px; } + .scrollbar-thin::-webkit-scrollbar-thumb { background: rgb(51 65 85 / 0.6); border-radius: 3px; } + + details summary { cursor: pointer; list-style: none; transition: background-color 160ms ease; } + details summary::-webkit-details-marker { display: none; } + details > summary:hover { background-color: rgb(30 41 59 / 0.45); } + details[open] > summary .lucide-chevron-down { transform: rotate(180deg); } + .lucide-chevron-down { transition: transform 200ms ease; } + @keyframes details-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } + details[open] > *:not(summary) { animation: details-in 180ms ease-out; } + + a, button { transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease, opacity 160ms ease; } + + #loading { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; + background: rgb(2 6 23 / 0.6); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + transition: opacity 280ms ease; } + #loading.hide { opacity: 0; pointer-events: none; } + #loading .card { background: rgb(15 23 42); border: 1px solid rgb(51 65 85); border-radius: 14px; + padding: 16px 22px; display: flex; align-items: center; gap: 12px; + box-shadow: 0 30px 60px rgb(0 0 0 / 0.5); } + #loading .spinner { width: 22px; height: 22px; border: 2.5px solid rgb(59 130 246 / 0.25); + border-top-color: rgb(96 165 250); border-radius: 50%; animation: spin 700ms linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } + + main { animation: page-fade 280ms ease-out; } + @keyframes page-fade { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } } + + /* Pulse no LED do coletor quando rodando */ + .led-pulse { animation: led-pulse 2.4s ease-in-out infinite; } + @keyframes led-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } + + /* Stat card hover lift */ + .stat-card { transition: transform 160ms ease, border-color 160ms ease; } + .stat-card:hover { transform: translateY(-1px); border-color: rgb(71 85 105); } + + /* Dropdown do controller */ + [data-menu] { position: relative; } + [data-menu] > [data-menu-panel] { display: none; position: absolute; right: 0; top: calc(100% + 6px); min-width: 240px; + z-index: 30; } + [data-menu].open > [data-menu-panel] { display: block; } + </style> +</head> +<body class="min-h-screen bg-slate-950 text-slate-100"> + +<div id="loading" aria-live="polite" aria-busy="true"> + <div class="card"><div class="spinner"></div><span class="text-slate-200 text-sm font-medium">carregando…</span></div> +</div> + +<!-- ─────────────────────────────── HEADER ─────────────────────────────── --> +<header class="border-b border-slate-800 px-6 py-3 sticky top-0 bg-slate-950/95 backdrop-blur z-20"> + <div class="max-w-7xl mx-auto flex items-center gap-3 flex-wrap"> + <div class="flex items-center gap-2"> + <i data-lucide="brain" class="w-5 h-5 text-blue-400"></i> + <span class="font-bold">segundo-cerebro</span> + </div> + <span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded + {% if mode == 'local' %}bg-emerald-950 text-emerald-300{% else %}bg-blue-950 text-blue-300{% endif %}">{{ mode }}</span> + <span class="text-slate-400 text-sm flex items-center gap-1"> + <i data-lucide="calendar" class="w-3.5 h-3.5"></i>{{ date }} + </span> + <span class="text-slate-400 text-sm flex items-center gap-1"> + <i data-lucide="laptop" class="w-3.5 h-3.5"></i><strong class="text-slate-200">{{ device_filter or 'todos' }}</strong> + </span> + + {% if mode == 'global' %} + <span class="text-slate-700">·</span> + <a href="/?d={{ date }}" class="text-xs hover:text-blue-400 {% if not device_filter %}text-blue-400 font-semibold{% else %}text-slate-400{% endif %}">todos</a> + {% for dev in devices %} + <a href="/?d={{ date }}&device={{ dev }}" class="text-xs hover:text-blue-400 {% if device_filter == dev %}text-blue-400 font-semibold{% else %}text-slate-400{% endif %}">{{ dev }}</a> + {% endfor %} + {% endif %} + + {# Pills do coletor à direita (só faz sentido no modo local) #} + {% if mode == 'local' %} + <div class="ml-auto flex items-center gap-2" data-controller="http://localhost:8766"> + <span id="col-queue" class="hidden text-[11px] px-2 py-0.5 rounded-md border border-amber-800 bg-amber-950/60 text-amber-300 flex items-center gap-1" title=""> + <i data-lucide="cloud-off" class="w-3 h-3"></i><span></span> + </span> + + <span id="col-uploads" class="text-[11px] text-slate-500 hidden sm:flex items-center gap-1" title=""></span> + + <div data-menu class="relative"> + <button id="col-pill" type="button" onclick="toggleMenu(event)" + class="text-xs px-2.5 py-1 rounded-full font-medium border border-slate-700 bg-slate-900 text-slate-300 flex items-center gap-1.5 hover:border-slate-600"> + <span id="col-led" class="w-1.5 h-1.5 rounded-full bg-slate-600"></span> + <span id="col-status">…</span> + </button> + <div data-menu-panel class="rounded-xl border border-slate-800 bg-slate-900 shadow-2xl p-3 text-xs space-y-2"> + <div id="col-detail" class="text-slate-400 space-y-1"></div> + <div class="flex gap-1.5"> + <button onclick="collectorCall('start')" class="flex-1 px-2 py-1.5 rounded-md border border-emerald-800 text-emerald-300 hover:bg-emerald-950 flex items-center justify-center gap-1"><i data-lucide="play" class="w-3 h-3"></i>start</button> + <button onclick="collectorCall('stop')" class="flex-1 px-2 py-1.5 rounded-md border border-red-800 text-red-300 hover:bg-red-950 flex items-center justify-center gap-1"><i data-lucide="square" class="w-3 h-3"></i>stop</button> + <button onclick="collectorCall('restart')" class="flex-1 px-2 py-1.5 rounded-md border border-slate-700 text-slate-300 hover:bg-slate-800 flex items-center justify-center gap-1"><i data-lucide="refresh-cw" class="w-3 h-3"></i>restart</button> + </div> + <button onclick="drainQueue()" class="w-full px-2 py-1.5 rounded-md border border-slate-700 text-slate-300 hover:bg-slate-800 flex items-center justify-center gap-1"><i data-lucide="cloud-upload" class="w-3 h-3"></i>forçar drain da fila</button> + </div> + </div> + </div> + {% endif %} + </div> +</header> + +<main class="max-w-7xl mx-auto px-6 py-5 space-y-5"> + + {# ─────────────────────────────── HOJE — STATS ─────────────────────────────── #} + <section> + <div class="flex items-baseline gap-3 mb-3"> + <h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2"> + <i data-lucide="sparkles" class="w-3.5 h-3.5 text-amber-400"></i>Hoje + </h2> + <span class="text-slate-600 text-xs">snapshot ao vivo · atualiza ao recarregar</span> + </div> + + {% set s = stats %} + {% set has_any = (s.sessions or s.wa_count or s.transcripts or s.keystrokes_chars) %} + + {% if not has_any %} + <div class="rounded-lg border border-dashed border-slate-800 bg-slate-900/30 px-4 py-3 text-slate-500 text-xs flex items-center gap-2"> + <i data-lucide="moon" class="w-3.5 h-3.5"></i> + Nada capturado ainda hoje. + {% if mode == 'local' %}<span class="ml-auto">→ verifique o pill do coletor</span>{% endif %} + </div> + {% else %} + {# Cards densos. 3 cols mobile · 6 cols desktop · cada card é um bloco compacto de 3 linhas curtas. #} + <div class="grid grid-cols-3 lg:grid-cols-6 gap-1.5"> + {% macro stat(icon, label, value, sub, icon_class='') %} + <div class="stat-card rounded-lg border border-slate-800 bg-slate-900/60 px-2.5 py-1.5"> + <div class="flex items-center gap-1 text-[10px] text-slate-500"><i data-lucide="{{ icon }}" class="w-3 h-3 {{ icon_class }}"></i>{{ label }}</div> + <div class="font-mono text-base text-slate-100 leading-tight">{{ value }}</div> + <div class="text-[9px] text-slate-600 leading-tight h-3">{{ sub or '' }}</div> + </div> + {% endmacro %} + {{ stat('clock', 'atenção', fmt_dur(s.attention_sec), '+ ' ~ fmt_dur(s.idle_sec) ~ ' ocioso' if s.idle_sec else '') }} + {{ stat('app-window', 'janelas', s.sessions, s.unique_apps ~ ' apps') }} + {{ stat('keyboard', 'digitado', fmt_num(s.keystrokes_chars), s.clipboards ~ ' cópia' ~ ('s' if s.clipboards != 1 else '')) }} + {{ stat('image', 'prints', s.screenshots, '') }} + {{ stat('message-circle', 'whatsapp', s.wa_count, s.wa_chats ~ ' chat' ~ ('s' if s.wa_chats != 1 else ''), 'text-emerald-400') }} + {{ stat('mic', 'áudios', s.transcripts, 'transcritos' if s.transcripts else '', 'text-purple-400') }} + </div> + + {# Top apps por tempo — barras compactas. #} + {% if s.top_apps %} + <div class="mt-1.5 rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2 space-y-0.5"> + <div class="flex items-center gap-1 text-[10px] text-slate-500 mb-1"> + <i data-lucide="trending-up" class="w-3 h-3"></i>onde gastou tempo + </div> + {% for app, sec in s.top_apps %} + {% set pct = (sec * 100 // s.attention_sec) if s.attention_sec else 0 %} + <div class="flex items-center gap-2 text-[11px]"> + <span class="w-24 truncate {% if loop.first %}text-slate-100 font-semibold{% else %}text-slate-400{% endif %}" title="{{ app }}">{{ app }}</span> + <div class="flex-1 h-1 rounded-full bg-slate-800 overflow-hidden"> + <div class="h-full {% if loop.first %}bg-blue-500{% else %}bg-slate-600{% endif %}" style="width: {{ pct }}%"></div> + </div> + <span class="font-mono text-slate-500 w-12 text-right">{{ fmt_dur(sec) }}</span> + </div> + {% endfor %} + </div> + {% endif %} + {% endif %} + </section> + + {# ─────────────────────────────── RESUMOS RECENTES ─────────────────────────────── #} + <section> + <div class="flex items-baseline gap-3 mb-3"> + <h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2"> + <i data-lucide="book-open" class="w-3.5 h-3.5 text-amber-400"></i>Resumos recentes + </h2> + <span class="text-slate-600 text-xs">o de hoje sai automaticamente às 22h</span> + {% if mode == 'local' %} + <button data-loading onclick="fetch('/api/pipeline/run',{method:'POST'}).then(()=>location.reload());" + class="ml-auto text-[11px] px-2.5 py-1 rounded-md border border-blue-800 text-blue-300 hover:bg-blue-950 flex items-center gap-1"> + <i data-lucide="play" class="w-3 h-3"></i>rodar pipeline agora + </button> + {% endif %} + </div> + + {% if not recent_summaries %} + <div class="rounded-xl border border-dashed border-slate-800 bg-slate-900/30 px-4 py-3 text-slate-500 text-xs"> + Ainda sem resumos anteriores. {% if mode == 'local' %}O primeiro será gerado às 22h.{% endif %} + </div> + {% else %} + <div class="space-y-1"> + {% for r in recent_summaries %} + <details class="rounded-lg border border-slate-800 bg-slate-900/40"> + <summary class="px-3 py-1.5 flex items-baseline gap-2 list-none"> + <span class="font-mono text-[11px] text-slate-400 shrink-0 w-16">{{ r.date }}</span> + <span class="text-[11px] text-slate-300 truncate flex-1">{{ (r.summary or '')[:160] }}{% if (r.summary or '')|length > 160 %}…{% endif %}</span> + <i data-lucide="chevron-down" class="w-3 h-3 text-slate-600 shrink-0"></i> + </summary> + <pre class="px-3 pb-2 whitespace-pre-wrap text-[12px] leading-snug text-slate-200 font-sans">{{ r.summary }}</pre> + </details> + {% endfor %} + </div> + {% endif %} + </section> + + {# ─────────────────────────────── LINHA DO TEMPO ─────────────────────────────── #} + <section> + <div class="flex items-center gap-3 mb-3 flex-wrap"> + <h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2"> + <i data-lucide="git-branch" class="w-3.5 h-3.5 text-blue-400"></i>Linha do tempo + <span class="text-slate-600 normal-case tracking-normal font-normal">· {{ buckets|length }} janela{% if buckets|length != 1 %}s{% endif %}</span> + </h2> + <div class="ml-auto flex items-center gap-1 text-[11px]"> + {% for m in bucket_options %} + {% set label = (m // 60 ~ 'h') if m >= 60 and m % 60 == 0 else (m ~ 'm') %} + <a href="?{% if mode == 'global' %}d={{ date } ... [truncated at 50000 chars]https://github.com/eleotherium/VPS.git
2026-06-05 VPS 1 commit initt
-
21:29
1f52436main initt +2903 · 26 arq..envCLAUDE.mdcreate_stacks.pysegundo-cerebro/.env.examplesegundo-cerebro/CLAUDE.mdsegundo-cerebro/collectors/__init__.pysegundo-cerebro/collectors/clipboard_monitor.pysegundo-cerebro/collectors/keylogger.py +18diff (49177 chars)
commit 1f5243685d3f97b6bf27aa360b12680f1fddf4c4 Author: Gabriel Eleoterio <gabrieleleoterioptc@gmail.com> Date: Fri Jun 5 18:29:59 2026 -0300 initt diff --git a/.env b/.env new file mode 100644 index 0000000..8f83bce --- /dev/null +++ b/.env @@ -0,0 +1,77 @@ +# ══════════════════════════════════════════════════════════════════ +# VPS eleotherium.tech — credenciais e segredos +# Gerado em: 2026-06-05 +# ══════════════════════════════════════════════════════════════════ + +# ── VPS ──────────────────────────────────────────────────────────── +VPS_IP=2.25.174.226 +VPS_USER=root +VPS_PASSWORD=***[REDACTED]*** +VPS_SSH_KEY=~/.ssh/vps_segundo_cerebro # chave ed25519 gerada localmente + +# ── Hostinger DNS API ────────────────────────────────────────────── +HOSTINGER_API_TOKEN=***[REDACTED]*** + +# ── Traefik dashboard ───────────────────────────────────────────── +# URL: https://traefik.eleotherium.tech +TRAEFIK_USER=admin +TRAEFIK_PASSWORD=***[REDACTED]*** + +# ── Portainer CE ────────────────────────────────────────────────── +# URL: https://portainer.eleotherium.tech +PORTAINER_USER=admin +PORTAINER_PASSWORD=***[REDACTED]*** + +# ── Evolution API ───────────────────────────────────────────────── +# URL: https://evolution.eleotherium.tech +# Stack Portainer id=3 +EVOLUTION_API_KEY=***[REDACTED]*** +EVOLUTION_SERVER_URL=https://evolution.eleotherium.tech +EVOLUTION_VERSION=2.3.7 +EVOLUTION_IMAGE=evoapicloud/evolution-api:v2.3.7 + +# Evolution PostgreSQL (container evo-db) +EVO_DB_HOST=evo-db +EVO_DB_PORT=5432 +EVO_DB_NAME=evolutiondb +EVO_DB_USER=evolution +EVO_DB_PASSWORD=***[REDACTED]*** +EVO_DB_URI=postgresql://evolution:HCRpDOT4cjC38rimM71mSWCyHlc@evo-db:5432/evolutiondb?schema=evolution_api + +# Evolution Redis (container evo-redis) +EVO_REDIS_URI=redis://evo-redis:6379/6 + +# ── Supabase ────────────────────────────────────────────────────── +# Studio URL: https://supabase.eleotherium.tech +# REST API: https://supabase.eleotherium.tech/rest/v1/ +# Auth API: https://supabase.eleotherium.tech/auth/v1/ +# Storage: https://supabase.eleotherium.tech/storage/v1/ +# Realtime: https://supabase.eleotherium.tech/realtime/v1/ +# Stack Portainer id=6 + +SUPABASE_URL=https://supabase.eleotherium.tech + +# JWT — assina todos os tokens do Supabase +SUPABASE_JWT_SECRET=***[REDACTED]*** + +# Chave pública — usada no cliente (segura para expor no frontend) +SUPABASE_ANON_KEY=***[REDACTED]***.***[REDACTED]***.3L7NdMjr8dmystPAv6HlTZC-z3KfwQ-4ywdMG94IxDg + +# Chave de serviço — backend only, nunca expor no cliente +SUPABASE_SERVICE_ROLE_KEY=***[REDACTED]***.***[REDACTED]***.8n_gQlSUWpUcn5kUUC8b52199FKGpMJ4lHUj7SfGcQo + +# Supabase PostgreSQL (container supabase-db) +# Superuser real da imagem supabase/postgres é supabase_admin (não postgres) +# Para acessar: docker exec supabase-db psql -h 127.0.0.1 -U supabase_admin -d postgres +SUPABASE_DB_HOST=db +SUPABASE_DB_PORT=5432 +SUPABASE_DB_NAME=postgres +SUPABASE_DB_PASSWORD=***[REDACTED]*** +SUPABASE_DB_URI=postgresql://postgres:Kr6029vY2tr03IlLJv5ICPdwrh4@db:5432/postgres + +# Supabase Kong dashboard (básica auth no /api endpoint interno) +SUPABASE_KONG_USER=admin +SUPABASE_KONG_PASSWORD=***[REDACTED]*** + +# Chave de criptografia do postgres-meta (AES-256, 43 chars base64url) +SUPABASE_PG_META_CRYPTO_KEY=T5nknUgjDkcRbCytxOLqL2MG2o_OshjxzwbXZz9a-GY diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bb7314e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,270 @@ +# VPS eleotherium.tech — Contexto do Projeto + +Infraestrutura de VPS para o projeto **segundo-cerebro**: captura passiva de contexto diário (keylogger, OCR, WhatsApp via Evolution API) + sumarização noturna via Claude API. A VPS hospeda os serviços que precisam rodar 24/7 ou receber webhooks externos. + +--- + +## Acesso à VPS + +``` +IP: 2.25.174.226 +OS: Ubuntu 24.04.4 LTS +RAM: 7.8 GB | CPU: 2 cores | Disco: 96 GB +SSH: ssh root@2.25.174.226 (senha=***[REDACTED]*** +SSH key: ~/.ssh/vps_segundo_cerebro (ed25519, já no authorized_keys) +``` + +Para executar comandos na VPS via Python (sem sshpass): +```python +import paramiko +client = paramiko.SSHClient() +client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +client.connect("2.25.174.226", username="root", password=***[REDACTED]*** timeout=15) +stdin, stdout, stderr = client.exec_command("comando aqui") +print(stdout.read().decode()) +client.close() +``` + +--- + +## Domínio e DNS + +- **Domínio:** eleotherium.tech (Hostinger) +- **Hostinger API:** `https://developers.hostinger.com/api/dns/v1/zones/eleotherium.tech` +- **Auth:** `Bearer mZCQaZZImaPbDMAVkKYQrPnBS8V3dH3hJNcaiqBrb7d2b8b0` +- **User-Agent obrigatório:** `Mozilla/5.0 ...` (Cloudflare bloqueia sem ele) +- **Método:** `PUT` com `{"overwrite": false, "zone": [...]}` + +Subdomínios ativos (todos → 2.25.174.226): +- `portainer.eleotherium.tech` +- `evolution.eleotherium.tech` +- `traefik.eleotherium.tech` +- `supabase.eleotherium.tech` + +--- + +## Stack Docker + +### Arquitetura + +``` +Internet → Traefik v2.11 (80/443) → containers via rede Docker "proxy" + → Let's Encrypt SSL automático + +Portainer CE 2.39.3 → gerencia todas as stacks via UI +``` + +### Containers rodando + +| Container | Imagem | Recurso | +|---|---|---| +| traefik | traefik:v2.11 | 0.2 CPU / 128M | +| portainer | portainer/portainer-ce:latest | 0.2 CPU / 256M | +| evolution | evoapicloud/evolution-api:v2.3.7 | 1.0 CPU / 512M | +| evo-db | postgres:16-alpine | 0.5 CPU / 512M | +| evo-redis | redis:7-alpine | 0.2 CPU / 128M | +| supabase-db | supabase/postgres:15.8.1.085 | 0.8 CPU / 1G | +| supabase-studio | supabase/studio:2026.06.03-sha-0bca601 | 0.5 CPU / 512M | +| supabase-kong | kong/kong:3.9.1 | 0.3 CPU / 256M | +| supabase-auth | supabase/gotrue:v2.189.0 | 0.3 CPU / 256M | +| supabase-rest | postgrest/postgrest:v14.12 | 0.3 CPU / 256M | +| supabase-realtime | supabase/realtime:v2.102.3 | 0.5 CPU / 512M | +| supabase-storage | supabase/storage-api:v1.60.4 | 0.2 CPU / 256M | +| supabase-meta | supabase/postgres-meta:v0.96.6 | 0.2 CPU / 256M | +| supabase-imgproxy | darthsim/imgproxy:v3.30.1 | 0.3 CPU / 256M | + +**Regra:** todo serviço novo deve ter `deploy.resources.limits` (cpus + memory). VPS tem 2 vCPUs e 8 GB RAM. + +### Redes Docker + +- `proxy` — rede externa compartilhada, usada pelo Traefik para descobrir serviços +- `evo-net` — interna da stack Evolution +- `supabase_supa-net` — interna da stack Supabase + +### Arquivos de compose na VPS + +``` +/opt/traefik/docker-compose.yml # Traefik (gerenciado por SSH direto) +/opt/portainer/docker-compose.yml # Portainer (gerenciado por SSH direto) +/opt/portainer/data/compose/3/ # Stack evolution (gerenciado pelo Portainer) +/opt/portainer/data/compose/6/ # Stack supabase (gerenciado pelo Portainer) +/opt/supabase/volumes/api/kong.yml # Config Kong (editado manualmente) +/opt/supabase/volumes/db/ # Scripts init do Postgres +/opt/traefik/certs/acme.json # Certificados Let's Encrypt +``` + +--- + +## URLs e Credenciais + +| Serviço | URL | Login | +|---|---|---| +| Portainer | https://portainer.eleotherium.tech | admin / `P0rt4in3r@Sec!` | +| Traefik | https://traefik.eleotherium.tech | admin / `S3cur3D4shb04rd!` | +| Evolution | https://evolution.eleotherium.tech | API Key abaixo | +| Supabase Studio | https://supabase.eleotherium.tech | — (auth via Supabase) | + +Arquivo `.env` em `vps/.env` tem todas as credenciais completas. + +--- + +## Portainer API + +Endpoint local: `https://portainer.eleotherium.tech` + +```python +import urllib.request, json, ssl +ctx = ssl.create_default_context() + +def papi(path, method="GET", body=None, token=***[REDACTED]*** + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if token=***[REDACTED]*** = f"Bearer {token}" + data = json.dumps(body).encode() if body else None + req = urllib.request.Request(f"https://portainer.eleotherium.tech/api{path}", + data=data, method=method, headers=headers) + with urllib.request.urlopen(req, context=ctx, timeout=600) as r: + return json.loads(r.read()), r.status + +# Auth +r, _ = papi("/auth", "POST", {"Username": "admin", "Password": "P0rt4in3r@Sec!"}) +token=***[REDACTED]*** + +# Criar stack +papi("/stacks/create/standalone/string?endpointId=1", "POST", + {"name": "minha-stack", "stackFileContent": "services:\n ..."}, token) + +# Atualizar stack existente +papi(f"/stacks/{stack_id}?endpointId=1", "PUT", + {"stackFileContent": "services:\n ...", "pullImage": False}, token) +``` + +IDs das stacks: evolution=3, supabase=6 + +--- + +## Evolution API v2.3.7 + +- **Imagem:** `evoapicloud/evolution-api:v2.3.7` +- **API Key:** `boxTZJG4wJr-sj0fMxAcGHM58asYHlhgYRGP9hwLx-Y` +- **DB schema:** `evolution_api` (mudou de `public` na v2.2.3) +- **DB:** `postgresql://evolution:HCRpDOT4cjC38rimM71mSWCyHlc@evo-db:5432/evolutiondb?schema=evolution_api` +- **Redis:** `redis://evo-redis:6379/6` + +Para criar instância e conectar WhatsApp: +```bash +# Criar instância +curl -X POST https://evolution.eleotherium.tech/instance/create \ + -H "apikey=***[REDACTED]*** \ + -H "Content-Type: application/json" \ + -d '{"instanceName": "segundo-cerebro", "qrcode": true}' + +# Buscar QR code +curl https://evolution.eleotherium.tech/instance/connect/segundo-cerebro \ + -H "apikey=***[REDACTED]*** +``` + +Webhook para segundo-cerebro: `http://host.docker.internal:9000/webhook/evolution` + +--- + +## Supabase + +### URLs de acesso + +| Endpoint | URL | +|---|---| +| Studio (UI) | https://supabase.eleotherium.tech | +| REST API | https://supabase.eleotherium.tech/rest/v1/ | +| Auth API | https://supabase.eleotherium.tech/auth/v1/ | +| Storage | https://supabase.eleotherium.tech/storage/v1/ | +| Realtime | https://supabase.eleotherium.tech/realtime/v1/ | +| GraphQL | https://supabase.eleotherium.tech/graphql/v1/ | + +### Chaves + +``` +SUPABASE_URL=https://supabase.eleotherium.tech +SUPABASE_ANON_KEY=***[REDACTED]***.***[REDACTED]***.3L7NdMjr8dmystPAv6HlTZC-z3KfwQ-4ywdMG94IxDg +SUPABASE_SERVICE_ROLE_KEY=***[REDACTED]***.***[REDACTED]***.8n_gQlSUWpUcn5kUUC8b52199FKGpMJ4lHUj7SfGcQo +SUPABASE_JWT_SECRET=***[REDACTED]*** +SUPABASE_DB_PASSWORD=***[REDACTED]*** +PG_META_CRYPTO_KEY=T5nknUgjDkcRbCytxOLqL2MG2o_OshjxzwbXZz9a-GY +``` + +### Acesso ao banco como superuser + +```bash +# Dentro da VPS: +docker exec supabase-db psql -h 127.0.0.1 -U supabase_admin -d postgres + +# O user "postgres" NÃO é superuser nessa imagem — use "supabase_admin" +# Conexão via rede Docker usa scram-sha-256 com a senha SUPABASE_DB_PASSWORD +``` + +### Kong (API Gateway) + +- **kong.yml:** `/opt/supabase/volumes/api/kong.yml` +- Após editar kong.yml: `docker exec supabase-kong kong reload` +- **Gotcha:** kong.yml usa `$LUA_AUTH_EXPR` que é env var substituída em tempo de load — NÃO é avaliada como Lua em tempo de request. O `replace: headers: - "Authorization: ..."` foi removido (bug: sobrescrevia o JWT válido com o valor literal da variável). Só o `add:` permanece (para S3 presigned URLs). + +### Gotchas do Supabase self-hosted + +| Problema | Causa | Fix aplicado | +|---|---|---| +| `Failed to load schemas` | `postgres-meta` usa `CRYPTO_KEY` (não `PG_META_CRYPTO_KEY`) para decriptar o header `x-connection-encrypted` | Adicionado `CRYPTO_KEY` no ambiente do meta | +| `Invalid Compact JWS` no storage | Kong substituía `Authorization` por literal `$LUA_AUTH_EXPR` | Removido o bloco `replace:` do kong.yml, feito `kong reload` | +| `APP_NAME not available` no realtime | Env var faltando | Adicionado `APP_NAME: realtime` no compose | +| `_realtime schema` não existia | Init scripts do supabase/postgres foram ignorados | Criado manualmente via `supabase_admin` | +| Senhas dos service users | `postgres` não é superuser, roles são "reserved" | Setadas via `psql -h 127.0.0.1 -U supabase_admin` (usa trust auth no localhost) | + +--- + +## Adicionando Novos Serviços + +Template de stack para Portainer (sempre incluir resource limits e rede proxy): + +```yaml +services: + meu-servico: + image: imagem:tag + container_name: meu-servico + restart: unless-stopped + networks: + - proxy # necessário para Traefik enxergar + - meu-net + environment: + VARIAVEL: valor + labels: + - traefik.enable=true + - traefik.http.routers.meu-servico.rule=Host(`meu-servico.eleotherium.tech`) + - traefik.http.routers.meu-servico.entrypoints=websecure + - traefik.http.services.meu-servico.loadbalancer.server.port=8080 + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.05' + memory: 64M + +networks: + proxy: + external: true + meu-net: + driver: bridge +``` + +Para expor um novo subdomínio, criar DNS via Hostinger API antes de subir o container (Traefik só emite cert quando o DNS já resolve). + +--- + +## Projeto segundo-cerebro + +Ver `segundo-cerebro/guide.md` para o plano completo. Resumo do que a VPS suporta: + +- **Evolution API** — recebe webhooks do WhatsApp, salva mensagens e áudios +- **Supabase** — banco de dados para persistência de dados processados +- **Próximo passo:** conectar WhatsApp na Evolution, depois implementar `evolution_webhook.py` (FastAPI na porta 9000 do Windows) para receber os eventos + +O pipeline noturno (22h) roda no Windows local: transcrição Whisper → sumarização Claude API → `daily_summary.md`. diff --git a/create_stacks.py b/create_stacks.py new file mode 100644 index 0000000..3f3db33 --- /dev/null +++ b/create_stacks.py @@ -0,0 +1,491 @@ +import urllib.request, urllib.error, json, ssl, time + +JWT_SECRET=***[REDACTED]*** +ANON_KEY = "***[REDACTED]***.***[REDACTED]***.3L7NdMjr8dmystPAv6HlTZC-z3KfwQ-4ywdMG94IxDg" +SERVICE_KEY = "***[REDACTED]***.***[REDACTED]***.8n_gQlSUWpUcn5kUUC8b52199FKGpMJ4lHUj7SfGcQo" +PG_PASS = "Kr6029vY2tr03IlLJv5ICPdwrh4" +EVO_DB_PASS = "HCRpDOT4cjC38rimM71mSWCyHlc" +EVO_API_KEY=***[REDACTED]*** +PG_META_KEY = "T5nknUgjDkcRbCytxOLqL2MG2o_OshjxzwbXZz9a-GY" + +BASE = "https://portainer.eleotherium.tech" +ctx = ssl.create_default_context() + +def papi(path, method="GET", body=None, token=***[REDACTED]*** + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if token=***[REDACTED]*** headers["Authorization"] = f"Bearer {token}" + data = json.dumps(body).encode() if body else None + req = urllib.request.Request(f"{BASE}/api{path}", data=data, method=method, headers=headers) + try: + with urllib.request.urlopen(req, context=ctx, timeout=600) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + try: + return json.loads(e.read().decode()), e.code + except: + return {}, e.code + +result, _ = papi("/auth", "POST", {"Username": "admin", "Password": "P0rt4in3r@Sec!"}) +token=***[REDACTED]*** +print("Portainer auth: ok") + +# ─── Evolution Stack ──────────────────────────────────────────────────────── +evolution_compose = f"""services: + evolution: + image: evoapicloud/evolution-api:v2.3.7 + container_name: evolution + restart: unless-stopped + networks: + - proxy + - evo-net + environment: + SERVER_URL: https://evolution.eleotherium.tech + SERVER_PORT: "8080" + TELEMETRY_ENABLED: "false" + CORS_ORIGIN: "*" + CORS_METHODS: GET,POST,PUT,DELETE + CORS_CREDENTIALS: "true" + LOG_LEVEL: ERROR,WARN + LOG_COLOR: "true" + LOG_BAILEYS: error + DEL_INSTANCE: "false" + DATABASE_PROVIDER: postgresql + DATABASE_CONNECTION_URI: postgresql://evolution:{EVO_DB_PASS}@evo-db:5432/evolutiondb?schema=evolution_api + DATABASE_CONNECTION_CLIENT_NAME: evolution_segundo_cerebro + DATABASE_SAVE_DATA_INSTANCE: "true" + DATABASE_SAVE_DATA_NEW_MESSAGE: "true" + DATABASE_SAVE_MESSAGE_UPDATE: "true" + DATABASE_SAVE_DATA_CONTACTS: "true" + DATABASE_SAVE_DATA_CHATS: "true" + DATABASE_SAVE_DATA_LABELS: "true" + DATABASE_SAVE_DATA_HISTORIC: "true" + DATABASE_SAVE_IS_ON_WHATSAPP: "true" + DATABASE_SAVE_IS_ON_WHATSAPP_DAYS: "7" + DATABASE_DELETE_MESSAGE: "true" + CACHE_REDIS_ENABLED: "true" + CACHE_REDIS_URI: redis://evo-redis:6379/6 + CACHE_REDIS_PREFIX_KEY: evolution + CACHE_REDIS_TTL: "604800" + CACHE_REDIS_SAVE_INSTANCES: "false" + CACHE_LOCAL_ENABLED: "false" + WEBHOOK_GLOBAL_ENABLED: "false" + WEBHOOK_EVENTS_MESSAGES_UPSERT: "true" + WEBHOOK_EVENTS_MESSAGES_UPDATE: "true" + WEBHOOK_EVENTS_MESSAGES_DELETE: "true" + WEBHOOK_EVENTS_CONNECTION_UPDATE: "true" + WEBHOOK_EVENTS_QRCODE_UPDATED: "true" + WEBHOOK_REQUEST_TIMEOUT_MS: "60000" + WEBHOOK_RETRY_MAX_ATTEMPTS: "10" + AUTHENTICATION_API_KEY=***[REDACTED]*** + AUTHENTICATION_EXPOSE_IN_FETCH_INSTANCES: "true" + QRCODE_LIMIT: "30" + LANGUAGE: en + volumes: + - evo_instances:/evolution/instances + labels: + - traefik.enable=true + - traefik.http.routers.evolution.rule=Host(`evolution.eleotherium.tech`) + - traefik.http.routers.evolution.entrypoints=websecure + - traefik.http.services.evolution.loadbalancer.server.port=8080 + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + reservations: + cpus: '0.1' + memory: 128M + + evo-db: + image: postgres:16-alpine + container_name: evo-db + restart: unless-stopped + networks: + - evo-net + environment: + POSTGRES_DB: evolutiondb + POSTGRES_USER: evolution + POSTGRES_PASSWORD=***[REDACTED]*** + volumes: + - evo_db:/var/lib/postgresql/data + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.05' + memory: 64M + + evo-redis: + image: redis:7-alpine + container_name: evo-redis + restart: unless-stopped + networks: + - evo-net + command: redis-server --appendonly yes + volumes: + - evo_redis:/data + deploy: + resources: + limits: + cpus: '0.2' + memory: 128M + reservations: + cpus: '0.05' + memory: 32M + +networks: + proxy: + external: true + evo-net: + driver: bridge + +volumes: + evo_instances: + evo_db: + evo_redis: +""" + +# ─── Supabase Stack ───────────────────────────────────────────────────────── +supabase_compose = f"""services: + + studio: + image: supabase/studio:2026.06.03-sha-0bca601 + container_name: supabase-studio + restart: unless-stopped + networks: + - proxy + - supa-net + healthcheck: + test: ["CMD-SHELL", "node -e \\"fetch('http://localhost:3000/api/platform/profile').then((r)=>{{if(r.status!==200)throw new Error(r.status)}})\\"" ] + timeout: 10s + interval: 5s + retries: 3 + start_period: 30s + environment: + HOSTNAME: "0.0.0.0" + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PORT: "5432" + POSTGRES_HOST: db + POSTGRES_DB: postgres + POSTGRES_PASSWORD=***[REDACTED]*** + PG_META_CRYPTO_KEY: {PG_META_KEY} + PGRST_DB_SCHEMAS: public,storage,graphql_public + DEFAULT_ORGANIZATION_NAME: eleotherium + DEFAULT_PROJECT_NAME: segundo-cerebro + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: https://supabase.eleotherium.tech + SUPABASE_ANON_KEY: {ANON_KEY} + SUPABASE_SERVICE_KEY: {SERVICE_KEY} + AUTH_JWT_SECRET=***[REDACTED]*** + SUPABASE_PUBLISHABLE_KEY: "" + SUPABASE_SECRET_KEY: "" + OPENAI_API_KEY=***[REDACTED]*** + ENABLED_FEATURES_LOGS_ALL: "false" + POSTGRES_USER: postgres + SNIPPETS_MANAGEMENT_FOLDER: /app/snippets + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions + volumes: + - supa_snippets:/app/snippets + - supa_functions:/app/edge-functions + labels: + - traefik.enable=true + - traefik.http.routers.supa-studio.rule=Host(`supabase.eleotherium.tech`) + - traefik.http.routers.supa-studio.entrypoints=websecure + - traefik.http.routers.supa-studio.service=supa-studio-svc + - traefik.http.services.supa-studio-svc.loadbalancer.server.port=3000 + - traefik.http.routers.supa-studio.priority=1 + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.05' + memory: 128M + + kong: + image: kong/kong:3.9.1 + container_name: supabase-kong + restart: unless-stopped + networks: + - proxy + - supa-net + healthcheck: + test: ["CMD", "kong", "health"] + interval: 5s + timeout: 5s + retries: 5 + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml + KONG_DNS_ORDER: LAST,A,CNAME + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,jwt,request-termination,post-function + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + SUPABASE_ANON_KEY: {ANON_KEY} + SUPABASE_SERVICE_KEY: {SERVICE_KEY} + SUPABASE_PUBLISHABLE_KEY: "" + SUPABASE_SECRET_KEY: "" + DASHBOARD_USERNAME: admin + DASHBOARD_PASSWORD=***[REDACTED]*** + LUA_AUTH_EXPR: "$(headers.authorization)" + LUA_RT_WS_EXPR: "$(headers.apikey)" + volumes: + - /opt/supabase/volumes/api/kong.yml:/var/lib/kong/kong.yml:ro + labels: + - traefik.enable=true + - "traefik.http.routers.supa-kong.rule=Host(`supabase.eleotherium.tech`) && (PathPrefix(`/rest/`) || PathPrefix(`/auth/`) || PathPrefix(`/storage/`) || PathPrefix(`/realtime`) || PathPrefix(`/functions/`) || PathPrefix(`/graphql/`))" + - traefik.http.routers.supa-kong.entrypoints=websecure + - traefik.http.routers.supa-kong.service=supa-kong-svc + - traefik.http.services.supa-kong-svc.loadbalancer.server.port=8000 + - traefik.http.routers.supa-kong.priority=10 + deploy: + resources: + limits: + cpus: '0.3' + memory: 256M + reservations: + cpus: '0.05' + memory: 64M + + auth: + image: supabase/gotrue:v2.189.0 + container_name: supabase-auth + restart: unless-stopped + networks: + - supa-net + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"] + timeout: 5s + interval: 5s + retries: 3 + environment: + GOTRUE_API_HOST: "0.0.0.0" + GOTRUE_API_PORT: "9999" + API_EXTERNAL_URL: https://supabase.eleotherium.tech + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:{PG_PASS}@db:5432/postgres + GOTRUE_SITE_URL: https://supabase.eleotherium.tech + GOTRUE_URI_ALLOW_LIST: "" + GOTRUE_DISABLE_SIGNUP: "false" + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: "3600" + GOTRUE_JWT_SECRET=***[REDACTED]*** + GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "false" + GOTRUE_MAILER_AUTOCONFIRM: "true" + GOTRUE_SMTP_ADMIN_EMAIL: gabrieleleoterioptc@gmail.com + GOTRUE_SMTP_HOST: "" + GOTRUE_SMTP_PORT: "587" + GOTRUE_SMTP_USER: "" + GOTRUE_SMTP_PASS: "" + GOTRUE_SMTP_SENDER_NAME: Supabase + deploy: + resources: + limits: + cpus: '0.3' + memory: 256M + reservations: + cpus: '0.05' + memory: 64M + + rest: + image: postgrest/postgrest:v14.12 + container_name: supabase-rest + restart: unless-stopped + networks: + - supa-net + environment: + PGRST_DB_URI: postgres://authenticator:{PG_PASS}@db:5432/postgres + PGRST_DB_SCHEMAS: public,storage,graphql_public + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET=***[REDACTED]*** + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET=***[REDACTED]*** + PGRST_APP_SETTINGS_JWT_EXP: "3600" + command: postgrest + deploy: + resources: + limits: + cpus: '0.3' + memory: 256M + reservations: + cpus: '0.05' + memory: 64M + + realtime: + image: supabase/realtime:v2.102.3 + container_name: supabase-realtime + restart: unless-stopped + networks: + - supa-net + environment: + PORT: "4000" + DB_HOST: db + DB_PORT: "5432" + DB_USER: supabase_admin + DB_PASSWORD=***[REDACTED]*** + DB_NAME: postgres + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime" + DB_ENC_KEY: supabaserealtime + API_JWT_SECRET=***[REDACTED]*** + METRICS_JWT_SECRET=***[REDACTED]*** + APP_NAME: realtime + FLY_ALLOC_ID: local + FLY_APP_NAME: realtime + SECRET_KEY_BASE: "{JWT_SECRET}{JWT_SECRET}" + ERL_AFLAGS: -proto_dist inet_tcp + ENABLE_TAILSCALE: "false" + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + command: > + sh -c "/app/bin/migrate && /app/bin/realtime eval 'Realtime.Release.seeds(Realtime.Repo)' && /app/bin/server" + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.05' + memory: 128M + + storage: + image: supabase/storage-api:v1.60.4 + container_name: supabase-storage + restart: unless-stopped + networks: + - supa-net + environment: + ANON_KEY: {ANON_KEY} + SERVICE_KEY: {SERVICE_KEY} + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET=***[REDACTED]*** + DATABASE_URL: postgres://supabase_storage_admin:{PG_PASS}@db:5432/postgres + FILE_SIZE_LIMIT: "52428800" + STORAGE_BACKEND: file + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + REGION: local + GLOBAL_S3_BUCKET: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + volumes: + - supa_storage:/var/lib/storage + deploy: + resources: + limits: + cpus: '0.2' + memory: 256M + reservations: + cpus: '0.05' + memory: 32M + + imgproxy: + image: darthsim/imgproxy:v3.30.1 + container_name: supabase-imgproxy + restart: unless-stopped + networks: + - supa-net + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: "true" + volumes: + - supa_storage:/var/lib/storage:ro + deploy: + resources: + limits: + cpus: '0.3' + memory: 256M + reservations: + cpus: '0.05' + memory: 32M + + meta: + image: supabase/postgres-meta:v0.96.6 + container_name: supabase-meta + restart: unless-stopped + networks: + - supa-net + environment: + PG_META_PORT: "8080" + PG_META_DB_HOST: db + PG_META_DB_PORT: "5432" + PG_META_DB_NAME: postgres + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD=***[REDACTED]*** + PG_META_CRYPTO_KEY: {PG_META_KEY} + CRYPTO_KEY: {PG_META_KEY} + deploy: + resources: + limits: + cpus: '0.2' + memory: 256M + reservations: + cpus: '0.05' + memory: 32M + + db: + image: supabase/postgres:15.8.1.085 + container_name: supabase-db + restart: unless-stopped + networks: + - supa-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -h localhost"] + interval: 5s + timeout: 5s + retries: 10 + environment: + POSTGRES_HOST: /var/run/postgresql + PGPORT: "5432" + POSTGRES_PORT: "5432" + PGPASSWORD=***[REDACTED]*** + POSTGRES_PASSWORD=***[REDACTED]*** + PGDATABASE: postgres + POSTGRES_DB: postgres + JWT_SECRET=***[REDACTED]*** + JWT_EXP: "3600" + volumes: + - supa_db:/var/lib/postgresql/data + - /opt/supabase/volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/migrations/99-webhooks.sql:ro + - /opt/supabase/volumes/db/roles.sql:/docker-entrypoint-initdb.d/migrations/99-roles.sql:ro + - /opt/supabase/volumes/db/jwt.sql:/docker-entrypoint-initdb.d/migrations/99-jwt.sql:ro + - /opt/supabase/volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:ro + - /opt/supabase/volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:ro + deploy: + resources: + limits: + cpus: '0.8' + memory: 1G + reservations: + cpus: '0.1' + memory: 256M + +networks: + proxy: + external: true + supa-net: + driver: bridge + +volumes: + supa_db: + supa_storage: + supa_snippets: + supa_functions: +""" + +def create_stack(name, compose_content, tok): + body = {"name": name, "stackFileContent": compose_content} + return papi(f"/stacks/create/standalone/string?endpointId=1", "POST", body, tok) + +print("\n--- Updating Supabase stack (id=6) ---") +body = {"stackFileContent": supabase_compose, "pullImage": False} +r, s = papi("/stacks/6?endpointId=1", "PUT", body, token) +print(f"Status {s}: id={r.get('Id')} name={r.get('Name')} err={r.get('message', '')}") diff --git a/segundo-cerebro/.env.example b/segundo-cerebro/.env.example new file mode 100644 index 0000000..ade6b86 --- /dev/null +++ b/segundo-cerebro/.env.example @@ -0,0 +1,9 @@ +SUPABASE_URL=https://supabase.eleotherium.tech +SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here +ANTHROPIC_API_KEY=***[REDACTED]*** +EVOLUTION_URL=https://evolution.eleotherium.tech +EVOLUTION_API_KEY=***[REDACTED]*** +EVOLUTION_INSTANCE=gab +VPS_HOST=2.25.174.226 +VPS_USER=root +VPS_SSH_KEY=~/.ssh/vps_segundo_cerebro diff --git a/segundo-cerebro/CLAUDE.md b/segundo-cerebro/CLAUDE.md new file mode 100644 index 0000000..5b26d49 --- /dev/null +++ b/segundo-cerebro/CLAUDE.md @@ -0,0 +1,125 @@ +# segundo-cerebro — CLAUDE.md + +Projeto de captura passiva de contexto diário. Registra o que foi digitado, visto na tela e dito no WhatsApp, e sumariza tudo às 22h via Claude API. + +--- + +## Arquitetura + +``` +[Windows local] [VPS — Supabase] +keylogger ──── sanitizer ──────────────► tabela: raw_events +ocr / screenshots ─────────────────────► bucket: screenshots +window_monitor ── sanitizer ───────────► tabela: raw_events + +[VPS — Evolution API] +WhatsApp msgs/áudios ──────────────────► tabela: evolution_api.Message + arquivos: /data/audio/*.ogg + +[22h — Windows local] + ├── puxa .ogg da VPS via SSH/SCP + ├── roda Whisper medium (local, CPU) + ├── push transcrições ─────────────────► tabela: audio_transcripts + └── Claude API + lê raw_events + audio_transcripts + WhatsApp msgs do Supabase + ─────────────────────────────────► tabela: daily_summaries +``` + +**Regra:** dados brutos (keystrokes, OCR) passam pelo sanitizer *antes* de sair da máquina. Nunca subir texto não-sanitizado para o Supabase. + +--- + +## Estrutura de pastas + +``` +segundo-cerebro/ +├── collectors/ +│ ├── window_monitor.py # SetWinEventHook — janela ativa + título + processo +│ ├── keylogger.py # pynput — agrupa keystrokes por sessão de janela +│ ├── ocr.py # mss + pytesseract — screenshot na troca de janela +│ └── clipboard_monitor.py # win32clipboard — mudanças em background thread +├── pipeline/ +│ ├── audio_puller.py # SSH/SCP — baixa .ogg pendentes da VPS +│ ├── transcriber.py # faster-whisper medium — processa áudios localmente +│ ├── summarizer.py # Claude API — sumariza eventos do dia +│ └── scheduler.py # APScheduler — dispara pipeline às 22h +├── storage/ +│ └── uploader.py # push de eventos para Supabase (tabelas + bucket) +├── utils/ +│ └── sanitizer.py # regex — mascara senhas, tokens, cartões antes do upload +├── config/ +│ └── tracked_contacts.json # JIDs do WhatsApp monitorados (grupos + contatos) +├── data/ +│ └── audio/ # .ogg temporários baixados da VPS (apagados após transcrição) +├── tasks.md # histórico de tarefas e mudanças +├── CLAUDE.md # este arquivo +├── requirements.txt +└── main.py # entrypoint — sobe coletores + scheduler +``` + +--- + +## Supabase (VPS) + +| Tabela / Bucket | Conteúdo | +|---|---| +| `raw_events` | keystrokes, OCR, clipboard, trocas de janela — sanitizados | +| `audio_transcripts` | transcrições dos áudios WhatsApp geradas pelo Whisper | +| `daily_summaries` | resumo diário gerado pelo Claude | +| `evolution_api.Message` | mensagens WhatsApp (gerenciado pela Evolution API) | +| bucket `screenshots` | prints de troca de janela | + +Conexão: usar `SUPABASE_URL` e `SUPABASE_SERVICE_ROLE_KEY` do `.env`. + +--- + +## WhatsApp — contatos monitorados + +Definidos em `config/tracked_contacts.json`. O filtro é aplicado no pipeline das 22h ao ler `evolution_api.Message` — só processa mensagens cujo `remoteJid` está na lista. + +Whisper transcribe áudios (`messageType = audioMessage`) dos JIDs rastreados. + +--- + +## Stack + +| Função | Biblioteca | +|---|---| +| Detectar troca de janela | `pywin32` — `SetWinEventHook` | +| Keylogger global | `pynput` | +| Screenshot | `mss` | +| OCR | `pytesseract` + Tesseract (pt-BR) | +| Clipboard | `win32clipboard` | +| Transcrição de áudio | `faster-whisper` modelo `medium` | +| Scheduler | `APScheduler` | +| Upload Supabase | `supabase-py` | +| Download áudios VPS | `paramiko` (SSH/SCP) | +| Sumarização | `anthropic` SDK | + +Tesseract instalado separadamente. Configurar no `ocr.py`: +```python +pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" +``` + +--- + +## Variáveis de ambiente (.env) + +``` +SUPABASE_URL=https://supabase.eleotherium.tech +SUPABASE_SERVICE_ROLE_KEY=... +ANTHROPIC_API_KEY=***[REDACTED]*** +VPS_HOST=2.25.174.226 +VPS_USER=root +VPS_SSH_KEY=~/.ssh/vps_segundo_cerebro +EVOLUTION_AUDIO_PATH=/opt/portainer/data/compose/3/instances/gab/store/media +``` + +--- + +## Observações + +- Coletores rodam no **Windows nativo** — sem WSL. +- Storage é **append-only** no Supabase — nunca sobrescreve eventos brutos. +- Áudios `.ogg` são temporários: baixados, transcritos e apagados localmente. +- `main.py` deve rodar na inicialização do Windows via Task Scheduler. diff --git a/segundo-cerebro/collectors/__init__.py b/segundo-cerebro/collectors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/segundo-cerebro/collectors/clipboard_monitor.py b/segundo-cerebro/collectors/clipboard_monitor.py new file mode 100644 index 0000000..5f45a49 --- /dev/null +++ b/segundo-cerebro/collectors/clipboard_monitor.py @@ -0,0 +1,44 @@ +import threading +import time +import win32clipboard +from datetime import datetime, timezone + +from utils.sanitizer import sanitize +from storage.uploader import upload_event + + +class ClipboardMonitor: + def __init__(self, poll_interval: float = 1.0): + self._interval = poll_interval + self._last = None + self._thread = threading.Thread(target=self._run, daemon=True) + + def start(self): + self._thread.start() + + def _run(self): + while True: + try: + win32clipboard.OpenClipboard() + try: + text = win32clipboard.GetClipboardData(win32clipboard.CF_UNICODETEXT) + except Exception: + text = None + finally: + win32clipboard.CloseClipboard() + + if text and text != self._last: + self._last = text + self._emit(text) + except Exception: + pass + time.sleep(self._interval) + + def _emit(self, text: str): + now = datetime.now(timezone.utc).isoformat() + upload_event({ + "type": "clipboard", + "ts_start": now, + "ts_end": now, + "clipboard": sanitize(text[:4000]), + }) diff --git a/segundo-cerebro/collectors/keylogger.py b/segundo-cerebro/collectors/keylogger.py new file mode 100644 index 0000000..33926a6 --- /dev/null +++ b/segundo-cerebro/collectors/keylogger.py @@ -0,0 +1,40 @@ +import threading +from pynput import keyboard + + +class KeyLogger: + def __init__(self): + self._lock = threading.Lock() + self._buffer: list[str] = [] + self._listener = keyboard.Listener(on_press=self._on_press) + + def start(self): + self._listener.start() + + def stop(self): + self._listener.stop() + + def flush(self) -> str: + with self._lock: + text = "".join(self._buffer) + self._buffer.clear() + return text + + def _on_press(self, key): + try: + char = key.char + except AttributeError: + char = self._special(key) + if char: + with self._lock: + self._buffer.append(char) + + @staticmethod + def _special(key) -> str: + mapping = { + keyboard.Key.space: " ", + keyboard.Key.enter: "\n", + keyboard.Key.tab: "\t", + keyboard.Key.backspace: "\b", + } + return mapping.get(key, "") diff --git a/segundo-cerebro/collectors/ocr.py b/segundo-cerebro/collectors/ocr.py new file mode 100644 index 0000000..51aba97 --- /dev/null +++ b/segundo-cerebro/collectors/ocr.py @@ -0,0 +1,23 @@ +import os +import pytesseract +import mss +import mss.tools +from PIL import Image + +pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + + +def screenshot_and_ocr(save_path: str | None = None) -> tuple[str, str | None]: + """Take a screenshot, run OCR, optionally save. Returns (ocr_text, saved_path).""" + with mss.mss() as sct: + monitor = sct.monitors[1] + img = sct.grab(monitor) + + pil_img = Image.frombytes("RGB", img.size, img.bgra, "raw", "BGRX") + + if save_path: + os.makedirs(os.path.dirname(save_path), exist_ok=True) + pil_img.save(save_path, "PNG") + + text = pytesseract.image_to_string(pil_img, lang="por") + return text.strip(), save_path diff --git a/segundo-cerebro/collectors/window_monitor.py b/segundo-cerebro/collectors/window_monitor.py new file mode 100644 index 0000000..91f8bf7 --- /dev/null +++ b/segundo-cerebro/collectors/window_monitor.py @@ -0,0 +1,87 @@ +import os +import threading +import time +import win32gui +import win32process +import psutil +from datetime import datetime, timezone + +from collectors.keylogger import KeyLogger +from collectors.ocr import screenshot_and_ocr +from utils.sanitizer import sanitize +from storage.uploader import upload_event, upload_screenshot + + +class WindowMonitor: + def __init__(self, keylogger: KeyLogger, poll_interval: float = 1.0): + self._keylogger = keylogger + self._interval = poll_interval + self._current_hwnd = None + self._session_start: datetime | None = None + self._current_app = "" + self._current_title = "" + self._thread = threading.Thread(target=self._run, daemon=True) + + def start(self): + self._thread.start() + + def _run(self): + while True: + hwnd = win32gui.GetForegroundWindow() + if hwnd != self._current_hwnd: + self._on_window_change(hwnd) + time.sleep(self._interval) + + def _on_window_change(self, new_hwnd: int): + if self._current_hwnd is not None and self._session_start is not None: + self._flush_session() + + self._current_hwnd = new_hwnd + self._session_start = datetime.now(timezone.utc) + self._current_app = self._get_app(new_hwnd) + self._current_title = win32gui.GetWindowText(new_hwnd) + + def _flush_session(self): + ts_end = datetime.now(timezone.utc) + keystrokes = sanitize(self._keylogger.flush()) + duration = int((ts_end - self._session_start).total_seconds()) + + date_str = self._session_start.strftime("%Y-%m-%d") + time_str = self._session_start.strftime("%Hh%M") + snap_filename = f"{time_str}_{self._current_app[:20]}.png" + snap_dir = os.path.join("data", "screenshots", date_str) + snap_local = os.path.join(snap_dir, snap_filename) + + ocr_text, saved = screenshot_and_ocr(save_path=snap_local) + ocr_text = sanitize(ocr_text) + + screenshot_path = None + if saved: + try: + screenshot_path = upload_screenshot(saved, date_str, snap_filename) + os.remove(saved) + except Exception: + pass + + if not keystrokes and not ocr_text: + return + + upload_event({ + "type": "window_session", + "ts_start": self._session_start.isoformat(), + "ts_end": ts_end.isoformat(), + "app": self._current_app, + "window_title": self._current_title[:500], + "keystrokes": keystrokes or None, + "ocr_text": ocr_text[:8000] if ocr_text else None, + "screenshot_path": screenshot_path, + "duration_sec": duration, + }) + + @staticmethod + def _get_app(hwnd: int) -> str: + try: + _, pid = win32process.GetWindowThreadProcessId(hwnd) + return psutil.Process(pid).name() + except Exception: + return "unknown" diff --git a/segundo-cerebro/config/tracked_contacts.json b/segundo-cerebro/config/tracked_contacts.json new file mode 100644 index 0000000..98a5269 --- /dev/null +++ b/segundo-cerebro/config/tracked_contacts.json @@ -0,0 +1,36 @@ +{ + "groups": [ + { "jid": "***[REDACTED]***@g.us", "name": "🌐 [interno] Engaja" }, + { "jid": "***[REDACTED]***@g.us", "name": "TNL" }, + { "jid": "***[REDACTED]***@g.us", "name": "Squad Stack" }, + { "jid": "***[REDACTED]***@g.us", "name": "Escalada | Núcleo" }, + { "jid": "***[REDACTED]***@g.us", "name": "FIM DA ESCALA 6X1" }, + { "jid": "***[REDACTED]***@g.us", "name": "[coord] NEON Sebrae AL" }, + { "jid": "***[REDACTED]***@g.us", "name": "Time Escalada | Esquadrões" }, + { "jid": "***[REDACTED]***@g.us", "name": "CE Pernambuco" }, + { "jid": "***[REDACTED]***@g.us", "name": "Política sem filtro" }, + { "jid": "***[REDACTED]***@g.us", "name": "NEON 2026" }, + { "jid": "***[REDACTED]***@g.us", "name": "Escalada (interno)" }, + { "jid": "***[REDACTED]***@g.us", "name": "Gestão de Dados Escalada" }, + { "jid": "558299137239-1582941627@g.us", "name": "Eu (saved messages)" }, + { "jid": "***[REDACTED]***@g.us", "name": "Líderes squad" }, + { "jid": "***[REDACTED]***@g.us", "name": "🌐 Central de Engajamento" } + ], + "contacts": [ + { "jid": "558288542508@s.whatsapp.net", "name": "Lucas | Tiro na Lua" }, + { "jid": "558299014384@s.whatsapp.net", "name": "Leonardo Amorim" }, + { "jid": "***[REDACTED]***@lid", "name": "dev/tech (\"a computaria vai matando o xovem\")" }, + { "jid": "558298288583@s.whatsapp.net", "name": "Vitória Estenio" }, + { "jid": "558296292122@s.whatsapp.net", "name": "Abib Steves" }, + { "jid": "***[REDACTED]***@lid", "name": "Bot inscrições NEON (@estudante.rn.gov.br)" }, + { "jid": "***[REDACTED]***@lid", "name": "Mima do TNL (mimamirella@gmail.com)" }, + { "jid": "558181179771@s.whatsapp.net", "name": "Nanda | Tiro na Lua" }, + { "jid": "558299997934@s.whatsapp.net", "name": "Pedro Gabriel | Tiro Na Lua" }, + { "jid": "558281837070@s.whatsapp.net", "name": "Ing | Tiro Na Lua" }, + { "jid": "***[REDACTED]***@lid", "name": "LID (\"Chamaaaa!!!\" + documentos)" }, + { "jid": "***[REDACTED]***@lid", "name": "LID dev/tech (Netlify)" }, + { "jid": "558291057867@s.whatsapp.net", "name": "Aninha | Tiro na Lua" }, + { "jid": "558287555299@s.whatsapp.net", "name": "Gabriela Araujo | Tiro Na Lua" }, + { "jid": "***[REDACTED]***@lid", "name": "LID (\"Valeu, meu querido\")" } + ] +} diff --git a/segundo-cerebro/guide.md b/segundo-cerebro/guide.md new file mode 100644 index 0000000..9eea09f --- /dev/null +++ b/segundo-cerebro/guide.md @@ -0,0 +1,261 @@ +# Personal Knowledge Logger — GUIDE.md + +Sistema de captura passiva de contexto diário: o que você digitou, em qual janela, o que estava na tela, e o que foi dito no WhatsApp — tudo sumarizado automaticamente pelo Claude ao fim do dia. + +--- + +## Visão geral + +``` +[Windows local] [VPS — Supabase] +keylogger ──── sanitizer ──────────────► tabela: raw_events +ocr / screenshots ─────────────────────► bucket: screenshots +window_monitor ── sanitizer ───────────► tabela: raw_events + +[VPS — Evolution API] +WhatsApp msgs/áudios ──────────────────► tabela: evolution_api.Message + arquivos: /data/audio/*.ogg + +[22h — Windows local] + ├── puxa .ogg da VPS via SSH/SCP + ├── roda Whisper medium (local, CPU) + ├── push transcrições ─────────────────► tabela: audio_transcripts + └── Claude API + lê raw_events + audio_transcripts + WhatsApp msgs do Supabase + ─────────────────────────────────► tabela: daily_summaries +``` + +--- + +## Stack + +| Função | Biblioteca / Ferramenta | +|---|---| +| Detectar troca de janela | `pywin32` — `SetWinEventHook` (evento nativo, sem polling) | +| Título + processo da janela | `win32gui` + `win32process` | +| Keylogger global | `pynput` | +| Screenshot por troca de janela | `mss` | +| OCR do screenshot | `pytesseract` + Tesseract pt-BR | +| Clipboard monitor | `win32clipboard` | +| Transcrição de áudio (WhatsApp) | `faster-whisper` modelo `medium` (local) | +| Download áudios da VPS | `paramiko` (SSH/SCP) | +| Scheduler (22h) | `APScheduler` | +| Upload para Supabase | `supabase-py` | +| Sumarização diária | `anthropic` SDK (batch, não tempo real) | +| Sanitização de dados sensíveis | regex — mascara senhas e tokens antes de subir | + +--- + +## Estrutura de pastas + +``` +segundo-cerebro/ +├── collectors/ +│ ├── window_monitor.py # SetWinEventHook — janela ativa + título + processo +│ ├── keylogger.py # pynput — agrupa keystrokes por sessão de janela +│ ├── ocr.py # mss + pytesseract — screenshot na troca de janela +│ └── clipboard_monitor.py # win32clipboard — mudanças em background thread +├── pipeline/ +│ ├── audio_puller.py # SSH/SCP — baixa .ogg pendentes da VPS +│ ├── transcriber.py # faster-whisper medium — processa áudios localmente +│ ├── summarizer.py # Claude API — sumariza eventos do dia +│ └── scheduler.py # APScheduler — dispara pipeline às 22h +├── storage/ +│ └── uploader.py # push de eventos para Supabase (tabelas + bucket) +├── utils/ +│ └── sanitizer.py # regex — mascara senhas, tokens, cartões antes do upload +├── config/ +│ └── tracked_contacts.json # JIDs do WhatsApp monitorados (grupos + contatos) +├── data/ +│ └── audio/ # .ogg temporários (apagados após transcrição) +├── tasks.md # histórico de tarefas e mudanças +├── CLAUDE.md # contexto do projeto para Claude Code +├── requirements.txt +└── main.py # entrypoint — sobe coletores + scheduler +``` + +--- + +## Schema Supabase + +### `raw_events` + +```sql +create table raw_events ( + id uuid primary key default gen_random_uuid(), + type text not null, -- window_session | clipboard | ocr + ts_start timestamptz not null, + ts_end timestamptz, + app text, + window_title text, + keystrokes text, + ocr_text text, + clipboard text, + screenshot_path text, -- path no bucket screenshots + duration_sec int, + created_at timestamptz default now() +); +``` + +### `audio_transcripts` + +```sql +create table audio_transcripts ( + id uuid primary key default gen_random_uuid(), + remote_jid text not null, + message_id text, + audio_path text, + transcript text, + status text default 'pending', -- pending | done | error + processed_at timestamptz, + created_at timestamptz default now() +); +``` + +### `daily_summaries` + +```sql +create table daily_summaries ( + id uuid primary key default gen_random_uuid(), + date date unique not null, + summary text, + model text, + created_at timestamptz default now() +); +``` + +Bucket: `screenshots` (público: false, políticas via service role key). + +--- + +## Schema de evento (raw_events) + +Cada registro corresponde a uma sessão numa janela ou evento pontual ... [truncated at 50000 chars]https://github.com/eleotherium/VPS.git
Nenhum projeto cadastrado ainda.
A tabela public.projects está criada — pendente cadastrar manualmente ou via importador.
Quando houver projetos, esta aba mostra:
- cards de cada projeto (nome, status, owner, repos)
- linha do tempo agregada: dailies marcados, weeklies relacionados, commits, reuniões
- drill-down em qualquer evento → contexto original
Próximos passos
- Endpoint
POST /api/projectspra cadastrar manualmente - Linker LLM: enriquecer cada
daily_summarycom tag de projeto - Integração GitHub (read-only token pessoal + trabalho): cron pull de commits + diffs →
project_events - Schema
meetingsjá criado — pipeline noturno passa a incluí-las