1935 lines
96 KiB
TypeScript
1935 lines
96 KiB
TypeScript
/**
|
||
* PersonaForge Stewardship Dashboard
|
||
*
|
||
* The steward-facing workspace for vetting corpus artifacts before a build.
|
||
* Not the admin panel — this is the person responsible for the corpus doing
|
||
* their actual review work, with Ody as a research assistant.
|
||
*
|
||
* Routes (agentify.help host, dashboard-token protected):
|
||
* GET /steward/:slug — SSR dashboard
|
||
* GET /api/steward/:slug/info — subject + stats
|
||
* GET /api/steward/:slug/artifacts — list artifacts + curation
|
||
* PATCH /api/steward/:slug/artifacts/:artifact_id — update curation status
|
||
* POST /api/steward/:slug/artifacts/:artifact_id/ody-ask — Ody analysis
|
||
* POST /api/steward/:slug/corpus/fetch — trigger essay fetch
|
||
* POST /api/steward/:slug/corpus/build — build approved-only corpus
|
||
* GET /api/steward/:slug/dashboard-token — admin: get/generate token
|
||
*/
|
||
|
||
import type { Express, Request, Response } from "express";
|
||
import { pool } from "../db";
|
||
import crypto from "crypto";
|
||
import Anthropic from "@anthropic-ai/sdk";
|
||
|
||
const ADMIN_KEY = process.env.ADMIN_KEY || "b0db7a87384fc814b0f46ea7bdc6ab6a81152be5b098718b";
|
||
const claude = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||
|
||
// ── Schema migrations ─────────────────────────────────────────────────────────
|
||
export async function initStewardDash() {
|
||
// Curation columns on artifacts
|
||
await pool.query(`ALTER TABLE personaforge_artifacts ADD COLUMN IF NOT EXISTS curation_status TEXT NOT NULL DEFAULT 'pending'`);
|
||
await pool.query(`ALTER TABLE personaforge_artifacts ADD COLUMN IF NOT EXISTS curation_note TEXT`);
|
||
await pool.query(`ALTER TABLE personaforge_artifacts ADD COLUMN IF NOT EXISTS ody_verdict TEXT`);
|
||
// Manual artifact content (bypasses URL fetch — used for injected bio facts, Wikipedia excerpts, etc.)
|
||
await pool.query(`ALTER TABLE personaforge_artifacts ADD COLUMN IF NOT EXISTS content_text TEXT`);
|
||
// Dashboard token stored on personaforge_subjects (guaranteed to exist for all subjects)
|
||
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS dashboard_token TEXT`);
|
||
// Biographical baseline facts (structured markdown, highest-priority corpus chunk, not curatable)
|
||
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS bio_facts_md TEXT`);
|
||
// PTP attestation metadata for bio_facts (who wrote it and under what token/scope)
|
||
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS bio_facts_ptp_token_id TEXT`);
|
||
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS bio_facts_attested_at TIMESTAMPTZ`);
|
||
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS bio_facts_vcap_uri TEXT`);
|
||
// Wikipedia/secondary source summary (skepticism-weighted; separate from primary corpus)
|
||
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS wiki_summary_md TEXT`);
|
||
// PTP attestation metadata for wiki summary
|
||
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS wiki_ptp_token_id TEXT`);
|
||
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS wiki_attested_at TIMESTAMPTZ`);
|
||
// Corpus consult log (PTP injection events, QA sessions)
|
||
await pool.query(`
|
||
CREATE TABLE IF NOT EXISTS corpus_consult_log (
|
||
id SERIAL PRIMARY KEY,
|
||
slug TEXT NOT NULL,
|
||
event_type TEXT NOT NULL,
|
||
token_id TEXT,
|
||
scope TEXT,
|
||
purpose TEXT,
|
||
attested_at TEXT,
|
||
session_notes TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)
|
||
`);
|
||
// Also add to registry for forward-compat (new stewards via web form)
|
||
await pool.query(`ALTER TABLE agentify_subject_registry ADD COLUMN IF NOT EXISTS dashboard_token TEXT`).catch(() => {});
|
||
// Co-steward collaboration: array of invited co-steward emails
|
||
await pool.query(`ALTER TABLE agentify_subject_registry ADD COLUMN IF NOT EXISTS co_steward_emails TEXT[] DEFAULT '{}'`).catch(() => {});
|
||
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS co_steward_emails TEXT[] DEFAULT '{}'`).catch(() => {});
|
||
// Catalog source curation — park/restore without deleting
|
||
await pool.query(`ALTER TABLE agentify_source_catalog ADD COLUMN IF NOT EXISTS catalog_status TEXT NOT NULL DEFAULT 'active'`).catch(() => {});
|
||
// Steward action audit log
|
||
await pool.query(`
|
||
CREATE TABLE IF NOT EXISTS steward_audit_log (
|
||
id SERIAL PRIMARY KEY,
|
||
subject_slug TEXT NOT NULL,
|
||
actor TEXT NOT NULL DEFAULT 'steward',
|
||
action TEXT NOT NULL,
|
||
target_url TEXT,
|
||
target_title TEXT,
|
||
detail TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)
|
||
`).catch(() => {});
|
||
console.log("[StewardDash] Schema columns ready");
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
function isAgentify(req: Request): boolean {
|
||
const candidates = [
|
||
req.headers["x-forwarded-host"],
|
||
req.headers["x-geo-node-host"],
|
||
req.hostname,
|
||
req.headers["host"],
|
||
].flat().filter(Boolean).map((h: any) => h.toString().toLowerCase().split(",")[0].trim());
|
||
return candidates.some(h => h.includes("agentify") && !h.startsWith("skills."));
|
||
}
|
||
|
||
async function validateStewardToken(slug: string, token: string | undefined): Promise<boolean> {
|
||
if (!token) return false;
|
||
// Primary source: personaforge_subjects (exists for all subjects including admin-seeded)
|
||
const { rows } = await pool.query(
|
||
`SELECT dashboard_token FROM personaforge_subjects WHERE slug = $1`,
|
||
[slug]
|
||
);
|
||
if (rows.length > 0 && rows[0].dashboard_token === token) return true;
|
||
// Fallback: agentify_subject_registry (for stewards registered via web form)
|
||
const { rows: reg } = await pool.query(
|
||
`SELECT dashboard_token FROM agentify_subject_registry WHERE subject_slug = $1`,
|
||
[slug]
|
||
).catch(() => ({ rows: [] }));
|
||
return reg.length > 0 && reg[0].dashboard_token === token;
|
||
}
|
||
|
||
async function getOrCreateDashboardToken(slug: string): Promise<string> {
|
||
// Primary: personaforge_subjects
|
||
const { rows } = await pool.query(
|
||
`SELECT dashboard_token FROM personaforge_subjects WHERE slug = $1`, [slug]
|
||
);
|
||
if (rows[0]?.dashboard_token) return rows[0].dashboard_token;
|
||
const token = crypto.randomBytes(24).toString("hex");
|
||
// Store in personaforge_subjects
|
||
const updated = await pool.query(
|
||
`UPDATE personaforge_subjects SET dashboard_token = $2, updated_at = NOW() WHERE slug = $1 RETURNING slug`,
|
||
[slug, token]
|
||
);
|
||
if ((updated.rowCount || 0) === 0) throw new Error(`Subject '${slug}' not found in personaforge_subjects`);
|
||
// Also sync to agentify_subject_registry if that row exists
|
||
await pool.query(
|
||
`UPDATE agentify_subject_registry SET dashboard_token = $2, updated_at = NOW() WHERE subject_slug = $1`,
|
||
[slug, token]
|
||
).catch(() => {});
|
||
return token;
|
||
}
|
||
|
||
async function fetchEssayText(url: string): Promise<string> {
|
||
try {
|
||
const html = await fetch(url, {
|
||
headers: { "User-Agent": "WellSpr.ing PersonaForge/1.0" },
|
||
signal: AbortSignal.timeout(15000),
|
||
}).then(r => r.text());
|
||
return html
|
||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, " ")
|
||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ")
|
||
.replace(/<[^>]+>/g, " ")
|
||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " ")
|
||
.replace(/\s+/g, " ").trim();
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
// ── SSR Dashboard page ────────────────────────────────────────────────────────
|
||
function dashboardPage(slug: string, subjectName: string): string {
|
||
return `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>${subjectName} — Stewardship Dashboard | Agentify.Help</title>
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
--navy:#07102a;--navy2:#0d1b3e;--navy3:#132148;
|
||
--gold:#d4a730;--gold2:#e8c24a;
|
||
--green:#22c55e;--red:#ef4444;--amber:#f59e0b;--blue:#60a5fa;
|
||
--muted:#8899bb;--border:#1e3060;
|
||
--text:#dde6f4;--text2:#a8bcd8;
|
||
}
|
||
body{background:var(--navy);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;min-height:100vh}
|
||
|
||
/* ── header ── */
|
||
.header{background:var(--navy2);border-bottom:1px solid var(--border);padding:1rem 2rem;display:flex;align-items:center;gap:1.5rem;flex-wrap:wrap}
|
||
.header-left{display:flex;align-items:center;gap:1rem}
|
||
.logo-badge{background:var(--gold);color:#07102a;font-size:0.6rem;font-weight:900;letter-spacing:0.12em;padding:0.25rem 0.5rem;border-radius:3px}
|
||
.header-title{font-size:1.25rem;font-weight:700;color:#fff}
|
||
.header-sub{font-size:0.8rem;color:var(--muted);margin-top:0.1rem}
|
||
.header-actions{margin-left:auto;display:flex;gap:0.75rem;align-items:center}
|
||
.btn{display:inline-flex;align-items:center;gap:0.4rem;padding:0.5rem 1rem;border-radius:6px;border:none;cursor:pointer;font-size:0.85rem;font-weight:600;transition:opacity 0.15s}
|
||
.btn:disabled{opacity:0.4;cursor:not-allowed}
|
||
.btn-gold{background:var(--gold);color:#07102a}
|
||
.btn-gold:hover:not(:disabled){background:var(--gold2)}
|
||
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text2)}
|
||
.btn-outline:hover:not(:disabled){border-color:var(--gold);color:var(--gold)}
|
||
.btn-sm{padding:0.3rem 0.65rem;font-size:0.78rem}
|
||
|
||
/* ── stats bar ── */
|
||
.stats-bar{background:var(--navy3);border-bottom:1px solid var(--border);padding:0.85rem 2rem;display:flex;gap:2rem;align-items:center;flex-wrap:wrap}
|
||
.stat{display:flex;flex-direction:column;align-items:center;gap:0.15rem}
|
||
.stat-val{font-size:1.4rem;font-weight:800;color:#fff}
|
||
.stat-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted)}
|
||
.stat-val.green{color:var(--green)}
|
||
.stat-val.red{color:var(--red)}
|
||
.stat-val.amber{color:var(--amber)}
|
||
.progress-wrap{flex:1;min-width:200px}
|
||
.progress-label{font-size:0.75rem;color:var(--muted);margin-bottom:0.35rem}
|
||
.progress-bar{height:6px;background:var(--border);border-radius:3px;overflow:hidden}
|
||
.progress-fill{height:100%;background:var(--green);border-radius:3px;transition:width 0.4s}
|
||
|
||
/* ── filters ── */
|
||
.filters{padding:1rem 2rem;display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;border-bottom:1px solid var(--border)}
|
||
.filter-btn{padding:0.4rem 0.9rem;border-radius:20px;border:1px solid var(--border);background:transparent;color:var(--text2);font-size:0.82rem;cursor:pointer;transition:all 0.15s}
|
||
.filter-btn.active{background:var(--gold);color:#07102a;border-color:var(--gold);font-weight:700}
|
||
.filter-btn:hover:not(.active){border-color:var(--gold);color:var(--gold)}
|
||
.search-wrap{margin-left:auto}
|
||
.search-input{background:var(--navy3);border:1px solid var(--border);border-radius:6px;padding:0.4rem 0.75rem;color:var(--text);font-size:0.82rem;width:220px;outline:none}
|
||
.search-input:focus{border-color:var(--gold)}
|
||
|
||
/* ── artifact grid ── */
|
||
.artifacts{padding:1.5rem 2rem;display:flex;flex-direction:column;gap:0.85rem}
|
||
.artifact-card{background:var(--navy2);border:1px solid var(--border);border-radius:10px;border-left:4px solid var(--border);padding:1rem 1.25rem;transition:border-color 0.2s}
|
||
.artifact-card.pending{border-left-color:var(--border)}
|
||
.artifact-card.approved{border-left-color:var(--green)}
|
||
.artifact-card.rejected{border-left-color:var(--red)}
|
||
.artifact-card.flagged{border-left-color:var(--amber)}
|
||
.card-top{display:flex;align-items:flex-start;gap:1rem}
|
||
.card-meta{flex:1;min-width:0}
|
||
.card-title{font-size:0.97rem;font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.card-details{display:flex;gap:0.75rem;margin-top:0.3rem;flex-wrap:wrap}
|
||
.detail-chip{font-size:0.72rem;color:var(--muted);background:var(--navy3);padding:0.18rem 0.5rem;border-radius:4px}
|
||
.status-badge{font-size:0.7rem;font-weight:700;padding:0.22rem 0.6rem;border-radius:4px;text-transform:uppercase;letter-spacing:0.06em;white-space:nowrap}
|
||
.status-badge.pending{background:rgba(136,153,187,0.15);color:var(--muted)}
|
||
.status-badge.approved{background:rgba(34,197,94,0.15);color:var(--green)}
|
||
.status-badge.rejected{background:rgba(239,68,68,0.15);color:var(--red)}
|
||
.status-badge.flagged{background:rgba(245,158,11,0.15);color:var(--amber)}
|
||
.card-actions{display:flex;gap:0.5rem;margin-top:0.85rem;flex-wrap:wrap;align-items:center}
|
||
.act-btn{padding:0.3rem 0.7rem;border-radius:5px;border:1px solid;font-size:0.78rem;font-weight:600;cursor:pointer;transition:all 0.15s}
|
||
.act-btn.approve{border-color:rgba(34,197,94,0.4);color:var(--green);background:rgba(34,197,94,0.08)}
|
||
.act-btn.approve:hover,.act-btn.approve.active{background:var(--green);color:#07102a;border-color:var(--green)}
|
||
.act-btn.reject{border-color:rgba(239,68,68,0.4);color:var(--red);background:rgba(239,68,68,0.08)}
|
||
.act-btn.reject:hover,.act-btn.reject.active{background:var(--red);color:#fff;border-color:var(--red)}
|
||
.act-btn.flag{border-color:rgba(245,158,11,0.4);color:var(--amber);background:rgba(245,158,11,0.08)}
|
||
.act-btn.flag:hover,.act-btn.flag.active{background:var(--amber);color:#07102a;border-color:var(--amber)}
|
||
.act-btn.ody{border-color:rgba(96,165,250,0.4);color:var(--blue);background:rgba(96,165,250,0.08)}
|
||
.act-btn.ody:hover{background:var(--blue);color:#07102a;border-color:var(--blue)}
|
||
.act-btn:disabled{opacity:0.4;cursor:not-allowed}
|
||
.source-link{font-size:0.72rem;color:var(--muted);text-decoration:none;margin-left:auto}
|
||
.source-link:hover{color:var(--gold)}
|
||
|
||
/* ── Ody verdict inline ── */
|
||
.ody-verdict{background:var(--navy3);border:1px solid rgba(96,165,250,0.25);border-radius:7px;padding:0.85rem 1rem;margin-top:0.85rem;position:relative}
|
||
.ody-verdict-label{font-size:0.68rem;font-weight:700;text-transform:uppercase;letter-spacing:0.1em;color:var(--blue);margin-bottom:0.4rem}
|
||
.ody-verdict-text{font-size:0.86rem;color:var(--text2);line-height:1.6}
|
||
.ody-verdict-tag{display:inline-block;font-size:0.7rem;font-weight:700;padding:0.15rem 0.5rem;border-radius:4px;margin-bottom:0.5rem}
|
||
.ody-verdict-tag.approve{background:rgba(34,197,94,0.15);color:var(--green)}
|
||
.ody-verdict-tag.flag{background:rgba(245,158,11,0.15);color:var(--amber)}
|
||
.ody-verdict-tag.reject{background:rgba(239,68,68,0.15);color:var(--red)}
|
||
.ody-close{position:absolute;top:0.5rem;right:0.75rem;background:none;border:none;color:var(--muted);cursor:pointer;font-size:1rem;padding:0.1rem}
|
||
.ody-close:hover{color:var(--text)}
|
||
.ody-spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(96,165,250,0.3);border-top-color:var(--blue);border-radius:50%;animation:spin 0.7s linear infinite}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
|
||
/* ── empty + loading states ── */
|
||
.empty-state{text-align:center;padding:4rem 2rem;color:var(--muted)}
|
||
.empty-state h2{font-size:1.1rem;color:var(--text2);margin-bottom:0.5rem}
|
||
.empty-state p{font-size:0.85rem;line-height:1.6}
|
||
.loading-state{text-align:center;padding:4rem 2rem;color:var(--muted);font-size:0.9rem}
|
||
|
||
/* ── fetch modal ── */
|
||
.modal-overlay{position:fixed;inset:0;background:rgba(7,16,42,0.85);display:flex;align-items:center;justify-content:center;z-index:100;backdrop-filter:blur(4px)}
|
||
.modal-overlay.hidden{display:none}
|
||
.modal{background:var(--navy2);border:1px solid var(--border);border-radius:12px;padding:2rem;max-width:480px;width:90%;max-height:80vh;overflow-y:auto}
|
||
.modal h2{font-size:1.1rem;font-weight:700;color:#fff;margin-bottom:0.5rem}
|
||
.modal p{font-size:0.85rem;color:var(--text2);line-height:1.6;margin-bottom:1.25rem}
|
||
.modal-log{background:var(--navy);border:1px solid var(--border);border-radius:6px;padding:0.75rem;font-size:0.75rem;font-family:monospace;color:var(--muted);height:150px;overflow-y:auto;margin-bottom:1rem}
|
||
.modal-actions{display:flex;gap:0.75rem;justify-content:flex-end}
|
||
|
||
/* ── build status panel ── */
|
||
.build-panel{background:var(--navy3);border-top:1px solid var(--border);padding:1rem 2rem;display:none;align-items:center;gap:1rem;flex-wrap:wrap;position:sticky;bottom:0}
|
||
.build-panel.visible{display:flex}
|
||
.build-log{background:var(--navy);border:1px solid var(--border);border-radius:6px;padding:0.5rem 0.75rem;font-size:0.72rem;font-family:monospace;color:var(--muted);flex:1;min-width:200px;max-height:80px;overflow-y:auto}
|
||
|
||
/* ── biographical baseline panel ── */
|
||
.bio-panel{border-left:3px solid var(--gold);background:rgba(212,175,55,0.06);margin:1.25rem 2rem 0;border-radius:6px;overflow:hidden}
|
||
.bio-panel-header{display:flex;align-items:center;gap:0.6rem;padding:0.7rem 1rem;cursor:pointer;user-select:none}
|
||
.bio-panel-header h3{font-size:0.85rem;font-weight:700;color:var(--gold);margin:0;letter-spacing:0.04em;text-transform:uppercase}
|
||
.bio-panel-header .bio-badge{font-size:0.68rem;background:rgba(212,175,55,0.18);color:var(--gold);padding:0.2rem 0.5rem;border-radius:10px;font-weight:600}
|
||
.bio-panel-header .bio-expand{margin-left:auto;font-size:0.7rem;color:var(--muted)}
|
||
.bio-panel-body{padding:0 1rem 1rem;display:none}
|
||
.bio-panel-body.open{display:block}
|
||
.bio-section{margin-bottom:1rem}
|
||
.bio-section h4{font-size:0.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin:0 0 0.4rem}
|
||
.bio-textarea{width:100%;background:var(--navy);border:1px solid var(--border);border-radius:5px;color:#e8e8e8;font-family:monospace;font-size:0.72rem;line-height:1.5;padding:0.6rem 0.75rem;resize:vertical;min-height:160px;box-sizing:border-box}
|
||
.bio-textarea:focus{outline:none;border-color:var(--gold)}
|
||
.bio-actions{display:flex;gap:0.6rem;margin-top:0.5rem;align-items:center}
|
||
.bio-status{font-size:0.72rem;color:var(--muted);margin-left:auto}
|
||
|
||
a{color:inherit;text-decoration:none}
|
||
|
||
/* ── tab nav ── */
|
||
.tab-nav{display:flex;align-items:center;gap:0.5rem;padding:0.6rem 2rem;border-bottom:1px solid var(--border);flex-wrap:wrap;background:var(--navy2)}
|
||
.tab-btn{padding:0.38rem 0.85rem;border-radius:20px;border:1px solid var(--border);background:transparent;color:var(--text2);font-size:0.82rem;cursor:pointer;transition:all 0.15s;display:inline-flex;align-items:center;gap:0.35rem}
|
||
.tab-btn.active{background:var(--gold);color:#07102a;border-color:var(--gold);font-weight:700}
|
||
.tab-btn:hover:not(.active){border-color:var(--gold);color:var(--gold)}
|
||
.tab-count{font-size:0.7rem;background:rgba(255,255,255,0.14);padding:0.05rem 0.45rem;border-radius:10px;min-width:1.2em;text-align:center}
|
||
.tab-btn.active .tab-count{background:rgba(0,0,0,0.18)}
|
||
|
||
/* ── corpus sources ── */
|
||
.corpus-section{padding:1.25rem 2rem;display:flex;flex-direction:column;gap:0.75rem}
|
||
.cs-toolbar{display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.25rem}
|
||
#csStatus{font-size:0.8rem;color:var(--muted);flex:1}
|
||
.cs-list{display:flex;flex-direction:column;gap:0.65rem}
|
||
.cs-card{background:var(--navy2);border:1px solid var(--border);border-radius:10px;border-left:4px solid var(--border);padding:0.9rem 1.1rem;transition:border-color 0.2s;display:flex;align-items:flex-start;gap:1rem}
|
||
.cs-card.parked{border-left-color:var(--amber);opacity:0.6}
|
||
.cs-card.active{border-left-color:var(--green)}
|
||
.cs-main{flex:1;min-width:0}
|
||
.cs-title{font-size:0.88rem;font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:0.3rem}
|
||
.cs-meta{display:flex;gap:0.45rem;flex-wrap:wrap;margin-bottom:0.4rem}
|
||
.cs-badge{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.05em;padding:0.15rem 0.45rem;border-radius:3px;font-weight:700;background:rgba(255,255,255,0.07);color:var(--muted)}
|
||
.cs-badge.podcast{background:rgba(96,165,250,0.14);color:var(--blue)}
|
||
.cs-badge.article{background:rgba(74,222,128,0.1);color:#4ade80}
|
||
.cs-badge.book{background:rgba(212,167,55,0.14);color:var(--gold)}
|
||
.cs-badge.interview{background:rgba(192,132,252,0.14);color:#c084fc}
|
||
.cs-badge.parked-badge{background:rgba(245,158,11,0.14);color:var(--amber)}
|
||
.cs-badge.active-badge{background:rgba(34,197,94,0.1);color:var(--green)}
|
||
.cs-actions{display:flex;gap:0.5rem;align-items:center;flex-shrink:0}
|
||
.cs-btn{padding:0.3rem 0.7rem;border-radius:5px;border:1px solid var(--border);background:transparent;color:var(--text2);font-size:0.75rem;cursor:pointer;transition:all 0.15s;font-family:inherit;white-space:nowrap}
|
||
.cs-btn:hover{border-color:var(--gold);color:var(--gold)}
|
||
.cs-btn.park{border-color:rgba(245,158,11,0.35);color:var(--amber)}
|
||
.cs-btn.park:hover{background:rgba(245,158,11,0.09)}
|
||
.cs-btn.restore{border-color:rgba(34,197,94,0.35);color:var(--green)}
|
||
.cs-btn.restore:hover{background:rgba(34,197,94,0.09)}
|
||
|
||
/* ── activity log ── */
|
||
.activity-section{padding:1.5rem 2rem;max-width:760px}
|
||
.act-header{font-size:0.78rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.09em;margin-bottom:1rem}
|
||
.act-item{display:flex;gap:0.85rem;padding:0.6rem 0;border-bottom:1px solid var(--border)}
|
||
.act-item:last-child{border-bottom:none}
|
||
.act-icon{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.72rem;flex-shrink:0;background:rgba(255,255,255,0.06);margin-top:0.1rem}
|
||
.act-body{flex:1;min-width:0}
|
||
.act-what{font-size:0.82rem;color:var(--text);text-transform:capitalize}
|
||
.act-detail{font-size:0.73rem;color:var(--muted);margin-top:0.15rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.act-when{font-size:0.7rem;color:var(--muted);white-space:nowrap;flex-shrink:0;padding-top:0.15rem}
|
||
.act-empty{font-size:0.85rem;color:var(--muted);padding:2.5rem;text-align:center}
|
||
.discover-query-wrap{margin:0.75rem 0}
|
||
.discover-query-wrap .search-input{width:100%;font-size:0.85rem}
|
||
.discover-result{background:var(--navy);border:1px solid var(--border);border-radius:5px;padding:0.6rem 0.75rem;font-size:0.72rem;font-family:monospace;color:var(--text2);white-space:pre-wrap;max-height:200px;overflow-y:auto;line-height:1.5}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ── Header ── -->
|
||
<header class="header">
|
||
<div class="header-left">
|
||
<span class="logo-badge">AGENTIFY.HELP</span>
|
||
<div>
|
||
<div class="header-title" id="subjectName">${subjectName}</div>
|
||
<div class="header-sub">Corpus Stewardship Dashboard</div>
|
||
</div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<button class="btn btn-outline btn-sm" id="btnFetch" onclick="openFetchModal()">Fetch source essays</button>
|
||
<button class="btn btn-gold" id="btnBuild" onclick="triggerBuild()" disabled>
|
||
Build corpus from approved
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ── Stats bar ── -->
|
||
<div class="stats-bar" id="statsBar">
|
||
<div class="stat"><span class="stat-val" id="statTotal">—</span><span class="stat-label">Total</span></div>
|
||
<div class="stat"><span class="stat-val green" id="statApproved">—</span><span class="stat-label">Approved</span></div>
|
||
<div class="stat"><span class="stat-val red" id="statRejected">—</span><span class="stat-label">Rejected</span></div>
|
||
<div class="stat"><span class="stat-val amber" id="statFlagged">—</span><span class="stat-label">Flagged</span></div>
|
||
<div class="stat"><span class="stat-val" id="statPending">—</span><span class="stat-label">Pending</span></div>
|
||
<div class="progress-wrap">
|
||
<div class="progress-label" id="progressLabel">0 of 0 reviewed</div>
|
||
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Biographical Baseline Panel ── -->
|
||
<div class="bio-panel" id="bioPanel">
|
||
<div class="bio-panel-header" onclick="toggleBioPanel()">
|
||
<h3>Biographical Baseline</h3>
|
||
<span class="bio-badge" id="bioBadge">Loading…</span>
|
||
<span class="bio-expand" id="bioExpandLabel">▼ show</span>
|
||
</div>
|
||
<div class="bio-panel-body" id="bioPanelBody">
|
||
<div class="bio-section">
|
||
<h4>Ground-Truth Facts — injected as first corpus chunk, highest priority, not curatable</h4>
|
||
<textarea class="bio-textarea" id="bioFactsMd" placeholder="Paste structured biographical facts here (markdown). These become the persona's factual anchor — name, birthdate, family, education, career timeline, known frameworks. Skepticism-weight each fact: [V]=verified, [P]=partial, [U]=unconfirmed."></textarea>
|
||
<div class="bio-actions">
|
||
<button class="btn btn-gold btn-sm" onclick="saveBioFacts()">Save facts</button>
|
||
<button class="btn btn-outline btn-sm" onclick="loadBioFacts()">Reload</button>
|
||
<span class="bio-status" id="bioFactsStatus"></span>
|
||
</div>
|
||
</div>
|
||
<div class="bio-section">
|
||
<h4>Wikipedia / Secondary Sources — skepticism-weighted; separate from primary corpus</h4>
|
||
<textarea class="bio-textarea" id="wikiSummaryMd" placeholder="Paste Wikipedia summary or article excerpts here. These are ingested with a skepticism-weighting note — the persona knows this is secondary source material and will not treat it as first-person memory."></textarea>
|
||
<div class="bio-actions">
|
||
<button class="btn btn-gold btn-sm" onclick="saveWikiSummary()">Save wiki/articles</button>
|
||
<button class="btn btn-outline btn-sm" onclick="loadBioFacts()">Reload</button>
|
||
<span class="bio-status" id="wikiStatus"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Tab nav ── -->
|
||
<div class="tab-nav">
|
||
<button class="tab-btn active" onclick="switchTab('artifacts',this)">Artifacts <span class="tab-count" id="tabCountArtifacts">0</span></button>
|
||
<button class="tab-btn" onclick="switchTab('corpus',this)">Corpus Sources <span class="tab-count" id="tabCountCorpus">0</span></button>
|
||
<button class="tab-btn" onclick="switchTab('activity',this)">Activity <span class="tab-count" id="tabCountActivity"></span></button>
|
||
<button class="btn btn-outline btn-sm" style="margin-left:auto" onclick="openDiscoverModal()">+ Discover content</button>
|
||
</div>
|
||
|
||
<!-- ── Filters ── -->
|
||
<div class="filters" id="artifactFilters">
|
||
<button class="filter-btn active" data-filter="all" onclick="setFilter('all',this)">All</button>
|
||
<button class="filter-btn" data-filter="pending" onclick="setFilter('pending',this)">Pending</button>
|
||
<button class="filter-btn" data-filter="approved" onclick="setFilter('approved',this)">Approved</button>
|
||
<button class="filter-btn" data-filter="flagged" onclick="setFilter('flagged',this)">Flagged</button>
|
||
<button class="filter-btn" data-filter="rejected" onclick="setFilter('rejected',this)">Rejected</button>
|
||
<div class="search-wrap">
|
||
<input class="search-input" type="text" id="searchInput" placeholder="Search essays..." oninput="renderArtifacts()">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Artifact list ── -->
|
||
<div class="artifacts" id="artifactsContainer">
|
||
<div class="loading-state" id="loadingState">Loading corpus artifacts…</div>
|
||
</div>
|
||
|
||
<!-- ── Corpus Sources section ── -->
|
||
<div class="corpus-section" id="corpusSection" style="display:none">
|
||
<div class="cs-toolbar">
|
||
<span id="csStatus">Loading…</span>
|
||
<button class="filter-btn active" data-csf="all" onclick="setCsFilter('all',this)">All</button>
|
||
<button class="filter-btn" data-csf="active" onclick="setCsFilter('active',this)">Active</button>
|
||
<button class="filter-btn" data-csf="parked" onclick="setCsFilter('parked',this)">Parked</button>
|
||
</div>
|
||
<div class="cs-list" id="csList"><div class="loading-state">Loading corpus sources…</div></div>
|
||
</div>
|
||
|
||
<!-- ── Activity section ── -->
|
||
<div class="activity-section" id="activitySection" style="display:none">
|
||
<div class="act-list" id="actList"><div class="act-empty">Loading activity…</div></div>
|
||
</div>
|
||
|
||
<!-- ── Discover modal ── -->
|
||
<div class="modal-overlay hidden" id="discoverModal">
|
||
<div class="modal">
|
||
<h2>Discover new content</h2>
|
||
<p>Let Ody search for books, articles, and other sources not yet in the corpus. Results appear here for review — nothing is added automatically.</p>
|
||
<div class="discover-query-wrap">
|
||
<input class="search-input" type="text" id="discoverQuery" placeholder='e.g. "books by Guy Kawasaki" or leave blank for all types'>
|
||
</div>
|
||
<div class="discover-result" id="discoverLog">Ready.</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-outline" onclick="closeDiscoverModal()">Close</button>
|
||
<button class="btn btn-gold" id="btnDiscoverConfirm" onclick="doDiscover()">Find content</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Build panel ── -->
|
||
<div class="build-panel" id="buildPanel">
|
||
<span style="font-size:0.85rem;font-weight:600;color:var(--gold)">Building corpus…</span>
|
||
<div class="build-log" id="buildLog"></div>
|
||
<button class="btn btn-outline btn-sm" onclick="closeBuildPanel()">Dismiss</button>
|
||
</div>
|
||
|
||
<!-- ── Fetch modal ── -->
|
||
<div class="modal-overlay hidden" id="fetchModal">
|
||
<div class="modal">
|
||
<h2>Fetch source essays</h2>
|
||
<p>This will scrape the source URL and pull the full list of essays. Existing entries are preserved. No essay text is fetched yet — just titles and URLs.</p>
|
||
<div class="modal-log" id="fetchLog">Ready.</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-outline" onclick="closeFetchModal()">Cancel</button>
|
||
<button class="btn btn-gold" id="btnFetchConfirm" onclick="doFetch()">Fetch essays</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const SLUG = ${JSON.stringify(slug)};
|
||
const TOKEN = new URLSearchParams(location.search).get('token') || sessionStorage.getItem('steward_token_' + SLUG) || '';
|
||
if (TOKEN) sessionStorage.setItem('steward_token_' + SLUG, TOKEN);
|
||
|
||
let allArtifacts = [];
|
||
let currentFilter = 'all';
|
||
|
||
// ── Bootstrap ──────────────────────────────────────────────────────────────
|
||
async function init() {
|
||
await Promise.all([loadArtifacts(), loadBioFacts(), loadCatalogSources(), loadActivity()]);
|
||
}
|
||
|
||
// ── Tab switching ──────────────────────────────────────────────────────────
|
||
function switchTab(tab, btn) {
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
const filters = document.getElementById('artifactFilters');
|
||
const artifacts = document.getElementById('artifactsContainer');
|
||
const corpus = document.getElementById('corpusSection');
|
||
const activity = document.getElementById('activitySection');
|
||
if (tab === 'artifacts') {
|
||
filters.style.display = '';
|
||
artifacts.style.display = '';
|
||
corpus.style.display = 'none';
|
||
activity.style.display = 'none';
|
||
} else if (tab === 'corpus') {
|
||
filters.style.display = 'none';
|
||
artifacts.style.display = 'none';
|
||
corpus.style.display = '';
|
||
activity.style.display = 'none';
|
||
} else {
|
||
filters.style.display = 'none';
|
||
artifacts.style.display = 'none';
|
||
corpus.style.display = 'none';
|
||
activity.style.display = '';
|
||
}
|
||
}
|
||
|
||
// ── Catalog Sources ─────────────────────────────────────────────────────────
|
||
let allCatalogSources = [];
|
||
let csLoaded = false;
|
||
let csFilter = 'all';
|
||
let actLoaded = false;
|
||
|
||
function setCsFilter(f, el) {
|
||
csFilter = f;
|
||
document.querySelectorAll('[data-csf]').forEach(b => b.classList.remove('active'));
|
||
el.classList.add('active');
|
||
renderCatalogSources();
|
||
}
|
||
|
||
async function loadCatalogSources() {
|
||
const listEl = document.getElementById('csList');
|
||
const statusEl = document.getElementById('csStatus');
|
||
if (!listEl) return;
|
||
try {
|
||
const r = await fetch('/api/steward/' + SLUG + '/catalog-sources?token=' + TOKEN);
|
||
if (!r.ok) {
|
||
if (r.status === 403) { listEl.innerHTML = '<div class="empty-state"><p>Invalid token.</p></div>'; return; }
|
||
throw new Error(await r.text());
|
||
}
|
||
const data = await r.json();
|
||
allCatalogSources = data.sources || [];
|
||
csLoaded = true;
|
||
const countEl = document.getElementById('tabCountCorpus');
|
||
if (countEl) countEl.textContent = allCatalogSources.length;
|
||
if (statusEl) statusEl.textContent = allCatalogSources.length + ' sources in corpus';
|
||
renderCatalogSources();
|
||
} catch(e) {
|
||
listEl.innerHTML = '<div class="empty-state"><h2>Error</h2><p>' + esc(e.message) + '</p></div>';
|
||
if (statusEl) statusEl.textContent = 'Error loading';
|
||
}
|
||
}
|
||
|
||
function renderCatalogSources() {
|
||
const listEl = document.getElementById('csList');
|
||
if (!listEl) return;
|
||
let visible = allCatalogSources.filter(s => {
|
||
if (csFilter === 'active') return s.catalog_status !== 'parked';
|
||
if (csFilter === 'parked') return s.catalog_status === 'parked';
|
||
return true;
|
||
});
|
||
if (!visible.length) {
|
||
listEl.innerHTML = '<div class="empty-state"><h2>No sources</h2><p>Try a different filter or use Discover to find new content.</p></div>';
|
||
return;
|
||
}
|
||
listEl.innerHTML = visible.map(s => catalogCardHTML(s)).join('');
|
||
}
|
||
|
||
function catalogCardHTML(s) {
|
||
const parked = s.catalog_status === 'parked';
|
||
const type = (s.type || 'article').toLowerCase();
|
||
const chunks = s.chunk_count ? s.chunk_count + ' chunks' : '';
|
||
let domain = '';
|
||
try {
|
||
if (s.url && !s.url.startsWith('podcast://')) domain = new URL(s.url).hostname.replace('www.', '');
|
||
} catch {}
|
||
return \`<div class="cs-card \${parked ? 'parked' : 'active'}" id="cscard-\${s.id}">
|
||
<div class="cs-main">
|
||
<div class="cs-title" title="\${esc(s.title || s.url)}">\${esc(s.title || s.url)}</div>
|
||
<div class="cs-meta">
|
||
<span class="cs-badge \${type}">\${type}</span>
|
||
\${chunks ? \`<span class="cs-badge">\${esc(chunks)}</span>\` : ''}
|
||
\${domain ? \`<span class="cs-badge">\${esc(domain)}</span>\` : ''}
|
||
<span class="cs-badge \${parked ? 'parked-badge' : 'active-badge'}">\${parked ? 'Parked' : 'Active'}</span>
|
||
</div>
|
||
\${s.url && !s.url.startsWith('podcast://') ? \`<a href="\${esc(s.url)}" target="_blank" rel="noopener" style="font-size:0.72rem;color:var(--muted)">View source ↗</a>\` : ''}
|
||
</div>
|
||
<div class="cs-actions">
|
||
\${parked
|
||
? \`<button class="cs-btn restore" onclick="togglePark(\${s.id},false)">↩ Restore</button>\`
|
||
: \`<button class="cs-btn park" onclick="togglePark(\${s.id},true)">⊘ Park</button>\`
|
||
}
|
||
</div>
|
||
</div>\`;
|
||
}
|
||
|
||
async function togglePark(id, park) {
|
||
const card = document.getElementById('cscard-' + id);
|
||
const btn = card ? card.querySelector('.cs-btn') : null;
|
||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||
try {
|
||
const r = await fetch('/api/steward/' + SLUG + '/catalog-sources/' + id, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token: TOKEN, action: park ? 'park' : 'restore' }),
|
||
});
|
||
if (!r.ok) throw new Error(await r.text());
|
||
const s = allCatalogSources.find(x => x.id === id);
|
||
if (s) s.catalog_status = park ? 'parked' : 'active';
|
||
renderCatalogSources();
|
||
actLoaded = false;
|
||
loadActivity();
|
||
} catch(e) {
|
||
alert('Could not save: ' + e.message);
|
||
if (btn) { btn.disabled = false; btn.textContent = park ? '⊘ Park' : '↩ Restore'; }
|
||
}
|
||
}
|
||
|
||
// ── Activity log ──────────────────────────────────────────────────────────
|
||
async function loadActivity() {
|
||
const listEl = document.getElementById('actList');
|
||
if (!listEl) return;
|
||
try {
|
||
const r = await fetch('/api/steward/' + SLUG + '/activity?token=' + TOKEN);
|
||
if (!r.ok) throw new Error(await r.text());
|
||
const data = await r.json();
|
||
const items = data.items || [];
|
||
actLoaded = true;
|
||
const countEl = document.getElementById('tabCountActivity');
|
||
if (countEl) countEl.textContent = items.length > 0 ? String(items.length) : '';
|
||
if (!items.length) {
|
||
listEl.innerHTML = '<div class="act-empty">No activity recorded yet. Actions like parking sources or discovering content will appear here.</div>';
|
||
return;
|
||
}
|
||
const icons = {park:'⊘',restore:'↩',discover:'⌕',build:'⚙',fetch:'⬇',curation:'✓'};
|
||
listEl.innerHTML = '<div class="act-header">Recent activity</div>' + items.map(item => {
|
||
const icon = icons[item.action] || '·';
|
||
const when = item.created_at ? new Date(item.created_at).toLocaleString() : '';
|
||
return \`<div class="act-item">
|
||
<div class="act-icon">\${esc(icon)}</div>
|
||
<div class="act-body">
|
||
<div class="act-what">\${esc(item.action)}</div>
|
||
\${item.target_title ? \`<div class="act-detail">\${esc(item.target_title)}</div>\` : ''}
|
||
\${item.detail ? \`<div class="act-detail" style="color:var(--text2)">\${esc(item.detail)}</div>\` : ''}
|
||
</div>
|
||
<div class="act-when">\${esc(when)}</div>
|
||
</div>\`;
|
||
}).join('');
|
||
} catch(e) {
|
||
listEl.innerHTML = '<div class="act-empty">Error loading activity: ' + esc(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
// ── Discover content ──────────────────────────────────────────────────────
|
||
function openDiscoverModal() {
|
||
document.getElementById('discoverLog').textContent = 'Ready to search for new sources.';
|
||
document.getElementById('discoverQuery').value = '';
|
||
document.getElementById('discoverModal').classList.remove('hidden');
|
||
document.getElementById('btnDiscoverConfirm').disabled = false;
|
||
document.getElementById('btnDiscoverConfirm').textContent = 'Find content';
|
||
}
|
||
|
||
function closeDiscoverModal() {
|
||
document.getElementById('discoverModal').classList.add('hidden');
|
||
}
|
||
|
||
async function doDiscover() {
|
||
const logEl = document.getElementById('discoverLog');
|
||
const btn = document.getElementById('btnDiscoverConfirm');
|
||
const query = document.getElementById('discoverQuery').value.trim();
|
||
btn.disabled = true;
|
||
btn.textContent = 'Searching…';
|
||
logEl.textContent = 'Asking Ody to find new content…';
|
||
try {
|
||
const r = await fetch('/api/steward/' + SLUG + '/discover', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token: TOKEN, query }),
|
||
});
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.error || 'Discover failed');
|
||
const found = data.suggestions || [];
|
||
if (!found.length) {
|
||
logEl.textContent = 'No new sources found. Try a more specific query.';
|
||
} else {
|
||
logEl.textContent = 'Found ' + found.length + ' potential source(s):\\n\\n' +
|
||
found.map((s, i) => (i+1) + '. ' + (s.title || 'Untitled') + '\\n ' + (s.url || '') + (s.note ? '\\n ' + s.note : '')).join('\\n\\n');
|
||
}
|
||
btn.textContent = 'Search again';
|
||
btn.disabled = false;
|
||
actLoaded = false;
|
||
loadActivity();
|
||
} catch(e) {
|
||
logEl.textContent = 'Error: ' + e.message;
|
||
btn.disabled = false;
|
||
btn.textContent = 'Try again';
|
||
}
|
||
}
|
||
|
||
// ── Biographical Baseline ──────────────────────────────────────────────────
|
||
let bioPanelOpen = false;
|
||
|
||
function toggleBioPanel() {
|
||
bioPanelOpen = !bioPanelOpen;
|
||
document.getElementById('bioPanelBody').classList.toggle('open', bioPanelOpen);
|
||
document.getElementById('bioExpandLabel').textContent = bioPanelOpen ? '▲ hide' : '▼ show';
|
||
}
|
||
|
||
async function loadBioFacts() {
|
||
try {
|
||
const r = await fetch('/api/steward/' + SLUG + '/bio-facts?token=' + TOKEN);
|
||
const data = await r.json();
|
||
document.getElementById('bioFactsMd').value = data.bio_facts_md || '';
|
||
document.getElementById('wikiSummaryMd').value = data.wiki_summary_md || '';
|
||
const hasB = !!(data.bio_facts_md && data.bio_facts_md.trim());
|
||
const hasW = !!(data.wiki_summary_md && data.wiki_summary_md.trim());
|
||
const badge = hasB ? (hasW ? 'Bio + Wiki loaded' : 'Bio loaded') : (hasW ? 'Wiki only' : 'Not set');
|
||
document.getElementById('bioBadge').textContent = badge;
|
||
document.getElementById('bioBadge').style.color = hasB ? 'var(--gold)' : 'var(--muted)';
|
||
if (hasB && !bioPanelOpen) toggleBioPanel();
|
||
} catch (e) {
|
||
document.getElementById('bioBadge').textContent = 'Error loading';
|
||
}
|
||
}
|
||
|
||
async function saveBioFacts() {
|
||
const md = document.getElementById('bioFactsMd').value;
|
||
const st = document.getElementById('bioFactsStatus');
|
||
st.textContent = 'Saving…';
|
||
try {
|
||
const r = await fetch('/api/steward/' + SLUG + '/bio-facts', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token: TOKEN, bio_facts_md: md }),
|
||
});
|
||
const data = await r.json();
|
||
st.textContent = data.ok ? 'Saved ✓' : ('Error: ' + (data.error || 'unknown'));
|
||
if (data.ok) await loadBioFacts();
|
||
} catch (e) {
|
||
st.textContent = 'Network error';
|
||
}
|
||
setTimeout(() => { st.textContent = ''; }, 3000);
|
||
}
|
||
|
||
async function saveWikiSummary() {
|
||
const md = document.getElementById('wikiSummaryMd').value;
|
||
const st = document.getElementById('wikiStatus');
|
||
st.textContent = 'Saving…';
|
||
try {
|
||
const r = await fetch('/api/steward/' + SLUG + '/bio-facts', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token: TOKEN, wiki_summary_md: md }),
|
||
});
|
||
const data = await r.json();
|
||
st.textContent = data.ok ? 'Saved ✓' : ('Error: ' + (data.error || 'unknown'));
|
||
if (data.ok) await loadBioFacts();
|
||
} catch (e) {
|
||
st.textContent = 'Network error';
|
||
}
|
||
setTimeout(() => { st.textContent = ''; }, 3000);
|
||
}
|
||
|
||
async function loadArtifacts() {
|
||
try {
|
||
const r = await fetch('/api/steward/' + SLUG + '/artifacts?token=' + TOKEN);
|
||
if (!r.ok) {
|
||
if (r.status === 403) return showError('Invalid or expired dashboard token. Check your link.');
|
||
return showError('Could not load artifacts.');
|
||
}
|
||
const data = await r.json();
|
||
allArtifacts = data.artifacts || [];
|
||
renderStats();
|
||
renderArtifacts();
|
||
updateBuildBtn();
|
||
} catch (e) {
|
||
showError('Network error loading artifacts.');
|
||
}
|
||
}
|
||
|
||
function showError(msg) {
|
||
document.getElementById('artifactsContainer').innerHTML =
|
||
'<div class="empty-state"><h2>Error</h2><p>' + msg + '</p></div>';
|
||
}
|
||
|
||
// ── Stats ──────────────────────────────────────────────────────────────────
|
||
function renderStats() {
|
||
const total = allArtifacts.length;
|
||
const approved = allArtifacts.filter(a => a.curation_status === 'approved').length;
|
||
const rejected = allArtifacts.filter(a => a.curation_status === 'rejected').length;
|
||
const flagged = allArtifacts.filter(a => a.curation_status === 'flagged').length;
|
||
const pending = allArtifacts.filter(a => a.curation_status === 'pending').length;
|
||
const reviewed = total - pending;
|
||
document.getElementById('statTotal').textContent = total;
|
||
document.getElementById('statApproved').textContent = approved;
|
||
document.getElementById('statRejected').textContent = rejected;
|
||
document.getElementById('statFlagged').textContent = flagged;
|
||
document.getElementById('statPending').textContent = pending;
|
||
document.getElementById('progressLabel').textContent = reviewed + ' of ' + total + ' reviewed';
|
||
document.getElementById('progressFill').style.width = total ? Math.round(reviewed/total*100)+'%' : '0%';
|
||
}
|
||
|
||
function updateBuildBtn() {
|
||
const approved = allArtifacts.filter(a => a.curation_status === 'approved').length;
|
||
const btn = document.getElementById('btnBuild');
|
||
btn.disabled = approved === 0;
|
||
btn.textContent = approved > 0
|
||
? 'Build corpus from ' + approved + ' approved'
|
||
: 'Build corpus from approved';
|
||
}
|
||
|
||
// ── Filter + search ────────────────────────────────────────────────────────
|
||
function setFilter(f, el) {
|
||
currentFilter = f;
|
||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||
el.classList.add('active');
|
||
renderArtifacts();
|
||
}
|
||
|
||
function renderArtifacts() {
|
||
const query = (document.getElementById('searchInput').value || '').toLowerCase();
|
||
let visible = allArtifacts.filter(a => {
|
||
if (currentFilter !== 'all' && a.curation_status !== currentFilter) return false;
|
||
if (query && !a.title.toLowerCase().includes(query)) return false;
|
||
return true;
|
||
});
|
||
|
||
const container = document.getElementById('artifactsContainer');
|
||
if (allArtifacts.length === 0) {
|
||
container.innerHTML = '<div class="empty-state"><h2>No essays fetched yet</h2><p>Click "Fetch source essays" to pull the essay list from the source URL. No essay text is downloaded yet — just titles and links.</p></div>';
|
||
return;
|
||
}
|
||
if (visible.length === 0) {
|
||
container.innerHTML = '<div class="empty-state"><h2>No matches</h2><p>Try a different filter or search term.</p></div>';
|
||
return;
|
||
}
|
||
container.innerHTML = visible.map(a => cardHTML(a)).join('');
|
||
}
|
||
|
||
// ── Card HTML ──────────────────────────────────────────────────────────────
|
||
function cardHTML(a) {
|
||
const wc = a.word_count ? a.word_count.toLocaleString() + ' words' : 'word count unknown';
|
||
const date = a.pub_date || 'undated';
|
||
const statusClass = a.curation_status || 'pending';
|
||
const statusLabel = {pending:'Pending',approved:'Approved',rejected:'Rejected',flagged:'Flagged'}[statusClass] || 'Pending';
|
||
|
||
const verdictHtml = a.ody_verdict ? odtVerdictInlineHTML(a.ody_verdict, a.artifact_id) : '';
|
||
|
||
return \`<div class="artifact-card \${statusClass}" id="card-\${a.artifact_id}">
|
||
<div class="card-top">
|
||
<div class="card-meta">
|
||
<div class="card-title" title="\${esc(a.title)}">\${esc(a.title)}</div>
|
||
<div class="card-details">
|
||
<span class="detail-chip">\${esc(date)}</span>
|
||
<span class="detail-chip">\${esc(wc)}</span>
|
||
<span class="detail-chip">\${esc(a.artifact_type || 'essay')}</span>
|
||
</div>
|
||
</div>
|
||
<span class="status-badge \${statusClass}">\${statusLabel}</span>
|
||
</div>
|
||
<div class="card-actions">
|
||
<button class="act-btn approve \${statusClass==='approved'?'active':''}" onclick="setCuration('\${a.artifact_id}','approved',this)">✓ Approve</button>
|
||
<button class="act-btn reject \${statusClass==='rejected'?'active':''}" onclick="setCuration('\${a.artifact_id}','rejected',this)">✗ Reject</button>
|
||
<button class="act-btn flag \${statusClass==='flagged'?'active':''}" onclick="setCuration('\${a.artifact_id}','flagged',this)">⚑ Flag</button>
|
||
<button class="act-btn ody" id="ody-btn-\${a.artifact_id}" onclick="askOdy('\${a.artifact_id}',\${JSON.stringify(a.title)},\${JSON.stringify(a.source_url||'')})">Ask Ody</button>
|
||
\${a.source_url ? \`<a class="source-link" href="\${esc(a.source_url)}" target="_blank" rel="noopener">Read →</a>\` : ''}
|
||
</div>
|
||
\${verdictHtml}
|
||
</div>\`;
|
||
}
|
||
|
||
function odtVerdictInlineHTML(verdict, artifactId) {
|
||
const parsed = tryParseVerdict(verdict);
|
||
return \`<div class="ody-verdict" id="verdict-\${artifactId}">
|
||
<div class="ody-verdict-label">Ody's read</div>
|
||
\${parsed.tag ? \`<span class="ody-verdict-tag \${parsed.tag}">\${parsed.tagLabel}</span>\` : ''}
|
||
<div class="ody-verdict-text">\${esc(parsed.body)}</div>
|
||
<button class="ody-close" onclick="clearVerdict('\${artifactId}')" title="Dismiss">×</button>
|
||
</div>\`;
|
||
}
|
||
|
||
function tryParseVerdict(text) {
|
||
const lower = text.toLowerCase();
|
||
let tag = '', tagLabel = '';
|
||
if (lower.includes('verdict: approve') || lower.startsWith('approve')) { tag='approve'; tagLabel='Approve'; }
|
||
else if (lower.includes('verdict: flag') || lower.startsWith('flag')) { tag='flag'; tagLabel='Flag for review'; }
|
||
else if (lower.includes('verdict: reject') || lower.startsWith('reject')) { tag='reject'; tagLabel='Reject'; }
|
||
return { tag, tagLabel, body: text };
|
||
}
|
||
|
||
function clearVerdict(artifactId) {
|
||
const el = document.getElementById('verdict-' + artifactId);
|
||
if (el) el.remove();
|
||
const a = allArtifacts.find(x => x.artifact_id === artifactId);
|
||
if (a) a.ody_verdict = null;
|
||
}
|
||
|
||
function esc(str) {
|
||
return String(str||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ── Curation actions ───────────────────────────────────────────────────────
|
||
async function setCuration(artifactId, status, clickedBtn) {
|
||
const card = document.getElementById('card-' + artifactId);
|
||
const btns = card.querySelectorAll('.act-btn.approve,.act-btn.reject,.act-btn.flag');
|
||
btns.forEach(b => b.disabled = true);
|
||
try {
|
||
const r = await fetch('/api/steward/' + SLUG + '/artifacts/' + artifactId, {
|
||
method: 'PATCH',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ curation_status: status, token: TOKEN }),
|
||
});
|
||
if (!r.ok) throw new Error('Save failed');
|
||
const a = allArtifacts.find(x => x.artifact_id === artifactId);
|
||
if (a) a.curation_status = status;
|
||
// update card in place
|
||
card.className = 'artifact-card ' + status;
|
||
const badge = card.querySelector('.status-badge');
|
||
if (badge) {
|
||
badge.className = 'status-badge ' + status;
|
||
badge.textContent = {pending:'Pending',approved:'Approved',rejected:'Rejected',flagged:'Flagged'}[status];
|
||
}
|
||
btns.forEach(b => {
|
||
b.classList.remove('active');
|
||
b.disabled = false;
|
||
});
|
||
clickedBtn.classList.add('active');
|
||
renderStats();
|
||
updateBuildBtn();
|
||
} catch {
|
||
btns.forEach(b => b.disabled = false);
|
||
alert('Could not save. Try again.');
|
||
}
|
||
}
|
||
|
||
// ── Ask Ody ────────────────────────────────────────────────────────────────
|
||
async function askOdy(artifactId, title, sourceUrl) {
|
||
const btn = document.getElementById('ody-btn-' + artifactId);
|
||
if (!btn) return;
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="ody-spinner"></span> Asking…';
|
||
|
||
// Remove existing verdict if any
|
||
const existingVerdict = document.getElementById('verdict-' + artifactId);
|
||
if (existingVerdict) existingVerdict.remove();
|
||
|
||
// Show spinner in card
|
||
const card = document.getElementById('card-' + artifactId);
|
||
const spinEl = document.createElement('div');
|
||
spinEl.className = 'ody-verdict';
|
||
spinEl.id = 'verdict-' + artifactId;
|
||
spinEl.innerHTML = '<div class="ody-verdict-label">Ody is reading…</div><div style="display:flex;align-items:center;gap:0.5rem;margin-top:0.4rem"><span class="ody-spinner"></span><span style="font-size:0.82rem;color:var(--text2)">Fetching essay and analyzing…</span></div>';
|
||
card.appendChild(spinEl);
|
||
|
||
try {
|
||
const r = await fetch('/api/steward/' + SLUG + '/artifacts/' + artifactId + '/ody-ask', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ token: TOKEN }),
|
||
});
|
||
if (!r.ok) throw new Error(await r.text());
|
||
const data = await r.json();
|
||
const verdict = data.verdict || '';
|
||
const a = allArtifacts.find(x => x.artifact_id === artifactId);
|
||
if (a) a.ody_verdict = verdict;
|
||
// Replace spinner with verdict
|
||
const verdictEl = document.getElementById('verdict-' + artifactId);
|
||
if (verdictEl) {
|
||
const parsed = tryParseVerdict(verdict);
|
||
verdictEl.innerHTML = \`
|
||
<div class="ody-verdict-label">Ody's read</div>
|
||
\${parsed.tag ? \`<span class="ody-verdict-tag \${parsed.tag}">\${parsed.tagLabel}</span>\` : ''}
|
||
<div class="ody-verdict-text">\${esc(verdict)}</div>
|
||
<button class="ody-close" onclick="clearVerdict('\${artifactId}')" title="Dismiss">×</button>
|
||
\`;
|
||
}
|
||
} catch(e) {
|
||
const verdictEl = document.getElementById('verdict-' + artifactId);
|
||
if (verdictEl) {
|
||
verdictEl.innerHTML = '<div class="ody-verdict-label" style="color:var(--red)">Error</div><div class="ody-verdict-text">Could not get Ody\'s analysis. ' + esc(e.message) + '</div><button class="ody-close" onclick="clearVerdict(\\''+artifactId+'\\')">×</button>';
|
||
}
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = 'Ask Ody';
|
||
}
|
||
}
|
||
|
||
// ── Fetch modal ────────────────────────────────────────────────────────────
|
||
function openFetchModal() {
|
||
document.getElementById('fetchLog').textContent = 'Ready to fetch.';
|
||
document.getElementById('fetchModal').classList.remove('hidden');
|
||
document.getElementById('btnFetchConfirm').disabled = false;
|
||
document.getElementById('btnFetchConfirm').textContent = 'Fetch essays';
|
||
}
|
||
|
||
function closeFetchModal() {
|
||
document.getElementById('fetchModal').classList.add('hidden');
|
||
}
|
||
|
||
async function doFetch() {
|
||
const logEl = document.getElementById('fetchLog');
|
||
const btn = document.getElementById('btnFetchConfirm');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Fetching…';
|
||
logEl.textContent = 'Sending fetch request…\\n';
|
||
|
||
try {
|
||
const r = await fetch('/api/steward/' + SLUG + '/corpus/fetch', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ token: TOKEN }),
|
||
});
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.error || 'Fetch failed');
|
||
logEl.textContent += 'Done. ' + (data.count || 0) + ' essays found.\\n';
|
||
btn.textContent = 'Close';
|
||
btn.disabled = false;
|
||
btn.onclick = closeFetchModal;
|
||
await loadArtifacts();
|
||
} catch(e) {
|
||
logEl.textContent += 'Error: ' + e.message;
|
||
btn.disabled = false;
|
||
btn.textContent = 'Retry';
|
||
btn.onclick = doFetch;
|
||
}
|
||
}
|
||
|
||
// ── Build ──────────────────────────────────────────────────────────────────
|
||
async function triggerBuild() {
|
||
const approved = allArtifacts.filter(a => a.curation_status === 'approved');
|
||
if (approved.length === 0) return;
|
||
|
||
const panel = document.getElementById('buildPanel');
|
||
const logEl = document.getElementById('buildLog');
|
||
panel.classList.add('visible');
|
||
logEl.textContent = 'Initiating corpus build for ' + approved.length + ' approved essays…\\n';
|
||
document.getElementById('btnBuild').disabled = true;
|
||
|
||
try {
|
||
const r = await fetch('/api/steward/' + SLUG + '/corpus/build', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ token: TOKEN }),
|
||
});
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.error || 'Build failed to start');
|
||
logEl.textContent += 'Build started. Bundle: ' + (data.bundle_id || '?') + '\\n';
|
||
logEl.textContent += 'Connecting to live log stream…\\n';
|
||
|
||
// SSE stream
|
||
const es = new EventSource('/api/personaforge/subjects/' + SLUG + '/logs');
|
||
es.addEventListener('log', e => {
|
||
try {
|
||
const { msg } = JSON.parse(e.data);
|
||
logEl.textContent += msg + '\\n';
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
} catch {}
|
||
});
|
||
es.addEventListener('done', () => {
|
||
logEl.textContent += '\\nBuild complete.\\n';
|
||
es.close();
|
||
document.getElementById('btnBuild').disabled = false;
|
||
});
|
||
es.onerror = () => {
|
||
logEl.textContent += '(log stream ended)\\n';
|
||
es.close();
|
||
document.getElementById('btnBuild').disabled = false;
|
||
};
|
||
} catch(e) {
|
||
logEl.textContent += 'Error: ' + e.message;
|
||
document.getElementById('btnBuild').disabled = false;
|
||
}
|
||
}
|
||
|
||
function closeBuildPanel() {
|
||
document.getElementById('buildPanel').classList.remove('visible');
|
||
}
|
||
|
||
// ── Run ────────────────────────────────────────────────────────────────────
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||
// NOTE: This function is intentionally synchronous so that app.get("/steward/:slug")
|
||
// is registered before serveSpaFallback() in the production middleware stack.
|
||
// initStewardDash() runs DB schema migrations in the background — the route
|
||
// handlers themselves are safe to execute once the DB is available.
|
||
export function registerStewardRoutes(app: Express) {
|
||
// Run schema migrations in the background; do NOT await before registering routes
|
||
initStewardDash().catch((e: any) => console.error("[StewardDash] init error:", e.message));
|
||
|
||
// ── GET /steward/:slug — SSR dashboard ──────────────────────────────────
|
||
// Note: host guard removed — the dashboard token is the access control.
|
||
// Subjects may exist in either personaforge_subjects (admin-seeded) or
|
||
// agentify_subject_registry (registered via web form). We check both and
|
||
// auto-promote registry-only subjects into personaforge_subjects on first access.
|
||
app.get("/steward/:slug", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const token = req.query.token as string;
|
||
|
||
const notFoundPage = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Not found</title>
|
||
<style>body{background:#07102a;color:#dde6f4;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
||
.box{text-align:center}h1{color:#d4a730;margin-bottom:0.5rem}p{color:#8899bb;font-size:0.9rem}</style></head>
|
||
<body><div class="box"><h1>Not found</h1><p>No subject found for slug: ${slug}</p></div></body></html>`;
|
||
|
||
const accessDeniedPage = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Access denied</title>
|
||
<style>body{background:#07102a;color:#dde6f4;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
||
.box{text-align:center;max-width:400px}h1{color:#d4a730;margin-bottom:0.5rem}p{color:#8899bb;font-size:0.9rem;line-height:1.6}</style></head>
|
||
<body><div class="box"><h1>Access denied</h1><p>This dashboard requires a valid stewardship token. Use the link from your confirmation email, or contact <a href="mailto:ody@wellspr.ing" style="color:#d4a730">ody@wellspr.ing</a>.</p></div></body></html>`;
|
||
|
||
try {
|
||
// 1. Look up in personaforge_subjects (primary/canonical source)
|
||
let { rows: pf } = await pool.query(
|
||
`SELECT subject_name, build_status, dashboard_token FROM personaforge_subjects WHERE slug = $1`, [slug]
|
||
);
|
||
|
||
// 2. If not found in personaforge_subjects, check agentify_subject_registry
|
||
// (stewards who registered via the web form end up here)
|
||
if (!pf[0]) {
|
||
const { rows: reg } = await pool.query(
|
||
`SELECT subject_name, subject_domain, steward_name, steward_email, dashboard_token, status
|
||
FROM agentify_subject_registry WHERE subject_slug = $1`, [slug]
|
||
).catch(() => ({ rows: [] as any[] }));
|
||
|
||
if (!reg[0]) {
|
||
return res.status(404).send(notFoundPage);
|
||
}
|
||
|
||
const r = reg[0];
|
||
|
||
// Auto-promote into personaforge_subjects so all dashboard features work
|
||
await pool.query(
|
||
`INSERT INTO personaforge_subjects (slug, subject_name, domain, build_status, dashboard_token)
|
||
VALUES ($1, $2, $3, 'active', $4)
|
||
ON CONFLICT (slug) DO UPDATE
|
||
SET subject_name = EXCLUDED.subject_name,
|
||
domain = EXCLUDED.domain,
|
||
dashboard_token = COALESCE(personaforge_subjects.dashboard_token, EXCLUDED.dashboard_token),
|
||
updated_at = NOW()`,
|
||
[slug, r.subject_name, r.subject_domain || "general", r.dashboard_token || null]
|
||
).catch((e: any) => console.warn("[StewardDash] auto-promote error:", e.message));
|
||
|
||
// Re-fetch so the rest of the handler sees the row
|
||
const refetch = await pool.query(
|
||
`SELECT subject_name, build_status, dashboard_token FROM personaforge_subjects WHERE slug = $1`, [slug]
|
||
);
|
||
pf = refetch.rows;
|
||
if (!pf[0]) {
|
||
// Fallback: use registry data directly
|
||
const validToken = r.dashboard_token;
|
||
if (!validToken || token !== validToken) return res.status(403).send(accessDeniedPage);
|
||
return res.send(dashboardPage(slug, r.subject_name));
|
||
}
|
||
}
|
||
|
||
const subjectName = pf[0].subject_name;
|
||
const validToken = pf[0].dashboard_token;
|
||
if (!validToken || token !== validToken) return res.status(403).send(accessDeniedPage);
|
||
|
||
return res.send(dashboardPage(slug, subjectName));
|
||
} catch (e: any) {
|
||
console.error("[StewardDash] /steward/:slug error:", e.message);
|
||
return res.status(500).send(`<!DOCTYPE html><html><body style="background:#07102a;color:#dde6f4;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0"><div style="text-align:center"><h1 style="color:#d4a730">Error</h1><p style="color:#8899bb">Something went wrong loading this dashboard. Please try again.</p></div></body></html>`);
|
||
}
|
||
});
|
||
|
||
// ── GET /api/steward/:slug/info ──────────────────────────────────────────
|
||
app.get("/api/steward/:slug/info", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const token = req.query.token as string || req.body?.token;
|
||
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
|
||
|
||
const { rows: pf } = await pool.query(`SELECT * FROM personaforge_subjects WHERE slug = $1`, [slug]);
|
||
if (!pf[0]) return res.status(404).json({ error: "Subject not found" });
|
||
|
||
const { rows: counts } = await pool.query(
|
||
`SELECT curation_status, COUNT(*) as n FROM personaforge_artifacts WHERE subject_slug = $1 GROUP BY curation_status`, [slug]
|
||
);
|
||
const stats: Record<string, number> = { pending: 0, approved: 0, rejected: 0, flagged: 0 };
|
||
counts.forEach(r => { stats[r.curation_status] = parseInt(r.n); });
|
||
|
||
res.json({ subject: pf[0], stats });
|
||
});
|
||
|
||
// ── GET /api/steward/:slug/artifacts ────────────────────────────────────
|
||
app.get("/api/steward/:slug/artifacts", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const token = req.query.token as string;
|
||
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
|
||
|
||
const { rows } = await pool.query(
|
||
`SELECT artifact_id, title, source_url, pub_date, artifact_type, word_count, chunk_count, curation_status, curation_note, ody_verdict, created_at
|
||
FROM personaforge_artifacts WHERE subject_slug = $1 ORDER BY pub_date DESC NULLS LAST, title ASC`,
|
||
[slug]
|
||
);
|
||
res.json({ artifacts: rows });
|
||
});
|
||
|
||
// ── PATCH /api/steward/:slug/artifacts/:artifact_id ─────────────────────
|
||
app.patch("/api/steward/:slug/artifacts/:artifact_id", async (req: Request, res: Response) => {
|
||
const { slug, artifact_id } = req.params;
|
||
const { token, curation_status, curation_note } = req.body;
|
||
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
|
||
|
||
const allowed = ["pending", "approved", "rejected", "flagged"];
|
||
if (!allowed.includes(curation_status)) return res.status(400).json({ error: "Invalid curation_status" });
|
||
|
||
await pool.query(
|
||
`UPDATE personaforge_artifacts SET curation_status = $1, curation_note = $2 WHERE artifact_id = $3 AND subject_slug = $4`,
|
||
[curation_status, curation_note || null, artifact_id, slug]
|
||
);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// ── GET /api/steward/:slug/catalog-sources ──────────────────────────────
|
||
app.get("/api/steward/:slug/catalog-sources", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const token = req.query.token as string;
|
||
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
|
||
|
||
const { rows } = await pool.query(
|
||
`SELECT id, url, title, type, chunk_count, catalog_status, ingested_at
|
||
FROM agentify_source_catalog WHERE expert_slug = $1
|
||
ORDER BY type ASC, title ASC NULLS LAST`,
|
||
[slug]
|
||
);
|
||
res.json({ sources: rows });
|
||
});
|
||
|
||
// ── PATCH /api/steward/:slug/catalog-sources/:id ─────────────────────────
|
||
app.patch("/api/steward/:slug/catalog-sources/:id", async (req: Request, res: Response) => {
|
||
const { slug, id } = req.params;
|
||
const { token, action } = req.body;
|
||
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
|
||
if (!["park", "restore"].includes(action)) return res.status(400).json({ error: "action must be 'park' or 'restore'" });
|
||
|
||
const newStatus = action === "park" ? "parked" : "active";
|
||
const { rows } = await pool.query(
|
||
`UPDATE agentify_source_catalog SET catalog_status = $1 WHERE id = $2 AND expert_slug = $3 RETURNING title, url`,
|
||
[newStatus, id, slug]
|
||
);
|
||
if (!rows[0]) return res.status(404).json({ error: "Source not found" });
|
||
|
||
await pool.query(
|
||
`INSERT INTO steward_audit_log (subject_slug, actor, action, target_url, target_title, detail)
|
||
VALUES ($1, 'steward', $2, $3, $4, $5)`,
|
||
[slug, action, rows[0].url, rows[0].title, `Status set to ${newStatus}`]
|
||
).catch(() => {});
|
||
|
||
res.json({ ok: true, status: newStatus });
|
||
});
|
||
|
||
// ── POST /api/steward/:slug/discover ─────────────────────────────────────
|
||
app.post("/api/steward/:slug/discover", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const { token, query } = req.body;
|
||
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
|
||
|
||
const { rows: pf } = await pool.query(`SELECT subject_name FROM personaforge_subjects WHERE slug = $1`, [slug]);
|
||
const subjectName = pf[0]?.subject_name || slug;
|
||
|
||
const { rows: existing } = await pool.query(
|
||
`SELECT url FROM agentify_source_catalog WHERE expert_slug = $1`, [slug]
|
||
);
|
||
const existingUrls = new Set(existing.map((r: any) => r.url));
|
||
const skipList = [...existingUrls].slice(0, 30).join("\n");
|
||
|
||
const systemPrompt = `You are a research librarian helping build an authoritative corpus for "${subjectName}".
|
||
Find content sources NOT already in the corpus. Focus on books (publisher page or Google Books URL), landmark articles, and notable interviews.
|
||
Return a JSON array — no markdown fences — of objects: { "title": string, "url": string, "type": "book"|"article"|"podcast"|"interview", "note": string }`;
|
||
|
||
const userMsg = query
|
||
? `Find new sources matching: ${query}\n\nSkip these already-indexed URLs:\n${skipList}`
|
||
: `Find books, long-form articles, and notable interviews by or about ${subjectName}.\n\nSkip these already-indexed URLs:\n${skipList}`;
|
||
|
||
try {
|
||
const Anthropic = (await import("@anthropic-ai/sdk")).default;
|
||
const ai = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||
const msg = await ai.messages.create({
|
||
model: "claude-opus-4-5",
|
||
max_tokens: 1024,
|
||
system: systemPrompt,
|
||
messages: [{ role: "user", content: userMsg }],
|
||
});
|
||
const text = ((msg.content[0] as any).text || "[]").trim();
|
||
let suggestions: any[] = [];
|
||
try { suggestions = JSON.parse(text); } catch { suggestions = []; }
|
||
suggestions = suggestions.filter((s: any) => s.url && !existingUrls.has(s.url));
|
||
|
||
await pool.query(
|
||
`INSERT INTO steward_audit_log (subject_slug, actor, action, detail)
|
||
VALUES ($1, 'steward', 'discover', $2)`,
|
||
[slug, `Found ${suggestions.length} suggestion(s)${query ? ` for: ${query}` : ""}`]
|
||
).catch(() => {});
|
||
|
||
res.json({ ok: true, suggestions });
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
// ── GET /api/steward/:slug/activity ──────────────────────────────────────
|
||
app.get("/api/steward/:slug/activity", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const token = req.query.token as string;
|
||
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
|
||
|
||
const { rows } = await pool.query(
|
||
`SELECT id, actor, action, target_title, detail, created_at
|
||
FROM steward_audit_log WHERE subject_slug = $1
|
||
ORDER BY created_at DESC LIMIT 100`,
|
||
[slug]
|
||
);
|
||
res.json({ items: rows });
|
||
});
|
||
|
||
// ── POST /api/steward/:slug/artifacts/:artifact_id/ody-ask ──────────────
|
||
app.post("/api/steward/:slug/artifacts/:artifact_id/ody-ask", async (req: Request, res: Response) => {
|
||
const { slug, artifact_id } = req.params;
|
||
const { token } = req.body;
|
||
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
|
||
|
||
// Get artifact
|
||
const { rows } = await pool.query(
|
||
`SELECT title, source_url, word_count, artifact_type FROM personaforge_artifacts WHERE artifact_id = $1 AND subject_slug = $2`,
|
||
[artifact_id, slug]
|
||
);
|
||
if (!rows[0]) return res.status(404).json({ error: "Artifact not found" });
|
||
const art = rows[0];
|
||
|
||
// Get subject info
|
||
const { rows: pf } = await pool.query(`SELECT subject_name, domain FROM personaforge_subjects WHERE slug = $1`, [slug]);
|
||
const subject = pf[0] || { subject_name: slug, domain: "" };
|
||
|
||
// Fetch essay text (first ~3000 words worth)
|
||
let essayText = "";
|
||
if (art.source_url) {
|
||
const raw = await fetchEssayText(art.source_url);
|
||
essayText = raw.substring(0, 12000); // ~3000 words
|
||
}
|
||
|
||
if (!essayText) {
|
||
return res.json({ verdict: `Verdict: Flag\n\nCould not fetch the essay text from ${art.source_url || "the source URL"}. You may want to review this one manually before including it.` });
|
||
}
|
||
|
||
const prompt = `You are helping a steward vet corpus material for an AI persona based on ${subject.subject_name}.
|
||
|
||
The steward is reviewing each piece of source material to decide whether it should be included in the corpus that will train and ground the persona.
|
||
|
||
**Essay title:** "${art.title}"
|
||
**Type:** ${art.artifact_type || "essay"}
|
||
**Word count:** ${art.word_count || "unknown"}
|
||
|
||
**Essay text (excerpt):**
|
||
${essayText}
|
||
|
||
Please give a concise vetting assessment (3–5 sentences). Address:
|
||
1. Is this representative of ${subject.subject_name}'s actual thinking and voice?
|
||
2. Is the content high signal — substantive ideas, not just announcements or filler?
|
||
3. Any concerns about quality, accuracy, or relevance to the corpus?
|
||
|
||
Then give your verdict on a new line: "Verdict: Approve", "Verdict: Flag", or "Verdict: Reject".`;
|
||
|
||
try {
|
||
const msg = await claude.messages.create({
|
||
model: "claude-haiku-4-5",
|
||
max_tokens: 400,
|
||
messages: [{ role: "user", content: prompt }],
|
||
});
|
||
const verdict = (msg.content[0] as any).text || "";
|
||
// Store verdict
|
||
await pool.query(
|
||
`UPDATE personaforge_artifacts SET ody_verdict = $1 WHERE artifact_id = $2`,
|
||
[verdict, artifact_id]
|
||
);
|
||
res.json({ verdict });
|
||
} catch (e: any) {
|
||
console.error("[StewardDash] Ody ask error:", e.message);
|
||
res.status(500).json({ error: "Claude error: " + e.message });
|
||
}
|
||
});
|
||
|
||
// ── POST /api/steward/:slug/corpus/fetch ────────────────────────────────
|
||
// Delegates to PersonaForge fetch (admin-side), authorized via steward token
|
||
app.post("/api/steward/:slug/corpus/fetch", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const { token } = req.body;
|
||
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
|
||
|
||
// Call the PersonaForge fetch endpoint internally (same process)
|
||
try {
|
||
const { rows: pf } = await pool.query(
|
||
`SELECT source_url FROM personaforge_subjects WHERE slug = $1`, [slug]
|
||
);
|
||
if (!pf[0]?.source_url) return res.status(400).json({ error: "No source URL configured for this subject" });
|
||
|
||
const sourceUrl = pf[0].source_url;
|
||
const html = await fetch(sourceUrl, { headers: { "User-Agent": "WellSpr.ing PersonaForge/1.0" } }).then(r => r.text());
|
||
const base = new URL(sourceUrl);
|
||
const found: { slug: string; title: string; url: string }[] = [];
|
||
const re = /<a\s+href="([^"]+\.html)"[^>]*>([^<]+)<\/a>/gi;
|
||
let m: RegExpExecArray | null;
|
||
while ((m = re.exec(html)) !== null) {
|
||
const href = m[1].trim();
|
||
const title = m[2].trim();
|
||
if (!title || title.length < 3) continue;
|
||
try {
|
||
const full = new URL(href, base).href;
|
||
const artSlug = href.replace(/.*\//, "").replace(".html", "").toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||
if (!found.find(r => r.slug === artSlug)) found.push({ slug: artSlug, title, url: full });
|
||
} catch {}
|
||
}
|
||
|
||
let inserted = 0;
|
||
for (const item of found) {
|
||
const artifactId = `${slug}::${item.slug}`;
|
||
const result = await pool.query(
|
||
`INSERT INTO personaforge_artifacts (subject_slug, artifact_id, title, source_url, artifact_type, curation_status)
|
||
VALUES ($1, $2, $3, $4, 'essay', 'pending')
|
||
ON CONFLICT (artifact_id) DO NOTHING`,
|
||
[slug, artifactId, item.title, item.url]
|
||
);
|
||
if (result.rowCount && result.rowCount > 0) inserted++;
|
||
}
|
||
|
||
await pool.query(`UPDATE personaforge_subjects SET essay_count = $2, updated_at = NOW() WHERE slug = $1`, [slug, found.length]);
|
||
res.json({ ok: true, count: found.length, inserted });
|
||
} catch (e: any) {
|
||
console.error("[StewardDash] Fetch error:", e.message);
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
// ── POST /api/steward/:slug/corpus/build ────────────────────────────────
|
||
// Triggers a build using only approved artifacts
|
||
app.post("/api/steward/:slug/corpus/build", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const { token } = req.body;
|
||
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
|
||
|
||
// Get approved artifact IDs
|
||
const { rows: approved } = await pool.query(
|
||
`SELECT artifact_id FROM personaforge_artifacts WHERE subject_slug = $1 AND curation_status = 'approved'`, [slug]
|
||
);
|
||
if (approved.length === 0) return res.status(400).json({ error: "No approved artifacts to build" });
|
||
|
||
// Delegate to personaforge build endpoint (internal HTTP call via admin key)
|
||
try {
|
||
const bundleId = crypto.randomUUID();
|
||
await pool.query(
|
||
`UPDATE personaforge_subjects SET build_status = 'building', bundle_id = $2, build_log = '', updated_at = NOW() WHERE slug = $1`,
|
||
[slug, bundleId]
|
||
);
|
||
|
||
res.json({ ok: true, bundle_id: bundleId, approved_count: approved.length, message: "Build started — connect to SSE log stream for progress" });
|
||
|
||
// Async build (fire and forget — steward watches via SSE)
|
||
setImmediate(() => runApprovedBuild(slug, bundleId, approved.map(r => r.artifact_id)));
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
// ── POST /api/steward/:slug/bio-facts/ptp ───────────────────────────────
|
||
// PTP-authenticated corpus injection. Any AI agent holding a valid WellSpr.ing
|
||
// PTP token with scope corpus:write:personaforge:{slug} (or :*) may inject
|
||
// verified biographical facts and have them stamped with their attestation.
|
||
//
|
||
// Intended use: a Claude session runs the QA skill against a persona, verifies
|
||
// facts against primary sources, then writes the verified facts back to the
|
||
// corpus with a PTP-attested audit trail. The token_id is stored with the facts
|
||
// so anyone can verify who/what wrote them and under what scope.
|
||
//
|
||
// Flow:
|
||
// 1. Agent requests PTP token: POST /api/v1/ptp/token
|
||
// scope: "corpus:write:personaforge:{slug}" (or "corpus:write:personaforge:*")
|
||
// purpose: "QA review and verified fact injection for [Subject Name]"
|
||
// 2. Agent presents token here with facts payload
|
||
// 3. Server verifies token validity + scope coverage
|
||
// 4. Facts stored with ptp_token_id, attested_at, vcap_attestation_uri
|
||
// 5. Returns verifiable attestation record
|
||
app.post("/api/steward/:slug/bio-facts/ptp", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const { token_id, bio_facts_md, wiki_summary_md, session_notes } = req.body;
|
||
if (!token_id) return res.status(400).json({ error: "token_id required (PTP presence token)" });
|
||
if (bio_facts_md === undefined && wiki_summary_md === undefined) {
|
||
return res.status(400).json({ error: "At least one of bio_facts_md or wiki_summary_md is required" });
|
||
}
|
||
|
||
try {
|
||
// Verify the PTP token and check scope coverage for this subject
|
||
const requiredScope = `corpus:write:personaforge:${slug}`;
|
||
const ptpResult = await pool.query(`SELECT * FROM ptp_tokens WHERE id = $1`, [token_id]);
|
||
if (!ptpResult.rows.length) return res.status(403).json({ error: "ptp_token_not_found", token_id });
|
||
|
||
const tk = ptpResult.rows[0];
|
||
const now = new Date();
|
||
if (tk.revoked) return res.status(403).json({ error: "ptp_token_revoked", revoked_at: tk.revoked_at });
|
||
if (tk.expires_at && new Date(tk.expires_at) < now) {
|
||
return res.status(403).json({ error: "ptp_token_expired", expired_at: tk.expires_at });
|
||
}
|
||
|
||
// Scope check: token scope must equal or be a wildcard superset of corpus:write:personaforge:{slug}
|
||
const tokenParts = (tk.scope as string).split(":");
|
||
const requiredParts = requiredScope.split(":");
|
||
let scopeOk = true;
|
||
for (let i = 0; i < requiredParts.length; i++) {
|
||
if (tokenParts[i] !== "*" && tokenParts[i] !== requiredParts[i]) { scopeOk = false; break; }
|
||
}
|
||
if (!scopeOk) {
|
||
return res.status(403).json({
|
||
error: "scope_insufficient",
|
||
token_scope: tk.scope,
|
||
required_scope: requiredScope,
|
||
hint: `Request a token with scope "corpus:write:personaforge:${slug}" or "corpus:write:personaforge:*"`,
|
||
});
|
||
}
|
||
|
||
// Ensure subject row exists
|
||
await pool.query(
|
||
`INSERT INTO personaforge_subjects (slug, subject_name, build_status)
|
||
VALUES ($1, $1, 'registered') ON CONFLICT (slug) DO NOTHING`, [slug]
|
||
);
|
||
|
||
// Write facts + stamp with PTP attestation metadata
|
||
const attestedAt = new Date().toISOString();
|
||
if (bio_facts_md !== undefined) {
|
||
await pool.query(
|
||
`UPDATE personaforge_subjects
|
||
SET bio_facts_md = $2,
|
||
bio_facts_ptp_token_id = $3,
|
||
bio_facts_attested_at = $4,
|
||
bio_facts_vcap_uri = $5,
|
||
updated_at = NOW()
|
||
WHERE slug = $1`,
|
||
[slug, bio_facts_md, token_id, attestedAt, tk.vcap_attestation_uri]
|
||
);
|
||
}
|
||
if (wiki_summary_md !== undefined) {
|
||
await pool.query(
|
||
`UPDATE personaforge_subjects
|
||
SET wiki_summary_md = $2,
|
||
wiki_ptp_token_id = $3,
|
||
wiki_attested_at = $4,
|
||
updated_at = NOW()
|
||
WHERE slug = $1`,
|
||
[slug, wiki_summary_md, token_id, attestedAt]
|
||
);
|
||
}
|
||
|
||
// Log to corpus_consult_log if it exists (non-fatal)
|
||
await pool.query(
|
||
`INSERT INTO corpus_consult_log
|
||
(slug, event_type, token_id, scope, purpose, attested_at, session_notes, created_at)
|
||
VALUES ($1, 'ptp_bio_injection', $2, $3, $4, $5, $6, NOW())`,
|
||
[slug, token_id, tk.scope, tk.purpose_declaration, attestedAt, session_notes || null]
|
||
).catch(() => {}); // table may not exist in all environments
|
||
|
||
res.json({
|
||
ok: true,
|
||
slug,
|
||
attested_at: attestedAt,
|
||
token_id,
|
||
scope: tk.scope,
|
||
vcap_attestation_uri: tk.vcac_attestation_uri || tk.vcap_attestation_uri,
|
||
fields_written: [
|
||
...(bio_facts_md !== undefined ? ["bio_facts_md"] : []),
|
||
...(wiki_summary_md !== undefined ? ["wiki_summary_md"] : []),
|
||
],
|
||
verification: `GET https://wellspr.ing/api/v1/ptp/token/${token_id}`,
|
||
note: "Biographical facts are now stamped with this PTP token. The corpus build will inject them as priority-0 chunks.",
|
||
});
|
||
} catch (e: any) {
|
||
console.error("[StewardDash] PTP injection error:", e.message);
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
// ── GET /api/steward/:slug/bio-facts ────────────────────────────────────
|
||
app.get("/api/steward/:slug/bio-facts", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const token = req.query.token as string;
|
||
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT bio_facts_md, wiki_summary_md FROM personaforge_subjects WHERE slug = $1`, [slug]
|
||
);
|
||
const row = rows[0] || {};
|
||
res.json({ bio_facts_md: row.bio_facts_md || null, wiki_summary_md: row.wiki_summary_md || null });
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
// ── POST /api/steward/:slug/bio-facts ────────────────────────────────────
|
||
// Requires steward token (or admin key). Updates bio_facts_md and/or wiki_summary_md.
|
||
app.post("/api/steward/:slug/bio-facts", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const { token, bio_facts_md, wiki_summary_md } = req.body;
|
||
const adminKey = (req.headers["x-admin-key"] as string) || req.query.admin_key as string;
|
||
const isAdmin = adminKey === ADMIN_KEY;
|
||
if (!isAdmin && !await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token or admin key" });
|
||
|
||
if (bio_facts_md === undefined && wiki_summary_md === undefined) return res.status(400).json({ error: "Nothing to update" });
|
||
|
||
try {
|
||
// Ensure the subject row exists (auto-create if missing, e.g. dev DB or fresh environment)
|
||
await pool.query(
|
||
`INSERT INTO personaforge_subjects (slug, subject_name, build_status)
|
||
VALUES ($1, $1, 'registered')
|
||
ON CONFLICT (slug) DO NOTHING`,
|
||
[slug]
|
||
);
|
||
|
||
// Update whichever fields were provided
|
||
if (bio_facts_md !== undefined) {
|
||
await pool.query(
|
||
`UPDATE personaforge_subjects SET bio_facts_md = $2, updated_at = NOW() WHERE slug = $1`,
|
||
[slug, bio_facts_md]
|
||
);
|
||
}
|
||
if (wiki_summary_md !== undefined) {
|
||
await pool.query(
|
||
`UPDATE personaforge_subjects SET wiki_summary_md = $2, updated_at = NOW() WHERE slug = $1`,
|
||
[slug, wiki_summary_md]
|
||
);
|
||
}
|
||
res.json({ ok: true });
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
// ── POST /api/steward/:slug/artifacts/inject ─────────────────────────────
|
||
// Admin-only. Inject a manual artifact with full text content (no URL required).
|
||
// Used for Wikipedia excerpts, articles-about-the-person, curated secondary sources.
|
||
app.post("/api/steward/:slug/artifacts/inject", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const adminKey = (req.headers["x-admin-key"] as string) || req.body.admin_key;
|
||
if (adminKey !== ADMIN_KEY) return res.status(403).json({ error: "Admin key required" });
|
||
const { title, content_text, artifact_type, source_url, pub_date } = req.body;
|
||
if (!title || !content_text) return res.status(400).json({ error: "title and content_text required" });
|
||
const artifactId = `${slug}::injected::${crypto.randomUUID().slice(0, 8)}`;
|
||
try {
|
||
await pool.query(
|
||
`INSERT INTO personaforge_artifacts
|
||
(subject_slug, artifact_id, title, source_url, artifact_type, content_text, curation_status, pub_date)
|
||
VALUES ($1, $2, $3, $4, $5, $6, 'approved', $7)
|
||
ON CONFLICT (artifact_id) DO UPDATE SET content_text = EXCLUDED.content_text, title = EXCLUDED.title`,
|
||
[slug, artifactId, title, source_url || null, artifact_type || 'injected', content_text, pub_date || null]
|
||
);
|
||
await pool.query(`UPDATE personaforge_subjects SET updated_at = NOW() WHERE slug = $1`, [slug]);
|
||
res.json({ ok: true, artifact_id: artifactId });
|
||
} catch (e: any) {
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
// ── GET /api/steward/:slug/dashboard-token (admin only) ─────────────────
|
||
app.get("/api/steward/:slug/dashboard-token", async (req: Request, res: Response) => {
|
||
const { slug } = req.params;
|
||
const key = (req.headers["x-admin-key"] as string) || req.query.admin_key as string;
|
||
if (key !== ADMIN_KEY) return res.status(403).json({ error: "Admin key required" });
|
||
|
||
const token = await getOrCreateDashboardToken(slug);
|
||
const dashUrl = `https://agentify.help/steward/${slug}?token=${token}`;
|
||
res.json({ slug, dashboard_token: token, dashboard_url: dashUrl });
|
||
});
|
||
|
||
// ── POST /api/steward/:slug/invite-co-steward ────────────────────────────
|
||
// Invite a collaborator email to co-steward this agent.
|
||
// They gain access via the /my-agents portfolio magic-link flow.
|
||
app.post("/api/steward/:slug/invite-co-steward", async (req: Request, res: Response) => {
|
||
if (!isAgentify(req)) return res.status(404).json({ error: "Not found" });
|
||
const { slug } = req.params;
|
||
const token = req.query.token as string | undefined;
|
||
const key = (req.headers["x-admin-key"] as string) || (req.query.admin_key as string);
|
||
if (key !== ADMIN_KEY && !(await validateStewardToken(slug, token))) {
|
||
return res.status(403).json({ error: "Steward token or admin key required" });
|
||
}
|
||
const { email } = req.body ?? {};
|
||
if (!email || !email.includes("@")) return res.status(400).json({ error: "Valid email required" });
|
||
const normalized = email.toLowerCase().trim();
|
||
|
||
await pool.query(
|
||
`UPDATE agentify_subject_registry
|
||
SET co_steward_emails = array_append(COALESCE(co_steward_emails, '{}'), $2)
|
||
WHERE subject_slug = $1
|
||
AND NOT ($2 = ANY(COALESCE(co_steward_emails, '{}')))`,
|
||
[slug, normalized]
|
||
).catch(() => {});
|
||
await pool.query(
|
||
`UPDATE personaforge_subjects
|
||
SET co_steward_emails = array_append(COALESCE(co_steward_emails, '{}'), $2)
|
||
WHERE slug = $1
|
||
AND NOT ($2 = ANY(COALESCE(co_steward_emails, '{}')))`,
|
||
[slug, normalized]
|
||
).catch(() => {});
|
||
|
||
res.json({ ok: true, slug, co_steward_email: normalized,
|
||
note: "Co-steward added. They can now use /my-agents with their email to receive a portal access link." });
|
||
});
|
||
|
||
// ── GET /api/steward/:slug/co-stewards ───────────────────────────────────
|
||
app.get("/api/steward/:slug/co-stewards", async (req: Request, res: Response) => {
|
||
if (!isAgentify(req)) return res.status(404).json({ error: "Not found" });
|
||
const { slug } = req.params;
|
||
const token = req.query.token as string | undefined;
|
||
const key = (req.headers["x-admin-key"] as string) || (req.query.admin_key as string);
|
||
if (key !== ADMIN_KEY && !(await validateStewardToken(slug, token))) {
|
||
return res.status(403).json({ error: "Steward token or admin key required" });
|
||
}
|
||
const { rows } = await pool.query(
|
||
`SELECT co_steward_emails FROM agentify_subject_registry WHERE subject_slug = $1`, [slug]
|
||
).catch(() => ({ rows: [] }));
|
||
res.json({ slug, co_steward_emails: rows[0]?.co_steward_emails || [] });
|
||
});
|
||
|
||
// ── DELETE /api/steward/:slug/co-stewards/:email ─────────────────────────
|
||
app.delete("/api/steward/:slug/co-stewards/:email", async (req: Request, res: Response) => {
|
||
if (!isAgentify(req)) return res.status(404).json({ error: "Not found" });
|
||
const { slug, email } = req.params;
|
||
const token = req.query.token as string | undefined;
|
||
const key = (req.headers["x-admin-key"] as string) || (req.query.admin_key as string);
|
||
if (key !== ADMIN_KEY && !(await validateStewardToken(slug, token))) {
|
||
return res.status(403).json({ error: "Steward token or admin key required" });
|
||
}
|
||
const normalized = decodeURIComponent(email).toLowerCase().trim();
|
||
await pool.query(
|
||
`UPDATE agentify_subject_registry
|
||
SET co_steward_emails = array_remove(COALESCE(co_steward_emails, '{}'), $2)
|
||
WHERE subject_slug = $1`,
|
||
[slug, normalized]
|
||
).catch(() => {});
|
||
await pool.query(
|
||
`UPDATE personaforge_subjects
|
||
SET co_steward_emails = array_remove(COALESCE(co_steward_emails, '{}'), $2)
|
||
WHERE slug = $1`,
|
||
[slug, normalized]
|
||
).catch(() => {});
|
||
res.json({ ok: true, slug, removed: normalized });
|
||
});
|
||
}
|
||
|
||
// ── Approved-only corpus build (async) ───────────────────────────────────────
|
||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||
|
||
const S3_HOSTNAME = process.env.VULTR_S3_HOSTNAME || "ewr1.vultrobjects.com";
|
||
const S3_BUCKET = process.env.CORPUS_S3_BUCKET || "agentify-corpus";
|
||
|
||
function getS3(): S3Client {
|
||
return new S3Client({
|
||
endpoint: `https://${S3_HOSTNAME}`,
|
||
region: "ewr1",
|
||
forcePathStyle: true,
|
||
credentials: {
|
||
accessKeyId: process.env.VULTR_S3_ACCESS_KEY || "",
|
||
secretAccessKey: process.env.VULTR_S3_SECRET_KEY || "",
|
||
},
|
||
});
|
||
}
|
||
|
||
function chunkText(text: string, maxWords = 400): string[] {
|
||
const sentences = text.split(/(?<=[.!?])\s+/);
|
||
const chunks: string[] = [];
|
||
let current = "";
|
||
for (const s of sentences) {
|
||
const proposed = current ? current + " " + s : s;
|
||
if (proposed.split(/\s+/).length > maxWords && current) {
|
||
chunks.push(current.trim());
|
||
current = s;
|
||
} else {
|
||
current = proposed;
|
||
}
|
||
}
|
||
if (current.trim()) chunks.push(current.trim());
|
||
return chunks.filter(c => c.split(/\s+/).length >= 30);
|
||
}
|
||
|
||
async function broadcastStewardLog(slug: string, msg: string) {
|
||
console.log(`[StewardBuild/${slug}] ${msg}`);
|
||
await pool.query(
|
||
`UPDATE personaforge_subjects SET build_log = COALESCE(build_log,'') || $2, updated_at=NOW() WHERE slug=$1`,
|
||
[slug, `[${new Date().toISOString().slice(11, 19)}] ${msg}\n`]
|
||
).catch(() => {});
|
||
}
|
||
|
||
async function runApprovedBuild(slug: string, bundleId: string, approvedIds: string[]) {
|
||
const s3 = getS3();
|
||
const allChunks: any[] = [];
|
||
let chunkSeq = 0;
|
||
let processed = 0;
|
||
let errors = 0;
|
||
|
||
const { rows: pf } = await pool.query(
|
||
`SELECT subject_name, domain, corpus_version, bio_facts_md, wiki_summary_md FROM personaforge_subjects WHERE slug = $1`, [slug]
|
||
);
|
||
const subject = pf[0] || { subject_name: slug, domain: "", corpus_version: "1.0.0" };
|
||
|
||
await broadcastStewardLog(slug, `Starting approved-only build: ${approvedIds.length} artifacts, bundle ${bundleId}`);
|
||
|
||
// ── Priority chunk 0: Biographical baseline facts ──────────────────────
|
||
if (subject.bio_facts_md && subject.bio_facts_md.trim()) {
|
||
const bioChunks = chunkText(subject.bio_facts_md);
|
||
for (const chunk of bioChunks) {
|
||
allChunks.push({
|
||
chunk_id: `${slug}-${bundleId}-bio-${String(chunkSeq++).padStart(5, "0")}`,
|
||
subject_slug: slug,
|
||
artifact_id: `${slug}::bio_facts_baseline`,
|
||
title: "Biographical Baseline — Ground-Truth Facts",
|
||
pub_date: null,
|
||
artifact_type: "bio_facts",
|
||
priority: "critical",
|
||
text: chunk,
|
||
});
|
||
}
|
||
await broadcastStewardLog(slug, `Injected biographical baseline (${bioChunks.length} chunks) as priority-0 corpus anchor.`);
|
||
}
|
||
|
||
// ── Priority chunk 1: Wikipedia / secondary sources ────────────────────
|
||
if (subject.wiki_summary_md && subject.wiki_summary_md.trim()) {
|
||
const wikiPreamble = `[SKEPTICISM NOTE: The following is from Wikipedia and secondary sources. Treat as background reference, not first-person memory. Verify contested facts against primary corpus.]\n\n`;
|
||
const wikiChunks = chunkText(wikiPreamble + subject.wiki_summary_md);
|
||
for (const chunk of wikiChunks) {
|
||
allChunks.push({
|
||
chunk_id: `${slug}-${bundleId}-wiki-${String(chunkSeq++).padStart(5, "0")}`,
|
||
subject_slug: slug,
|
||
artifact_id: `${slug}::wiki_secondary_sources`,
|
||
title: "Wikipedia & Secondary Sources — Skepticism-Weighted",
|
||
pub_date: null,
|
||
artifact_type: "wiki_secondary",
|
||
priority: "background",
|
||
text: chunk,
|
||
});
|
||
}
|
||
await broadcastStewardLog(slug, `Injected Wikipedia/secondary sources (${wikiChunks.length} chunks) with skepticism weighting.`);
|
||
}
|
||
|
||
for (const artifactId of approvedIds) {
|
||
const { rows: arts } = await pool.query(
|
||
`SELECT title, source_url, pub_date, artifact_type, content_text FROM personaforge_artifacts WHERE artifact_id = $1`, [artifactId]
|
||
);
|
||
if (!arts[0]) continue;
|
||
const art = arts[0];
|
||
|
||
try {
|
||
await broadcastStewardLog(slug, `Fetching: ${art.title}`);
|
||
// Use inline content_text if present (injected artifacts), otherwise fetch from URL
|
||
const text = art.content_text || (art.source_url ? await fetchEssayTextBuild(art.source_url) : "");
|
||
if (!text || text.split(/\s+/).length < 50) {
|
||
await broadcastStewardLog(slug, `Skipped (too short): ${art.title}`);
|
||
errors++;
|
||
continue;
|
||
}
|
||
const chunks = chunkText(text);
|
||
const wc = text.split(/\s+/).length;
|
||
for (const chunk of chunks) {
|
||
allChunks.push({
|
||
chunk_id: `${slug}-${bundleId}-${String(chunkSeq++).padStart(5, "0")}`,
|
||
subject_slug: slug,
|
||
artifact_id: artifactId,
|
||
title: art.title,
|
||
pub_date: art.pub_date || null,
|
||
artifact_type: art.artifact_type || "essay",
|
||
text: chunk,
|
||
});
|
||
}
|
||
await pool.query(
|
||
`UPDATE personaforge_artifacts SET chunk_count = $1, word_count = $2 WHERE artifact_id = $3`,
|
||
[chunks.length, wc, artifactId]
|
||
);
|
||
processed++;
|
||
} catch (e: any) {
|
||
await broadcastStewardLog(slug, `Error on ${art.title}: ${e.message}`);
|
||
errors++;
|
||
}
|
||
}
|
||
|
||
await broadcastStewardLog(slug, `All artifacts processed. ${allChunks.length} chunks total. Uploading to S3…`);
|
||
|
||
try {
|
||
const prefix = `staging/${slug}/v${subject.corpus_version}/${bundleId}`;
|
||
const ndjson = allChunks.map(c => JSON.stringify(c)).join("\n");
|
||
await s3.send(new PutObjectCommand({
|
||
Bucket: S3_BUCKET,
|
||
Key: `${prefix}/chunks/index.ndjson`,
|
||
Body: ndjson,
|
||
ContentType: "application/x-ndjson",
|
||
}));
|
||
|
||
const manifest = {
|
||
bundle_id: bundleId,
|
||
subject_slug: slug,
|
||
subject_name: subject.subject_name,
|
||
domain: subject.domain,
|
||
corpus_version: subject.corpus_version,
|
||
chunk_count: allChunks.length,
|
||
artifact_count: processed,
|
||
approved_only: true,
|
||
built_at: new Date().toISOString(),
|
||
};
|
||
await s3.send(new PutObjectCommand({
|
||
Bucket: S3_BUCKET,
|
||
Key: `${prefix}/manifest.json`,
|
||
Body: JSON.stringify(manifest, null, 2),
|
||
ContentType: "application/json",
|
||
}));
|
||
|
||
await pool.query(
|
||
`UPDATE personaforge_subjects SET build_status = 'built', chunk_count = $2, updated_at = NOW() WHERE slug = $1`,
|
||
[slug, allChunks.length]
|
||
);
|
||
await broadcastStewardLog(slug, `Build complete. ${allChunks.length} chunks uploaded. Bundle: ${bundleId}`);
|
||
if (errors > 0) await broadcastStewardLog(slug, `Note: ${errors} artifacts had errors and were skipped.`);
|
||
} catch (e: any) {
|
||
await broadcastStewardLog(slug, `S3 upload error: ${e.message}`);
|
||
await pool.query(`UPDATE personaforge_subjects SET build_status = 'error' WHERE slug = $1`, [slug]);
|
||
}
|
||
}
|
||
|
||
async function fetchEssayTextBuild(url: string): Promise<string> {
|
||
try {
|
||
const html = await fetch(url, {
|
||
headers: { "User-Agent": "WellSpr.ing PersonaForge/1.0" },
|
||
signal: AbortSignal.timeout(20000),
|
||
}).then(r => r.text());
|
||
return html
|
||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, " ")
|
||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ")
|
||
.replace(/<[^>]+>/g, " ")
|
||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " ")
|
||
.replace(/\s+/g, " ").trim();
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|