diff --git a/docs/superpowers/specs/2026-06-18-memoria-perfil-curado-design.md b/docs/superpowers/specs/2026-06-18-memoria-perfil-curado-design.md index 043c92b..2ffb72a 100644 --- a/docs/superpowers/specs/2026-06-18-memoria-perfil-curado-design.md +++ b/docs/superpowers/specs/2026-06-18-memoria-perfil-curado-design.md @@ -70,7 +70,34 @@ Dar ao Zinom uma camada de **perfil curado por conta**, pequena e injetada em toda sessão MCP, alimentada por uma escada de confiança determinística (sinal → evidência → confirmado) e mantida por uma passada de curadoria que opera **só na tabela nova** (nunca em `brain_chunks`), com trilha de auditoria, -owner-first e tudo atrás de flag. +e tudo atrás de flag. + +## Atualização (2026-06-19): per-account + allowlist (pivô de produto) + +Motivo: o uso real do Bruno (Claude.ai + portal logado) é via conta **friend** +(gmail), enquanto o `owner` (`bruno`) só é usado via Claude Code (bearer). Um v1 +estritamente owner-only injetaria só no Claude Code e a UI owner-only não casaria +com a conta do portal. Decisão do Bruno (2026-06-19): **perfil por conta + UI no +portal Next.js + injeção governada por allowlist de contas** (não pelo booleano +`owner`). Mudanças vs. o desenho original: + +- **Injeção (D9):** `resolveInstructions` carrega os fatos da **conta da sessão** + (não `DEFAULT_ACCOUNT_ID`) e gateia por **allowlist** (`PROFILE_INJECT_ACCOUNTS`, + CSV de account_ids): `allowed = injectEnabled && !!accountId && + allowlist.includes(accountId)`. **Fail-closed reforçado:** cada conta só carrega + os **próprios** fatos, então accountId ausente/ambíguo não vaza (carrega vazio); + o allowlist controla o rollout. Não usar `getAccountId()` (fallback pra 'bruno'). +- **Curador (D10):** loop **por conta** (contas com linha em `user_profile_facts`), + não só o owner. Continua determinístico, só na tabela nova. +- **UI (novo):** página **Perfil** no portal Next.js (`web/app/(app)/perfil/`), + por conta (a sessão cura o próprio perfil). Rotas em `src/portal/routes.ts` + scoped por `res.locals.accountId`, sem gate de owner (a injeção é que é + allowlist-gated). +- **Rollout:** ligar `PROFILE_INJECT_ENABLED` + pôr a(s) conta(s) do Bruno em + `PROFILE_INJECT_ACCOUNTS`. Friends seguem sem injeção até entrarem no allowlist. + +O resto do desenho (tabela, confiança, render, orçamento, auditoria, fora do eval +gate, guarda de segredo) continua valendo. ## Decisões de design (com tradeoff) diff --git a/src/__tests__/profile-injection.test.ts b/src/__tests__/profile-injection.test.ts index 03a0d99..bf2f50c 100644 --- a/src/__tests__/profile-injection.test.ts +++ b/src/__tests__/profile-injection.test.ts @@ -1,9 +1,10 @@ // src/__tests__/profile-injection.test.ts -// T6 (CRÍTICO DE SEGURANÇA): resolveInstructions injeta o perfil curado nas -// `instructions` da sessão MCP de forma FAIL-CLOSED e gated por flag. Um bug aqui -// vazaria o perfil do owner para um friend. Estes testes são PUROS (sem Postgres, -// sem Express): o carregamento de fatos é injetado (loadFacts fake/spy), então -// um friend NUNCA pode tocar os fatos do owner. +// CRÍTICO DE SEGURANÇA: resolveInstructions injeta o perfil curado nas +// `instructions` da sessão MCP de forma FAIL-CLOSED, gated por flag E por +// allowlist per-account. Um bug aqui vazaria o perfil de uma conta para outra. +// Estes testes são PUROS (sem Postgres, sem Express): o carregamento de fatos é +// injetado (loadFacts fake/spy), então uma conta fora do allowlist NUNCA pode +// tocar os fatos, e a conta A nunca recebe os fatos da conta B. import { test } from "node:test"; import assert from "node:assert/strict"; import { @@ -35,58 +36,128 @@ function makeFact(overrides: Partial = {}): ProfileFact { }; } -// Um spy de loadFacts que FALHA o teste se for chamado: prova que o owner=false -// (friend) curto-circuita ANTES de qualquer carregamento de fatos do owner. +// Um spy de loadFacts que FALHA o teste se for chamado: prova fail-closed (a +// conta fora do allowlist / sem accountId curto-circuita ANTES de carregar fatos). function neverCalled(): (accountId: string) => Promise { return async () => { throw new Error("loadFacts NÃO deveria ter sido chamado (fail-closed violado)"); }; } -// Um spy que, se chamado, devolveria os fatos do OWNER — usado para provar que o -// resultado para um friend não contém nenhum byte desses fatos. -function spyThatReturnsOwnerFacts(): { +// Spy que registra COM QUAL accountId loadFacts foi chamado e devolve um fato +// marcado com esse accountId — usado para provar isolamento per-account. +function spyLoad(): { fn: (accountId: string) => Promise; - called: () => boolean; + calls: () => string[]; } { - let was = false; - const fn = async () => { - was = true; - return [makeFact()]; + const calls: string[] = []; + const fn = async (accountId: string) => { + calls.push(accountId); + return [makeFact({ content: `FATO_DA_CONTA_${accountId}` })]; }; - return { fn, called: () => was }; + return { fn, calls: () => calls }; } -// AC5 — flag off = baseline exato, e loadFacts NUNCA é chamado. -test("AC5: flag off → owner recebe OWNER_INSTRUCTIONS sem tocar loadFacts", async () => { +// --- allowlist: conta liberada injeta os PRÓPRIOS fatos -------------------- +test("allowlist: accountId no allowlist + flag on → injeta os fatos DAQUELA conta", async () => { + const spy = spyLoad(); + const out = await resolveInstructions({ + owner: false, + injectEnabled: true, + accountId: "acct_A", + allowlist: ["acct_A", "acct_B"], + loadFacts: spy.fn, + }); + assert.deepEqual(spy.calls(), ["acct_A"], "loadFacts chamado com a conta REAL da sessão"); + assert.ok(out.includes(FRIEND_INSTRUCTIONS), "mantém a base"); + assert.ok(out.includes("FATO_DA_CONTA_acct_A"), "injeta os fatos da própria conta"); +}); + +test("allowlist (owner): owner no allowlist + flag on → base owner + perfil da conta owner", async () => { + const spy = spyLoad(); const out = await resolveInstructions({ owner: true, - injectEnabled: false, - defaultAccountId: "bruno", + injectEnabled: true, + accountId: "bruno", + allowlist: ["bruno"], + loadFacts: spy.fn, + }); + assert.deepEqual(spy.calls(), ["bruno"]); + assert.ok(out.includes(OWNER_INSTRUCTIONS), "mantém a base owner íntegra"); + assert.ok(out.includes("FATO_DA_CONTA_bruno")); +}); + +// --- fail-closed: fora do allowlist = base pura, sem tocar loadFacts ------- +test("fail-closed: accountId FORA do allowlist → base pura, loadFacts NÃO chamado", async () => { + const out = await resolveInstructions({ + owner: false, + injectEnabled: true, + accountId: "acct_intruso", + allowlist: ["acct_A"], loadFacts: neverCalled(), }); - assert.equal(out, OWNER_INSTRUCTIONS); + assert.equal(out, FRIEND_INSTRUCTIONS, "fora do allowlist = base friend pura"); }); -test("AC5: flag off → friend recebe FRIEND_INSTRUCTIONS sem tocar loadFacts", async () => { +test("fail-closed: accountId null → base pura, loadFacts NÃO chamado", async () => { + const out = await resolveInstructions({ + owner: false, + injectEnabled: true, + accountId: null, + allowlist: ["acct_A"], + loadFacts: neverCalled(), + }); + assert.equal(out, FRIEND_INSTRUCTIONS); +}); + +test("fail-closed: accountId '' (vazio) → base pura, loadFacts NÃO chamado", async () => { + const out = await resolveInstructions({ + owner: true, + injectEnabled: true, + accountId: "", + allowlist: ["", "bruno"], // mesmo que '' esteja no array, !!accountId barra + loadFacts: neverCalled(), + }); + assert.equal(out, OWNER_INSTRUCTIONS, "string vazia nunca injeta (fail-closed)"); +}); + +test("fail-closed: flag OFF → base pura mesmo no allowlist, loadFacts NÃO chamado", async () => { const out = await resolveInstructions({ owner: false, injectEnabled: false, - defaultAccountId: "bruno", + accountId: "acct_A", + allowlist: ["acct_A"], loadFacts: neverCalled(), }); assert.equal(out, FRIEND_INSTRUCTIONS); }); -// AC4 — fail-closed. -test("AC4: isOwnerContext é false para OAuth friend com accountId", () => { +// --- ISOLAMENTO: conta A nunca recebe os fatos da conta B ------------------ +test("isolamento: a conta A só recebe os PRÓPRIOS fatos (nunca os da conta B)", async () => { + const spy = spyLoad(); + const out = await resolveInstructions({ + owner: false, + injectEnabled: true, + accountId: "acct_A", + allowlist: ["acct_A", "acct_B"], + loadFacts: spy.fn, + }); + // loadFacts foi chamado SÓ com acct_A; nunca com acct_B nem com um DEFAULT. + assert.deepEqual(spy.calls(), ["acct_A"]); + assert.ok(out.includes("FATO_DA_CONTA_acct_A"), "contém os fatos da própria conta"); + assert.ok(!out.includes("FATO_DA_CONTA_acct_B"), "NUNCA contém os fatos de outra conta"); + assert.ok(!out.includes("FATO_DA_CONTA_bruno"), "NUNCA cai num DEFAULT account"); +}); + +// --- isOwnerContext (fail-closed do owner-gate, inalterado) ---------------- +test("isOwnerContext é false para OAuth friend com accountId", () => { assert.equal( isOwnerContext({ authType: "oauth", scopes: ["personal"], accountId: "friend:x" }), false, ); }); -test("AC4: isOwnerContext é false para OAuth sem accountId e sem isOperator", () => { +test("isOwnerContext é false para OAuth sem accountId e sem isOperator", () => { assert.equal( isOwnerContext({ authType: "oauth", @@ -98,35 +169,31 @@ test("AC4: isOwnerContext é false para OAuth sem accountId e sem isOperator", ( ); }); -test("AC4: flag on + friend → NÃO contém fatos do owner E loadFacts NÃO é chamado", async () => { - const spy = spyThatReturnsOwnerFacts(); - const out = await resolveInstructions({ - owner: false, - injectEnabled: true, - defaultAccountId: "bruno", - loadFacts: spy.fn, - }); - // owner=false curto-circuita: nunca carrega nem renderiza fatos do owner. - assert.equal(spy.called(), false, "loadFacts não pode ser chamado para friend"); - assert.equal(out, FRIEND_INSTRUCTIONS, "friend recebe a base friend pura"); - assert.ok( - !out.includes("OWNER_SECRET_FACT_marcador_unico"), - "resultado do friend não pode conter nenhum byte dos fatos do owner", - ); +// --- index wiring: parse de PROFILE_INJECT_ACCOUNTS ------------------------ +// Reproduz EXATAMENTE a expressão de index.ts (CSV → string[] aparado, sem +// vazios). Mantido como teste puro: index.ts arrasta Express/clients no load. +function parseInjectAccounts(raw: string | undefined): string[] { + return (raw ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +test("PROFILE_INJECT_ACCOUNTS: CSV com espaços é aparado", () => { + assert.deepEqual(parseInjectAccounts(" bruno , acct_A ,acct_B "), [ + "bruno", + "acct_A", + "acct_B", + ]); }); -// owner on — perfil é injetado. -test("owner on: contém OWNER_INSTRUCTIONS E o conteúdo do fato pinned", async () => { - const pinned = makeFact({ content: "Bruno prefere respostas curtas em PT-BR." }); - const out = await resolveInstructions({ - owner: true, - injectEnabled: true, - defaultAccountId: "bruno", - loadFacts: async () => [pinned], - }); - assert.ok(out.includes(OWNER_INSTRUCTIONS), "deve conter a base owner íntegra"); - assert.ok( - out.includes("Bruno prefere respostas curtas em PT-BR."), - "deve conter o conteúdo do fato pinned", - ); +test("PROFILE_INJECT_ACCOUNTS: vazio/undefined → []", () => { + assert.deepEqual(parseInjectAccounts(""), []); + assert.deepEqual(parseInjectAccounts(undefined), []); + assert.deepEqual(parseInjectAccounts(" "), []); + assert.deepEqual(parseInjectAccounts(",, ,"), [], "só separadores/vazios → []"); +}); + +test("PROFILE_INJECT_ACCOUNTS: conta única", () => { + assert.deepEqual(parseInjectAccounts("bruno"), ["bruno"]); }); diff --git a/src/index.ts b/src/index.ts index bde26aa..d42e1a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,7 +36,7 @@ import { createRubrixRouter } from "./rubrix/routes.js"; import { registerRubrixTools } from "./rubrix/tools.js"; import { resolveBearer, accountWorkspaces } from "./account-bearer.js"; import { isAccountActive } from "./account-status.js"; -import { requestContext, getContext, getAccountId, DEFAULT_ACCOUNT_ID, type RequestContext } from "./context.js"; +import { requestContext, getContext, getAccountId, type RequestContext } from "./context.js"; import { isOwnerContext, isOperatorToken, resolveInstructions } from "./mcp-account-config.js"; import { loadProfileFacts } from "./rag/profile-storage.js"; import { ALL_WORKSPACES } from "./clients.js"; @@ -132,10 +132,16 @@ app.use((req, _res, next) => { // Auth middleware for /mcp — accepts static BEARER_TOKEN or OAuth access tokens const BEARER_TOKEN = process.env.BEARER_TOKEN; -// T6 — gate (default OFF) para injetar o perfil curado do owner nas instructions -// da sessão. Fail-closed: o perfil só é carregado para o owner (ver -// resolveInstructions); um friend nunca toca os fatos do owner, flag ligada ou não. +// Gate (default OFF) para injetar o perfil curado nas instructions da sessão. +// Pivô per-account + allowlist: a injeção exige a flag ligada E a conta REAL da +// sessão estar na PROFILE_INJECT_ACCOUNTS (CSV). Fail-closed: conta ausente ou +// fora do allowlist nunca carrega fatos (ver resolveInstructions). Cada conta só +// carrega os PRÓPRIOS fatos. const PROFILE_INJECT_ENABLED = process.env.PROFILE_INJECT_ENABLED === "true"; +const PROFILE_INJECT_ACCOUNTS = (process.env.PROFILE_INJECT_ACCOUNTS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); // OAuth routes (well-known, register, authorize, token, admin) app.use(createOAuthRouter(BASE_URL, BEARER_TOKEN)); @@ -452,13 +458,18 @@ app.post("/mcp", async (req, res) => { if (id) evictSession(id); }; - // T6 — instructions da sessão (owner/friend), opcionalmente enriquecidas com o - // perfil curado do owner quando PROFILE_INJECT_ENABLED. Fail-closed: friend nunca - // carrega os fatos do owner. Montado ANTES de instanciar o server (handler async). + // Instructions da sessão (owner/friend), opcionalmente enriquecidas com o perfil + // curado DA PRÓPRIA CONTA quando PROFILE_INJECT_ENABLED e a conta está na + // allowlist. Fail-closed per-account: passamos a conta REAL da sessão (sem o + // fallback 'bruno' de getAccountId) — null se o request não resolveu conta — e + // resolveInstructions só injeta se accountId não-vazio E na allowlist; cada conta + // carrega só os próprios fatos. Montado ANTES de instanciar o server. + const realAccountId = getContext()?.accountId ?? null; const instructions = await resolveInstructions({ owner, injectEnabled: PROFILE_INJECT_ENABLED, - defaultAccountId: DEFAULT_ACCOUNT_ID, + accountId: realAccountId, + allowlist: PROFILE_INJECT_ACCOUNTS, loadFacts: loadProfileFacts, }); diff --git a/src/mcp-account-config.ts b/src/mcp-account-config.ts index b9f83d0..17b3547 100644 --- a/src/mcp-account-config.ts +++ b/src/mcp-account-config.ts @@ -62,14 +62,19 @@ export function composeInstructions(base: string, profileBlock: string | null): } /** - * T6 (CRÍTICO DE SEGURANÇA): resolve as `instructions` da sessão MCP, opcionalmente - * enriquecidas com o perfil curado do owner. FAIL-CLOSED por desenho: + * CRÍTICO DE SEGURANÇA: resolve as `instructions` da sessão MCP, opcionalmente + * enriquecidas com o perfil curado DA PRÓPRIA CONTA da sessão. FAIL-CLOSED por + * desenho (pivô per-account + allowlist): * * - `base` = owner ? OWNER_INSTRUCTIONS : FRIEND_INSTRUCTIONS. - * - O perfil só é carregado/renderizado quando `injectEnabled && owner` for true. - * Um friend (owner=false) NUNCA chama `loadFacts`: o perfil curado é do owner e - * carregá-lo no caminho de um friend já seria um vazamento (mesmo que o bloco - * fosse descartado depois). O curto-circuito `owner &&` garante isso. + * - O perfil só é carregado/renderizado quando TODAS as condições valem: + * `injectEnabled` (flag global) E `accountId` presente (string não-vazia) E + * `allowlist.includes(accountId)`. Qualquer condição falsa ⇒ profileBlock null. + * - FAIL-CLOSED: `accountId` ausente/vazio (null/undefined/'') OU fora do allowlist + * ⇒ NUNCA carrega fatos, NUNCA injeta. O curto-circuito `allowed &&` garante que + * `loadFacts` só é chamado para uma conta explicitamente liberada. + * - ISOLAMENTO: cada conta carrega SÓ os PRÓPRIOS fatos — `loadFacts(accountId)`, + * nunca um DEFAULT. A conta A jamais recebe os fatos da conta B. * - O resultado SEMPRE contém `base` (composeInstructions nunca devolve '' nem a * destrói): mesmo sem fatos elegíveis, a sessão mantém suas instructions. * @@ -80,7 +85,8 @@ export function composeInstructions(base: string, profileBlock: string | null): export async function resolveInstructions(args: { owner: boolean; injectEnabled: boolean; - defaultAccountId: string; + accountId: string | null | undefined; // conta REAL da sessão (fail-closed) + allowlist: string[]; // PROFILE_INJECT_ACCOUNTS parseado loadFacts: (accountId: string) => Promise; render?: (facts: ProfileFact[], budget: number) => string | null; budget?: number; @@ -89,12 +95,15 @@ export async function resolveInstructions(args: { const render = args.render ?? renderProfile; const budget = args.budget ?? PROFILE_CHAR_BUDGET; - // FAIL-CLOSED: o perfil curado é do owner. Só carrega quando a flag está ligada - // E o request é do owner. Para um friend, NUNCA toca loadFacts. - const profileBlock = - args.injectEnabled && args.owner - ? render(await args.loadFacts(args.defaultAccountId), budget) - : null; + // FAIL-CLOSED: injeta só com flag ligada, accountId presente E na allowlist. + // accountId vazio/ausente ou fora do allowlist ⇒ nunca toca loadFacts. + const allowed = + args.injectEnabled && !!args.accountId && args.allowlist.includes(args.accountId); + + // ISOLAMENTO: cada conta carrega SÓ os próprios fatos (loadFacts(accountId)). + const profileBlock = allowed + ? render(await args.loadFacts(args.accountId!), budget) + : null; return composeInstructions(base, profileBlock); } diff --git a/src/portal/__tests__/profile-routes.test.ts b/src/portal/__tests__/profile-routes.test.ts new file mode 100644 index 0000000..124e86a --- /dev/null +++ b/src/portal/__tests__/profile-routes.test.ts @@ -0,0 +1,387 @@ +// src/portal/__tests__/profile-routes.test.ts +// Rotas /portal/profile (perfil curado per-account): exigem sessão, escopadas na +// conta da sessão (res.locals.accountId, nunca input), :id valida +// WHERE id=$1 AND account_id=$2 (cross-account → 404). Pool em memória (sem DB +// real): um store de fatos por account_id que reproduz SELECT/INSERT/UPDATE/DELETE +// de user_profile_facts + INSERT memory_audit, então as asserções são puras. +import { test, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import type { Server } from "node:http"; + +import { createPortalRouter } from "../routes.js"; +import { __setPoolForTest } from "../../rag/storage.js"; +import { hashSession } from "../session.js"; + +const SID = "test-session-profile"; +const ACCOUNT = "acct_profile"; +const OTHER = "acct_other"; + +interface FactRow { + id: number; + account_id: string; + category: string; + content: string; + status: string; + applied_count: number; + violated_count: number; + confidence_value: number; + confidence_band: string; + pinned: boolean; + source: string | null; + content_hash: string | null; + last_evidence_at: Date | null; + updated_at: Date | null; +} + +let facts: FactRow[]; +let audits: Array>; +let nextId: number; + +// Minimal SQL interpreter over the in-memory `facts` store. Matches only the +// statements profile-portal.ts emits (plus the session lookup requireSession does). +function memPool() { + return { + query: async (sql: string, params: any[] = []) => { + // requireSession: resolve account from session hash. + if (/SELECT account_id FROM portal_sessions/i.test(sql)) { + return params[0] === hashSession(SID) + ? { rows: [{ account_id: ACCOUNT }] } + : { rows: [] }; + } + if (/UPDATE portal_sessions/i.test(sql)) return { rows: [], rowCount: 1 }; + + // memory_audit append. + if (/INSERT INTO memory_audit/i.test(sql)) { + audits.push({ + account_id: params[0], + fact_id: params[1], + from_state: params[2], + to_state: params[3], + trigger: params[4], + }); + return { rows: [], rowCount: 1 }; + } + + // List facts for account (GET). + if (/SELECT[\s\S]*FROM user_profile_facts\s+WHERE account_id = \$1/i.test(sql)) { + const rows = facts + .filter((f) => f.account_id === params[0]) + .sort((a, b) => + a.pinned !== b.pinned ? (a.pinned ? -1 : 1) : b.confidence_value - a.confidence_value, + ); + return { rows }; + } + + // Fetch one by id + account (getRow). + if (/SELECT[\s\S]*FROM user_profile_facts WHERE id = \$1 AND account_id = \$2/i.test(sql)) { + const row = facts.find((f) => f.id === params[0] && f.account_id === params[1]); + return { rows: row ? [row] : [] }; + } + + // Create (INSERT ... ON CONFLICT ... RETURNING id). + if (/INSERT INTO user_profile_facts/i.test(sql)) { + // params: [accountId, category, content, contentHash] + const id = nextId++; + facts.push({ + id, + account_id: params[0], + category: params[1], + content: params[2], + status: "confirmed", + applied_count: 0, + violated_count: 0, + confidence_value: 1, + confidence_band: "high", + pinned: true, + source: "manual", + content_hash: params[3], + last_evidence_at: null, + updated_at: new Date(), + }); + return { rows: [{ id }] }; + } + + // PATCH (UPDATE ... SET content, category, pinned, content_hash ...). + if (/UPDATE user_profile_facts\s+SET content = \$3/i.test(sql)) { + // params: [id, accountId, content, category, pinned, contentHash] + const f = facts.find((x) => x.id === params[0] && x.account_id === params[1]); + if (!f) return { rows: [] }; + f.content = params[2]; + f.category = params[3]; + f.pinned = params[4]; + f.content_hash = params[5]; + f.updated_at = new Date(); + return { rows: [f] }; + } + + // Evidence (UPDATE ... SET applied_count ...). + if (/UPDATE user_profile_facts\s+SET applied_count = \$3/i.test(sql)) { + // params: [id, accountId, applied, violated, value, band, now] + const f = facts.find((x) => x.id === params[0] && x.account_id === params[1]); + if (!f) return { rows: [] }; + f.applied_count = params[2]; + f.violated_count = params[3]; + f.confidence_value = params[4]; + f.confidence_band = params[5]; + f.last_evidence_at = params[6]; + f.updated_at = new Date(); + return { rows: [f] }; + } + + // DELETE. + if (/DELETE FROM user_profile_facts WHERE id = \$1 AND account_id = \$2/i.test(sql)) { + const before = facts.length; + facts = facts.filter((f) => !(f.id === params[0] && f.account_id === params[1])); + return { rows: [], rowCount: before - facts.length }; + } + + return { rows: [] }; + }, + }; +} + +function seedFact(overrides: Partial = {}): FactRow { + const row: FactRow = { + id: nextId++, + account_id: ACCOUNT, + category: "estilo", + content: "Prefere respostas concisas", + status: "confirmed", + applied_count: 2, + violated_count: 0, + confidence_value: 0.9, + confidence_band: "high", + pinned: false, + source: "manual", + content_hash: `h${nextId}`, + last_evidence_at: null, + updated_at: new Date(), + ...overrides, + }; + facts.push(row); + return row; +} + +let server: Server; +let base = ""; + +before(async () => { + const app = express(); + app.use(express.json()); + app.use(createPortalRouter()); + await new Promise((resolve) => { + server = app.listen(0, () => { + const addr = server.address(); + base = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`; + resolve(); + }); + }); +}); + +after(() => { + server?.close(); + __setPoolForTest(null); + delete process.env.PROFILE_INJECT_ENABLED; + delete process.env.PROFILE_INJECT_ACCOUNTS; +}); + +beforeEach(() => { + facts = []; + audits = []; + nextId = 1; + __setPoolForTest(memPool() as never); + delete process.env.PROFILE_INJECT_ENABLED; + delete process.env.PROFILE_INJECT_ACCOUNTS; +}); + +const cookie = { cookie: `portal_session=${SID}` }; +const jsonHeaders = { ...cookie, "content-type": "application/json" }; + +// --- auth ------------------------------------------------------------------- +test("sem sessão → 401 nas rotas de perfil", async () => { + for (const [method, path] of [ + ["GET", "/portal/profile"], + ["POST", "/portal/profile"], + ["PATCH", "/portal/profile/1"], + ["POST", "/portal/profile/1/evidence"], + ["DELETE", "/portal/profile/1"], + ] as const) { + const res = await fetch(`${base}${path}`, { method }); + assert.equal(res.status, 401, `${method} ${path}`); + } +}); + +// --- GET -------------------------------------------------------------------- +test("GET /portal/profile → DTO + eligible + budget + injecting", async () => { + seedFact({ id: 1, pinned: true, content: "Bruno usa PT-BR", confidence_value: 0.5, status: "signal" }); + seedFact({ id: 2, pinned: false, status: "confirmed", confidence_value: 0.9 }); // eligible + seedFact({ id: 3, pinned: false, status: "evidence", confidence_value: 0.5 }); // não eligible + + const res = await fetch(`${base}/portal/profile`, { headers: cookie }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.budgetChars, 2800); + assert.ok(body.usedChars > 0, "usedChars vem do renderProfile real"); + assert.equal(body.injecting, false, "flag off → não injetando"); + + const byId = new Map(body.facts.map((f: any) => [f.id, f])); + assert.equal(byId.get(1).eligible, true, "pinned → eligible mesmo com confiança baixa"); + assert.equal(byId.get(2).eligible, true, "confirmed + conf>=0.75 → eligible"); + assert.equal(byId.get(3).eligible, false, "evidence/conf baixa → não eligible"); + // DTO tem as chaves do contrato. + const dto = byId.get(1); + for (const k of [ + "id", "category", "content", "status", "confidence_value", "confidence_band", + "pinned", "applied_count", "violated_count", "eligible", "updated_at", + ]) { + assert.ok(k in dto, `DTO deve ter ${k}`); + } +}); + +test("GET /portal/profile: injecting=true só com flag on E conta no allowlist", async () => { + process.env.PROFILE_INJECT_ENABLED = "true"; + process.env.PROFILE_INJECT_ACCOUNTS = `outra,${ACCOUNT}`; + const res = await fetch(`${base}/portal/profile`, { headers: cookie }); + const body = await res.json(); + assert.equal(body.injecting, true); + + // Conta fora do allowlist → false. + process.env.PROFILE_INJECT_ACCOUNTS = "outra"; + const res2 = await fetch(`${base}/portal/profile`, { headers: cookie }); + assert.equal((await res2.json()).injecting, false); +}); + +// --- POST ------------------------------------------------------------------- +test("POST /portal/profile cria pinned/confirmed → 201 {id} + audit portal-create", async () => { + const res = await fetch(`${base}/portal/profile`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ category: "estilo", content: "Prefere bullet points" }), + }); + assert.equal(res.status, 201); + const body = await res.json(); + assert.ok(typeof body.id === "number"); + const created = facts.find((f) => f.id === body.id)!; + assert.equal(created.account_id, ACCOUNT, "gravado na conta da sessão"); + assert.equal(created.pinned, true); + assert.equal(created.status, "confirmed"); + assert.equal(created.confidence_value, 1); + assert.equal(created.source, "manual"); + assert.ok(audits.some((a) => a.trigger === "portal-create")); +}); + +test("POST /portal/profile rejeita segredo → 400 looks_like_secret (nada gravado)", async () => { + const res = await fetch(`${base}/portal/profile`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ category: "x", content: "minha chave é sk-ant-abcdefghijklmnopqrstuvwxyz1234" }), + }); + assert.equal(res.status, 400); + assert.equal((await res.json()).error, "looks_like_secret"); + assert.equal(facts.length, 0, "segredo não pode ser gravado"); +}); + +test("POST /portal/profile rejeita vazio e > 500 chars → 400 invalid", async () => { + const empty = await fetch(`${base}/portal/profile`, { + method: "POST", headers: jsonHeaders, body: JSON.stringify({ content: " " }), + }); + assert.equal(empty.status, 400); + assert.equal((await empty.json()).error, "invalid"); + + const tooLong = await fetch(`${base}/portal/profile`, { + method: "POST", headers: jsonHeaders, body: JSON.stringify({ content: "a".repeat(501) }), + }); + assert.equal(tooLong.status, 400); + assert.equal((await tooLong.json()).error, "invalid"); + assert.equal(facts.length, 0); +}); + +// --- PATCH ------------------------------------------------------------------ +test("PATCH /portal/profile/:id atualiza pinned → 200 DTO + audit portal-edit", async () => { + seedFact({ id: 5, pinned: true }); + const res = await fetch(`${base}/portal/profile/5`, { + method: "PATCH", headers: jsonHeaders, body: JSON.stringify({ pinned: false }), + }); + assert.equal(res.status, 200); + const dto = await res.json(); + assert.equal(dto.pinned, false); + assert.equal(facts.find((f) => f.id === 5)!.pinned, false); + assert.ok(audits.some((a) => a.trigger === "portal-edit")); +}); + +test("PATCH com content secreto → 400 looks_like_secret", async () => { + seedFact({ id: 6 }); + const res = await fetch(`${base}/portal/profile/6`, { + method: "PATCH", headers: jsonHeaders, + body: JSON.stringify({ content: "AKIAIOSFODNN7EXAMPLE token vazado" }), + }); + assert.equal(res.status, 400); + assert.equal((await res.json()).error, "looks_like_secret"); +}); + +// --- evidence --------------------------------------------------------------- +test("POST /portal/profile/:id/evidence incrementa e recomputa confiança → 200 DTO", async () => { + seedFact({ id: 7, applied_count: 1, violated_count: 0, confidence_value: 0.3, confidence_band: "low" }); + const res = await fetch(`${base}/portal/profile/7/evidence`, { + method: "POST", headers: jsonHeaders, body: JSON.stringify({ kind: "applied" }), + }); + assert.equal(res.status, 200); + const dto = await res.json(); + assert.equal(dto.applied_count, 2, "applied_count incrementado"); + // computeConfidence(2,0,now,now) = 2/(2+0+2)*1 = 0.5 → band medium. + assert.ok(Math.abs(dto.confidence_value - 0.5) < 1e-9); + assert.equal(dto.confidence_band, "medium"); + const f = facts.find((x) => x.id === 7)!; + assert.ok(f.last_evidence_at instanceof Date, "last_evidence_at setado"); + assert.ok(audits.some((a) => a.trigger === "portal-evidence")); +}); + +test("POST evidence com kind inválido → 400", async () => { + seedFact({ id: 8 }); + const res = await fetch(`${base}/portal/profile/8/evidence`, { + method: "POST", headers: jsonHeaders, body: JSON.stringify({ kind: "nope" }), + }); + assert.equal(res.status, 400); +}); + +// --- DELETE ----------------------------------------------------------------- +test("DELETE /portal/profile/:id → 204 + audit portal-delete", async () => { + seedFact({ id: 9 }); + const res = await fetch(`${base}/portal/profile/9`, { method: "DELETE", headers: cookie }); + assert.equal(res.status, 204); + assert.equal(facts.find((f) => f.id === 9), undefined); + assert.ok(audits.some((a) => a.trigger === "portal-delete")); +}); + +// --- isolamento cross-account ---------------------------------------------- +test("isolamento: :id de OUTRA conta → 404 (GET via PATCH/evidence/DELETE)", async () => { + // Fato pertence a OTHER, não à conta da sessão (ACCOUNT). + seedFact({ id: 50, account_id: OTHER }); + + const patch = await fetch(`${base}/portal/profile/50`, { + method: "PATCH", headers: jsonHeaders, body: JSON.stringify({ pinned: true }), + }); + assert.equal(patch.status, 404, "PATCH em fato de outra conta → 404"); + + const ev = await fetch(`${base}/portal/profile/50/evidence`, { + method: "POST", headers: jsonHeaders, body: JSON.stringify({ kind: "applied" }), + }); + assert.equal(ev.status, 404, "evidence em fato de outra conta → 404"); + + const del = await fetch(`${base}/portal/profile/50`, { method: "DELETE", headers: cookie }); + assert.equal(del.status, 404, "DELETE de fato de outra conta → 404"); + + // O fato da outra conta segue intacto e nenhum audit da sessão tocou nele. + assert.ok(facts.find((f) => f.id === 50 && f.account_id === OTHER), "fato da outra conta intacto"); + for (const a of audits) assert.equal(a.account_id, ACCOUNT, "audits só da conta da sessão"); +}); + +test("PATCH/evidence/DELETE de id inexistente → 404", async () => { + const patch = await fetch(`${base}/portal/profile/999`, { + method: "PATCH", headers: jsonHeaders, body: JSON.stringify({ pinned: true }), + }); + assert.equal(patch.status, 404); + const del = await fetch(`${base}/portal/profile/999`, { method: "DELETE", headers: cookie }); + assert.equal(del.status, 404); +}); diff --git a/src/portal/profile-portal.ts b/src/portal/profile-portal.ts new file mode 100644 index 0000000..7e5d1d0 --- /dev/null +++ b/src/portal/profile-portal.ts @@ -0,0 +1,290 @@ +// src/portal/profile-portal.ts +// Per-account portal helpers for the curated memory profile (user_profile_facts + +// memory_audit, migration 0019). These back the /portal/profile routes so a +// signed-in account can see, pin/edit, register evidence on, and prune its OWN +// curated facts. Reuses the pure pieces: renderProfile/INJECT_MIN_CONFIDENCE +// (profile.ts), computeConfidence (utility.ts), looksLikeSecret (profile-guard.ts). +// +// HARD MULTI-TENANT RULE (mirrors profile-storage.ts): account_id is ALWAYS an +// explicit parameter (never request input — the route passes res.locals.accountId) +// and ALWAYS appears in the columns AND the WHERE / conflict target of every +// statement. A `:id` lookup is `WHERE id=$1 AND account_id=$2`, so one account can +// never read/mutate another's fact (returns null → the route answers 404). +import { createHash } from "node:crypto"; +import pg from "pg"; +import { getPool } from "../rag/storage.js"; +import { + renderProfile, + PROFILE_CHAR_BUDGET, + INJECT_MIN_CONFIDENCE, + type ProfileFact, +} from "../rag/profile.js"; +import { computeConfidence } from "../rag/utility.js"; + +type PoolLike = Pick; + +/** Max chars a manually-entered fact may hold (route rejects > this). */ +export const PROFILE_CONTENT_MAX = 500; + +/** The shape the portal returns for one fact. `eligible` = whether the fact would + * be injected (pinned, or confirmed with enough confidence) — mirrors the pure + * isEligible() in profile.ts so the UI shows exactly what the MCP would inject. */ +export interface ProfileFactDTO { + id: number; + category: string; + content: string; + status: ProfileFact["status"]; + confidence_value: number; + confidence_band: ProfileFact["confidence_band"]; + pinned: boolean; + applied_count: number; + violated_count: number; + eligible: boolean; + updated_at: string | null; +} + +/** Full row used internally (superset of ProfileFact with updated_at). */ +interface ProfileFactFullRow { + id: number | string; + account_id: string; + category: string; + content: string; + status: string; + applied_count: number; + violated_count: number; + confidence_value: number; + confidence_band: string; + pinned: boolean; + source: string | null; + content_hash: string | null; + last_evidence_at: Date | null; + updated_at: Date | null; +} + +const SELECT_COLS = `id, account_id, category, content, status, + applied_count, violated_count, confidence_value, confidence_band, + pinned, source, content_hash, last_evidence_at, updated_at`; + +/** Account-scoped content_hash: sha256(accountId + '\0' + content). The NUL + * separator makes the (account, content) pair unambiguous so two accounts with + * the same content get different hashes, and the same account dedups by content + * via the (account_id, content_hash) UNIQUE key. */ +export function profileContentHash(accountId: string, content: string): string { + return createHash("sha256").update(`${accountId}\0${content}`).digest("hex"); +} + +/** isEligible, mirrored from profile.ts (not exported there): pinned, or + * confirmed with confidence >= INJECT_MIN_CONFIDENCE. */ +function isEligible(r: { pinned: boolean; status: string; confidence_value: number }): boolean { + if (r.pinned) return true; + return r.status === "confirmed" && r.confidence_value >= INJECT_MIN_CONFIDENCE; +} + +function rowToDTO(r: ProfileFactFullRow): ProfileFactDTO { + return { + id: Number(r.id), + category: r.category, + content: r.content, + status: r.status as ProfileFact["status"], + confidence_value: Number(r.confidence_value), + confidence_band: r.confidence_band as ProfileFact["confidence_band"], + pinned: r.pinned, + applied_count: Number(r.applied_count), + violated_count: Number(r.violated_count), + eligible: isEligible({ + pinned: r.pinned, + status: r.status, + confidence_value: Number(r.confidence_value), + }), + updated_at: r.updated_at ? r.updated_at.toISOString() : null, + }; +} + +/** A full row → the domain ProfileFact (for renderProfile / usedChars). */ +function rowToFact(r: ProfileFactFullRow): ProfileFact { + return { + id: Number(r.id), + account_id: r.account_id, + category: r.category, + content: r.content, + status: r.status as ProfileFact["status"], + applied_count: Number(r.applied_count), + violated_count: Number(r.violated_count), + confidence_value: Number(r.confidence_value), + confidence_band: r.confidence_band as ProfileFact["confidence_band"], + pinned: r.pinned, + source: r.source ?? undefined, + content_hash: r.content_hash ?? undefined, + last_evidence_at: r.last_evidence_at ?? null, + }; +} + +/** Append one memory_audit row (account-scoped). Mirrors insertMemoryAudit but + * kept local so profile-portal owns its own audit writes. */ +async function audit( + p: PoolLike, + row: { + account_id: string; + fact_id: number | null; + from_state?: string | null; + to_state?: string | null; + trigger: string; + }, +): Promise { + await p.query( + `INSERT INTO memory_audit (account_id, fact_id, from_state, to_state, trigger) + VALUES ($1, $2, $3, $4, $5)`, + [row.account_id, row.fact_id, row.from_state ?? null, row.to_state ?? null, row.trigger], + ); +} + +/** List ALL facts for ONE account (DTOs), pinned-first then confidence desc. */ +export async function listProfileFactsDTO( + accountId: string, + pool?: PoolLike, +): Promise<{ facts: ProfileFactDTO[]; usedChars: number }> { + const p = pool ?? getPool(); + const { rows } = await p.query( + `SELECT ${SELECT_COLS} FROM user_profile_facts + WHERE account_id = $1 + ORDER BY pinned DESC, confidence_value DESC`, + [accountId], + ); + const facts = rows.map(rowToDTO); + const rendered = renderProfile(rows.map(rowToFact), PROFILE_CHAR_BUDGET); + return { facts, usedChars: rendered?.length ?? 0 }; +} + +/** Fetch ONE fact by id, scoped to the account (cross-account → null). */ +async function getRow( + accountId: string, + id: number, + p: PoolLike, +): Promise { + const { rows } = await p.query( + `SELECT ${SELECT_COLS} FROM user_profile_facts WHERE id = $1 AND account_id = $2`, + [id, accountId], + ); + return rows[0] ?? null; +} + +/** Public DTO read of one fact (cross-account → null). */ +export async function getProfileFactDTO( + accountId: string, + id: number, + pool?: PoolLike, +): Promise { + const p = pool ?? getPool(); + const row = await getRow(accountId, id, p); + return row ? rowToDTO(row) : null; +} + +/** Create a manual, pinned, confirmed fact (confidence 1 / high). Returns its id. + * Caller validates content (non-empty, <= PROFILE_CONTENT_MAX, not a secret). */ +export async function createManualFact( + accountId: string, + input: { category: string; content: string }, + pool?: PoolLike, +): Promise { + const p = pool ?? getPool(); + const contentHash = profileContentHash(accountId, input.content); + const { rows } = await p.query<{ id: number | string }>( + `INSERT INTO user_profile_facts + (account_id, category, content, status, applied_count, violated_count, + confidence_value, confidence_band, pinned, source, content_hash, updated_at) + VALUES ($1, $2, $3, 'confirmed', 0, 0, 1, 'high', true, 'manual', $4, now()) + ON CONFLICT (account_id, content_hash) DO UPDATE SET + category = EXCLUDED.category, + pinned = true, + updated_at = now() + RETURNING id`, + [accountId, input.category, input.content, contentHash], + ); + const id = Number(rows[0]?.id); + await audit(p, { account_id: accountId, fact_id: id, trigger: "portal-create" }); + return id; +} + +/** Patch pinned / content / category of one fact (account-scoped). Returns the + * updated DTO, or null if the fact is not this account's (→ 404). When content + * changes, content_hash is recomputed (kept consistent with the account key). */ +export async function patchProfileFact( + accountId: string, + id: number, + patch: { pinned?: boolean; content?: string; category?: string }, + pool?: PoolLike, +): Promise { + const p = pool ?? getPool(); + const existing = await getRow(accountId, id, p); + if (!existing) return null; + + const nextContent = patch.content !== undefined ? patch.content : existing.content; + const nextCategory = patch.category !== undefined ? patch.category : existing.category; + const nextPinned = patch.pinned !== undefined ? patch.pinned : existing.pinned; + const nextHash = + patch.content !== undefined + ? profileContentHash(accountId, nextContent) + : existing.content_hash; + + const { rows } = await p.query( + `UPDATE user_profile_facts + SET content = $3, category = $4, pinned = $5, content_hash = $6, updated_at = now() + WHERE id = $1 AND account_id = $2 + RETURNING ${SELECT_COLS}`, + [id, accountId, nextContent, nextCategory, nextPinned, nextHash], + ); + const row = rows[0]; + if (!row) return null; + await audit(p, { account_id: accountId, fact_id: id, trigger: "portal-edit" }); + return rowToDTO(row); +} + +/** Register one piece of evidence (applied|violated), bump the count + freshen + * last_evidence_at to now, recompute {confidence_value, confidence_band} via + * computeConfidence. Account-scoped; null if not this account's (→ 404). */ +export async function registerEvidence( + accountId: string, + id: number, + kind: "applied" | "violated", + pool?: PoolLike, + now: Date = new Date(), +): Promise { + const p = pool ?? getPool(); + const existing = await getRow(accountId, id, p); + if (!existing) return null; + + const applied = Number(existing.applied_count) + (kind === "applied" ? 1 : 0); + const violated = Number(existing.violated_count) + (kind === "violated" ? 1 : 0); + const { value, band } = computeConfidence(applied, violated, now, now); + + const { rows } = await p.query( + `UPDATE user_profile_facts + SET applied_count = $3, violated_count = $4, + confidence_value = $5, confidence_band = $6, + last_evidence_at = $7, updated_at = now() + WHERE id = $1 AND account_id = $2 + RETURNING ${SELECT_COLS}`, + [id, accountId, applied, violated, value, band, now], + ); + const row = rows[0]; + if (!row) return null; + await audit(p, { account_id: accountId, fact_id: id, trigger: "portal-evidence" }); + return rowToDTO(row); +} + +/** Delete one fact (account-scoped). True if a row was deleted, false otherwise + * (not found OR another account's → the route answers 404). */ +export async function deleteProfileFact( + accountId: string, + id: number, + pool?: PoolLike, +): Promise { + const p = pool ?? getPool(); + const { rowCount } = await p.query( + `DELETE FROM user_profile_facts WHERE id = $1 AND account_id = $2`, + [id, accountId], + ); + if (!rowCount) return false; + await audit(p, { account_id: accountId, fact_id: id, trigger: "portal-delete" }); + return true; +} diff --git a/src/portal/routes.ts b/src/portal/routes.ts index 78d9e06..2fe9ef5 100644 --- a/src/portal/routes.ts +++ b/src/portal/routes.ts @@ -98,6 +98,19 @@ function clearSessionCookie(res: express.Response): void { }); } +/** Whether the curated profile is actually being injected into THIS account's MCP + * sessions right now: the global flag AND the account in the allowlist (same + * contract as resolveInstructions in index.ts). Read from env per-call so the + * portal reflects the live config (and tests can toggle it). */ +function isInjectingForAccount(accountId: string): boolean { + if (process.env.PROFILE_INJECT_ENABLED !== "true") return false; + const allow = (process.env.PROFILE_INJECT_ACCOUNTS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return allow.includes(accountId); +} + import { portalNextOn, appLanding, planoReturn, fontesUnconfigured } from "./cutover-urls.js"; export function createPortalRouter(): express.Router { @@ -234,6 +247,134 @@ export function createPortalRouter(): express.Router { res.json(await sourcesSummary(accountId)); }); + // --- Perfil curado de memória (per-account) ------------------------------- + // Todas escopadas por res.locals.accountId (sessão, nunca input). :id sempre + // valida WHERE id=$1 AND account_id=$2 (cross-account → 404). Helpers em + // profile-portal.ts (reusa profile-storage/computeConfidence/profile-guard). + + // GET /portal/profile → {facts, budgetChars, usedChars, injecting}. + router.get("/portal/profile", requireSession, async (_req, res) => { + const accountId: string = res.locals.accountId; + try { + const { listProfileFactsDTO } = await import("./profile-portal.js"); + const { PROFILE_CHAR_BUDGET } = await import("../rag/profile.js"); + const { facts, usedChars } = await listProfileFactsDTO(accountId); + res.json({ + facts, + budgetChars: PROFILE_CHAR_BUDGET, + usedChars, + injecting: isInjectingForAccount(accountId), + }); + } catch (err: any) { + console.warn(`[portal] profile GET unavailable: ${err?.message ?? err}`); + res.status(503).json({ error: "perfil indisponível", facts: [], budgetChars: 0, usedChars: 0, injecting: false }); + } + }); + + // POST /portal/profile {category, content} → 201 {id}. Rejeita segredo (400) + // e content vazio / > 500 chars (400 invalid). Cria pinned/confirmed/manual. + router.post("/portal/profile", requireSession, async (req, res) => { + const accountId: string = res.locals.accountId; + const category = typeof req.body?.category === "string" ? req.body.category.trim() : ""; + const content = typeof req.body?.content === "string" ? req.body.content.trim() : ""; + if (!content || content.length > 500) { + res.status(400).json({ error: "invalid" }); + return; + } + const { looksLikeSecret } = await import("../rag/profile-guard.js"); + if (looksLikeSecret(content)) { + res.status(400).json({ error: "looks_like_secret" }); + return; + } + try { + const { createManualFact } = await import("./profile-portal.js"); + const id = await createManualFact(accountId, { category: category || "geral", content }); + res.status(201).json({ id }); + } catch (err: any) { + console.error(`[portal] profile POST ${accountId}: ${err?.message ?? err}`); + res.status(500).json({ error: "server_error" }); + } + }); + + // PATCH /portal/profile/:id {pinned?, content?, category?} → 200 DTO | 404. + router.patch("/portal/profile/:id", requireSession, async (req, res) => { + const accountId: string = res.locals.accountId; + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + res.status(400).json({ error: "invalid" }); + return; + } + const patch: { pinned?: boolean; content?: string; category?: string } = {}; + if (typeof req.body?.pinned === "boolean") patch.pinned = req.body.pinned; + if (typeof req.body?.category === "string") patch.category = req.body.category.trim(); + if (typeof req.body?.content === "string") { + const content = req.body.content.trim(); + if (!content || content.length > 500) { + res.status(400).json({ error: "invalid" }); + return; + } + const { looksLikeSecret } = await import("../rag/profile-guard.js"); + if (looksLikeSecret(content)) { + res.status(400).json({ error: "looks_like_secret" }); + return; + } + patch.content = content; + } + try { + const { patchProfileFact } = await import("./profile-portal.js"); + const dto = await patchProfileFact(accountId, id, patch); + if (!dto) { + res.status(404).json({ error: "not_found" }); + return; + } + res.json(dto); + } catch (err: any) { + console.error(`[portal] profile PATCH ${accountId}: ${err?.message ?? err}`); + res.status(500).json({ error: "server_error" }); + } + }); + + // POST /portal/profile/:id/evidence {kind:'applied'|'violated'} → 200 DTO | 404. + router.post("/portal/profile/:id/evidence", requireSession, async (req, res) => { + const accountId: string = res.locals.accountId; + const id = parseInt(req.params.id, 10); + const kind = req.body?.kind; + if (isNaN(id) || (kind !== "applied" && kind !== "violated")) { + res.status(400).json({ error: "invalid" }); + return; + } + try { + const { registerEvidence } = await import("./profile-portal.js"); + const dto = await registerEvidence(accountId, id, kind); + if (!dto) { + res.status(404).json({ error: "not_found" }); + return; + } + res.json(dto); + } catch (err: any) { + console.error(`[portal] profile evidence ${accountId}: ${err?.message ?? err}`); + res.status(500).json({ error: "server_error" }); + } + }); + + // DELETE /portal/profile/:id → 204 | 404. + router.delete("/portal/profile/:id", requireSession, async (req, res) => { + const accountId: string = res.locals.accountId; + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + res.status(400).json({ error: "invalid" }); + return; + } + try { + const { deleteProfileFact } = await import("./profile-portal.js"); + const ok = await deleteProfileFact(accountId, id); + res.sendStatus(ok ? 204 : 404); + } catch (err: any) { + console.error(`[portal] profile DELETE ${accountId}: ${err?.message ?? err}`); + res.status(500).json({ error: "server_error" }); + } + }); + // Generate (or regenerate) the per-account MCP bearer the friend puts in their // AI client. Shown ONCE; only its hash is stored. Regenerating revokes the old // one so there's a single active token (the friend updates their client). diff --git a/src/rag/__tests__/memory-curator.test.ts b/src/rag/__tests__/memory-curator.test.ts index 1d49daf..cc39560 100644 --- a/src/rag/__tests__/memory-curator.test.ts +++ b/src/rag/__tests__/memory-curator.test.ts @@ -262,10 +262,10 @@ test("AC7: accountId explícito chega em loadFacts e em TODA escrita", async () } }); -test("AC7: default é DEFAULT_ACCOUNT_ID quando accountId não é passado", async () => { +test("AC7: sem accountId E sem listAccounts → cai no DEFAULT_ACCOUNT_ID (fallback)", async () => { const { deps, spies } = makeDeps([ fact({ id: 70, status: "signal", applied_count: 1, last_evidence_at: NOW }), - ]); // accountId undefined + ]); // accountId undefined, listAccounts undefined await runMemoryCuration(deps); assert.deepEqual(spies.loadCalls, [DEFAULT_ACCOUNT_ID]); @@ -273,11 +273,85 @@ test("AC7: default é DEFAULT_ACCOUNT_ID quando accountId não é passado", asyn for (const a of spies.audits) assert.equal(a.account_id, DEFAULT_ACCOUNT_ID); }); -test("AC7: o curador NÃO recebe dep de listar contas (sem caminho multi-conta)", () => { - // Structural: MemoryCurationDeps must not carry any "list accounts" hook. - const { deps } = makeDeps([]); - const keys = Object.keys(deps); - for (const k of keys) { - assert.doesNotMatch(k, /listAccounts|allAccounts|accounts/i, `dep inesperada: ${k}`); +// --------------------------------------------------------------------------- +// Per-account — itera todas as contas com fatos, cada uma isolada +// --------------------------------------------------------------------------- + +test("per-account: com listAccounts processa TODAS as contas, cada uma isolada", async () => { + const accounts = ["acct_A", "acct_B"]; + const spies: Spies = { loadCalls: [], upserts: [], audits: [] }; + // Cada conta tem um fato promovível (signal + applied >= 1 + evidência hoje). + const deps: MemoryCurationDeps = { + now: NOW, + listAccounts: async () => accounts, + loadFacts: async (acct: string) => { + spies.loadCalls.push(acct); + return [ + { + ...fact({ id: 100, status: "signal", applied_count: 1, last_evidence_at: NOW }), + account_id: acct, + content_hash: `h_${acct}`, + }, + ]; + }, + upsertFact: async (f) => { + spies.upserts.push(f as unknown as Record); + return 0; + }, + insertAudit: async (row) => { + spies.audits.push(row as unknown as Record); + }, + }; + + const res = await runMemoryCuration(deps); + + // Processou as DUAS contas; totais somados. + assert.deepEqual(spies.loadCalls.sort(), ["acct_A", "acct_B"]); + assert.equal(res.processed, 2, "um fato por conta = 2 processados no total"); + assert.equal(res.transitions, 2, "cada conta promoveu 1 degrau"); + + // ISOLAMENTO: toda escrita carrega o account_id da PRÓPRIA conta. + const upsertA = spies.upserts.filter((u) => u.account_id === "acct_A"); + const upsertB = spies.upserts.filter((u) => u.account_id === "acct_B"); + assert.equal(upsertA.length, 1); + assert.equal(upsertB.length, 1); + assert.equal(upsertA[0].content_hash, "h_acct_A", "conta A escreve só o próprio fato"); + assert.equal(upsertB[0].content_hash, "h_acct_B", "conta B escreve só o próprio fato"); + for (const a of spies.audits) { + assert.ok(a.account_id === "acct_A" || a.account_id === "acct_B"); } + // Nenhuma escrita cruza contas. + assert.equal( + spies.upserts.length + spies.audits.length, + 4, + "2 upserts + 2 audits, nenhum vazamento entre contas", + ); +}); + +test("per-account: accountId explícito processa SÓ essa conta (não chama listAccounts)", async () => { + let listCalled = false; + const spies: Spies = { loadCalls: [], upserts: [], audits: [] }; + const deps: MemoryCurationDeps = { + accountId: "acct_A", + now: NOW, + listAccounts: async () => { + listCalled = true; + return ["acct_A", "acct_B"]; + }, + loadFacts: async (acct: string) => { + spies.loadCalls.push(acct); + return [{ ...fact({ id: 200, applied_count: 0 }), account_id: acct }]; + }, + upsertFact: async (f) => { + spies.upserts.push(f as unknown as Record); + return 0; + }, + insertAudit: async (row) => { + spies.audits.push(row as unknown as Record); + }, + }; + + await runMemoryCuration(deps); + assert.equal(listCalled, false, "accountId explícito não deve chamar listAccounts"); + assert.deepEqual(spies.loadCalls, ["acct_A"]); }); diff --git a/src/rag/__tests__/profile-storage.test.ts b/src/rag/__tests__/profile-storage.test.ts index b05d30b..d17f3dd 100644 --- a/src/rag/__tests__/profile-storage.test.ts +++ b/src/rag/__tests__/profile-storage.test.ts @@ -11,6 +11,7 @@ import { loadProfileFacts, upsertProfileFact, insertMemoryAudit, + listAccountsWithProfileFacts, } from "../profile-storage.js"; // A fake pg-like pool that records every query as {text, values}. @@ -77,6 +78,22 @@ test("loadProfileFacts maps a row into a ProfileFact", async () => { assert.equal(facts[0].content_hash, "abc123"); }); +// --- listAccountsWithProfileFacts ------------------------------------------- + +test("listAccountsWithProfileFacts emits SELECT DISTINCT account_id and maps rows", async () => { + const { pool, calls } = recordingPool([ + { account_id: "bruno" }, + { account_id: "acct_A" }, + ]); + __setPoolForTest(pool as never); + + const accounts = await listAccountsWithProfileFacts(pool as never); + + assert.equal(calls.length, 1); + assert.match(calls[0].text, /SELECT\s+DISTINCT\s+account_id\s+FROM\s+user_profile_facts/i); + assert.deepEqual(accounts, ["bruno", "acct_A"]); +}); + // --- upsertProfileFact ------------------------------------------------------ test("upsertProfileFact uses ON CONFLICT (account_id, content_hash) and puts account_id in the columns + params", async () => { diff --git a/src/rag/memory-curation-tick.ts b/src/rag/memory-curation-tick.ts index 235eadf..a424f24 100644 --- a/src/rag/memory-curation-tick.ts +++ b/src/rag/memory-curation-tick.ts @@ -10,8 +10,9 @@ // - recordRun(worker='classifier', source='memory-curation', ...) no mesmo // formato dos outros ticks. // -// OWNER-ONLY: runMemoryCuration processa só DEFAULT_ACCOUNT_ID (default da dep -// accountId); este tick não passa accountId, então cai no default. +// PER-ACCOUNT: runMemoryCuration processa TODAS as contas com fatos +// (deps.listAccounts = listAccountsWithProfileFacts); este tick não passa +// accountId, então itera todas, cada uma isolada. import { recordRun } from "./storage.js"; /** Stats do curador, reexportado pra tipar o seam de teste. */ @@ -38,10 +39,14 @@ export async function tickMemoryCuration( stats = await run(); } else { const { runMemoryCuration } = await import("./memory-curator.js"); - const { loadProfileFacts, upsertProfileFact, insertMemoryAudit } = await import( - "./profile-storage.js" - ); + const { + listAccountsWithProfileFacts, + loadProfileFacts, + upsertProfileFact, + insertMemoryAudit, + } = await import("./profile-storage.js"); stats = await runMemoryCuration({ + listAccounts: () => listAccountsWithProfileFacts(), loadFacts: (accountId) => loadProfileFacts(accountId), upsertFact: (fact) => upsertProfileFact(fact), insertAudit: (row) => insertMemoryAudit(row), diff --git a/src/rag/memory-curator.ts b/src/rag/memory-curator.ts index bf40e8e..9a4923d 100644 --- a/src/rag/memory-curator.ts +++ b/src/rag/memory-curator.ts @@ -1,15 +1,16 @@ // src/rag/memory-curator.ts -// T7 — curador determinístico de memória (owner-only, v1). +// Curador determinístico de memória (per-account). // // Roda no brain-classifier (tick noturno, atrás do gate MEMORY_CURATION_ENABLED). -// Reavalia a confiança de cada fato curado da CONTA OWNER e promove o status -// PARA FRENTE quando há evidência. Sem LLM, sem heurística: é uma máquina de -// estados determinística sobre user_profile_facts. +// Reavalia a confiança de cada fato curado e promove o status PARA FRENTE quando +// há evidência. Sem LLM, sem heurística: é uma máquina de estados determinística +// sobre user_profile_facts. // -// ESCOPO (não confundir com runEntityExtraction, que varre TODAS as contas): -// o curador v1 é OWNER-ONLY. Processa SOMENTE DEFAULT_ACCOUNT_ID (ou o -// accountId injetado nos testes). NÃO existe caminho que liste/itere outras -// contas — não há dep de "listar contas". Isolamento por conta é estrutural. +// ESCOPO (pivô per-account): processa TODAS as contas com fatos (deps.listAccounts), +// CADA UMA isolada — loadFacts/upsertFact/insertAudit sempre carregam o account_id +// da conta sendo processada. Para testabilidade, se deps.accountId for passado, +// processa SÓ essa conta (não chama listAccounts). A conta A nunca lê/escreve os +// fatos da conta B. // // REGRAS: // - reavalia {value, band} = computeConfidence(applied, violated, last_evidence_at, now). @@ -62,8 +63,14 @@ export interface CuratedAuditRow { } export interface MemoryCurationDeps { - /** Conta a processar. Default: DEFAULT_ACCOUNT_ID. NUNCA itera outras contas. */ + /** Se passado, processa SÓ essa conta (não chama listAccounts) — usado nos + * testes e quando se quer escopar a uma conta. Sem ele, processa TODAS as + * contas com fatos. */ accountId?: string; + /** Lista as contas com fatos (SELECT DISTINCT account_id). Só é chamada quando + * deps.accountId NÃO foi passado. Opcional: sem ela, cai no DEFAULT_ACCOUNT_ID + * (compat/fallback). */ + listAccounts?: () => Promise; /** Lê TODOS os fatos curados de UMA conta (account-scoped). */ loadFacts: (accountId: string) => Promise; /** Insere/atualiza um fato (keyed por account_id+content_hash). */ @@ -94,7 +101,8 @@ function nextStatus( } /** - * Roda o curador determinístico para UMA conta (owner-only no v1). + * Roda o curador determinístico para UMA conta. account_id da conta processada + * vai em TODA escrita (insertAudit/upsertFact) — isolamento estrutural. * * Para cada fato: * 1. recomputa {value, band} de confiança. @@ -103,15 +111,13 @@ function nextStatus( * 3. se o status mudou OU value/band mudaram, faz upsert com os novos valores. * pinned nunca muda de status; só value/band são recomputados. * - * NUNCA deleta. account_id da conta processada vai em toda escrita. + * NUNCA deleta. */ -export async function runMemoryCuration( +async function curateAccount( + accountId: string, deps: MemoryCurationDeps, + now: Date, ): Promise { - const accountId = deps.accountId ?? DEFAULT_ACCOUNT_ID; - const now = deps.now ?? new Date(); - - // Owner-only: SOMENTE esta conta. Sem listagem de contas, sem loop externo. const facts = await deps.loadFacts(accountId); let transitions = 0; @@ -169,3 +175,36 @@ export async function runMemoryCuration( return { processed: facts.length, transitions }; } + +/** + * Roda o curador determinístico (per-account). + * + * - Se `deps.accountId` for passado, processa SÓ essa conta (testabilidade; sem + * chamar listAccounts). + * - Senão, processa TODAS as contas com fatos (deps.listAccounts), cada uma + * isolada. Sem listAccounts, cai no DEFAULT_ACCOUNT_ID (fallback de compat). + * + * Os totais ({processed, transitions}) são a soma das contas processadas. Cada + * conta lê/escreve SOMENTE os próprios fatos — isolamento estrutural. + */ +export async function runMemoryCuration( + deps: MemoryCurationDeps, +): Promise { + const now = deps.now ?? new Date(); + + const accounts = deps.accountId + ? [deps.accountId] + : deps.listAccounts + ? await deps.listAccounts() + : [DEFAULT_ACCOUNT_ID]; + + let processed = 0; + let transitions = 0; + for (const accountId of accounts) { + const r = await curateAccount(accountId, deps, now); + processed += r.processed; + transitions += r.transitions; + } + + return { processed, transitions }; +} diff --git a/src/rag/profile-storage.ts b/src/rag/profile-storage.ts index 3d4febe..8a9541a 100644 --- a/src/rag/profile-storage.ts +++ b/src/rag/profile-storage.ts @@ -73,6 +73,20 @@ export async function loadProfileFacts( return rows.map(rowToProfileFact); } +/** + * List the DISTINCT account_ids that own at least one curated profile fact. Used + * by the per-account curator to iterate every tenant with facts (each then read/ + * written in isolation via loadProfileFacts/upsertProfileFact, which are + * account-scoped). Pure SELECT DISTINCT — no per-account leak. + */ +export async function listAccountsWithProfileFacts(pool?: PoolLike): Promise { + const p = pool ?? getPool(); + const { rows } = await p.query<{ account_id: string }>( + `SELECT DISTINCT account_id FROM user_profile_facts`, + ); + return rows.map((r) => r.account_id); +} + /** Fields needed to upsert one curated profile fact. account_id + content_hash * form the unique key (account-scoped dedup); both are mandatory. */ export interface UpsertProfileFactInput { diff --git a/web/app/(app)/perfil/page.tsx b/web/app/(app)/perfil/page.tsx new file mode 100644 index 0000000..fc78d2e --- /dev/null +++ b/web/app/(app)/perfil/page.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { + AddFactForm, + BudgetMeter, + FactList, + ProfileHeader, +} from "@/components/profile"; +import { Card, CardBody, CardHead, Skeleton } from "@/components/ui"; +import { useProfile } from "@/hooks/perfil"; + +function CardSkeleton() { + return ( + + + + + + + + + + ); +} + +function LoadingState() { + return ( +
+
+ + + +
+
+ + + +
+
+ ); +} + +export default function Page() { + const profile = useProfile(); + + if (profile.isLoading) return ; + + if (profile.isError || !profile.data) { + return ( +
+ + + +

+ Não consegui carregar seu perfil agora. Recarregue a página em + instantes. +

+
+
+
+ ); + } + + const { facts, budgetChars, usedChars, injecting } = profile.data; + + return ( +
+ + +
+ + + +
+
+ ); +} diff --git a/web/components/profile/AddFactForm.tsx b/web/components/profile/AddFactForm.tsx new file mode 100644 index 0000000..ab44987 --- /dev/null +++ b/web/components/profile/AddFactForm.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useId, useState } from "react"; +import { Button, Card, CardBody, TextField, useToast } from "@/components/ui"; +import { ApiError } from "@/lib/api"; +import type { ErrorResponse } from "@/lib/contracts"; +import { useAddFact } from "@/hooks/perfil"; +import { CATEGORIES, categoryLabel } from "./helpers"; + +const selectCls = + "w-full rounded-md8 border border-line bg-bg px-3 py-2 text-body text-ink " + + "focus:outline-none focus-visible:ring-2 focus-visible:ring-accent " + + "focus-visible:ring-offset-1 focus-visible:ring-offset-bg " + + "disabled:opacity-50 disabled:cursor-not-allowed"; + +/** Maps a 400 error body to a user-facing message. */ +function messageForError(err: unknown): string { + if (err instanceof ApiError && err.status === 400) { + const code = (err.body as ErrorResponse | undefined)?.error; + if (code === "looks_like_secret") { + return "Isso parece um segredo/chave; não vou guardar."; + } + if (code === "invalid") { + return "Esse fato não é válido. Tente reescrever de outra forma."; + } + } + return "Não consegui adicionar o fato. Tente de novo."; +} + +/** + * Add a curated fact: category select + content textarea. Surfaces the + * backend's 400 looks_like_secret / invalid errors inline (and clears the form + * + toasts on success). The list refetches via the mutation's invalidation. + */ +export function AddFactForm() { + const show = useToast((s) => s.show); + const add = useAddFact(); + const selectId = useId(); + + const [category, setCategory] = useState(CATEGORIES[0]); + const [content, setContent] = useState(""); + const [error, setError] = useState(null); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const value = content.trim(); + if (!value) { + setError("Escreva o fato que você quer que o Zinom lembre."); + return; + } + setError(null); + add.mutate( + { category, content: value }, + { + onSuccess: () => { + setContent(""); + show("Fato adicionado ao seu perfil", "success"); + }, + onError: (err) => setError(messageForError(err)), + } + ); + }; + + return ( + + +
+
+ + +
+ + { + setContent(e.target.value); + if (error) setError(null); + }} + /> + +
+ +
+ +
+
+ ); +} diff --git a/web/components/profile/BudgetMeter.tsx b/web/components/profile/BudgetMeter.tsx new file mode 100644 index 0000000..6979eb5 --- /dev/null +++ b/web/components/profile/BudgetMeter.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Card, CardBody } from "@/components/ui"; + +/** + * Shows how much of the injection char budget the curated profile uses. The bar + * turns warm/amber as it approaches the limit and red once it overflows, so the + * user can see when pruning is needed to keep every fact eligible. + */ +export function BudgetMeter({ + usedChars, + budgetChars, +}: { + usedChars: number; + budgetChars: number; +}) { + const safeBudget = budgetChars > 0 ? budgetChars : 0; + const ratio = safeBudget > 0 ? usedChars / safeBudget : 0; + const pct = Math.max(0, Math.min(1, ratio)); + const over = safeBudget > 0 && usedChars > safeBudget; + const near = !over && ratio >= 0.85; + + const barColor = over ? "bg-bad" : near ? "bg-warn" : "bg-accent"; + + return ( + + +
+ + Espaço usado na injeção + + + {usedChars.toLocaleString("pt-BR")} /{" "} + {safeBudget.toLocaleString("pt-BR")} caracteres + +
+
+
+
+ {over ? ( +

+ Seu perfil passou do limite. Os fatos menos relevantes podem ficar de + fora da injeção — fixe ou remova alguns para garantir o que importa. +

+ ) : near ? ( +

+ Você está perto do limite. Em breve pode ser preciso remover fatos + antigos para abrir espaço. +

+ ) : null} + + + ); +} diff --git a/web/components/profile/FactCard.tsx b/web/components/profile/FactCard.tsx new file mode 100644 index 0000000..d9335e5 --- /dev/null +++ b/web/components/profile/FactCard.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState } from "react"; +import { Button, Card, CardBody, Tag, useToast } from "@/components/ui"; +import type { ProfileFact } from "@/lib/contracts"; +import { + useDeleteFact, + useFactEvidence, + usePatchFact, +} from "@/hooks/perfil"; +import { bandLabel, bandVariant, categoryLabel } from "./helpers"; + +/** A pinned-state star toggle (filled when pinned). */ +function PinToggle({ + pinned, + busy, + onToggle, +}: { + pinned: boolean; + busy: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +/** + * One curated fact, rendered as a card: content + category chip + confidence + * band badge + "injetado agora" when eligible, the applied/violated counters, + * and the per-card actions (pin, "ainda vale" / "não vale mais", excluir with a + * two-step confirm). Each action invalidates the profile query to refetch. + */ +export function FactCard({ fact }: { fact: ProfileFact }) { + const show = useToast((s) => s.show); + const patch = usePatchFact(); + const evidence = useFactEvidence(); + const del = useDeleteFact(); + const [confirming, setConfirming] = useState(false); + + const busy = patch.isPending || evidence.isPending || del.isPending; + + const togglePin = () => { + patch.mutate( + { id: fact.id, patch: { pinned: !fact.pinned } }, + { + onSuccess: () => + show(fact.pinned ? "Fato desafixado" : "Fato fixado", "success"), + onError: () => show("Não consegui atualizar o fato", "error"), + } + ); + }; + + const sendEvidence = (kind: "applied" | "violated") => { + evidence.mutate( + { id: fact.id, kind }, + { + onSuccess: () => + show( + kind === "applied" + ? "Registrei que esse fato ainda vale" + : "Registrei que esse fato não vale mais", + "success" + ), + onError: () => show("Não consegui registrar", "error"), + } + ); + }; + + const onDelete = () => { + del.mutate(fact.id, { + onSuccess: () => show("Fato removido do perfil", "success"), + onError: () => { + setConfirming(false); + show("Não consegui remover", "error"); + }, + }); + }; + + return ( + + +
+ +

+ {fact.content} +

+
+ +
+ + {categoryLabel(fact.category)} + + + {bandLabel(fact.confidence_band)} + + {fact.eligible ? ( + + Injetado agora + + ) : null} + + + ✓ {fact.applied_count} + + · + + ✗ {fact.violated_count} + + +
+ +
+ + + + {confirming ? ( + + Remover? + + + + ) : ( + + )} +
+
+
+ ); +} diff --git a/web/components/profile/FactList.tsx b/web/components/profile/FactList.tsx new file mode 100644 index 0000000..2c45d5e --- /dev/null +++ b/web/components/profile/FactList.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { EmptyState } from "@/components/ui"; +import type { ProfileFact } from "@/lib/contracts"; +import { FactCard } from "./FactCard"; + +/** + * The curated facts as a stacked list of cards. Pinned facts float to the top, + * then by most-recently updated. Renders the empty state when there's nothing + * yet. + */ +export function FactList({ facts }: { facts: ProfileFact[] }) { + if (facts.length === 0) { + return ( + + Você ainda não tem fatos no seu perfil. Adicione o primeiro acima. + + ); + } + + const ordered = [...facts].sort((a, b) => { + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; + return b.updated_at.localeCompare(a.updated_at); + }); + + return ( +
+ {ordered.map((fact) => ( + + ))} +
+ ); +} diff --git a/web/components/profile/ProfileHeader.tsx b/web/components/profile/ProfileHeader.tsx new file mode 100644 index 0000000..e10017d --- /dev/null +++ b/web/components/profile/ProfileHeader.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Tag } from "@/components/ui"; + +/** + * Editorial page head for Perfil: gold eyebrow, big serif title, a one-line + * lead, and an injection-status badge (active vs not-yet-active in sessions). + */ +export function ProfileHeader({ injecting }: { injecting: boolean }) { + return ( +
+
+

+ perfil +

+ {injecting ? ( + + Ativo nas suas sessões + + ) : ( + + Ainda não ativado nas suas sessões + + )} +
+

+ Seu perfil de memória +

+

+ O que o Zinom lembra sobre você e injeta nas suas conversas. +

+
+ ); +} diff --git a/web/components/profile/helpers.ts b/web/components/profile/helpers.ts new file mode 100644 index 0000000..a5808ad --- /dev/null +++ b/web/components/profile/helpers.ts @@ -0,0 +1,52 @@ +// Pure presentation helpers for the Perfil (curated memory) view. Kept free of +// React so they're trivially unit-testable and shared across the components. + +import type { TagVariant } from "@/components/ui"; +import type { ProfileFactConfidenceBand } from "@/lib/contracts"; + +/** The categories offered in the add form, in display order. */ +export const CATEGORIES = [ + "projeto", + "pessoa", + "preferência", + "rotina", + "outro", +] as const; + +export type Category = (typeof CATEGORIES)[number]; + +const CATEGORY_LABELS: Record = { + projeto: "Projeto", + pessoa: "Pessoa", + preferência: "Preferência", + rotina: "Rotina", + outro: "Outro", +}; + +/** Human label for a category chip; falls back to the raw value. */ +export function categoryLabel(category: string): string { + return CATEGORY_LABELS[category] ?? category; +} + +const BAND_LABELS: Record = { + low: "Confiança baixa", + medium: "Confiança média", + high: "Confiança alta", +}; + +/** Short label for a confidence band. */ +export function bandLabel(band: ProfileFactConfidenceBand): string { + return BAND_LABELS[band] ?? band; +} + +/** Tag variant for a confidence band (high→ok/green, medium→warn, low→neutral). */ +export function bandVariant(band: ProfileFactConfidenceBand): TagVariant { + switch (band) { + case "high": + return "ok"; + case "medium": + return "warn"; + default: + return "neutral"; + } +} diff --git a/web/components/profile/index.ts b/web/components/profile/index.ts new file mode 100644 index 0000000..fee3210 --- /dev/null +++ b/web/components/profile/index.ts @@ -0,0 +1,6 @@ +export * from "./ProfileHeader"; +export * from "./BudgetMeter"; +export * from "./AddFactForm"; +export * from "./FactCard"; +export * from "./FactList"; +export * from "./helpers"; diff --git a/web/components/profile/profile.test.tsx b/web/components/profile/profile.test.tsx new file mode 100644 index 0000000..80119cf --- /dev/null +++ b/web/components/profile/profile.test.tsx @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { createElement } from "react"; +import * as api from "@/lib/api"; +import { FactList } from "./FactList"; +import { AddFactForm } from "./AddFactForm"; +import type { ProfileFact } from "@/lib/contracts"; + +function wrap(node: ReactNode) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + createElement(QueryClientProvider, { client: qc }, node) + ); +} + +function fact(over: Partial = {}): ProfileFact { + return { + id: 1, + category: "preferência", + content: "Prefiro respostas curtas.", + status: "confirmed", + confidence_value: 0.9, + confidence_band: "high", + pinned: false, + applied_count: 0, + violated_count: 0, + eligible: true, + updated_at: "2026-06-19T00:00:00Z", + ...over, + }; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("FactList", () => { + it("renders the empty state when there are no facts", () => { + const { container, getByText } = wrap(createElement(FactList, { facts: [] })); + expect(container.querySelector("#perfil-list")).toBeNull(); + expect(getByText(/seu perfil ainda está vazio/i)).toBeTruthy(); + }); + + it("renders a card per fact with the eligible badge", () => { + const facts = [ + fact({ id: 1, content: "Fato A", eligible: true }), + fact({ id: 2, content: "Fato B", eligible: false }), + ]; + const { container, getByText } = wrap(createElement(FactList, { facts })); + expect(container.querySelectorAll("[data-perfil-fact]").length).toBe(2); + expect(getByText("Fato A")).toBeTruthy(); + expect(getByText("Fato B")).toBeTruthy(); + // exactly one fact is currently eligible → one "Injetado agora" badge + expect(container.querySelectorAll("[data-perfil-eligible]").length).toBe(1); + }); + + it("floats pinned facts to the top", () => { + const facts = [ + fact({ id: 1, content: "Solto", pinned: false }), + fact({ id: 2, content: "Fixado", pinned: true }), + ]; + const { container } = wrap(createElement(FactList, { facts })); + const cards = container.querySelectorAll("[data-perfil-fact]"); + expect(cards[0]?.getAttribute("data-perfil-fact")).toBe("2"); + }); +}); + +describe("AddFactForm", () => { + it("submits category + content through apiFetch", async () => { + const spy = vi.spyOn(api, "apiFetch").mockResolvedValue({ id: 42 }); + const { container } = wrap(createElement(AddFactForm)); + + const textarea = container.querySelector( + "[data-perfil-content]" + ) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "Acordo cedo." } }); + + const form = textarea.closest("form")!; + await act(async () => { + fireEvent.submit(form); + }); + + await vi.waitFor(() => + expect(spy).toHaveBeenCalledWith("/portal/profile", { + method: "POST", + body: { category: "projeto", content: "Acordo cedo." }, + }) + ); + }); + + it("shows a clear message on a 400 looks_like_secret", async () => { + vi.spyOn(api, "apiFetch").mockRejectedValue( + new api.ApiError(400, { error: "looks_like_secret" }) + ); + const { container, findByText } = wrap(createElement(AddFactForm)); + + const textarea = container.querySelector( + "[data-perfil-content]" + ) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "sk-secret-123" } }); + fireEvent.submit(textarea.closest("form")!); + + expect(await findByText(/parece um segredo/i)).toBeTruthy(); + }); +}); diff --git a/web/hooks/perfil.test.tsx b/web/hooks/perfil.test.tsx new file mode 100644 index 0000000..c903668 --- /dev/null +++ b/web/hooks/perfil.test.tsx @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { createElement } from "react"; +import * as api from "@/lib/api"; +import { + perfilKeys, + useAddFact, + useDeleteFact, + useFactEvidence, + usePatchFact, + useProfile, +} from "./perfil"; +import type { ProfileFact, ProfileResponse } from "@/lib/contracts"; + +function wrapper() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: qc }, children); +} + +const FACT: ProfileFact = { + id: 7, + category: "preferência", + content: "Prefiro respostas curtas.", + status: "confirmed", + confidence_value: 0.9, + confidence_band: "high", + pinned: false, + applied_count: 2, + violated_count: 0, + eligible: true, + updated_at: "2026-06-19T00:00:00Z", +}; + +const PROFILE: ProfileResponse = { + facts: [FACT], + budgetChars: 2000, + usedChars: 120, + injecting: true, +}; + +describe("perfilKeys", () => { + it("uses a stable local key for the profile", () => { + expect(perfilKeys.profile).toEqual(["profile"]); + }); +}); + +describe("useProfile", () => { + afterEach(() => vi.restoreAllMocks()); + + it("resolves the profile payload from apiFetch", async () => { + vi.spyOn(api, "apiFetch").mockResolvedValue(PROFILE); + const { result } = renderHook(() => useProfile(), { wrapper: wrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(PROFILE); + }); +}); + +describe("perfil mutations", () => { + afterEach(() => vi.restoreAllMocks()); + + it("useAddFact posts category + content", async () => { + const spy = vi.spyOn(api, "apiFetch").mockResolvedValue({ id: 9 }); + const { result } = renderHook(() => useAddFact(), { wrapper: wrapper() }); + await result.current.mutateAsync({ category: "rotina", content: "Acordo cedo." }); + expect(spy).toHaveBeenCalledWith("/portal/profile", { + method: "POST", + body: { category: "rotina", content: "Acordo cedo." }, + }); + }); + + it("useAddFact rethrows the 400 looks_like_secret error", async () => { + vi.spyOn(api, "apiFetch").mockRejectedValue( + new api.ApiError(400, { error: "looks_like_secret" }) + ); + const { result } = renderHook(() => useAddFact(), { wrapper: wrapper() }); + await expect( + result.current.mutateAsync({ category: "outro", content: "sk-123" }) + ).rejects.toBeInstanceOf(api.ApiError); + }); + + it("usePatchFact PATCHes the pinned flag at the fact id", async () => { + const spy = vi.spyOn(api, "apiFetch").mockResolvedValue(FACT); + const { result } = renderHook(() => usePatchFact(), { wrapper: wrapper() }); + await result.current.mutateAsync({ id: 7, patch: { pinned: true } }); + expect(spy).toHaveBeenCalledWith("/portal/profile/7", { + method: "PATCH", + body: { pinned: true }, + }); + }); + + it("useFactEvidence posts the evidence kind", async () => { + const spy = vi.spyOn(api, "apiFetch").mockResolvedValue(FACT); + const { result } = renderHook(() => useFactEvidence(), { + wrapper: wrapper(), + }); + await result.current.mutateAsync({ id: 7, kind: "violated" }); + expect(spy).toHaveBeenCalledWith("/portal/profile/7/evidence", { + method: "POST", + body: { kind: "violated" }, + }); + }); + + it("useDeleteFact DELETEs the fact id", async () => { + const spy = vi.spyOn(api, "apiFetch").mockResolvedValue(undefined); + const { result } = renderHook(() => useDeleteFact(), { wrapper: wrapper() }); + await result.current.mutateAsync(7); + expect(spy).toHaveBeenCalledWith("/portal/profile/7", { method: "DELETE" }); + }); +}); diff --git a/web/hooks/perfil.ts b/web/hooks/perfil.ts new file mode 100644 index 0000000..7667647 --- /dev/null +++ b/web/hooks/perfil.ts @@ -0,0 +1,113 @@ +"use client"; + +// Typed React Query hooks/mutations for the Perfil view (curated memory profile). +// +// Read: useProfile (/portal/profile → ProfileResponse). Writes (add / patch / +// evidence / delete) all invalidate the ["profile"] query so the budget meter, +// status badge and fact list re-render from fresh server state — the same +// refetch-after-mutation pattern used by the Fontes/Conta views. +// +// queryKeys.ts is a shared module we don't edit, so the Perfil-only key lives +// here, mirroring contaQueryKeys / fontesKeys. + +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult, +} from "@tanstack/react-query"; +import { apiFetch } from "@/lib/api"; +import type { + ProfileEvidenceKind, + ProfileFact, + ProfileFactCreateRequest, + ProfileFactCreateResponse, + ProfileFactPatchRequest, + ProfileResponse, +} from "@/lib/contracts"; + +export const perfilKeys = { + profile: ["profile"] as const, +} as const; + +// --- read ------------------------------------------------------------------ + +/** /portal/profile — the curated facts + budget + injection status. */ +export function useProfile(): UseQueryResult { + return useQuery({ + queryKey: perfilKeys.profile, + queryFn: () => apiFetch("/portal/profile"), + }); +} + +// --- mutations ------------------------------------------------------------- + +function useInvalidateProfile() { + const qc = useQueryClient(); + return () => qc.invalidateQueries({ queryKey: perfilKeys.profile }); +} + +/** + * POST /portal/profile {category,content} → 201 {id}. Throws ApiError(400) with + * {error:'looks_like_secret'} or {error:'invalid'}; the form surfaces those. + */ +export function useAddFact(): UseMutationResult< + ProfileFactCreateResponse, + unknown, + ProfileFactCreateRequest +> { + const invalidate = useInvalidateProfile(); + return useMutation({ + mutationFn: (body: ProfileFactCreateRequest) => + apiFetch("/portal/profile", { + method: "POST", + body, + }), + onSuccess: invalidate, + }); +} + +/** PATCH /portal/profile/:id {pinned?,content?,category?} → 200 ProfileFact. */ +export function usePatchFact(): UseMutationResult< + ProfileFact, + unknown, + { id: number; patch: ProfileFactPatchRequest } +> { + const invalidate = useInvalidateProfile(); + return useMutation({ + mutationFn: ({ id, patch }) => + apiFetch(`/portal/profile/${id}`, { + method: "PATCH", + body: patch, + }), + onSuccess: invalidate, + }); +} + +/** POST /portal/profile/:id/evidence {kind} → 200 ProfileFact. */ +export function useFactEvidence(): UseMutationResult< + ProfileFact, + unknown, + { id: number; kind: ProfileEvidenceKind } +> { + const invalidate = useInvalidateProfile(); + return useMutation({ + mutationFn: ({ id, kind }) => + apiFetch(`/portal/profile/${id}/evidence`, { + method: "POST", + body: { kind }, + }), + onSuccess: invalidate, + }); +} + +/** DELETE /portal/profile/:id → 204. */ +export function useDeleteFact(): UseMutationResult { + const invalidate = useInvalidateProfile(); + return useMutation({ + mutationFn: (id: number) => + apiFetch(`/portal/profile/${id}`, { method: "DELETE" }), + onSuccess: invalidate, + }); +} diff --git a/web/lib/contracts.ts b/web/lib/contracts.ts index b38efa5..b93edb8 100644 --- a/web/lib/contracts.ts +++ b/web/lib/contracts.ts @@ -646,6 +646,62 @@ export interface IndexWebResponse { title?: string; } +// --------------------------------------------------------------------------- +// Perfil curado de memoria - /portal/profile +// --------------------------------------------------------------------------- + +/** Curation lifecycle of a fact: signal → evidence → confirmed. */ +export type ProfileFactStatus = "signal" | "evidence" | "confirmed"; + +/** Confidence band derived server-side from confidence_value. */ +export type ProfileFactConfidenceBand = "low" | "medium" | "high"; + +export interface ProfileFact { + id: number; + category: string; + content: string; + status: ProfileFactStatus; + confidence_value: number; + confidence_band: ProfileFactConfidenceBand; + pinned: boolean; + applied_count: number; + violated_count: number; + /** True when this fact is currently being injected into sessions. */ + eligible: boolean; + updated_at: string; +} + +export interface ProfileResponse { + facts: ProfileFact[]; + /** Char budget for the injected profile. */ + budgetChars: number; + /** Chars currently consumed by eligible facts. */ + usedChars: number; + /** Whether the curated profile is active in the user's sessions. */ + injecting: boolean; +} + +export interface ProfileFactCreateRequest { + category: string; + content: string; +} + +export interface ProfileFactCreateResponse { + id: number; +} + +export interface ProfileFactPatchRequest { + pinned?: boolean; + content?: string; + category?: string; +} + +export type ProfileEvidenceKind = "applied" | "violated"; + +export interface ProfileEvidenceRequest { + kind: ProfileEvidenceKind; +} + // --------------------------------------------------------------------------- // Sessoes do portal // --------------------------------------------------------------------------- diff --git a/web/lib/nav.ts b/web/lib/nav.ts index e73f642..77f17db 100644 --- a/web/lib/nav.ts +++ b/web/lib/nav.ts @@ -11,6 +11,7 @@ export const NAV_ITEMS: NavItem[] = [ { id: "fontes", label: "Fontes", href: "/fontes/" }, { id: "atividade", label: "Atividade", href: "/atividade/" }, { id: "consultar", label: "Consultar", href: "/consultar/" }, + { id: "perfil", label: "Perfil", href: "/perfil/" }, { id: "guia", label: "Guia", href: "/guia/" }, { id: "conta", label: "Conta", href: "/conta/" }, { id: "plano", label: "Plano", href: "/plano/" }, diff --git a/web/lib/store.ts b/web/lib/store.ts index ac352ff..cd7c9b0 100644 --- a/web/lib/store.ts +++ b/web/lib/store.ts @@ -5,6 +5,7 @@ export type NavId = | "fontes" | "atividade" | "consultar" + | "perfil" | "guia" | "conta" | "plano";