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/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/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.
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"}
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 @@
+
+
+
`;
+ 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();
+}
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/
+```