diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6dab223..fc33349 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,7 +9,7 @@ ## Checklist - [ ] I have tested the changes in the Label Studio Labeling Config. -- [ ] I have added the `script.js` file. +- [ ] 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/.github/workflows/validate-structure.yml b/.github/workflows/validate-structure.yml deleted file mode 100644 index 3660fcc..0000000 --- a/.github/workflows/validate-structure.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Validate Folder Structure - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - check-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 diff --git a/README.md b/README.md index de0b1b4..cc8097b 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,65 @@ -![Label Studio Custom Scripts](docs/banner.png) +![Label Studio Plugins](docs/banner.png) -# Label Studio Custom Scripts +# Label Studio Plugins -Welcome to **Label Studio Custom Scripts**! This repository contains `custom scripts` designed to extend the functionality of [Label Studio](https://labelstud.io), a powerful data labeling tool. These scripts can be used to automate workflows, integrate with external tools, and customize the labeling process according to your needs. +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. +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. ## Official Documentation -For detailed documentation and guides on how to use and extend Label Studio with custom scripts, visit the official [Label Studio Scripts Documentation](https://docs.humansignal.com/guide/scripts). +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 custom scripts and configuration files: +This repository follows a clear folder structure to organize the various plugins and configuration files: ```bash -label-studio-custom-scripts/ -├── custom-scripts/ -│ ├── script1/ +label-studio-plugins/ +├── src/ +│ ├── plugin1/ │ │ ├── data.{json|mp3|mp4} -│ │ ├── script.js +│ │ ├── plugin.js │ │ └── view.xml -│ ├── script2/ +│ ├── plugin2/ │ │ ├── data.{json|mp3|mp4} -│ │ ├── script.js +│ │ ├── plugin.js │ │ └── view.xml │ └── ... └── manifest.json ``` -- **`/script1`**: Contains all the files to document a custom script. - - Each script has a `script.js` and `view.xml` file that define the logic and UI of the script. -- **`/script.js`**: Contains the actual custom script `javascript` file that can be embedded in the Label Studio code editor. -- **`/view.xml`**: Stores an example of a `` that will work along the script. -- **`/data.{json|mp3|mp4}`**: Stores an example of the data that can be used along with the script. -- **`manifest.json`**: This file lists the scripts, their metadata (title, description, etc.), and their paths for easy integration with Label Studio. +- **`/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 script gets merged you will be able to find it in your project's **Labeling Interface** +After your plugin gets merged you will be able to find it in your project's **Labeling Interface** ![Labeling Interface](docs/labeling-interface.png) ## Contributing -We welcome contributions! Whether it's bug fixes or new scripts, feel free to open a pull request. Here's how you can get started: +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 script and add a `view.xml` and a `script.js` file minimum. -3. **Register the script** in the `manifest.json` adding the following information: +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 script title", - "description": "Your script description", - "path": "exact_name_of_the_script_folder", // `script1` as per the File Structure example - "private": false // whether you want to hide it in the "Insert Script" dropdown in the Configurator code tab + "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 } ] ``` diff --git a/custom-scripts/bulk-labeling/data.json b/custom-scripts/bulk-labeling/data.json deleted file mode 100644 index 13dff1b..0000000 --- a/custom-scripts/bulk-labeling/data.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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/custom-scripts/bulk-labeling/script.js b/custom-scripts/bulk-labeling/script.js deleted file mode 100644 index 6ffa23f..0000000 --- a/custom-scripts/bulk-labeling/script.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 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 - region.object._value.matchAll(new RegExp(region.text, "gi")).forEach(m => { - if (m.index === region.startOffset) return; - - // 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/custom-scripts/count-words-in-textarea/data.json b/custom-scripts/count-words-in-textarea/data.json deleted file mode 100644 index 6b80ccc..0000000 --- a/custom-scripts/count-words-in-textarea/data.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "data": { - "text": "The quick brown fox jumps over the lazy dog." - } -} diff --git a/custom-scripts/count-words-in-textarea/script.js b/custom-scripts/count-words-in-textarea/script.js deleted file mode 100644 index d757610..0000000 --- a/custom-scripts/count-words-in-textarea/script.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 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/custom-scripts/different-images-per-label/data.json b/custom-scripts/different-images-per-label/data.json deleted file mode 100644 index 9474baf..0000000 --- a/custom-scripts/different-images-per-label/data.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "data": { - "image": "/static/custom-scripts/custom-scripts/show_different_images_based_on_label_selected/img/demo-sample.png" - } -} diff --git a/custom-scripts/different-images-per-label/script.js b/custom-scripts/different-images-per-label/script.js deleted file mode 100644 index 1907842..0000000 --- a/custom-scripts/different-images-per-label/script.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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/custom-scripts/custom-scripts/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/custom-scripts/llm-backend/script.js b/custom-scripts/llm-backend/script.js deleted file mode 100644 index 09d0d41..0000000 --- a/custom-scripts/llm-backend/script.js +++ /dev/null @@ -1,103 +0,0 @@ -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 = llmResponse["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 = llmResponse["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 = llmResponse["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/custom-scripts/markdown-to-html/data.json b/custom-scripts/markdown-to-html/data.json deleted file mode 100644 index d342f55..0000000 --- a/custom-scripts/markdown-to-html/data.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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/custom-scripts/markdown-to-html/script.js b/custom-scripts/markdown-to-html/script.js deleted file mode 100644 index c8e86aa..0000000 --- a/custom-scripts/markdown-to-html/script.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 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"); - -let sumBlock = document.querySelector(".lsf-richtext"); - -if (sumBlock) { - var converter = new showdown.Converter(); - var html = converter.makeHtml(LSI.task.data.chat_transcript); - sumBlock.innerHTML = html; -} diff --git a/custom-scripts/ner-text-span-overlap-validation/data.json b/custom-scripts/ner-text-span-overlap-validation/data.json deleted file mode 100644 index f09fb58..0000000 --- a/custom-scripts/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/custom-scripts/ner-text-span-overlap-validation/script.js b/custom-scripts/ner-text-span-overlap-validation/script.js deleted file mode 100644 index 0233aaa..0000000 --- a/custom-scripts/ner-text-span-overlap-validation/script.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * 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); - - 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 -}); diff --git a/custom-scripts/pausing-annotator/data.json b/custom-scripts/pausing-annotator/data.json deleted file mode 100644 index a0e9809..0000000 --- a/custom-scripts/pausing-annotator/data.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "data": { - "text": "This is a great movie!" - } -} diff --git a/custom-scripts/pausing-annotator/script.js b/custom-scripts/pausing-annotator/script.js deleted file mode 100644 index 0200cac..0000000 --- a/custom-scripts/pausing-annotator/script.js +++ /dev/null @@ -1,177 +0,0 @@ -/** - * 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 (!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); -} - -// 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 = {} - 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, "[]"); - - try { - await pause(result); - } catch (error) { - Htx.showModal(error.message, 'error'); - } finally { - return false; - } - } - } - } - - localStorage.setItem(key, JSON.stringify(stats)); -}); - -/** - * Sends a request to the API to pause an annotator - */ -async function pause(verbose_reason) { - const body = { - reason: "CUSTOM_SCRIPT", - 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/custom-scripts/plotly/data.json b/custom-scripts/plotly/data.json deleted file mode 100644 index 5030a1d..0000000 --- a/custom-scripts/plotly/data.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data": { - "plotly": { - "x": [ 1, 2, 3, 4 ], - "y": [ 10, 15, 13, 17 ], - "type": "scatter" - } - } -} diff --git a/custom-scripts/plotly/script.js b/custom-scripts/plotly/script.js deleted file mode 100644 index e35dae9..0000000 --- a/custom-scripts/plotly/script.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * 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',); - -let 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/custom-scripts/redact-pii/data.json b/custom-scripts/redact-pii/data.json deleted file mode 100644 index a49c1b0..0000000 --- a/custom-scripts/redact-pii/data.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "data": {"text": "This is a great 3D movie that delivers everything almost right in your face."} -} diff --git a/custom-scripts/redact-pii/script.js b/custom-scripts/redact-pii/script.js deleted file mode 100644 index d5664bd..0000000 --- a/custom-scripts/redact-pii/script.js +++ /dev/null @@ -1,71 +0,0 @@ -/* - 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(function (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, 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/custom-scripts/spellcheck/data.json b/custom-scripts/spellcheck/data.json deleted file mode 100644 index 8f69e7c..0000000 --- a/custom-scripts/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/custom-scripts/spellcheck/script.js b/custom-scripts/spellcheck/script.js deleted file mode 100644 index 64ea2a5..0000000 --- a/custom-scripts/spellcheck/script.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 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 - let misspelledAreas = annotation.results.filter( - r => r.type === 'textarea' && r.value.text.some(t => { - let 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 - let 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/custom-scripts/validate-json-in-textarea/data.json b/custom-scripts/validate-json-in-textarea/data.json deleted file mode 100644 index 3e30a61..0000000 --- a/custom-scripts/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/custom-scripts/validate-json-in-textarea/script.js b/custom-scripts/validate-json-in-textarea/script.js deleted file mode 100644 index 57d65a2..0000000 --- a/custom-scripts/validate-json-in-textarea/script.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * 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/docs/banner.png b/docs/banner.png index de67a13..b8ee75b 100644 Binary files a/docs/banner.png and b/docs/banner.png differ diff --git a/docs/labeling-interface.png b/docs/labeling-interface.png index 4f997dd..6c570e1 100644 Binary files a/docs/labeling-interface.png and b/docs/labeling-interface.png differ diff --git a/manifest.json b/manifest.json index 0438d46..2590fbb 100644 --- a/manifest.json +++ b/manifest.json @@ -1,68 +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 - } + { + "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/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/custom-scripts/bulk-labeling/view.xml b/src/bulk-labeling/view.xml similarity index 100% rename from custom-scripts/bulk-labeling/view.xml rename to src/bulk-labeling/view.xml 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/custom-scripts/count-words-in-textarea/demo.gif b/src/count-words-in-textarea/demo.gif similarity index 100% rename from custom-scripts/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/custom-scripts/count-words-in-textarea/view.xml b/src/count-words-in-textarea/view.xml similarity index 100% rename from custom-scripts/count-words-in-textarea/view.xml rename to src/count-words-in-textarea/view.xml 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/custom-scripts/different-images-per-label/demo.mp4 b/src/different-images-per-label/demo.mp4 similarity index 100% rename from custom-scripts/different-images-per-label/demo.mp4 rename to src/different-images-per-label/demo.mp4 diff --git a/custom-scripts/different-images-per-label/img/demo-addressee.jpg b/src/different-images-per-label/img/demo-addressee.jpg similarity index 100% rename from custom-scripts/different-images-per-label/img/demo-addressee.jpg rename to src/different-images-per-label/img/demo-addressee.jpg diff --git a/custom-scripts/different-images-per-label/img/demo-amount.jpg b/src/different-images-per-label/img/demo-amount.jpg similarity index 100% rename from custom-scripts/different-images-per-label/img/demo-amount.jpg rename to src/different-images-per-label/img/demo-amount.jpg diff --git a/custom-scripts/different-images-per-label/img/demo-correction.jpg b/src/different-images-per-label/img/demo-correction.jpg similarity index 100% rename from custom-scripts/different-images-per-label/img/demo-correction.jpg rename to src/different-images-per-label/img/demo-correction.jpg diff --git a/custom-scripts/different-images-per-label/img/demo-date.png b/src/different-images-per-label/img/demo-date.png similarity index 100% rename from custom-scripts/different-images-per-label/img/demo-date.png rename to src/different-images-per-label/img/demo-date.png diff --git a/custom-scripts/different-images-per-label/img/demo-routing-number.png b/src/different-images-per-label/img/demo-routing-number.png similarity index 100% rename from custom-scripts/different-images-per-label/img/demo-routing-number.png rename to src/different-images-per-label/img/demo-routing-number.png diff --git a/custom-scripts/different-images-per-label/img/demo-sample.png b/src/different-images-per-label/img/demo-sample.png similarity index 100% rename from custom-scripts/different-images-per-label/img/demo-sample.png rename to src/different-images-per-label/img/demo-sample.png diff --git a/custom-scripts/different-images-per-label/img/demo-sign.jpg b/src/different-images-per-label/img/demo-sign.jpg similarity index 100% rename from custom-scripts/different-images-per-label/img/demo-sign.jpg rename to src/different-images-per-label/img/demo-sign.jpg diff --git a/custom-scripts/different-images-per-label/img/demo-watermark.png b/src/different-images-per-label/img/demo-watermark.png similarity index 100% rename from custom-scripts/different-images-per-label/img/demo-watermark.png rename to src/different-images-per-label/img/demo-watermark.png 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/custom-scripts/different-images-per-label/view.xml b/src/different-images-per-label/view.xml similarity index 89% rename from custom-scripts/different-images-per-label/view.xml rename to src/different-images-per-label/view.xml index d4112ab..146f36c 100644 --- a/custom-scripts/different-images-per-label/view.xml +++ b/src/different-images-per-label/view.xml @@ -22,7 +22,7 @@ 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/custom-scripts/llm-backend/demo.mp4 b/src/llm-backend/demo.mp4 similarity index 100% rename from custom-scripts/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/custom-scripts/llm-backend/view.xml b/src/llm-backend/view.xml similarity index 100% rename from custom-scripts/llm-backend/view.xml rename to src/llm-backend/view.xml 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/custom-scripts/markdown-to-html/view.xml b/src/markdown-to-html/view.xml similarity index 100% rename from custom-scripts/markdown-to-html/view.xml rename to src/markdown-to-html/view.xml 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/custom-scripts/ner-text-span-overlap-validation/view.xml b/src/ner-text-span-overlap-validation/view.xml similarity index 100% rename from custom-scripts/ner-text-span-overlap-validation/view.xml rename to src/ner-text-span-overlap-validation/view.xml 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/custom-scripts/pausing-annotator/view.xml b/src/pausing-annotator/view.xml similarity index 100% rename from custom-scripts/pausing-annotator/view.xml rename to src/pausing-annotator/view.xml 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/custom-scripts/plotly/view.xml b/src/plotly/view.xml similarity index 100% rename from custom-scripts/plotly/view.xml rename to src/plotly/view.xml 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/custom-scripts/redact-pii/demo.mp4 b/src/redact-pii/demo.mp4 similarity index 100% rename from custom-scripts/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/custom-scripts/redact-pii/view.xml b/src/redact-pii/view.xml similarity index 94% rename from custom-scripts/redact-pii/view.xml rename to src/redact-pii/view.xml index db51830..5a2bd26 100644 --- a/custom-scripts/redact-pii/view.xml +++ b/src/redact-pii/view.xml @@ -6,6 +6,7 @@ .lsf-annotation-button__date { display: none; } .lsf-comment-item__name { display: none; } .lsf-comment-item__date { display: none; } + .lsf-comment-item__userpic { display: none; } .lsf-history-item__name { display: none; } .lsf-history-item__date { display: none; } 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/custom-scripts/spellcheck/view.xml b/src/spellcheck/view.xml similarity index 100% rename from custom-scripts/spellcheck/view.xml rename to src/spellcheck/view.xml 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/custom-scripts/validate-json-in-textarea/view.xml b/src/validate-json-in-textarea/view.xml similarity index 100% rename from custom-scripts/validate-json-in-textarea/view.xml rename to src/validate-json-in-textarea/view.xml diff --git a/validate-structure.mjs b/validate-structure.mjs index 25c8a8e..f7d92ef 100644 --- a/validate-structure.mjs +++ b/validate-structure.mjs @@ -1,62 +1,77 @@ #!/usr/bin/env node -import { existsSync, readFileSync, readdirSync } from 'fs' -import { join } from 'path' +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 SCRIPT_FILENAME = 'script.js' -const VIEW_FILENAME = 'view.xml' -const CUSTOM_SCRIPTS_DIR = join(ROOT_DIR, 'custom-scripts') +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) - } + 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')) + 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 (!Array.isArray(manifest)) { + console.error("manifest.json must be an array"); + process.exit(1); + } - if (!existsSync(CUSTOM_SCRIPTS_DIR)) { - console.error('Missing custom-scripts directory') - process.exit(1) - } + if (!existsSync(PLUGINS_DIR)) { + console.error("Missing src directory"); + process.exit(1); + } - const folders = readdirSync(CUSTOM_SCRIPTS_DIR, { withFileTypes: true }) - .filter(dirent => dirent.isDirectory()) - .map(dirent => dirent.name) + const folders = readdirSync(PLUGINS_DIR, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name); - const errors = [] + const errors = []; - folders.forEach(folder => { - const scriptFile = join(CUSTOM_SCRIPTS_DIR, folder, SCRIPT_FILENAME) - const viewFile = join(CUSTOM_SCRIPTS_DIR, folder, 'view.xml') + 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(scriptFile)) errors.push(`Missing ${SCRIPT_FILENAME} in "${folder}"`) - if (!existsSync(viewFile)) errors.push(`Missing ${VIEW_FILENAME} in "${folder}"`) + 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) + 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 (!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) { - errors.forEach(error => console.error(error)) - process.exit(1) - } + if (errors.length > 0) { + for (const error of errors) { + console.error(error); + } + process.exit(1); + } - console.log('Folder structure is correct.') -} + console.log("Folder structure is correct."); +}; -validateStructure() +validateStructure();