/* PDB — explorateur SPID. SPA sans dépendance (Chart.js chargé par CDN). */ (() => { const $ = (s, r = document) => r.querySelector(s); const view = $("#view"); const API = "/app"; const TYPE = {}; const STATUT = { planned: "Planifié", in_progress: "En cours", completed: "Terminé", mentioned: "Mentionné" }; const COL = { bleu: "#003E7E", bleu2: "#0066B3", or: "#C6A300", teal: "#007979", orange: "#D67A00", vert: "#008046", violet: "#5E35B1", rouge: "#B42318", gris: "#8a8c90" }; const PAL = [COL.bleu, COL.or, COL.teal, COL.orange, COL.vert, COL.violet, COL.rouge, COL.bleu2, COL.gris, "#8DA9C4", "#E0C36B"]; let charts = []; const fmtN = (n) => n == null ? "—" : Number(n).toLocaleString("fr-CA"); const fmtUSD = (v) => v == null ? "—" : v >= 1e9 ? (v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 }) + " G$" : v >= 1e6 ? (v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " M$" : Math.round(v).toLocaleString("fr-CA") + " $"; const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const tl = (t) => TYPE[t] || t; const stPill = (s) => `${esc(STATUT[s] || s)}`; const qs = (o) => Object.entries(o).filter(([, v]) => v !== "" && v != null && v !== false).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&"); const toast = (m) => { const t = $("#toast"); t.textContent = m; t.hidden = false; clearTimeout(t._h); t._h = setTimeout(() => (t.hidden = true), 2600); }; async function api(path, params) { const url = API + path + (params ? "?" + qs(params) : ""); const r = await fetch(url, { headers: { Accept: "application/json" } }); if (!r.ok) { let d = ""; try { d = (await r.json()).detail; } catch { } throw new Error(d || `${r.status} ${r.statusText}`); } return r.json(); } function destroyCharts() { charts.forEach((c) => c.destroy()); charts = []; } function chart(el, cfg) { if (!window.Chart) return; Chart.defaults.font.family = getComputedStyle(document.body).fontFamily; Chart.defaults.color = "#58595B"; const c = new Chart(el, cfg); charts.push(c); return c; } const hashParams = () => { const h = location.hash.slice(2); const [p, q] = h.split("?"); return { path: p || "", parts: (p || "").split("/"), q: Object.fromEntries(new URLSearchParams(q || "")) }; }; const setQuery = (obj) => { const { parts } = hashParams(); location.hash = "#/" + parts[0] + (Object.keys(obj).length ? "?" + qs(obj) : ""); }; /* ------------------------------------------------------------ tableau générique */ function table(cols, rows, opts = {}) { const th = cols.map((c) => `${esc(c.label)}`).join(""); const tr = rows.map((r) => `${cols.map((c) => `${c.render ? c.render(r) : esc(r[c.key])}`).join("")}`).join(""); return `
${th}${tr || ``}
Aucun résultat.
`; } function bindTable(root, onSort) { root.querySelectorAll("tr[data-href]").forEach((tr) => tr.addEventListener("click", (e) => { if (e.target.closest("a")) return; if (tr.dataset.href) location.hash = tr.dataset.href; })); if (onSort) root.querySelectorAll("th[data-sort]").forEach((th) => th.addEventListener("click", () => th.dataset.sort && onSort(th.dataset.sort))); } function pager(total, limit, offset) { const page = Math.floor(offset / limit) + 1, pages = Math.max(1, Math.ceil(total / limit)); return `
${fmtN(total)} résultats · page ${page} / ${fmtN(pages)}
`; } /* ------------------------------------------------------------ tableau de bord */ async function dashboard() { const s = await api("/stats"); const ov = s.overview; view.innerHTML = `

SEC Project Intelligence Database

${fmtN(ov.projects)} projets stratégiques et ${fmtN(ov.mentions)} mentions extraits par LLM des filings SEC (10-K, 10-Q, 8-K) de ${fmtN(ov.companies)} sociétés du S&P 500, ${ov.first_seen?.slice(0, 4)}–${ov.last_seen?.slice(0, 4)}. Explorez, filtrez, interrogez en SQL, ou branchez-vous sur l'API.

Explorer les projets   API
${kpi(ov.projects, "projets résolus")}${kpi(ov.mentions, "mentions extraites")}${kpi(ov.filings, "filings avec projets")}${kpi(ov.companies, "entreprises")} ${kpi(ov.with_amount, "projets avec montant")}${kpi(fmtUSD(ov.median_amount_usd), "montant médian")}${kpi(ov.kg_nodes, "nœuds du graphe")}${kpi(ov.sections_10k, "sections 10-K")}

Mentions par année et formulaire

Statut des projets

Les 21 types de projets (nombre et capital divulgué)

Projets par secteur GICS

Thèmes émergents — part des mentions annuelles

Entreprises les plus prolifiques

${table([ { key: "ticker", label: "Ticker", render: (r) => `${esc(r.ticker)}` }, { key: "company_name", label: "Entreprise" }, { key: "sector", label: "Secteur" }, { key: "n", label: "Projets", num: true, render: (r) => fmtN(r.n) }, { key: "amount_usd", label: "Capital", num: true, render: (r) => fmtUSD(r.amount_usd) }, ], s.top_companies.slice(0, 12))}

Technologies et localisations les plus citées

${s.top_technologies.slice(0, 20).map((t) => `${esc(t.technology)} ${fmtN(t.n)}`).join("")}
${s.top_locations.slice(0, 20).map((t) => `${esc(t.location)} ${fmtN(t.n)}`).join("")}
`; // graphiques const years = [...new Set(s.mentions_by_year.map((r) => r.year))].sort(); const forms = ["10-K", "10-Q", "8-K"]; chart($("#c-year"), { type: "bar", data: { labels: years, datasets: forms.map((f, i) => ({ label: f, data: years.map((y) => s.mentions_by_year.find((r) => r.year === y && r.form_type === f)?.n || 0), backgroundColor: [COL.bleu, COL.or, COL.teal][i] })) }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { stacked: true }, y: { stacked: true } }, plugins: { legend: { position: "bottom" } } } }); chart($("#c-status"), { type: "doughnut", data: { labels: s.by_status.map((r) => STATUT[r.status] || r.status), datasets: [{ data: s.by_status.map((r) => r.n), backgroundColor: [COL.bleu2, COL.vert, COL.or, COL.gris] }] }, options: { responsive: true, maintainAspectRatio: false, cutout: "58%", plugins: { legend: { position: "right" } } } }); chart($("#c-type"), { type: "bar", data: { labels: s.by_type.map((r) => r.label), datasets: [{ label: "Projets", data: s.by_type.map((r) => r.n), backgroundColor: COL.bleu, yAxisID: "y" }, { label: "Capital (G$)", data: s.by_type.map((r) => (r.amount_usd || 0) / 1e9), backgroundColor: COL.or, yAxisID: "y2" }] }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { ticks: { autoSkip: false, maxRotation: 60, minRotation: 45 } }, y: { position: "left", title: { display: true, text: "projets" } }, y2: { position: "right", grid: { drawOnChartArea: false }, title: { display: true, text: "G$" } } }, plugins: { legend: { position: "bottom" } }, onClick: (e, els) => { if (els[0]) location.hash = "#/projects?type=" + s.by_type[els[0].index].project_type; } } }); chart($("#c-sector"), { type: "bar", data: { labels: s.by_sector.map((r) => r.sector), datasets: [{ label: "Projets", data: s.by_sector.map((r) => r.n), backgroundColor: COL.bleu2 }] }, options: { indexAxis: "y", responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, onClick: (e, els) => { if (els[0]) location.hash = "#/projects?sector=" + encodeURIComponent(s.by_sector[els[0].index].sector); } } }); const tot = {}; s.mentions_by_year.forEach((r) => (tot[r.year] = (tot[r.year] || 0) + r.n)); const themes = [...new Set(s.themes_by_year.map((r) => r.project_type))]; chart($("#c-themes"), { type: "line", data: { labels: years, datasets: themes.map((t, i) => ({ label: tl(t), data: years.map((y) => { const n = s.themes_by_year.find((r) => r.year === y && r.project_type === t)?.n || 0; return tot[y] ? +(100 * n / tot[y]).toFixed(2) : 0; }), borderColor: [COL.rouge, COL.violet, COL.bleu2, COL.teal, COL.vert, COL.or][i], backgroundColor: "transparent", tension: .25, pointRadius: 2 })) }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { title: { display: true, text: "% des mentions" } } }, plugins: { legend: { position: "bottom" } } } }); } const kpi = (v, l) => `
${typeof v === "number" ? fmtN(v) : v}${l}
`; /* ------------------------------------------------------------ projets */ const PCOLS = [ { key: "ticker", label: "Ticker", sortKey: "ticker", render: (r) => `${esc(r.ticker)}` }, { key: "project_name", label: "Projet", sortKey: "name", trunc: true, render: (r) => `${esc(r.project_name)}
${esc(r.company_name)}
` }, { key: "project_type", label: "Type", sortKey: "type", render: (r) => `${esc(tl(r.project_type))}` }, { key: "status", label: "Statut", render: (r) => stPill(r.status) }, { key: "canonical_location", label: "Lieu" }, { key: "total_amount_usd", label: "Montant", num: true, sortKey: "amount", render: (r) => fmtUSD(r.total_amount_usd) }, { key: "n_mentions", label: "Mentions", num: true, sortKey: "mentions", render: (r) => fmtN(r.n_mentions) }, { key: "first_seen", label: "Première / dernière", sortKey: "first_seen", render: (r) => `${esc(r.first_seen?.slice(0, 7))} → ${esc(r.last_seen?.slice(0, 7))}` }, { key: "avg_confidence", label: "Conf.", num: true, sortKey: "confidence", render: (r) => (r.avg_confidence ?? 0).toFixed(2) }, ]; async function projects() { const { q } = hashParams(); const f = { q: q.q || "", type: q.type || "", sector: q.sector || "", status: q.status || "", ticker: q.ticker || "", location: q.location || "", tech: q.tech || "", partner: q.partner || "", min_amount: q.min_amount || "", year_from: q.year_from || "", year_to: q.year_to || "", min_confidence: q.min_confidence || "", has_amount: q.has_amount || "", sort: q.sort || "mentions", order: q.order || "desc", limit: 50, offset: +q.offset || 0 }; const [tax, secs] = await Promise.all([api("/taxonomy"), api("/sectors")]); view.innerHTML = `

Projets

${fmtN(0)} — chargement…

Exporter CSV
Chargement…
`; $("#pf").addEventListener("submit", (e) => { e.preventDefault(); const d = Object.fromEntries(new FormData(e.target)); setQuery({ ...d, sort: f.sort, order: f.order }); }); $("#reset").addEventListener("click", () => (location.hash = "#/projects")); const data = await api("/projects", f); $(".page-head p").textContent = `${fmtN(data.total)} projets correspondent aux filtres.`; const res = $("#res"); res.innerHTML = table(PCOLS, data.items, { href: (r) => `#/project/${r.project_id}`, sort: f.sort, order: f.order }) + pager(data.total, f.limit, f.offset); bindTable(res, (k) => setQuery({ ...f, offset: 0, sort: k, order: f.sort === k && f.order === "desc" ? "asc" : "desc" })); res.querySelectorAll("[data-off]").forEach((b) => b.addEventListener("click", () => setQuery({ ...f, offset: b.dataset.off }))); } /* ------------------------------------------------------------ fiche projet */ async function project(id) { const d = await api("/projects/" + id); const p = d.project; const list = (arr, cls) => (arr || []).length ? `
${arr.map((x) => `${esc(x)}`).join("")}
` : `—`; view.innerHTML = `

${esc(p.project_name)}

${esc(tl(p.project_type))}   ${stPill(p.status)}   ${esc(p.sector || "")}

Voir dans le graphe API ↗

Description

${esc(p.description)}

Entreprise
${esc(p.company_name)} (${esc(p.ticker)}) · CIK ${esc(p.cik)}
Localisation
${p.canonical_location ? `${esc(p.canonical_location)}` : "—"}
Montant (max divulgué)
${fmtUSD(p.total_amount_usd)}
Observé
${esc(p.first_seen)} → ${esc(p.last_seen)} · ${fmtN(p.n_mentions)} mentions dans ${fmtN(p.n_filings)} filings
Années citées
${p.first_year ?? "—"} → ${p.last_year ?? "—"}
Confiance moyenne
${(p.avg_confidence ?? 0).toFixed(3)}
Technologies
${list(p.technologies, "t-tech")}
Identifiant
${esc(p.project_id)}

Chronologie (${d.timeline.length})

Mentions extraites (${d.mentions.length})

${d.mentions.map((m) => `
${esc(m.form_type)}${esc(m.filing_date)}${esc(m.section_name)}${stPill(m.status)}${m.amount_usd ? `${fmtUSD(m.amount_usd)}` : ""}conf. ${(m.confidence ?? 0).toFixed(2)}détail

${esc(m.project_name)} — ${esc(m.description)}

${m.objective ? `

Objectif : ${esc(m.objective)}

` : ""}
${m.locations?.length ? `
${list(m.locations, "t-loc")}
` : ""}${m.technologies?.length ? `
${list(m.technologies, "t-tech")}
` : ""}${m.partners?.length ? `
Partenaires ${list(m.partners, "t-part")}
` : ""}${m.benefits?.length ? `
Bénéfices ${list(m.benefits, "t-ben")}
` : ""}${m.risks?.length ? `
Risques ${list(m.risks, "t-risk")}
` : ""}${m.years?.length ? `
Années ${list(m.years, "")}
` : ""}
`).join("")}

Projets similaires (embeddings)

${d.similar.length ? d.similar.map((s) => `
${esc(s.project_name)}
${esc(s.ticker)} · ${esc(tl(s.project_type))} · ${(s.score * 100).toFixed(0)} %
`).join("") : `—`}

Voisins dans le graphe

${d.graph.map((g) => `${esc(g.rel)}${esc(g.label)}`).join("") || `—`}
`; } /* ------------------------------------------------------------ mention */ async function mention(id) { const d = await api("/mentions/" + id); const m = d.mention; view.innerHTML = `

${esc(m.project_name)}

${esc(tl(m.project_type))} ${stPill(m.status)} · ${esc(m.company_name)} · ${esc(m.form_type)} du ${esc(m.filing_date)} · section ${esc(m.section_name)}

Extraction

Description
${esc(m.description)}
Objectif
${esc(m.objective) || "—"}
Montant
${fmtUSD(m.amount_usd)} ${m.amount_raw ? `(${esc(m.amount_raw)})` : ""}
Localisations
${(m.locations || []).join(", ") || "—"}
Technologies
${(m.technologies || []).join(", ") || "—"}
Partenaires
${(m.partners || []).join(", ") || "—"}
Fournisseurs
${(m.suppliers || []).join(", ") || "—"}
Bénéfices
${(m.benefits || []).join(" · ") || "—"}
Risques
${(m.risks || []).join(" · ") || "—"}
Années
${(m.years || []).join(", ") || "—"}
Confiance
${(m.confidence ?? 0).toFixed(3)} (backend ${esc(m.backend)})
Accession
${esc(m.accession_number)}
Section
${esc(m.section_id)}
Mention
${esc(m.mention_id)}

Section source

${d.section ? `

${esc(d.section.section_name)} · ${fmtN(d.section.word_count)} mots · 10-K

` : `
Le texte source n'est disponible dans la base que pour les sections 10-K ingérées par SPID. Cette mention provient d'un ${esc(m.form_type)} dont la section reste dans le corpus parquet amont.
`}

Filing sur EDGAR : liste des ${esc(m.form_type)} de ${esc(m.ticker)} ↗

`; $("#load-sec")?.addEventListener("click", async (e) => { e.target.disabled = true; const s = await api("/sections/" + m.section_id); const key = (m.project_name || "").split(/\s+/).filter((w) => w.length > 4)[0]; let txt = esc(s.text); if (key) txt = txt.replace(new RegExp(esc(key).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), (x) => `${x}`); $("#sec").innerHTML = `
${txt}
`; }); } /* ------------------------------------------------------------ mentions */ async function mentions() { const { q } = hashParams(); const f = { q: q.q || "", type: q.type || "", form: q.form || "", ticker: q.ticker || "", status: q.status || "", date_from: q.date_from || "", date_to: q.date_to || "", min_amount: q.min_amount || "", min_confidence: q.min_confidence || "", sort: q.sort || "date", order: q.order || "desc", limit: 50, offset: +q.offset || 0 }; const tax = await api("/taxonomy"); view.innerHTML = `

Mentions

Unité d'extraction : un projet cité dans une section d'un filing.

Chargement…
`; $("#mf").addEventListener("submit", (e) => { e.preventDefault(); setQuery({ ...Object.fromEntries(new FormData(e.target)), sort: f.sort, order: f.order }); }); $("#reset").addEventListener("click", () => (location.hash = "#/mentions")); const data = await api("/mentions", f); const cols = [ { key: "filing_date", label: "Date", sortKey: "date" }, { key: "form_type", label: "Form." }, { key: "ticker", label: "Ticker", sortKey: "ticker", render: (r) => `${esc(r.ticker)}` }, { key: "project_name", label: "Projet / description", trunc: true, title: (r) => r.description, render: (r) => `${esc(r.project_name)}
${esc(r.description)}
` }, { key: "project_type", label: "Type", render: (r) => `${esc(tl(r.project_type))}` }, { key: "status", label: "Statut", render: (r) => stPill(r.status) }, { key: "section_name", label: "Section" }, { key: "amount_usd", label: "Montant", num: true, sortKey: "amount", render: (r) => fmtUSD(r.amount_usd) }, { key: "confidence", label: "Conf.", num: true, sortKey: "confidence", render: (r) => (r.confidence ?? 0).toFixed(2) }, ]; const res = $("#res"); res.innerHTML = `

${fmtN(data.total)} mentions.

` + table(cols, data.items, { href: (r) => `#/mention/${r.mention_id}`, sort: f.sort, order: f.order }) + pager(data.total, f.limit, f.offset); bindTable(res, (k) => setQuery({ ...f, offset: 0, sort: k, order: f.sort === k && f.order === "desc" ? "asc" : "desc" })); res.querySelectorAll("[data-off]").forEach((b) => b.addEventListener("click", () => setQuery({ ...f, offset: b.dataset.off }))); } /* ------------------------------------------------------------ entreprises */ async function companies() { const { q } = hashParams(); const f = { q: q.q || "", sector: q.sector || "", sort: q.sort || "projects", order: q.order || "desc", limit: 100, offset: +q.offset || 0 }; const secs = await api("/sectors"); view.innerHTML = `

Entreprises

500 sociétés du S&P 500 et la taille de leur portefeuille de projets divulgués.

`; $("#cf").addEventListener("submit", (e) => { e.preventDefault(); setQuery({ ...Object.fromEntries(new FormData(e.target)), sort: f.sort, order: f.order }); }); const data = await api("/companies", f); const cols = [ { key: "ticker", label: "Ticker", sortKey: "ticker", render: (r) => `${esc(r.ticker)}` }, { key: "company_name", label: "Entreprise" }, { key: "sector", label: "Secteur" }, { key: "n_projects", label: "Projets", num: true, sortKey: "projects", render: (r) => fmtN(r.n_projects) }, { key: "n_mentions", label: "Mentions", num: true, sortKey: "mentions", render: (r) => fmtN(r.n_mentions) }, { key: "n_types", label: "Types", num: true }, { key: "amount_usd", label: "Capital divulgué", num: true, sortKey: "amount", render: (r) => fmtUSD(r.amount_usd) }, { key: "first_seen", label: "Période", render: (r) => `${esc(r.first_seen?.slice(0, 4))}–${esc(r.last_seen?.slice(0, 4))}` }, ]; const res = $("#res"); res.innerHTML = table(cols, data.items, { href: (r) => `#/company/${r.ticker}`, sort: f.sort, order: f.order }) + pager(data.total, f.limit, f.offset); bindTable(res, (k) => setQuery({ ...f, offset: 0, sort: k, order: f.sort === k && f.order === "desc" ? "asc" : "desc" })); res.querySelectorAll("[data-off]").forEach((b) => b.addEventListener("click", () => setQuery({ ...f, offset: b.dataset.off }))); } async function company(ident) { const d = await api("/companies/" + encodeURIComponent(ident)); const c = d.company; view.innerHTML = `

${esc(c.company_name)} (${esc(c.ticker)})

${esc(c.sector || "")} · CIK ${esc(c.cik)} · ${fmtN(c.n_projects)} projets · ${fmtN(c.n_mentions)} mentions · ${esc(c.first_seen?.slice(0, 4))}–${esc(c.last_seen?.slice(0, 4))}

Filtrer les projets Graphe
${kpi(c.n_projects, "projets")}${kpi(c.n_mentions, "mentions")}${kpi(fmtUSD(c.amount_usd), "capital divulgué (Σ max)")}${kpi(d.by_type.length, "types de projets")}

Par type

Mentions par année

Lieux, technologies, partenaires

${d.locations.map((x) => `${esc(x.location)} ${x.n}`).join("")}

${d.technologies.map((x) => `${esc(x.technology)} ${x.n}`).join("")}

${d.partners.map((x) => `${esc(x.partner)} ${x.n}`).join("")}

Portefeuille de projets (${fmtN(d.projects.length)})

`; chart($("#c1"), { type: "bar", data: { labels: d.by_type.map((r) => r.label), datasets: [{ data: d.by_type.map((r) => r.n), backgroundColor: COL.bleu }] }, options: { indexAxis: "y", responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } } }); chart($("#c2"), { type: "bar", data: { labels: d.by_year.map((r) => r.year), datasets: [{ data: d.by_year.map((r) => r.n), backgroundColor: COL.or }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } } }); const res = $("#res"); res.innerHTML = table(PCOLS.filter((c) => c.key !== "ticker"), d.projects, { href: (r) => `#/project/${r.project_id}` }); bindTable(res); } /* ------------------------------------------------------------ graphe */ async function graph() { const { q } = hashParams(); view.innerHTML = `

Graphe de connaissances

32 226 nœuds (entreprises, projets, lieux, technologies, partenaires) et 33 176 arêtes owns · located_in · uses. Cherchez un nœud puis naviguez de voisin en voisin.

`; $("#gf").addEventListener("submit", (e) => { e.preventDefault(); const d = Object.fromEntries(new FormData(e.target)); location.hash = "#/graph?" + qs(d); }); if (q.node) await showNode(q.node); else if (q.q) { const hits = await api("/graph/search", { q: q.q, type: q.type || "", limit: 60 }); $("#hits").innerHTML = `

${hits.length} nœuds

${hits.map((h) => `${esc(h.node_type)}${esc(h.label)} · ${fmtN(h.degree)}`).join("") || "Aucun résultat."}
`; } else { $("#hits").innerHTML = `

Points d'entrée

${[["L:texas", "Location"], ["L:china", "Location"], ["L:united states", "Location"], ["T:AI", "Technology"], ["T:Renewable Energy", "Technology"], ["T:Battery/Storage", "Technology"], ["T:Data Center", "Technology"], ["C:0001318605", "Company"], ["C:0000045012", "Company"]].map(([n, t]) => `${t}${esc(n.slice(2))}`).join("")}
`; } } async function showNode(nodeId) { const d = await api("/graph/node/" + nodeId, { limit: 400 }); const n = d.node; const props = safeJson(n.props); const groups = {}; d.edges.forEach((e) => ((groups[e.neighbor_type] ||= []).push(e))); const target = n.node_type === "Project" ? `Fiche du projet` : n.node_type === "Company" ? `Fiche de l'entreprise` : n.node_type === "Location" ? `Projets à cet endroit` : n.node_type === "Technology" ? `Projets avec cette technologie` : `Projets avec ce partenaire`; $("#node").innerHTML = `
${esc(n.node_type)}

${esc(n.label)}

${esc(n.node_id)} · degré ${fmtN(d.degree)}
${props ? `
${Object.entries(props).filter(([, v]) => v != null).map(([k, v]) => `${esc(k)} = ${esc(k.includes("amount") ? fmtUSD(v) : v)}`).join(" · ")}
` : ""}
${target}
${Object.entries(groups).map(([t, es]) => `

${esc(t)} (${es.length})

${es.slice(0, 200).map((e) => `${esc(e.rel)} ${e.direction === "out" ? "→" : "←"}${esc(e.neighbor_label)}`).join("")}${es.length > 200 ? ` … ${es.length - 200} de plus` : ""}
`).join("") || "

Ce nœud n'a pas d'arête (les partenaires ne sont pas encore reliés dans cette version du graphe).

"}
`; drawRadial($("#svg"), n, d.edges.slice(0, 48)); } function safeJson(s) { if (!s) return null; try { return typeof s === "string" ? JSON.parse(s) : s; } catch { return null; } } function drawRadial(svg, center, edges) { const W = svg.clientWidth || 900, H = 520, cx = W / 2, cy = H / 2; const colors = { Company: COL.bleu, Project: COL.violet, Location: COL.orange, Technology: COL.teal, Partner: COL.or }; const n = edges.length; const R = Math.min(W, H) / 2 - 70; let s = ``; edges.forEach((e, i) => { const a = (2 * Math.PI * i) / n - Math.PI / 2; const x = cx + R * Math.cos(a), y = cy + R * Math.sin(a); s += ``; }); edges.forEach((e, i) => { const a = (2 * Math.PI * i) / n - Math.PI / 2; const x = cx + R * Math.cos(a), y = cy + R * Math.sin(a); const lab = e.neighbor_label.length > 28 ? e.neighbor_label.slice(0, 26) + "…" : e.neighbor_label; const anchor = Math.cos(a) > 0.2 ? "start" : Math.cos(a) < -0.2 ? "end" : "middle"; s += `${esc(lab)}`; }); s += `${esc(center.label.slice(0, 40))}`; svg.setAttribute("viewBox", `0 0 ${W} ${H}`); svg.innerHTML = s; } /* ------------------------------------------------------------ recherche sémantique */ async function semantic() { const { q } = hashParams(); view.innerHTML = `

Recherche sémantique

Décrivez un projet en langage naturel (anglais recommandé, langue des filings) : la requête est encodée avec all-MiniLM-L6-v2 et comparée aux 19 227 vecteurs de projets.

`; $("#sf").addEventListener("submit", (e) => { e.preventDefault(); location.hash = "#/semantic?" + qs(Object.fromEntries(new FormData(e.target))); }); if (!q.q) { $("#res").innerHTML = `
${["gigafactory for battery cells", "generative AI assistant for customers", "offshore wind farm", "ERP migration to SAP S/4HANA", "new hospital or clinical trial for oncology drug", "data center campus in Virginia", "restructuring plan with plant closures"].map((x) => ``).join("")}
`; $("#res").querySelectorAll("button").forEach((b) => b.addEventListener("click", () => (location.hash = "#/semantic?q=" + encodeURIComponent(b.dataset.q)))); return; } $("#res").innerHTML = `
Encodage et recherche…
`; try { const d = await api("/search/semantic", { q: q.q, k: 40 }); const cols = [{ key: "score", label: "Score", num: true, render: (r) => `${(r.score * 100).toFixed(1)} %` }, ...PCOLS.filter((c) => !["avg_confidence", "first_seen"].includes(c.key))]; const res = $("#res"); res.innerHTML = table(cols, d.items, { href: (r) => `#/project/${r.project_id}` }); bindTable(res); } catch (e) { $("#res").innerHTML = `
${esc(e.message)}

La similarité entre projets reste disponible sur chaque fiche (« Projets similaires »), car elle n'utilise que les vecteurs déjà stockés.

`; } } /* ------------------------------------------------------------ SQL */ const SQL_EXAMPLES = [ ["Projets par type", "select project_type, count(*) n, round(sum(total_amount_usd)/1e9,1) capital_gusd\nfrom projects group by 1 order by n desc"], ["Centres de données > 500 M$", "select ticker, project_name, canonical_location, total_amount_usd, first_seen\nfrom projects where project_type='data_center' and total_amount_usd > 5e8\norder by total_amount_usd desc"], ["Mentions IA par année", "select year(filing_date) y, count(*) n\nfrom project_mentions where project_type='ai_initiative' group by 1 order by 1"], ["Technologies les plus citées", "select lower(t) technology, count(*) n\nfrom projects, unnest(technologies) as u(t) group by 1 order by n desc limit 25"], ["Partenaires de Tesla", "select p partner, count(*) n from project_mentions, unnest(partners) as u(p)\nwhere ticker='TSLA' group by 1 order by n desc"], ["Durée de suivi médiane par type", "select project_type, median(last_year-first_year) mediane_annees, count(*) n\nfrom projects where last_year>=first_year group by 1 order by 2 desc"], ["Graphe : lieux les plus connectés", "select n.label, count(*) degre from kg_edges e join kg_nodes n on n.node_id=e.dst\nwhere e.rel='located_in' group by 1 order by 2 desc limit 20"], ["Texte 10-K : sections les plus longues", "select s.accession_number, s.section_name, s.word_count\nfrom spid_sections s order by word_count desc limit 10"], ]; async function sqlPage() { const schema = await api("/schema"); const saved = localStorage.getItem("pdb_sql") || SQL_EXAMPLES[0][1]; view.innerHTML = `

Bac à sable SQL

DuckDB en lecture seule sur les 7 tables de la base. SELECT / WITH / DESCRIBE / SUMMARIZE ; 20 s et 5 000 lignes au maximum. ⌘⏎ pour exécuter.

Schéma

${schema.map((t) => `
${esc(t.table)} (${fmtN(t.rows)})
    ${t.columns.map((c) => `
  • ${esc(c.name)} ${esc(c.type)}
  • `).join("")}
`).join("")}
${SQL_EXAMPLES.map((e, i) => ``).join("")}
`; const ta = $("#sql"); view.querySelectorAll(".examples button").forEach((b) => b.addEventListener("click", () => { ta.value = SQL_EXAMPLES[b.dataset.i][1]; run(); })); let last = null; async function run() { const sql = ta.value; localStorage.setItem("pdb_sql", sql); $("#out").innerHTML = `
Exécution…
`; $("#info").textContent = ""; try { const r = await fetch(API + "/sql", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sql, limit: +$("#lim").value || 500 }) }); const d = await r.json(); if (!r.ok) throw new Error(d.detail || r.statusText); last = d; $("#info").textContent = `${fmtN(d.rows.length)} lignes${d.truncated ? " (tronqué)" : ""} · ${d.elapsed_ms} ms`; $("#csv").disabled = false; $("#out").innerHTML = `
${d.columns.map((c) => ``).join("")}${d.rows.map((row) => `${row.map((v) => ``).join("")}`).join("")}
${esc(c)}
${esc(Array.isArray(v) ? v.join(" | ") : typeof v === "object" && v ? JSON.stringify(v) : v)}
`; } catch (e) { $("#out").innerHTML = `
${esc(e.message)}
`; } } $("#run").addEventListener("click", run); ta.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") run(); }); $("#csv").addEventListener("click", () => { if (!last) return; const csv = [last.columns.join(",")].concat(last.rows.map((r) => r.map((v) => `"${String(Array.isArray(v) ? v.join("|") : v ?? "").replace(/"/g, '""')}"`).join(","))).join("\n"); const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" })); a.download = "pdb_query.csv"; a.click(); }); run(); } /* ------------------------------------------------------------ à propos */ async function about() { view.innerHTML = `

La base SPID et son pipeline

SPID (SEC Project Intelligence Database) reconstruit le portefeuille de projets stratégiques des grandes sociétés cotées américaines à partir du texte brut de leurs documents réglementaires (10-K annuels, 10-Q trimestriels, 8-K d'événements) déposés auprès de la SEC sur EDGAR. Plutôt que de lire ces filings comme des documents financiers, SPID les traite comme une source d'intelligence sur les projets : usines, centres de données, acquisitions, programmes de R&D, initiatives d'IA, transition énergétique…

D'où viennent les données

Comment fonctionne le pipeline

  1. Ingestion des 10-K bruts (nettoyage HTML, découpage en Items).
  2. Extraction par grand modèle de langage (gpt-4o-mini, température 0, JSON strict) : un pré-filtre lexical écarte les sections sans signal de projet, une fenêtre de focalisation limite le texte envoyé ; le modèle renvoie nom, type (taxonomie de 21 catégories), description, objectif, statut, montant, lieux, technologies, partenaires, bénéfices, risques, années et confiance. Les mentions sous 0,45 de confiance sont écartées.
  3. Résolution : les mentions sont fusionnées en projets par entreprise × type × ancre (première localisation ou premier mot significatif du nom) ; chaque projet reçoit une chronologie, un statut courant, le montant maximal divulgué et des dates de première et dernière observation.
  4. Graphe de connaissances : entreprises, projets, lieux, technologies et partenaires reliés par owns, located_in, uses.
  5. Embeddings : chaque projet est encodé en 384 dimensions (all-MiniLM-L6-v2) pour la recherche sémantique et les projets similaires.

Ce que contient la base

39 930 mentions, 19 227 projets, 39 930 points de chronologie, 32 226 nœuds et 33 176 arêtes, 19 227 vecteurs, 27 164 sections 10-K en texte intégral. Un fichier DuckDB de 2,9 Go, interrogeable ici en SQL.

Limites à garder en tête

Le rapport technique complet (PDF) détaille la provenance, chaque étape du pipeline, le schéma et le portrait statistique. Équipe : Manel Kammoun, Charli Tandja Mbianda, Simon-Pierre Boucher — Département des sciences administratives, Université du Québec en Outaouais.

`; } /* ------------------------------------------------------------ routeur */ const routes = { "": dashboard, projects, companies, mentions, graph, semantic, sql: sqlPage, api: () => window.PDBPlayground.render({ view, api, esc, fmtN, fmtUSD, qs, toast, tl }), about }; async function render() { destroyCharts(); const { parts } = hashParams(); const r = parts[0]; document.querySelectorAll("#nav a[data-route]").forEach((a) => a.classList.toggle("active", a.dataset.route === r)); $("#burger") && $(".side").classList.remove("open"); window.scrollTo(0, 0); try { if (r === "project" && parts[1]) await project(parts[1]); else if (r === "company" && parts[1]) await company(parts[1]); else if (r === "mention" && parts[1]) await mention(parts[1]); else if (routes[r]) await routes[r](); else view.innerHTML = `

Page introuvable

Retour au tableau de bord
`; } catch (e) { view.innerHTML = `
Erreur : ${esc(e.message)}
`; } } async function init() { try { const tax = await api("/taxonomy"); tax.forEach((t) => (TYPE[t.project_type] = t.label)); } catch { } try { const h = await fetch("/health").then((r) => r.json()); $("#kpi-projects").textContent = fmtN(h.projects) + " projets"; $("#side-status").textContent = `service ${h.status} · sémantique : ${h.semantic}`; } catch { $("#side-status").textContent = "service indisponible"; } fetch("/report.pdf", { method: "HEAD" }).then((r) => { if (!r.ok) $("#report-link").remove(); }).catch(() => { }); $("#global-search").addEventListener("submit", (e) => { e.preventDefault(); const v = $("#global-q").value.trim(); if (v) location.hash = "#/projects?q=" + encodeURIComponent(v); }); document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); $("#global-q").focus(); } }); $("#burger").addEventListener("click", () => $(".side").classList.toggle("open")); window.addEventListener("hashchange", render); render(); } init(); })();