diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index a893f23..0000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..fc33349
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,16 @@
+## Description
+
+
+
+## Related Issues
+
+
+
+## Checklist
+
+- [ ] I have tested the changes in the Label Studio Labeling Config.
+- [ ] I have added the `plugin.js` file.
+- [ ] I have added the `view.xml` file.
+- [ ] My code follows the guidelines stated in the README.md.
+
+
diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml
new file mode 100644
index 0000000..a122825
--- /dev/null
+++ b/.github/workflows/validate-plugins.yml
@@ -0,0 +1,37 @@
+name: Validate Plugins
+
+on:
+ push:
+ branches: ["**"]
+ pull_request:
+ branches: ["**"]
+
+jobs:
+ validate-structure:
+ name: Validate File Structure
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Run Folder Structure Check
+ run: node validate-structure.mjs
+ linting:
+ name: Linting
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Biome
+ uses: biomejs/setup-biome@v2
+ with:
+ version: latest
+
+ - name: Run Biome
+ run: biome ci .
diff --git a/README.md b/README.md
index cf60134..87c60f2 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,79 @@
-# label-studio-custom-scripts
\ No newline at end of file
+
+
+# Label Studio Plugins
+
+Welcome to **Label Studio Plugins**! This repository contains `plugins` designed to extend the functionality of
+[Label Studio](https://labelstud.io), a powerful data labeling tool. These plugins can be used to automate workflows,
+integrate with external tools, and customize the labeling process according to your needs.
+
+Whether you're building custom data processors, integrations, or UI components, you'll find the necessary resources and
+examples in this repo to get started.
+
+> **Note**: Plugins are an `Enterprise` feature and require a [Label Studio Enterprise](https://humansignal.com/pricing/) subscription to use.
+
+## Official Documentation
+
+For detailed documentation and guides on how to use and extend Label Studio with plugins, visit the official
+[Label Studio Plugins Documentation](https://docs.humansignal.com/guide/plugins).
+
+## File Structure
+
+This repository follows a clear folder structure to organize the various plugins and configuration files:
+
+```bash
+label-studio-plugins/
+├── src/
+│ ├── plugin1/
+│ │ ├── data.{json|mp3|mp4}
+│ │ ├── plugin.js
+│ │ └── view.xml
+│ ├── plugin2/
+│ │ ├── data.{json|mp3|mp4}
+│ │ ├── plugin.js
+│ │ └── view.xml
+│ └── ...
+└── manifest.json
+```
+
+- **`/plugin1`**: Contains all the files to document a plugin.
+ - Each plugin has `plugin.js`, `view.xml`, and `data.json` files that define the logic, UI, and data of the plugin.
+- **`/plugin.js`**: Contains the actual `javascript` plugin file that can be embedded in the Label Studio code editor.
+- **`/view.xml`**: Stores an example of a `` that will work along the plugin.
+- **`/data.{json|mp3|mp4}`**: Stores an example of the data that can be used along with the plugin.
+- **`manifest.json`**: This file lists the plugins, their metadata (title, description, etc.), and their paths for easy integration with Label Studio.
+
+## Usage
+
+After your plugin gets merged you will be able to find it in your project's **Labeling Interface**
+
+
+
+## Contributing
+
+We welcome contributions! Whether it's bug fixes or new plugins, feel free to open a pull request. Here's how you can get started:
+
+1. **Create a new branch** for your feature or bugfix.
+2. **Make your changes** and ensure that they adhere to the project's file structure and guidelines. You need to create a folder with the name using underscores (`path`) of your plugin and add a `view.xml` and a `plugin.js` file minimum.
+3. **Register the plugin** in the `manifest.json` adding the following information:
+ ```json
+ [
+ {
+ "title": "Your plugin title",
+ "description": "Your plugin description",
+ "path": "exact-name-of-the-plugin-folder", // `plugin1` as per the File Structure example
+ "private": false // whether you want to hide it in the "Insert Plugin" dropdown in the Configurator code tab
+ }
+ ]
+ ```
+4. **Test your changes** to ensure everything works as expected.
+5. **Submit a pull request** explaining the changes you made.
+
+Please make sure that your contributions follow the existing code style and structure.
+
+## License
+
+This software is licensed under the [Apache 2.0 LICENSE](/LICENSE) © [Heartex](https://www.heartex.com/). 2020-2025
+
+---
+
+If you have any questions or need assistance, feel free to reach out through issues or discussions. We look forward to your contributions!
diff --git a/bulk_labeling/html.txt b/bulk_labeling/html.txt
deleted file mode 100644
index 6eb45dd..0000000
--- a/bulk_labeling/html.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/bulk_labeling/js.txt b/bulk_labeling/js.txt
deleted file mode 100644
index 3164174..0000000
--- a/bulk_labeling/js.txt
+++ /dev/null
@@ -1,18 +0,0 @@
-LSI.on('entityCreate', region => {
- if (window.BULK_REGIONS) return;
- window.BULK_REGIONS = true;
- setTimeout(() => window.BULK_REGIONS = false, 1000);
- console.log('matches', region.object._value.matchAll(region.text));
- setTimeout(() => {
- region.object._value.matchAll(new RegExp(region.text, "gi")).forEach(m => {
- if (m.index === region.startOffset) return;
- Htx.annotationStore.selected.createResult(
- { text: region.text, start: "/span[1]/text()[1]", startOffset: m.index, end: "/span[1]/text()[1]", endOffset: m.index + region.text.length },
- { labels: [...region.labeling.value.labels] },
- region.labeling.from_name,
- region.object,
- )
- })
- Htx.annotationStore.selected.updateObjects()
- }, 100);
-});
diff --git a/count_words_in_textarea/.DS_Store b/count_words_in_textarea/.DS_Store
deleted file mode 100644
index 5008ddf..0000000
Binary files a/count_words_in_textarea/.DS_Store and /dev/null differ
diff --git a/count_words_in_textarea/html.txt b/count_words_in_textarea/html.txt
deleted file mode 100644
index 29dc91c..0000000
--- a/count_words_in_textarea/html.txt
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/count_words_in_textarea/js.txt b/count_words_in_textarea/js.txt
deleted file mode 100644
index 413c645..0000000
--- a/count_words_in_textarea/js.txt
+++ /dev/null
@@ -1,15 +0,0 @@
-let dismissed = false;
-
-LSI.on("beforeSaveAnnotation", (store, annotation) => {
- const textAreaResult = annotation.results.find(r => r.type === 'textarea' && r.from_name.name === 'textarea');
- if (textAreaResult) {
- words = textAreaResult.value.text[0]
- word_count = words.split(" ").length;
- if (word_count > 10) {
- Htx.showModal("Word count is " + word_count + ". Please reduce to 10 or less.");
- dismissed = true;
- return false;
- }
- }
- return true;
-});
\ No newline at end of file
diff --git a/docs/banner.png b/docs/banner.png
new file mode 100644
index 0000000..b8ee75b
Binary files /dev/null and b/docs/banner.png differ
diff --git a/docs/labeling-interface.png b/docs/labeling-interface.png
new file mode 100644
index 0000000..6c570e1
Binary files /dev/null and b/docs/labeling-interface.png differ
diff --git a/llm_backend/html.txt b/llm_backend/html.txt
deleted file mode 100644
index da58a64..0000000
--- a/llm_backend/html.txt
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/llm_backend/js.txt b/llm_backend/js.txt
deleted file mode 100644
index 69e254a..0000000
--- a/llm_backend/js.txt
+++ /dev/null
@@ -1,88 +0,0 @@
-window.LSI = LSI;
-
-const baseUrl = "MY_URL_ROOT";
-
-async function fetchML(prompt) {
- const params = {
- // TODO: Prompt should come from the annotator's input for `prompt` from the labeling
- "prompt": prompt,
- "llm_endpoint_name": "chatgpt",
- "redteam_categories": ["cat1"]
- };
-
- const searchParams = new URLSearchParams(params).toString();
- const url = `${baseUrl}?${searchParams}`;
- console.log("URL = ", url);
-
- const response = await fetch(url, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- // No auth needed because the API is currently open
- },
- });
-
- const data = await response.json();
-
-}
-
-async function runEval() {
- const promptTag = LSI.annotation.names.get('prompt')
- promptTag.submitChanges();
- const prompt = promptTag.result?.value.text.join("\n");
-
- if (!prompt) return;
-
- console.log("Input prompt:" + prompt);
- const llm_res = await fetchML(prompt);
- const results = [];
-
- const response = {
- from_name: "response",
- to_name: "placeholder",
- type: "textarea",
- value: { "text": [llm_res["LLM_response"]] },
- }
- results.push(response);
- console.log("Response:" + llm_res["LLM_response"]);
-
- const category = llm_res["Category"]?.category;
- if (category?.length) {
- const attack = {
- from_name: "category",
- to_name: "placeholder",
- type: "choices",
- value: { choices: category },
- }
- results.push(attack);
- console.log("Category:" + category);
- }
-
- const reasonText = llm_res["Type"]?.reason;
- if (reasonText) {
- const reason = {
- from_name: "reason",
- to_name: "placeholder",
- type: "textarea",
- value: { "text": [reasonText] },
- }
- results.push(reason);
- console.log("Reason:" + reason);
- }
-
- LSI.annotation.deserializeResults(results);
-}
-
-function setup() {
- const aBtn = document.querySelector('.analyzeButton');
- const button = document.createElement('button');
- button.textContent = 'Analyze'; // Set the button text
-
- // Attach an onclick event to the button
- button.onclick = runEval;
-
- // Insert the button into the div
- aBtn.replaceChildren(button);
-}
-
-setup();
\ No newline at end of file
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..2590fbb
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,68 @@
+[
+ {
+ "title": "Bulk labeling for text spans",
+ "description": "Assigns labels to all occurrences of the selected text at once",
+ "path": "bulk-labeling",
+ "private": false
+ },
+ {
+ "title": "Text area word count",
+ "description": "Validates the length of a text area based on its word count",
+ "path": "count-words-in-textarea",
+ "private": false
+ },
+ {
+ "title": "Connect to LLM backend",
+ "description": "Sends prompts to an LLM URL",
+ "path": "llm-backend",
+ "private": false
+ },
+ {
+ "title": "Markdown to HTML",
+ "description": "Converts markdown to HTML format",
+ "path": "markdown-to-html",
+ "private": false
+ },
+ {
+ "title": "Text span overlap validation",
+ "description": "Prevents overlap of text spans",
+ "path": "ner-text-span-overlap-validation",
+ "private": false
+ },
+ {
+ "title": "Spam and bot detection",
+ "description": "Pauses an annotator if bot behavior in detected",
+ "path": "pausing-annotator",
+ "private": false
+ },
+ {
+ "title": "Data visualization",
+ "description": "Display a Plotly chart to annotators",
+ "path": "plotly",
+ "private": false
+ },
+ {
+ "title": "Redact annotator PII",
+ "description": "Anonymize the annotator to reduce bias",
+ "path": "redact-pii",
+ "private": false
+ },
+ {
+ "title": "Dynamic image swap",
+ "description": "Changes active image based on conditions",
+ "path": "different-images-per-label",
+ "private": false
+ },
+ {
+ "title": "Spellcheck",
+ "description": "Validates misspelled text",
+ "path": "spellcheck",
+ "private": false
+ },
+ {
+ "title": "Validate JSON",
+ "description": "Checks that the introduced text is a valid JSON",
+ "path": "validate-json-in-textarea",
+ "private": false
+ }
+]
diff --git a/markdown_to_html/data.json b/markdown_to_html/data.json
deleted file mode 100644
index a20d21b..0000000
--- a/markdown_to_html/data.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
-"data": {
- "summary": "The user reported a 'Paper Jam' issue with the office printer. The LLM Agent provided troubleshooting steps that resolved the issue, avoiding the need for further maintenance.",
- "chat_transcript": "**User**: The printer in the office is not working. Can you help troubleshoot?\n\n**LLM Agent**: I can help with that. Is the printer showing any error codes or messages on the display?\n\n**User**: It’s showing 'Paper Jam' but I’ve checked and there’s no paper stuck in there.\n\n**LLM Agent**: Sometimes the sensors in the printer may still think there’s a jam after clearing it. Let’s try turning the printer off for a minute and turning it back on. That should reset the sensors.\n\n**User**: Okay, let me try that... It worked! The error message is gone now.\n\n**LLM Agent**: Great! If you encounter the issue again, let me know. I can also file a maintenance ticket if the problem persists.\n\n**User**: Will do, thanks for your help!"
- }
-}
diff --git a/markdown_to_html/js.txt b/markdown_to_html/js.txt
deleted file mode 100644
index b94518d..0000000
--- a/markdown_to_html/js.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-window.LSI = LSI;
-
-await LSI.import("https://unpkg.com/showdown/dist/showdown.min.js");
-
-let sumBlock = document.querySelector(".lsf-richtext");
-
-if (sumBlock) {
- var converter = new showdown.Converter(),
- html = converter.makeHtml(LSI.task.data.chat_transcript);
- sumBlock.innerHTML = html;
-}
diff --git a/ner_text-span-overlap_validation/data.json b/ner_text-span-overlap_validation/data.json
deleted file mode 100644
index f09fb58..0000000
--- a/ner_text-span-overlap_validation/data.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "data": {
- "text": "UK envoy said Trump left Iran nuclear deal to spite Obama: Report https://t.co/OucTNhHHyd https://t.co/jCMZ01ZYIr"
- }
-}
diff --git a/ner_text-span-overlap_validation/js.txt b/ner_text-span-overlap_validation/js.txt
deleted file mode 100644
index 8fde0ce..0000000
--- a/ner_text-span-overlap_validation/js.txt
+++ /dev/null
@@ -1,37 +0,0 @@
-LSI.on("beforeSaveAnnotation", (store, annotation) => {
- const existingEntities = Htx.annotationStore.selected.regions;
-
- const textRegions = existingEntities.filter(r =>
- r.type === 'richtextregion' &&
- typeof r.startOffset === 'number' &&
- typeof r.endOffset === 'number'
- );
-
- // console.log(textRegions); // Print the filtered result
- textRegions.sort((a, b) => a.startOffset - b.startOffset);
-
- let overlaps = [];
-
- // Check for overlaps
- for (let i = 0; i < textRegions.length - 1; i++) {
- const current = textRegions[i];
- const next = textRegions[i + 1];
- // console.log("This is current: ", current, "This is next: ", next);
-
- if (current.endOffset > next.startOffset) {
- // Collect overlapping regions
- const currentText = current.text || 'Unknown text';
- const nextText = next.text || 'Unknown text';
- overlaps.push(`"${currentText}" and "${nextText}"`);
- }
- }
-
- if (overlaps.length > 0) {
- // Show error with all overlapping text pairs
- const errorMessage = `Overlapping annotations are not allowed between the following text pairs: ${overlaps.join(', ')}. Please adjust your annotations to remove overlaps.`;
- Htx.showModal(errorMessage, 'error');
- return false; // Prevent saving the annotation
- }
-
- return true; // Allow saving the annotation
-});
\ No newline at end of file
diff --git a/pausing_annotator/pause.js b/pausing_annotator/pause.js
deleted file mode 100644
index 6dbc350..0000000
--- a/pausing_annotator/pause.js
+++ /dev/null
@@ -1,141 +0,0 @@
-/****** CONFIGURATION FOR PAUSING RULES ******/
-/**
- * `fields` describe per-field rules in a format
- * : [()]
- * `global` is for rules applied to the whole annotation
- */
-const RULES = {
- fields: {
- comment: [timesInARow(3)],
- sentiment: [tooSimilar()],
- },
- global: [tooFast()],
-}
-/**
- * Messages for users when they are paused.
- * Each message is a function with the same name as original rule and it receives an object with
- * `items` and `field`.
- */
-const MESSAGES = {
- timesInARow: ({ field }) => `Too many similar values for ${field}`,
- tooSimilar: ({ field }) => `Too similar values for ${field}`,
- tooFast: () => `Too fast annotations`,
-}
-
-
-
-/****** ALL AVAILABLE RULES ******/
-/**
- * They recieve params and return function which recieves `items` and optional `field`.
- * If condition is met it returns warning message. If not — returns `false`.
- */
-
-// check if values for the `field` in last `times` items are the same
-function timesInARow(times) {
- return (items, field) => {
- if (items.length < times) return false
- const last = String(items.at(-1).values[field])
- return items.slice(-times).every((item) => String(item.values[field]) === last)
- ? MESSAGES.timesInARow({ items, field })
- : false
- };
-}
-function tooSimilar(deviation = 0.1, max_count = 10) {
- return (items, field) => {
- if (items.length < max_count) return false
- const values = items.map((item) => item.values[field])
- const points = values.map((v) => values.indexOf(v))
- return calcDeviation(points) < deviation
- ? MESSAGES.tooSimilar({ items, field })
- : false
- };
-}
-function tooFast(minutes = 10, times = 20) {
- return (items) => {
- if (items.length < times) return false
- const last = items.at(-1)
- const first = items.at(-times)
- return last.created_at - first.created_at < minutes * 60
- ? MESSAGES.tooFast({ items })
- : false
- };
-}
-
-
-
-/****** INTERNAL CODE ******/
-const project = DM.project.id
-if (!DM.project) return;
-
-const key = ["__pause_stats", project].join("|")
-const fields = Object.keys(RULES.fields)
-// { sentiment: ["positive", ...], comment: undefined }
-const values = Object.fromEntries(fields.map(
- (field) => [field, DM.project.parsed_label_config[field]?.labels],
-))
-
-// simplified version of MSE with normalized x-axis
-function calcDeviation(data) {
- const n = data.length;
- // we normalize indices from -n/2 to n/2 so meanX is 0
- const mid = n / 2;
- const mean = data.reduce((a, b) => a + b) / n;
-
- const k = data.reduce((a, b, i) => a + (b - mean) * (i - mid), 0) / data.reduce((a, b, i) => a + (i - mid) ** 2, 0);
- const mse = data.reduce((a, b, i) => a + (b - (k * (i - mid) + mean)) ** 2, 0) / n;
-
- return Math.abs(mse);
-}
-
-LSI.on("submitAnnotation", (_store, ann) => {
- const results = ann.serializeAnnotation()
- // { sentiment: "positive", comment: "good" }
- const values = {}
- fields.forEach((field) => {
- const value = results.find((r) => r.from_name === field)?.value
- if (!value) return;
- if (value.choices) values[field] = value.choices.join("|")
- else if (value.text) values[field] = value.text
- })
- let stats = []
- try {
- stats = JSON.parse(localStorage.getItem(key)) ?? []
- } catch(e) {}
- stats.push({ values, created_at: Date.now() / 1000 })
-
- for (const rule of RULES.global) {
- const result = rule(stats)
- if (result) {
- localStorage.setItem(key, "[]");
- pause(result);
- return;
- }
- }
-
- for (const field of fields) {
- if (!values[field]) continue;
- for (const rule of RULES.fields[field]) {
- const result = rule(stats, field)
- if (result) {
- localStorage.setItem(key, "[]");
- pause(result);
- return;
- }
- }
- }
-
- localStorage.setItem(key, JSON.stringify(stats));
-});
-
-function pause(verbose_reason) {
- const body = {
- reason: "CUSTOM_SCRIPT",
- verbose_reason,
- }
- const options = {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- }
- fetch(`/api/projects/${project}/members/${Htx.user.id}/pauses`, options)
-}
diff --git a/plotly/data.json b/plotly/data.json
deleted file mode 100644
index b0a53d8..0000000
--- a/plotly/data.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "data":
- {
- "plotly":
- {
- "x":
- [
- 1,
- 2,
- 3,
- 4
- ],
- "y":
- [
- 10,
- 15,
- 13,
- 17
- ],
- "type": "scatter"
- }
- }
-}
\ No newline at end of file
diff --git a/plotly/js.txt b/plotly/js.txt
deleted file mode 100644
index 9805b4f..0000000
--- a/plotly/js.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-await LSI.import('https://cdn.plot.ly/plotly-2.26.0.min.js', 'sha384-xuh4dD2xC9BZ4qOrUrLt8psbgevXF2v+K+FrXxV4MlJHnWKgnaKoh74vd/6Ik8uF',);
-
-let data = LSI.task.data;
-if (window.Plotly && data) {
- Plotly.newPlot("plot", [data.plotly]);
-}
diff --git a/redact_pii/html.txt b/redact_pii/html.txt
deleted file mode 100644
index 402edab..0000000
--- a/redact_pii/html.txt
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/redact_pii/js.txt b/redact_pii/js.txt
deleted file mode 100644
index 77abc0f..0000000
--- a/redact_pii/js.txt
+++ /dev/null
@@ -1,57 +0,0 @@
-async function fetchUserInfo() {
- const whoamiUrl = "https://app.humansignal.com/api/current-user/whoami";
- try {
- const response = await fetch(whoamiUrl, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- // Include authorization if needed
- // 'Authorization': 'Bearer YOUR_ACCESS_TOKEN'
- }
- });
-
- if (!response.ok) {
- throw new Error(`Error: ${response.status} ${response.statusText}`);
- }
-
- const data = await response.json();
- return data;
-
- } catch (error) {
- console.error('Failed to fetch user information:', error);
- }
-}
-
-function displayEl(sel) {
- const els = document.querySelectorAll(sel);
- if (els) {
- els.forEach(function (el, idx) {
- el.style.display = "block";
- });
- }
-}
-
-async function replacePII() {
- const userInfo = await fetchUserInfo();
- const user = userInfo.username || 'Unknown';
- const role = userInfo.org_membership[0].role || 'Unknown';
-
- if (!user) {
- console.warn("Did not find a username and it was not 'Unknown'");
- return;
- }
-
- if (role == 'AD') {
- console.log("Role is admin; displaying PII");
- // If admin, remove the nulled Style tag
- const firstChild = document.getElementById('noPII').firstChild;
- if (firstChild.tagName === 'STYLE') {
- firstChild.remove();
- }
- }
-
-}
-
-(async () => {
- await replacePII();
-})();
diff --git a/show_different_images_based_on_label_selected/html.txt b/show_different_images_based_on_label_selected/html.txt
deleted file mode 100644
index 9881f3a..0000000
--- a/show_different_images_based_on_label_selected/html.txt
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/show_different_images_based_on_label_selected/js.txt b/show_different_images_based_on_label_selected/js.txt
deleted file mode 100644
index 38c9ddd..0000000
--- a/show_different_images_based_on_label_selected/js.txt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
-Objective:
-Display different example check images at the bottom of the layout
-depending on the class label selected
-*/
-
-const IMG_ID = 'img_uniq'
-// TODO: use your own keys and values here for label lookup and data objects to display
-const images = {
- 'Addressee': '/storage-data/uploaded/?filepath=upload/86849/f7bce1e6-addressee.jpg',
- 'Account number': '/storage-data/uploaded/?filepath=upload/86849/f3fe2182-account-routing-number.png',
- 'Routing number': '/storage-data/uploaded/?filepath=upload/86849/f3fe2182-account-routing-number.png',
- 'Signature': '/storage-data/uploaded/?filepath=upload/86849/6926d68d-sign.jpg',
- 'Amount': '/storage-data/uploaded/?filepath=upload/86849/d8bf4fac-amount.jpg',
- 'Watermark': '/storage-data/uploaded/?filepath=upload/86849/695ba9b9-watermark.png',
- 'Date': '/storage-data/uploaded/?filepath=upload/86849/afc3193a-date.png',
- 'Correction': '/storage-data/uploaded/?filepath=upload/86849/d2c0218b-correction.jpg',
-}
-
-
-function appendCheckImg() {
- let imageEl = window[IMG_ID]
- if (!imageEl) {
- imageEl = document.createElement('img');
- imageEl.id = IMG_ID
-
- const labelingInterface = document.querySelector('.lsf-main-view__annotation');
- if (labelingInterface) {
- labelingInterface.insertAdjacentElement('beforeend', imageEl);
- } else {
- console.error('Labeling interface element not found.');
- }
- }
-
- // `label` is an actual tag name from config
- const labels = LSI.annotation.names.get('label').children
- // hardcoded access to rendered labels,
- // if you will have more Labels in a future adjust the logic
- document.querySelectorAll('.lsf-label_clickable').forEach(
- (lbl, index) => lbl.addEventListener('click', () => {
- const src = images[labels[index].value]
- // if there are no images with this key image will just have an empty src
- imageEl.src = src
- })
- )
-}
-
-appendCheckImg();
diff --git a/spellcheck/data.json b/spellcheck/data.json
deleted file mode 100644
index 8f69e7c..0000000
--- a/spellcheck/data.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-"data": {
- "text": "This is a great 3D movie that dleivers everything almost right in your face."
- }
-}
diff --git a/spellcheck/html.txt b/spellcheck/html.txt
deleted file mode 100644
index 458c786..0000000
--- a/spellcheck/html.txt
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/spellcheck/js.txt b/spellcheck/js.txt
deleted file mode 100644
index 42f2872..0000000
--- a/spellcheck/js.txt
+++ /dev/null
@@ -1,41 +0,0 @@
-// Load the spelling check library
-await LSI.import('https://cdn.jsdelivr.net/npm/typo-js@1.1.0/typo.js');
-
-// Initialize the dictionary
-var dictionary = new Typo("en_US", false, false, { dictionaryPath: "https://cdn.jsdelivr.net/npm/typo-js@1.1.0/dictionaries" })
-
-LSI.on("beforeSaveAnnotation", async (store, ann) => {
- // Find all textareas with misspellings
- let misspelledAreas = ann.results.filter(
- r => r.type === 'textarea' && r.value.text.some(t => {
- let words = t.split(/\s+/);
- return words.some(word => !dictionary.check(word));
- })
- );
- console.log(misspelledAreas)
- if (misspelledAreas.length === 0) return true;
-
- // Collect all misspelled texts
- let misspelledTexts = misspelledAreas.map(area => {
- let misspelledTextsInArea = area.value.text
- .map(t => {
- const words = t.split(/\s+/);
- const misspelledWords = words.filter(word => !dictionary.check(word));
- if (misspelledWords.length > 0) {
- return `Misspelled words: ${misspelledWords.join(', ')}\n`;
- }
- return null;
- })
- .filter(text => text !== null);
-
- return misspelledTextsInArea.join('\n');
- }).join('\n\n');
-
- // Select the first region to see textarea
- if (!misspelledAreas[0].area.classification) ann.selectArea(misspelledAreas[0].area);
-
- // Show the modal with the misspelled words
- Htx.showModal(`\n\n${misspelledTexts}\n\nPlease correct them before submitting.`, "error");
-
- return false;
-});
diff --git a/src/bulk-labeling/data.json b/src/bulk-labeling/data.json
new file mode 100644
index 0000000..aad9068
--- /dev/null
+++ b/src/bulk-labeling/data.json
@@ -0,0 +1,10 @@
+{
+ "data": {
+ "text": [
+ "Opossums, commonly known as possums in North America, are marsupials found primarily in the Americas. The most well-known species is the Virginia opossum (Didelphis virginiana), which ranges from Central America and the eastern United States to southern Canada. These adaptable creatures are known for their ability to thrive in a variety of environments, including both rural and urban areas. Opossums are also found in South America, where different species inhabit a range of ecosystems, from tropical rainforests to temperate forests.",
+ "Opossums are highly adaptable in terms of habitat, often residing in woodlands, farmland, and even suburban backyards. They typically seek shelter in hollow trees, abandoned burrows, or any dark, enclosed space they can find. Opossums are nocturnal and omnivorous, with a diet that includes fruits, insects, small animals, and even carrion. Their opportunistic feeding habits contribute to their resilience and ability to live in close proximity to human settlements.",
+ "In terms of behavior, opossums are solitary and nomadic, often moving to different locations in search of food. They are known for their unique defense mechanism of 'playing dead' or 'playing possum' when threatened, which involves mimicking the appearance and smell of a sick or dead animal to deter predators. Opossums have relatively short lifespans, typically living only 2 to 4 years in the wild. Despite their short lives, they reproduce quickly, with females giving birth to large litters of up to 20 young, although not all offspring typically survive to maturity.",
+ "In popular culture, opossums often appear as symbols of resilience and survival due to their hardy nature and ability to adapt to various environments. They are sometimes depicted in a comical or misunderstood light, given their nocturnal habits and somewhat disheveled appearance. Despite this, they play a crucial role in the ecosystem by controlling insect and rodent populations and cleaning up carrion. Opossums have been featured in various forms of media, from cartoons and children's books to movies, often emphasizing their unique behaviors and survival strategies."
+ ]
+ }
+}
diff --git a/src/bulk-labeling/plugin.js b/src/bulk-labeling/plugin.js
new file mode 100644
index 0000000..dd20dd0
--- /dev/null
+++ b/src/bulk-labeling/plugin.js
@@ -0,0 +1,38 @@
+/**
+ * Automatically creates all the text regions containing all instances of the selected text.
+ */
+
+// It will be triggered when a text selection happens
+LSI.on("entityCreate", (region) => {
+ if (window.BULK_REGIONS) return;
+
+ window.BULK_REGIONS = true;
+ setTimeout(() => {
+ window.BULK_REGIONS = false;
+ }, 1000);
+
+ setTimeout(() => {
+ // Find all the text regions matching the selection
+ const matches = Array.from(
+ region.object._value.matchAll(new RegExp(region.text, "gi")),
+ );
+ for (const m of matches) {
+ if (m.index === region.startOffset) continue;
+
+ // Include them in the results as new selections
+ Htx.annotationStore.selected.createResult(
+ {
+ text: region.text,
+ start: "/span[1]/text()[1]",
+ startOffset: m.index,
+ end: "/span[1]/text()[1]",
+ endOffset: m.index + region.text.length,
+ },
+ { labels: [...region.labeling.value.labels] },
+ region.labeling.from_name,
+ region.object,
+ );
+ }
+ Htx.annotationStore.selected.updateObjects();
+ }, 100);
+});
diff --git a/bulk_labeling/data.json b/src/bulk-labeling/view.xml
similarity index 88%
rename from bulk_labeling/data.json
rename to src/bulk-labeling/view.xml
index 13dff1b..e771ce7 100644
--- a/bulk_labeling/data.json
+++ b/src/bulk-labeling/view.xml
@@ -1,3 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/count-words-in-textarea/data.json b/src/count-words-in-textarea/data.json
new file mode 100644
index 0000000..12ad194
--- /dev/null
+++ b/src/count-words-in-textarea/data.json
@@ -0,0 +1,5 @@
+{
+ "data": {
+ "text": "The quick brown fox jumps over the lazy dog."
+ }
+}
diff --git a/count_words_in_textarea/demo.gif b/src/count-words-in-textarea/demo.gif
similarity index 100%
rename from count_words_in_textarea/demo.gif
rename to src/count-words-in-textarea/demo.gif
diff --git a/src/count-words-in-textarea/plugin.js b/src/count-words-in-textarea/plugin.js
new file mode 100644
index 0000000..b916b58
--- /dev/null
+++ b/src/count-words-in-textarea/plugin.js
@@ -0,0 +1,26 @@
+/**
+ * Validates the word count of the entered text to prevent submission if it exceeds a specified threshold
+ */
+
+let dismissed = false;
+
+LSI.on("beforeSaveAnnotation", (store, annotation) => {
+ const textAreaResult = annotation.results.find(
+ (r) => r.type === "textarea" && r.from_name.name === "textarea",
+ );
+
+ if (textAreaResult) {
+ words = textAreaResult.value.text[0];
+ word_count = words.split(" ").length;
+
+ if (word_count > 10) {
+ Htx.showModal(
+ `Word count is ${word_count}. Please reduce to 10 or less.`,
+ );
+ dismissed = true;
+ return false; // Block submission
+ }
+ }
+
+ return true; // Allow submission
+});
diff --git a/src/count-words-in-textarea/view.xml b/src/count-words-in-textarea/view.xml
new file mode 100644
index 0000000..e92aaaf
--- /dev/null
+++ b/src/count-words-in-textarea/view.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/src/different-images-per-label/data.json b/src/different-images-per-label/data.json
new file mode 100644
index 0000000..0d7ade5
--- /dev/null
+++ b/src/different-images-per-label/data.json
@@ -0,0 +1,5 @@
+{
+ "data": {
+ "image": "/static/plugins/src/different-images-per-label/img/demo-sample.png"
+ }
+}
diff --git a/show_different_images_based_on_label_selected/demo.mp4 b/src/different-images-per-label/demo.mp4
similarity index 100%
rename from show_different_images_based_on_label_selected/demo.mp4
rename to src/different-images-per-label/demo.mp4
diff --git a/src/different-images-per-label/img/demo-addressee.jpg b/src/different-images-per-label/img/demo-addressee.jpg
new file mode 100644
index 0000000..99bf846
Binary files /dev/null and b/src/different-images-per-label/img/demo-addressee.jpg differ
diff --git a/src/different-images-per-label/img/demo-amount.jpg b/src/different-images-per-label/img/demo-amount.jpg
new file mode 100644
index 0000000..6267a4b
Binary files /dev/null and b/src/different-images-per-label/img/demo-amount.jpg differ
diff --git a/src/different-images-per-label/img/demo-correction.jpg b/src/different-images-per-label/img/demo-correction.jpg
new file mode 100644
index 0000000..f1a7317
Binary files /dev/null and b/src/different-images-per-label/img/demo-correction.jpg differ
diff --git a/src/different-images-per-label/img/demo-date.png b/src/different-images-per-label/img/demo-date.png
new file mode 100644
index 0000000..902ab8f
Binary files /dev/null and b/src/different-images-per-label/img/demo-date.png differ
diff --git a/src/different-images-per-label/img/demo-routing-number.png b/src/different-images-per-label/img/demo-routing-number.png
new file mode 100644
index 0000000..fa52c66
Binary files /dev/null and b/src/different-images-per-label/img/demo-routing-number.png differ
diff --git a/src/different-images-per-label/img/demo-sample.png b/src/different-images-per-label/img/demo-sample.png
new file mode 100644
index 0000000..b735140
Binary files /dev/null and b/src/different-images-per-label/img/demo-sample.png differ
diff --git a/src/different-images-per-label/img/demo-sign.jpg b/src/different-images-per-label/img/demo-sign.jpg
new file mode 100644
index 0000000..099944a
Binary files /dev/null and b/src/different-images-per-label/img/demo-sign.jpg differ
diff --git a/src/different-images-per-label/img/demo-watermark.png b/src/different-images-per-label/img/demo-watermark.png
new file mode 100644
index 0000000..7c23698
Binary files /dev/null and b/src/different-images-per-label/img/demo-watermark.png differ
diff --git a/src/different-images-per-label/plugin.js b/src/different-images-per-label/plugin.js
new file mode 100644
index 0000000..a2f6d8d
--- /dev/null
+++ b/src/different-images-per-label/plugin.js
@@ -0,0 +1,50 @@
+/**
+ * Display different example check images at the bottom of the layout
+ * depending on the class label selected
+ */
+
+const IMG_ID = "img_uniq";
+// TODO: use your own keys and values here for label lookup and data objects to display
+const imagesRoot = "/static/plugins/src/different-images-per-label/img";
+const images = {
+ Addressee: `${imagesRoot}/demo-addressee.jpg`,
+ "Account number": `${imagesRoot}/demo-routing-number.png`,
+ "Routing number": `${imagesRoot}/demo-routing-number.png`,
+ Signature: `${imagesRoot}/demo-sign.jpg`,
+ Amount: `${imagesRoot}/demo-amount.jpg`,
+ Watermark: `${imagesRoot}/demo-watermark.png`,
+ Date: `${imagesRoot}/demo-date.png`,
+ Correction: `${imagesRoot}/demo-correction.jpg`,
+};
+
+function appendCheckImg() {
+ let imageEl = window[IMG_ID];
+ if (!imageEl) {
+ imageEl = document.createElement("img");
+ imageEl.id = IMG_ID;
+
+ const labelingInterface = document.querySelector(
+ ".lsf-main-view__annotation",
+ );
+ if (labelingInterface) {
+ labelingInterface.insertAdjacentElement("beforeend", imageEl);
+ } else {
+ console.error("Labeling interface element not found.");
+ }
+ }
+
+ // `label` is an actual tag name from config
+ const labels = LSI.annotation.names.get("label").children;
+
+ // If you will have more Labels in a future adjust the logic
+ document.querySelectorAll(".lsf-label_clickable").forEach((lbl, index) =>
+ lbl.addEventListener("click", () => {
+ const src = images[labels[index].value];
+
+ // if there are no images with this key image will just have an empty src
+ imageEl.src = src;
+ }),
+ );
+}
+
+appendCheckImg();
diff --git a/src/different-images-per-label/view.xml b/src/different-images-per-label/view.xml
new file mode 100644
index 0000000..146f36c
--- /dev/null
+++ b/src/different-images-per-label/view.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/llm-backend/data.json b/src/llm-backend/data.json
new file mode 100644
index 0000000..fc923aa
--- /dev/null
+++ b/src/llm-backend/data.json
@@ -0,0 +1,5 @@
+{
+ "data": {
+ "text": "LLM backend"
+ }
+}
diff --git a/llm_backend/demo.mp4 b/src/llm-backend/demo.mp4
similarity index 100%
rename from llm_backend/demo.mp4
rename to src/llm-backend/demo.mp4
diff --git a/src/llm-backend/plugin.js b/src/llm-backend/plugin.js
new file mode 100644
index 0000000..a2d0f1b
--- /dev/null
+++ b/src/llm-backend/plugin.js
@@ -0,0 +1,110 @@
+window.LSI = LSI;
+
+const baseUrl = "MY_URL_ROOT";
+
+/**
+ * Makes a request to the configured LLM sending the given prompt
+ */
+async function fetchLLM(prompt) {
+ const params = {
+ prompt,
+ llm_endpoint_name: "chatgpt",
+ redteam_categories: ["cat1"],
+ };
+
+ const searchParams = new URLSearchParams(params).toString();
+ const url = `${baseUrl}?${searchParams}`;
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ // No auth needed because the API is currently open
+ },
+ });
+
+ const data = await response.json();
+}
+
+/**
+ * Sends the introduced prompt to the LLM endpoint and attahces the given results to the annotation
+ */
+async function sendPrompt() {
+ const promptTag = LSI.annotation.names.get("prompt");
+ promptTag.submitChanges();
+ const prompt = promptTag.result?.value.text.join("\n");
+
+ if (!prompt) {
+ Htx.showModal("The prompt is empty", "error");
+ return false;
+ }
+
+ let response;
+
+ // console.log("Input prompt:" + prompt);
+ try {
+ response = await fetchLLM(prompt);
+ } catch (error) {
+ Htx.showModal(
+ `Error fetching the LLM endpoint "${baseUrl}": ${error.message}`,
+ "error",
+ );
+ return false;
+ }
+ const results = [];
+
+ const llmResponse = response.LLM_response;
+ if (llmResponse) {
+ const llmResult = {
+ from_name: "response",
+ to_name: "placeholder",
+ type: "textarea",
+ value: { text: [] },
+ };
+ results.push(llmResult);
+ }
+ // console.log("Response:" + llmResponse["LLM_response"]);
+
+ const category = response.Category?.category;
+ if (category?.length) {
+ const attackResult = {
+ from_name: "category",
+ to_name: "placeholder",
+ type: "choices",
+ value: { choices: category },
+ };
+ results.push(attackResult);
+ // console.log("Category:" + category);
+ }
+
+ const reasonText = response.Type?.reason;
+ if (reasonText) {
+ const reasonResult = {
+ from_name: "reason",
+ to_name: "placeholder",
+ type: "textarea",
+ value: { text: [reasonText] },
+ };
+ results.push(reasonResult);
+ // console.log("Reason:" + reason);
+ }
+
+ LSI.annotation.deserializeResults(results);
+}
+
+/**
+ * Sets up the onClick event of the template to trigger the LLM request
+ */
+function setup() {
+ const aBtn = document.querySelector(".analyzeButton");
+ const button = document.createElement("button");
+ button.textContent = "Analyze"; // Set the button text
+
+ // Attach an onclick event to the button
+ button.onclick = sendPrompt;
+
+ // Insert the button into the div
+ aBtn.replaceChildren(button);
+}
+
+setup();
diff --git a/src/llm-backend/view.xml b/src/llm-backend/view.xml
new file mode 100644
index 0000000..03d19db
--- /dev/null
+++ b/src/llm-backend/view.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/markdown-to-html/data.json b/src/markdown-to-html/data.json
new file mode 100644
index 0000000..35bc61d
--- /dev/null
+++ b/src/markdown-to-html/data.json
@@ -0,0 +1,5 @@
+{
+ "data": {
+ "chat_transcript": "**User**: The printer in the office is not working. Can you help troubleshoot?\n\n**LLM Agent**: I can help with that. Is the printer showing any error codes or messages on the display?\n\n**User**: It’s showing 'Paper Jam' but I’ve checked and there’s no paper stuck in there.\n\n**LLM Agent**: Sometimes the sensors in the printer may still think there’s a jam after clearing it. Let’s try turning the printer off for a minute and turning it back on. That should reset the sensors.\n\n**User**: Okay, let me try that... It worked! The error message is gone now.\n\n**LLM Agent**: Great! If you encounter the issue again, let me know. I can also file a maintenance ticket if the problem persists.\n\n**User**: Will do, thanks for your help!"
+ }
+}
diff --git a/src/markdown-to-html/plugin.js b/src/markdown-to-html/plugin.js
new file mode 100644
index 0000000..7d68c09
--- /dev/null
+++ b/src/markdown-to-html/plugin.js
@@ -0,0 +1,15 @@
+/**
+ * Converts the Markdown code given in a specific selector to HTML code.
+ */
+
+window.LSI = LSI;
+
+await LSI.import("https://unpkg.com/showdown/dist/showdown.min.js");
+
+const sumBlock = document.querySelector(".lsf-richtext");
+
+if (sumBlock) {
+ const converter = new showdown.Converter();
+ const html = converter.makeHtml(LSI.task.data.chat_transcript);
+ sumBlock.innerHTML = html;
+}
diff --git a/markdown_to_html/html.txt b/src/markdown-to-html/view.xml
similarity index 57%
rename from markdown_to_html/html.txt
rename to src/markdown-to-html/view.xml
index caa02f4..528f99a 100644
--- a/markdown_to_html/html.txt
+++ b/src/markdown-to-html/view.xml
@@ -30,10 +30,7 @@
-
-
-
@@ -43,3 +40,11 @@
+
+
diff --git a/src/ner-text-span-overlap-validation/data.json b/src/ner-text-span-overlap-validation/data.json
new file mode 100644
index 0000000..aa32f56
--- /dev/null
+++ b/src/ner-text-span-overlap-validation/data.json
@@ -0,0 +1,5 @@
+{
+ "data": {
+ "text": "UK envoy said Trump left Iran nuclear deal to spite Obama: Report https://t.co/OucTNhHHyd https://t.co/jCMZ01ZYIr"
+ }
+}
diff --git a/src/ner-text-span-overlap-validation/plugin.js b/src/ner-text-span-overlap-validation/plugin.js
new file mode 100644
index 0000000..0636316
--- /dev/null
+++ b/src/ner-text-span-overlap-validation/plugin.js
@@ -0,0 +1,42 @@
+/**
+ * Validates there are no NER text spans overlap before submitting an annotation
+ */
+
+LSI.on("beforeSaveAnnotation", (store, annotation) => {
+ const existingEntities = Htx.annotationStore.selected.regions;
+
+ const textRegions = existingEntities.filter(
+ (r) =>
+ r.type === "richtextregion" &&
+ typeof r.startOffset === "number" &&
+ typeof r.endOffset === "number",
+ );
+
+ // console.log(textRegions); // Print the filtered result
+ textRegions.sort((a, b) => a.startOffset - b.startOffset);
+
+ const overlaps = [];
+
+ // Check for overlaps
+ for (let i = 0; i < textRegions.length - 1; i++) {
+ const current = textRegions[i];
+ const next = textRegions[i + 1];
+ // console.log("This is current: ", current, "This is next: ", next);
+
+ if (current.endOffset > next.startOffset) {
+ // Collect overlapping regions
+ const currentText = current.text || "Unknown text";
+ const nextText = next.text || "Unknown text";
+ overlaps.push(`"${currentText}" and "${nextText}"`);
+ }
+ }
+
+ if (overlaps.length > 0) {
+ // Show error with all overlapping text pairs
+ const errorMessage = `Overlapping annotations are not allowed between the following text pairs: ${overlaps.join(", ")}. Please adjust your annotations to remove overlaps.`;
+ Htx.showModal(errorMessage, "error");
+ return false; // Prevent saving the annotation
+ }
+
+ return true; // Allow saving the annotation
+});
diff --git a/ner_text-span-overlap_validation/html.txt b/src/ner-text-span-overlap-validation/view.xml
similarity index 95%
rename from ner_text-span-overlap_validation/html.txt
rename to src/ner-text-span-overlap-validation/view.xml
index 9c355df..161d3a1 100644
--- a/ner_text-span-overlap_validation/html.txt
+++ b/src/ner-text-span-overlap-validation/view.xml
@@ -7,8 +7,12 @@
-
\ No newline at end of file
+}
+-->
diff --git a/src/pausing-annotator/data.json b/src/pausing-annotator/data.json
new file mode 100644
index 0000000..1cdbf1b
--- /dev/null
+++ b/src/pausing-annotator/data.json
@@ -0,0 +1,5 @@
+{
+ "data": {
+ "text": "This is a great movie!"
+ }
+}
diff --git a/src/pausing-annotator/plugin.js b/src/pausing-annotator/plugin.js
new file mode 100644
index 0000000..ef5e250
--- /dev/null
+++ b/src/pausing-annotator/plugin.js
@@ -0,0 +1,187 @@
+/**
+ * Defines a set of rules the annotator must follow so good quality can be guaranteed. If the rules are not follow,
+ * it will pause the annotator
+ */
+
+/**
+ * Rules configuration for pausing the annotation
+ *
+ * `fields` describe per-field rules in a format
+ * : [()]
+ * `global` is for rules applied to the whole annotation
+ */
+const RULES = {
+ fields: {
+ comment: [timesInARow(3)],
+ sentiment: [tooSimilar()],
+ },
+ global: [tooFast()],
+};
+
+/**
+ * Messages for users when they are paused.
+ *
+ * Each message is a function with the same name as original rule and it receives an object with
+ * `items` and `field`.
+ */
+const MESSAGES = {
+ timesInARow: ({ field }) => `Too many similar values for ${field}`,
+ tooSimilar: ({ field }) => `Too similar values for ${field}`,
+ tooFast: () => "Too fast annotations",
+};
+
+/**
+ * All Available rules are below.
+ *
+ * They recieve params and return function which recieves `items` and optional `field`.
+ * If condition is met it returns warning message. If not — returns `false`.
+ */
+
+/**
+ * Validates if values for the `field` in last `times` items are the same
+ */
+function timesInARow(times) {
+ return (items, field) => {
+ if (items.length < times) return false;
+ const last = String(items.at(-1).values[field]);
+ return items
+ .slice(-times)
+ .every((item) => String(item.values[field]) === last)
+ ? MESSAGES.timesInARow({ items, field })
+ : false;
+ };
+}
+
+/**
+ * Validates if the annotations are too similar (`deviation`) with the given frequency (`max_count`)
+ */
+function tooSimilar(deviation = 0.1, max_count = 10) {
+ return (items, field) => {
+ if (items.length < max_count) return false;
+ const values = items.map((item) => item.values[field]);
+ const points = values.map((v) => values.indexOf(v));
+ return calcDeviation(points) < deviation
+ ? MESSAGES.tooSimilar({ items, field })
+ : false;
+ };
+}
+
+/**
+ * Validates the annotations are less than `times` in the given time window (`minutes`)
+ */
+function tooFast(minutes = 10, times = 20) {
+ return (items) => {
+ if (items.length < times) return false;
+ const last = items.at(-1);
+ const first = items.at(-times);
+ return last.created_at - first.created_at < minutes * 60
+ ? MESSAGES.tooFast({ items })
+ : false;
+ };
+}
+
+/**
+ * Internal code for calculating the deviation and provide faster accessors
+ */
+const project = DM.project?.id;
+if (!project) throw new Error("Project is not initialized");
+
+const key = ["__pause_stats", project].join("|");
+const fields = Object.keys(RULES.fields);
+// { sentiment: ["positive", ...], comment: undefined }
+const values = Object.fromEntries(
+ fields.map((field) => [field, DM.project.parsed_label_config[field]?.labels]),
+);
+
+// simplified version of MSE with normalized x-axis
+function calcDeviation(data) {
+ const n = data.length;
+ // we normalize indices from -n/2 to n/2 so meanX is 0
+ const mid = n / 2;
+ const mean = data.reduce((a, b) => a + b) / n;
+
+ const k =
+ data.reduce((a, b, i) => a + (b - mean) * (i - mid), 0) /
+ data.reduce((a, b, i) => a + (i - mid) ** 2, 0);
+ const mse =
+ data.reduce((a, b, i) => a + (b - (k * (i - mid) + mean)) ** 2, 0) / n;
+
+ return Math.abs(mse);
+}
+
+// When triggering the submission of the annotation, it will check the annotators are following the predefined `RULES`
+// and they will be paused otherwise
+LSI.on("submitAnnotation", async (_store, annotation) => {
+ const results = annotation.serializeAnnotation();
+ // { sentiment: "positive", comment: "good" }
+ const values = {};
+ for (const field of fields) {
+ const value = results.find((r) => r.from_name === field)?.value;
+ if (!value) return;
+ if (value.choices) values[field] = value.choices.join("|");
+ else if (value.text) values[field] = value.text;
+ }
+ let stats = [];
+ try {
+ stats = JSON.parse(localStorage.getItem(key)) ?? [];
+ } catch (e) {
+ // Ignore parse errors
+ }
+ stats.push({ values, created_at: Date.now() / 1000 });
+
+ for (const rule of RULES.global) {
+ const result = rule(stats);
+ if (result) {
+ localStorage.setItem(key, "[]");
+ await pause(result);
+ return;
+ }
+ }
+
+ for (const field of fields) {
+ if (!values[field]) continue;
+ for (const rule of RULES.fields[field]) {
+ const result = rule(stats, field);
+
+ if (result) {
+ localStorage.setItem(key, "[]");
+
+ try {
+ await pause(result);
+ } catch (error) {
+ Htx.showModal(error.message, "error");
+ }
+ return;
+ }
+ }
+ }
+
+ localStorage.setItem(key, JSON.stringify(stats));
+});
+
+/**
+ * Sends a request to the API to pause an annotator
+ */
+async function pause(verbose_reason) {
+ const body = {
+ reason: "PLUGIN",
+ verbose_reason,
+ };
+ const options = {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ };
+ const response = await fetch(
+ `/api/projects/${project}/members/${Htx.user.id}/pauses`,
+ options,
+ );
+ if (!response.ok) {
+ throw new Error(
+ `Error pausing the annotator: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const data = await response.json();
+ return data;
+}
diff --git a/src/pausing-annotator/view.xml b/src/pausing-annotator/view.xml
new file mode 100644
index 0000000..4207cb8
--- /dev/null
+++ b/src/pausing-annotator/view.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plotly/data.json b/src/plotly/data.json
new file mode 100644
index 0000000..bfa2811
--- /dev/null
+++ b/src/plotly/data.json
@@ -0,0 +1,9 @@
+{
+ "data": {
+ "plotly": {
+ "x": [1, 2, 3, 4],
+ "y": [10, 15, 13, 17],
+ "type": "scatter"
+ }
+ }
+}
diff --git a/src/plotly/plugin.js b/src/plotly/plugin.js
new file mode 100644
index 0000000..31d0e68
--- /dev/null
+++ b/src/plotly/plugin.js
@@ -0,0 +1,19 @@
+/**
+ * Displays a Plotly line chart from the task data
+ */
+
+await LSI.import(
+ "https://cdn.plot.ly/plotly-2.26.0.min.js",
+ "sha384-xuh4dD2xC9BZ4qOrUrLt8psbgevXF2v+K+FrXxV4MlJHnWKgnaKoh74vd/6Ik8uF",
+);
+
+const data = LSI.task.data;
+if (!window.Plotly || !data) {
+ Htx.showModal("Plotly data not found in task", "error");
+}
+
+try {
+ Plotly.newPlot("plot", [data.plotly]);
+} catch (error) {
+ Htx.showModal(`Error loading Plotly: ${error.message}`, "error");
+}
diff --git a/plotly/html.txt b/src/plotly/view.xml
similarity index 64%
rename from plotly/html.txt
rename to src/plotly/view.xml
index a6f4de9..0d3363a 100644
--- a/plotly/html.txt
+++ b/src/plotly/view.xml
@@ -7,3 +7,15 @@
+
+
diff --git a/src/redact-pii/data.json b/src/redact-pii/data.json
new file mode 100644
index 0000000..5f8d869
--- /dev/null
+++ b/src/redact-pii/data.json
@@ -0,0 +1,5 @@
+{
+ "data": {
+ "text": "This is a great 3D movie that delivers everything almost right in your face."
+ }
+}
diff --git a/redact_pii/demo.mp4 b/src/redact-pii/demo.mp4
similarity index 100%
rename from redact_pii/demo.mp4
rename to src/redact-pii/demo.mp4
diff --git a/src/redact-pii/plugin.js b/src/redact-pii/plugin.js
new file mode 100644
index 0000000..f13f98b
--- /dev/null
+++ b/src/redact-pii/plugin.js
@@ -0,0 +1,72 @@
+/*
+ Hide annotator personal information (PII) if the logged user is not an Admin
+*/
+
+/**
+ * Fetch currently logged user via the HumanSignal API
+ */
+async function fetchUserInfo() {
+ const whoamiUrl = "https://app.humansignal.com/api/current-user/whoami";
+
+ const response = await fetch(whoamiUrl, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ // No Auth `credentials` needed for `same-origin` given Session-Based Authentication is used in the API
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Error: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ return data;
+}
+
+/**
+ * Give visibility to the given selector
+ */
+function displayEl(sel) {
+ const els = document.querySelectorAll(sel);
+ if (els) {
+ els.forEach((el, idx) => {
+ el.style.display = "block";
+ });
+ }
+}
+
+/**
+ * If the logged in user is an Admin, remove the styling added to the view that hides
+ * the annotator identity
+ */
+async function hidePII() {
+ let user;
+ let role;
+ try {
+ const userInfo = await fetchUserInfo();
+ user = userInfo.username || "Unknown";
+ role = userInfo.org_membership[0].role || "Unknown";
+ } catch (error) {
+ Htx.showModal(`Error fetching user information: ${error.message}`);
+ }
+
+ if (!user) {
+ console.warn("Did not find a username and it was not 'Unknown'");
+ return;
+ }
+
+ if (role === "AD") {
+ // console.log("Role is admin; displaying PII");
+
+ // If admin, remove the nulled Style tag
+ const firstChild = document.getElementById("noPII").firstChild;
+ if (firstChild.tagName === "STYLE") {
+ firstChild.remove();
+ }
+ }
+}
+
+(async () => {
+ await hidePII();
+})();
diff --git a/src/redact-pii/view.xml b/src/redact-pii/view.xml
new file mode 100644
index 0000000..5a2bd26
--- /dev/null
+++ b/src/redact-pii/view.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/spellcheck/data.json b/src/spellcheck/data.json
new file mode 100644
index 0000000..296e31d
--- /dev/null
+++ b/src/spellcheck/data.json
@@ -0,0 +1,5 @@
+{
+ "data": {
+ "text": "This is a great 3D movie that dleivers everything almost right in your face."
+ }
+}
diff --git a/src/spellcheck/plugin.js b/src/spellcheck/plugin.js
new file mode 100644
index 0000000..9c8844f
--- /dev/null
+++ b/src/spellcheck/plugin.js
@@ -0,0 +1,52 @@
+/**
+ * Detect misspelled words in the template textareas
+ */
+
+// Load the spelling check library
+await LSI.import("https://cdn.jsdelivr.net/npm/typo-js@1.1.0/typo.js");
+
+// Initialize the dictionary
+const dictionary = new Typo("en_US", false, false, {
+ dictionaryPath: "https://cdn.jsdelivr.net/npm/typo-js@1.1.0/dictionaries",
+});
+const WORD_REGEX = /\w+/g;
+
+LSI.on("beforeSaveAnnotation", async (store, annotation) => {
+ // Find all textareas with misspellings
+ const misspelledAreas = annotation.results.filter(
+ (r) =>
+ r.type === "textarea" &&
+ r.value.text.some((t) => {
+ const words = t.match(WORD_REGEX) || []; // Extract words
+ return words.some((word) => !dictionary.check(word));
+ }),
+ );
+
+ // If no misspelled textareas, continue with the save process
+ if (misspelledAreas.length === 0) return true;
+
+ // Collect all misspelled words
+ const misspelledWords = [
+ ...new Set(
+ misspelledAreas.flatMap((area) =>
+ area.value.text.flatMap((t) =>
+ (t.match(WORD_REGEX) || []).filter((word) => !dictionary.check(word)),
+ ),
+ ),
+ ),
+ ];
+ // console.log('words:', misspelledWords) // print misspelled words
+
+ // Select the first region to see textarea
+ if (!misspelledAreas[0].area.classification)
+ annotation.selectArea(misspelledAreas[0].area);
+
+ // Show the modal with the misspelled words
+ Htx.showModal(
+ `Misspelled words: ${misspelledWords.join(", ")}. Please correct them before submitting.`,
+ "error",
+ );
+
+ // Block the saving process
+ return false;
+});
diff --git a/src/spellcheck/view.xml b/src/spellcheck/view.xml
new file mode 100644
index 0000000..108ab71
--- /dev/null
+++ b/src/spellcheck/view.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/validate-json-in-textarea/data.json b/src/validate-json-in-textarea/data.json
new file mode 100644
index 0000000..e099f87
--- /dev/null
+++ b/src/validate-json-in-textarea/data.json
@@ -0,0 +1,5 @@
+{
+ "data": {
+ "text": "The quick brown fox jumps over the lazy dog"
+ }
+}
diff --git a/src/validate-json-in-textarea/plugin.js b/src/validate-json-in-textarea/plugin.js
new file mode 100644
index 0000000..8530a17
--- /dev/null
+++ b/src/validate-json-in-textarea/plugin.js
@@ -0,0 +1,21 @@
+/**
+ * Validate the JSON data introduced in the textarea is valid.
+ */
+
+LSI.on("beforeSaveAnnotation", (store, annotation) => {
+ const textAreaResult = annotation.results.find(
+ (r) => r.type === "textarea" && r.from_name.name === "textarea",
+ );
+ if (textAreaResult) {
+ try {
+ JSON.parse(textAreaResult.value.text[0]);
+ } catch (e) {
+ Htx.showModal(
+ "Invalid JSON format. Please correct the JSON and try again.",
+ "error",
+ );
+ return false;
+ }
+ }
+ return true;
+});
diff --git a/validate_json_in_textarea/html.txt b/src/validate-json-in-textarea/view.xml
similarity index 81%
rename from validate_json_in_textarea/html.txt
rename to src/validate-json-in-textarea/view.xml
index 75d5a2f..b38890a 100644
--- a/validate_json_in_textarea/html.txt
+++ b/src/validate-json-in-textarea/view.xml
@@ -11,6 +11,12 @@
-
+
+
+
diff --git a/validate-structure.mjs b/validate-structure.mjs
new file mode 100644
index 0000000..f7d92ef
--- /dev/null
+++ b/validate-structure.mjs
@@ -0,0 +1,77 @@
+#!/usr/bin/env node
+
+import { existsSync, readFileSync, readdirSync } from "node:fs";
+import { join } from "node:path";
+
+const ROOT_DIR = process.cwd();
+const MANIFEST_FILE = join(ROOT_DIR, "manifest.json");
+const PLUGIN_FILENAME = "plugin.js";
+const VIEW_FILENAME = "view.xml";
+const DATA_FILENAME = "data.json";
+const PLUGINS_DIR = join(ROOT_DIR, "src");
+
+const validateStructure = () => {
+ if (!existsSync(MANIFEST_FILE)) {
+ console.error("Missing manifest.json in the root directory");
+ process.exit(1);
+ }
+
+ const manifest = JSON.parse(readFileSync(MANIFEST_FILE, "utf-8"));
+
+ if (!Array.isArray(manifest)) {
+ console.error("manifest.json must be an array");
+ process.exit(1);
+ }
+
+ if (!existsSync(PLUGINS_DIR)) {
+ console.error("Missing src directory");
+ process.exit(1);
+ }
+
+ const folders = readdirSync(PLUGINS_DIR, { withFileTypes: true })
+ .filter((dirent) => dirent.isDirectory())
+ .map((dirent) => dirent.name);
+
+ const errors = [];
+
+ for (const folder of folders) {
+ const pluginFile = join(PLUGINS_DIR, folder, PLUGIN_FILENAME);
+ const viewFile = join(PLUGINS_DIR, folder, VIEW_FILENAME);
+ const dataFile = join(PLUGINS_DIR, folder, DATA_FILENAME);
+
+ if (!existsSync(pluginFile)) {
+ errors.push(`Missing ${PLUGIN_FILENAME} in "${folder}"`);
+ }
+ if (!existsSync(viewFile)) {
+ errors.push(`Missing ${VIEW_FILENAME} in "${folder}"`);
+ }
+ if (!existsSync(dataFile)) {
+ errors.push(`Missing ${DATA_FILENAME} in "${folder}"`);
+ }
+
+ const manifestEntry = manifest.find((entry) => entry.path === folder);
+
+ if (!manifestEntry) {
+ errors.push(`Folder "${folder}" is missing in manifest.json`);
+ } else if (
+ !manifestEntry.title ||
+ !manifestEntry.description ||
+ typeof manifestEntry.private !== "boolean"
+ ) {
+ errors.push(
+ `Invalid manifest entry for "${folder}". It must have a title, description, private flag, and path.`,
+ );
+ }
+ }
+
+ if (errors.length > 0) {
+ for (const error of errors) {
+ console.error(error);
+ }
+ process.exit(1);
+ }
+
+ console.log("Folder structure is correct.");
+};
+
+validateStructure();
diff --git a/validate_json_in_textarea/data.json b/validate_json_in_textarea/data.json
deleted file mode 100644
index 3e30a61..0000000
--- a/validate_json_in_textarea/data.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "data": {
- "text": "The quick brown fox jumps over the lazy dog"
- }
-}
diff --git a/validate_json_in_textarea/js.txt b/validate_json_in_textarea/js.txt
deleted file mode 100644
index db8f9fc..0000000
--- a/validate_json_in_textarea/js.txt
+++ /dev/null
@@ -1,12 +0,0 @@
-LSI.on("beforeSaveAnnotation", (store, annotation) => {
- const textAreaResult = annotation.results.find(r => r.type === 'textarea' && r.from_name.name === 'textarea');
- if (textAreaResult) {
- try {
- JSON.parse(textAreaResult.value.text[0]);
- } catch (e) {
- Htx.showModal("Invalid JSON format. Please correct the JSON and try again.", "error");
- return false;
- }
- }
- return true;
-});