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("helloworld")
+ self.assertEqual(out, "helloworld")
+
+ def test_strips_zero_width_joiner_and_non_joiner(self):
+ out = sanitize_text("abc")
+ 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("safetxt.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("hiddentext")
+ 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("")
+ 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 itbecause"}
+ 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", "bflip"])
+ 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()