diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b6709fb..a0eb58d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "datapoint", - "version": "0.1.4", + "version": "0.2.0", "description": "Get real human opinions — surveys, preference comparisons, ratings, and rankings — from inside Claude Code, powered by Datapoint AI.", "author": { "name": "Datapoint AI", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..19ce88b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: pytest + ruff + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + + - name: Install Python + run: uv python install 3.12 + + - name: Install project + dev dependencies + run: | + uv venv + uv pip install -e '.[dev]' + + - name: ruff check + run: uv run ruff check mcp_server tests + + - name: pytest + run: uv run pytest tests/ -v diff --git a/mcp_server/client.py b/mcp_server/client.py index 952976f..dba519a 100644 --- a/mcp_server/client.py +++ b/mcp_server/client.py @@ -107,6 +107,13 @@ def get_job_responses( def list_jobs(self) -> dict: return self._request("GET", "/jobs") + def retry_job(self, job_id: str, datapoint_indices: list[int] | None = None) -> dict: + """POST /jobs/{job_id}/retry — re-queue failed datapoints.""" + body: dict = {} + if datapoint_indices is not None: + body["datapoint_indices"] = datapoint_indices + return self._request("POST", f"/jobs/{job_id}/retry", json=body) + def pause_job(self, job_id: str) -> dict: """POST /jobs/{job_id}/pause — stops serving new tasks.""" return self._request("POST", f"/jobs/{job_id}/pause") diff --git a/mcp_server/sanitize.py b/mcp_server/sanitize.py index 88e53cd..7bb1233 100644 --- a/mcp_server/sanitize.py +++ b/mcp_server/sanitize.py @@ -1,14 +1,32 @@ """Sanitize annotator-sourced text to prevent prompt injection. All text from annotator responses that flows back through MCP tools -must be sanitized before being returned to Claude's context. +must be sanitized before being returned to the agent's context. + +The sanitizer applies, in order: + - Unicode NFKC normalization to fold lookalike codepoints to a canonical form + (e.g. fullwidth digits and Cyrillic homoglyphs). + - Stripping of zero-width characters and bidi/RTL override codepoints + commonly used to hide text or visually spoof content. + - Stripping of C0/C1 control characters except tab and newline. + - Removal of XML/HTML-like tags so script-style payloads don't ride along. + - Defanging of Markdown link / image syntax so URLs are not auto-clickable + when the agent renders annotator text to a human. + - Truncation to a fixed cap so a runaway response cannot crowd the context. """ import re +import unicodedata MAX_RESPONSE_LENGTH = 500 _TAG_RE = re.compile(r"<[^>]*>") +# Zero-width spaces, bidi/RTL overrides, BOM, and other invisible formatters. +_INVISIBLE_RE = re.compile( + "[​-‏‪-‮⁠-⁤]" +) +# C0 controls except tab (\x09) and newline (\x0a); plus C1 controls (\x80-\x9f). +_CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]") def sanitize_text(text: str) -> str: @@ -16,7 +34,14 @@ def sanitize_text(text: str) -> str: if not isinstance(text, str): return str(text) + text = unicodedata.normalize("NFKC", text) + text = _INVISIBLE_RE.sub("", text) + text = _CONTROL_RE.sub("", text) text = _TAG_RE.sub("", text) + # Defang Markdown links / images (including nested-bracket variants) by + # escaping the `(` that follows `]`, so URLs aren't auto-clickable when + # the agent renders annotator text to a human. + text = text.replace("](", "]\\(") if len(text) > MAX_RESPONSE_LENGTH: text = text[:MAX_RESPONSE_LENGTH] + "..." diff --git a/mcp_server/server.py b/mcp_server/server.py index ac7a248..7b4adc1 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -1,7 +1,7 @@ """Datapoint AI MCP Server. Provides tools for creating human evaluation surveys, checking results, -and managing credits — all from within Claude Code conversations. +and managing credits — all from inside any MCP-capable agent. """ import atexit @@ -145,22 +145,37 @@ def upload_media(file_paths: list[str]) -> str: """ client = _get_client() + if not file_paths: + return "No files provided." + uploaded = [] errors = [] + files_succeeded = 0 for path in file_paths: try: result = client.upload_media(path) # Response shape: {"media": [{"filename","media_ref","type","size_bytes"}]} - for item in result.get("media", []): - uploaded.append(item) + uploaded.extend(result.get("media", [])) + files_succeeded += 1 except FileNotFoundError as e: errors.append(f"{path}: {e}") except DatapointAPIError as e: errors.append(f"{path}: {_describe_upload_error(e)}") - lines: list[str] = [] + total = len(file_paths) + files_failed = len(errors) + if files_failed == 0: + summary = f"Uploaded {_pluralize(files_succeeded, 'file')}." + elif files_succeeded == 0: + summary = f"All {_pluralize(total, 'file')} failed to upload." + else: + summary = f"Uploaded {files_succeeded} of {total} files; {files_failed} failed." + + lines: list[str] = [summary] + if uploaded: - lines.append(f"Uploaded {len(uploaded)} file(s):") + lines.append("") + lines.append("Media references:") for item in uploaded: lines.append( f" {item.get('filename', '?')} → {item.get('media_ref', '?')} " @@ -170,13 +185,12 @@ def upload_media(file_paths: list[str]) -> str: lines.append("Pass the media_ref values (dp://…) inside the plan_survey description.") if errors: - if lines: - lines.append("") + lines.append("") lines.append(f"Failed ({len(errors)}):") for err in errors: lines.append(f" {err}") - return "\n".join(lines) if lines else "No files provided." + return "\n".join(lines) # --------------------------------------------------------------------------- @@ -231,6 +245,11 @@ def _render_filter_values(vals: list) -> str: return ", ".join(rendered) +def _pluralize(n: int, word: str) -> str: + """English plural for `word` based on count `n` (no irregular forms).""" + return f"{n} {word}{'s' if n != 1 else ''}" + + def _format_standalone_plan_output(plan: dict, summary: str, cost: float, warnings: list) -> list[str]: """Render a standalone (non-chain) plan for user confirmation.""" lines = [ @@ -259,7 +278,7 @@ def _format_standalone_plan_output(plan: dict, summary: str, cost: float, warnin def _format_chain_plan_output(plan: dict, summary: str, cost: float, warnings: list) -> list[str]: """Render a chain plan so the user sees the full flow + skip conditions - before confirming. Claude should show this to the user verbatim. + before confirming. The agent should show this to the user verbatim. Chain plans are served atomically: an annotator who picks up the chain walks through every step in order, possibly terminated early by a step's @@ -382,7 +401,7 @@ def plan_survey(description: str, max_responses: int = 10) -> str: except DatapointAPIError as e: # 422 carries a structured {"message", "warnings"} body from _validate_plan — - # surface the warnings so the user sees what the LLM got wrong. + # surface the warnings so the user sees what the planner got wrong. if e.status_code == 422 and isinstance(e.detail, dict): message = e.detail.get("message", "Plan failed validation") warnings = e.detail.get("warnings") or [] @@ -565,6 +584,12 @@ def _format_check_survey(status: dict, results_data: dict | None, results_error: f"Cost so far: ${status.get('cost_usd', 0):.2f}", ] + lines.extend(_format_audience_targeting(status)) + + response_options = status.get("response_options") + if response_options: + lines.append(f"Response options: {response_options}") + errors = status.get("errors", []) if errors: lines.append(f"\nErrors ({len(errors)}):") @@ -737,6 +762,38 @@ def resume_survey(job_id: str) -> str: return _run_lifecycle_action("resume", client.resume_job, job_id) +@mcp.tool() +def retry_failed_datapoints(job_id: str, datapoint_indices: list[int] | None = None) -> str: + """Re-queue failed datapoints on a survey. + + Each retried datapoint reserves credit again, the same way the original + submission did — only call this when the failures are worth recovering. + + Args: + job_id: The job ID returned by create_survey. + datapoint_indices: Specific failed indices to retry. Omit to retry + every failed datapoint in the survey. + """ + client = _get_client() + try: + result = client.retry_job(job_id, datapoint_indices=datapoint_indices) + except DatapointAPIError as e: + if e.status_code == 400: + return f"Cannot retry: {e.detail}" + if e.status_code == 404: + return f"Survey not found: {job_id}" + return f"Error: {e.detail}" + + retried = result.get("retried", 0) + indices = result.get("datapoint_indices", []) + if retried == 0: + return f"No failed datapoints to retry on survey {job_id}." + return ( + f"Re-queued {_pluralize(retried, 'datapoint')} on survey {job_id}: " + f"{indices}. Use check_survey to monitor progress." + ) + + # --------------------------------------------------------------------------- # Tool: get_survey_responses # --------------------------------------------------------------------------- @@ -785,11 +842,6 @@ def _format_response_row(r: dict) -> str: return f"{annotator} @ {timestamp}: {response_text!r}{rt_str}{loc_str}{walk_str}" -def _pluralize(n: int, word: str) -> str: - """English plural for `word` based on count `n` (no irregular forms).""" - return f"{n} {word}{'s' if n != 1 else ''}" - - def _format_responses_page( data: dict, job_id: str, @@ -978,12 +1030,12 @@ def check_balance() -> str: def add_credits(product_id: str | None = None) -> str: """Open a checkout link to purchase Datapoint AI credits. - Returns a Polar.sh checkout URL. The user completes payment in their - browser; the credits land on their account once Polar's webhook fires. + Returns a hosted checkout URL. The user completes payment in their + browser; credits land on their account once payment confirms. Args: - product_id: Optional Polar product ID. Omit to use the default credit - bundle configured on the server. + product_id: Optional product identifier. Omit to use the default + credit bundle configured on the server. """ client = _get_client() diff --git a/pyproject.toml b/pyproject.toml index a500750..c8fea8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "datapoint-mcp" -version = "0.1.5" +version = "0.2.0" description = "MCP server for creating human evaluation surveys via Datapoint AI" license = "MIT" license-files = ["LICENSE"] @@ -14,6 +14,13 @@ dependencies = [ "httpx>=0.27.0", ] +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.21", + "ruff>=0.5", +] + [project.scripts] datapoint-mcp = "mcp_server.server:main" diff --git a/server.json b/server.json index 4402f26..32c8928 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.impel-intelligence/datapoint-mcp", "title": "Datapoint", "description": "MCP server for human-in-the-loop surveys, A/B preference tests, ratings, and rankings. Get real human opinions on text, images, audio, and video — directly from any MCP client.", - "version": "0.1.4", + "version": "0.2.0", "repository": { "url": "https://github.com/impel-intelligence/datapoint-mcp", "source": "github" @@ -14,7 +14,7 @@ "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "datapoint-mcp", - "version": "0.1.4", + "version": "0.2.0", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/tests/test_error_mapping.py b/tests/test_error_mapping.py index fc81df8..453eca6 100644 --- a/tests/test_error_mapping.py +++ b/tests/test_error_mapping.py @@ -14,7 +14,9 @@ from mcp_server.server import ( _describe_upload_error, check_balance, + check_survey, create_survey, + retry_failed_datapoints, upload_media, ) @@ -53,6 +55,168 @@ def test_413_renders_friendly_message_in_failed_block(self): self.assertNotIn("max_bytes", out) +class UploadMediaSummaryTests(unittest.TestCase): + def _ok_response(self, name: str) -> dict: + return { + "media": [ + {"filename": name, "media_ref": f"dp://media/{name}", "type": "image", "size_bytes": 1024} + ] + } + + def test_all_success_summary(self): + client = mock.Mock() + client.upload_media.side_effect = [self._ok_response("a.png"), self._ok_response("b.png")] + with mock.patch("mcp_server.server._get_client", return_value=client): + out = upload_media(["/tmp/a.png", "/tmp/b.png"]) + self.assertIn("Uploaded 2 files.", out) + self.assertIn("dp://media/a.png", out) + self.assertIn("dp://media/b.png", out) + + def test_partial_failure_summary(self): + client = mock.Mock() + client.upload_media.side_effect = [ + self._ok_response("a.png"), + DatapointAPIError(413, {"code": "media_too_large", "max_bytes": 20 * 1024 * 1024}), + ] + with mock.patch("mcp_server.server._get_client", return_value=client): + out = upload_media(["/tmp/a.png", "/tmp/big.mp4"]) + self.assertIn("Uploaded 1 of 2 files; 1 failed.", out) + self.assertIn("dp://media/a.png", out) + self.assertIn("/tmp/big.mp4: file exceeds the upload cap", out) + + def test_all_failure_summary(self): + client = mock.Mock() + client.upload_media.side_effect = [ + DatapointAPIError(500, "boom"), + DatapointAPIError(500, "boom"), + ] + with mock.patch("mcp_server.server._get_client", return_value=client): + out = upload_media(["/tmp/a.png", "/tmp/b.png"]) + self.assertIn("All 2 files failed to upload.", out) + + def test_no_files_returns_explanation(self): + client = mock.Mock() + with mock.patch("mcp_server.server._get_client", return_value=client): + out = upload_media([]) + self.assertEqual(out, "No files provided.") + client.upload_media.assert_not_called() + + +class RetryFailedDatapointsTests(unittest.TestCase): + def test_retry_all_failed_datapoints(self): + client = mock.Mock() + client.retry_job.return_value = { + "job_id": "job_x", + "retried": 3, + "datapoint_indices": [0, 4, 7], + } + with mock.patch("mcp_server.server._get_client", return_value=client): + out = retry_failed_datapoints("job_x") + client.retry_job.assert_called_once_with("job_x", datapoint_indices=None) + self.assertIn("Re-queued 3 datapoints on survey job_x", out) + self.assertIn("[0, 4, 7]", out) + + def test_retry_specific_indices_propagates(self): + client = mock.Mock() + client.retry_job.return_value = { + "job_id": "job_x", + "retried": 1, + "datapoint_indices": [4], + } + with mock.patch("mcp_server.server._get_client", return_value=client): + out = retry_failed_datapoints("job_x", datapoint_indices=[4]) + client.retry_job.assert_called_once_with("job_x", datapoint_indices=[4]) + self.assertIn("Re-queued 1 datapoint", out) + + def test_retry_when_nothing_failed(self): + client = mock.Mock() + client.retry_job.return_value = { + "job_id": "job_x", + "retried": 0, + "datapoint_indices": [], + } + with mock.patch("mcp_server.server._get_client", return_value=client): + out = retry_failed_datapoints("job_x") + self.assertIn("No failed datapoints to retry", out) + + def test_retry_404_renders_not_found(self): + client = mock.Mock() + client.retry_job.side_effect = DatapointAPIError(404, "Job not found") + with mock.patch("mcp_server.server._get_client", return_value=client): + out = retry_failed_datapoints("job_x") + self.assertIn("Survey not found: job_x", out) + + def test_retry_400_surfaces_backend_reason(self): + client = mock.Mock() + client.retry_job.side_effect = DatapointAPIError(400, "Job is not in a retriable state") + with mock.patch("mcp_server.server._get_client", return_value=client): + out = retry_failed_datapoints("job_x") + self.assertIn("Cannot retry: Job is not in a retriable state", out) + + +class CheckSurveyAudienceTargetingTests(unittest.TestCase): + def _status(self, **overrides) -> dict: + base = { + "job_id": "job_x", + "name": "test-survey", + "status": "active", + "task_type": "comparison", + "total_datapoints": 5, + "processing_datapoints": 0, + "ready_datapoints": 5, + "completed_datapoints": 5, + "failed_datapoints": 0, + "total_responses": 10, + "max_responses_per_datapoint": 2, + "cost_usd": 0.50, + "errors": [], + "is_paused": False, + } + base.update(overrides) + return base + + def test_renders_targeting_when_filter_present(self): + client = mock.Mock() + client.get_job_status.return_value = self._status( + annotator_filter={"country": ["US"]}, + ) + client.get_job_results.return_value = {"results": [], "task_type": "comparison"} + with mock.patch("mcp_server.server._get_client", return_value=client): + out = check_survey("job_x") + self.assertIn("Targeting: country in [US]", out) + + def test_renders_distribution_when_present(self): + client = mock.Mock() + client.get_job_status.return_value = self._status( + annotator_distribution=["country", "is_eu"], + ) + client.get_job_results.return_value = {"results": [], "task_type": "comparison"} + with mock.patch("mcp_server.server._get_client", return_value=client): + out = check_survey("job_x") + self.assertIn("Balanced by: country, is_eu", out) + + def test_renders_response_options_when_present(self): + client = mock.Mock() + client.get_job_status.return_value = self._status( + response_options={"options": ["yes", "no"]}, + ) + client.get_job_results.return_value = {"results": [], "task_type": "comparison"} + with mock.patch("mcp_server.server._get_client", return_value=client): + out = check_survey("job_x") + self.assertIn("Response options:", out) + self.assertIn("yes", out) + + def test_no_targeting_fields_omits_lines(self): + client = mock.Mock() + client.get_job_status.return_value = self._status() + client.get_job_results.return_value = {"results": [], "task_type": "comparison"} + with mock.patch("mcp_server.server._get_client", return_value=client): + out = check_survey("job_x") + self.assertNotIn("Targeting:", out) + self.assertNotIn("Balanced by:", out) + self.assertNotIn("Response options:", out) + + class CreateSurveyErrorTests(unittest.TestCase): def _client_raising(self, exc: Exception) -> mock.Mock: client = mock.Mock() diff --git a/tests/test_sanitize.py b/tests/test_sanitize.py new file mode 100644 index 0000000..b3fe598 --- /dev/null +++ b/tests/test_sanitize.py @@ -0,0 +1,181 @@ +"""Tests for the annotator-text sanitizer. + +stdlib unittest only. Each test names the threat it covers. +""" + +from __future__ import annotations + +import unittest + +from mcp_server.sanitize import ( + MAX_RESPONSE_LENGTH, + _sanitize_value, + sanitize_responses, + sanitize_results, + sanitize_text, +) + + +class HtmlTagStrippingTests(unittest.TestCase): + def test_strips_simple_html_tags(self): + self.assertEqual(sanitize_text("bold"), "bold") + + def test_strips_script_payload(self): + out = sanitize_text("safe") + self.assertNotIn("<", out) + self.assertNotIn("script", out) + self.assertEqual(out, "alert(1)safe") + + def test_keeps_text_with_angle_brackets_in_words(self): + out = sanitize_text("a < b > c") + self.assertIn("a ", out) + self.assertIn(" c", out) + + +class TruncationTests(unittest.TestCase): + def test_long_string_truncated_with_ellipsis(self): + text = "x" * (MAX_RESPONSE_LENGTH + 100) + out = sanitize_text(text) + self.assertEqual(len(out), MAX_RESPONSE_LENGTH + 3) + self.assertTrue(out.endswith("...")) + + def test_short_string_unchanged(self): + self.assertEqual(sanitize_text("short"), "short") + + +class InvisibleCharacterStrippingTests(unittest.TestCase): + def test_strips_zero_width_space(self): + out = sanitize_text("hello​world") + self.assertEqual(out, "helloworld") + + def test_strips_zero_width_joiner_and_non_joiner(self): + out = sanitize_text("a‌b‍c") + self.assertEqual(out, "abc") + + def test_strips_byte_order_mark(self): + out = sanitize_text("text") + self.assertEqual(out, "text") + + def test_strips_bidi_rtl_override(self): + out = sanitize_text("safe‮txt.exe") + self.assertNotIn("‮", out) + self.assertIn("safe", out) + self.assertIn("txt.exe", out) + + def test_strips_word_joiner_and_invisible_separators(self): + out = sanitize_text("⁠hidden⁣text") + self.assertEqual(out, "hiddentext") + + +class ControlCharacterStrippingTests(unittest.TestCase): + def test_strips_null_and_other_c0(self): + out = sanitize_text("a\x00b\x07c") + self.assertEqual(out, "abc") + + def test_strips_c1_controls(self): + out = sanitize_text("a\x80b\x9fc") + self.assertEqual(out, "abc") + + def test_preserves_tab_and_newline(self): + out = sanitize_text("line1\nline2\tcol") + self.assertIn("\n", out) + self.assertIn("\t", out) + + def test_strips_del_character(self): + out = sanitize_text("a\x7fb") + self.assertEqual(out, "ab") + + +class UnicodeNormalizationTests(unittest.TestCase): + def test_fullwidth_digits_fold_to_ascii(self): + # U+FF11 etc. — fullwidth versions of "1" "2" "3" + out = sanitize_text("123") + self.assertEqual(out, "123") + + def test_compatibility_ligature_decomposed(self): + # U+FB01 ("fi") decomposes to "fi" under NFKC + out = sanitize_text("final") + self.assertEqual(out, "final") + + +class MarkdownLinkDefangingTests(unittest.TestCase): + def test_link_open_paren_escaped(self): + out = sanitize_text("[click me](http://example.com)") + self.assertNotIn("](http", out) + self.assertIn(r"\(http", out) + + def test_image_open_paren_escaped(self): + out = sanitize_text("![alt text](http://example.com/img.png)") + self.assertIn(r"\(http", out) + + def test_plain_text_with_brackets_preserved(self): + # A bracket without a following paren is harmless and unchanged + out = sanitize_text("see [note] for context") + self.assertEqual(out, "see [note] for context") + + def test_only_first_paren_after_bracket_is_escaped(self): + out = sanitize_text("[a](url1) and [b](url2)") + self.assertEqual(out.count(r"\("), 2) + + def test_nested_brackets_still_defanged(self): + out = sanitize_text("[outer [inner]](http://evil.com)") + self.assertNotIn("](http", out) + self.assertIn(r"\(http", out) + + def test_double_bracket_still_defanged(self): + out = sanitize_text("[[wikilink]](http://evil.com)") + self.assertNotIn("](http", out) + + +class NonStringInputTests(unittest.TestCase): + def test_int_coerced_via_str(self): + self.assertEqual(sanitize_text(42), "42") + + def test_none_coerced_to_string(self): + self.assertEqual(sanitize_text(None), "None") + + +class SanitizeValueRecursionTests(unittest.TestCase): + def test_dict_values_sanitized_recursively(self): + payload = {"answer": "yes", "rationale": "I prefer it​because"} + out = _sanitize_value(payload) + self.assertEqual(out["answer"], "yes") + self.assertEqual(out["rationale"], "I prefer itbecause") + + def test_list_items_sanitized(self): + out = _sanitize_value(["a", "b‮flip"]) + self.assertEqual(out[0], "a") + self.assertNotIn("‮", out[1]) + + def test_non_string_preserved(self): + out = _sanitize_value({"score": 4, "flagged": True, "extras": None}) + self.assertEqual(out, {"score": 4, "flagged": True, "extras": None}) + + def test_deep_nesting_terminates(self): + # Build a 15-deep nested dict; the sanitizer caps recursion at 10. + obj: object = "leaf" + for _ in range(15): + obj = {"k": obj} + out = _sanitize_value(obj) + self.assertIsInstance(out, dict) + + +class SanitizeListWrappersTests(unittest.TestCase): + def test_sanitize_results_processes_each_dict(self): + results = [ + {"datapoint_index": 0, "consensus": "A"}, + {"datapoint_index": 1, "consensus": "B​"}, + ] + out = sanitize_results(results) + self.assertEqual(out[0]["consensus"], "A") + self.assertEqual(out[1]["consensus"], "B") + + def test_sanitize_responses_processes_each_dict(self): + responses = [{"response": "yes", "annotator_id": "anon_1"}] + out = sanitize_responses(responses) + self.assertEqual(out[0]["response"], "yes") + self.assertEqual(out[0]["annotator_id"], "anon_1") + + +if __name__ == "__main__": + unittest.main()