diff --git a/PolicyPulseExperimental.jsx b/PolicyPulseExperimental.jsx deleted file mode 100644 index c6971e5..0000000 --- a/PolicyPulseExperimental.jsx +++ /dev/null @@ -1,2012 +0,0 @@ -import { Fragment, useEffect, useLayoutEffect, useRef, useState } from "react"; - -// ─── FONTS & GLOBAL CSS ────────────────────────────────────────────────────── -const GLOBAL = theme => ` -@import url('https://fonts.googleapis.com/css2?family=Oxanium:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap'); -*{box-sizing:border-box;margin:0;padding:0} -::-webkit-scrollbar{width:4px;height:4px} -::-webkit-scrollbar-track{background:${theme === "light" ? "#e8edf7" : "#0a0a18"}} -::-webkit-scrollbar-thumb{background:${theme === "light" ? "#b8c2d4" : "#252540"};border-radius:2px} -@keyframes glow{0%,100%{box-shadow:0 0 10px #00e67630,inset 0 0 8px #00e67610}50%{box-shadow:0 0 22px #00e67660,inset 0 0 16px #00e67620}} -@keyframes spin{to{transform:rotate(360deg)}} -@keyframes fadein{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}} -@keyframes slide{from{opacity:0;transform:translateX(-6px)}to{opacity:1;transform:translateX(0)}} -@keyframes blink{0%,100%{opacity:1}50%{opacity:0.3}} -.phase-active{animation:glow 1.6s ease-in-out infinite!important;border-color:#00e67660!important} -.spin{animation:spin 0.9s linear infinite} -.fadein{animation:fadein 0.35s ease-out} -.slide{animation:slide 0.2s ease-out} -.blink{animation:blink 1.2s ease-in-out infinite} -`; - -// ─── DESIGN TOKENS ─────────────────────────────────────────────────────────── -const PALETTES = { - dark: { - bg: "#070712", - panel: "#0d0d1e", - card: "#111124", - border: "#1d1d34", - borderB: "#2a2a4a", - green: "#00e676", - greenD: "#00e67615", - blue: "#448aff", - blueD: "#448aff15", - orange: "#ff9100", - orangeD: "#ff910015", - red: "#ff4444", - redD: "#ff444415", - text: "#7878a0", - textB: "#c8c8e0", - textD: "#333358", - white: "#eeeeff", - }, - light: { - bg: "#f5f7fb", - panel: "#ffffff", - card: "#eef2f7", - border: "#d7deeb", - borderB: "#b9c4d6", - green: "#087f5b", - greenD: "#e5f7ef", - blue: "#2563eb", - blueD: "#e8efff", - orange: "#c75d00", - orangeD: "#fff0dc", - red: "#c62828", - redD: "#ffe7e7", - text: "#586477", - textB: "#172033", - textD: "#9aa5b8", - white: "#0b1020", - }, -}; - -let T = PALETTES.dark; - -// ─── API ───────────────────────────────────────────────────────────────────── -const OLD_FREE_MODEL = "nvidia/nemotron-3-super-120b-a12b:free"; -const DEFAULT_MODEL = "mistralai/mistral-nemo"; -const API_BASE = ((import.meta.env.VITE_POLICY_PULSE_API_BASE || "/api").trim()).replace(/\/+$/, ""); -const apiPath = path => `${API_BASE}${path}`; -const API = apiPath("/openrouter"); -const TAVILY = apiPath("/tavily"); -const EXA = apiPath("/exa"); -const SESSION_STATE = apiPath("/session-state"); -const PYTHON_RUN = apiPath("/run"); -const API_TOKEN = (import.meta.env.VITE_POLICY_PULSE_API_TOKEN || "").trim(); - -const mkHeaders = () => ({ - "Content-Type": "application/json", - ...(API_TOKEN ? {"X-PolicyPulse-Token": API_TOKEN} : {}), -}); - -const pullText = data => { - const msg = data?.choices?.[0]?.message; - if (typeof msg?.content === "string") return msg.content; - if (Array.isArray(msg?.content)) { - return msg.content.map(b => b?.text || b?.content || "").join("\n"); - } - return ""; -}; - -// Official / government domain detector — hostname-based to avoid path/query spoofing. -const OFFICIAL_HOSTS = [ - "europa.eu", "bamf.de", "daad.de", "make-it-in-germany.com", "berlin.de", "bund.de", - "auswaertiges-amt.de", "diplo.de", "germany.info", "studierendenwerke.de", -]; -const hostMatches = (host, domain) => host === domain || host.endsWith(`.${domain}`); -const isOfficialUrl = url => { - try { - const host = new URL(url).hostname.toLowerCase(); - return host.endsWith(".gov") - || /(^|\.)gov\.[a-z]{2}$/.test(host) - || hostMatches(host, "gc.ca") - || hostMatches(host, "govt.nz") - || /(^|\.)go\.[a-z]{2}$/.test(host) - || host.endsWith(".edu") - || /(^|\.)ac\.[a-z]{2}$/.test(host) - || OFFICIAL_HOSTS.some(domain => hostMatches(host, domain)); - } catch { - return false; - } -}; - -// Map Tavily search results into PolicyPulse source objects (deduped by url). -const tavilyToSources = data => { - const seen = new Set(); - return (data?.results || []) - .filter(r => r?.url && !seen.has(r.url) && seen.add(r.url)) - .map(r => ({ - url: r.url, - title: r.title || r.url, - type: isOfficialUrl(r.url) ? "government" : "source", - key_info: r.content ? r.content.slice(0, 500) : "Found via Tavily search.", - reliability: isOfficialUrl(r.url) ? 0.9 : 0.72, - })); -}; - -const tryJSON = txt => { - try { const m = txt.match(/\{[\s\S]*\}/); return m ? JSON.parse(m[0]) : null; } - catch { return null; } -}; - -// Lightweight schema validation: which required keys are missing/empty. -const missingKeys = (obj, keys) => obj - ? keys.filter(k => obj[k] === undefined || obj[k] === null || (Array.isArray(obj[k]) && obj[k].length === 0)) - : keys.slice(); - -// Call the LLM for strict JSON, validating against a key schema and RETRYING -// (with a corrective prompt) when the output is truncated or schema-invalid. -// Returns { data, json, attempts, valid }. attempts-1 = retries used. -const callJSON = async ({ model, messages, requiredKeys = [], maxRetries = 2, maxTokens = 2500 }) => { - let lastData = null, lastText = ""; - for (let attempt = 0; attempt <= maxRetries; attempt++) { - const convo = attempt === 0 ? messages : [ - ...messages, - { role: "assistant", content: lastText.slice(0, 1200) }, - { role: "user", content: `Your previous reply was invalid or incomplete. Respond with ONLY one complete JSON object${requiredKeys.length ? ` containing keys: ${requiredKeys.join(", ")}` : ""}. No markdown, no prose.` }, - ]; - const res = await fetch(API, { - method: "POST", headers: mkHeaders(), - body: JSON.stringify({ model, max_tokens: maxTokens, messages: convo }), - }); - if (!res.ok) throw new Error(`LLM HTTP ${res.status}`); - const data = await res.json(); - lastData = data; - lastText = pullText(data); - const truncated = data?.choices?.[0]?.finish_reason === "length"; - const json = tryJSON(lastText); - const miss = missingKeys(json, requiredKeys); - if (json && miss.length === 0 && !truncated) { - return { data, json, attempts: attempt + 1, valid: true }; - } - } - return { data: lastData, json: tryJSON(lastText), attempts: maxRetries + 1, valid: false }; -}; - -const sleep = ms => new Promise(r => setTimeout(r, ms)); - -// Normalize loaded memory: drop legacy duplicates (topic history / trusted URLs), -// keeping the first occurrence of each. Makes memory robust against past bloat. -const dedupeMemory = m => { - const seenTopic = new Set(); - const seenUrl = new Set(); - return { - ...m, - preferences: (m.preferences || []).filter(p => p && !seenTopic.has(p.topic) && seenTopic.add(p.topic)), - trustedSources: (m.trustedSources || []).filter(s => s?.url && !seenUrl.has(s.url) && seenUrl.add(s.url)), - }; -}; - -// diff_engine: what changed between the previous alert and the new one (same topic). -const diffAlerts = (prev, next) => { - if (!prev) return ["First run for this topic — baseline saved."]; - const out = []; - if (prev.current_status !== next.current_status) { - out.push(`Status changed:\n was: ${prev.current_status}\n now: ${next.current_status}`); - } - if (prev.impact_level !== next.impact_level) { - out.push(`Impact level: ${prev.impact_level || "—"} → ${next.impact_level || "—"}`); - } - const toMap = arr => Object.fromEntries((arr || []).map(k => [k.label, k.value])); - const pn = toMap(prev.key_numbers), nn = toMap(next.key_numbers); - for (const [label, val] of Object.entries(nn)) { - if (pn[label] === undefined) out.push(`New figure — ${label}: ${val}`); - else if (pn[label] !== val) out.push(`${label}: ${pn[label]} → ${val}`); - } - return out.length ? out : ["No material changes since last run."]; -}; - -// Only allow http(s) links; blocks javascript:/data: URLs from model output. -const isHttpUrl = u => /^https?:\/\//i.test(u || ""); - -const TRACE_STEPS = [ - ["input_received", "Input received", "UI"], - ["context_built", "Context built", "context"], - ["guardrail_checked", "Guardrail checked", "guardrail"], - ["web_search_called", "Web search called", "web_search"], - ["sources_ranked", "Sources ranked", "diff_engine"], - ["reasoning_done", "Reasoning done", "LLM"], - ["output_checked", "Output checked", "guardrail"], - ["alert_generated", "Alert generated", "summarizer"], -]; - -const makeTraceRows = model => TRACE_STEPS.map(([key, step, tool]) => ({ - key, - step, - status: "pending", - duration: "—", - model: model || "—", - tool, - tokensCost: "—", - retries: 0, - error: "", - startedAt: null, -})); - -const formatUsage = data => { - const usage = data?.usage; - if (!usage) return "—"; - const tokens = usage.total_tokens ?? usage.totalTokens ?? - ((usage.prompt_tokens || usage.input_tokens || 0) + (usage.completion_tokens || usage.output_tokens || 0)); - const cost = usage.cost ?? usage.total_cost ?? usage.estimated_cost; - return `${tokens || "—"} tok${cost ? ` / $${Number(cost).toFixed(5)}` : ""}`; -}; - -const withCitationFallback = (output, sources) => { - if (output?.citations?.length || !sources?.length) return output; - return { - ...output, - citations: sources.slice(0, 3).map(source => ({ - text: source.key_info || `Source used for ${output.current_status || "the generated policy alert"}`, - source_title: source.title || source.url, - url: source.url, - })), - }; -}; - -const readLocal = (key, fallback = "") => { - try { return localStorage.getItem(key) || fallback; } - catch { return fallback; } -}; - -const writeLocal = (key, value) => { - try { localStorage.setItem(key, value); } - catch {} -}; - -const readModelLocal = () => { - const value = readLocal("policypulse.model", DEFAULT_MODEL).trim() || DEFAULT_MODEL; - const next = value === OLD_FREE_MODEL ? DEFAULT_MODEL : value; - if (next !== value) writeLocal("policypulse.model", next); - return next; -}; - -const loadSessionState = async () => { - const res = await fetch(SESSION_STATE); - if (!res.ok) throw new Error(`Session load failed: HTTP ${res.status}`); - return res.json(); -}; - -const saveSessionState = state => fetch(SESSION_STATE, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(state), -}); - -// ─── SEARCH PROVIDERS (toggleable, with automatic fallback) ────────────────── -// Each provider returns a uniform { sources, summary }. isOfficialUrl classifies hostnames. -const exaToSources = data => { - const seen = new Set(); - return (data?.results || []) - .filter(r => r?.url && !seen.has(r.url) && seen.add(r.url)) - .map(r => ({ - url: r.url, - title: r.title || r.url, - type: isOfficialUrl(r.url) ? "government" : "source", - key_info: (r.text || r.summary || "Found via Exa search.").slice(0, 500), - reliability: isOfficialUrl(r.url) ? 0.9 : 0.72, - })); -}; - -const annotationsToSources = data => { - const seen = new Set(); - return (data?.choices?.[0]?.message?.annotations || []) - .filter(a => a.type === "url_citation" && a.url_citation?.url - && !seen.has(a.url_citation.url) && seen.add(a.url_citation.url)) - .map(a => a.url_citation) - .map(c => ({ - url: c.url, - title: c.title || c.url, - type: isOfficialUrl(c.url) ? "government" : "source", - key_info: c.content ? c.content.slice(0, 500) : "Found via OpenRouter web search.", - reliability: isOfficialUrl(c.url) ? 0.9 : 0.72, - })); -}; - -const searchTavily = async query => { - const res = await fetch(TAVILY, { - method: "POST", - headers: mkHeaders(), - body: JSON.stringify({ query, search_depth: "advanced", max_results: 6, include_answer: true }), - }); - if (!res.ok) throw new Error(`Tavily: HTTP ${res.status}`); - const data = await res.json(); - return { sources: tavilyToSources(data), summary: data.answer || "" }; -}; - -const searchExa = async query => { - const res = await fetch(EXA, { - method: "POST", - headers: mkHeaders(), - body: JSON.stringify({ query, type: "auto", numResults: 6, contents: { text: { maxCharacters: 500 } } }), - }); - if (!res.ok) throw new Error(`Exa: HTTP ${res.status}`); - const data = await res.json(); - return { sources: exaToSources(data), summary: "" }; -}; - -// Legacy method: the LLM itself runs web_search and returns citation annotations. -const searchOpenRouter = async query => { - const res = await fetch(API, { - method: "POST", - headers: mkHeaders(), - body: JSON.stringify({ - model: DEFAULT_MODEL, max_tokens: 1000, - messages: [ - {role: "system", content: "Use web search to find 3-5 current official sources about the policy topic. Prefer government, BAMF, DAAD, EU, embassy, or official institutional pages."}, - {role: "user", content: `Policy topic to research: "${query}"`}, - ], - tools: [{type: "openrouter:web_search"}], - }), - }); - if (!res.ok) throw new Error(`OpenRouter search: HTTP ${res.status}`); - const data = await res.json(); - return { sources: annotationsToSources(data), summary: (pullText(data) || "").slice(0, 220) }; -}; - -const SEARCH_PROVIDERS = { - tavily: { label: "Tavily (AI search)", run: searchTavily }, - exa: { label: "Exa.ai (neural search)", run: searchExa }, - openrouter: { label: "LLM web search (legacy)", run: searchOpenRouter }, -}; -const PROVIDER_ORDER = ["tavily", "exa", "openrouter"]; - -// Try the chosen provider; on error or zero results, fall back to the others -// (e.g. when Tavily credits run out, Exa or the legacy LLM scrape takes over). -const runSearch = async (query, preferred) => { - const order = [preferred, ...PROVIDER_ORDER.filter(p => p !== preferred)]; - let lastErr; - for (const key of order) { - try { - const { sources, summary } = await SEARCH_PROVIDERS[key].run(query); - if (sources.length) return { sources, summary, provider: key }; - } catch (e) { lastErr = e; } - } - if (lastErr) throw lastErr; - return { sources: [], summary: "", provider: preferred }; -}; - -// ─── PROMPTS ───────────────────────────────────────────────────────────────── -const SYS_RSN = `You are PolicyPulse's Reason module. Analyse and rank the sources, extract key rules with numbers and dates. Return ONLY raw JSON starting with {: -{"ranked_sources":[{"url":"...","title":"...","rank":1,"why":"..."}],"key_findings":["specific finding with number/date"],"current_rules":[{"rule":"...","value":"number or date","source_url":"..."}],"impact_level":"medium","affected_groups":["group"],"confidence":0.87,"analysis_summary":"2-3 sentence summary"}`; - -const SYS_ACT = `You are PolicyPulse's Act module. Generate a clear, actionable PolicyPulse alert. Return ONLY raw JSON starting with {: -{"current_status":"one sentence on current rule/policy state","why_it_matters":"practical importance","who_is_affected":"specific description","key_numbers":[{"label":"label","value":"value"}],"recommended_action":"specific next step","citations":[{"text":"specific quoted fact","source_title":"...","url":"https://..."}],"impact_level":"medium","confidence":0.87,"disclaimer":"Informational summary only, not legal advice. Always verify with official sources before taking action."} -Include 2-3 key_numbers if specific numbers/thresholds exist. Include 2-3 citations.`; - -// ─── MICRO COMPONENTS ──────────────────────────────────────────────────────── -const Badge = ({ color = "green", children }) => { - const cc = { - green: [T.greenD, `${T.green}44`, T.green], - blue: [T.blueD, `${T.blue}44`, T.blue], - orange: [T.orangeD, `${T.orange}44`, T.orange], - red: [T.redD, `${T.red}44`, T.red], - }[color] || [T.greenD, `${T.green}44`, T.green]; - return ( - - {children} - - ); -}; - -const Dot = ({ on, error }) => ( - -); - -const Spinner = () => ( - -); - -const Lbl = ({ children, style }) => ( -
- {children} -
-); - -const PanelBox = ({ children, style }) => ( -
- {children} -
-); - -// ─── PHASE BOX ─────────────────────────────────────────────────────────────── -const PhaseBox = ({ id, label, badgeColor, phase }) => { - const st = phase?.status || "idle"; - const active = st === "active"; - const done = st === "done"; - const d = phase?.data; - - const preview = (() => { - if (!d) return null; - const raw = - id === "context" ? d.topic : - id === "observe" ? d.summary : - id === "reason" ? d.analysis_summary : - id === "act" ? d.current_status : null; - return raw ? (raw.length > 80 ? raw.slice(0,80)+"…" : raw) : null; - })(); - - return ( -
-
- {active ? : } - - {label} - - LLM - {done && } -
- {active && ( -
- processing… -
- )} - {done && preview && ( -
- {preview} -
- )} -
- ); -}; - -// ─── HARNESS LOOP (center) ─────────────────────────────────────────────────── -const HarnessLoop = ({ phases }) => ( -
-
- AGENT HARNESS -
- - {/* Inner loop box */} -
-
- ↺ LOOP -
- -
- {[ - {id:"context", label:"CONTEXT", bc:"blue"}, - {id:"observe", label:"OBSERVE", bc:"green"}, - {id:"reason", label:"REASON", bc:"green"}, - {id:"act", label:"ACT", bc:"green"}, - ].map((p,i) => ( -
- {i > 0 && ( -
- )} - -
- ))} -
-
-
-); - -// ─── LEFT PANEL ────────────────────────────────────────────────────────────── -const LeftPanel = ({ topic, setTopic, onRun, status }) => { - const running = status === "running"; - return ( -
- - Prompt -