From 27681678d4176ab033736a970625f78b7e7cc3fd Mon Sep 17 00:00:00 2001 From: Hassan Ibrahim Date: Thu, 15 Oct 2026 19:02:08 -0400 Subject: [PATCH 1/4] Add the Project Finder chat widget for the website --- chatbot/index.html | 14 +++ chatbot/recommend.js | 60 +++++++++++ chatbot/recommend.test.mjs | 19 ++++ chatbot/widget.js | 214 +++++++++++++++++++++++++++++++++++++ 4 files changed, 307 insertions(+) create mode 100644 chatbot/index.html create mode 100644 chatbot/recommend.js create mode 100644 chatbot/recommend.test.mjs create mode 100644 chatbot/widget.js diff --git a/chatbot/index.html b/chatbot/index.html new file mode 100644 index 0000000..967f8d4 --- /dev/null +++ b/chatbot/index.html @@ -0,0 +1,14 @@ + + + + + + Public Invention Project Finder + + + + +
+ + + diff --git a/chatbot/recommend.js b/chatbot/recommend.js new file mode 100644 index 0000000..ed587a1 --- /dev/null +++ b/chatbot/recommend.js @@ -0,0 +1,60 @@ +// Public Invention's recommendation rules for the browser. +// Mirrors pipeline/ranking.py; tests/ranking_cases.json keeps them in step. + +export const EXCLUDED_TITLES = ["Moonrat", "Oxygen Concentrator", "PolyVent", "ADaM", "Librecorder"]; +export const STATUS_ORDER = { "Highly Active": 1, "Progress but Inactive": 2, "Ready-to-start": 3 }; +const HIDDEN_STATUSES = new Set(["Preliminary"]); +const MIN_README_MATCHES = 2; + +export function isEligible(project) { + const title = (project.project || "").toLowerCase(); + if (EXCLUDED_TITLES.some((name) => title.includes(name.toLowerCase()))) return false; + if (HIDDEN_STATUSES.has(project.status || "")) return false; + return project.seeking_volunteers !== "no"; +} + +export function statusRank(status) { + return STATUS_ORDER[status] ?? Object.keys(STATUS_ORDER).length + 1; +} + +const lower = (list) => new Set((list || []).map((s) => s.toLowerCase())); + +/** Rank eligible projects: status first, then skill matches, then the sheet's Order priority. */ +export function recommend(projects, { have = [], want = [], category = "", limit = 5 } = {}) { + const haveSet = lower(have); + const wantSet = lower(want); + const ranked = []; + for (const project of projects) { + if (!(project.eligible ?? isEligible(project))) continue; + if (category && (project.category || "").toLowerCase() !== category.toLowerCase()) continue; + const sheet = project.skills || []; + const skills = sheet.length ? sheet : project.readme_skills || []; + const matchedHave = skills.filter((s) => haveSet.has(s.toLowerCase())); + const matchedWant = skills.filter((s) => wantSet.has(s.toLowerCase())); + const matches = matchedHave.length + matchedWant.length; + if ((haveSet.size || wantSet.size) && matches < (sheet.length ? 1 : MIN_README_MATCHES)) continue; + ranked.push({ project, matchedHave, matchedWant, key: [statusRank(project.status), -matches, -(project.priority || 0), project.project] }); + } + ranked.sort((a, b) => { + for (let i = 0; i < a.key.length; i++) { + if (a.key[i] < b.key[i]) return -1; + if (a.key[i] > b.key[i]) return 1; + } + return 0; + }); + return ranked.slice(0, limit); +} + +/** Pick known skills out of free text like "I do Arduino and some 3D printing". */ +export function skillsFromText(text, skills, aliases = {}) { + const low = ` ${(text || "").toLowerCase()} `; + const found = new Set(); + for (const skill of skills) { + if (low.includes(skill.toLowerCase())) found.add(skill); + } + for (const [phrase, mapped] of Object.entries(aliases)) { + if (phrase.startsWith("_") || phrase.length < 4) continue; + if (low.includes(phrase)) mapped.filter((s) => skills.includes(s)).forEach((s) => found.add(s)); + } + return [...found].sort(); +} diff --git a/chatbot/recommend.test.mjs b/chatbot/recommend.test.mjs new file mode 100644 index 0000000..d584b85 --- /dev/null +++ b/chatbot/recommend.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { recommend, skillsFromText } from "./recommend.js"; + +const cases = JSON.parse(readFileSync(new URL("../tests/ranking_cases.json", import.meta.url))); + +for (const c of cases.cases) { + test(c.name, () => { + const got = recommend(cases.projects, { have: c.have, want: c.want, category: c.category }); + assert.deepEqual(got.map((r) => r.project.project), c.expected); + }); +} + +test("free text maps to known skills through aliases", () => { + const found = skillsFromText("I do arduino and javascript programming", ["Arduino", "JavaScript", "Programming"], + { "javascript programming": ["JavaScript"] }); + assert.deepEqual(found, ["Arduino", "JavaScript", "Programming"]); +}); diff --git a/chatbot/widget.js b/chatbot/widget.js new file mode 100644 index 0000000..470446f --- /dev/null +++ b/chatbot/widget.js @@ -0,0 +1,214 @@ +// Public Invention Project Finder: a guided chat that recommends projects from data/projects.json. +// Embed:
+// The ranking itself lives in recommend.js; this file only asks questions and renders answers. +import { recommend, skillsFromText } from "./recommend.js"; + +const DEFAULT_DATA = "https://raw.githubusercontent.com/PubInv/Project-ChatBot/main/data/"; +const VOLUNTEER_URL = "https://www.pubinv.org/volunteer/"; +const JOINABILITY = { 1: "Easy to join", 2: "Join with a mentor", 3: "You may have to start it yourself" }; + +const STYLES = ` + :host { all: initial; display: block; font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; color: #142b4d; } + .box { max-width: 720px; margin: 0 auto; border: 1px solid #ccd7e8; border-radius: 14px; background: #fff; overflow: hidden; } + .head { padding: 14px 18px; background: #142b4d; color: #fff; font-weight: 700; } + .log { padding: 16px; display: flex; flex-direction: column; gap: 10px; max-height: 640px; overflow-y: auto; } + .bot, .me { padding: 10px 14px; border-radius: 12px; max-width: 88%; } + .bot { background: #eef3ff; align-self: flex-start; } + .me { background: #2f66dc; color: #fff; align-self: flex-end; } + .controls { border-top: 1px solid #ccd7e8; padding: 12px 16px; display: flex; flex-direction: column; gap: 10px; } + .chips { display: flex; flex-wrap: wrap; gap: 6px; } + .chip { border: 1px solid #a9bbd6; background: #fff; color: #142b4d; border-radius: 999px; padding: 4px 12px; cursor: pointer; font: inherit; font-size: 14px; } + .chip[aria-pressed="true"] { background: #2f66dc; border-color: #2f66dc; color: #fff; } + .row { display: flex; gap: 8px; } + input { flex: 1; min-width: 0; padding: 8px 12px; border: 1px solid #a9bbd6; border-radius: 8px; font: inherit; } + .main { display: inline-block; text-decoration: none; background: #2f66dc; color: #fff; border: 0; border-radius: 8px; padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; } + button.ghost { background: none; border: 1px solid #a9bbd6; color: #142b4d; border-radius: 8px; padding: 8px 14px; font: inherit; cursor: pointer; } + .card { border: 1px solid #ccd7e8; border-radius: 10px; padding: 12px 14px; background: #fff; align-self: stretch; } + .card h3 { margin: 0 0 4px; font-size: 16px; } + .card a { color: #2f66dc; } + .meta { font-size: 13px; color: #5e6f88; margin-bottom: 6px; } + .why { font-size: 14px; margin-top: 6px; } + .badge { display: inline-block; font-size: 12px; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: #eaf5ee; color: #2e7a46; margin-right: 6px; } + .muted { color: #5e6f88; font-size: 13px; } + @media (max-width: 520px) { .bot, .me { max-width: 100%; } } +`; + +class ProjectFinder { + constructor(host, dataUrl) { + this.dataUrl = dataUrl.endsWith("/") ? dataUrl : `${dataUrl}/`; + this.root = host.attachShadow({ mode: "open" }); + this.root.innerHTML = ` +
+
Find a Public Invention project
+
+
+
`; + this.log = this.root.querySelector(".log"); + this.controls = this.root.querySelector(".controls"); + this.answers = { have: [], want: [], category: "" }; + } + + async start() { + try { + const [projects, skills, aliases] = await Promise.all( + ["projects.json", "skills.json", "skill_aliases.json"].map((f) => fetch(this.dataUrl + f).then((r) => r.json())), + ); + this.projects = projects; + this.skills = skills.skills; + this.aliases = Object.fromEntries(Object.entries(aliases).filter(([k]) => !k.startsWith("_")).map(([k, v]) => [k.toLowerCase(), v])); + this.categories = [...new Set(projects.filter((p) => p.eligible && p.category).map((p) => p.category))].sort(); + } catch { + this.say("Sorry, the project list didn't load. Please try again later, or email read.robert@pubinv.org."); + return; + } + this.askSkills("have", "Hi! I'll match you with an open project. What skills do you already have? Pick a few or type them.", () => + this.askSkills("want", "Anything you'd like to learn while you help?", () => this.askCategory())); + } + + say(text, cls = "bot") { + const div = document.createElement("div"); + div.className = cls; + div.textContent = text; + this.log.append(div); + this.log.scrollTop = this.log.scrollHeight; + return div; + } + + askSkills(field, question, next) { + this.say(question); + const picked = new Set(); + this.controls.innerHTML = ` +
+
+
`; + const chips = this.controls.querySelector(".chips"); + const input = this.controls.querySelector("input"); + const render = () => { + const popular = this.popularSkills(18); + const shown = [...new Set([...picked, ...popular])]; + chips.innerHTML = ""; + for (const skill of shown) { + const chip = document.createElement("button"); + chip.className = "chip"; + chip.textContent = skill; + chip.setAttribute("aria-pressed", picked.has(skill)); + chip.onclick = () => { picked.has(skill) ? picked.delete(skill) : picked.add(skill); render(); }; + chips.append(chip); + } + }; + const addTyped = () => { + const found = skillsFromText(input.value, this.skills, this.aliases); + if (input.value.trim() && !found.length) this.say(`I couldn't match "${input.value.trim()}" to a project skill. Try a broader word, like "electronics".`); + found.forEach((s) => picked.add(s)); + input.value = ""; + render(); + }; + this.controls.querySelector(".add").onclick = addTyped; + input.onkeydown = (e) => { if (e.key === "Enter") addTyped(); }; + const finish = (skip) => { + if (!skip && input.value.trim()) addTyped(); + this.answers[field] = skip ? [] : [...picked]; + this.say(this.answers[field].length ? this.answers[field].join(", ") : "Skip", "me"); + next(); + }; + this.controls.querySelector(".next").onclick = () => finish(false); + this.controls.querySelector(".skip").onclick = () => finish(true); + render(); + input.focus(); + } + + popularSkills(limit) { + const counts = new Map(); + for (const p of this.projects) if (p.eligible) for (const s of p.skills) counts.set(s, (counts.get(s) || 0) + 1); + return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit).map(([s]) => s).sort(); + } + + askCategory() { + this.say("Is there an area you care most about?"); + this.controls.innerHTML = `
`; + const chips = this.controls.querySelector(".chips"); + for (const category of ["Any area", ...this.categories]) { + const chip = document.createElement("button"); + chip.className = "chip"; + chip.textContent = category; + chip.onclick = () => { + this.answers.category = category === "Any area" ? "" : category; + this.say(category, "me"); + this.showResults(); + }; + chips.append(chip); + } + } + + showResults() { + let results = recommend(this.projects, this.answers); + let note = ""; + if (!results.length && this.answers.category) { + results = recommend(this.projects, { ...this.answers, category: "" }); + note = ` Nothing in ${this.answers.category} matched, so here are the best fits in any area.`; + } + if (!results.length) { + this.say("I couldn't find an open project that uses those skills. Here are the most active projects instead."); + results = recommend(this.projects, {}); + } else { + this.say(`Here ${results.length === 1 ? "is the project" : `are ${results.length} projects`} I'd suggest, most active first.${note}`); + } + for (const r of results) this.log.append(this.card(r)); + this.log.scrollTop = this.log.scrollHeight; + + const names = results.map((r) => r.project.project).join("; "); + this.controls.innerHTML = ` +
+
The volunteer form asks which projects interest you. These names are filled in for you.
`; + const link = this.controls.querySelector("a"); + link.textContent = "Volunteer for these projects"; + link.href = `${VOLUNTEER_URL}?projects=${encodeURIComponent(names)}`; + this.controls.querySelector(".again").onclick = () => { + this.log.innerHTML = ""; + this.answers = { have: [], want: [], category: "" }; + this.askSkills("have", "What skills do you already have?", () => + this.askSkills("want", "Anything you'd like to learn while you help?", () => this.askCategory())); + }; + } + + card({ project, matchedHave, matchedWant }) { + const div = document.createElement("div"); + div.className = "card"; + const title = document.createElement("h3"); + const a = document.createElement("a"); + a.href = project.page || project.link || "https://www.pubinv.org/projects/"; + a.target = "_blank"; + a.rel = "noopener"; + a.textContent = project.project; + title.append(a); + + const meta = document.createElement("div"); + meta.className = "meta"; + const badge = document.createElement("span"); + badge.className = "badge"; + badge.textContent = project.status || "Status unknown"; + meta.append(badge, [project.category, JOINABILITY[Math.round(project.joinability)], `difficulty ${project.difficulty}/10`].filter(Boolean).join(" · ")); + + const summary = document.createElement("div"); + summary.textContent = project.synopsis || project.summary; + + const why = document.createElement("div"); + why.className = "why"; + const reasons = []; + if (matchedHave.length) reasons.push(`you know ${joinNames(matchedHave)}`); + if (matchedWant.length) reasons.push(`you'd learn ${joinNames(matchedWant)}`); + if (project.seeking_volunteers === "yes") reasons.push("it's looking for volunteers now"); + why.textContent = reasons.length ? `Why: ${reasons.join("; ")}.` : "Why: it's one of the most active projects."; + + div.append(title, meta, summary, why); + return div; + } +} + +function joinNames(names) { + return names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0]; +} + +for (const host of document.querySelectorAll("#pubinv-project-finder, [data-pubinv-project-finder]")) { + new ProjectFinder(host, host.dataset.src || DEFAULT_DATA).start(); +} From 078598ab8752c8d9204a6b95bf8742a9fb9378c2 Mon Sep 17 00:00:00 2001 From: Hassan Ibrahim Date: Thu, 15 Oct 2026 19:02:08 -0400 Subject: [PATCH 2/4] Point the ChatGPT bot at the live project JSON --- ChatBot_Instructions.md | 40 ++++++++++++++------------------ chatbot/gpt/projects-action.yaml | 38 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 22 deletions(-) create mode 100644 chatbot/gpt/projects-action.yaml diff --git a/ChatBot_Instructions.md b/ChatBot_Instructions.md index fdf087e..06fb761 100644 --- a/ChatBot_Instructions.md +++ b/ChatBot_Instructions.md @@ -8,37 +8,33 @@ Public Invention Project Chatbot Refers site visitors to projects based on their skillsets and interests. Also provides information about various projects, their statuses, and priorities. -## Instructions - -You are a chatbot whose goal is to connect technologists and various subject matter experts with open source hardware projects that they might want to contribute or become involved in. -To do this you will use the Public Invention Projects PDF in your knowledge base - only these projects will be used. -Ask simple, clear questions to the user to help you identify which projects are the best fit and then give them a short list of 1-5 projects, listed according to skills and status. Include the title, synopsis and Github link that feel like a best match, and explain to the user why these projects have been chosen. +## Setup -The "Category" column means the General Means of Improving the World - use this category to look for projects in a certain domain or category. -For example "Project #40: For oil painting, a wheel for very thin lines" has category "Art", so should show up in a search for art related projects. +1. In the GPT editor, open **Configure → Actions → Create new action**. +2. Paste the contents of `chatbot/gpt/projects-action.yaml` as the schema. Authentication: **None**. +3. Remove the old Public Invention Projects PDF from **Knowledge**, so the GPT can only use the live list. +4. Replace the instructions with the section below. -The "Order" column means the project's priority for Public Invention within its category. Consider only the "Order" column for priority. -11=high priority, 1=low priority. -Recommend entries with higher priority applicable to the criteria that fits the request. -Important: Do not use "Synopsis" to determine priority. Use "Order" and "Skills" entries to determine priority. -For "Joinability",1=easy to join, 3=you may have to start it yourself -For "Difficulty", 1=easiest, 10=hardest +The action reads `data/projects.json` from this repository. A GitHub Action regenerates it every week from the project workbook and each project's GitHub activity, so the GPT never needs a new upload. -Discard the "Titles" "Moonrat", "Oxygen Concentrator", "PolyVent", "ADaM", and "Librecorder". +## Instructions -For "Status", sort according to the following order: +You are a chatbot whose goal is to connect technologists and various subject matter experts with open source hardware projects that they might want to contribute to or become involved in. -1 = "Highly Active" -2 = "Progress but Inactive" -3 = "Ready-to-start" +At the start of every conversation, call `getProjects`. Use only the projects it returns. Never invent, embellish or recommend a project that is not in that list. -Discard all entries with a "Preliminary" "Status". +Ask simple, clear questions to learn which skills the user has, what they would like to learn, and which area they care about. Then give a short list of 1-5 projects with the title, synopsis and link (use `page` when it is not empty, otherwise `link`), and explain why each one fits. -Important: Do not show entries that are "Ready-to-start" before "Highly Active" entries. +Choosing projects: -This is important: Only include projects that are listed on the PDF in your knowledge base. Don't embellish or create projects, only reference projects listed under "Public Invention". +- Only recommend projects where `eligible` is `true`. The list already removes Preliminary projects, the projects Public Invention does not recruit for through the chatbot, and projects not seeking volunteers. +- A project fits when its `skills` overlap the user's skills or learning goals. If `skills` is empty, use `readme_skills`, but only when at least two of them match. +- Sort by `status_rank` (lowest first), then by how many skills match, then by `priority` (highest first). Never show a project with a higher `status_rank` before one with a lower `status_rank`. +- `category` is the general means of improving the world (for example Art, Health, Energy). Use it when the user asks about an area. +- `joinability`: 1 = easy to join, 3 = you may have to start it yourself. `difficulty`: 1 = easiest, 10 = hardest. Mention both. +- If `seeking_volunteers` is `yes`, say the project is looking for volunteers now. -Important: Do not recommend anything from anywhere other than your knowledge base. +End with a link to the volunteer form: https://www.pubinv.org/volunteer/ ## Conversation Starters diff --git a/chatbot/gpt/projects-action.yaml b/chatbot/gpt/projects-action.yaml new file mode 100644 index 0000000..96a2b92 --- /dev/null +++ b/chatbot/gpt/projects-action.yaml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: Public Invention projects + description: The live project list the Public Invention chatbot recommends from, regenerated weekly from the project workbook and GitHub. + version: 1.0.0 +servers: + - url: https://raw.githubusercontent.com/PubInv/Project-ChatBot/main +paths: + /data/projects.json: + get: + operationId: getProjects + summary: Every Public Invention project with status, priority, skills and whether it may be recommended. + responses: + "200": + description: Array of projects. + content: + application/json: + schema: + type: array + items: + type: object + properties: + project: {type: string} + status: {type: string, description: "Highly Active, Progress but Inactive, Ready-to-start or Preliminary"} + status_rank: {type: integer, description: "1 is shown first"} + eligible: {type: boolean, description: "false means never recommend it"} + priority: {type: number, description: "The sheet's Order column; 11 is highest"} + synopsis: {type: string} + summary: {type: string} + link: {type: string, description: "GitHub repo or idea file"} + page: {type: string, description: "The project's page on pubinv.org, when it has one; prefer it over link"} + category: {type: string} + joinability: {type: number, description: "1 easy to join, 3 you may have to start it yourself"} + difficulty: {type: number, description: "1 easiest, 10 hardest"} + skills: {type: array, items: {type: string}, description: "Skills from the project sheet"} + readme_skills: {type: array, items: {type: string}, description: "Skills guessed from the README; weaker evidence"} + seeking_volunteers: {type: string, description: "yes, maybe, no or unknown"} + activity_status: {type: string, description: "GitHub activity: Active, Stale, Dormant or Unknown"} From 91367daaa70383e78afb65bf8f59bad644f07191 Mon Sep 17 00:00:00 2001 From: Hassan Ibrahim Date: Thu, 15 Oct 2026 19:02:08 -0400 Subject: [PATCH 3/4] Run the finder tests in the workflow and document the finder --- .github/workflows/refresh-project-workbook.yml | 7 +++++++ README.md | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/refresh-project-workbook.yml b/.github/workflows/refresh-project-workbook.yml index 968c541..4b6916a 100644 --- a/.github/workflows/refresh-project-workbook.yml +++ b/.github/workflows/refresh-project-workbook.yml @@ -40,6 +40,13 @@ jobs: - name: Run pipeline tests run: python3 -m unittest discover -s tests + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Run chatbot tests + run: node --test chatbot/recommend.test.mjs + - name: Commit workbook changes run: | git config user.name "github-actions[bot]" diff --git a/README.md b/README.md index 71cf647..8e2e6c0 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,16 @@ A weekly GitHub Action (`.github/workflows/refresh-project-workbook.yml`) refres Skills come from the workbook's Skills column, mapped through `data/skill_aliases.json`, plus keywords found in each project's README or idea file (`pipeline/skill_rules.py`). To run it locally: `GITHUB_TOKEN=$(gh auth token) python3 pipeline/build_json.py`, and `python3 -m unittest discover -s tests` for the tests. +## Project Finder (the chatbot) + +`chatbot/widget.js` is a guided chat for the website: it asks a visitor's skills, what they want to learn and which area they care about, then recommends projects from `data/projects.json`. Public Invention's rules decide the order, in code (`pipeline/ranking.py`, mirrored in `chatbot/recommend.js`): + +- never recommend Moonrat, Oxygen Concentrator, PolyVent, ADaM or Librecorder, Preliminary projects, or projects not seeking volunteers; +- Highly Active first, then Progress but Inactive, then Ready-to-start; +- within a status, more matching skills first, then the sheet's Order priority. + +`tests/ranking_cases.json` is shared by the Python and JavaScript tests, so both rank the same way. Embedding it on pubinv.org: `docs/wordpress-embed.md`. The ChatGPT version reads the same JSON through an Action: `ChatBot_Instructions.md`. How the volunteer form works today: `docs/volunteer-form.md`. + ## Resources The current [Public Invention Project Chatbot](https://chatgpt.com/g/g-67c0c512437c8191844d6afd21abfb21-public-invention-project-chatbot?model=gpt-4o) is live and can provide details about projects currently on the project spreadsheet included in this repository. From ede69a37583040333ab71c470d7d2505811b8b35 Mon Sep 17 00:00:00 2001 From: Hassan Ibrahim Date: Thu, 15 Oct 2026 19:02:08 -0400 Subject: [PATCH 4/4] Document the volunteer form and how to embed the finder --- docs/volunteer-form.md | 32 ++++++++++++++++++++++++++++++++ docs/wordpress-embed.md | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 docs/volunteer-form.md create mode 100644 docs/wordpress-embed.md diff --git a/docs/volunteer-form.md b/docs/volunteer-form.md new file mode 100644 index 0000000..c247b6f --- /dev/null +++ b/docs/volunteer-form.md @@ -0,0 +1,32 @@ +# The volunteer form on pubinv.org (issue #3) + +Checked on 2026-10-15 from the public page and an Editor login. Nothing on the site was changed. + +## What the form is + +- `https://www.pubinv.org/volunteer/` (page 76, built with Beaver Builder) holds **Gravity Forms form #4**. +- Fields: Name (first, last), Email, LinkedIn/GitHub/portfolio, How did you find Public Invention, Which projects are you interested in (free text, with a hint to "use our Project Chat bot"), What drives you to volunteer, How much time can you commit, Phone. +- It asks nothing about skills, so the form alone can't match a volunteer to a project. +- The same page and `/projects/` link to the ChatGPT custom GPT with a "Find Your Project with the Chatbot" button. + +## Where the data goes + +- Gravity Forms saves every submission as an **entry in the WordPress database** (Forms → Entries → form #4). It also sends any **notification emails** set on the form, and shows a **confirmation** (a message, a page, or a redirect). +- An Editor account has no Gravity Forms permissions, so the entries, notification recipients and confirmation can only be checked by an Administrator. Issue #3 mentions a Calendly step; it isn't on the page, so it's most likely the form's confirmation redirect. +- The site also runs Jetpack Forms (0 responses), which isn't used for volunteering. + +## Questions for an Administrator + +1. Forms → form #4 → Settings → **Notifications**: who receives each submission? +2. Settings → **Confirmations**: is it a redirect to Calendly? Which questions does Calendly ask that the form already asked (name, email, interests)? Those are the redundant ones to remove. +3. Does anyone review **Entries** today, or only the emails? + +## Recommendation + +- Keep Gravity Forms as the store: it already holds the entries and sends the emails, and nothing new needs hosting. +- Let the Project Finder fill in the "Which projects" field (see `docs/wordpress-embed.md`), so every submission names real projects instead of free text. +- Optionally add a "Skills" field to form #4 with the same skill list as `data/skills.json`, so Robert can see a volunteer's skills next to their picks. + +## The site's own project list + +WordPress also has a **Projects** post type: 35 project pages (33 published) with "Activity Statuses", "Means", "Project Categories" and "Skills Needed" taxonomies. It overlaps the workbook but isn't the same list. `data/site_pages.json` links the 12 workbook projects that have a page, so the finder and the GPT send people to the page on pubinv.org instead of raw GitHub. diff --git a/docs/wordpress-embed.md b/docs/wordpress-embed.md new file mode 100644 index 0000000..8a1ecf7 --- /dev/null +++ b/docs/wordpress-embed.md @@ -0,0 +1,41 @@ +# Embedding the Project Finder on pubinv.org (issue #5) + +The finder is `chatbot/widget.js`: a guided chat that asks for skills, learning goals and an area, then recommends projects from `data/projects.json` using Public Invention's rules (`pipeline/ranking.py`). It renders inside a shadow DOM, so the site theme can't break it and it can't break the theme. + +## 1. Add it to the Volunteer page + +An Editor can do this (it needs the `unfiltered_html` capability, which Editors have on this site). + +1. Pages → **Volunteer** → **Beaver Builder**. +2. Drag an **HTML** module under "Find Your Project with the Chatbot". +3. Paste: + +```html +
+ +``` + +4. Preview, then publish. + +The script loads from jsDelivr, and the project list loads from `raw.githubusercontent.com`. The page sends no Content-Security-Policy, and FlyingPress isn't delaying scripts there, so nothing else needs changing. jsDelivr caches `@main` for up to 12 hours. After changing `widget.js`, purge it at `https://purge.jsdelivr.net/gh/PubInv/Project-ChatBot@main/chatbot/widget.js` (and the same for `recommend.js`). The data itself refreshes within 5 minutes of the weekly Action. + +## 2. Pre-fill the form with the chosen projects + +The finder's "Volunteer for these projects" button opens `/volunteer/?projects=`. For Gravity Forms to fill the field from that, an Administrator: + +1. Forms → form #4 → the "Which projects at Public Invention are you interested in?" field → **Advanced**. +2. Tick **Allow field to be populated dynamically** and set the parameter name to `projects`. +3. Save the form. + +Until then the link still opens the form; the field just starts empty. + +## 3. Optional: replace the ChatGPT button + +The button currently opens the custom GPT. After the finder is on the page, point the button at `#pubinv-project-finder` instead, or keep both. To update the GPT itself so it reads the same live data, follow the Setup section of `ChatBot_Instructions.md`. + +## Try it locally + +```bash +python3 -m http.server 8000 # from the repo root +# open http://localhost:8000/chatbot/ +```