Leon4gr45 commited on
Commit
fd4e724
·
verified ·
1 Parent(s): b7335a5

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. huey_config.py +21 -0
  3. images/augmentoolkit-logo.png +3 -0
  4. linux.sh +614 -0
  5. local_linux.sh +738 -0
  6. local_macos.sh +713 -0
  7. logs/.gitkeep +3 -0
  8. macos.sh +612 -0
  9. ocr/.env.example +23 -0
  10. ocr/LICENSE +21 -0
  11. ocr/README.md +17 -0
  12. ocr/THIRD_PARTY.md +23 -0
  13. ocr/__init__.py +3 -0
  14. ocr/adapters/__init__.py +0 -0
  15. ocr/adapters/cpu_http.py +61 -0
  16. ocr/adapters/freellm_alias.py +160 -0
  17. ocr/adapters/http.py +30 -0
  18. ocr/adapters/rapidocr_cpu.py +146 -0
  19. ocr/anonymization/__init__.py +15 -0
  20. ocr/anonymization/__main__.py +40 -0
  21. ocr/anonymization/approval_ledger.py +109 -0
  22. ocr/anonymization/artifacts.json +11 -0
  23. ocr/anonymization/atomic_output.py +41 -0
  24. ocr/anonymization/candidate_io.py +22 -0
  25. ocr/anonymization/compare03d.py +111 -0
  26. ocr/anonymization/corrections.py +754 -0
  27. ocr/anonymization/demonstrate03f.py +220 -0
  28. ocr/anonymization/detectors.py +38 -0
  29. ocr/anonymization/develop03e.py +145 -0
  30. ocr/anonymization/engine.py +149 -0
  31. ocr/anonymization/evaluate.py +196 -0
  32. ocr/anonymization/fixtures/holdout03c/cases.json +3854 -0
  33. ocr/anonymization/fixtures/holdout03c/freeze.json +18 -0
  34. ocr/anonymization/fixtures/holdout03d/README.md +35 -0
  35. ocr/anonymization/fixtures/holdout03d/cases.json +2730 -0
  36. ocr/anonymization/fixtures/holdout03d/freeze.json +129 -0
  37. ocr/anonymization/fixtures/qualification.json +647 -0
  38. ocr/anonymization/fixtures/validation.json +210 -0
  39. ocr/anonymization/historical.py +36 -0
  40. ocr/anonymization/holdout.py +79 -0
  41. ocr/anonymization/host_adapter.py +167 -0
  42. ocr/anonymization/investigate.py +142 -0
  43. ocr/anonymization/model.py +81 -0
  44. ocr/anonymization/normalization.py +11 -0
  45. ocr/anonymization/outcomes.py +124 -0
  46. ocr/anonymization/private_review.py +200 -0
  47. ocr/anonymization/private_transport.py +155 -0
  48. ocr/anonymization/provenance.py +56 -0
  49. ocr/anonymization/qualify03d.py +121 -0
  50. ocr/anonymization/recognizer.py +152 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ images/augmentoolkit-logo.png filter=lfs diff=lfs merge=lfs -text
huey_config.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ # Import RedisHuey and the necessary connection details from redis_config
4
+ from huey import RedisHuey
5
+ from redis_config import REDIS_HOST, REDIS_PORT, REDIS_DB
6
+
7
+ # Define the Huey instance using Redis
8
+ # The 'name' parameter is optional but good practice, often set to the app name.
9
+ # It prefixes keys used by Huey in Redis to avoid collisions if Redis is shared.
10
+ huey = RedisHuey(
11
+ name="augmentoolkit_tasks", # Optional, helps namespace Huey keys in Redis
12
+ host=REDIS_HOST,
13
+ port=REDIS_PORT,
14
+ db=REDIS_DB,
15
+ # Add other RedisHuey parameters if needed (e.g., password, connection_pool)
16
+ )
17
+
18
+ # Remove SQLite specific code
19
+ # os.makedirs("huey")
20
+ # _db_path = os.path.join(os.path.dirname(__file__), 'huey', 'huey_queue.db')
21
+ # huey = SqliteHuey(filename=_db_path)
images/augmentoolkit-logo.png ADDED

Git LFS Details

  • SHA256: 8a744037cb082826f037ee4c0066435c8ab91d62e2ce00026098e72ca7b95361
  • Pointer size: 131 Bytes
  • Size of remote file: 237 kB
linux.sh ADDED
@@ -0,0 +1,614 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Exit immediately if a command exits with a non-zero status.
4
+ # set -e # Disabled for manual error checking below
5
+
6
+ echo "Starting Augmentoolkit services..."
7
+
8
+ # --- Get script directory and change to it ---
9
+ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
10
+ cd "$SCRIPT_DIR"
11
+ if [ $? -ne 0 ]; then
12
+ echo "ERROR: Failed to change directory to script location '$SCRIPT_DIR'."
13
+ exit 1
14
+ fi
15
+ echo "Running in directory: $(pwd)"
16
+
17
+ # --- Cleanup Function ---
18
+ # Stores PIDs of background processes
19
+ HUEY_PID=""
20
+ UVICORN_PID=""
21
+ SERVE_PID="" # Though serve runs in foreground, trap might catch signals before it starts
22
+ VALKEY_PID="" # PID for valkey server
23
+
24
+ cleanup() {
25
+ echo "" # Newline after ^C
26
+ echo "----------------------------------------"
27
+ echo "Signal received, stopping background services..."
28
+
29
+ # Function to kill a process and wait for it to terminate
30
+ kill_and_wait() {
31
+ local pid=$1
32
+ local name=$2
33
+ local timeout=9
34
+
35
+ if [ -n "$pid" ] && kill -0 $pid &> /dev/null; then
36
+ echo "Stopping $name (PID $pid)..."
37
+
38
+ # First try SIGTERM (graceful shutdown)
39
+ kill -TERM $pid 2>/dev/null
40
+
41
+ # Wait for process to terminate gracefully
42
+ local count=0
43
+ while [ $count -lt $timeout ] && kill -0 $pid &> /dev/null; do
44
+ sleep 1
45
+ count=$((count + 1))
46
+ done
47
+
48
+ # If still running, force kill with SIGKILL
49
+ if kill -0 $pid &> /dev/null; then
50
+ echo "Process $pid ($name) didn't terminate gracefully, force killing..."
51
+ kill -KILL $pid 2>/dev/null
52
+ sleep 1
53
+ fi
54
+
55
+ # Final check
56
+ if kill -0 $pid &> /dev/null; then
57
+ echo "WARNING: Failed to stop $name (PID $pid)"
58
+ else
59
+ echo "$name stopped successfully."
60
+ fi
61
+ fi
62
+ }
63
+
64
+ # Stop processes in reverse order of startup
65
+ kill_and_wait "$SERVE_PID" "frontend serve"
66
+ kill_and_wait "$UVICORN_PID" "Uvicorn API server"
67
+ kill_and_wait "$HUEY_PID" "Huey worker"
68
+ kill_and_wait "$VALKEY_PID" "Valkey server"
69
+
70
+ # Find and kill any remaining huey consumers specific to this Augmentoolkit instance
71
+ echo "Searching for huey consumer processes in current directory ($SCRIPT_DIR)..."
72
+
73
+ # Use ps with full command line and grep for huey_consumer processes in our specific directory
74
+ # This avoids killing huey consumers from other Augmentoolkit instances or projects
75
+ HUEY_PIDS=$(ps aux | grep -E "huey_consumer.*tasks\.huey" | grep "$SCRIPT_DIR" | grep -v grep | awk '{print $2}')
76
+
77
+ if [ -n "$HUEY_PIDS" ]; then
78
+ echo "Found huey consumer processes in current directory: $HUEY_PIDS"
79
+ for pid in $HUEY_PIDS; do
80
+ if kill -0 $pid &> /dev/null; then
81
+ echo "Stopping huey consumer from current directory (PID $pid)..."
82
+ kill -TERM $pid 2>/dev/null
83
+ sleep 2
84
+ if kill -0 $pid &> /dev/null; then
85
+ echo "Force killing huey consumer (PID $pid)..."
86
+ kill -KILL $pid 2>/dev/null
87
+ fi
88
+ fi
89
+ done
90
+ sleep 1
91
+ # Final check for any remaining huey processes in our directory
92
+ REMAINING_HUEY=$(ps aux | grep -E "huey_consumer.*tasks\.huey" | grep "$SCRIPT_DIR" | grep -v grep | awk '{print $2}')
93
+ if [ -n "$REMAINING_HUEY" ]; then
94
+ echo "WARNING: Some huey consumer processes in current directory may still be running: $REMAINING_HUEY"
95
+ else
96
+ echo "All huey consumer processes from current directory stopped successfully."
97
+ fi
98
+ else
99
+ echo "No additional huey consumer processes found in current directory."
100
+ fi
101
+
102
+ # Deactivate virtual environment if active
103
+ if command -v deactivate &> /dev/null; then
104
+ echo "Deactivating virtual environment..."
105
+ deactivate
106
+ fi
107
+
108
+ # Give a moment for any remaining output to flush
109
+ sleep 1
110
+
111
+ # Clear any remaining output and reset cursor
112
+ echo "Cleanup complete."
113
+ echo "----------------------------------------"
114
+
115
+ # Ensure cursor is visible and terminal is in normal state
116
+ printf "\033[?25h" # Show cursor
117
+ printf "\033[0m" # Reset all attributes
118
+
119
+ exit 0 # Exit script after cleanup
120
+ }
121
+
122
+ # Trap signals (INT: Ctrl+C, TERM: standard kill) and run cleanup function
123
+ trap cleanup SIGINT SIGTERM
124
+
125
+ # --- 1. Python Virtual Environment & Dependencies ---
126
+ echo "----------------------------------------"
127
+ echo "1. Setting up Python virtual environment and installing dependencies..."
128
+ VENV_DIR=".venv"
129
+
130
+ # Check if python3.11 command exists (preferred)
131
+ if command -v python3.11 &> /dev/null; then
132
+ PYTHON_CMD="python3.11"
133
+ echo "Using Python 3.11: $(python3.11 --version)"
134
+ elif command -v python3 &> /dev/null; then
135
+ PYTHON_CMD="python3"
136
+ PYTHON_VERSION=$(python3 --version 2>&1 | grep -oE '[0-9]+\.[0-9]+')
137
+ echo "Using Python: $(python3 --version)"
138
+ if [[ "$PYTHON_VERSION" != "3.11" ]]; then
139
+ echo "WARNING: Python 3.11 is recommended but not found. Using $PYTHON_VERSION instead."
140
+ echo "If you encounter issues, please install Python 3.11 specifically."
141
+ fi
142
+ else
143
+ echo "ERROR: Neither python3.11 nor python3 command found."
144
+ echo "Please install Python 3.11 (recommended) or Python 3.x and rerun the script."
145
+ echo "On Ubuntu/Debian, you can install Python 3.11 using:"
146
+ echo " sudo apt update && sudo apt install python3.11 python3.11-venv python3.11-dev"
147
+ echo "On RHEL/CentOS/Fedora, you can install Python 3.11 using:"
148
+ echo " sudo dnf install python3.11 python3.11-devel"
149
+ echo "Or download from: https://www.python.org/downloads/"
150
+ exit 1
151
+ fi
152
+
153
+ # Create virtual environment if it doesn't exist
154
+ if [ ! -d "$VENV_DIR" ]; then
155
+ echo "Creating virtual environment in '$VENV_DIR' using $PYTHON_CMD..."
156
+ $PYTHON_CMD -m venv "$VENV_DIR"
157
+ if [ $? -ne 0 ]; then
158
+ echo "ERROR: Failed to create Python virtual environment using $PYTHON_CMD."
159
+ exit 1
160
+ fi
161
+ else
162
+ echo "Virtual environment '$VENV_DIR' already exists."
163
+ fi
164
+
165
+ # Activate virtual environment
166
+ echo "Activating virtual environment..."
167
+ source "$VENV_DIR/bin/activate"
168
+ if [ $? -ne 0 ]; then
169
+ echo "ERROR: Failed to activate Python virtual environment."
170
+ exit 1
171
+ fi
172
+ echo "Virtual environment activated. Python executable: $(which python)"
173
+
174
+ # --- Check/Install uv ---
175
+ echo "Checking for uv..."
176
+ if ! command -v uv &> /dev/null; then
177
+ echo "'uv' command not found. Attempting to install uv using pip..."
178
+ # Ensure pip is available (it should be with the selected Python version)
179
+ if ! python -m pip --version &> /dev/null; then
180
+ echo "ERROR: python -m pip command failed. Cannot install uv."
181
+ exit 1
182
+ fi
183
+ python -m pip install uv
184
+ if [ $? -ne 0 ]; then
185
+ echo "ERROR: Failed to install uv using pip. Please install uv manually (e.g., 'pip install uv') and rerun the script."
186
+ exit 1
187
+ fi
188
+ echo "uv installed successfully."
189
+ else
190
+ echo "uv found."
191
+ fi
192
+ # --- End Check/Install uv ---
193
+
194
+
195
+ # Install dependencies using uv
196
+ echo "Installing Python dependencies from requirements.txt using uv..."
197
+ # Assuming uv is in PATH after the check above and venv is activated
198
+ uv pip install -r requirements.txt
199
+ if [ $? -ne 0 ]; then
200
+ echo "ERROR: Failed to install Python dependencies using uv. Please check requirements.txt and uv logs."
201
+ exit 1 # Cleanup trap will handle exit
202
+ fi
203
+ echo "Python dependencies installed successfully."
204
+
205
+ # Download spaCy model for anonymization
206
+ echo "Downloading spaCy model (en_core_web_lg) for anonymization..."
207
+ python -m spacy download en_core_web_lg
208
+ if [ $? -ne 0 ]; then
209
+ echo "WARNING: Failed to download spaCy model. Anonymization might fall back to NLTK."
210
+ fi
211
+
212
+
213
+ # --- Helper Process Logging Setup ---
214
+ echo "----------------------------------------"
215
+ echo "Setting up helper process logging..."
216
+
217
+ # Create helper_process_logs directory if it doesn't exist
218
+ HELPER_LOGS_DIR="helper_process_logs"
219
+ if [ ! -d "$HELPER_LOGS_DIR" ]; then
220
+ echo "Creating helper process logs directory: $HELPER_LOGS_DIR"
221
+ mkdir -p "$HELPER_LOGS_DIR"
222
+ if [ $? -ne 0 ]; then
223
+ echo "ERROR: Failed to create helper process logs directory."
224
+ exit 1
225
+ fi
226
+ else
227
+ echo "Helper process logs directory already exists: $HELPER_LOGS_DIR"
228
+ fi
229
+
230
+ # Generate timestamp for log files
231
+ TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
232
+ VALKEY_LOG_FILE="$HELPER_LOGS_DIR/valkey_server_${TIMESTAMP}.log"
233
+ HUEY_LOG_FILE="$HELPER_LOGS_DIR/huey_worker_${TIMESTAMP}.log"
234
+ UVICORN_LOG_FILE="$HELPER_LOGS_DIR/uvicorn_api_${TIMESTAMP}.log"
235
+ FRONTEND_LOG_FILE="$HELPER_LOGS_DIR/frontend_serve_${TIMESTAMP}.log"
236
+
237
+ echo "Helper process logs will be saved to:"
238
+ echo " Valkey server: $VALKEY_LOG_FILE"
239
+ echo " Huey worker: $HUEY_LOG_FILE"
240
+ echo " Uvicorn API: $UVICORN_LOG_FILE"
241
+ echo " Frontend serve: $FRONTEND_LOG_FILE"
242
+ echo "Helper process logging setup complete."
243
+
244
+ #### if REDIS_HOST skip
245
+ if [ -z "$REDIS_HOST" ]; then
246
+ # --- 1.5 Valkey/Redis Check ---
247
+ echo "----------------------------------------"
248
+ echo "1.5. Checking for running Valkey/Redis service..."
249
+
250
+ SERVER_CMD=""
251
+ SERVICE_NAME=""
252
+
253
+ # Check for valkey-server first
254
+ if command -v valkey-server &> /dev/null; then
255
+ SERVER_CMD="valkey-server"
256
+ SERVICE_NAME="valkey-server"
257
+ # Fallback to redis-server
258
+ elif command -v redis-server &> /dev/null; then
259
+ SERVER_CMD="redis-server"
260
+ SERVICE_NAME="redis-server"
261
+ else
262
+ # No background services started yet, just exit
263
+ # Check if something is listening on the default Valkey/Redis port (6379)
264
+ if command -v ss &> /dev/null; then
265
+ PORT_CHECK_CMD="ss -ltn"
266
+ else
267
+ PORT_CHECK_CMD="netstat -ltn"
268
+ fi
269
+
270
+ if $PORT_CHECK_CMD | grep -q ':6379'; then
271
+ echo "WARNING: Neither 'valkey-server' nor 'redis-server' found in PATH, but something is listening on port 6379."
272
+ echo "Assuming a compatible Valkey/Redis server is running (possibly built from source or custom install)."
273
+ else
274
+ echo "Neither 'valkey-server' nor 'redis-server' found in PATH."
275
+ echo "Building Valkey from source..."
276
+
277
+ # Check if valkey directory already exists
278
+ if [ ! -d "valkey" ]; then
279
+ echo "Cloning Valkey repository..."
280
+ git clone https://github.com/valkey-io/valkey.git
281
+ if [ $? -ne 0 ]; then
282
+ echo "ERROR: Failed to clone Valkey repository. Please check your internet connection and git installation."
283
+ exit 1
284
+ fi
285
+ else
286
+ echo "Valkey directory already exists, skipping clone."
287
+ fi
288
+
289
+ echo "Building Valkey..."
290
+ cd valkey
291
+ make
292
+ if [ $? -ne 0 ]; then
293
+ echo "ERROR: Failed to build Valkey. Please check build dependencies (gcc, make, etc.)."
294
+ cd "$SCRIPT_DIR"
295
+ exit 1
296
+ fi
297
+
298
+ echo "Starting Valkey server in background..."
299
+ echo "Output will be logged to: $VALKEY_LOG_FILE"
300
+ cd src
301
+ ./valkey-server > "../../$VALKEY_LOG_FILE" 2>&1 &
302
+ VALKEY_PID=$!
303
+ cd "$SCRIPT_DIR"
304
+
305
+ # Give it a moment to start/fail
306
+ sleep 4
307
+ if ! kill -0 $VALKEY_PID &> /dev/null; then
308
+ echo "ERROR: Valkey server (PID $VALKEY_PID) failed to start or crashed immediately."
309
+ echo "Check the log file for details: $VALKEY_LOG_FILE"
310
+ exit 1
311
+ fi
312
+ echo "Valkey server started successfully with PID $VALKEY_PID."
313
+
314
+ # Set SERVER_CMD and SERVICE_NAME for consistency with the rest of the script
315
+ SERVER_CMD="./valkey/src/valkey-server"
316
+ SERVICE_NAME="valkey-server"
317
+ fi
318
+ fi
319
+ echo "Found $SERVER_CMD."
320
+
321
+ # If we didn't just start Valkey server locally, perform service checks
322
+ if [ -z "$VALKEY_PID" ]; then
323
+ # Check if systemctl exists and is usable
324
+ if command -v systemctl &> /dev/null && systemctl status &> /dev/null; then
325
+ # systemctl seems to be working, so let's use it
326
+ if ! systemctl is-active --quiet $SERVICE_NAME; then
327
+ echo "WARNING: The $SERVICE_NAME service is not active according to systemctl."
328
+ # Check if something is listening on port 6379 as fallback
329
+ if ss -ltn | grep -q ':6379' 2>/dev/null; then
330
+ echo "However, something is listening on port 6379. Assuming a compatible Valkey/Redis server is running."
331
+ else
332
+ echo "ERROR: The $SERVICE_NAME service is not active and nothing is listening on port 6379."
333
+ echo "Please start the service, e.g., 'sudo systemctl start $SERVICE_NAME' and ensure it's enabled to start on boot, e.g., 'sudo systemctl enable $SERVICE_NAME'."
334
+ exit 1
335
+ fi
336
+ else
337
+ echo "The $SERVICE_NAME service is active."
338
+ fi
339
+ else
340
+ # systemctl not available or not working, fall back to simple port check
341
+ echo "WARNING: 'systemctl' not available/running. Checking for service via port."
342
+ if ss -ltn | grep -q ':6379' 2>/dev/null; then
343
+ echo "A service is listening on port 6379. Assuming a compatible Valkey/Redis server is running."
344
+ else
345
+ echo "ERROR: No running Valkey/Redis service detected on port 6379, and could not start one automatically as it was expected to be running."
346
+ echo "Please start valkey-server or redis-server manually in another terminal."
347
+ exit 1
348
+ fi
349
+ fi
350
+ else
351
+ echo "Valkey server was started by this script (PID $VALKEY_PID), skipping system service check."
352
+ fi
353
+ # --- End Valkey/Redis Check ---
354
+ fi
355
+
356
+ # --- 2. Huey Worker ---
357
+ echo "----------------------------------------"
358
+ echo "2. Starting Huey worker in the background (using venv)..."
359
+ echo "Output will be logged to: $HUEY_LOG_FILE"
360
+ # Call the huey_consumer script directly (should be in PATH from venv)
361
+ # Redirect both stdout and stderr to the log file
362
+ huey_consumer tasks.huey > "$HUEY_LOG_FILE" 2>&1 &
363
+ HUEY_PID=$!
364
+ # Give it a moment to start/fail
365
+ sleep 2
366
+ if ! kill -0 $HUEY_PID &> /dev/null; then
367
+ echo "ERROR: Huey worker (PID $HUEY_PID) failed to start or crashed immediately."
368
+ echo "Check the log file for details: $HUEY_LOG_FILE"
369
+ exit 1 # Cleanup trap will handle exit
370
+ fi
371
+ echo "Huey worker started successfully with PID $HUEY_PID."
372
+
373
+
374
+ # --- 3. API Server ---
375
+ echo "----------------------------------------"
376
+ echo "3. Starting FastAPI server (Uvicorn) in the background (using venv)..."
377
+ echo "Output will be logged to: $UVICORN_LOG_FILE"
378
+ # Host 0.0.0.0 makes it accessible on the network
379
+ # Use python -m uvicorn to ensure using the venv uvicorn
380
+ # Redirect both stdout and stderr to the log file
381
+ python -m uvicorn api:app --host 0.0.0.0 --port 8000 > "$UVICORN_LOG_FILE" 2>&1 &
382
+ UVICORN_PID=$!
383
+ # Give it a moment to start/fail
384
+ sleep 2
385
+ if ! kill -0 $UVICORN_PID &> /dev/null; then
386
+ echo "ERROR: Uvicorn server (PID $UVICORN_PID) failed to start or crashed immediately."
387
+ echo "Check the log file for details: $UVICORN_LOG_FILE"
388
+ # Cleanup trap will handle stopping Huey
389
+ exit 1 # Cleanup trap will handle exit
390
+ fi
391
+ echo "Uvicorn server started successfully with PID $UVICORN_PID. Access API at http://localhost:8000"
392
+
393
+
394
+ # --- 4. NPM Check ---
395
+ echo "----------------------------------------"
396
+ echo "4. Checking for npm..."
397
+ if ! command -v npm &> /dev/null; then
398
+ echo "ERROR: npm command not found."
399
+ echo "Please install Node.js and npm (e.g., via your system package manager like 'sudo apt install nodejs npm' or better still, install nvm and use that to get node (ask an AI how to get nodejs via nvm)) and run this script again."
400
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
401
+ kill $HUEY_PID $UVICORN_PID
402
+ exit 1
403
+ fi
404
+ echo "npm found."
405
+
406
+ # Check Node.js version
407
+ NODE_VERSION=$(node --version 2>/dev/null | sed 's/v//')
408
+ if [ -z "$NODE_VERSION" ]; then
409
+ echo "ERROR: Could not determine Node.js version."
410
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
411
+ kill $HUEY_PID $UVICORN_PID
412
+ exit 1
413
+ fi
414
+
415
+ # Extract major version number
416
+ NODE_MAJOR_VERSION=$(echo "$NODE_VERSION" | cut -d. -f1)
417
+
418
+ echo "Node.js version: v$NODE_VERSION"
419
+
420
+ # Check if Node.js version is at least 18
421
+ if [ "$NODE_MAJOR_VERSION" -lt 18 ]; then
422
+ echo "ERROR: Node.js version $NODE_VERSION is too old."
423
+ echo "This application requires Node.js 18.0.0 or higher (20+ recommended)."
424
+ echo ""
425
+ echo "To upgrade Node.js, run the following commands:"
426
+ echo ""
427
+ echo "1. Add NodeSource repository:"
428
+ echo " curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -"
429
+ echo ""
430
+ echo "2. Remove conflicting packages (if any):"
431
+ echo " sudo apt-get remove -y libnode-dev libnode72"
432
+ echo ""
433
+ echo "3. Install Node.js 20.x:"
434
+ echo " sudo apt-get install -y nodejs"
435
+ echo ""
436
+ echo " OR run all steps at once:"
437
+ echo " curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt-get remove -y libnode-dev libnode72 2>/dev/null || true && sudo apt-get install -y nodejs"
438
+ echo ""
439
+ echo "Alternative method using Node Version Manager (nvm):"
440
+ echo " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash"
441
+ echo " source ~/.bashrc"
442
+ echo " nvm install 20"
443
+ echo " nvm use 20"
444
+ echo ""
445
+ echo "After upgrading, please run this script again."
446
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
447
+ kill $HUEY_PID $UVICORN_PID
448
+ exit 1
449
+ fi
450
+
451
+
452
+ # --- 5. Frontend Dependencies ---
453
+ echo "----------------------------------------"
454
+ echo "5. Installing frontend dependencies in atk-interface..."
455
+ if [ ! -d "atk-interface" ]; then
456
+ echo "ERROR: Directory 'atk-interface' not found in $(pwd)."
457
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
458
+ kill $HUEY_PID $UVICORN_PID
459
+ exit 1
460
+ fi
461
+ cd atk-interface
462
+ npm install
463
+ if [ $? -ne 0 ]; then
464
+ echo "ERROR: Failed to install npm dependencies in atk-interface. Check package.json and npm logs."
465
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
466
+ kill $HUEY_PID $UVICORN_PID
467
+ cd "$SCRIPT_DIR" # Go back to root
468
+ exit 1
469
+ fi
470
+ echo "Frontend dependencies installed."
471
+
472
+
473
+ # --- 6. Frontend Build ---
474
+ echo "----------------------------------------"
475
+ echo "6. Building frontend application in atk-interface..."
476
+ npm run build
477
+ if [ $? -ne 0 ]; then
478
+ echo "ERROR: Failed to build frontend application. Check build script in package.json and logs."
479
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
480
+ kill $HUEY_PID $UVICORN_PID
481
+ cd "$SCRIPT_DIR" # Go back to root
482
+ exit 1
483
+ fi
484
+ echo "Frontend built successfully."
485
+
486
+
487
+ # --- 7. Frontend Serve ---
488
+ echo "----------------------------------------"
489
+ echo "7. Serving frontend application from atk-interface/dist/ in the FOREGROUND..."
490
+ echo "Output will be logged to: $FRONTEND_LOG_FILE"
491
+ # Ensure dist directory exists after build
492
+ if [ ! -d "dist" ]; then
493
+ echo "ERROR: 'dist' directory not found in atk-interface after build."
494
+ # Cleanup trap will handle stopping background services
495
+ exit 1
496
+ fi
497
+
498
+
499
+ # Show summary of all log files
500
+ echo ""
501
+ echo "═══════════════════════════════════════════════════════════════════════════════"
502
+ echo "All helper processes are now running with output redirected to log files:"
503
+ if [ -n "$VALKEY_PID" ]; then
504
+ echo " • Valkey server logs: $VALKEY_LOG_FILE"
505
+ fi
506
+ echo " • Huey worker logs: $HUEY_LOG_FILE"
507
+ echo " • Uvicorn API logs: $UVICORN_LOG_FILE"
508
+ echo " • Frontend serve logs: $FRONTEND_LOG_FILE"
509
+ echo ""
510
+ echo "You can monitor these logs in real-time with:"
511
+ if [ -n "$VALKEY_PID" ]; then
512
+ echo " tail -f $VALKEY_LOG_FILE"
513
+ fi
514
+ echo " tail -f $HUEY_LOG_FILE"
515
+ echo " tail -f $UVICORN_LOG_FILE"
516
+ echo " tail -f $FRONTEND_LOG_FILE"
517
+ echo "═══════════════════════════════════════════════════════════════════════════════"
518
+ echo ""
519
+
520
+ # Serve on port 5173 (matches CORS config)
521
+ # Run npx serve in the background first, then open browser once it's ready
522
+ echo "Starting serve process (output redirected to log file)..."
523
+ # Redirect both stdout and stderr to the log file - use ../ to go back to script directory
524
+ npx serve -s dist --listen 5173 > "../$FRONTEND_LOG_FILE" 2>&1 &
525
+ SERVE_PID=$!
526
+
527
+ # Wait for the serve process to be ready
528
+ echo "Waiting for frontend server to start..."
529
+ sleep 3
530
+
531
+ # Check if serve process is still running
532
+ if ! kill -0 $SERVE_PID &> /dev/null; then
533
+ echo "ERROR: Frontend serve process failed to start or crashed immediately."
534
+ echo "Check the log file for details: $FRONTEND_LOG_FILE"
535
+ cleanup
536
+ exit 1
537
+ fi
538
+
539
+ # Wait for the server to actually be listening on port 5173
540
+ echo "Checking if server is responding..."
541
+ for i in {1..10}; do
542
+ if command -v curl &> /dev/null; then
543
+ if curl -s http://localhost:5173 > /dev/null 2>&1; then
544
+ break
545
+ fi
546
+ elif command -v wget &> /dev/null; then
547
+ if wget -q --spider http://localhost:5173 > /dev/null 2>&1; then
548
+ break
549
+ fi
550
+ else
551
+ # Fallback: just check if something is listening on port 5173
552
+ if command -v ss &> /dev/null; then
553
+ if ss -ltn | grep -q ':5173'; then
554
+ break
555
+ fi
556
+ elif command -v netstat &> /dev/null; then
557
+ if netstat -ltn | grep -q ':5173'; then
558
+ break
559
+ fi
560
+ else
561
+ # No way to check, just wait a bit more
562
+ sleep 1
563
+ break
564
+ fi
565
+ fi
566
+ sleep 1
567
+ done
568
+
569
+ echo "Opening interface..."
570
+
571
+ # --- Check for xdg-open ---
572
+ echo "Checking for xdg-open..."
573
+ if ! command -v xdg-open &> /dev/null; then
574
+ echo "WARNING: 'xdg-open' command not found. Cannot automatically open browser."
575
+ echo "Please install xdg-utils (e.g., 'sudo apt install xdg-utils' or 'sudo dnf install xdg-utils') or open http://localhost:5173 manually."
576
+ # Not exiting, just warning
577
+ else
578
+ echo "xdg-open found. Opening http://localhost:5173 in default browser... (if this fails, open http://localhost:5173 in a web browser of your choice)"
579
+ xdg-open http://localhost:5173
580
+ fi
581
+
582
+ echo "Frontend server is now running. Press Ctrl+C to stop all services."
583
+
584
+ # Wait for the serve process to finish (it's now running in the background)
585
+ wait $SERVE_PID
586
+ SERVE_EXIT_CODE=$?
587
+ SERVE_PID="" # Clear PID as it has exited
588
+
589
+ if [ $SERVE_EXIT_CODE -ne 0 ]; then
590
+ echo "WARNING: Frontend serve process exited with code $SERVE_EXIT_CODE."
591
+ echo "Check the log file for details: $FRONTEND_LOG_FILE"
592
+ # Consider if we should stop background services here if serve crashes
593
+ # Calling cleanup manually if serve fails
594
+ cleanup
595
+ exit 1 # Exit with error code
596
+ fi
597
+
598
+
599
+ # --- 8. Done (Only reached if npx serve exits gracefully, e.g., if killed separately) ---
600
+ cd "$SCRIPT_DIR" # Go back to root directory
601
+ echo "----------------------------------------"
602
+ echo "Frontend serve process finished."
603
+ echo ""
604
+ echo "Helper process log files were saved to:"
605
+ if [ -n "$VALKEY_PID" ]; then
606
+ echo " • Valkey server logs: $VALKEY_LOG_FILE"
607
+ fi
608
+ echo " • Huey worker logs: $HUEY_LOG_FILE"
609
+ echo " • Uvicorn API logs: $UVICORN_LOG_FILE"
610
+ echo " • Frontend serve logs: $FRONTEND_LOG_FILE"
611
+ # Call cleanup to stop background services if serve finishes normally
612
+ cleanup
613
+
614
+ exit 0
local_linux.sh ADDED
@@ -0,0 +1,738 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Exit immediately if a command exits with a non-zero status.
4
+ # set -e # Disabled for manual error checking below
5
+
6
+ # Parse command line arguments
7
+ MODEL_TYPE="normal"
8
+ TENSOR_PARALLELISM=1
9
+
10
+ # Parse arguments
11
+ while [[ $# -gt 0 ]]; do
12
+ case $1 in
13
+ --tensor-parallelism)
14
+ TENSOR_PARALLELISM="$2"
15
+ shift 2
16
+ ;;
17
+ -h|--help)
18
+ echo "Usage: $0 [MODEL_TYPE] [OPTIONS]"
19
+ echo ""
20
+ echo "MODEL_TYPE options:"
21
+ echo " normal - Use default model (Heralax/Augmentoolkit-DataSpecialist-v0.1)"
22
+ echo " small - Use quantized model (Heralax/Augmentoolkit-DataSpecialist-gptqmodel-4bit)"
23
+ echo " path/to/custom/model - Use custom model path"
24
+ echo ""
25
+ echo "OPTIONS:"
26
+ echo " --tensor-parallelism INT - Number of tensor parallel processes for vLLM (default: 1)"
27
+ echo ""
28
+ echo "Examples:"
29
+ echo " $0 # Use normal model (default)"
30
+ echo " $0 small # Use quantized model"
31
+ echo " $0 deepseek-ai/DeepSeek-R1 # Use custom model"
32
+ echo " $0 normal --tensor-parallelism 2 # Use normal model with 2 tensor parallel processes"
33
+ echo " $0 --tensor-parallelism 4 # Use default model with 4 tensor parallel processes"
34
+ echo ""
35
+ exit 0
36
+ ;;
37
+ -*)
38
+ echo "Unknown option: $1"
39
+ echo "Use --help for usage information."
40
+ exit 1
41
+ ;;
42
+ *)
43
+ # This is the MODEL_TYPE (positional argument)
44
+ if [ "$MODEL_TYPE" = "normal" ]; then
45
+ MODEL_TYPE="$1"
46
+ else
47
+ echo "Error: Multiple model types specified"
48
+ echo "Use --help for usage information."
49
+ exit 1
50
+ fi
51
+ shift
52
+ ;;
53
+ esac
54
+ done
55
+
56
+ # Validate tensor parallelism value
57
+ if ! [[ "$TENSOR_PARALLELISM" =~ ^[0-9]+$ ]] || [ "$TENSOR_PARALLELISM" -lt 1 ]; then
58
+ echo "Error: --tensor-parallelism must be a positive integer (got: $TENSOR_PARALLELISM)"
59
+ exit 1
60
+ fi
61
+
62
+ echo "Starting Augmentoolkit services with model type: $MODEL_TYPE"
63
+ echo "Tensor parallelism: $TENSOR_PARALLELISM"
64
+ echo "(Use '$0 --help' to see available model options)"
65
+
66
+ # --- Get script directory and change to it ---
67
+ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
68
+ cd "$SCRIPT_DIR"
69
+ if [ $? -ne 0 ]; then
70
+ echo "ERROR: Failed to change directory to script location '$SCRIPT_DIR'."
71
+ exit 1
72
+ fi
73
+ echo "Running in directory: $(pwd)"
74
+
75
+ # --- Cleanup Function ---
76
+ # Stores PIDs of background processes
77
+ HUEY_PID=""
78
+ UVICORN_PID=""
79
+ SERVE_PID="" # Though serve runs in foreground, trap might catch signals before it starts
80
+ LLAMA_SERVER_PID="" # PID for llama.cpp server
81
+ VALKEY_PID="" # PID for valkey server
82
+
83
+ cleanup() {
84
+ echo "" # Newline after ^C
85
+ echo "----------------------------------------"
86
+ echo "Signal received, stopping background services..."
87
+
88
+ # Function to kill a process and wait for it to terminate
89
+ kill_and_wait() {
90
+ local pid=$1
91
+ local name=$2
92
+ local timeout=9
93
+
94
+ if [ -n "$pid" ] && kill -0 $pid &> /dev/null; then
95
+ echo "Stopping $name (PID $pid)..."
96
+
97
+ # First try SIGTERM (graceful shutdown)
98
+ kill -TERM $pid 2>/dev/null
99
+
100
+ # Wait for process to terminate gracefully
101
+ local count=0
102
+ while [ $count -lt $timeout ] && kill -0 $pid &> /dev/null; do
103
+ sleep 1
104
+ count=$((count + 1))
105
+ done
106
+
107
+ # If still running, force kill with SIGKILL
108
+ if kill -0 $pid &> /dev/null; then
109
+ echo "Process $pid ($name) didn't terminate gracefully, force killing..."
110
+ kill -KILL $pid 2>/dev/null
111
+ sleep 1
112
+ fi
113
+
114
+ # Final check
115
+ if kill -0 $pid &> /dev/null; then
116
+ echo "WARNING: Failed to stop $name (PID $pid)"
117
+ else
118
+ echo "$name stopped successfully."
119
+ fi
120
+ fi
121
+ }
122
+
123
+ # Stop processes in reverse order of startup
124
+ kill_and_wait "$SERVE_PID" "frontend serve"
125
+ kill_and_wait "$UVICORN_PID" "Uvicorn API server"
126
+ kill_and_wait "$HUEY_PID" "Huey worker"
127
+ kill_and_wait "$LLAMA_SERVER_PID" "VLLM server"
128
+ kill_and_wait "$VALKEY_PID" "Valkey server"
129
+
130
+ # Find and kill any remaining huey consumers specific to this Augmentoolkit instance
131
+ echo "Searching for huey consumer processes in current directory ($SCRIPT_DIR)..."
132
+
133
+ # Use ps with full command line and grep for huey_consumer processes in our specific directory
134
+ # This avoids killing huey consumers from other Augmentoolkit instances or projects
135
+ HUEY_PIDS=$(ps aux | grep -E "huey_consumer.*tasks\.huey" | grep "$SCRIPT_DIR" | grep -v grep | awk '{print $2}')
136
+
137
+ if [ -n "$HUEY_PIDS" ]; then
138
+ echo "Found huey consumer processes in current directory: $HUEY_PIDS"
139
+ for pid in $HUEY_PIDS; do
140
+ if kill -0 $pid &> /dev/null; then
141
+ echo "Stopping huey consumer from current directory (PID $pid)..."
142
+ kill -TERM $pid 2>/dev/null
143
+ sleep 2
144
+ if kill -0 $pid &> /dev/null; then
145
+ echo "Force killing huey consumer (PID $pid)..."
146
+ kill -KILL $pid 2>/dev/null
147
+ fi
148
+ fi
149
+ done
150
+ sleep 1
151
+ # Final check for any remaining huey processes in our directory
152
+ REMAINING_HUEY=$(ps aux | grep -E "huey_consumer.*tasks\.huey" | grep "$SCRIPT_DIR" | grep -v grep | awk '{print $2}')
153
+ if [ -n "$REMAINING_HUEY" ]; then
154
+ echo "WARNING: Some huey consumer processes in current directory may still be running: $REMAINING_HUEY"
155
+ else
156
+ echo "All huey consumer processes from current directory stopped successfully."
157
+ fi
158
+ else
159
+ echo "No additional huey consumer processes found in current directory."
160
+ fi
161
+
162
+ # Deactivate virtual environment if active
163
+ if command -v deactivate &> /dev/null; then
164
+ echo "Deactivating virtual environment..."
165
+ deactivate
166
+ fi
167
+
168
+ # Give a moment for any remaining output to flush
169
+ sleep 1
170
+
171
+ # Clear any remaining output and reset cursor
172
+ echo "Cleanup complete."
173
+ echo "----------------------------------------"
174
+
175
+ # Ensure cursor is visible and terminal is in normal state
176
+ printf "\033[?25h" # Show cursor
177
+ printf "\033[0m" # Reset all attributes
178
+
179
+ echo "" # Add extra newline for visual clarity before prompt returns
180
+ }
181
+
182
+ # Trap signals (INT: Ctrl+C, TERM: standard kill) and run cleanup function
183
+ trap cleanup SIGINT SIGTERM
184
+
185
+ # --- 1. Python Virtual Environment & Dependencies ---
186
+ echo "----------------------------------------"
187
+ echo "1 / 9. Setting up Python virtual environment and installing dependencies..."
188
+ VENV_DIR=".venv"
189
+
190
+ # Check if python3.11 command exists (preferred)
191
+ if command -v python3.11 &> /dev/null; then
192
+ PYTHON_CMD="python3.11"
193
+ echo "Using Python 3.11: $(python3.11 --version)"
194
+ elif command -v python3 &> /dev/null; then
195
+ PYTHON_CMD="python3"
196
+ PYTHON_VERSION=$(python3 --version 2>&1 | grep -oE '[0-9]+\.[0-9]+')
197
+ echo "Using Python: $(python3 --version)"
198
+ if [[ "$PYTHON_VERSION" != "3.11" ]]; then
199
+ echo "WARNING: Python 3.11 is recommended but not found. Using $PYTHON_VERSION instead."
200
+ echo "If you encounter issues, please install Python 3.11 specifically."
201
+ fi
202
+ else
203
+ echo "ERROR: Neither python3.11 nor python3 command found."
204
+ echo "Please install Python 3.11 (recommended) or Python 3.x and rerun the script."
205
+ echo "On Ubuntu/Debian, you can install Python 3.11 using:"
206
+ echo " sudo apt update && sudo apt install python3.11 python3.11-venv python3.11-dev"
207
+ echo "On RHEL/CentOS/Fedora, you can install Python 3.11 using:"
208
+ echo " sudo dnf install python3.11 python3.11-devel"
209
+ echo "Or download from: https://www.python.org/downloads/"
210
+ exit 1
211
+ fi
212
+
213
+ # Create virtual environment if it doesn't exist
214
+ if [ ! -d "$VENV_DIR" ]; then
215
+ echo "Creating virtual environment in '$VENV_DIR' using $PYTHON_CMD..."
216
+ $PYTHON_CMD -m venv "$VENV_DIR"
217
+ if [ $? -ne 0 ]; then
218
+ echo "ERROR: Failed to create Python virtual environment using $PYTHON_CMD."
219
+ exit 1
220
+ fi
221
+ else
222
+ echo "Virtual environment '$VENV_DIR' already exists."
223
+ fi
224
+
225
+ # Activate virtual environment
226
+ echo "Activating virtual environment..."
227
+ source "$VENV_DIR/bin/activate"
228
+ if [ $? -ne 0 ]; then
229
+ echo "ERROR: Failed to activate Python virtual environment."
230
+ exit 1
231
+ fi
232
+ echo "Virtual environment activated. Python executable: $(which python)"
233
+
234
+ # --- Check/Install uv ---
235
+ echo "Checking for uv..."
236
+ if ! command -v uv &> /dev/null; then
237
+ echo "'uv' command not found. Attempting to install uv using pip..."
238
+ # Ensure pip is available (it should be with python3)
239
+ if ! python -m pip --version &> /dev/null; then
240
+ echo "ERROR: python -m pip command failed. Cannot install uv."
241
+ exit 1
242
+ fi
243
+ python -m pip install uv
244
+ if [ $? -ne 0 ]; then
245
+ echo "ERROR: Failed to install uv using pip. Please install uv manually (e.g., 'pip install uv') and rerun the script."
246
+ exit 1
247
+ fi
248
+ echo "uv installed successfully."
249
+ else
250
+ echo "uv found."
251
+ fi
252
+ # --- End Check/Install uv ---
253
+
254
+
255
+ # Install dependencies using uv
256
+ echo "Installing Python dependencies from requirements.txt using uv..."
257
+ # Assuming uv is in PATH after the check above and venv is activated
258
+ uv pip install -r requirements.txt
259
+ if [ $? -ne 0 ]; then
260
+ echo "ERROR: Failed to install Python dependencies using uv. Please check requirements.txt and uv logs."
261
+ exit 1 # Cleanup trap will handle exit
262
+ fi
263
+ echo "Installing vllm using uv..."
264
+ uv pip install vllm
265
+ if [ $? -ne 0 ]; then
266
+ echo "ERROR: Failed to install vllm using uv."
267
+ exit 1
268
+ fi
269
+
270
+ echo "Python dependencies installed successfully."
271
+
272
+
273
+ # --- Helper Process Logging Setup ---
274
+ echo "----------------------------------------"
275
+ echo "Setting up helper process logging..."
276
+
277
+ # Create helper_process_logs directory if it doesn't exist
278
+ HELPER_LOGS_DIR="helper_process_logs"
279
+ if [ ! -d "$HELPER_LOGS_DIR" ]; then
280
+ echo "Creating helper process logs directory: $HELPER_LOGS_DIR"
281
+ mkdir -p "$HELPER_LOGS_DIR"
282
+ if [ $? -ne 0 ]; then
283
+ echo "ERROR: Failed to create helper process logs directory."
284
+ exit 1
285
+ fi
286
+ else
287
+ echo "Helper process logs directory already exists: $HELPER_LOGS_DIR"
288
+ fi
289
+
290
+ # Generate timestamp for log files
291
+ TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
292
+ VALKEY_LOG_FILE="$HELPER_LOGS_DIR/valkey_server_${TIMESTAMP}.log"
293
+ LLAMA_SERVER_LOG_FILE="$HELPER_LOGS_DIR/llama_server_${TIMESTAMP}.log"
294
+ HUEY_LOG_FILE="$HELPER_LOGS_DIR/huey_worker_${TIMESTAMP}.log"
295
+ UVICORN_LOG_FILE="$HELPER_LOGS_DIR/uvicorn_api_${TIMESTAMP}.log"
296
+ FRONTEND_LOG_FILE="$HELPER_LOGS_DIR/frontend_serve_${TIMESTAMP}.log"
297
+
298
+ echo "Helper process logs will be saved to:"
299
+ echo " Valkey server: $VALKEY_LOG_FILE"
300
+ echo " VLLM server: $LLAMA_SERVER_LOG_FILE"
301
+ echo " Huey worker: $HUEY_LOG_FILE"
302
+ echo " Uvicorn API: $UVICORN_LOG_FILE"
303
+ echo " Frontend serve: $FRONTEND_LOG_FILE"
304
+ echo "Helper process logging setup complete."
305
+
306
+ #### if REDIS_HOST skip
307
+ if [ -z "$REDIS_HOST" ]; then
308
+ # --- 1.5 Valkey/Redis Check ---
309
+ echo "----------------------------------------"
310
+ echo "2 / 9. Checking for running Valkey/Redis service..."
311
+
312
+ SERVER_CMD=""
313
+ SERVICE_NAME=""
314
+
315
+ # Check for valkey-server first
316
+ if command -v valkey-server &> /dev/null; then
317
+ SERVER_CMD="valkey-server"
318
+ SERVICE_NAME="valkey-server"
319
+ # Fallback to redis-server
320
+ elif command -v redis-server &> /dev/null; then
321
+ SERVER_CMD="redis-server"
322
+ SERVICE_NAME="redis-server"
323
+ else
324
+ # Check if something is listening on the default Valkey/Redis port (6379)
325
+ if command -v ss &> /dev/null; then
326
+ PORT_CHECK_CMD="ss -ltn"
327
+ else
328
+ PORT_CHECK_CMD="netstat -ltn"
329
+ fi
330
+
331
+ if $PORT_CHECK_CMD | grep -q ':6379'; then
332
+ echo "WARNING: Neither 'valkey-server' nor 'redis-server' found in PATH, but something is listening on port 6379."
333
+ echo "Assuming a compatible Valkey/Redis server is running (possibly built from source or custom install)."
334
+ else
335
+ echo "Neither 'valkey-server' nor 'redis-server' found in PATH."
336
+ echo "Building Valkey from source..."
337
+
338
+ # Check if valkey directory already exists
339
+ if [ ! -d "valkey" ]; then
340
+ echo "Cloning Valkey repository..."
341
+ git clone https://github.com/valkey-io/valkey.git
342
+ if [ $? -ne 0 ]; then
343
+ echo "ERROR: Failed to clone Valkey repository. Please check your internet connection and git installation."
344
+ exit 1
345
+ fi
346
+ else
347
+ echo "Valkey directory already exists, skipping clone."
348
+ fi
349
+
350
+ # Only build if valkey-server doesn't exist
351
+ if [ ! -f "valkey/src/valkey-server" ]; then
352
+ echo "Building Valkey..."
353
+ cd valkey
354
+ make
355
+ if [ $? -ne 0 ]; then
356
+ echo "ERROR: Failed to build Valkey. Please check build dependencies (gcc, make, etc.)."
357
+ cd "$SCRIPT_DIR"
358
+ exit 1
359
+ fi
360
+ cd "$SCRIPT_DIR"
361
+ else
362
+ echo "Valkey already built, skipping build."
363
+ fi
364
+
365
+ echo "Starting Valkey server in background..."
366
+ echo "Output will be logged to: $VALKEY_LOG_FILE"
367
+ cd valkey/src
368
+ ./valkey-server > "../../$VALKEY_LOG_FILE" 2>&1 &
369
+ VALKEY_PID=$!
370
+ cd "$SCRIPT_DIR"
371
+
372
+ # Give it a moment to start/fail
373
+ sleep 4
374
+ if ! kill -0 $VALKEY_PID &> /dev/null; then
375
+ echo "ERROR: Valkey server (PID $VALKEY_PID) failed to start or crashed immediately."
376
+ echo "Check the log file for details: $VALKEY_LOG_FILE"
377
+ exit 1
378
+ fi
379
+ echo "Valkey server started successfully with PID $VALKEY_PID."
380
+
381
+ # Set SERVER_CMD and SERVICE_NAME for consistency with the rest of the script
382
+ SERVER_CMD="./valkey/src/valkey-server"
383
+ SERVICE_NAME="valkey-server"
384
+ fi
385
+ fi
386
+ echo "Found $SERVER_CMD."
387
+
388
+ # If we didn't just start Valkey server locally, perform service checks
389
+ if [ -z "$VALKEY_PID" ]; then
390
+ # Check if systemctl exists and is usable
391
+ if command -v systemctl &> /dev/null && systemctl status &> /dev/null; then
392
+ # systemctl seems to be working, so let's use it
393
+ if ! systemctl is-active --quiet $SERVICE_NAME; then
394
+ echo "WARNING: The $SERVICE_NAME service is not active according to systemctl."
395
+ # Check if something is listening on port 6379 as fallback
396
+ if ss -ltn | grep -q ':6379' 2>/dev/null; then
397
+ echo "However, something is listening on port 6379. Assuming a compatible Valkey/Redis server is running."
398
+ else
399
+ echo "ERROR: The $SERVICE_NAME service is not active and nothing is listening on port 6379."
400
+ echo "Please start the service, e.g., 'sudo systemctl start $SERVICE_NAME' and ensure it's enabled to start on boot, e.g., 'sudo systemctl enable $SERVICE_NAME'."
401
+ exit 1
402
+ fi
403
+ else
404
+ echo "The $SERVICE_NAME service is active."
405
+ fi
406
+ else
407
+ # systemctl not available or not working, fall back to simple port check
408
+ echo "WARNING: 'systemctl' not available/running. Checking for service via port."
409
+ if ss -ltn | grep -q ':6379' 2>/dev/null; then
410
+ echo "A service is listening on port 6379. Assuming a compatible Valkey/Redis server is running."
411
+ else
412
+ echo "ERROR: No running Valkey/Redis service detected on port 6379, and could not start one automatically as it was expected to be running."
413
+ echo "Please start valkey-server or redis-server manually in another terminal."
414
+ exit 1
415
+ fi
416
+ fi
417
+ else
418
+ echo "Valkey server was started by this script (PID $VALKEY_PID), skipping system service check."
419
+ fi
420
+ # --- End Valkey/Redis Check (Step 2) ---
421
+ fi
422
+
423
+ # --- 1.6. VLLM Setup and Server ---
424
+ echo "----------------------------------------"
425
+ echo "3 / 9. Starting VLLM server..."
426
+ echo "Output will be logged to: $LLAMA_SERVER_LOG_FILE"
427
+
428
+ # Determine which model to use based on argument
429
+ if [ "$MODEL_TYPE" = "normal" ]; then
430
+ MODEL_NAME="Heralax/Augmentoolkit-DataSpecialist-v0.1"
431
+ echo "Using normal model: $MODEL_NAME"
432
+ elif [ "$MODEL_TYPE" = "small" ]; then
433
+ MODEL_NAME="Heralax/Augmentoolkit-DataSpecialist-gptqmodel-4bit"
434
+ echo "Using small (quantized) model: $MODEL_NAME"
435
+ echo "=============================================="
436
+ echo "!!! IMPORTANT NOTICE !!!"
437
+ echo "=============================================="
438
+ echo "You are using the quantized model."
439
+ echo "When doing local dataset generation, you MUST use:"
440
+ echo " 'Heralax/Augmentoolkit-DataSpecialist-gptqmodel-4bit'"
441
+ echo "in your config files instead of the default model."
442
+ echo ""
443
+ echo "Update your config files to use the quantized model:"
444
+ echo " - pdf_cleaning_small_model: Heralax/Augmentoolkit-DataSpecialist-gptqmodel-4bit"
445
+ echo " - pdf_cleaning_large_model: Heralax/Augmentoolkit-DataSpecialist-gptqmodel-4bit"
446
+ echo " - representation_variation_small_model: Heralax/Augmentoolkit-DataSpecialist-gptqmodel-4bit"
447
+ echo " - representation_variation_large_model: Heralax/Augmentoolkit-DataSpecialist-gptqmodel-4bit"
448
+ echo " - ...etc... for any model. Find replace all cases of 'Heralax/Augmentoolkit-DataSpecialist-v0.1' with the gptq-4bit one."
449
+ echo "=============================================="
450
+ echo ""
451
+ else
452
+ MODEL_NAME="$MODEL_TYPE"
453
+ echo "Using custom model: $MODEL_NAME"
454
+ fi
455
+
456
+ # Start vllm server with output redirection
457
+ vllm serve "$MODEL_NAME" --port 8082 --max-model-len 20000 --tensor-parallel-size "$TENSOR_PARALLELISM" > "$LLAMA_SERVER_LOG_FILE" 2>&1 &
458
+ LLAMA_SERVER_PID=$!
459
+ sleep 5 # Give it a moment to start/fail
460
+
461
+ echo "VLLM server started with PID $LLAMA_SERVER_PID."
462
+ echo "Access VLLM server API at http://localhost:8082"
463
+ # --- End VLLM Setup (Step 3) ---
464
+
465
+
466
+ # --- 2. Huey Worker ---
467
+ echo "----------------------------------------"
468
+ echo "4 / 9. Starting Huey worker in the background (using venv)..."
469
+ echo "Output will be logged to: $HUEY_LOG_FILE"
470
+ # Call the huey_consumer script directly (should be in PATH from venv)
471
+ # Redirect both stdout and stderr to the log file
472
+ huey_consumer tasks.huey > "$HUEY_LOG_FILE" 2>&1 &
473
+ HUEY_PID=$!
474
+ # Give it a moment to start/fail
475
+ sleep 2
476
+ if ! kill -0 $HUEY_PID &> /dev/null; then
477
+ echo "ERROR: Huey worker (PID $HUEY_PID) failed to start or crashed immediately."
478
+ echo "Check the log file for details: $HUEY_LOG_FILE"
479
+ exit 1 # Cleanup trap will handle exit
480
+ fi
481
+ echo "Huey worker started successfully with PID $HUEY_PID."
482
+
483
+
484
+ # --- 3. API Server ---
485
+ echo "----------------------------------------"
486
+ echo "5 / 9. Starting FastAPI server (Uvicorn) in the background (using venv)..."
487
+ echo "Output will be logged to: $UVICORN_LOG_FILE"
488
+ # Host 0.0.0.0 makes it accessible on the network
489
+ # Use python -m uvicorn to ensure using the venv uvicorn
490
+ # Redirect both stdout and stderr to the log file
491
+ python -m uvicorn api:app --host 0.0.0.0 --port 8000 > "$UVICORN_LOG_FILE" 2>&1 &
492
+ UVICORN_PID=$!
493
+ # Give it a moment to start/fail
494
+ sleep 2
495
+ if ! kill -0 $UVICORN_PID &> /dev/null; then
496
+ echo "ERROR: Uvicorn server (PID $UVICORN_PID) failed to start or crashed immediately."
497
+ echo "Check the log file for details: $UVICORN_LOG_FILE"
498
+ # Cleanup trap will handle stopping Huey
499
+ exit 1 # Cleanup trap will handle exit
500
+ fi
501
+ echo "Uvicorn server started successfully with PID $UVICORN_PID. Access API at http://localhost:8000"
502
+
503
+
504
+ # --- 4. NPM Check ---
505
+ echo "----------------------------------------"
506
+ echo "6 / 9. Checking for npm..."
507
+ if ! command -v npm &> /dev/null; then
508
+ echo "ERROR: npm command not found."
509
+ echo "Please install Node.js and npm (e.g., via your system package manager like 'sudo apt install nodejs npm') and run this script again."
510
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
511
+ kill $HUEY_PID $UVICORN_PID
512
+ exit 1
513
+ fi
514
+ echo "npm found."
515
+
516
+ # Check Node.js version
517
+ NODE_VERSION=$(node --version 2>/dev/null | sed 's/v//')
518
+ if [ -z "$NODE_VERSION" ]; then
519
+ echo "ERROR: Could not determine Node.js version."
520
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
521
+ kill $HUEY_PID $UVICORN_PID
522
+ exit 1
523
+ fi
524
+
525
+ # Extract major version number
526
+ NODE_MAJOR_VERSION=$(echo "$NODE_VERSION" | cut -d. -f1)
527
+
528
+ echo "Node.js version: v$NODE_VERSION"
529
+
530
+ # Check if Node.js version is at least 18
531
+ if [ "$NODE_MAJOR_VERSION" -lt 18 ]; then
532
+ echo "ERROR: Node.js version $NODE_VERSION is too old."
533
+ echo "This application requires Node.js 18.0.0 or higher (20+ recommended)."
534
+ echo ""
535
+ echo "To upgrade Node.js, run the following commands:"
536
+ echo ""
537
+ echo "1. Add NodeSource repository:"
538
+ echo " curl -fsSL https://deb.nodesource.com/setup_20.x | bash -"
539
+ echo ""
540
+ echo "2. Remove conflicting packages (if any):"
541
+ echo " apt-get remove -y libnode-dev libnode72"
542
+ echo ""
543
+ echo "3. Install Node.js 20.x:"
544
+ echo " apt-get install -y nodejs"
545
+ echo ""
546
+ echo " OR run all steps at once:"
547
+ echo " curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get remove -y libnode-dev libnode72 2>/dev/null || true && apt-get install -y nodejs"
548
+ echo ""
549
+ echo "Alternative method using Node Version Manager (nvm):"
550
+ echo " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash"
551
+ echo " source ~/.bashrc"
552
+ echo " nvm install 20"
553
+ echo " nvm use 20"
554
+ echo ""
555
+ echo "After upgrading, please run this script again."
556
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
557
+ kill $HUEY_PID $UVICORN_PID
558
+ exit 1
559
+ fi
560
+
561
+
562
+ # --- 5. Frontend Dependencies ---
563
+ echo "----------------------------------------"
564
+ echo "7 / 9. Installing frontend dependencies in atk-interface..."
565
+ if [ ! -d "atk-interface" ]; then
566
+ echo "ERROR: Directory 'atk-interface' not found in $(pwd)."
567
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
568
+ kill $HUEY_PID $UVICORN_PID
569
+ exit 1
570
+ fi
571
+ cd atk-interface
572
+ npm install
573
+ if [ $? -ne 0 ]; then
574
+ echo "ERROR: Failed to install npm dependencies in atk-interface. Check package.json and npm logs."
575
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
576
+ kill $HUEY_PID $UVICORN_PID
577
+ cd "$SCRIPT_DIR" # Go back to root
578
+ exit 1
579
+ fi
580
+ echo "Frontend dependencies installed."
581
+
582
+
583
+ # --- 6. Frontend Build ---
584
+ echo "----------------------------------------"
585
+ echo "8 / 9. Building frontend application in atk-interface..."
586
+ npm run build
587
+ if [ $? -ne 0 ]; then
588
+ echo "ERROR: Failed to build frontend application. Check build script in package.json and logs."
589
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
590
+ kill $HUEY_PID $UVICORN_PID
591
+ cd "$SCRIPT_DIR" # Go back to root
592
+ exit 1
593
+ fi
594
+ echo "Frontend built successfully."
595
+
596
+
597
+ # --- 7. Frontend Serve ---
598
+ echo "----------------------------------------"
599
+ echo "9 / 9. Serving frontend application from atk-interface/dist/ in the FOREGROUND..."
600
+ echo "Output will be logged to: $FRONTEND_LOG_FILE"
601
+ # Ensure dist directory exists after build
602
+ if [ ! -d "dist" ]; then
603
+ echo "ERROR: 'dist' directory not found in atk-interface after build."
604
+ # Cleanup trap will handle stopping background services
605
+ exit 1
606
+ fi
607
+
608
+
609
+ # Show summary of all log files
610
+ echo ""
611
+ echo "═══════════════════════════════════════════════════════════════════════════════"
612
+ echo "All helper processes are now running with output redirected to log files:"
613
+ if [ -n "$VALKEY_PID" ]; then
614
+ echo " • Valkey server logs: $VALKEY_LOG_FILE"
615
+ fi
616
+ echo " • VLLM server logs: $LLAMA_SERVER_LOG_FILE"
617
+ echo " • Huey worker logs: $HUEY_LOG_FILE"
618
+ echo " • Uvicorn API logs: $UVICORN_LOG_FILE"
619
+ echo " • Frontend serve logs: $FRONTEND_LOG_FILE"
620
+ echo ""
621
+ echo "You can monitor these logs in real-time with:"
622
+ if [ -n "$VALKEY_PID" ]; then
623
+ echo " tail -f $VALKEY_LOG_FILE"
624
+ fi
625
+ echo " tail -f $LLAMA_SERVER_LOG_FILE"
626
+ echo " tail -f $HUEY_LOG_FILE"
627
+ echo " tail -f $UVICORN_LOG_FILE"
628
+ echo " tail -f $FRONTEND_LOG_FILE"
629
+ echo "═══════════════════════════════════════════════════════════════════════════════"
630
+ echo ""
631
+
632
+ # Serve on port 5173 (matches CORS config)
633
+ # Run npx serve in the background first, then open browser once it's ready
634
+ echo "Starting serve process (output redirected to log file)..."
635
+ # Redirect both stdout and stderr to the log file - use ../ to go back to script directory
636
+ npx serve -s dist --listen 5173 > "../$FRONTEND_LOG_FILE" 2>&1 &
637
+ SERVE_PID=$!
638
+
639
+ # Wait for the serve process to be ready
640
+ echo "Waiting for frontend server to start..."
641
+ sleep 3
642
+
643
+ # Check if serve process is still running
644
+ if ! kill -0 $SERVE_PID &> /dev/null; then
645
+ echo "ERROR: Frontend serve process failed to start or crashed immediately."
646
+ echo "Check the log file for details: $FRONTEND_LOG_FILE"
647
+ cleanup
648
+ exit 1
649
+ fi
650
+
651
+ # Wait for the server to actually be listening on port 5173
652
+ echo "Checking if server is responding..."
653
+ for i in {1..10}; do
654
+ if command -v curl &> /dev/null; then
655
+ if curl -s http://localhost:5173 > /dev/null 2>&1; then
656
+ break
657
+ fi
658
+ elif command -v wget &> /dev/null; then
659
+ if wget -q --spider http://localhost:5173 > /dev/null 2>&1; then
660
+ break
661
+ fi
662
+ else
663
+ # Fallback: just check if something is listening on port 5173
664
+ if command -v ss &> /dev/null; then
665
+ if ss -ltn | grep -q ':5173'; then
666
+ break
667
+ fi
668
+ elif command -v netstat &> /dev/null; then
669
+ if netstat -ltn | grep -q ':5173'; then
670
+ break
671
+ fi
672
+ else
673
+ # No way to check, just wait a bit more
674
+ sleep 1
675
+ break
676
+ fi
677
+ fi
678
+ sleep 1
679
+ done
680
+
681
+ if ! kill -0 $LLAMA_SERVER_PID &> /dev/null; then
682
+ echo "ERROR: VLLM server (PID $LLAMA_SERVER_PID) failed to start or crashed."
683
+ echo "Check the log file for details: $LLAMA_SERVER_LOG_FILE"
684
+ echo "The model probably wants to take too much memory. You may need to:"
685
+ echo " - Use 'small' argument to run quantized model: ./local_linux.sh small"
686
+ echo " - Use a custom model path: ./local_linux.sh path/to/your/model"
687
+ echo " - Use an API instead of local inference"
688
+ cleanup
689
+ exit 1
690
+ fi
691
+
692
+ echo "Opening interface..."
693
+
694
+ # --- Check for xdg-open ---
695
+ echo "Checking for xdg-open..."
696
+ if ! command -v xdg-open &> /dev/null; then
697
+ echo "WARNING: 'xdg-open' command not found. Cannot automatically open browser."
698
+ echo "Please install xdg-utils (e.g., 'sudo apt install xdg-utils' or 'sudo dnf install xdg-utils') or open http://localhost:5173 manually."
699
+ # Not exiting, just warning
700
+ else
701
+ echo "xdg-open found. Opening http://localhost:5173 in default browser..."
702
+ xdg-open http://localhost:5173
703
+ fi
704
+
705
+ echo "Frontend server is now running. Press Ctrl+C to stop all services."
706
+
707
+ # Wait for the serve process to finish (it's now running in the background)
708
+ wait $SERVE_PID
709
+ SERVE_EXIT_CODE=$?
710
+ SERVE_PID="" # Clear PID as it has exited
711
+
712
+ if [ $SERVE_EXIT_CODE -ne 0 ]; then
713
+ echo "WARNING: Frontend serve process exited with code $SERVE_EXIT_CODE."
714
+ echo "Check the log file for details: $FRONTEND_LOG_FILE"
715
+ # Consider if we should stop background services here if serve crashes
716
+ # Calling cleanup manually if serve fails
717
+ cleanup
718
+ exit 1 # Exit with error code
719
+ fi
720
+
721
+
722
+ # --- 8. Done (Only reached if npx serve exits gracefully, e.g., if killed separately) ---
723
+ cd "$SCRIPT_DIR" # Go back to root directory
724
+ echo "----------------------------------------"
725
+ echo "Frontend serve process finished."
726
+ echo ""
727
+ echo "Helper process log files were saved to:"
728
+ if [ -n "$VALKEY_PID" ]; then
729
+ echo " • Valkey server logs: $VALKEY_LOG_FILE"
730
+ fi
731
+ echo " • VLLM server logs: $LLAMA_SERVER_LOG_FILE"
732
+ echo " • Huey worker logs: $HUEY_LOG_FILE"
733
+ echo " • Uvicorn API logs: $UVICORN_LOG_FILE"
734
+ echo " • Frontend serve logs: $FRONTEND_LOG_FILE"
735
+ # Call cleanup to stop background services if serve finishes normally
736
+ cleanup
737
+
738
+ exit 0
local_macos.sh ADDED
@@ -0,0 +1,713 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Exit immediately if a command exits with a non-zero status.
4
+ # set -e # Disabled for manual error checking below
5
+
6
+ echo "Starting Augmentoolkit services..."
7
+
8
+ # --- Get script directory and change to it ---
9
+ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
10
+ cd "$SCRIPT_DIR"
11
+ if [ $? -ne 0 ]; then
12
+ echo "ERROR: Failed to change directory to script location '$SCRIPT_DIR'."
13
+ exit 1
14
+ fi
15
+ echo "Running in directory: $(pwd)"
16
+
17
+ # --- Cleanup Function ---
18
+ # Stores PIDs of background processes
19
+ HUEY_PID=""
20
+ UVICORN_PID=""
21
+ SERVE_PID="" # Though serve runs in foreground, trap might catch signals before it starts
22
+ LLAMA_SERVER_PID="" # PID for llama.cpp server
23
+
24
+ cleanup() {
25
+ echo "" # Newline after ^C
26
+ echo "----------------------------------------"
27
+ echo "Signal received, stopping background services..."
28
+
29
+ # Function to kill a process and wait for it to terminate
30
+ kill_and_wait() {
31
+ local pid=$1
32
+ local name=$2
33
+ local timeout=9
34
+
35
+ if [ -n "$pid" ] && kill -0 $pid &> /dev/null; then
36
+ echo "Stopping $name (PID $pid)..."
37
+
38
+ # First try SIGTERM (graceful shutdown)
39
+ kill -TERM $pid 2>/dev/null
40
+
41
+ # Wait for process to terminate gracefully
42
+ local count=0
43
+ while [ $count -lt $timeout ] && kill -0 $pid &> /dev/null; do
44
+ sleep 1
45
+ count=$((count + 1))
46
+ done
47
+
48
+ # If still running, force kill with SIGKILL
49
+ if kill -0 $pid &> /dev/null; then
50
+ echo "Process $pid ($name) didn't terminate gracefully, force killing..."
51
+ kill -KILL $pid 2>/dev/null
52
+ sleep 1
53
+ fi
54
+
55
+ # Final check
56
+ if kill -0 $pid &> /dev/null; then
57
+ echo "WARNING: Failed to stop $name (PID $pid)"
58
+ else
59
+ echo "$name stopped successfully."
60
+ fi
61
+ fi
62
+ }
63
+
64
+ # Stop processes in reverse order of startup
65
+ kill_and_wait "$SERVE_PID" "frontend serve"
66
+ kill_and_wait "$UVICORN_PID" "Uvicorn API server"
67
+ kill_and_wait "$HUEY_PID" "Huey worker"
68
+ kill_and_wait "$LLAMA_SERVER_PID" "LLaMA.cpp server"
69
+
70
+ # Find and kill any remaining huey consumers specific to this Augmentoolkit instance
71
+ echo "Searching for huey consumer processes in current directory ($SCRIPT_DIR)..."
72
+
73
+ # Use ps with full command line and grep for huey_consumer processes in our specific directory
74
+ # This avoids killing huey consumers from other Augmentoolkit instances or projects
75
+ HUEY_PIDS=$(ps aux | grep -E "huey_consumer.*tasks\.huey" | grep "$SCRIPT_DIR" | grep -v grep | awk '{print $2}')
76
+
77
+ if [ -n "$HUEY_PIDS" ]; then
78
+ echo "Found huey consumer processes in current directory: $HUEY_PIDS"
79
+ for pid in $HUEY_PIDS; do
80
+ if kill -0 $pid &> /dev/null; then
81
+ echo "Stopping huey consumer from current directory (PID $pid)..."
82
+ kill -TERM $pid 2>/dev/null
83
+ sleep 2
84
+ if kill -0 $pid &> /dev/null; then
85
+ echo "Force killing huey consumer (PID $pid)..."
86
+ kill -KILL $pid 2>/dev/null
87
+ fi
88
+ fi
89
+ done
90
+ sleep 1
91
+ # Final check for any remaining huey processes in our directory
92
+ REMAINING_HUEY=$(ps aux | grep -E "huey_consumer.*tasks\.huey" | grep "$SCRIPT_DIR" | grep -v grep | awk '{print $2}')
93
+ if [ -n "$REMAINING_HUEY" ]; then
94
+ echo "WARNING: Some huey consumer processes in current directory may still be running: $REMAINING_HUEY"
95
+ else
96
+ echo "All huey consumer processes from current directory stopped successfully."
97
+ fi
98
+ else
99
+ echo "No additional huey consumer processes found in current directory."
100
+ fi
101
+
102
+ # Deactivate virtual environment if active
103
+ if command -v deactivate &> /dev/null; then
104
+ echo "Deactivating virtual environment..."
105
+ deactivate
106
+ fi
107
+
108
+ # Give a moment for any remaining output to flush
109
+ sleep 1
110
+
111
+ # Clear any remaining output and reset cursor
112
+ echo "Cleanup complete."
113
+ echo "----------------------------------------"
114
+
115
+ # Ensure cursor is visible and terminal is in normal state
116
+ printf "\033[?25h" # Show cursor
117
+ printf "\033[0m" # Reset all attributes
118
+
119
+ # Do not exit from cleanup function itself, let the caller decide exit status
120
+ echo "" # Add extra newline for visual clarity before prompt returns
121
+ }
122
+
123
+ # Trap signals (INT: Ctrl+C, TERM: standard kill) and run cleanup function
124
+ trap cleanup SIGINT SIGTERM
125
+
126
+ # --- 1. Python Virtual Environment & Dependencies ---
127
+ echo "----------------------------------------"
128
+ echo "1 / 10. Setting up Python virtual environment and installing dependencies..."
129
+ VENV_DIR=".venv"
130
+
131
+ # Check if python3.11 command exists (preferred)
132
+ if command -v python3.11 &> /dev/null; then
133
+ PYTHON_CMD="python3.11"
134
+ echo "Using Python 3.11: $(python3.11 --version)"
135
+ elif command -v python3 &> /dev/null; then
136
+ PYTHON_CMD="python3"
137
+ PYTHON_VERSION=$(python3 --version 2>&1 | grep -oE '[0-9]+\.[0-9]+')
138
+ echo "Using Python: $(python3 --version)"
139
+ if [[ "$PYTHON_VERSION" != "3.11" ]]; then
140
+ echo "WARNING: Python 3.11 is recommended but not found. Using $PYTHON_VERSION instead."
141
+ echo "If you encounter issues, please install Python 3.11 specifically."
142
+ fi
143
+ else
144
+ echo "ERROR: Neither python3.11 nor python3 command found."
145
+ echo "Please install Python 3.11 (recommended) or Python 3.x and rerun the script."
146
+ echo "On macOS, you can install Python 3.11 using:"
147
+ echo " brew install python@3.11"
148
+ echo "Or download from: https://www.python.org/downloads/"
149
+ exit 1
150
+ fi
151
+
152
+ # --- Check/Install uv ---
153
+ echo "Checking for uv..."
154
+ if ! command -v uv &> /dev/null; then
155
+ echo "'uv' command not found. Attempting to install uv using pip..."
156
+ # Ensure pip is available (it should be with the selected Python version)
157
+ if ! $PYTHON_CMD -m pip --version &> /dev/null; then
158
+ echo "ERROR: $PYTHON_CMD -m pip command failed. Cannot install uv."
159
+ exit 1
160
+ fi
161
+ $PYTHON_CMD -m pip install uv
162
+ if [ $? -ne 0 ]; then
163
+ echo "ERROR: Failed to install uv using pip. Please install uv manually (e.g., 'pip install uv' or 'brew install uv') and rerun the script."
164
+ exit 1
165
+ fi
166
+ echo "uv installed successfully."
167
+ else
168
+ echo "uv found."
169
+ fi
170
+ # --- End Check/Install uv ---
171
+
172
+
173
+ # Create virtual environment if it doesn't exist
174
+ if [ ! -d "$VENV_DIR" ]; then
175
+ echo "Creating virtual environment in '$VENV_DIR' using $PYTHON_CMD..."
176
+ $PYTHON_CMD -m venv "$VENV_DIR"
177
+ if [ $? -ne 0 ]; then
178
+ echo "ERROR: Failed to create Python virtual environment using $PYTHON_CMD."
179
+ exit 1
180
+ fi
181
+ else
182
+ echo "Virtual environment '$VENV_DIR' already exists."
183
+ fi
184
+
185
+ # Activate virtual environment
186
+ echo "Activating virtual environment..."
187
+ source "$VENV_DIR/bin/activate"
188
+ if [ $? -ne 0 ]; then
189
+ echo "ERROR: Failed to activate Python virtual environment."
190
+ exit 1
191
+ fi
192
+ echo "Virtual environment activated. Python executable: $(which python)"
193
+
194
+ # --- Attempt to install SSL certificates (macOS specific fix) ---
195
+ echo "Attempting to install SSL certificates for the active Python environment..."
196
+ # Get the full path to the python executable inside the venv
197
+ VENV_PYTHON_EXE=$(which python)
198
+ # Try to infer the Applications directory path (this is heuristic)
199
+ # Assumes Python was installed via the standard macOS installer
200
+ PYTHON_BASE_DIR=$(dirname "$(dirname "$VENV_PYTHON_EXE")") # Go up two levels from venv/bin/python
201
+ CERT_SCRIPT_PATH=""
202
+ # Check common installation patterns
203
+ if [ -d "$PYTHON_BASE_DIR/Install Certificates.command" ]; then
204
+ # Might be like /Library/Frameworks/Python.framework/Versions/3.11/Install Certificates.command
205
+ CERT_SCRIPT_PATH="$PYTHON_BASE_DIR/Install Certificates.command"
206
+ elif [ -d "/Applications/Python $(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')/Install Certificates.command" ]; then
207
+ # Check standard /Applications path using the version
208
+ PY_VERSION_SHORT=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
209
+ CERT_SCRIPT_PATH="/Applications/Python ${PY_VERSION_SHORT}/Install Certificates.command"
210
+ fi
211
+
212
+ if [ -n "$CERT_SCRIPT_PATH" ] && [ -f "$CERT_SCRIPT_PATH" ]; then
213
+ echo "Found certificate installation script: $CERT_SCRIPT_PATH"
214
+ echo "Running certificate installation script..."
215
+ # Execute the script using the venv's python
216
+ "$CERT_SCRIPT_PATH"
217
+ if [ $? -ne 0 ]; then
218
+ echo "WARNING: Certificate installation script finished with a non-zero status. SSL issues might persist."
219
+ # Continue anyway, maybe it partially worked or wasn't strictly needed
220
+ else
221
+ echo "Certificate installation script completed successfully."
222
+ fi
223
+ else
224
+ echo "WARNING: Could not find the 'Install Certificates.command' script automatically."
225
+ echo "If you encounter SSL errors (e.g., downloading NLTK data, used in chunking), you may need to run it manually."
226
+ echo "Look for it in your Python application folder (e.g., /Applications/Python 3.11/)."
227
+ fi
228
+ # --- End SSL Certificate Install (Step 2) ---
229
+
230
+
231
+ # Install dependencies using uv
232
+ echo "Installing Python dependencies from requirements.txt using uv..."
233
+ # Assuming uv is in PATH after the check above and venv is activated
234
+ uv pip install -r requirements.txt
235
+ if [ $? -ne 0 ]; then
236
+ echo "ERROR: Failed to install Python dependencies using uv. Please check requirements.txt and uv logs."
237
+ exit 1 # Cleanup trap will handle exit
238
+ fi
239
+ echo "Python dependencies installed successfully."
240
+
241
+ # --- Configure SSL Certificate Path (Part of Step 2 Logic) ---
242
+ echo "----------------------------------------"
243
+ echo "2 / 10. Configuring SSL certificate path..." # Renumbered
244
+
245
+ # Ensure certifi is installed (uv pip install should handle this if in requirements.txt,
246
+ # but explicit check adds robustness - though uv handles it well)
247
+ # It's likely already installed as a dependency of requests/huggingface_hub etc.
248
+ # If dependency issues arise, add 'certifi' explicitly to requirements.txt
249
+
250
+ # Get the certificate path from certifi
251
+ CERT_PATH=$(python -c "import certifi; print(certifi.where())" 2>/dev/null) # Suppress stderr in case certifi isn't installed yet
252
+
253
+ if [ -n "$CERT_PATH" ] && [ -f "$CERT_PATH" ]; then
254
+ echo "Setting SSL_CERT_FILE environment variable to: $CERT_PATH"
255
+ export SSL_CERT_FILE="$CERT_PATH"
256
+ echo "SSL_CERT_FILE set for this script session and its children."
257
+ # Optional: Add a connection test here if desired
258
+ # python -c "import requests; r = requests.get('https://huggingface.co', timeout=10); print(f'Connection test status: {r.status_code}')"
259
+ # if [ $? -ne 0 ]; then echo "WARNING: Connection test failed after setting SSL_CERT_FILE."; fi
260
+ else
261
+ echo "WARNING: Could not determine certificate path using certifi. Certifi might not be installed yet or there's an issue."
262
+ echo "Attempting to proceed, but SSL errors (like CERTIFICATE_VERIFY_FAILED) may occur."
263
+ echo "Ensure 'certifi' is listed in requirements.txt if SSL errors persist."
264
+ fi
265
+ # --- End SSL Configuration ---
266
+
267
+
268
+ # --- Helper Process Logging Setup ---
269
+ echo "----------------------------------------"
270
+ echo "Setting up helper process logging..."
271
+
272
+ # Create helper_process_logs directory if it doesn't exist
273
+ HELPER_LOGS_DIR="helper_process_logs"
274
+ if [ ! -d "$HELPER_LOGS_DIR" ]; then
275
+ echo "Creating helper process logs directory: $HELPER_LOGS_DIR"
276
+ mkdir -p "$HELPER_LOGS_DIR"
277
+ if [ $? -ne 0 ]; then
278
+ echo "ERROR: Failed to create helper process logs directory."
279
+ exit 1
280
+ fi
281
+ else
282
+ echo "Helper process logs directory already exists: $HELPER_LOGS_DIR"
283
+ fi
284
+
285
+ # Generate timestamp for log files
286
+ TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
287
+ LLAMA_SERVER_LOG_FILE="$HELPER_LOGS_DIR/llama_server_${TIMESTAMP}.log"
288
+ HUEY_LOG_FILE="$HELPER_LOGS_DIR/huey_worker_${TIMESTAMP}.log"
289
+ UVICORN_LOG_FILE="$HELPER_LOGS_DIR/uvicorn_api_${TIMESTAMP}.log"
290
+ FRONTEND_LOG_FILE="$HELPER_LOGS_DIR/frontend_serve_${TIMESTAMP}.log"
291
+
292
+ echo "Helper process logs will be saved to:"
293
+ echo " LLaMA server: $LLAMA_SERVER_LOG_FILE"
294
+ echo " Huey worker: $HUEY_LOG_FILE"
295
+ echo " Uvicorn API: $UVICORN_LOG_FILE"
296
+ echo " Frontend serve: $FRONTEND_LOG_FILE"
297
+ echo "Helper process logging setup complete."
298
+
299
+
300
+ # --- 1.5 Valkey (Redis Fork) Check & Installation ---
301
+ echo "----------------------------------------"
302
+ echo "3 / 10. Checking for Valkey (Redis fork)..." # Renumbered
303
+
304
+ # First, check if something is already listening on port 6379
305
+ if command -v lsof &> /dev/null; then
306
+ PORT_CHECK_CMD="lsof -i :6379"
307
+ elif command -v netstat &> /dev/null; then
308
+ PORT_CHECK_CMD="netstat -an"
309
+ else
310
+ PORT_CHECK_CMD=""
311
+ fi
312
+
313
+ if [ -n "$PORT_CHECK_CMD" ]; then
314
+ if [ "$PORT_CHECK_CMD" = "lsof -i :6379" ]; then
315
+ if $PORT_CHECK_CMD &> /dev/null; then
316
+ echo "Something is already listening on port 6379. Assuming a compatible Valkey/Redis server is running."
317
+ echo "Skipping Valkey installation and setup."
318
+ else
319
+ NEED_VALKEY_SETUP=true
320
+ fi
321
+ else
322
+ # netstat case
323
+ if $PORT_CHECK_CMD | grep -q ':6379'; then
324
+ echo "Something is already listening on port 6379. Assuming a compatible Valkey/Redis server is running."
325
+ echo "Skipping Valkey installation and setup."
326
+ else
327
+ NEED_VALKEY_SETUP=true
328
+ fi
329
+ fi
330
+ else
331
+ echo "WARNING: Cannot check port 6379 (lsof and netstat not available). Proceeding with Valkey setup."
332
+ NEED_VALKEY_SETUP=true
333
+ fi
334
+
335
+ if [ "$NEED_VALKEY_SETUP" = true ]; then
336
+ # Check if brew command exists
337
+ if ! command -v brew &> /dev/null; then
338
+ echo "Homebrew (brew) command not found."
339
+ read -p "Homebrew is required to install Valkey. Install Homebrew now? (y/N) " -n 1 -r
340
+ echo # Move to a new line after input
341
+ if [[ $REPLY =~ ^[Yy]$ ]]; then
342
+ echo "Attempting to install Homebrew. This may take a few minutes and will likely ask for your administrator password..."
343
+ # Execute the official Homebrew installation script
344
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
345
+ # Check if installation succeeded by seeing if 'brew' command is now available
346
+ # Might require sourcing profile or opening new shell, but let's check command first
347
+ if ! command -v brew &> /dev/null; then
348
+ echo "ERROR: Homebrew installation appears to have failed or is not available in this shell session."
349
+ echo "Please try installing Homebrew manually from https://brew.sh/ and run this script again, possibly in a new terminal window."
350
+ exit 1
351
+ else
352
+ # Add brew to PATH for the current script execution if possible
353
+ # This is complex as the path depends on architecture (Intel vs ARM)
354
+ # For simplicity, we'll rely on the installer potentially modifying the profile
355
+ # or the user running in a new shell later if 'brew' isn't found immediately after install.
356
+ # A more robust solution might involve detecting arch and adding the known path.
357
+ echo "Homebrew installed successfully. Proceeding..."
358
+ # Re-check explicitly here just in case the path isn't immediately active
359
+ if ! command -v brew &> /dev/null; then
360
+ echo "ERROR: Homebrew installed but 'brew' command still not found in PATH for this session."
361
+ echo "Please open a new terminal window and run this script again."
362
+ exit 1
363
+ fi
364
+ fi
365
+ else
366
+ echo "Homebrew installation declined. Valkey cannot be installed automatically."
367
+ echo "Please install Homebrew (https://brew.sh/) and Valkey ('brew install valkey') manually and rerun the script."
368
+ exit 1
369
+ fi
370
+ fi
371
+ echo "Homebrew found."
372
+
373
+ # Check if valkey is installed
374
+ if ! brew list valkey &>/dev/null; then
375
+ echo "Valkey not found via Homebrew. Attempting to install..."
376
+ # Update brew minimally and install valkey
377
+ # Using 'brew install' directly avoids running 'brew update' if possible,
378
+ # but Homebrew might still update itself or core formulae.
379
+ # We avoid 'brew upgrade' to prevent upgrading all packages.
380
+ brew install valkey
381
+ if [ $? -ne 0 ]; then
382
+ echo "ERROR: Failed to install Valkey using Homebrew. Please install it manually ('brew install valkey') and rerun the script."
383
+ exit 1
384
+ fi
385
+ echo "Valkey installed successfully."
386
+ else
387
+ echo "Valkey found via Homebrew."
388
+ fi
389
+
390
+ # Check if Valkey service is running, start if not
391
+ if ! brew services list | grep -q "valkey.*started"; then
392
+ echo "Valkey service is not running. Starting it now..."
393
+ brew services start valkey
394
+ if [ $? -ne 0 ]; then
395
+ echo "ERROR: Failed to start Valkey service using Homebrew. Please check Homebrew services ('brew services list') and start Valkey manually if needed."
396
+ exit 1
397
+ fi
398
+ # Give it a moment to start
399
+ sleep 2
400
+ if ! brew services list | grep -q "valkey.*started"; then
401
+ echo "ERROR: Valkey service failed to stay running after starting. Please investigate ('brew services list', check logs)."
402
+ exit 1
403
+ fi
404
+ echo "Valkey service started successfully."
405
+ else
406
+ echo "Valkey service is already running."
407
+ fi
408
+ fi # End NEED_VALKEY_SETUP conditional
409
+ # --- End Valkey Check ---
410
+
411
+
412
+ # --- 1.6. LLaMA.cpp Setup and Server ---
413
+ echo "----------------------------------------"
414
+ echo "4 / 10. Setting up LLaMA.cpp server..." # Renumbered
415
+ LLAMA_CPP_DIR_NAME="llama.cpp" # Relative to SCRIPT_DIR
416
+ LLAMA_CPP_FULL_PATH="$SCRIPT_DIR/$LLAMA_CPP_DIR_NAME"
417
+ LLAMA_MODEL_SUBDIR="models" # Subdirectory within LLAMA_CPP_DIR
418
+
419
+ # Model details
420
+ MODEL_URL="https://huggingface.co/Heralax/Augmentoolkit-DataSpecialist-v0.1-GGUF/resolve/main/Augmentoolkit-DataSpecialist-7.2B-Q8_0.gguf?download=true"
421
+ MODEL_FILENAME="augmentoolkit-datagen-7.2B-Q8_0.gguf"
422
+ LLAMA_MODEL_PATH="$LLAMA_CPP_FULL_PATH/$LLAMA_MODEL_SUBDIR/$MODEL_FILENAME"
423
+
424
+ # Check for prerequisites
425
+ if ! command -v git &> /dev/null; then
426
+ echo "ERROR: git command not found. Please install git and rerun the script."
427
+ cleanup
428
+ exit 1
429
+ fi
430
+ if ! command -v cmake &> /dev/null; then
431
+ echo "ERROR: cmake command not found. Please install cmake (e.g., 'brew install cmake') and rerun the script."
432
+ cleanup
433
+ exit 1
434
+ fi
435
+ DOWNLOAD_CMD=""
436
+ if command -v curl &> /dev/null; then # Prefer curl as it's more common on macOS by default
437
+ DOWNLOAD_CMD="curl"
438
+ elif command -v wget &> /dev/null; then
439
+ DOWNLOAD_CMD="wget"
440
+ else
441
+ echo "ERROR: Neither curl nor wget found. Please install one of them to download the model."
442
+ cleanup
443
+ exit 1
444
+ fi
445
+
446
+ # Clone llama.cpp if not already cloned
447
+ if [ ! -d "$LLAMA_CPP_FULL_PATH" ]; then
448
+ echo "Cloning llama.cpp repository into '$LLAMA_CPP_FULL_PATH'..."
449
+ git clone https://github.com/ggml-org/llama.cpp.git "$LLAMA_CPP_FULL_PATH"
450
+ if [ $? -ne 0 ]; then
451
+ echo "ERROR: Failed to clone llama.cpp repository."
452
+ cleanup
453
+ exit 1
454
+ fi
455
+ echo "Checking out specific commit..."
456
+ cd "$LLAMA_CPP_FULL_PATH"
457
+ # git checkout 3cb203c89f60483e349f841684173446ed23c28f
458
+ git checkout 3cb203c89f60483e349f841684173446ed23c28f
459
+ if [ $? -ne 0 ]; then
460
+ echo "ERROR: Failed to checkout specific commit."
461
+ cleanup
462
+ exit 1
463
+ fi
464
+ cd - > /dev/null
465
+ else
466
+ echo "llama.cpp directory '$LLAMA_CPP_FULL_PATH' already exists. Skipping clone."
467
+ # Optional: Add logic to pull latest changes if desired
468
+ # echo "Pulling latest changes for llama.cpp..."
469
+ # (cd "$LLAMA_CPP_FULL_PATH" && git pull)
470
+ fi
471
+
472
+ # Create model directory inside llama.cpp if it doesn't exist
473
+ mkdir -p "$LLAMA_CPP_FULL_PATH/$LLAMA_MODEL_SUBDIR"
474
+ if [ $? -ne 0 ]; then
475
+ echo "ERROR: Failed to create model directory '$LLAMA_CPP_FULL_PATH/$LLAMA_MODEL_SUBDIR'."
476
+ cleanup
477
+ exit 1
478
+ fi
479
+
480
+ # Download model if it doesn't exist
481
+ if [ ! -f "$LLAMA_MODEL_PATH" ]; then
482
+ echo "LLaMA.cpp model file '$MODEL_FILENAME' not found at '$LLAMA_MODEL_PATH'."
483
+ read -p "This will download a ~7.7GB model file. This may take a while and obviously requires a bit of disk space. Proceed? (y/N) " -n 1 -r
484
+ echo # Newline
485
+ if [[ $REPLY =~ ^[Yy]$ ]]; then
486
+ echo "Downloading LLaMA.cpp model to '$LLAMA_MODEL_PATH'..."
487
+ if [ "$DOWNLOAD_CMD" = "curl" ]; then
488
+ curl -L "$MODEL_URL" -o "$LLAMA_MODEL_PATH"
489
+ else # wget
490
+ wget "$MODEL_URL" -O "$LLAMA_MODEL_PATH"
491
+ fi
492
+ if [ $? -ne 0 ]; then
493
+ echo "ERROR: Failed to download LLaMA.cpp model."
494
+ rm -f "$LLAMA_MODEL_PATH" # Remove partial download if any
495
+ cleanup
496
+ exit 1
497
+ fi
498
+ echo "Model downloaded successfully."
499
+ else
500
+ echo "Model download declined. LLaMA.cpp server cannot start without the model."
501
+ cleanup
502
+ exit 1
503
+ fi
504
+ else
505
+ echo "LLaMA.cpp model '$MODEL_FILENAME' already exists at '$LLAMA_MODEL_PATH'."
506
+ fi
507
+
508
+ # Build llama.cpp
509
+ LLAMA_SERVER_BINARY_PATH="$LLAMA_CPP_FULL_PATH/build/bin/llama-server"
510
+ if [ ! -f "$LLAMA_SERVER_BINARY_PATH" ]; then
511
+ echo "Building llama.cpp (this may take several minutes)..."
512
+ cd "$LLAMA_CPP_FULL_PATH" # Change to llama.cpp directory for build
513
+
514
+ NUM_CORES=$(sysctl -n hw.ncpu 2>/dev/null || echo 4) # Default to 4 if sysctl fails
515
+
516
+ cmake -B build # Metal is enabled by default on macOS
517
+ if [ $? -ne 0 ]; then
518
+ echo "ERROR: cmake configuration for llama.cpp failed."
519
+ cd "$SCRIPT_DIR" # Return to original script directory
520
+ cleanup
521
+ exit 1
522
+ fi
523
+ cmake --build build --config Release -j "$NUM_CORES"
524
+ if [ $? -ne 0 ]; then
525
+ echo "ERROR: cmake build for llama.cpp failed."
526
+ cd "$SCRIPT_DIR" # Return to original script directory
527
+ cleanup
528
+ exit 1
529
+ fi
530
+ echo "llama.cpp built successfully. Server binary at '$LLAMA_SERVER_BINARY_PATH'."
531
+ cd "$SCRIPT_DIR" # Return to original script directory
532
+ else
533
+ echo "llama-server binary already exists at '$LLAMA_SERVER_BINARY_PATH'. Skipping build."
534
+ fi
535
+
536
+ # Start llama-server
537
+ echo "Starting LLaMA.cpp server in the background..."
538
+ echo "Output will be logged to: $LLAMA_SERVER_LOG_FILE"
539
+ LLAMA_SERVER_PORT=8082 # Port for llama.cpp server
540
+ # The model path for llama-server is absolute: $LLAMA_MODEL_PATH
541
+ # The server binary path is absolute: $LLAMA_SERVER_BINARY_PATH
542
+
543
+ # Redirect both stdout and stderr to the log file
544
+ "$LLAMA_SERVER_BINARY_PATH" -m "$LLAMA_MODEL_PATH" -ngl 100 -c 30000 --port "$LLAMA_SERVER_PORT" --host 0.0.0.0 > "$LLAMA_SERVER_LOG_FILE" 2>&1 &
545
+ LLAMA_SERVER_PID=$!
546
+ sleep 3 # Give it a moment to start/fail
547
+
548
+ if ! kill -0 $LLAMA_SERVER_PID &> /dev/null; then
549
+ echo "ERROR: LLaMA.cpp server (PID $LLAMA_SERVER_PID) failed to start or crashed."
550
+ echo "Check the log file for details: $LLAMA_SERVER_LOG_FILE"
551
+ # No need to cd back as we are already in SCRIPT_DIR if build was skipped or after build
552
+ cleanup
553
+ exit 1
554
+ fi
555
+ echo "LLaMA.cpp server started successfully with PID $LLAMA_SERVER_PID."
556
+ echo "Access LLaMA.cpp server API at http://localhost:$LLAMA_SERVER_PORT"
557
+ # --- End LLaMA.cpp Setup (Step 4) ---
558
+
559
+
560
+ # --- 2. Huey Worker ---
561
+ echo "----------------------------------------"
562
+ echo "5 / 10. Starting Huey worker in the background (using venv)..." # Renumbered
563
+ echo "Output will be logged to: $HUEY_LOG_FILE"
564
+ # Call the huey_consumer script directly (should be in PATH from venv)
565
+ # Redirect both stdout and stderr to the log file
566
+ huey_consumer tasks.huey > "$HUEY_LOG_FILE" 2>&1 &
567
+ HUEY_PID=$!
568
+ # Give it a moment to start/fail
569
+ sleep 2
570
+ if ! kill -0 $HUEY_PID &> /dev/null; then
571
+ echo "ERROR: Huey worker (PID $HUEY_PID) failed to start or crashed immediately."
572
+ echo "Check the log file for details: $HUEY_LOG_FILE"
573
+ exit 1 # Cleanup trap will handle exit
574
+ fi
575
+ echo "Huey worker started successfully with PID $HUEY_PID."
576
+
577
+
578
+ # --- 3. API Server ---
579
+ echo "----------------------------------------"
580
+ echo "6 / 10. Starting FastAPI server (Uvicorn) in the background (using venv)..." # Renumbered
581
+ echo "Output will be logged to: $UVICORN_LOG_FILE"
582
+ # Host 0.0.0.0 makes it accessible on the network
583
+ # Use python -m uvicorn to ensure using the venv uvicorn
584
+ # Redirect both stdout and stderr to the log file
585
+ python -m uvicorn api:app --host 0.0.0.0 --port 8000 > "$UVICORN_LOG_FILE" 2>&1 &
586
+ UVICORN_PID=$!
587
+ # Give it a moment to start/fail
588
+ sleep 2
589
+ if ! kill -0 $UVICORN_PID &> /dev/null; then
590
+ echo "ERROR: Uvicorn server (PID $UVICORN_PID) failed to start or crashed immediately."
591
+ echo "Check the log file for details: $UVICORN_LOG_FILE"
592
+ # Cleanup trap will handle stopping Huey
593
+ exit 1 # Cleanup trap will handle exit
594
+ fi
595
+ echo "Uvicorn server started successfully with PID $UVICORN_PID. Access API at http://localhost:8000"
596
+
597
+
598
+ # --- 4. NPM Check ---
599
+ echo "----------------------------------------"
600
+ echo "7 / 10. Checking for npm..." # Renumbered
601
+ if ! command -v npm &> /dev/null; then
602
+ echo "ERROR: npm command not found."
603
+ echo "Please install Node.js and npm (e.g., from https://nodejs.org/) and run this script again."
604
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
605
+ kill $HUEY_PID $UVICORN_PID
606
+ exit 1
607
+ fi
608
+ echo "npm found."
609
+
610
+
611
+ # --- 5. Frontend Dependencies ---
612
+ echo "----------------------------------------"
613
+ echo "8 / 10. Installing frontend dependencies in atk-interface..." # Renumbered
614
+ if [ ! -d "atk-interface" ]; then
615
+ echo "ERROR: Directory 'atk-interface' not found in $(pwd)."
616
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
617
+ kill $HUEY_PID $UVICORN_PID
618
+ exit 1
619
+ fi
620
+ cd atk-interface
621
+ npm install
622
+ if [ $? -ne 0 ]; then
623
+ echo "ERROR: Failed to install npm dependencies in atk-interface. Check package.json and npm logs."
624
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
625
+ kill $HUEY_PID $UVICORN_PID
626
+ cd "$SCRIPT_DIR" # Go back to root
627
+ exit 1
628
+ fi
629
+ echo "Frontend dependencies installed."
630
+
631
+
632
+ # --- 6. Frontend Build ---
633
+ echo "----------------------------------------"
634
+ echo "9 / 10. Building frontend application in atk-interface..." # Renumbered
635
+ npm run build
636
+ if [ $? -ne 0 ]; then
637
+ echo "ERROR: Failed to build frontend application. Check build script in package.json and logs."
638
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
639
+ kill $HUEY_PID $UVICORN_PID
640
+ cd "$SCRIPT_DIR" # Go back to root
641
+ exit 1
642
+ fi
643
+ echo "Frontend built successfully."
644
+
645
+
646
+ # --- 7. Frontend Serve ---
647
+ echo "----------------------------------------"
648
+ echo "10 / 10. Serving frontend application from atk-interface/dist/ in the FOREGROUND..." # Renumbered
649
+ echo "Output will be logged to: $FRONTEND_LOG_FILE"
650
+ # Ensure dist directory exists after build
651
+ if [ ! -d "dist" ]; then
652
+ echo "ERROR: 'dist' directory not found in atk-interface after build."
653
+ # Cleanup trap will handle stopping background services
654
+ exit 1
655
+ fi
656
+
657
+
658
+ echo "Opening interface..."
659
+
660
+
661
+ # Open the URL in the default browser (macOS)
662
+ echo "Opening http://localhost:5173 in default browser..."
663
+ open http://localhost:5173
664
+
665
+ # Show summary of all log files
666
+ echo ""
667
+ echo "═══════════════════════════════════════════════════════════════════════════════"
668
+ echo "All helper processes are now running with output redirected to log files:"
669
+ echo " • LLaMA server logs: $LLAMA_SERVER_LOG_FILE"
670
+ echo " • Huey worker logs: $HUEY_LOG_FILE"
671
+ echo " • Uvicorn API logs: $UVICORN_LOG_FILE"
672
+ echo " • Frontend serve logs: $FRONTEND_LOG_FILE"
673
+ echo ""
674
+ echo "You can monitor these logs in real-time with:"
675
+ echo " tail -f $LLAMA_SERVER_LOG_FILE"
676
+ echo " tail -f $HUEY_LOG_FILE"
677
+ echo " tail -f $UVICORN_LOG_FILE"
678
+ echo " tail -f $FRONTEND_LOG_FILE"
679
+ echo "═══════════════════════════════════════════════════════════════════════════════"
680
+ echo ""
681
+
682
+ # Serve on port 5173 (matches CORS config)
683
+ # Run npx serve in the foreground with output redirected to log file
684
+ echo "Starting serve process (output redirected to log file)..."
685
+ # Redirect both stdout and stderr to the log file - use ../ to go back to script directory
686
+ npx serve -s dist --listen 5173 > "../$FRONTEND_LOG_FILE" 2>&1
687
+ SERVE_EXIT_CODE=$?
688
+ SERVE_PID="" # Clear PID as it has exited
689
+
690
+ if [ $SERVE_EXIT_CODE -ne 0 ]; then
691
+ echo "WARNING: Frontend serve process exited with code $SERVE_EXIT_CODE."
692
+ echo "Check the log file for details: $FRONTEND_LOG_FILE"
693
+ # Consider if we should stop background services here if serve crashes
694
+ # Calling cleanup manually if serve fails
695
+ cleanup
696
+ exit 1 # Exit with error code
697
+ fi
698
+
699
+
700
+ # --- 8. Done (Only reached if npx serve exits gracefully, e.g., if killed separately) ---
701
+ cd "$SCRIPT_DIR" # Go back to root directory
702
+ echo "----------------------------------------"
703
+ echo "Frontend serve process finished."
704
+ echo ""
705
+ echo "Helper process log files were saved to:"
706
+ echo " • LLaMA server logs: $LLAMA_SERVER_LOG_FILE"
707
+ echo " • Huey worker logs: $HUEY_LOG_FILE"
708
+ echo " • Uvicorn API logs: $UVICORN_LOG_FILE"
709
+ echo " • Frontend serve logs: $FRONTEND_LOG_FILE"
710
+ # Call cleanup to stop background services if serve finishes normally
711
+ cleanup
712
+
713
+ exit 0
logs/.gitkeep ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+
2
+
3
+
macos.sh ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Exit immediately if a command exits with a non-zero status.
4
+ # set -e # Disabled for manual error checking below
5
+
6
+ echo "Starting Augmentoolkit services..."
7
+
8
+ # --- Get script directory and change to it ---
9
+ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
10
+ cd "$SCRIPT_DIR"
11
+ if [ $? -ne 0 ]; then
12
+ echo "ERROR: Failed to change directory to script location '$SCRIPT_DIR'."
13
+ exit 1
14
+ fi
15
+ echo "Running in directory: $(pwd)"
16
+
17
+ # --- Cleanup Function ---
18
+ # Stores PIDs of background processes
19
+ HUEY_PID=""
20
+ UVICORN_PID=""
21
+ SERVE_PID="" # Though serve runs in foreground, trap might catch signals before it starts
22
+
23
+ cleanup() {
24
+ echo "" # Newline after ^C
25
+ echo "----------------------------------------"
26
+ echo "Signal received, stopping background services..."
27
+
28
+ # Function to kill a process and wait for it to terminate
29
+ kill_and_wait() {
30
+ local pid=$1
31
+ local name=$2
32
+ local timeout=9
33
+
34
+ if [ -n "$pid" ] && kill -0 $pid &> /dev/null; then
35
+ echo "Stopping $name (PID $pid)..."
36
+
37
+ # First try SIGTERM (graceful shutdown)
38
+ kill -TERM $pid 2>/dev/null
39
+
40
+ # Wait for process to terminate gracefully
41
+ local count=0
42
+ while [ $count -lt $timeout ] && kill -0 $pid &> /dev/null; do
43
+ sleep 1
44
+ count=$((count + 1))
45
+ done
46
+
47
+ # If still running, force kill with SIGKILL
48
+ if kill -0 $pid &> /dev/null; then
49
+ echo "Process $pid ($name) didn't terminate gracefully, force killing..."
50
+ kill -KILL $pid 2>/dev/null
51
+ sleep 1
52
+ fi
53
+
54
+ # Final check
55
+ if kill -0 $pid &> /dev/null; then
56
+ echo "WARNING: Failed to stop $name (PID $pid)"
57
+ else
58
+ echo "$name stopped successfully."
59
+ fi
60
+ fi
61
+ }
62
+
63
+ # Stop processes in reverse order of startup
64
+ kill_and_wait "$SERVE_PID" "frontend serve"
65
+ kill_and_wait "$UVICORN_PID" "Uvicorn API server"
66
+ kill_and_wait "$HUEY_PID" "Huey worker"
67
+
68
+ # Find and kill any remaining huey consumers specific to this Augmentoolkit instance
69
+ echo "Searching for huey consumer processes in current directory ($SCRIPT_DIR)..."
70
+
71
+ # Use ps with full command line and grep for huey_consumer processes in our specific directory
72
+ # This avoids killing huey consumers from other Augmentoolkit instances or projects
73
+ HUEY_PIDS=$(ps aux | grep -E "huey_consumer.*tasks\.huey" | grep "$SCRIPT_DIR" | grep -v grep | awk '{print $2}')
74
+
75
+ if [ -n "$HUEY_PIDS" ]; then
76
+ echo "Found huey consumer processes in current directory: $HUEY_PIDS"
77
+ for pid in $HUEY_PIDS; do
78
+ if kill -0 $pid &> /dev/null; then
79
+ echo "Stopping huey consumer from current directory (PID $pid)..."
80
+ kill -TERM $pid 2>/dev/null
81
+ sleep 2
82
+ if kill -0 $pid &> /dev/null; then
83
+ echo "Force killing huey consumer (PID $pid)..."
84
+ kill -KILL $pid 2>/dev/null
85
+ fi
86
+ fi
87
+ done
88
+ sleep 1
89
+ # Final check for any remaining huey processes in our directory
90
+ REMAINING_HUEY=$(ps aux | grep -E "huey_consumer.*tasks\.huey" | grep "$SCRIPT_DIR" | grep -v grep | awk '{print $2}')
91
+ if [ -n "$REMAINING_HUEY" ]; then
92
+ echo "WARNING: Some huey consumer processes in current directory may still be running: $REMAINING_HUEY"
93
+ else
94
+ echo "All huey consumer processes from current directory stopped successfully."
95
+ fi
96
+ else
97
+ echo "No additional huey consumer processes found in current directory."
98
+ fi
99
+
100
+ # Deactivate virtual environment if active
101
+ if command -v deactivate &> /dev/null; then
102
+ echo "Deactivating virtual environment..."
103
+ deactivate
104
+ fi
105
+
106
+ # Give a moment for any remaining output to flush
107
+ sleep 1
108
+
109
+ # Clear any remaining output and reset cursor
110
+ echo "Cleanup complete."
111
+ echo "----------------------------------------"
112
+
113
+ # Ensure cursor is visible and terminal is in normal state
114
+ printf "\033[?25h" # Show cursor
115
+ printf "\033[0m" # Reset all attributes
116
+
117
+ exit 0 # Exit script after cleanup
118
+ }
119
+
120
+ # Trap signals (INT: Ctrl+C, TERM: standard kill) and run cleanup function
121
+ trap cleanup SIGINT SIGTERM
122
+
123
+ # --- 1. Python Virtual Environment & Dependencies ---
124
+ echo "----------------------------------------"
125
+ echo "1. Setting up Python virtual environment and installing dependencies..."
126
+ VENV_DIR=".venv"
127
+
128
+ # Check if python3.11 command exists (preferred)
129
+ if command -v python3.11 &> /dev/null; then
130
+ PYTHON_CMD="python3.11"
131
+ echo "Using Python 3.11: $(python3.11 --version)"
132
+ elif command -v python3 &> /dev/null; then
133
+ PYTHON_CMD="python3"
134
+ PYTHON_VERSION=$(python3 --version 2>&1 | grep -oE '[0-9]+\.[0-9]+')
135
+ echo "Using Python: $(python3 --version)"
136
+ if [[ "$PYTHON_VERSION" != "3.11" ]]; then
137
+ echo "WARNING: Python 3.11 is recommended but not found. Using $PYTHON_VERSION instead."
138
+ echo "If you encounter issues, please install Python 3.11 specifically."
139
+ fi
140
+ else
141
+ echo "ERROR: Neither python3.11 nor python3 command found."
142
+ echo "Please install Python 3.11 (recommended) or Python 3.x and rerun the script."
143
+ echo "On macOS, you can install Python 3.11 using:"
144
+ echo " brew install python@3.11"
145
+ echo "Or download from: https://www.python.org/downloads/"
146
+ exit 1
147
+ fi
148
+
149
+ # --- Check/Install uv ---
150
+ echo "Checking for uv..."
151
+ if ! command -v uv &> /dev/null; then
152
+ echo "'uv' command not found. Attempting to install uv using pip..."
153
+ # Ensure pip is available (it should be with the selected Python version)
154
+ if ! $PYTHON_CMD -m pip --version &> /dev/null; then
155
+ echo "ERROR: $PYTHON_CMD -m pip command failed. Cannot install uv."
156
+ exit 1
157
+ fi
158
+ $PYTHON_CMD -m pip install uv
159
+ if [ $? -ne 0 ]; then
160
+ echo "ERROR: Failed to install uv using pip. Please install uv manually (e.g., 'pip install uv' or 'brew install uv') and rerun the script."
161
+ exit 1
162
+ fi
163
+ echo "uv installed successfully."
164
+ else
165
+ echo "uv found."
166
+ fi
167
+ # --- End Check/Install uv ---
168
+
169
+
170
+ # Create virtual environment if it doesn't exist
171
+ if [ ! -d "$VENV_DIR" ]; then
172
+ echo "Creating virtual environment in '$VENV_DIR' using $PYTHON_CMD..."
173
+ $PYTHON_CMD -m venv "$VENV_DIR"
174
+ if [ $? -ne 0 ]; then
175
+ echo "ERROR: Failed to create Python virtual environment using $PYTHON_CMD."
176
+ exit 1
177
+ fi
178
+ else
179
+ echo "Virtual environment '$VENV_DIR' already exists."
180
+ fi
181
+
182
+ # Activate virtual environment
183
+ echo "Activating virtual environment..."
184
+ source "$VENV_DIR/bin/activate"
185
+ if [ $? -ne 0 ]; then
186
+ echo "ERROR: Failed to activate Python virtual environment."
187
+ exit 1
188
+ fi
189
+ echo "Virtual environment activated. Python executable: $(which python)"
190
+
191
+ # --- Attempt to install SSL certificates (macOS specific fix) ---
192
+ echo "Attempting to install SSL certificates for the active Python environment..."
193
+ # Get the full path to the python executable inside the venv
194
+ VENV_PYTHON_EXE=$(which python)
195
+ # Try to infer the Applications directory path (this is heuristic)
196
+ # Assumes Python was installed via the standard macOS installer
197
+ PYTHON_BASE_DIR=$(dirname "$(dirname "$VENV_PYTHON_EXE")") # Go up two levels from venv/bin/python
198
+ CERT_SCRIPT_PATH=""
199
+ # Check common installation patterns
200
+ if [ -d "$PYTHON_BASE_DIR/Install Certificates.command" ]; then
201
+ # Might be like /Library/Frameworks/Python.framework/Versions/3.11/Install Certificates.command
202
+ CERT_SCRIPT_PATH="$PYTHON_BASE_DIR/Install Certificates.command"
203
+ elif [ -d "/Applications/Python $(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')/Install Certificates.command" ]; then
204
+ # Check standard /Applications path using the version
205
+ PY_VERSION_SHORT=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
206
+ CERT_SCRIPT_PATH="/Applications/Python ${PY_VERSION_SHORT}/Install Certificates.command"
207
+ fi
208
+
209
+ if [ -n "$CERT_SCRIPT_PATH" ] && [ -f "$CERT_SCRIPT_PATH" ]; then
210
+ echo "Found certificate installation script: $CERT_SCRIPT_PATH"
211
+ echo "Running certificate installation script..."
212
+ # Execute the script using the venv's python
213
+ "$CERT_SCRIPT_PATH"
214
+ if [ $? -ne 0 ]; then
215
+ echo "WARNING: Certificate installation script finished with a non-zero status. SSL issues might persist."
216
+ # Continue anyway, maybe it partially worked or wasn't strictly needed
217
+ else
218
+ echo "Certificate installation script completed successfully."
219
+ fi
220
+ else
221
+ echo "WARNING: Could not find the 'Install Certificates.command' script automatically."
222
+ echo "If you encounter SSL errors (e.g., downloading NLTK data, used in chunking), you may need to run it manually."
223
+ echo "Look for it in your Python application folder (e.g., /Applications/Python 3.11/)."
224
+ fi
225
+ # --- End SSL Certificate Install ---
226
+
227
+
228
+ # Install dependencies using uv
229
+ echo "Installing Python dependencies from requirements.txt using uv..."
230
+ # Assuming uv is in PATH after the check above and venv is activated
231
+ uv pip install -r requirements.txt
232
+ if [ $? -ne 0 ]; then
233
+ echo "ERROR: Failed to install Python dependencies using uv. Please check requirements.txt and uv logs."
234
+ exit 1 # Cleanup trap will handle exit
235
+ fi
236
+ echo "Python dependencies installed successfully."
237
+
238
+ # Download spaCy model for anonymization
239
+ echo "Downloading spaCy model (en_core_web_lg) for anonymization..."
240
+ python3 -m spacy download en_core_web_lg
241
+ if [ $? -ne 0 ]; then
242
+ echo "WARNING: Failed to download spaCy model. Anonymization might fall back to NLTK."
243
+ fi
244
+
245
+ # --- Configure SSL Certificate Path ---
246
+ echo "----------------------------------------"
247
+ echo "Configuring SSL certificate path..."
248
+
249
+ # Ensure certifi is installed (uv pip install should handle this if in requirements.txt,
250
+ # but explicit check adds robustness - though uv handles it well)
251
+ # It's likely already installed as a dependency of requests/huggingface_hub etc.
252
+ # If dependency issues arise, add 'certifi' explicitly to requirements.txt
253
+
254
+ # Get the certificate path from certifi
255
+ CERT_PATH=$(python -c "import certifi; print(certifi.where())" 2>/dev/null) # Suppress stderr in case certifi isn't installed yet
256
+
257
+ if [ -n "$CERT_PATH" ] && [ -f "$CERT_PATH" ]; then
258
+ echo "Setting SSL_CERT_FILE environment variable to: $CERT_PATH"
259
+ export SSL_CERT_FILE="$CERT_PATH"
260
+ echo "SSL_CERT_FILE set for this script session and its children."
261
+ # Optional: Add a connection test here if desired
262
+ # python -c "import requests; r = requests.get('https://huggingface.co', timeout=10); print(f'Connection test status: {r.status_code}')"
263
+ # if [ $? -ne 0 ]; then echo "WARNING: Connection test failed after setting SSL_CERT_FILE."; fi
264
+ else
265
+ echo "WARNING: Could not determine certificate path using certifi. Certifi might not be installed yet or there's an issue."
266
+ echo "Attempting to proceed, but SSL errors (like CERTIFICATE_VERIFY_FAILED) may occur."
267
+ echo "Ensure 'certifi' is listed in requirements.txt if SSL errors persist."
268
+ fi
269
+ # --- End SSL Configuration ---
270
+
271
+
272
+ # --- Helper Process Logging Setup ---
273
+ echo "----------------------------------------"
274
+ echo "Setting up helper process logging..."
275
+
276
+ # Create helper_process_logs directory if it doesn't exist
277
+ HELPER_LOGS_DIR="helper_process_logs"
278
+ if [ ! -d "$HELPER_LOGS_DIR" ]; then
279
+ echo "Creating helper process logs directory: $HELPER_LOGS_DIR"
280
+ mkdir -p "$HELPER_LOGS_DIR"
281
+ if [ $? -ne 0 ]; then
282
+ echo "ERROR: Failed to create helper process logs directory."
283
+ exit 1
284
+ fi
285
+ else
286
+ echo "Helper process logs directory already exists: $HELPER_LOGS_DIR"
287
+ fi
288
+
289
+ # Generate timestamp for log files
290
+ TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
291
+ HUEY_LOG_FILE="$HELPER_LOGS_DIR/huey_worker_${TIMESTAMP}.log"
292
+ UVICORN_LOG_FILE="$HELPER_LOGS_DIR/uvicorn_api_${TIMESTAMP}.log"
293
+ FRONTEND_LOG_FILE="$HELPER_LOGS_DIR/frontend_serve_${TIMESTAMP}.log"
294
+
295
+ echo "Helper process logs will be saved to:"
296
+ echo " Huey worker: $HUEY_LOG_FILE"
297
+ echo " Uvicorn API: $UVICORN_LOG_FILE"
298
+ echo " Frontend serve: $FRONTEND_LOG_FILE"
299
+ echo "Helper process logging setup complete."
300
+
301
+
302
+ # --- 1.5 Valkey (Redis Fork) Check & Installation ---
303
+ echo "----------------------------------------"
304
+ echo "1.5. Checking for Valkey (Redis fork)..."
305
+
306
+ # First, check if something is already listening on port 6379
307
+ if command -v lsof &> /dev/null; then
308
+ PORT_CHECK_CMD="lsof -i :6379"
309
+ elif command -v netstat &> /dev/null; then
310
+ PORT_CHECK_CMD="netstat -an"
311
+ else
312
+ PORT_CHECK_CMD=""
313
+ fi
314
+
315
+ if [ -n "$PORT_CHECK_CMD" ]; then
316
+ if [ "$PORT_CHECK_CMD" = "lsof -i :6379" ]; then
317
+ if $PORT_CHECK_CMD &> /dev/null; then
318
+ echo "Something is already listening on port 6379. Assuming a compatible Valkey/Redis server is running."
319
+ echo "Skipping Valkey installation and setup."
320
+ else
321
+ NEED_VALKEY_SETUP=true
322
+ fi
323
+ else
324
+ # netstat case
325
+ if $PORT_CHECK_CMD | grep -q ':6379'; then
326
+ echo "Something is already listening on port 6379. Assuming a compatible Valkey/Redis server is running."
327
+ echo "Skipping Valkey installation and setup."
328
+ else
329
+ NEED_VALKEY_SETUP=true
330
+ fi
331
+ fi
332
+ else
333
+ echo "WARNING: Cannot check port 6379 (lsof and netstat not available). Proceeding with Valkey setup."
334
+ NEED_VALKEY_SETUP=true
335
+ fi
336
+
337
+ if [ "$NEED_VALKEY_SETUP" = true ]; then
338
+ # Check if brew command exists
339
+
340
+ # Check if brew command exists
341
+ if ! command -v brew &> /dev/null; then
342
+ echo "Homebrew (brew) command not found."
343
+ read -p "Homebrew is required to install Valkey. Install Homebrew now? (y/N) " -n 1 -r
344
+ echo # Move to a new line after input
345
+ if [[ $REPLY =~ ^[Yy]$ ]]; then
346
+ echo "Attempting to install Homebrew. This may take a few minutes and will likely ask for your administrator password..."
347
+ # Execute the official Homebrew installation script
348
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
349
+ # Check if installation succeeded by seeing if 'brew' command is now available
350
+ # Might require sourcing profile or opening new shell, but let's check command first
351
+ if ! command -v brew &> /dev/null; then
352
+ echo "ERROR: Homebrew installation appears to have failed or is not available in this shell session."
353
+ echo "Please try installing Homebrew manually from https://brew.sh/ and run this script again, possibly in a new terminal window."
354
+ exit 1
355
+ else
356
+ # Add brew to PATH for the current script execution if possible
357
+ # This is complex as the path depends on architecture (Intel vs ARM)
358
+ # For simplicity, we'll rely on the installer potentially modifying the profile
359
+ # or the user running in a new shell later if 'brew' isn't found immediately after install.
360
+ # A more robust solution might involve detecting arch and adding the known path.
361
+ echo "Homebrew installed successfully. Proceeding..."
362
+ # Re-check explicitly here just in case the path isn't immediately active
363
+ if ! command -v brew &> /dev/null; then
364
+ echo "ERROR: Homebrew installed but 'brew' command still not found in PATH for this session."
365
+ echo "Please open a new terminal window and run this script again."
366
+ exit 1
367
+ fi
368
+ fi
369
+ else
370
+ echo "Homebrew installation declined. Valkey cannot be installed automatically."
371
+ echo "Please install Homebrew (https://brew.sh/) and Valkey ('brew install valkey') manually and rerun the script."
372
+ exit 1
373
+ fi
374
+ fi
375
+ echo "Homebrew found."
376
+
377
+ # Check if valkey is installed
378
+ if ! brew list valkey &>/dev/null; then
379
+ echo "Valkey not found via Homebrew. Attempting to install..."
380
+ # Update brew minimally and install valkey
381
+ # Using 'brew install' directly avoids running 'brew update' if possible,
382
+ # but Homebrew might still update itself or core formulae.
383
+ # We avoid 'brew upgrade' to prevent upgrading all packages.
384
+ brew install valkey
385
+ if [ $? -ne 0 ]; then
386
+ echo "ERROR: Failed to install Valkey using Homebrew. Please install it manually ('brew install valkey') and rerun the script."
387
+ exit 1
388
+ fi
389
+ echo "Valkey installed successfully."
390
+ else
391
+ echo "Valkey found via Homebrew."
392
+ fi
393
+
394
+ # Check if Valkey service is running, start if not
395
+ if ! brew services list | grep -q "valkey.*started"; then
396
+ echo "Valkey service is not running. Starting it now..."
397
+ brew services start valkey
398
+ if [ $? -ne 0 ]; then
399
+ echo "ERROR: Failed to start Valkey service using Homebrew. Please check Homebrew services ('brew services list') and start Valkey manually if needed."
400
+ exit 1
401
+ fi
402
+ # Give it a moment to start
403
+ sleep 2
404
+ if ! brew services list | grep -q "valkey.*started"; then
405
+ echo "ERROR: Valkey service failed to stay running after starting. Please investigate ('brew services list', check logs)."
406
+ exit 1
407
+ fi
408
+ echo "Valkey service started successfully."
409
+ else
410
+ echo "Valkey service is already running."
411
+ fi
412
+ fi # End NEED_VALKEY_SETUP conditional
413
+ # --- End Valkey Check ---
414
+
415
+
416
+ # --- 2. Huey Worker ---
417
+ echo "----------------------------------------"
418
+ echo "2. Starting Huey worker in the background (using venv)..."
419
+ echo "Output will be logged to: $HUEY_LOG_FILE"
420
+ # Call the huey_consumer script directly (should be in PATH from venv)
421
+ # Redirect both stdout and stderr to the log file
422
+ huey_consumer tasks.huey > "$HUEY_LOG_FILE" 2>&1 &
423
+ HUEY_PID=$!
424
+ # Give it a moment to start/fail
425
+ sleep 2
426
+ if ! kill -0 $HUEY_PID &> /dev/null; then
427
+ echo "ERROR: Huey worker (PID $HUEY_PID) failed to start or crashed immediately."
428
+ echo "Check the log file for details: $HUEY_LOG_FILE"
429
+ exit 1 # Cleanup trap will handle exit
430
+ fi
431
+ echo "Huey worker started successfully with PID $HUEY_PID."
432
+
433
+
434
+ # --- 3. API Server ---
435
+ echo "----------------------------------------"
436
+ echo "3. Starting FastAPI server (Uvicorn) in the background (using venv)..."
437
+ echo "Output will be logged to: $UVICORN_LOG_FILE"
438
+ # Host 0.0.0.0 makes it accessible on the network
439
+ # Use python -m uvicorn to ensure using the venv uvicorn
440
+ # Redirect both stdout and stderr to the log file
441
+ python -m uvicorn api:app --host 0.0.0.0 --port 8000 > "$UVICORN_LOG_FILE" 2>&1 &
442
+ UVICORN_PID=$!
443
+ # Give it a moment to start/fail
444
+ sleep 2
445
+ if ! kill -0 $UVICORN_PID &> /dev/null; then
446
+ echo "ERROR: Uvicorn server (PID $UVICORN_PID) failed to start or crashed immediately."
447
+ echo "Check the log file for details: $UVICORN_LOG_FILE"
448
+ # Cleanup trap will handle stopping Huey
449
+ exit 1 # Cleanup trap will handle exit
450
+ fi
451
+ echo "Uvicorn server started successfully with PID $UVICORN_PID. Access API at http://localhost:8000"
452
+
453
+
454
+ # --- 4. NPM Check ---
455
+ echo "----------------------------------------"
456
+ echo "4. Checking for npm..."
457
+ if ! command -v npm &> /dev/null; then
458
+ echo "ERROR: npm command not found."
459
+ echo "Please install Node.js and npm (e.g., from https://nodejs.org/) and run this script again."
460
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
461
+ kill $HUEY_PID $UVICORN_PID
462
+ exit 1
463
+ fi
464
+ echo "npm found."
465
+
466
+
467
+ # --- 5. Frontend Dependencies ---
468
+ echo "----------------------------------------"
469
+ echo "5. Installing frontend dependencies in atk-interface..."
470
+ if [ ! -d "atk-interface" ]; then
471
+ echo "ERROR: Directory 'atk-interface' not found in $(pwd)."
472
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
473
+ kill $HUEY_PID $UVICORN_PID
474
+ exit 1
475
+ fi
476
+ cd atk-interface
477
+ npm install
478
+ if [ $? -ne 0 ]; then
479
+ echo "ERROR: Failed to install npm dependencies in atk-interface. Check package.json and npm logs."
480
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
481
+ kill $HUEY_PID $UVICORN_PID
482
+ cd "$SCRIPT_DIR" # Go back to root
483
+ exit 1
484
+ fi
485
+ echo "Frontend dependencies installed."
486
+
487
+
488
+ # --- 6. Frontend Build ---
489
+ echo "----------------------------------------"
490
+ echo "6. Building frontend application in atk-interface..."
491
+ npm run build
492
+ if [ $? -ne 0 ]; then
493
+ echo "ERROR: Failed to build frontend application. Check build script in package.json and logs."
494
+ echo "Stopping background services (PIDs $HUEY_PID, $UVICORN_PID)..."
495
+ kill $HUEY_PID $UVICORN_PID
496
+ cd "$SCRIPT_DIR" # Go back to root
497
+ exit 1
498
+ fi
499
+ echo "Frontend built successfully."
500
+
501
+
502
+ # --- 7. Frontend Serve ---
503
+ echo "----------------------------------------"
504
+ echo "7. Serving frontend application from atk-interface/dist/ in the FOREGROUND..."
505
+ echo "Output will be logged to: $FRONTEND_LOG_FILE"
506
+ # Ensure dist directory exists after build
507
+ if [ ! -d "dist" ]; then
508
+ echo "ERROR: 'dist' directory not found in atk-interface after build."
509
+ # Cleanup trap will handle stopping background services
510
+ exit 1
511
+ fi
512
+
513
+ # Show summary of all log files
514
+ echo ""
515
+ echo "═══════════════════════════════════════════════════════════════════════════════"
516
+ echo "All helper processes are now running with output redirected to log files:"
517
+ echo " • Huey worker logs: $HUEY_LOG_FILE"
518
+ echo " • Uvicorn API logs: $UVICORN_LOG_FILE"
519
+ echo " • Frontend serve logs: $FRONTEND_LOG_FILE"
520
+ echo ""
521
+ echo "You can monitor these logs in real-time with:"
522
+ echo " tail -f $HUEY_LOG_FILE"
523
+ echo " tail -f $UVICORN_LOG_FILE"
524
+ echo " tail -f $FRONTEND_LOG_FILE"
525
+ echo "═══════════════════════════════════════════════════════════════════════════════"
526
+ echo ""
527
+
528
+ # Serve on port 5173 (matches CORS config)
529
+ # Run npx serve in the background first, then open browser once it's ready
530
+ echo "Starting serve process (output redirected to log file)..."
531
+ # Redirect both stdout and stderr to the log file - use ../ to go back to script directory
532
+ npx serve -s dist --listen 5173 > "../$FRONTEND_LOG_FILE" 2>&1 &
533
+ SERVE_PID=$!
534
+
535
+ # Wait for the serve process to be ready
536
+ echo "Waiting for frontend server to start..."
537
+ sleep 3
538
+
539
+ # Check if serve process is still running
540
+ if ! kill -0 $SERVE_PID &> /dev/null; then
541
+ echo "ERROR: Frontend serve process failed to start or crashed immediately."
542
+ echo "Check the log file for details: $FRONTEND_LOG_FILE"
543
+ cleanup
544
+ exit 1
545
+ fi
546
+
547
+ # Wait for the server to actually be listening on port 5173
548
+ echo "Checking if server is responding..."
549
+ for i in {1..10}; do
550
+ if command -v curl &> /dev/null; then
551
+ if curl -s http://localhost:5173 > /dev/null 2>&1; then
552
+ break
553
+ fi
554
+ elif command -v nc &> /dev/null; then
555
+ if nc -z localhost 5173 > /dev/null 2>&1; then
556
+ break
557
+ fi
558
+ else
559
+ # Fallback: just check if something is listening on port 5173
560
+ if command -v lsof &> /dev/null; then
561
+ if lsof -i :5173 > /dev/null 2>&1; then
562
+ break
563
+ fi
564
+ elif command -v netstat &> /dev/null; then
565
+ if netstat -an | grep -q ':5173'; then
566
+ break
567
+ fi
568
+ else
569
+ # No way to check, just wait a bit more
570
+ sleep 1
571
+ break
572
+ fi
573
+ fi
574
+ sleep 1
575
+ done
576
+
577
+ echo "Opening interface..."
578
+
579
+ # Open the URL in the default browser (macOS)
580
+ echo "Opening http://localhost:5173 in default browser..."
581
+ open http://localhost:5173
582
+
583
+ echo "Frontend server is now running. Press Ctrl+C to stop all services."
584
+
585
+ # Wait for the serve process to finish (it's now running in the background)
586
+ wait $SERVE_PID
587
+ SERVE_EXIT_CODE=$?
588
+ SERVE_PID="" # Clear PID as it has exited
589
+
590
+ if [ $SERVE_EXIT_CODE -ne 0 ]; then
591
+ echo "WARNING: Frontend serve process exited with code $SERVE_EXIT_CODE."
592
+ echo "Check the log file for details: $FRONTEND_LOG_FILE"
593
+ # Consider if we should stop background services here if serve crashes
594
+ # Calling cleanup manually if serve fails
595
+ cleanup
596
+ exit 1 # Exit with error code
597
+ fi
598
+
599
+
600
+ # --- 8. Done (Only reached if npx serve exits gracefully, e.g., if killed separately) ---
601
+ cd "$SCRIPT_DIR" # Go back to root directory
602
+ echo "----------------------------------------"
603
+ echo "Frontend serve process finished."
604
+ echo ""
605
+ echo "Helper process log files were saved to:"
606
+ echo " • Huey worker logs: $HUEY_LOG_FILE"
607
+ echo " • Uvicorn API logs: $UVICORN_LOG_FILE"
608
+ echo " • Frontend serve logs: $FRONTEND_LOG_FILE"
609
+ # Call cleanup to stop background services if serve finishes normally
610
+ cleanup
611
+
612
+ exit 0
ocr/.env.example ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Securely provision values, do not commit .env or print the process environment.
2
+ OCR_DISPATCHER_TOKEN=
3
+ OCR_CPU_AUTH_TOKEN=
4
+ OCR_MODELS_DIR=/opt/ocr-models
5
+ # Empty means trusted local CPU. Remote URL must be HTTPS.
6
+ OCR_CPU_URL=
7
+ OCR_MIN_SENSITIVITY=D2
8
+ OCR_ALLOW_EXTERNAL_CPU_D0=true
9
+ OCR_ALLOW_EXTERNAL_CPU_D1=false
10
+ OCR_ALLOW_EXTERNAL_CPU_D2=false
11
+ OCR_ENABLE_GPU=false
12
+ FREELLMAPI_BASE_URL=
13
+ FREELLMAPI_API_KEY=
14
+ OCR_GPU_MODEL=alias-ocr
15
+ OCR_CPU_CONCURRENCY=1
16
+ OCR_GPU_CONCURRENCY=2
17
+ OCR_ENABLE_GPU_OVERFLOW=false
18
+ OCR_OVERFLOW_BENCHMARK_QUALIFIED=false
19
+ OCR_QUALITY_DUAL_RUN=false
20
+ OCR_MAX_UPLOAD_MIB=25
21
+ OCR_MAX_PAGES=100
22
+ OCR_MAX_PIXELS=20000000
23
+ OCR_JOB_TTL_MIN=30
ocr/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Evan Armstrong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ocr/README.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hybrid CPU-first OCR
2
+
3
+ Separate private dispatcher and authenticated PP-OCRv6 Small CPU worker.
4
+ No production integration or startup occurs on import. See:
5
+
6
+ - [API and schemas](docs/api.md)
7
+ - [Install, HF packaging, secrets and rollback](docs/deployment.md)
8
+ - [Routing, limits and cancellation](docs/routing.md)
9
+ - [Reproducible benchmarks](docs/benchmark.md) and [distribution notices](THIRD_PARTY.md)
10
+ - [Security boundaries](docs/threat-model.md)
11
+ - [Audit](docs/audit.md) and [decisions/deviations](docs/adr.md)
12
+ - [Release-readiness report](../docs/OCR_RELEASE_READINESS.md)
13
+
14
+ `python -m ocr.cli run file.pdf --format markdown` runs trusted local CPU.
15
+ `python -m ocr.cli probe-alias` uses a synthetic public image and credentials from
16
+ runtime environment. `python -m ocr.benchmark` generates a CC0 synthetic DE/EN
17
+ corpus and measures real CPU OCR. Live alias and all D2 remote routing default off.
ocr/THIRD_PARTY.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Distribution notices
2
+
3
+ The application code inherits the repository's MIT license, copied to `ocr/LICENSE`
4
+ for a standalone worker staging directory. The Space README's license field refers
5
+ to that application code, not to every bundled model/dependency.
6
+
7
+ PP-OCRv6 Small detector/recognizer artifacts come from the RapidAI/RapidOCR
8
+ ModelScope repository, revision `v3.10.0`; its public metadata declares Apache
9
+ License 2.0. Exact HTTPS URLs and upstream SHA256s are in `models.json`. Preserve
10
+ Apache license/attribution requirements when distributing the baked model files.
11
+ The pinned RapidOCR distribution includes its Apache-2.0 full license in installed
12
+ distribution metadata. No additional model redistribution rights are asserted.
13
+
14
+ Runtime dependencies retain their installed `.dist-info` license files in the
15
+ Docker image. Key metadata: RapidOCR Apache-2.0; ONNX Runtime MIT; Pillow MIT-CMU;
16
+ pypdf BSD-3-Clause; pypdfium2 BSD-3-Clause/Apache-2.0 plus bundled PDFium dependency
17
+ licenses; FastAPI MIT; HTTPX BSD-3-Clause. See those full installed notices rather
18
+ than treating this summary as a substitute. Debian's DejaVu font copyright notice
19
+ is retained under `/usr/share/doc/fonts-dejavu-core/copyright`.
20
+
21
+ Review all transitive notices and operator/provider terms before publication.
22
+ Document ownership/processing rights and downstream provider compliance are the
23
+ operator's responsibility; the software makes no regulatory certification claim.
ocr/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """CPU-first OCR. No backend or network is initialized on import."""
2
+
3
+ __version__ = "1.0.0"
ocr/adapters/__init__.py ADDED
File without changes
ocr/adapters/cpu_http.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+
3
+ from ..schemas import BackendCapabilities, BackendHealth, OCRPageResult, SafeError
4
+ from .http import request_json
5
+
6
+
7
+ class RemoteCPU:
8
+ name = "cpu"
9
+ external = True
10
+
11
+ def __init__(self, url, token, timeout=90, transport=None):
12
+ if not token:
13
+ raise ValueError("worker_token_required")
14
+ self.url = url
15
+ self.destination = url
16
+ self.client = httpx.AsyncClient(
17
+ timeout=timeout,
18
+ follow_redirects=False,
19
+ transport=transport,
20
+ headers={"Authorization": "Bearer " + token},
21
+ )
22
+
23
+ async def close(self):
24
+ await self.client.aclose()
25
+
26
+ async def capabilities(self):
27
+ # Conservative local contract; don't inherit a remote service's sensitive-class claims.
28
+ return BackendCapabilities(
29
+ name="cpu",
30
+ bounding_boxes=True,
31
+ languages=["de", "en"],
32
+ supports_sensitive_classes=["D0"],
33
+ multimodal_verified=True,
34
+ )
35
+
36
+ async def health(self):
37
+ try:
38
+ r = await request_json(self.client, "GET", self.url + "/ready", limit=4096)
39
+ return BackendHealth(ready=r.get("ready") is True)
40
+ except SafeError:
41
+ return BackendHealth(ready=False, code="cpu_worker_unavailable")
42
+
43
+ async def recognize(self, page, options):
44
+ r = await request_json(
45
+ self.client,
46
+ "POST",
47
+ self.url + "/internal/ocr/page",
48
+ files={"file": ("page.png", page.image, "image/png")},
49
+ data={"request_id": page.page_id, "options": "{}"},
50
+ )
51
+ try:
52
+ result = OCRPageResult.model_validate(r)
53
+ except Exception:
54
+ raise SafeError("invalid_cpu_response") from None
55
+ if result.source != "cpu_ocr" or result.engine != "pp-ocrv6-small":
56
+ raise SafeError("unexpected_cpu_engine")
57
+ if result.image_width != page.width or result.image_height != page.height:
58
+ raise SafeError("cpu_geometry_mismatch")
59
+ result.page_index = page.index
60
+ result.provider_destination = self.destination
61
+ return result
ocr/adapters/freellm_alias.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import io
3
+ import time
4
+
5
+ import httpx
6
+ from PIL import Image, ImageDraw
7
+
8
+ from ..normalize import grounding
9
+ from ..schemas import BackendCapabilities, BackendHealth, OCRPageResult, SafeError
10
+ from .http import request_json
11
+
12
+
13
+ class FreeLLMAlias:
14
+ name = "gpu"
15
+ external = True
16
+
17
+ def __init__(self, url, key, timeout=90, transport=None):
18
+ if not url or not key:
19
+ raise ValueError("alias_configuration_missing")
20
+ self.url = url
21
+ self.destination = None
22
+ self.qualified = False
23
+ self.qualified_at = 0
24
+ self.actual_model = None
25
+ self.grounding_verified = False
26
+ self.client = httpx.AsyncClient(
27
+ timeout=timeout,
28
+ follow_redirects=False,
29
+ transport=transport,
30
+ headers={"Authorization": "Bearer " + key},
31
+ )
32
+
33
+ async def close(self):
34
+ await self.client.aclose()
35
+
36
+ async def capabilities(self):
37
+ return BackendCapabilities(
38
+ name="gpu",
39
+ layout_blocks=self.grounding_verified,
40
+ bounding_boxes=self.grounding_verified,
41
+ grounding_verified=self.grounding_verified,
42
+ multimodal_verified=self.qualified,
43
+ effective_destination_verifiable=False,
44
+ supports_sensitive_classes=["D0"] if self.qualified else [],
45
+ )
46
+
47
+ async def health(self):
48
+ return BackendHealth(
49
+ ready=self.qualified and time.monotonic() - self.qualified_at < 3600,
50
+ code="ok" if self.qualified else "alias_unqualified",
51
+ )
52
+
53
+ async def _completion(self, image, *, probe=False):
54
+ # Separate copy: CPU source raster and coordinate system remain untouched.
55
+ with Image.open(io.BytesIO(image)) as im:
56
+ im = im.convert("RGB")
57
+ im.thumbnail((2048, 2048))
58
+ b = io.BytesIO()
59
+ im.save(b, format="PNG")
60
+ content = [
61
+ {
62
+ "type": "image_url",
63
+ "image_url": {
64
+ "url": "data:image/png;base64," + base64.b64encode(b.getvalue()).decode()
65
+ },
66
+ }
67
+ ]
68
+ # No unverified provider-specific options, tools or thinking flags.
69
+ content.append(
70
+ {
71
+ "type": "text",
72
+ "text": "Transcribe the document verbatim. Return only document text or Markdown.",
73
+ }
74
+ )
75
+ data = await request_json(
76
+ self.client,
77
+ "POST",
78
+ self.url + "/chat/completions",
79
+ json={
80
+ "model": "alias-ocr",
81
+ "messages": [{"role": "user", "content": content}],
82
+ "stream": False,
83
+ "temperature": 0.1,
84
+ },
85
+ )
86
+ try:
87
+ choice = data["choices"][0]
88
+ text = choice["message"]["content"]
89
+ if choice.get("finish_reason") not in ("stop",):
90
+ raise SafeError("alias_truncated")
91
+ if not isinstance(text, str) or not text.strip():
92
+ raise SafeError("alias_empty")
93
+ if "<think>" in text or "<analysis>" in text:
94
+ raise SafeError("alias_reasoning_output")
95
+ if len(text) > 2_000_000:
96
+ raise SafeError("backend_response_limit")
97
+ model = data.get("model")
98
+ # Response metadata is recorded as an observation, NEVER proof of enforced routing.
99
+ observed = data.get("provider") if isinstance(data.get("provider"), str) else None
100
+ return (
101
+ text,
102
+ model if isinstance(model, str) and model != "alias-ocr" else None,
103
+ observed,
104
+ )
105
+ except SafeError:
106
+ raise
107
+ except (KeyError, IndexError, TypeError):
108
+ raise SafeError("alias_invalid_response") from None
109
+
110
+ async def probe(self):
111
+ self.qualified = False
112
+ # Synthetic D0 challenge text is not included in the prompt; a text-only echo cannot pass.
113
+ import secrets
114
+
115
+ challenge = "OCR " + secrets.token_hex(3).upper()
116
+ im = Image.new("RGB", (800, 160), "white")
117
+ d = ImageDraw.Draw(im)
118
+ from PIL import ImageFont
119
+
120
+ font = ImageFont.truetype("DejaVuSans.ttf", 48)
121
+ d.text((25, 40), challenge, fill="black", font=font)
122
+ b = io.BytesIO()
123
+ im.save(b, format="PNG")
124
+ text, model, provider = await self._completion(b.getvalue(), probe=True)
125
+ if challenge.casefold() not in text.casefold():
126
+ raise SafeError("alias_multimodal_probe_failed")
127
+ self.qualified = True
128
+ self.qualified_at = time.monotonic()
129
+ self.actual_model = model
130
+ # Observed metadata is not a verified effective destination/fallback guarantee.
131
+ return {
132
+ "multimodal_verified": True,
133
+ "observed_model": model,
134
+ "observed_provider": provider,
135
+ "effective_destination_verified": False,
136
+ "grounding_verified": False,
137
+ "d2_qualified": False,
138
+ }
139
+
140
+ async def recognize(self, page, options):
141
+ if not (await self.health()).ready:
142
+ raise SafeError("alias_unqualified")
143
+ text, model, provider = await self._completion(page.image)
144
+ # Model changes revoke prior probe qualification, even for D0.
145
+ if model != self.actual_model:
146
+ self.qualified = False
147
+ raise SafeError("alias_model_changed")
148
+ blocks = grounding(text) if self.grounding_verified else []
149
+ return OCRPageResult(
150
+ page_index=page.index,
151
+ source="alias_ocr",
152
+ engine=model,
153
+ provider_destination=None,
154
+ text=text,
155
+ markdown=text,
156
+ image_width=page.width,
157
+ image_height=page.height,
158
+ blocks=blocks,
159
+ flags=["destination_unverified"] + ([] if blocks else ["boxes_unavailable"]),
160
+ )
ocr/adapters/http.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ import httpx
4
+
5
+ from ..schemas import SafeError
6
+
7
+
8
+ async def request_json(client, method, url, *, limit=4 * 1024 * 1024, **kwargs):
9
+ try:
10
+ async with client.stream(method, url, **kwargs) as r:
11
+ if r.status_code >= 400:
12
+ raise SafeError(
13
+ f"backend_http_{r.status_code}"
14
+ if r.status_code in (429, 502, 503, 401, 403)
15
+ else "backend_http_error",
16
+ r.status_code in (429, 502, 503),
17
+ )
18
+ data = bytearray()
19
+ async for part in r.aiter_bytes():
20
+ data.extend(part)
21
+ if len(data) > limit:
22
+ raise SafeError("backend_response_limit")
23
+ try:
24
+ return json.loads(data)
25
+ except (ValueError, TypeError):
26
+ raise SafeError("backend_invalid_json") from None
27
+ except SafeError:
28
+ raise
29
+ except (httpx.TimeoutException, httpx.TransportError):
30
+ raise SafeError("backend_unavailable", True) from None
ocr/adapters/rapidocr_cpu.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import hashlib
3
+ import importlib.metadata
4
+ import io
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ from PIL import Image
10
+
11
+ from ..normalize import polygon
12
+ from ..schemas import (
13
+ BackendCapabilities,
14
+ BackendHealth,
15
+ Block,
16
+ OCRPageResult,
17
+ SafeError,
18
+ )
19
+
20
+
21
+ class RapidCPU:
22
+ name = "cpu"
23
+ destination = "local-cpu"
24
+ external = False
25
+
26
+ def __init__(self, models_dir):
27
+ self.models_dir = Path(models_dir)
28
+ self.engine = None
29
+ self.lock = asyncio.Lock()
30
+
31
+ def _initialize(self):
32
+ import cv2
33
+ from rapidocr import EngineType, ModelType, OCRVersion, RapidOCR
34
+
35
+ cv2.setNumThreads(2)
36
+
37
+ manifest = json.loads((Path(__file__).parents[1] / "models.json").read_text())
38
+ params = {
39
+ "Global.use_cls": False,
40
+ "Global.log_level": "critical",
41
+ "EngineConfig.onnxruntime.intra_op_num_threads": 2,
42
+ "EngineConfig.onnxruntime.inter_op_num_threads": 1,
43
+ "EngineConfig.onnxruntime.use_cuda": False,
44
+ }
45
+ for kind in ("Det", "Rec"):
46
+ item = manifest[kind]
47
+ path = self.models_dir / item["filename"]
48
+ if (
49
+ not path.is_file()
50
+ or hashlib.sha256(path.read_bytes()).hexdigest() != item["sha256"]
51
+ ):
52
+ raise SafeError("model_integrity_unavailable")
53
+ params.update(
54
+ {
55
+ f"{kind}.model_path": str(path),
56
+ f"{kind}.model_type": ModelType.SMALL,
57
+ f"{kind}.ocr_version": OCRVersion.PPOCRV6,
58
+ f"{kind}.engine_type": EngineType.ONNXRUNTIME,
59
+ }
60
+ )
61
+ self.engine = RapidOCR(params=params)
62
+ # Force lazy models to initialize before readiness; never download implicitly.
63
+ self.engine(np.full((96, 256, 3), 255, dtype=np.uint8), use_cls=False)
64
+ self.engine(
65
+ np.full((48, 256, 3), 255, dtype=np.uint8),
66
+ use_det=False,
67
+ use_cls=False,
68
+ use_rec=True,
69
+ )
70
+ self.engine.update_params(use_det=True, use_cls=False, use_rec=True)
71
+ for model in (self.engine.text_det, self.engine.text_rec):
72
+ if model is None or model.session.session.get_providers() != ["CPUExecutionProvider"]:
73
+ self.engine = None
74
+ raise SafeError("unexpected_cpu_execution_provider")
75
+
76
+ async def initialize(self):
77
+ async with self.lock:
78
+ if self.engine is None:
79
+ try:
80
+ await asyncio.to_thread(self._initialize)
81
+ except SafeError:
82
+ raise
83
+ except Exception:
84
+ self.engine = None
85
+ raise SafeError("cpu_initialization_failed") from None
86
+
87
+ async def capabilities(self):
88
+ return BackendCapabilities(
89
+ name="cpu",
90
+ bounding_boxes=True,
91
+ languages=["de", "en"],
92
+ supports_sensitive_classes=["D0", "D1", "D2"],
93
+ effective_destination_verifiable=True,
94
+ multimodal_verified=self.engine is not None,
95
+ )
96
+
97
+ async def health(self):
98
+ return BackendHealth(
99
+ ready=self.engine is not None,
100
+ code="ok" if self.engine else "model_not_ready",
101
+ )
102
+
103
+ def _recognize(self, page):
104
+ image = np.asarray(Image.open(io.BytesIO(page.image)).convert("RGB"))[:, :, ::-1].copy()
105
+ out = self.engine(image, use_cls=False)
106
+ blocks = []
107
+ if out.txts is not None:
108
+ for text, score, box in zip(out.txts, out.scores, out.boxes, strict=True):
109
+ p, b = polygon(box, page.width, page.height)
110
+ blocks.append(
111
+ Block(
112
+ text=text,
113
+ confidence=float(score),
114
+ confidence_kind="rapidocr-recognition",
115
+ polygon_1000=p,
116
+ bbox_1000=b,
117
+ )
118
+ )
119
+ # Preserve engine reading order; multi-column order is explicitly flagged by quality checks.
120
+ text = "\n".join(b.text for b in blocks)
121
+ return OCRPageResult(
122
+ page_index=page.index,
123
+ source="cpu_ocr",
124
+ engine="pp-ocrv6-small",
125
+ backend_version=f"rapidocr/{importlib.metadata.version('rapidocr')};onnxruntime/{importlib.metadata.version('onnxruntime')}",
126
+ provider_destination=self.destination,
127
+ text=text,
128
+ markdown=text,
129
+ image_width=page.width,
130
+ image_height=page.height,
131
+ blocks=blocks,
132
+ )
133
+
134
+ async def recognize(self, page, options):
135
+ if self.engine is None:
136
+ raise SafeError("model_not_ready")
137
+ # Shield native work and retain the lock through cancellation: ORT cannot be force-cancelled.
138
+ async with self.lock:
139
+ task = asyncio.create_task(asyncio.to_thread(self._recognize, page))
140
+ try:
141
+ return await asyncio.shield(task)
142
+ except asyncio.CancelledError:
143
+ await task
144
+ raise
145
+ except Exception:
146
+ raise SafeError("cpu_inference_failed") from None
ocr/anonymization/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit trusted-layer anonymization; no legacy fallback or network inference."""
2
+
3
+ from .candidate_io import parse_candidate
4
+ from .engine import AnonymizationEngine
5
+ from .release_policy import ReleaseAuthority
6
+ from .schemas import Entity, LegacyReleaseCandidate, ReleaseCandidate
7
+
8
+ __all__ = [
9
+ "AnonymizationEngine",
10
+ "Entity",
11
+ "ReleaseAuthority",
12
+ "ReleaseCandidate",
13
+ "LegacyReleaseCandidate",
14
+ "parse_candidate",
15
+ ]
ocr/anonymization/__main__.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trusted local workflow: OCRResult JSON -> candidate JSON. No release shortcut."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from ocr.handoff import anonymization_handoff
7
+ from ocr.schemas import OCRResult, SafeError
8
+
9
+ from .atomic_output import write_candidate
10
+ from .engine import AnonymizationEngine
11
+ from .recognizer import GLiNERRecognizer
12
+
13
+
14
+ def main():
15
+ parser = argparse.ArgumentParser(
16
+ description="Local OCR anonymization candidate (review required)"
17
+ )
18
+ parser.add_argument("--model-cache", required=True)
19
+ parser.add_argument(
20
+ "--input", required=True, help="Classified OCRResult JSON; authorized local filesystem only"
21
+ )
22
+ parser.add_argument(
23
+ "--output", required=True, help="New candidate JSON file (still classified)"
24
+ )
25
+ args = parser.parse_args()
26
+ try:
27
+ path = Path(args.input)
28
+ if path.stat().st_size > 8 * 1024 * 1024:
29
+ raise SafeError("anonymization_input_limit")
30
+ evidence = OCRResult.model_validate_json(path.read_bytes())
31
+ model = GLiNERRecognizer(args.model_cache)
32
+ candidate = anonymization_handoff(evidence, AnonymizationEngine(model))
33
+ write_candidate(args.output, (candidate.model_dump_json(indent=2) + "\n").encode())
34
+ except Exception:
35
+ raise SystemExit("anonymization_candidate_failed") from None
36
+ print("candidate_created_review_required")
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
ocr/anonymization/approval_ledger.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicitly provisioned, persistent single-host SQLite replay ledger."""
2
+
3
+ import hashlib
4
+ import os
5
+ import sqlite3
6
+ import stat
7
+ import time
8
+ import uuid
9
+ from contextlib import closing
10
+ from pathlib import Path
11
+
12
+ from ocr.schemas import SafeError
13
+
14
+
15
+ class SQLiteApprovalLedger:
16
+ """Provision once with initialize=True; ordinary startup NEVER creates storage.
17
+
18
+ Persist ledger_id in private configuration and supply expected_id on restart.
19
+ Local ledgers must not share a signing key across hosts. A genuinely shared
20
+ durable backend is required for a shared key domain, not SQLite copies/NFS.
21
+ """
22
+
23
+ def __init__(self, path, *, initialize=False, expected_id=None):
24
+ self.path = Path(path).absolute()
25
+ try:
26
+ if initialize:
27
+ fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
28
+ os.close(fd)
29
+ self._metadata()
30
+ with closing(self._connect()) as connection, connection:
31
+ if initialize:
32
+ connection.execute("PRAGMA synchronous=FULL")
33
+ connection.execute(
34
+ "CREATE TABLE consumed (id TEXT PRIMARY KEY, expires INTEGER NOT NULL)"
35
+ )
36
+ connection.execute("CREATE TABLE identity (id TEXT NOT NULL)")
37
+ connection.execute("INSERT INTO identity VALUES (?)", (uuid.uuid4().hex,))
38
+ if connection.execute("PRAGMA quick_check").fetchone() != ("ok",):
39
+ raise ValueError()
40
+ self.ledger_id = connection.execute("SELECT id FROM identity").fetchone()[0]
41
+ if expected_id is not None and expected_id != self.ledger_id:
42
+ raise ValueError()
43
+ metadata = self.path.lstat()
44
+ self._inode = (metadata.st_dev, metadata.st_ino)
45
+ except Exception:
46
+ raise SafeError("approval_ledger_unavailable") from None
47
+
48
+ def _metadata(self):
49
+ metadata, parent = self.path.lstat(), self.path.parent.lstat()
50
+ if (
51
+ not stat.S_ISREG(metadata.st_mode)
52
+ or metadata.st_uid != os.getuid()
53
+ or metadata.st_mode & 0o077
54
+ or not stat.S_ISDIR(parent.st_mode)
55
+ or parent.st_uid != os.getuid()
56
+ or parent.st_mode & 0o077
57
+ ):
58
+ raise ValueError()
59
+ return metadata
60
+
61
+ def _connect(self):
62
+ return sqlite3.connect(self.path.as_uri() + "?mode=rw", uri=True, timeout=5)
63
+
64
+ def consume(self, approval_id, expires_at):
65
+ try:
66
+ now = int(time.time())
67
+ if type(expires_at) is not int or expires_at <= now:
68
+ return False
69
+ metadata = self._metadata()
70
+ if (metadata.st_dev, metadata.st_ino) != self._inode:
71
+ raise ValueError()
72
+ with closing(self._connect()) as connection, connection:
73
+ connection.execute("PRAGMA synchronous=FULL")
74
+ connection.execute("BEGIN IMMEDIATE")
75
+ # Lock acquisition may have waited beyond approval expiration.
76
+ # Recheck at the consumption transaction, not only before waiting.
77
+ if expires_at <= int(time.time()):
78
+ return False
79
+ if connection.execute("SELECT id FROM identity").fetchone() != (self.ledger_id,):
80
+ raise ValueError()
81
+ # Retain consumed IDs: clock rollback must not resurrect approvals
82
+ # after expiry-based garbage collection. Offline archive is operator work.
83
+ connection.execute("INSERT INTO consumed VALUES (?, ?)", (approval_id, expires_at))
84
+ return True
85
+ except sqlite3.IntegrityError:
86
+ return False
87
+ except Exception:
88
+ raise SafeError("approval_ledger_unavailable") from None
89
+
90
+
91
+ def validate_key_topology(bindings, *, signing_keys):
92
+ """Compare actual key fingerprints, never caller-chosen key aliases.
93
+
94
+ This implementation qualifies local per-host key domains only. A shared
95
+ backend declaration is NOT evidence of shared durability; that integration
96
+ remains blocked until a real backend is implemented and qualified separately.
97
+ """
98
+ try:
99
+ groups = {}
100
+ for entry in bindings:
101
+ key = signing_keys[entry["key_id"]]
102
+ if not isinstance(key, bytes) or len(key) < 32:
103
+ raise ValueError()
104
+ fingerprint = hashlib.sha256(key).hexdigest()
105
+ groups.setdefault(fingerprint, set()).add(entry["host_id"])
106
+ if any(len(hosts) > 1 for hosts in groups.values()):
107
+ raise ValueError()
108
+ except Exception:
109
+ raise SafeError("shared_key_multi_host_backend_not_qualified") from None
ocr/anonymization/artifacts.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "VAGOsolutions/SauerkrautLM-GLiNER",
3
+ "revision": "ba967ee030b7e679dd5422cb16fcd1b379da38e1",
4
+ "files": {
5
+ "gliner_config.json": "f0659e7440bff3dd8630c8c553c30e0244d3949b7e6fa6e02938fb4bb4e2e9da",
6
+ "pytorch_model.bin": "a4f25b51796426ca39bf5b59ea2d142902693b690ffc1458a792267e45e3c269",
7
+ "special_tokens_map.json": "6e3204c5a4004719c185007a745d1940471a6fb02521cfa0a00306dbb6bc5903",
8
+ "tokenizer.json": "4f65190517e4633347d84e0225b8c5c66a1a62321107ebc73c09d5cefa2b5cd5",
9
+ "tokenizer_config.json": "ae8ad41b7039b6f1121db8be5790eae14ca7226ec14110406d4068797f505705"
10
+ }
11
+ }
ocr/anonymization/atomic_output.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Publish a complete private file atomically, without overwriting any target."""
2
+
3
+ import fcntl
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from ocr.schemas import SafeError
9
+
10
+
11
+ def write_candidate(path, data):
12
+ temporary, directory = None, None
13
+ try:
14
+ path = Path(path)
15
+ directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
16
+ fcntl.flock(directory, fcntl.LOCK_SH)
17
+ # Same-directory staging guarantees a same-filesystem atomic hard link.
18
+ fd, temporary = tempfile.mkstemp(prefix=".anonymization-", dir=path.parent)
19
+ with os.fdopen(fd, "wb") as stream:
20
+ if stream.write(data) != len(data):
21
+ raise OSError("incomplete_write")
22
+ stream.flush()
23
+ os.fsync(stream.fileno())
24
+ # link() fails if ANY target (including a symlink) already exists.
25
+ # Never use rename()/replace(), which can clobber another process's file.
26
+ os.link(temporary, path)
27
+ except Exception:
28
+ raise SafeError("anonymization_candidate_write_failed") from None
29
+ finally:
30
+ if temporary is not None:
31
+ try:
32
+ os.unlink(temporary)
33
+ except FileNotFoundError:
34
+ pass
35
+ except OSError:
36
+ # Never print classified bytes or paths on cleanup failure. A
37
+ # leftover staging file is private (0600), never an approved export.
38
+ pass
39
+
40
+ if directory is not None:
41
+ os.close(directory)
ocr/anonymization/candidate_io.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit candidate version dispatch. No rewriting, migration or approval copying."""
2
+
3
+ import json
4
+
5
+ from ocr.schemas import SafeError
6
+
7
+ from .schemas import CorrectedCandidate, LegacyReleaseCandidate, ReleaseCandidate
8
+
9
+
10
+ def parse_candidate(data):
11
+ try:
12
+ if not isinstance(data, (str, bytes)) or len(data) > 16 * 1024 * 1024:
13
+ raise ValueError()
14
+ version = json.loads(data)["schema_version"]
15
+ cls = {
16
+ "ocr-anonymization-candidate/v1": LegacyReleaseCandidate,
17
+ "ocr-anonymization-candidate/v2": ReleaseCandidate,
18
+ "ocr-anonymization-candidate/v3": CorrectedCandidate,
19
+ }[version]
20
+ return cls.model_validate_json(data)
21
+ except Exception:
22
+ raise SafeError("unsupported_or_invalid_candidate") from None
ocr/anonymization/compare03d.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Development-only fixed experiments, never a claim of independent holdout quality.
2
+
3
+ All input paths are fixed repository synthetic corpora. Per-case payload evidence
4
+ is intentionally synthetic. The three datasets are development data from Gate03d.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ import resource
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from .atomic_output import write_candidate
16
+ from .detectors import detect_contacts as detect
17
+ from .evaluate import score_cases
18
+ from .investigate import Recorded
19
+ from .recognizer import DEFAULT_PROMPTS, GLiNERRecognizer
20
+ from .replacement import replace, resolve_overlaps, validate_entities
21
+ from .terminology import DEFAULT_TERMS, ReligiousTerminology
22
+
23
+
24
+ def run(cache, output):
25
+ denied = []
26
+
27
+ def offline(event, args):
28
+ if event in ("socket.connect", "socket.bind", "socket.getaddrinfo"):
29
+ denied.append(event)
30
+ raise PermissionError("qualification_network_disabled")
31
+
32
+ sys.addaudithook(offline)
33
+ start = time.perf_counter()
34
+ model = GLiNERRecognizer(cache)
35
+ load = (time.perf_counter() - start) * 1000
36
+ model.recognize("Synthetic warmup text.")
37
+ root = Path(__file__).parent / "fixtures"
38
+ paths = {
39
+ "qualification": root / "qualification.json",
40
+ "validation": root / "validation.json",
41
+ "development03c": root / "holdout03c/cases.json",
42
+ }
43
+ suites = {n: json.loads(p.read_bytes()) for n, p in paths.items()}
44
+ terminology = ReligiousTerminology()
45
+
46
+ def hybrid(text):
47
+ return detect(text) + terminology.detect(text)
48
+
49
+ report = dict(
50
+ schema_version="gate03d-development-comparison/v1",
51
+ source_sha=subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
52
+ model_revision=model.revision,
53
+ corpus_sha256={n: hashlib.sha256(p.read_bytes()).hexdigest() for n, p in paths.items()},
54
+ terminology=list(DEFAULT_TERMS),
55
+ loading_ms=load,
56
+ experiments={},
57
+ automated_release_eligible=False,
58
+ )
59
+ baseline = {}
60
+ # Fixed before inference. No low-threshold sweep or fixture-specific patterns.
61
+ for name, prompts, detector in (
62
+ ("control", DEFAULT_PROMPTS, detect),
63
+ (
64
+ "semantic_status_prompt",
65
+ DEFAULT_PROMPTS | {"RELIGION": "religion or religious non-affiliation"},
66
+ detect,
67
+ ),
68
+ ("hybrid_terminology", DEFAULT_PROMPTS, hybrid),
69
+ ):
70
+ model.prompts = dict(prompts)
71
+ recorder = Recorded(model)
72
+ result = dict(prompts=model.prompts, threshold=model.threshold, suites={}, changes=[])
73
+ for suite, cases in suites.items():
74
+ result["suites"][suite] = score_cases(recorder, cases, detector=detector)
75
+ for case in cases:
76
+ text = case["text"]
77
+ raw = recorder.predictions[text]
78
+ combined = resolve_overlaps(validate_entities(text, raw + detector(text)))
79
+ output_text, _ = replace(text, combined)
80
+ predicted = {(p.start, p.end, p.label) for p in combined}
81
+ gold = {(p["start"], p["end"], p["label"]) for p in case["spans"]}
82
+ item = dict(
83
+ suite=suite,
84
+ case_id=case["id"],
85
+ false_positives=sorted(predicted - gold),
86
+ false_negatives=sorted(gold - predicted),
87
+ exact_match=output_text == case["expected"],
88
+ predictions=[p.model_dump() for p in combined],
89
+ )
90
+ key = (suite, case["id"])
91
+ if name == "control":
92
+ baseline[key] = item
93
+ if item["false_positives"] or item["false_negatives"]:
94
+ result["changes"].append(item)
95
+ elif item["predictions"] != baseline[key]["predictions"]:
96
+ result["changes"].append(dict(before=baseline[key], after=item))
97
+ report["experiments"][name] = result
98
+ report["peak_rss_mib"] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
99
+ report["denied_network_calls"] = denied
100
+ write_candidate(output, (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode())
101
+
102
+
103
+ if __name__ == "__main__":
104
+ import argparse
105
+
106
+ parser = argparse.ArgumentParser(description="Compare fixed Gate03d development alternatives")
107
+ parser.add_argument("--cache", required=True)
108
+ parser.add_argument("--output", required=True)
109
+ args = parser.parse_args()
110
+ run(args.cache, args.output)
111
+ print("comparison_recorded_check_metrics_before_selection")
ocr/anonymization/corrections.py ADDED
@@ -0,0 +1,754 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source-bound, authorized human corrections. No recognition optimization/export.
2
+
3
+ Machine snapshots, proposals and published effective states are immutable private
4
+ records. The host must implement durable atomic CAS publication and current ACLs;
5
+ no production identity/store is supplied. Candidate metadata contains hashes only.
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ import re
11
+ import time
12
+ import uuid
13
+ from typing import Literal, Protocol
14
+
15
+ from pydantic import Field, model_validator
16
+
17
+ from ocr.schemas import OCRResult, SafeError
18
+
19
+ from .private_review import (
20
+ PrivateReviewBridge,
21
+ PrivateReviewHost,
22
+ PrivateReviewMaterial,
23
+ ReviewBinding,
24
+ SourcePageReference,
25
+ )
26
+ from .provenance import provenance
27
+ from .release_policy import digest
28
+ from .replacement import replace, validate_entities
29
+ from .schemas import (
30
+ LABELS,
31
+ CandidatePage,
32
+ CorrectedCandidate,
33
+ CorrectionProvenance,
34
+ Entity,
35
+ Label,
36
+ RecognitionProvenance,
37
+ ReleaseCandidate,
38
+ StrictModel,
39
+ )
40
+
41
+ POLICY = "reviewed-corrections-v1"
42
+
43
+
44
+ def commitment(value):
45
+ if isinstance(value, StrictModel):
46
+ value = value.model_dump(mode="json")
47
+ return hashlib.sha256(
48
+ json.dumps(
49
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
50
+ ).encode()
51
+ ).hexdigest()
52
+
53
+
54
+ def author_digest(subject):
55
+ return commitment(dict(domain="private-correction-author/v1", subject=subject))
56
+
57
+
58
+ class Observation(StrictModel):
59
+ observation_id: str = Field(pattern=r"^[a-f0-9]{64}$")
60
+ page_index: int = Field(ge=0)
61
+ entity: Entity = Field(repr=False)
62
+ human_correction_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
63
+
64
+ @model_validator(mode="after")
65
+ def explicit_human_provenance(self):
66
+ if self.human_correction_id is not None and (
67
+ self.entity.source != "deterministic"
68
+ or self.entity.model_revision != POLICY
69
+ or self.entity.score is not None
70
+ or self.entity.normalization != "identity"
71
+ ):
72
+ raise ValueError("invalid_human_observation")
73
+ return self
74
+
75
+
76
+ class Placeholder(StrictModel):
77
+ category: Label
78
+ token: str = Field(pattern=r"^\[[A-Z_]{1,40}\]$")
79
+
80
+
81
+ class MachineSnapshot(StrictModel):
82
+ schema_version: Literal["private-machine-snapshot/v1"] = "private-machine-snapshot/v1"
83
+ evidence_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
84
+ sources: tuple[SourcePageReference, ...] = Field(min_length=1, max_length=100, repr=False)
85
+ observations: tuple[Observation, ...] = Field(max_length=100_000, repr=False)
86
+ provenance: RecognitionProvenance
87
+ placeholders: tuple[Placeholder, ...]
88
+ page_sources: tuple[Literal["embedded_text", "cpu_ocr", "alias_ocr"], ...]
89
+ machine_candidate: ReleaseCandidate | None = Field(repr=False)
90
+
91
+ @model_validator(mode="after")
92
+ def machine_only(self):
93
+ if (
94
+ any(o.human_correction_id is not None for o in self.observations)
95
+ or {p.category for p in self.placeholders} != set(LABELS)
96
+ or len(self.placeholders) != len(LABELS)
97
+ or len(self.sources) != len(self.page_sources)
98
+ or len({s.page_index for s in self.sources}) != len(self.sources)
99
+ or sum(s.characters for s in self.sources) > 2_000_000
100
+ or len({s.sensitivity for s in self.sources}) != 1
101
+ or (
102
+ self.machine_candidate is not None
103
+ and type(self.machine_candidate) is not ReleaseCandidate
104
+ )
105
+ ):
106
+ raise ValueError("invalid_machine_snapshot")
107
+ return self
108
+
109
+
110
+ class CorrectionContext(StrictModel):
111
+ schema_version: Literal["private-correction-context/v1"] = "private-correction-context/v1"
112
+ material: PrivateReviewMaterial = Field(repr=False)
113
+ machine: MachineSnapshot = Field(repr=False)
114
+ effective: tuple[Observation, ...] = Field(max_length=100_000, repr=False)
115
+
116
+ @model_validator(mode="after")
117
+ def root_binding(self):
118
+ if (
119
+ self.material.evidence_digest != self.machine.evidence_digest
120
+ or self.material.sources != self.machine.sources
121
+ or self.material.provenance != self.machine.provenance
122
+ ):
123
+ raise ValueError("correction_context_mismatch")
124
+ candidate = self.material.candidate
125
+ if type(candidate) is CorrectedCandidate:
126
+ c = candidate.correction
127
+ if (
128
+ c.machine_snapshot_digest != commitment(self.machine)
129
+ or c.original_evidence_digest != self.machine.evidence_digest
130
+ or c.source_references_digest
131
+ != commitment([s.model_dump(mode="json") for s in self.machine.sources])
132
+ or c.effective_state_digest != state_digest(self.machine, self.effective)
133
+ ):
134
+ raise ValueError("correction_context_mismatch")
135
+ elif (
136
+ self.effective != self.machine.observations
137
+ or candidate != self.machine.machine_candidate
138
+ ):
139
+ raise ValueError("correction_context_mismatch")
140
+ if candidate is None and self.material.blocking_reason != "conflicting_spans":
141
+ raise ValueError("unsupported_failed_correction_context")
142
+ return self
143
+
144
+
145
+ def state_digest(machine, effective):
146
+ return commitment(
147
+ dict(
148
+ machine=commitment(machine), observations=[o.model_dump(mode="json") for o in effective]
149
+ )
150
+ )
151
+
152
+
153
+ class SpanBinding(StrictModel):
154
+ page_index: int = Field(ge=0)
155
+ start: int = Field(ge=0)
156
+ end: int = Field(gt=0)
157
+ source_span_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
158
+
159
+
160
+ class CorrectionEdit(StrictModel):
161
+ span: SpanBinding
162
+ category: Label
163
+ action: Literal["mask", "suppress"]
164
+ rationale: Literal[
165
+ "missing_entity", "incomplete_boundary", "wrong_category", "span_conflict", "false_positive"
166
+ ]
167
+ target_observation_ids: tuple[str, ...] = Field(max_length=100_000)
168
+
169
+ @model_validator(mode="after")
170
+ def valid_action(self):
171
+ if (
172
+ any(not re.fullmatch(r"[a-f0-9]{64}", s) for s in self.target_observation_ids)
173
+ or len(set(self.target_observation_ids)) != len(self.target_observation_ids)
174
+ or (self.action == "suppress") != (self.rationale == "false_positive")
175
+ or (not self.target_observation_ids) != (self.rationale == "missing_entity")
176
+ ):
177
+ raise ValueError("invalid_correction_action")
178
+ return self
179
+
180
+
181
+ class CorrectionProposal(StrictModel):
182
+ schema_version: Literal["private-ocr-correction/v1"] = "private-ocr-correction/v1"
183
+ policy_version: Literal["reviewed-corrections-v1"] = "reviewed-corrections-v1"
184
+ correction_id: str = Field(pattern=r"^[a-f0-9]{32}$")
185
+ review_ref: str = Field(pattern=r"^[a-zA-Z0-9_-]{1,128}$")
186
+ author_subject: str = Field(pattern=r"^[a-zA-Z0-9:_-]{1,128}$", repr=False)
187
+ evidence_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
188
+ context_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
189
+ machine_snapshot_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
190
+ prior_candidate_digest: str | None = Field(pattern=r"^[a-f0-9]{64}$")
191
+ provenance: RecognitionProvenance
192
+ edits: tuple[CorrectionEdit, ...] = Field(min_length=1, max_length=1000)
193
+ created_at: int
194
+ expires_at: int
195
+
196
+ @model_validator(mode="after")
197
+ def short_lived(self):
198
+ if not 0 <= self.created_at < self.expires_at <= self.created_at + 900:
199
+ raise ValueError("invalid_correction_expiration")
200
+ return self
201
+
202
+
203
+ class StoredProposal(StrictModel):
204
+ proposal: CorrectionProposal = Field(repr=False)
205
+ revoked: bool
206
+ published_candidate_digest: str | None = Field(pattern=r"^[a-f0-9]{64}$")
207
+
208
+ published_review_ref: str | None = Field(default=None, pattern=r"^[a-zA-Z0-9_-]{1,128}$")
209
+
210
+ @model_validator(mode="after")
211
+ def complete_publication_pointer(self):
212
+ if (self.published_review_ref is None) != (self.published_candidate_digest is None):
213
+ raise ValueError("invalid_publication_pointer")
214
+ return self
215
+
216
+
217
+ class PublicationReceipt(StrictModel):
218
+ correction_id: str = Field(pattern=r"^[a-f0-9]{32}$")
219
+ proposal_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
220
+ candidate_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
221
+ context_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
222
+ review_ref: str = Field(pattern=r"^[a-zA-Z0-9_-]{1,128}$")
223
+ automated_release_eligible: Literal[False] = False
224
+ release_approved: Literal[False] = False
225
+
226
+
227
+ class CorrectionReview(StrictModel):
228
+ context: CorrectionContext = Field(repr=False)
229
+ record: StoredProposal = Field(repr=False)
230
+ release_approved: Literal[False] = False
231
+
232
+
233
+ class CorrectionHost(PrivateReviewHost, Protocol):
234
+ """Required host contract, NOT a durable storage implementation.
235
+
236
+ store/publish must durably commit reauthorization, compare-and-swap of the
237
+ active head, immutable records and single-use publication in one transaction.
238
+ An RLock fixture demonstrates only in-process semantics. Crash/restart and
239
+ multi-process durability remain unqualified until a real adapter is supplied.
240
+ """
241
+
242
+ def load_correction_context(self, review_ref: str) -> CorrectionContext: ...
243
+ def correction_binding(self, correction_id: str) -> str: ...
244
+ def load_correction(self, correction_id: str) -> StoredProposal: ...
245
+ def store_correction(
246
+ self, credential: object, proposal: CorrectionProposal, expected_context: str
247
+ ) -> bool: ...
248
+ def publish_correction(
249
+ self,
250
+ credential: object,
251
+ proposal: CorrectionProposal,
252
+ context: CorrectionContext,
253
+ expected_context: str,
254
+ ) -> PublicationReceipt: ...
255
+
256
+ # Atomic operations must reauthorize and CAS the active context, retain all
257
+ # old snapshots/proposals and consume this correction once in DURABLE storage.
258
+
259
+
260
+ def span_binding(context, page_index, start, end, text):
261
+ source = next(s for s in context.machine.sources if s.page_index == page_index)
262
+ if (
263
+ type(start) is not int
264
+ or type(end) is not int
265
+ or not 0 <= start < end <= len(text)
266
+ or len(text) != source.characters
267
+ or hashlib.sha256(text.encode()).hexdigest() != source.sha256
268
+ ):
269
+ raise ValueError()
270
+ value = commitment(
271
+ dict(
272
+ domain="classified-original-span/v1",
273
+ evidence=context.machine.evidence_digest,
274
+ page=source.model_dump(mode="json"),
275
+ start=start,
276
+ end=end,
277
+ text_sha256=hashlib.sha256(text[start:end].encode()).hexdigest(),
278
+ )
279
+ )
280
+ return SpanBinding(page_index=page_index, start=start, end=end, source_span_digest=value)
281
+
282
+
283
+ def render(machine, effective, texts):
284
+ pages = []
285
+ allowed_pages = {s.page_index for s in machine.sources}
286
+ if any(o.page_index not in allowed_pages for o in effective):
287
+ raise ValueError()
288
+ for source, page_source in zip(machine.sources, machine.page_sources, strict=True):
289
+ text = texts[source.page_index]
290
+ if (
291
+ len(text) != source.characters
292
+ or hashlib.sha256(text.encode()).hexdigest() != source.sha256
293
+ ):
294
+ raise ValueError()
295
+ observations = [o for o in effective if o.page_index == source.page_index]
296
+ entities = validate_entities(text, [o.entity for o in observations])
297
+ output, tags = replace(text, entities, {p.category: p.token for p in machine.placeholders})
298
+ human = {
299
+ (o.entity.start, o.entity.end, o.entity.label)
300
+ for o in observations
301
+ if o.human_correction_id is not None
302
+ }
303
+ tags = tuple(
304
+ t.model_copy(update={"source": "human_correction"})
305
+ if (t.start, t.end, t.label) in human and t.model_revision == POLICY
306
+ else t
307
+ for t in tags
308
+ )
309
+ pages.append(
310
+ CandidatePage(
311
+ page_index=source.page_index,
312
+ anonymized_text=output,
313
+ tags=tags,
314
+ source=page_source,
315
+ original_sensitivity=source.sensitivity,
316
+ )
317
+ )
318
+ return tuple(pages)
319
+
320
+
321
+ def prepare_review(engine, result, *, review_ref, environment_id, expires_at):
322
+ """Trusted capture. Runtime failure aborts; recoverable span conflicts are retained.
323
+
324
+ The supplied review/env references are server-operator registration inputs, not
325
+ public request fields. Return objects must be stored in an immutable private host.
326
+ """
327
+ try:
328
+ snapshot = OCRResult.model_validate_json(result.model_dump_json())
329
+ engine.validate_document(snapshot)
330
+ original_digest = hashlib.sha256(snapshot.model_dump_json().encode()).hexdigest()
331
+ config = provenance(
332
+ engine.recognizer, engine.terminology, engine.status_policy, engine.placeholders
333
+ )
334
+ sources, observations, texts = [], [], {}
335
+ for page in snapshot.pages:
336
+ text = page.text
337
+ texts[page.page_index] = text
338
+ sources.append(
339
+ SourcePageReference(
340
+ evidence_ref=commitment(dict(evidence=original_digest, page=page.page_index)),
341
+ page_index=page.page_index,
342
+ sensitivity=snapshot.sensitivity,
343
+ sha256=hashlib.sha256(text.encode()).hexdigest(),
344
+ characters=len(text),
345
+ )
346
+ )
347
+ for entity in engine.observe(text):
348
+ payload = dict(
349
+ evidence=original_digest,
350
+ config=config.configuration_digest,
351
+ page=page.page_index,
352
+ entity=entity.model_dump(mode="json"),
353
+ )
354
+ observations.append(
355
+ Observation(
356
+ observation_id=commitment(payload),
357
+ page_index=page.page_index,
358
+ entity=entity,
359
+ )
360
+ )
361
+ if config != provenance(
362
+ engine.recognizer, engine.terminology, engine.status_policy, engine.placeholders
363
+ ):
364
+ raise ValueError()
365
+ placeholders = (
366
+ engine.placeholders
367
+ if engine.placeholders is not None
368
+ else {k: f"[{k}]" for k in LABELS}
369
+ )
370
+ machine = MachineSnapshot(
371
+ evidence_digest=original_digest,
372
+ sources=tuple(sources),
373
+ observations=tuple(observations),
374
+ provenance=config,
375
+ placeholders=tuple(
376
+ Placeholder(category=k, token=v) for k, v in sorted(placeholders.items())
377
+ ),
378
+ page_sources=tuple(p.source for p in snapshot.pages),
379
+ machine_candidate=None,
380
+ )
381
+ blocked = None
382
+ try:
383
+ pages = render(machine, machine.observations, texts)
384
+ reasons = ("recognition_incomplete_possible",) + (
385
+ ("empty_prediction",)
386
+ if any(
387
+ not any(
388
+ o.page_index == source.page_index and o.entity.source == "multilingual_ner"
389
+ for o in observations
390
+ )
391
+ for source in sources
392
+ )
393
+ else ()
394
+ )
395
+ candidate = ReleaseCandidate(
396
+ candidate_id=uuid.uuid4().hex,
397
+ sensitivity=snapshot.sensitivity,
398
+ pages=pages,
399
+ model_revision=config.model_revision,
400
+ provenance=config,
401
+ review_reasons=reasons,
402
+ )
403
+ machine = machine.model_copy(update={"machine_candidate": candidate})
404
+ except SafeError as exc:
405
+ if exc.code != "ambiguous_entity_overlap":
406
+ raise
407
+ candidate, blocked = None, "conflicting_spans"
408
+ material = PrivateReviewMaterial(
409
+ binding=ReviewBinding(
410
+ review_ref=review_ref,
411
+ environment_id=environment_id,
412
+ sensitivity=snapshot.sensitivity,
413
+ expires_at=expires_at,
414
+ revoked=False,
415
+ ),
416
+ evidence_digest=original_digest,
417
+ sources=tuple(sources),
418
+ candidate=candidate,
419
+ candidate_digest=digest(candidate) if candidate else None,
420
+ provenance=config,
421
+ blocking_reason=blocked,
422
+ )
423
+ return CorrectionContext(material=material, machine=machine, effective=machine.observations)
424
+ except Exception:
425
+ raise SafeError("correction_capture_failed") from None
426
+
427
+
428
+ def apply_edits(context, proposal, texts):
429
+ if (
430
+ proposal.context_digest != commitment(context)
431
+ or proposal.evidence_digest != context.machine.evidence_digest
432
+ or proposal.provenance != context.machine.provenance
433
+ or proposal.machine_snapshot_digest != commitment(context.machine)
434
+ or proposal.prior_candidate_digest != context.material.candidate_digest
435
+ or proposal.review_ref != context.material.binding.review_ref
436
+ ):
437
+ raise ValueError()
438
+ effective = {o.observation_id: o for o in context.effective}
439
+ if any(effective[o.observation_id] != o for o in context.effective):
440
+ raise ValueError()
441
+ ordered = sorted(proposal.edits, key=lambda e: (e.span.page_index, e.span.start, e.span.end))
442
+ used = set()
443
+ previous = None
444
+ for index, edit in enumerate(ordered):
445
+ s = edit.span
446
+ if previous is not None and previous.page_index == s.page_index and s.start < previous.end:
447
+ raise SafeError("correction_conflict")
448
+ previous = s
449
+ if span_binding(context, s.page_index, s.start, s.end, texts[s.page_index]) != s:
450
+ raise ValueError()
451
+ targets = set(edit.target_observation_ids)
452
+ if used & targets or not targets <= set(effective):
453
+ raise SafeError("correction_conflict")
454
+ used |= targets
455
+ target_obs = [effective[k] for k in targets]
456
+ overlap = {
457
+ k
458
+ for k, o in effective.items()
459
+ if o.page_index == s.page_index and o.entity.start < s.end and s.start < o.entity.end
460
+ }
461
+ if edit.action == "mask":
462
+ if targets != overlap:
463
+ raise SafeError("correction_conflict")
464
+ if edit.rationale == "wrong_category" and any(
465
+ (o.entity.start, o.entity.end) != (s.start, s.end) for o in target_obs
466
+ ):
467
+ raise ValueError()
468
+ elif any(
469
+ o.page_index != s.page_index
470
+ or o.entity.start != s.start
471
+ or o.entity.end != s.end
472
+ or o.entity.label != edit.category
473
+ for o in target_obs
474
+ ):
475
+ raise ValueError()
476
+ for key in targets:
477
+ del effective[key]
478
+ if edit.action == "mask":
479
+ entity = Entity(
480
+ start=s.start,
481
+ end=s.end,
482
+ label=edit.category,
483
+ text=texts[s.page_index][s.start : s.end],
484
+ source="deterministic",
485
+ model_revision=POLICY,
486
+ )
487
+ key = commitment(
488
+ dict(
489
+ correction=proposal.correction_id, ordinal=index, span=s.model_dump(mode="json")
490
+ )
491
+ )
492
+ effective[key] = Observation(
493
+ observation_id=key,
494
+ page_index=s.page_index,
495
+ entity=entity,
496
+ human_correction_id=proposal.correction_id,
497
+ )
498
+ result = tuple(
499
+ sorted(
500
+ effective.values(),
501
+ key=lambda o: (o.page_index, o.entity.start, o.entity.end, o.observation_id),
502
+ )
503
+ )
504
+ render(
505
+ context.machine, result, texts
506
+ ) # Unresolved machine conflicts still block the whole job.
507
+ return result
508
+
509
+
510
+ class CorrectionService:
511
+ def __init__(self, host: CorrectionHost):
512
+ self.host = host
513
+ self.review = PrivateReviewBridge(host)
514
+
515
+ def _authorize(self, credential, review_ref, scope):
516
+ binding = self.review._binding(credential, review_ref)
517
+ identity = self.review.authorization.identity(credential)
518
+ if (
519
+ self.host.current_acl(
520
+ identity,
521
+ "anonymization:correction:" + scope,
522
+ binding.sensitivity,
523
+ binding.environment_id,
524
+ )
525
+ is not True
526
+ ):
527
+ raise ValueError()
528
+ return identity, binding
529
+
530
+ def _context(self, credential, review_ref, scope):
531
+ identity, binding = self._authorize(credential, review_ref, scope)
532
+ material = self.review.open(credential, review_ref)
533
+ context = CorrectionContext.model_validate(self.host.load_correction_context(review_ref))
534
+ if context.material != material or self._authorize(credential, review_ref, scope) != (
535
+ identity,
536
+ binding,
537
+ ):
538
+ raise ValueError()
539
+ return identity, context
540
+
541
+ def inspect(self, credential, review_ref):
542
+ try:
543
+ return self._context(credential, review_ref, "inspect")[1]
544
+ except Exception:
545
+ raise SafeError("private_correction_denied") from None
546
+
547
+ def _texts(self, credential, context):
548
+ texts = {
549
+ s.page_index: self.review.source_page(
550
+ credential, context.material.binding.review_ref, s.page_index
551
+ ).text
552
+ for s in context.machine.sources
553
+ }
554
+ # Validate every captured/effective source slice, including observations
555
+ # the proposed correction intends to supersede. No corrupt evidence repair.
556
+ for observation in context.machine.observations:
557
+ expected = commitment(
558
+ dict(
559
+ evidence=context.machine.evidence_digest,
560
+ config=context.machine.provenance.configuration_digest,
561
+ page=observation.page_index,
562
+ entity=observation.entity.model_dump(mode="json"),
563
+ )
564
+ )
565
+ if observation.observation_id != expected:
566
+ raise ValueError()
567
+ for observations in (context.machine.observations, context.effective):
568
+ for source in context.machine.sources:
569
+ validate_entities(
570
+ texts[source.page_index],
571
+ [o.entity for o in observations if o.page_index == source.page_index],
572
+ )
573
+ if any(o.page_index not in texts for o in observations):
574
+ raise ValueError()
575
+ try:
576
+ original = render(context.machine, context.machine.observations, texts)
577
+ except SafeError as exc:
578
+ if (
579
+ exc.code != "ambiguous_entity_overlap"
580
+ or context.machine.machine_candidate is not None
581
+ ):
582
+ raise
583
+ else:
584
+ if (
585
+ context.machine.machine_candidate is None
586
+ or original != context.machine.machine_candidate.pages
587
+ ):
588
+ raise ValueError()
589
+ candidate = context.material.candidate
590
+ if (
591
+ candidate is not None
592
+ and render(context.machine, context.effective, texts) != candidate.pages
593
+ ):
594
+ raise ValueError()
595
+ return texts
596
+
597
+ def bind(self, credential, review_ref, page_index, start, end):
598
+ try:
599
+ _, context = self._context(credential, review_ref, "submit")
600
+ text = self.review.source_page(credential, review_ref, page_index).text
601
+ value = span_binding(context, page_index, start, end, text)
602
+ self._authorize(credential, review_ref, "submit")
603
+ return value
604
+ except Exception:
605
+ raise SafeError("private_correction_denied") from None
606
+
607
+ def propose(self, credential, review_ref, edits):
608
+ try:
609
+ identity, context = self._context(credential, review_ref, "submit")
610
+ if type(edits) not in (tuple, list) or not 1 <= len(edits) <= 1000:
611
+ raise ValueError()
612
+ edits = tuple(CorrectionEdit.model_validate(e) for e in edits)
613
+ now = int(time.time())
614
+ proposal = CorrectionProposal(
615
+ correction_id=uuid.uuid4().hex,
616
+ review_ref=review_ref,
617
+ author_subject=identity.subject,
618
+ evidence_digest=context.machine.evidence_digest,
619
+ context_digest=commitment(context),
620
+ machine_snapshot_digest=commitment(context.machine),
621
+ prior_candidate_digest=context.material.candidate_digest,
622
+ provenance=context.machine.provenance,
623
+ edits=edits,
624
+ created_at=now,
625
+ expires_at=min(now + 900, context.material.binding.expires_at),
626
+ )
627
+ texts = self._texts(credential, context)
628
+ apply_edits(context, proposal, texts)
629
+ if self._context(credential, review_ref, "submit") != (identity, context):
630
+ raise ValueError()
631
+ if self.host.store_correction(credential, proposal, commitment(context)) is not True:
632
+ raise ValueError()
633
+ stored = StoredProposal.model_validate(
634
+ self.host.load_correction(proposal.correction_id)
635
+ )
636
+ if (
637
+ stored.proposal != proposal
638
+ or stored.revoked
639
+ or stored.published_candidate_digest is not None
640
+ ):
641
+ raise ValueError()
642
+ return proposal
643
+ except Exception:
644
+ raise SafeError("private_correction_denied") from None
645
+
646
+ def inspect_proposal(self, credential, correction_id):
647
+ try:
648
+ if type(correction_id) is not str or not re.fullmatch(r"[a-f0-9]{32}", correction_id):
649
+ raise ValueError()
650
+ # Authenticate first; the host must authorize this opaque-ID lookup
651
+ # against its record binding before returning any proposal data.
652
+ self.review.authorization.identity(credential)
653
+ reference = self.host.correction_binding(correction_id)
654
+ _, context = self._context(credential, reference, "inspect")
655
+ record = StoredProposal.model_validate(self.host.load_correction(correction_id))
656
+ if (
657
+ record.proposal.correction_id != correction_id
658
+ or record.proposal.review_ref != reference
659
+ ):
660
+ raise ValueError()
661
+ self._authorize(credential, reference, "inspect")
662
+ return CorrectionReview(context=context, record=record)
663
+ except Exception:
664
+ raise SafeError("private_correction_denied") from None
665
+
666
+ def apply(self, credential, correction_id):
667
+ try:
668
+ view = self.inspect_proposal(credential, correction_id)
669
+ proposal = view.record.proposal
670
+ identity, context = self._context(credential, proposal.review_ref, "apply")
671
+ now = int(time.time())
672
+ if (
673
+ view.record.revoked
674
+ or view.record.published_candidate_digest is not None
675
+ or identity.subject != proposal.author_subject
676
+ or not proposal.created_at <= now < proposal.expires_at
677
+ ):
678
+ raise ValueError()
679
+ texts = self._texts(credential, context)
680
+ effective = apply_edits(context, proposal, texts)
681
+ pages = render(context.machine, effective, texts)
682
+ previous = context.material.candidate
683
+ authors = (
684
+ previous.correction.author_subject_digests
685
+ if type(previous) is CorrectedCandidate
686
+ else ()
687
+ )
688
+ authors = tuple(sorted(set(authors + (author_digest(identity.subject),))))
689
+ correction = CorrectionProvenance(
690
+ correction_id=proposal.correction_id,
691
+ proposal_digest=commitment(proposal),
692
+ original_evidence_digest=context.machine.evidence_digest,
693
+ machine_snapshot_digest=commitment(context.machine),
694
+ source_references_digest=commitment(
695
+ [s.model_dump(mode="json") for s in context.machine.sources]
696
+ ),
697
+ prior_candidate_digest=context.material.candidate_digest,
698
+ prior_state_digest=state_digest(context.machine, context.effective),
699
+ effective_state_digest=state_digest(context.machine, effective),
700
+ author_subject_digests=authors,
701
+ edit_count=len(proposal.edits),
702
+ )
703
+ candidate = CorrectedCandidate(
704
+ candidate_id=uuid.uuid4().hex,
705
+ sensitivity=context.material.binding.sensitivity,
706
+ pages=pages,
707
+ model_revision=context.machine.provenance.model_revision,
708
+ provenance=context.machine.provenance,
709
+ correction=correction,
710
+ review_reasons=("recognition_incomplete_possible",)
711
+ + (
712
+ ("empty_prediction",)
713
+ if any(
714
+ not any(
715
+ o.page_index == source.page_index
716
+ and o.entity.source == "multilingual_ner"
717
+ for o in context.machine.observations
718
+ )
719
+ for source in context.machine.sources
720
+ )
721
+ else ()
722
+ )
723
+ + ("human_correction_requires_review",),
724
+ )
725
+ binding = context.material.binding.model_copy(
726
+ update={"review_ref": "correction-" + uuid.uuid4().hex}
727
+ )
728
+ material = PrivateReviewMaterial(
729
+ binding=binding,
730
+ evidence_digest=context.machine.evidence_digest,
731
+ sources=context.machine.sources,
732
+ candidate=candidate,
733
+ candidate_digest=digest(candidate),
734
+ provenance=context.machine.provenance,
735
+ blocking_reason=None,
736
+ )
737
+ new = CorrectionContext(material=material, machine=context.machine, effective=effective)
738
+ if self._context(credential, proposal.review_ref, "apply") != (identity, context):
739
+ raise ValueError()
740
+ expected = PublicationReceipt(
741
+ correction_id=proposal.correction_id,
742
+ proposal_digest=commitment(proposal),
743
+ candidate_digest=digest(candidate),
744
+ context_digest=commitment(new),
745
+ review_ref=binding.review_ref,
746
+ )
747
+ actual = PublicationReceipt.model_validate(
748
+ self.host.publish_correction(credential, proposal, new, commitment(context))
749
+ )
750
+ if actual != expected:
751
+ raise ValueError()
752
+ return actual
753
+ except Exception:
754
+ raise SafeError("private_correction_denied") from None
ocr/anonymization/demonstrate03f.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pinned-model utility demo on EXISTING synthetic development cases only.
2
+
3
+ The isolated host simulates a human choosing existing gold spans; these are not
4
+ model predictions or a new holdout. No completed release review is manufactured.
5
+ """
6
+
7
+ import argparse
8
+ import hashlib
9
+ import importlib.metadata
10
+ import json
11
+ import resource
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import time
16
+ from pathlib import Path
17
+
18
+ from ocr.schemas import SafeError
19
+ from ocr.tests.fixtures.correction_host import CorrectionHostFixture
20
+ from ocr.tests.test_anonymization_security import evidence
21
+
22
+ from .approval_ledger import SQLiteApprovalLedger
23
+ from .atomic_output import write_candidate
24
+ from .corrections import CorrectionEdit, CorrectionService, commitment, prepare_review
25
+ from .engine import AnonymizationEngine
26
+ from .host_adapter import HostAuthorizationBridge
27
+ from .recognizer import GLiNERRecognizer
28
+ from .release_policy import digest
29
+
30
+
31
+ def coverage(candidate, case):
32
+ if candidate is None:
33
+ return None
34
+ tags = candidate.pages[0].tags
35
+ return all(
36
+ any(t.start <= i < t.end for t in tags)
37
+ for span in case["spans"]
38
+ for i in range(span["start"], span["end"])
39
+ if case["text"][i].isalnum()
40
+ )
41
+
42
+
43
+ def run(cache, output, *, correct_controls_only=False):
44
+ if Path(output).exists():
45
+ raise FileExistsError("correction_demo_exists")
46
+ denied = []
47
+
48
+ def offline(event, args):
49
+ if event in ("socket.connect", "socket.bind", "socket.getaddrinfo"):
50
+ denied.append(event)
51
+ raise PermissionError("correction_demo_network_disabled")
52
+
53
+ sys.addaudithook(offline)
54
+ source = Path(__file__).parent / "fixtures/holdout03d/cases.json"
55
+ cases = json.loads(source.read_bytes())
56
+ selected = {4, 5} if correct_controls_only else {1, 2, 3, 55, 63, 78, 125, 127, 137}
57
+ start = time.perf_counter()
58
+ model = GLiNERRecognizer(cache)
59
+ loading_ms = (time.perf_counter() - start) * 1000
60
+ rows = []
61
+ for case in cases:
62
+ number = int(case["id"].split("-")[-1])
63
+ if number not in selected:
64
+ continue
65
+ start = time.perf_counter()
66
+ context = prepare_review(
67
+ AnonymizationEngine(model),
68
+ evidence("D2", (case["text"],)),
69
+ review_ref=case["id"],
70
+ environment_id="synthetic-room",
71
+ expires_at=int(time.time()) + 600,
72
+ )
73
+ inference_ms = (time.perf_counter() - start) * 1000
74
+ host = CorrectionHostFixture(context, {0: case["text"]}, separate_approver=True)
75
+ service = CorrectionService(host)
76
+ candidate = context.material.candidate
77
+ before = coverage(candidate, case)
78
+ original = (
79
+ "blocked_conflicting_spans"
80
+ if candidate is None
81
+ else "complete_no_measured_leakage"
82
+ if before
83
+ else "candidate_partial_mask"
84
+ )
85
+ edits = []
86
+ if number not in (1, 2, 3, 4, 5):
87
+ for span in case["spans"]:
88
+ if not (number == 125 and span["label"] == "ORGANIZATION") and not (
89
+ number != 125 and span["label"] == "RELIGION"
90
+ ):
91
+ continue
92
+ targets = tuple(
93
+ sorted(
94
+ {
95
+ o.observation_id
96
+ for o in context.effective
97
+ if o.page_index == 0
98
+ and o.entity.start < span["end"]
99
+ and span["start"] < o.entity.end
100
+ }
101
+ )
102
+ )
103
+ edits.append(
104
+ CorrectionEdit(
105
+ span=service.bind(
106
+ host.author_credential, case["id"], 0, span["start"], span["end"]
107
+ ),
108
+ category=span["label"],
109
+ action="mask",
110
+ rationale="incomplete_boundary" if number == 125 else "span_conflict",
111
+ target_observation_ids=targets,
112
+ )
113
+ )
114
+ malicious_rejected = unauthorized_rejected = False
115
+ if edits:
116
+ forged = edits[0].model_copy(
117
+ update={"span": edits[0].span.model_copy(update={"source_span_digest": "f" * 64})}
118
+ )
119
+ try:
120
+ service.propose(host.author_credential, case["id"], (forged,))
121
+ except SafeError as exc:
122
+ malicious_rejected = exc.code == "private_correction_denied"
123
+ try:
124
+ service.propose(host.approver_credential, case["id"], tuple(edits))
125
+ except SafeError as exc:
126
+ unauthorized_rejected = exc.code == "private_correction_denied"
127
+ proposal = service.propose(host.author_credential, case["id"], tuple(edits))
128
+ receipt = service.apply(host.author_credential, proposal.correction_id)
129
+ candidate = host.candidates[receipt.candidate_digest]
130
+ assert host.contexts[case["id"]] == context
131
+ with tempfile.TemporaryDirectory() as directory:
132
+ authority = HostAuthorizationBridge(host).authority(
133
+ b"synthetic-demo-signing-key-not-production",
134
+ SQLiteApprovalLedger(Path(directory) / "approvals.sqlite", initialize=True),
135
+ ("synthetic-private",),
136
+ )
137
+ blocked = False
138
+ try:
139
+ authority.export(
140
+ candidate, None, actor=host.author_credential, destination="synthetic-private"
141
+ )
142
+ except SafeError as exc:
143
+ blocked = exc.code == "release_not_approved"
144
+ rows.append(
145
+ dict(
146
+ case_id=case["id"],
147
+ original_outcome=original,
148
+ machine_candidate_digest=context.material.candidate_digest,
149
+ machine_observations_digest=commitment(context.machine),
150
+ source_canaries_covered_before=before,
151
+ proposed_edits=[e.model_dump(mode="json") for e in edits],
152
+ new_candidate_produced=bool(edits),
153
+ final_candidate_digest=digest(candidate),
154
+ source_canaries_covered_after=coverage(candidate, case),
155
+ document_exact_match_after=candidate.pages[0].anonymized_text == case["expected"],
156
+ final_sensitivity=candidate.sensitivity,
157
+ release_blocked=blocked,
158
+ completed_release_reviews=len(host.completed),
159
+ malformed_correction_rejected=malicious_rejected if edits else None,
160
+ unauthorized_correction_rejected=unauthorized_rejected if edits else None,
161
+ capture_inference_ms=inference_ms,
162
+ )
163
+ )
164
+ report = dict(
165
+ schema_version="gate03f-synthetic-reviewed-correction-demo/v1",
166
+ source_sha=subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
167
+ corpus_sha256=hashlib.sha256(source.read_bytes()).hexdigest(),
168
+ model_revision=model.revision,
169
+ prompts=model.prompts,
170
+ threshold=model.threshold,
171
+ config_digest=context.machine.provenance.configuration_digest,
172
+ actual_inference_calls=len(rows),
173
+ correct_controls_only=correct_controls_only,
174
+ loading_ms=loading_ms,
175
+ peak_rss_mib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024,
176
+ runtime_versions={
177
+ k: importlib.metadata.version(k)
178
+ for k in ("gliner", "torch", "transformers", "numpy", "pydantic")
179
+ },
180
+ denied_network_calls=denied,
181
+ cases=rows,
182
+ correction_source="simulated authorized human using existing synthetic gold spans",
183
+ production_identity_qualified=False,
184
+ durable_correction_store_qualified=False,
185
+ independent_qualification=False,
186
+ model_qualification_passed=False,
187
+ corrected_outputs_count_as_model_true_positives=False,
188
+ automated_release_eligible=False,
189
+ )
190
+ write_candidate(output, (json.dumps(report, indent=2) + "\n").encode())
191
+ return report
192
+
193
+
194
+ def main():
195
+ parser = argparse.ArgumentParser(description=__doc__)
196
+ parser.add_argument("--cache", required=True)
197
+ parser.add_argument("--output", required=True)
198
+ parser.add_argument("--correct-controls-only", action="store_true")
199
+ args = parser.parse_args()
200
+ try:
201
+ report = run(args.cache, args.output, correct_controls_only=args.correct_controls_only)
202
+ except Exception:
203
+ raise SystemExit("private_correction_demo_failed") from None
204
+ # Emit only aggregate results; source text and paths are never printed.
205
+ print(
206
+ json.dumps(
207
+ dict(
208
+ cases=len(report["cases"]),
209
+ manual_corrections=sum(c["new_candidate_produced"] for c in report["cases"]),
210
+ all_seeded_canaries_covered=all(
211
+ c["source_canaries_covered_after"] for c in report["cases"]
212
+ ),
213
+ all_release_blocked=all(c["release_blocked"] for c in report["cases"]),
214
+ )
215
+ )
216
+ )
217
+
218
+
219
+ if __name__ == "__main__":
220
+ main()
ocr/anonymization/detectors.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic contact patterns, not an exhaustive identifier detector."""
2
+
3
+ import re
4
+
5
+ from .schemas import Entity
6
+ from .status import StatusPolicy
7
+ from .terminology import ReligiousTerminology
8
+
9
+ DEFAULT_TERMINOLOGY = ReligiousTerminology()
10
+
11
+ EMAIL = re.compile(r"(?<![\w.+-])[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+(?![\w-])")
12
+ PHONE = re.compile(
13
+ r"(?<!\w)(?:\+\d{1,3}[ .-]?)?(?:\(\d{2,5}\)[ .-]?)?\d{2,8}(?:[ ./-]\d{2,10}){1,4}(?!\w)"
14
+ )
15
+
16
+
17
+ def detect_contacts(text):
18
+ found = []
19
+ for label, pattern in (("EMAIL", EMAIL), ("PHONE", PHONE)):
20
+ for match in pattern.finditer(text):
21
+ if label == "PHONE" and not 7 <= sum(c.isdigit() for c in match[0]) <= 15:
22
+ continue
23
+ found.append(
24
+ Entity(
25
+ start=match.start(),
26
+ end=match.end(),
27
+ label=label,
28
+ text=match[0],
29
+ source="deterministic",
30
+ model_revision="contact-patterns-v1",
31
+ )
32
+ )
33
+ return found
34
+
35
+
36
+ def detect(text, *, terminology=DEFAULT_TERMINOLOGY, status=StatusPolicy()):
37
+ """Protect literal religious/status terms as well as contact identifiers."""
38
+ return detect_contacts(text) + terminology.detect(text) + status.detect(text)
ocr/anonymization/develop03e.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One pinned inference pass, controlled replay of existing development evidence.
2
+
3
+ Synthetic-only, fixed fixture paths. No fresh holdout or release qualification.
4
+ Model timing is recorded independently from replay/deterministic evaluation time.
5
+ """
6
+
7
+ import hashlib
8
+ import importlib.metadata
9
+ import json
10
+ import resource
11
+ import statistics
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+
17
+ from .atomic_output import write_candidate
18
+ from .detectors import detect_contacts
19
+ from .outcomes import score_outcomes
20
+ from .recognizer import GLiNERRecognizer
21
+ from .status import StatusPolicy
22
+ from .terminology import ReligiousTerminology
23
+
24
+
25
+ def run(cache, output):
26
+ if Path(output).exists():
27
+ raise FileExistsError("development_result_exists")
28
+ denied = []
29
+
30
+ def offline(event, args):
31
+ if event in ("socket.connect", "socket.bind", "socket.getaddrinfo"):
32
+ denied.append(event)
33
+ raise PermissionError("development_network_disabled")
34
+
35
+ sys.addaudithook(offline)
36
+ root = Path(__file__).parent / "fixtures"
37
+ paths = {
38
+ "qualification": root / "qualification.json",
39
+ "validation": root / "validation.json",
40
+ "development03c": root / "holdout03c/cases.json",
41
+ "development03d": root / "holdout03d/cases.json",
42
+ }
43
+ suites = {n: json.loads(p.read_bytes()) for n, p in paths.items()}
44
+ start = time.perf_counter()
45
+ model = GLiNERRecognizer(cache)
46
+ loading_ms = (time.perf_counter() - start) * 1000
47
+ model.recognize("An independent synthetic warmup sentence.")
48
+ predictions, inference = {}, {}
49
+ for cases in suites.values():
50
+ for case in cases:
51
+ text = case["text"]
52
+ if text in predictions:
53
+ continue
54
+ start = time.perf_counter()
55
+ predictions[text] = model.recognize(text)
56
+ inference[text] = (time.perf_counter() - start) * 1000
57
+
58
+ class Replay:
59
+ def recognize(self, text):
60
+ return predictions[text]
61
+
62
+ terms = ReligiousTerminology()
63
+ report = dict(
64
+ schema_version="gate03e-development-comparison/v1",
65
+ source_sha=subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
66
+ corpus_sha256={n: hashlib.sha256(p.read_bytes()).hexdigest() for n, p in paths.items()},
67
+ model_revision=model.revision,
68
+ prompts=model.prompts,
69
+ threshold=model.threshold,
70
+ loading_ms=loading_ms,
71
+ actual_inference_calls=len(predictions),
72
+ experiments={},
73
+ runtime_versions={
74
+ k: importlib.metadata.version(k)
75
+ for k in ("gliner", "torch", "transformers", "numpy", "pydantic")
76
+ },
77
+ actual_inference_latency_ms={
78
+ n: dict(
79
+ median=statistics.median(inference[c["text"]] for c in cases),
80
+ max=max(inference[c["text"]] for c in cases),
81
+ )
82
+ for n, cases in suites.items()
83
+ },
84
+ automated_release_eligible=False,
85
+ independent_qualification=False,
86
+ )
87
+ for name, status in (
88
+ ("gate03d_literal_control", None),
89
+ ("contextual_status", StatusPolicy(ocr_enabled=False)),
90
+ ("status_scoped_ocr", StatusPolicy()),
91
+ ):
92
+
93
+ def detector(text, status=status):
94
+ return (
95
+ detect_contacts(text)
96
+ + terms.detect(text)
97
+ + ([] if status is None else status.detect(text))
98
+ )
99
+
100
+ results = {
101
+ n: score_outcomes(Replay(), cases, detector=detector) for n, cases in suites.items()
102
+ }
103
+ for result in results.values():
104
+ result["accuracy"]["deterministic_replay_latency_ms"] = result["accuracy"].pop(
105
+ "warm_latency_ms"
106
+ )
107
+ report["experiments"][name] = results
108
+ baseline = report["experiments"]["gate03d_literal_control"]
109
+ report["selection_criteria"] = {}
110
+ for name, suites_result in report["experiments"].items():
111
+ if name == "gate03d_literal_control":
112
+ continue
113
+ okay, improvements = True, 0
114
+ for suite, result in suites_result.items():
115
+ before = baseline[suite]
116
+ okay &= result["candidate_return_rate"] >= before["candidate_return_rate"]
117
+ okay &= (
118
+ result["accuracy"]["document_exact_match_rate"]
119
+ >= before["accuracy"]["document_exact_match_rate"]
120
+ )
121
+ for metric in ("candidate_partial_canaries", "candidate_fully_unmasked_canaries"):
122
+ okay &= all(result[metric][k] <= before[metric][k] for k in result[metric])
123
+ improvements += sum(before[metric].values()) - sum(result[metric].values())
124
+ okay &= all(
125
+ result["accuracy"]["combined_entity_metrics"][k]["fp"] <= v["fp"]
126
+ for k, v in before["accuracy"]["combined_entity_metrics"].items()
127
+ )
128
+ report["selection_criteria"][name] = dict(
129
+ passed=bool(okay and improvements > 0), net_canary_improvements=improvements
130
+ )
131
+ report["peak_rss_mib"] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
132
+ report["denied_network_calls"] = denied
133
+ write_candidate(output, (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode())
134
+ return report
135
+
136
+
137
+ if __name__ == "__main__":
138
+ import argparse
139
+
140
+ parser = argparse.ArgumentParser(description="Pinned development comparison, not a holdout")
141
+ parser.add_argument("--cache", required=True)
142
+ parser.add_argument("--output", required=True)
143
+ args = parser.parse_args()
144
+ result = run(args.cache, args.output)
145
+ print(json.dumps(result["selection_criteria"]))
ocr/anonymization/engine.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trusted local recognition and all-or-nothing OCR handoff."""
2
+
3
+ import uuid
4
+
5
+ from ocr.schemas import OCRResult, SafeError
6
+
7
+ from .detectors import detect
8
+ from .model import MODEL_REVISION
9
+ from .provenance import provenance
10
+ from .replacement import replace, validate_entities
11
+ from .schemas import LABELS, CandidatePage, ReleaseCandidate
12
+ from .status import StatusPolicy
13
+ from .terminology import DEFAULT_TERMS, ReligiousTerminology
14
+
15
+
16
+ class AnonymizationEngine:
17
+ def __init__(
18
+ self,
19
+ recognizer,
20
+ *,
21
+ placeholders=None,
22
+ religion_terms=DEFAULT_TERMS,
23
+ status_policy=StatusPolicy(),
24
+ ):
25
+ self.recognizer = recognizer
26
+ self.placeholders = placeholders
27
+ self.terminology = ReligiousTerminology(religion_terms)
28
+ if type(status_policy) is not StatusPolicy:
29
+ raise SafeError("invalid_status_configuration")
30
+ self.status_policy = status_policy
31
+
32
+ def observe(self, text):
33
+ try:
34
+ if set(self.recognizer.categories) != set(LABELS):
35
+ raise SafeError("unsupported_required_categories")
36
+ # Only local implementations are accepted. No remote recognizer transport exists.
37
+ if self.recognizer.local is not True:
38
+ raise SafeError("no_authorized_destination")
39
+ if self.recognizer.revision != MODEL_REVISION:
40
+ raise SafeError("anonymization_model_unavailable")
41
+ if not isinstance(text, str) or len(text) > 2_000_000:
42
+ raise SafeError("anonymization_text_limit")
43
+ predictions = self.recognizer.recognize(text)
44
+ if not isinstance(predictions, (list, tuple)):
45
+ raise SafeError("invalid_entity_spans")
46
+ predictions = validate_entities(text, predictions)
47
+ if any(
48
+ p.source != "multilingual_ner" or p.model_revision != MODEL_REVISION
49
+ for p in predictions
50
+ ):
51
+ raise SafeError("invalid_entity_provenance")
52
+ return predictions + detect(
53
+ text, terminology=self.terminology, status=self.status_policy
54
+ )
55
+ except SafeError as exc:
56
+ allowed = {
57
+ "unsupported_required_categories",
58
+ "no_authorized_destination",
59
+ "invalid_entity_spans",
60
+ "invalid_entity_provenance",
61
+ "ambiguous_entity_overlap",
62
+ "anonymization_model_unavailable",
63
+ "anonymization_text_limit",
64
+ "anonymization_token_limit",
65
+ "anonymization_inference_failed",
66
+ }
67
+ raise SafeError(exc.code if exc.code in allowed else "anonymization_failed") from None
68
+ except Exception:
69
+ raise SafeError("anonymization_failed") from None
70
+
71
+ def transform(self, text):
72
+ predictions = self.observe(text)
73
+ try:
74
+ output, tags = replace(text, predictions, self.placeholders)
75
+ return output, tags, not any(p.source == "multilingual_ner" for p in predictions)
76
+ except SafeError as exc:
77
+ if exc.code not in (
78
+ "unsupported_required_categories",
79
+ "invalid_entity_spans",
80
+ "ambiguous_entity_overlap",
81
+ ):
82
+ raise SafeError("anonymization_failed") from None
83
+ raise
84
+ except Exception:
85
+ raise SafeError("anonymization_failed") from None
86
+
87
+ @staticmethod
88
+ def validate_document(result):
89
+ if not isinstance(result, OCRResult):
90
+ raise SafeError("invalid_ocr_evidence")
91
+ if (
92
+ result.status != "completed"
93
+ or result.failures
94
+ or not result.pages
95
+ or result.page_count != len(result.pages)
96
+ or result.page_count > 100
97
+ or sum(len(p.text) for p in result.pages) > 2_000_000
98
+ ):
99
+ raise SafeError("incomplete_ocr_document")
100
+ indexes = [p.page_index for p in result.pages]
101
+ if indexes not in (
102
+ list(range(result.page_count)),
103
+ list(range(1, result.page_count + 1)),
104
+ ):
105
+ raise SafeError("incomplete_ocr_document")
106
+ # Sensitivity mismatches are rejected, never silently downgraded.
107
+ if any(p.sensitivity != result.sensitivity for p in result.pages):
108
+ raise SafeError("classification_mismatch")
109
+
110
+ def handoff(self, result: OCRResult) -> ReleaseCandidate:
111
+ try:
112
+ self.validate_document(result)
113
+ commitment = provenance(
114
+ self.recognizer, self.terminology, self.status_policy, self.placeholders
115
+ )
116
+ pages, empty = [], False
117
+ for p in result.pages:
118
+ text, tags, no_predictions = self.transform(p.text)
119
+ empty |= no_predictions
120
+ pages.append(
121
+ CandidatePage(
122
+ page_index=p.page_index,
123
+ anonymized_text=text,
124
+ tags=tags,
125
+ source=p.source,
126
+ original_sensitivity=p.sensitivity,
127
+ )
128
+ )
129
+ if commitment != provenance(
130
+ self.recognizer, self.terminology, self.status_policy, self.placeholders
131
+ ):
132
+ raise SafeError("recognition_configuration_changed")
133
+ reasons = ("recognition_incomplete_possible",) + (
134
+ ("empty_prediction",) if empty else ()
135
+ )
136
+ # Rebuild exclusively from allowlisted fields; Markdown, blocks, alternatives,
137
+ # engine/provider strings, page IDs and job IDs stay with classified evidence.
138
+ return ReleaseCandidate(
139
+ candidate_id=uuid.uuid4().hex,
140
+ sensitivity=result.sensitivity,
141
+ pages=tuple(pages),
142
+ model_revision=self.recognizer.revision,
143
+ review_reasons=reasons,
144
+ provenance=commitment,
145
+ )
146
+ except SafeError:
147
+ raise
148
+ except Exception:
149
+ raise SafeError("anonymization_failed") from None
ocr/anonymization/evaluate.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Opt-in synthetic evaluation; records aggregate metrics, never document payloads."""
2
+
3
+ import importlib.metadata
4
+ import json
5
+ import resource
6
+ import statistics
7
+ import time
8
+ from collections import Counter
9
+ from pathlib import Path
10
+
11
+ from ocr.schemas import SafeError
12
+
13
+ from .detectors import detect
14
+ from .model import MODEL_ID, MODEL_REVISION
15
+ from .replacement import replace, resolve_overlaps, validate_entities
16
+ from .schemas import LABELS
17
+
18
+
19
+ def metrics(counts):
20
+ output = {}
21
+ for label in LABELS:
22
+ tp, fp, fn = (counts[(label, name)] for name in ("tp", "fp", "fn"))
23
+ p = tp / (tp + fp) if tp + fp else 0.0
24
+ r = tp / (tp + fn) if tp + fn else 0.0
25
+ output[label] = dict(
26
+ tp=tp, fp=fp, fn=fn, precision=p, recall=r, f1=2 * p * r / (p + r) if p + r else 0.0
27
+ )
28
+ return output
29
+
30
+
31
+ def score_cases(recognizer, cases, *, detector=detect, case_reports=None):
32
+ ner_counts, combined_counts, leaks, failures = Counter(), Counter(), Counter(), Counter()
33
+ literal_leaks = Counter()
34
+ partial_canaries, uncovered_canaries, coverage = Counter(), Counter(), Counter()
35
+ durations, exact, blocked = [], 0, 0
36
+ groups = {}
37
+ for case in cases:
38
+ text = case["text"]
39
+ gold = {(s["start"], s["end"], s["label"]) for s in case["spans"]}
40
+ for s in case["spans"]:
41
+ if text[s["start"] : s["end"]] != s["text"]:
42
+ raise SafeError("invalid_evaluation_fixture")
43
+ start = time.perf_counter()
44
+ actual, predicted, ner = None, set(), set()
45
+ raw, combined, error_code = [], [], None
46
+ try:
47
+ raw = validate_entities(text, recognizer.recognize(text))
48
+ ner = {(s.start, s.end, s.label) for s in raw}
49
+ combined = resolve_overlaps(validate_entities(text, raw + detector(text)))
50
+ predicted = {(s.start, s.end, s.label) for s in combined}
51
+ actual, _ = replace(text, combined)
52
+ except SafeError as e:
53
+ error_code = e.code
54
+ failures[e.code] += 1
55
+ blocked += 1
56
+ durations.append((time.perf_counter() - start) * 1000)
57
+ for predicted_set, counts in ((ner, ner_counts), (predicted, combined_counts)):
58
+ for _, _, label in predicted_set & gold:
59
+ counts[(label, "tp")] += 1
60
+ for _, _, label in predicted_set - gold:
61
+ counts[(label, "fp")] += 1
62
+ for _, _, label in gold - predicted_set:
63
+ counts[(label, "fn")] += 1
64
+ exact += actual == case["expected"]
65
+ for s in case["spans"]:
66
+ if actual is None or s["text"].casefold() in actual.casefold():
67
+ literal_leaks[s["label"]] += 1
68
+ sensitive_positions = [i for i in range(s["start"], s["end"]) if text[i].isalnum()]
69
+ covered = sum(
70
+ actual is not None and any(left <= i < right for left, right, _ in predicted)
71
+ for i in sensitive_positions
72
+ )
73
+ coverage[(s["label"], "total")] += len(sensitive_positions)
74
+ coverage[(s["label"], "covered")] += covered
75
+ if covered < len(sensitive_positions):
76
+ leaks[s["label"]] += 1
77
+ if covered:
78
+ partial_canaries[s["label"]] += 1
79
+ else:
80
+ uncovered_canaries[s["label"]] += 1
81
+ if case_reports is not None:
82
+ case_reports.append(
83
+ dict(
84
+ case_id=case["id"],
85
+ scenario=case.get("scenario"),
86
+ predictions=[s.model_dump() for s in raw],
87
+ combined_predictions=[s.model_dump() for s in combined],
88
+ false_positives=sorted(predicted - gold),
89
+ false_negatives=sorted(gold - predicted),
90
+ exact_match=actual == case["expected"],
91
+ error_code=error_code,
92
+ canaries=[
93
+ dict(
94
+ **s,
95
+ uncovered_positions=[
96
+ i
97
+ for i in range(s["start"], s["end"])
98
+ if text[i].isalnum()
99
+ and (
100
+ actual is None
101
+ or not any(left <= i < right for left, right, _ in predicted)
102
+ )
103
+ ],
104
+ )
105
+ for s in case["spans"]
106
+ ],
107
+ )
108
+ )
109
+ group = case.get("language", "unspecified")
110
+ stats = groups.setdefault(group, dict(documents=0, exact_matches=0, false_negatives=0))
111
+ stats["documents"] += 1
112
+ stats["exact_matches"] += actual == case["expected"]
113
+ stats["false_negatives"] += len(gold - predicted)
114
+ if case.get("ocr_noise"):
115
+ stats = groups.setdefault(
116
+ "ocr_noise", dict(documents=0, exact_matches=0, false_negatives=0)
117
+ )
118
+ stats["documents"] += 1
119
+ stats["exact_matches"] += actual == case["expected"]
120
+ stats["false_negatives"] += len(gold - predicted)
121
+ return dict(
122
+ documents=len(cases),
123
+ ner_entity_metrics=metrics(ner_counts),
124
+ combined_entity_metrics=metrics(combined_counts),
125
+ unredacted_canaries={k: leaks[k] for k in LABELS},
126
+ literal_unredacted_canaries={k: literal_leaks[k] for k in LABELS},
127
+ partial_canaries={k: partial_canaries[k] for k in LABELS},
128
+ fully_uncovered_canaries={k: uncovered_canaries[k] for k in LABELS},
129
+ alphanumeric_canary_coverage={
130
+ k: dict(covered=coverage[(k, "covered")], total=coverage[(k, "total")]) for k in LABELS
131
+ },
132
+ document_exact_match_rate=exact / len(cases),
133
+ groups=groups,
134
+ blocked_documents=blocked,
135
+ failure_codes=dict(failures),
136
+ warm_latency_ms=dict(median=statistics.median(durations), max=max(durations)),
137
+ automated_release_eligible=False,
138
+ qualification_passed=not any(leaks.values()) and blocked == 0,
139
+ )
140
+
141
+
142
+ def run(cache, output):
143
+ from .recognizer import GLiNERRecognizer
144
+
145
+ start = time.perf_counter()
146
+ recognizer = GLiNERRecognizer(cache)
147
+ load = (time.perf_counter() - start) * 1000
148
+ # Explicit warmup is synthetic public text, independently reported.
149
+ recognizer.recognize("The cat sat on the mat.")
150
+ report = dict(
151
+ model=MODEL_ID,
152
+ revision=MODEL_REVISION,
153
+ threshold=recognizer.threshold,
154
+ prompts=recognizer.prompts,
155
+ runtime_versions={
156
+ k: importlib.metadata.version(k)
157
+ for k in ("gliner", "torch", "transformers", "numpy", "pydantic")
158
+ },
159
+ device="cpu",
160
+ threads=2,
161
+ loading_ms=load,
162
+ suites={},
163
+ )
164
+ root = Path(__file__).parent / "fixtures"
165
+ for name in ("qualification", "validation"):
166
+ report["suites"][name] = score_cases(
167
+ recognizer, json.loads((root / f"{name}.json").read_text())
168
+ )
169
+ report["peak_rss_mib"] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
170
+ report["automated_release_eligible"] = False
171
+ report["recognition_qualification_passed"] = all(
172
+ s["qualification_passed"] for s in report["suites"].values()
173
+ )
174
+ Path(output).write_text(json.dumps(report, indent=2) + "\n")
175
+ return report
176
+
177
+
178
+ if __name__ == "__main__":
179
+ import argparse
180
+
181
+ parser = argparse.ArgumentParser(description="Pinned local synthetic model evaluation")
182
+ parser.add_argument("--cache", required=True)
183
+ parser.add_argument("--output", required=True)
184
+ args = parser.parse_args()
185
+ try:
186
+ report = run(args.cache, args.output)
187
+ except Exception:
188
+ raise SystemExit("anonymization_evaluation_failed") from None
189
+ print(
190
+ json.dumps(
191
+ {
192
+ "recognition_qualification_passed": report["recognition_qualification_passed"],
193
+ "automated_release_eligible": False,
194
+ }
195
+ )
196
+ )
ocr/anonymization/fixtures/holdout03c/cases.json ADDED
@@ -0,0 +1,3854 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "holdout03c-000",
4
+ "language": "de",
5
+ "ocr_noise": false,
6
+ "features": [
7
+ "organization",
8
+ "geography"
9
+ ],
10
+ "text": "Auskunft: Saskia Dornfeld arbeitet für Aster Research Institute in Osnabrück.",
11
+ "spans": [
12
+ {
13
+ "start": 10,
14
+ "end": 25,
15
+ "label": "PERSON",
16
+ "text": "Saskia Dornfeld"
17
+ },
18
+ {
19
+ "start": 39,
20
+ "end": 63,
21
+ "label": "ORGANIZATION",
22
+ "text": "Aster Research Institute"
23
+ },
24
+ {
25
+ "start": 67,
26
+ "end": 76,
27
+ "label": "CITY",
28
+ "text": "Osnabrück"
29
+ }
30
+ ],
31
+ "expected": "Auskunft: [PERSON] arbeitet für [ORGANIZATION] in [CITY]."
32
+ },
33
+ {
34
+ "id": "holdout03c-001",
35
+ "language": "en",
36
+ "ocr_noise": false,
37
+ "features": [
38
+ "organization",
39
+ "geography"
40
+ ],
41
+ "text": "The contact Saskia Dornfeld represents Aster Research Institute near Osnabrück.",
42
+ "spans": [
43
+ {
44
+ "start": 12,
45
+ "end": 27,
46
+ "label": "PERSON",
47
+ "text": "Saskia Dornfeld"
48
+ },
49
+ {
50
+ "start": 39,
51
+ "end": 63,
52
+ "label": "ORGANIZATION",
53
+ "text": "Aster Research Institute"
54
+ },
55
+ {
56
+ "start": 69,
57
+ "end": 78,
58
+ "label": "CITY",
59
+ "text": "Osnabrück"
60
+ }
61
+ ],
62
+ "expected": "The contact [PERSON] represents [ORGANIZATION] near [CITY]."
63
+ },
64
+ {
65
+ "id": "holdout03c-002",
66
+ "language": "de",
67
+ "ocr_noise": false,
68
+ "features": [
69
+ "affiliation",
70
+ "contacts"
71
+ ],
72
+ "text": "Saskia Dornfeld nennt sikhistisch als Zugehörigkeit. E-Mail: case101.review@example.invalid; Telefon: +49 541 730101.",
73
+ "spans": [
74
+ {
75
+ "start": 0,
76
+ "end": 15,
77
+ "label": "PERSON",
78
+ "text": "Saskia Dornfeld"
79
+ },
80
+ {
81
+ "start": 22,
82
+ "end": 33,
83
+ "label": "RELIGION",
84
+ "text": "sikhistisch"
85
+ },
86
+ {
87
+ "start": 61,
88
+ "end": 91,
89
+ "label": "EMAIL",
90
+ "text": "case101.review@example.invalid"
91
+ },
92
+ {
93
+ "start": 102,
94
+ "end": 116,
95
+ "label": "PHONE",
96
+ "text": "+49 541 730101"
97
+ }
98
+ ],
99
+ "expected": "[PERSON] nennt [RELIGION] als Zugehörigkeit. E-Mail: [EMAIL]; Telefon: [PHONE]."
100
+ },
101
+ {
102
+ "id": "holdout03c-003",
103
+ "language": "en",
104
+ "ocr_noise": false,
105
+ "features": [
106
+ "affiliation",
107
+ "contacts"
108
+ ],
109
+ "text": "Saskia Dornfeld explicitly names Sikhism as their faith. Write to case101.review@example.invalid or call +49 541 730101.",
110
+ "spans": [
111
+ {
112
+ "start": 0,
113
+ "end": 15,
114
+ "label": "PERSON",
115
+ "text": "Saskia Dornfeld"
116
+ },
117
+ {
118
+ "start": 33,
119
+ "end": 40,
120
+ "label": "RELIGION",
121
+ "text": "Sikhism"
122
+ },
123
+ {
124
+ "start": 66,
125
+ "end": 96,
126
+ "label": "EMAIL",
127
+ "text": "case101.review@example.invalid"
128
+ },
129
+ {
130
+ "start": 105,
131
+ "end": 119,
132
+ "label": "PHONE",
133
+ "text": "+49 541 730101"
134
+ }
135
+ ],
136
+ "expected": "[PERSON] explicitly names [RELIGION] as their faith. Write to [EMAIL] or call [PHONE]."
137
+ },
138
+ {
139
+ "id": "holdout03c-004",
140
+ "language": "de",
141
+ "ocr_noise": false,
142
+ "features": [
143
+ "repeats",
144
+ "paragraphs"
145
+ ],
146
+ "text": "Saskia Dornfeld besucht Osnabrück.\n\nSaskia Dornfeld bestätigt den Termin bei Aster Research Institute.",
147
+ "spans": [
148
+ {
149
+ "start": 0,
150
+ "end": 15,
151
+ "label": "PERSON",
152
+ "text": "Saskia Dornfeld"
153
+ },
154
+ {
155
+ "start": 24,
156
+ "end": 33,
157
+ "label": "CITY",
158
+ "text": "Osnabrück"
159
+ },
160
+ {
161
+ "start": 36,
162
+ "end": 51,
163
+ "label": "PERSON",
164
+ "text": "Saskia Dornfeld"
165
+ },
166
+ {
167
+ "start": 77,
168
+ "end": 101,
169
+ "label": "ORGANIZATION",
170
+ "text": "Aster Research Institute"
171
+ }
172
+ ],
173
+ "expected": "[PERSON] besucht [CITY].\n\n[PERSON] bestätigt den Termin bei [ORGANIZATION]."
174
+ },
175
+ {
176
+ "id": "holdout03c-005",
177
+ "language": "en",
178
+ "ocr_noise": false,
179
+ "features": [
180
+ "repeats",
181
+ "ambiguous_geography"
182
+ ],
183
+ "text": "In Osnabrück, Saskia Dornfeld signed for Aster Research Institute. The representative Saskia Dornfeld returned to Osnabrück.",
184
+ "spans": [
185
+ {
186
+ "start": 3,
187
+ "end": 12,
188
+ "label": "CITY",
189
+ "text": "Osnabrück"
190
+ },
191
+ {
192
+ "start": 14,
193
+ "end": 29,
194
+ "label": "PERSON",
195
+ "text": "Saskia Dornfeld"
196
+ },
197
+ {
198
+ "start": 41,
199
+ "end": 65,
200
+ "label": "ORGANIZATION",
201
+ "text": "Aster Research Institute"
202
+ },
203
+ {
204
+ "start": 86,
205
+ "end": 101,
206
+ "label": "PERSON",
207
+ "text": "Saskia Dornfeld"
208
+ },
209
+ {
210
+ "start": 114,
211
+ "end": 123,
212
+ "label": "CITY",
213
+ "text": "Osnabrück"
214
+ }
215
+ ],
216
+ "expected": "In [CITY], [PERSON] signed for [ORGANIZATION]. The representative [PERSON] returned to [CITY]."
217
+ },
218
+ {
219
+ "id": "holdout03c-006",
220
+ "language": "de",
221
+ "ocr_noise": true,
222
+ "features": [
223
+ "line_wrap",
224
+ "organization"
225
+ ],
226
+ "text": "Name: Saskia-\nDornfeld\nOrt: Osna-\nbrück\nArbeitgeber: Aster Research Institute.",
227
+ "spans": [
228
+ {
229
+ "start": 6,
230
+ "end": 22,
231
+ "label": "PERSON",
232
+ "text": "Saskia-\nDornfeld"
233
+ },
234
+ {
235
+ "start": 28,
236
+ "end": 39,
237
+ "label": "CITY",
238
+ "text": "Osna-\nbrück"
239
+ },
240
+ {
241
+ "start": 53,
242
+ "end": 77,
243
+ "label": "ORGANIZATION",
244
+ "text": "Aster Research Institute"
245
+ }
246
+ ],
247
+ "expected": "Name: [PERSON]\nOrt: [CITY]\nArbeitgeber: [ORGANIZATION]."
248
+ },
249
+ {
250
+ "id": "holdout03c-007",
251
+ "language": "en",
252
+ "ocr_noise": true,
253
+ "features": [
254
+ "ocr_spelling",
255
+ "organization"
256
+ ],
257
+ "text": "Scanned record: Sask1a D0rnfeld resides in Osn4brück and works at Aster Research Institute.",
258
+ "spans": [
259
+ {
260
+ "start": 16,
261
+ "end": 31,
262
+ "label": "PERSON",
263
+ "text": "Sask1a D0rnfeld"
264
+ },
265
+ {
266
+ "start": 43,
267
+ "end": 52,
268
+ "label": "CITY",
269
+ "text": "Osn4brück"
270
+ },
271
+ {
272
+ "start": 66,
273
+ "end": 90,
274
+ "label": "ORGANIZATION",
275
+ "text": "Aster Research Institute"
276
+ }
277
+ ],
278
+ "expected": "Scanned record: [PERSON] resides in [CITY] and works at [ORGANIZATION]."
279
+ },
280
+ {
281
+ "id": "holdout03c-008",
282
+ "language": "de",
283
+ "ocr_noise": false,
284
+ "features": [
285
+ "religious_reference",
286
+ "no_inferred_affiliation"
287
+ ],
288
+ "text": "Das Thema sikhistisch wird in einem Bericht von Aster Research Institute erwähnt. Für Saskia Dornfeld wird keine Religionszugehörigkeit behauptet.",
289
+ "spans": [
290
+ {
291
+ "start": 10,
292
+ "end": 21,
293
+ "label": "RELIGION",
294
+ "text": "sikhistisch"
295
+ },
296
+ {
297
+ "start": 48,
298
+ "end": 72,
299
+ "label": "ORGANIZATION",
300
+ "text": "Aster Research Institute"
301
+ },
302
+ {
303
+ "start": 86,
304
+ "end": 101,
305
+ "label": "PERSON",
306
+ "text": "Saskia Dornfeld"
307
+ }
308
+ ],
309
+ "expected": "Das Thema [RELIGION] wird in einem Bericht von [ORGANIZATION] erwähnt. Für [PERSON] wird keine Religionszugehörigkeit behauptet."
310
+ },
311
+ {
312
+ "id": "holdout03c-009",
313
+ "language": "en",
314
+ "ocr_noise": false,
315
+ "features": [
316
+ "negative",
317
+ "religious_reference"
318
+ ],
319
+ "text": "Worksheet 101: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details.",
320
+ "spans": [],
321
+ "expected": "Worksheet 101: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details."
322
+ },
323
+ {
324
+ "id": "holdout03c-010",
325
+ "language": "de",
326
+ "ocr_noise": false,
327
+ "features": [
328
+ "organization",
329
+ "geography"
330
+ ],
331
+ "text": "Auskunft: Emil Farber arbeitet für Northbridge Analytics in Potsdam.",
332
+ "spans": [
333
+ {
334
+ "start": 10,
335
+ "end": 21,
336
+ "label": "PERSON",
337
+ "text": "Emil Farber"
338
+ },
339
+ {
340
+ "start": 35,
341
+ "end": 56,
342
+ "label": "ORGANIZATION",
343
+ "text": "Northbridge Analytics"
344
+ },
345
+ {
346
+ "start": 60,
347
+ "end": 67,
348
+ "label": "CITY",
349
+ "text": "Potsdam"
350
+ }
351
+ ],
352
+ "expected": "Auskunft: [PERSON] arbeitet für [ORGANIZATION] in [CITY]."
353
+ },
354
+ {
355
+ "id": "holdout03c-011",
356
+ "language": "en",
357
+ "ocr_noise": false,
358
+ "features": [
359
+ "organization",
360
+ "geography"
361
+ ],
362
+ "text": "The contact Emil Farber represents Northbridge Analytics near Potsdam.",
363
+ "spans": [
364
+ {
365
+ "start": 12,
366
+ "end": 23,
367
+ "label": "PERSON",
368
+ "text": "Emil Farber"
369
+ },
370
+ {
371
+ "start": 35,
372
+ "end": 56,
373
+ "label": "ORGANIZATION",
374
+ "text": "Northbridge Analytics"
375
+ },
376
+ {
377
+ "start": 62,
378
+ "end": 69,
379
+ "label": "CITY",
380
+ "text": "Potsdam"
381
+ }
382
+ ],
383
+ "expected": "The contact [PERSON] represents [ORGANIZATION] near [CITY]."
384
+ },
385
+ {
386
+ "id": "holdout03c-012",
387
+ "language": "de",
388
+ "ocr_noise": false,
389
+ "features": [
390
+ "affiliation",
391
+ "contacts"
392
+ ],
393
+ "text": "Emil Farber nennt orthodox als Zugehörigkeit. E-Mail: case102.review@example.invalid; Telefon: +49 541 730102.",
394
+ "spans": [
395
+ {
396
+ "start": 0,
397
+ "end": 11,
398
+ "label": "PERSON",
399
+ "text": "Emil Farber"
400
+ },
401
+ {
402
+ "start": 18,
403
+ "end": 26,
404
+ "label": "RELIGION",
405
+ "text": "orthodox"
406
+ },
407
+ {
408
+ "start": 54,
409
+ "end": 84,
410
+ "label": "EMAIL",
411
+ "text": "case102.review@example.invalid"
412
+ },
413
+ {
414
+ "start": 95,
415
+ "end": 109,
416
+ "label": "PHONE",
417
+ "text": "+49 541 730102"
418
+ }
419
+ ],
420
+ "expected": "[PERSON] nennt [RELIGION] als Zugehörigkeit. E-Mail: [EMAIL]; Telefon: [PHONE]."
421
+ },
422
+ {
423
+ "id": "holdout03c-013",
424
+ "language": "en",
425
+ "ocr_noise": false,
426
+ "features": [
427
+ "affiliation",
428
+ "contacts"
429
+ ],
430
+ "text": "Emil Farber explicitly names Orthodox Christianity as their faith. Write to case102.review@example.invalid or call +49 541 730102.",
431
+ "spans": [
432
+ {
433
+ "start": 0,
434
+ "end": 11,
435
+ "label": "PERSON",
436
+ "text": "Emil Farber"
437
+ },
438
+ {
439
+ "start": 29,
440
+ "end": 50,
441
+ "label": "RELIGION",
442
+ "text": "Orthodox Christianity"
443
+ },
444
+ {
445
+ "start": 76,
446
+ "end": 106,
447
+ "label": "EMAIL",
448
+ "text": "case102.review@example.invalid"
449
+ },
450
+ {
451
+ "start": 115,
452
+ "end": 129,
453
+ "label": "PHONE",
454
+ "text": "+49 541 730102"
455
+ }
456
+ ],
457
+ "expected": "[PERSON] explicitly names [RELIGION] as their faith. Write to [EMAIL] or call [PHONE]."
458
+ },
459
+ {
460
+ "id": "holdout03c-014",
461
+ "language": "de",
462
+ "ocr_noise": false,
463
+ "features": [
464
+ "repeats",
465
+ "paragraphs"
466
+ ],
467
+ "text": "Emil Farber besucht Potsdam.\n\nEmil Farber bestätigt den Termin bei Northbridge Analytics.",
468
+ "spans": [
469
+ {
470
+ "start": 0,
471
+ "end": 11,
472
+ "label": "PERSON",
473
+ "text": "Emil Farber"
474
+ },
475
+ {
476
+ "start": 20,
477
+ "end": 27,
478
+ "label": "CITY",
479
+ "text": "Potsdam"
480
+ },
481
+ {
482
+ "start": 30,
483
+ "end": 41,
484
+ "label": "PERSON",
485
+ "text": "Emil Farber"
486
+ },
487
+ {
488
+ "start": 67,
489
+ "end": 88,
490
+ "label": "ORGANIZATION",
491
+ "text": "Northbridge Analytics"
492
+ }
493
+ ],
494
+ "expected": "[PERSON] besucht [CITY].\n\n[PERSON] bestätigt den Termin bei [ORGANIZATION]."
495
+ },
496
+ {
497
+ "id": "holdout03c-015",
498
+ "language": "en",
499
+ "ocr_noise": false,
500
+ "features": [
501
+ "repeats",
502
+ "ambiguous_geography"
503
+ ],
504
+ "text": "In Potsdam, Emil Farber signed for Northbridge Analytics. The representative Emil Farber returned to Potsdam.",
505
+ "spans": [
506
+ {
507
+ "start": 3,
508
+ "end": 10,
509
+ "label": "CITY",
510
+ "text": "Potsdam"
511
+ },
512
+ {
513
+ "start": 12,
514
+ "end": 23,
515
+ "label": "PERSON",
516
+ "text": "Emil Farber"
517
+ },
518
+ {
519
+ "start": 35,
520
+ "end": 56,
521
+ "label": "ORGANIZATION",
522
+ "text": "Northbridge Analytics"
523
+ },
524
+ {
525
+ "start": 77,
526
+ "end": 88,
527
+ "label": "PERSON",
528
+ "text": "Emil Farber"
529
+ },
530
+ {
531
+ "start": 101,
532
+ "end": 108,
533
+ "label": "CITY",
534
+ "text": "Potsdam"
535
+ }
536
+ ],
537
+ "expected": "In [CITY], [PERSON] signed for [ORGANIZATION]. The representative [PERSON] returned to [CITY]."
538
+ },
539
+ {
540
+ "id": "holdout03c-016",
541
+ "language": "de",
542
+ "ocr_noise": true,
543
+ "features": [
544
+ "line_wrap",
545
+ "organization"
546
+ ],
547
+ "text": "Name: Emil-\nFarber\nOrt: Pot-\nsdam\nArbeitgeber: Northbridge Analytics.",
548
+ "spans": [
549
+ {
550
+ "start": 6,
551
+ "end": 18,
552
+ "label": "PERSON",
553
+ "text": "Emil-\nFarber"
554
+ },
555
+ {
556
+ "start": 24,
557
+ "end": 33,
558
+ "label": "CITY",
559
+ "text": "Pot-\nsdam"
560
+ },
561
+ {
562
+ "start": 47,
563
+ "end": 68,
564
+ "label": "ORGANIZATION",
565
+ "text": "Northbridge Analytics"
566
+ }
567
+ ],
568
+ "expected": "Name: [PERSON]\nOrt: [CITY]\nArbeitgeber: [ORGANIZATION]."
569
+ },
570
+ {
571
+ "id": "holdout03c-017",
572
+ "language": "en",
573
+ "ocr_noise": true,
574
+ "features": [
575
+ "ocr_spelling",
576
+ "organization"
577
+ ],
578
+ "text": "Scanned record: Em1l Farber resides in P0tsd4m and works at Northbridge Analytics.",
579
+ "spans": [
580
+ {
581
+ "start": 16,
582
+ "end": 27,
583
+ "label": "PERSON",
584
+ "text": "Em1l Farber"
585
+ },
586
+ {
587
+ "start": 39,
588
+ "end": 46,
589
+ "label": "CITY",
590
+ "text": "P0tsd4m"
591
+ },
592
+ {
593
+ "start": 60,
594
+ "end": 81,
595
+ "label": "ORGANIZATION",
596
+ "text": "Northbridge Analytics"
597
+ }
598
+ ],
599
+ "expected": "Scanned record: [PERSON] resides in [CITY] and works at [ORGANIZATION]."
600
+ },
601
+ {
602
+ "id": "holdout03c-018",
603
+ "language": "de",
604
+ "ocr_noise": false,
605
+ "features": [
606
+ "religious_reference",
607
+ "no_inferred_affiliation"
608
+ ],
609
+ "text": "Das Thema orthodox wird in einem Bericht von Northbridge Analytics erwähnt. Für Emil Farber wird keine Religionszugehörigkeit behauptet.",
610
+ "spans": [
611
+ {
612
+ "start": 10,
613
+ "end": 18,
614
+ "label": "RELIGION",
615
+ "text": "orthodox"
616
+ },
617
+ {
618
+ "start": 45,
619
+ "end": 66,
620
+ "label": "ORGANIZATION",
621
+ "text": "Northbridge Analytics"
622
+ },
623
+ {
624
+ "start": 80,
625
+ "end": 91,
626
+ "label": "PERSON",
627
+ "text": "Emil Farber"
628
+ }
629
+ ],
630
+ "expected": "Das Thema [RELIGION] wird in einem Bericht von [ORGANIZATION] erwähnt. Für [PERSON] wird keine Religionszugehörigkeit behauptet."
631
+ },
632
+ {
633
+ "id": "holdout03c-019",
634
+ "language": "en",
635
+ "ocr_noise": false,
636
+ "features": [
637
+ "negative",
638
+ "religious_reference"
639
+ ],
640
+ "text": "Worksheet 102: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details.",
641
+ "spans": [],
642
+ "expected": "Worksheet 102: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details."
643
+ },
644
+ {
645
+ "id": "holdout03c-020",
646
+ "language": "de",
647
+ "ocr_noise": false,
648
+ "features": [
649
+ "organization",
650
+ "geography"
651
+ ],
652
+ "text": "Auskunft: Cora Lindenfels arbeitet für Amber Health Foundation in Dundee.",
653
+ "spans": [
654
+ {
655
+ "start": 10,
656
+ "end": 25,
657
+ "label": "PERSON",
658
+ "text": "Cora Lindenfels"
659
+ },
660
+ {
661
+ "start": 39,
662
+ "end": 62,
663
+ "label": "ORGANIZATION",
664
+ "text": "Amber Health Foundation"
665
+ },
666
+ {
667
+ "start": 66,
668
+ "end": 72,
669
+ "label": "CITY",
670
+ "text": "Dundee"
671
+ }
672
+ ],
673
+ "expected": "Auskunft: [PERSON] arbeitet für [ORGANIZATION] in [CITY]."
674
+ },
675
+ {
676
+ "id": "holdout03c-021",
677
+ "language": "en",
678
+ "ocr_noise": false,
679
+ "features": [
680
+ "organization",
681
+ "geography"
682
+ ],
683
+ "text": "The contact Cora Lindenfels represents Amber Health Foundation near Dundee.",
684
+ "spans": [
685
+ {
686
+ "start": 12,
687
+ "end": 27,
688
+ "label": "PERSON",
689
+ "text": "Cora Lindenfels"
690
+ },
691
+ {
692
+ "start": 39,
693
+ "end": 62,
694
+ "label": "ORGANIZATION",
695
+ "text": "Amber Health Foundation"
696
+ },
697
+ {
698
+ "start": 68,
699
+ "end": 74,
700
+ "label": "CITY",
701
+ "text": "Dundee"
702
+ }
703
+ ],
704
+ "expected": "The contact [PERSON] represents [ORGANIZATION] near [CITY]."
705
+ },
706
+ {
707
+ "id": "holdout03c-022",
708
+ "language": "de",
709
+ "ocr_noise": false,
710
+ "features": [
711
+ "affiliation",
712
+ "contacts"
713
+ ],
714
+ "text": "Cora Lindenfels nennt jainistisch als Zugehörigkeit. E-Mail: case103.review@example.invalid; Telefon: +49 541 730103.",
715
+ "spans": [
716
+ {
717
+ "start": 0,
718
+ "end": 15,
719
+ "label": "PERSON",
720
+ "text": "Cora Lindenfels"
721
+ },
722
+ {
723
+ "start": 22,
724
+ "end": 33,
725
+ "label": "RELIGION",
726
+ "text": "jainistisch"
727
+ },
728
+ {
729
+ "start": 61,
730
+ "end": 91,
731
+ "label": "EMAIL",
732
+ "text": "case103.review@example.invalid"
733
+ },
734
+ {
735
+ "start": 102,
736
+ "end": 116,
737
+ "label": "PHONE",
738
+ "text": "+49 541 730103"
739
+ }
740
+ ],
741
+ "expected": "[PERSON] nennt [RELIGION] als Zugehörigkeit. E-Mail: [EMAIL]; Telefon: [PHONE]."
742
+ },
743
+ {
744
+ "id": "holdout03c-023",
745
+ "language": "en",
746
+ "ocr_noise": false,
747
+ "features": [
748
+ "affiliation",
749
+ "contacts"
750
+ ],
751
+ "text": "Cora Lindenfels explicitly names Jainism as their faith. Write to case103.review@example.invalid or call +49 541 730103.",
752
+ "spans": [
753
+ {
754
+ "start": 0,
755
+ "end": 15,
756
+ "label": "PERSON",
757
+ "text": "Cora Lindenfels"
758
+ },
759
+ {
760
+ "start": 33,
761
+ "end": 40,
762
+ "label": "RELIGION",
763
+ "text": "Jainism"
764
+ },
765
+ {
766
+ "start": 66,
767
+ "end": 96,
768
+ "label": "EMAIL",
769
+ "text": "case103.review@example.invalid"
770
+ },
771
+ {
772
+ "start": 105,
773
+ "end": 119,
774
+ "label": "PHONE",
775
+ "text": "+49 541 730103"
776
+ }
777
+ ],
778
+ "expected": "[PERSON] explicitly names [RELIGION] as their faith. Write to [EMAIL] or call [PHONE]."
779
+ },
780
+ {
781
+ "id": "holdout03c-024",
782
+ "language": "de",
783
+ "ocr_noise": false,
784
+ "features": [
785
+ "repeats",
786
+ "paragraphs"
787
+ ],
788
+ "text": "Cora Lindenfels besucht Dundee.\n\nCora Lindenfels bestätigt den Termin bei Amber Health Foundation.",
789
+ "spans": [
790
+ {
791
+ "start": 0,
792
+ "end": 15,
793
+ "label": "PERSON",
794
+ "text": "Cora Lindenfels"
795
+ },
796
+ {
797
+ "start": 24,
798
+ "end": 30,
799
+ "label": "CITY",
800
+ "text": "Dundee"
801
+ },
802
+ {
803
+ "start": 33,
804
+ "end": 48,
805
+ "label": "PERSON",
806
+ "text": "Cora Lindenfels"
807
+ },
808
+ {
809
+ "start": 74,
810
+ "end": 97,
811
+ "label": "ORGANIZATION",
812
+ "text": "Amber Health Foundation"
813
+ }
814
+ ],
815
+ "expected": "[PERSON] besucht [CITY].\n\n[PERSON] bestätigt den Termin bei [ORGANIZATION]."
816
+ },
817
+ {
818
+ "id": "holdout03c-025",
819
+ "language": "en",
820
+ "ocr_noise": false,
821
+ "features": [
822
+ "repeats",
823
+ "ambiguous_geography"
824
+ ],
825
+ "text": "In Dundee, Cora Lindenfels signed for Amber Health Foundation. The representative Cora Lindenfels returned to Dundee.",
826
+ "spans": [
827
+ {
828
+ "start": 3,
829
+ "end": 9,
830
+ "label": "CITY",
831
+ "text": "Dundee"
832
+ },
833
+ {
834
+ "start": 11,
835
+ "end": 26,
836
+ "label": "PERSON",
837
+ "text": "Cora Lindenfels"
838
+ },
839
+ {
840
+ "start": 38,
841
+ "end": 61,
842
+ "label": "ORGANIZATION",
843
+ "text": "Amber Health Foundation"
844
+ },
845
+ {
846
+ "start": 82,
847
+ "end": 97,
848
+ "label": "PERSON",
849
+ "text": "Cora Lindenfels"
850
+ },
851
+ {
852
+ "start": 110,
853
+ "end": 116,
854
+ "label": "CITY",
855
+ "text": "Dundee"
856
+ }
857
+ ],
858
+ "expected": "In [CITY], [PERSON] signed for [ORGANIZATION]. The representative [PERSON] returned to [CITY]."
859
+ },
860
+ {
861
+ "id": "holdout03c-026",
862
+ "language": "de",
863
+ "ocr_noise": true,
864
+ "features": [
865
+ "line_wrap",
866
+ "organization"
867
+ ],
868
+ "text": "Name: Cora-\nLindenfels\nOrt: Dun-\ndee\nArbeitgeber: Amber Health Foundation.",
869
+ "spans": [
870
+ {
871
+ "start": 6,
872
+ "end": 22,
873
+ "label": "PERSON",
874
+ "text": "Cora-\nLindenfels"
875
+ },
876
+ {
877
+ "start": 28,
878
+ "end": 36,
879
+ "label": "CITY",
880
+ "text": "Dun-\ndee"
881
+ },
882
+ {
883
+ "start": 50,
884
+ "end": 73,
885
+ "label": "ORGANIZATION",
886
+ "text": "Amber Health Foundation"
887
+ }
888
+ ],
889
+ "expected": "Name: [PERSON]\nOrt: [CITY]\nArbeitgeber: [ORGANIZATION]."
890
+ },
891
+ {
892
+ "id": "holdout03c-027",
893
+ "language": "en",
894
+ "ocr_noise": true,
895
+ "features": [
896
+ "ocr_spelling",
897
+ "organization"
898
+ ],
899
+ "text": "Scanned record: C0ra L1ndenfels resides in Dundee and works at Amber Health Foundation.",
900
+ "spans": [
901
+ {
902
+ "start": 16,
903
+ "end": 31,
904
+ "label": "PERSON",
905
+ "text": "C0ra L1ndenfels"
906
+ },
907
+ {
908
+ "start": 43,
909
+ "end": 49,
910
+ "label": "CITY",
911
+ "text": "Dundee"
912
+ },
913
+ {
914
+ "start": 63,
915
+ "end": 86,
916
+ "label": "ORGANIZATION",
917
+ "text": "Amber Health Foundation"
918
+ }
919
+ ],
920
+ "expected": "Scanned record: [PERSON] resides in [CITY] and works at [ORGANIZATION]."
921
+ },
922
+ {
923
+ "id": "holdout03c-028",
924
+ "language": "de",
925
+ "ocr_noise": false,
926
+ "features": [
927
+ "religious_reference",
928
+ "no_inferred_affiliation"
929
+ ],
930
+ "text": "Das Thema jainistisch wird in einem Bericht von Amber Health Foundation erwähnt. Für Cora Lindenfels wird keine Religionszugehörigkeit behauptet.",
931
+ "spans": [
932
+ {
933
+ "start": 10,
934
+ "end": 21,
935
+ "label": "RELIGION",
936
+ "text": "jainistisch"
937
+ },
938
+ {
939
+ "start": 48,
940
+ "end": 71,
941
+ "label": "ORGANIZATION",
942
+ "text": "Amber Health Foundation"
943
+ },
944
+ {
945
+ "start": 85,
946
+ "end": 100,
947
+ "label": "PERSON",
948
+ "text": "Cora Lindenfels"
949
+ }
950
+ ],
951
+ "expected": "Das Thema [RELIGION] wird in einem Bericht von [ORGANIZATION] erwähnt. Für [PERSON] wird keine Religionszugehörigkeit behauptet."
952
+ },
953
+ {
954
+ "id": "holdout03c-029",
955
+ "language": "en",
956
+ "ocr_noise": false,
957
+ "features": [
958
+ "negative",
959
+ "religious_reference"
960
+ ],
961
+ "text": "Worksheet 103: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details.",
962
+ "spans": [],
963
+ "expected": "Worksheet 103: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details."
964
+ },
965
+ {
966
+ "id": "holdout03c-030",
967
+ "language": "de",
968
+ "ocr_noise": false,
969
+ "features": [
970
+ "organization",
971
+ "geography"
972
+ ],
973
+ "text": "Auskunft: Jasper Keene arbeitet für Silver Finch Robotics in Aberdeen.",
974
+ "spans": [
975
+ {
976
+ "start": 10,
977
+ "end": 22,
978
+ "label": "PERSON",
979
+ "text": "Jasper Keene"
980
+ },
981
+ {
982
+ "start": 36,
983
+ "end": 57,
984
+ "label": "ORGANIZATION",
985
+ "text": "Silver Finch Robotics"
986
+ },
987
+ {
988
+ "start": 61,
989
+ "end": 69,
990
+ "label": "CITY",
991
+ "text": "Aberdeen"
992
+ }
993
+ ],
994
+ "expected": "Auskunft: [PERSON] arbeitet für [ORGANIZATION] in [CITY]."
995
+ },
996
+ {
997
+ "id": "holdout03c-031",
998
+ "language": "en",
999
+ "ocr_noise": false,
1000
+ "features": [
1001
+ "organization",
1002
+ "geography"
1003
+ ],
1004
+ "text": "The contact Jasper Keene represents Silver Finch Robotics near Aberdeen.",
1005
+ "spans": [
1006
+ {
1007
+ "start": 12,
1008
+ "end": 24,
1009
+ "label": "PERSON",
1010
+ "text": "Jasper Keene"
1011
+ },
1012
+ {
1013
+ "start": 36,
1014
+ "end": 57,
1015
+ "label": "ORGANIZATION",
1016
+ "text": "Silver Finch Robotics"
1017
+ },
1018
+ {
1019
+ "start": 63,
1020
+ "end": 71,
1021
+ "label": "CITY",
1022
+ "text": "Aberdeen"
1023
+ }
1024
+ ],
1025
+ "expected": "The contact [PERSON] represents [ORGANIZATION] near [CITY]."
1026
+ },
1027
+ {
1028
+ "id": "holdout03c-032",
1029
+ "language": "de",
1030
+ "ocr_noise": false,
1031
+ "features": [
1032
+ "affiliation",
1033
+ "contacts"
1034
+ ],
1035
+ "text": "Jasper Keene nennt hinduistisch als Zugehörigkeit. E-Mail: case104.review@example.invalid; Telefon: +49 541 730104.",
1036
+ "spans": [
1037
+ {
1038
+ "start": 0,
1039
+ "end": 12,
1040
+ "label": "PERSON",
1041
+ "text": "Jasper Keene"
1042
+ },
1043
+ {
1044
+ "start": 19,
1045
+ "end": 31,
1046
+ "label": "RELIGION",
1047
+ "text": "hinduistisch"
1048
+ },
1049
+ {
1050
+ "start": 59,
1051
+ "end": 89,
1052
+ "label": "EMAIL",
1053
+ "text": "case104.review@example.invalid"
1054
+ },
1055
+ {
1056
+ "start": 100,
1057
+ "end": 114,
1058
+ "label": "PHONE",
1059
+ "text": "+49 541 730104"
1060
+ }
1061
+ ],
1062
+ "expected": "[PERSON] nennt [RELIGION] als Zugehörigkeit. E-Mail: [EMAIL]; Telefon: [PHONE]."
1063
+ },
1064
+ {
1065
+ "id": "holdout03c-033",
1066
+ "language": "en",
1067
+ "ocr_noise": false,
1068
+ "features": [
1069
+ "affiliation",
1070
+ "contacts"
1071
+ ],
1072
+ "text": "Jasper Keene explicitly names Hinduism as their faith. Write to case104.review@example.invalid or call +49 541 730104.",
1073
+ "spans": [
1074
+ {
1075
+ "start": 0,
1076
+ "end": 12,
1077
+ "label": "PERSON",
1078
+ "text": "Jasper Keene"
1079
+ },
1080
+ {
1081
+ "start": 30,
1082
+ "end": 38,
1083
+ "label": "RELIGION",
1084
+ "text": "Hinduism"
1085
+ },
1086
+ {
1087
+ "start": 64,
1088
+ "end": 94,
1089
+ "label": "EMAIL",
1090
+ "text": "case104.review@example.invalid"
1091
+ },
1092
+ {
1093
+ "start": 103,
1094
+ "end": 117,
1095
+ "label": "PHONE",
1096
+ "text": "+49 541 730104"
1097
+ }
1098
+ ],
1099
+ "expected": "[PERSON] explicitly names [RELIGION] as their faith. Write to [EMAIL] or call [PHONE]."
1100
+ },
1101
+ {
1102
+ "id": "holdout03c-034",
1103
+ "language": "de",
1104
+ "ocr_noise": false,
1105
+ "features": [
1106
+ "repeats",
1107
+ "paragraphs"
1108
+ ],
1109
+ "text": "Jasper Keene besucht Aberdeen.\n\nJasper Keene bestätigt den Termin bei Silver Finch Robotics.",
1110
+ "spans": [
1111
+ {
1112
+ "start": 0,
1113
+ "end": 12,
1114
+ "label": "PERSON",
1115
+ "text": "Jasper Keene"
1116
+ },
1117
+ {
1118
+ "start": 21,
1119
+ "end": 29,
1120
+ "label": "CITY",
1121
+ "text": "Aberdeen"
1122
+ },
1123
+ {
1124
+ "start": 32,
1125
+ "end": 44,
1126
+ "label": "PERSON",
1127
+ "text": "Jasper Keene"
1128
+ },
1129
+ {
1130
+ "start": 70,
1131
+ "end": 91,
1132
+ "label": "ORGANIZATION",
1133
+ "text": "Silver Finch Robotics"
1134
+ }
1135
+ ],
1136
+ "expected": "[PERSON] besucht [CITY].\n\n[PERSON] bestätigt den Termin bei [ORGANIZATION]."
1137
+ },
1138
+ {
1139
+ "id": "holdout03c-035",
1140
+ "language": "en",
1141
+ "ocr_noise": false,
1142
+ "features": [
1143
+ "repeats",
1144
+ "ambiguous_geography"
1145
+ ],
1146
+ "text": "In Aberdeen, Jasper Keene signed for Silver Finch Robotics. The representative Jasper Keene returned to Aberdeen.",
1147
+ "spans": [
1148
+ {
1149
+ "start": 3,
1150
+ "end": 11,
1151
+ "label": "CITY",
1152
+ "text": "Aberdeen"
1153
+ },
1154
+ {
1155
+ "start": 13,
1156
+ "end": 25,
1157
+ "label": "PERSON",
1158
+ "text": "Jasper Keene"
1159
+ },
1160
+ {
1161
+ "start": 37,
1162
+ "end": 58,
1163
+ "label": "ORGANIZATION",
1164
+ "text": "Silver Finch Robotics"
1165
+ },
1166
+ {
1167
+ "start": 79,
1168
+ "end": 91,
1169
+ "label": "PERSON",
1170
+ "text": "Jasper Keene"
1171
+ },
1172
+ {
1173
+ "start": 104,
1174
+ "end": 112,
1175
+ "label": "CITY",
1176
+ "text": "Aberdeen"
1177
+ }
1178
+ ],
1179
+ "expected": "In [CITY], [PERSON] signed for [ORGANIZATION]. The representative [PERSON] returned to [CITY]."
1180
+ },
1181
+ {
1182
+ "id": "holdout03c-036",
1183
+ "language": "de",
1184
+ "ocr_noise": true,
1185
+ "features": [
1186
+ "line_wrap",
1187
+ "organization"
1188
+ ],
1189
+ "text": "Name: Jasper-\nKeene\nOrt: Aber-\ndeen\nArbeitgeber: Silver Finch Robotics.",
1190
+ "spans": [
1191
+ {
1192
+ "start": 6,
1193
+ "end": 19,
1194
+ "label": "PERSON",
1195
+ "text": "Jasper-\nKeene"
1196
+ },
1197
+ {
1198
+ "start": 25,
1199
+ "end": 35,
1200
+ "label": "CITY",
1201
+ "text": "Aber-\ndeen"
1202
+ },
1203
+ {
1204
+ "start": 49,
1205
+ "end": 70,
1206
+ "label": "ORGANIZATION",
1207
+ "text": "Silver Finch Robotics"
1208
+ }
1209
+ ],
1210
+ "expected": "Name: [PERSON]\nOrt: [CITY]\nArbeitgeber: [ORGANIZATION]."
1211
+ },
1212
+ {
1213
+ "id": "holdout03c-037",
1214
+ "language": "en",
1215
+ "ocr_noise": true,
1216
+ "features": [
1217
+ "ocr_spelling",
1218
+ "organization"
1219
+ ],
1220
+ "text": "Scanned record: Jasper Keene resides in Aberdeen and works at Silver Finch Robotics.",
1221
+ "spans": [
1222
+ {
1223
+ "start": 16,
1224
+ "end": 28,
1225
+ "label": "PERSON",
1226
+ "text": "Jasper Keene"
1227
+ },
1228
+ {
1229
+ "start": 40,
1230
+ "end": 48,
1231
+ "label": "CITY",
1232
+ "text": "Aberdeen"
1233
+ },
1234
+ {
1235
+ "start": 62,
1236
+ "end": 83,
1237
+ "label": "ORGANIZATION",
1238
+ "text": "Silver Finch Robotics"
1239
+ }
1240
+ ],
1241
+ "expected": "Scanned record: [PERSON] resides in [CITY] and works at [ORGANIZATION]."
1242
+ },
1243
+ {
1244
+ "id": "holdout03c-038",
1245
+ "language": "de",
1246
+ "ocr_noise": false,
1247
+ "features": [
1248
+ "religious_reference",
1249
+ "no_inferred_affiliation"
1250
+ ],
1251
+ "text": "Das Thema hinduistisch wird in einem Bericht von Silver Finch Robotics erwähnt. Für Jasper Keene wird keine Religionszugehörigkeit behauptet.",
1252
+ "spans": [
1253
+ {
1254
+ "start": 10,
1255
+ "end": 22,
1256
+ "label": "RELIGION",
1257
+ "text": "hinduistisch"
1258
+ },
1259
+ {
1260
+ "start": 49,
1261
+ "end": 70,
1262
+ "label": "ORGANIZATION",
1263
+ "text": "Silver Finch Robotics"
1264
+ },
1265
+ {
1266
+ "start": 84,
1267
+ "end": 96,
1268
+ "label": "PERSON",
1269
+ "text": "Jasper Keene"
1270
+ }
1271
+ ],
1272
+ "expected": "Das Thema [RELIGION] wird in einem Bericht von [ORGANIZATION] erwähnt. Für [PERSON] wird keine Religionszugehörigkeit behauptet."
1273
+ },
1274
+ {
1275
+ "id": "holdout03c-039",
1276
+ "language": "en",
1277
+ "ocr_noise": false,
1278
+ "features": [
1279
+ "negative",
1280
+ "religious_reference"
1281
+ ],
1282
+ "text": "Worksheet 104: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details.",
1283
+ "spans": [],
1284
+ "expected": "Worksheet 104: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details."
1285
+ },
1286
+ {
1287
+ "id": "holdout03c-040",
1288
+ "language": "de",
1289
+ "ocr_noise": false,
1290
+ "features": [
1291
+ "organization",
1292
+ "geography"
1293
+ ],
1294
+ "text": "Auskunft: Frida Östberg arbeitet für Blue Cedar Cooperative in Uppsala.",
1295
+ "spans": [
1296
+ {
1297
+ "start": 10,
1298
+ "end": 23,
1299
+ "label": "PERSON",
1300
+ "text": "Frida Östberg"
1301
+ },
1302
+ {
1303
+ "start": 37,
1304
+ "end": 59,
1305
+ "label": "ORGANIZATION",
1306
+ "text": "Blue Cedar Cooperative"
1307
+ },
1308
+ {
1309
+ "start": 63,
1310
+ "end": 70,
1311
+ "label": "CITY",
1312
+ "text": "Uppsala"
1313
+ }
1314
+ ],
1315
+ "expected": "Auskunft: [PERSON] arbeitet für [ORGANIZATION] in [CITY]."
1316
+ },
1317
+ {
1318
+ "id": "holdout03c-041",
1319
+ "language": "en",
1320
+ "ocr_noise": false,
1321
+ "features": [
1322
+ "organization",
1323
+ "geography"
1324
+ ],
1325
+ "text": "The contact Frida Östberg represents Blue Cedar Cooperative near Uppsala.",
1326
+ "spans": [
1327
+ {
1328
+ "start": 12,
1329
+ "end": 25,
1330
+ "label": "PERSON",
1331
+ "text": "Frida Östberg"
1332
+ },
1333
+ {
1334
+ "start": 37,
1335
+ "end": 59,
1336
+ "label": "ORGANIZATION",
1337
+ "text": "Blue Cedar Cooperative"
1338
+ },
1339
+ {
1340
+ "start": 65,
1341
+ "end": 72,
1342
+ "label": "CITY",
1343
+ "text": "Uppsala"
1344
+ }
1345
+ ],
1346
+ "expected": "The contact [PERSON] represents [ORGANIZATION] near [CITY]."
1347
+ },
1348
+ {
1349
+ "id": "holdout03c-042",
1350
+ "language": "de",
1351
+ "ocr_noise": false,
1352
+ "features": [
1353
+ "affiliation",
1354
+ "contacts"
1355
+ ],
1356
+ "text": "Frida Östberg nennt bahai als Zugehörigkeit. E-Mail: case105.review@example.invalid; Telefon: +49 541 730105.",
1357
+ "spans": [
1358
+ {
1359
+ "start": 0,
1360
+ "end": 13,
1361
+ "label": "PERSON",
1362
+ "text": "Frida Östberg"
1363
+ },
1364
+ {
1365
+ "start": 20,
1366
+ "end": 25,
1367
+ "label": "RELIGION",
1368
+ "text": "bahai"
1369
+ },
1370
+ {
1371
+ "start": 53,
1372
+ "end": 83,
1373
+ "label": "EMAIL",
1374
+ "text": "case105.review@example.invalid"
1375
+ },
1376
+ {
1377
+ "start": 94,
1378
+ "end": 108,
1379
+ "label": "PHONE",
1380
+ "text": "+49 541 730105"
1381
+ }
1382
+ ],
1383
+ "expected": "[PERSON] nennt [RELIGION] als Zugehörigkeit. E-Mail: [EMAIL]; Telefon: [PHONE]."
1384
+ },
1385
+ {
1386
+ "id": "holdout03c-043",
1387
+ "language": "en",
1388
+ "ocr_noise": false,
1389
+ "features": [
1390
+ "affiliation",
1391
+ "contacts"
1392
+ ],
1393
+ "text": "Frida Östberg explicitly names Baháʼí Faith as their faith. Write to case105.review@example.invalid or call +49 541 730105.",
1394
+ "spans": [
1395
+ {
1396
+ "start": 0,
1397
+ "end": 13,
1398
+ "label": "PERSON",
1399
+ "text": "Frida Östberg"
1400
+ },
1401
+ {
1402
+ "start": 31,
1403
+ "end": 43,
1404
+ "label": "RELIGION",
1405
+ "text": "Baháʼí Faith"
1406
+ },
1407
+ {
1408
+ "start": 69,
1409
+ "end": 99,
1410
+ "label": "EMAIL",
1411
+ "text": "case105.review@example.invalid"
1412
+ },
1413
+ {
1414
+ "start": 108,
1415
+ "end": 122,
1416
+ "label": "PHONE",
1417
+ "text": "+49 541 730105"
1418
+ }
1419
+ ],
1420
+ "expected": "[PERSON] explicitly names [RELIGION] as their faith. Write to [EMAIL] or call [PHONE]."
1421
+ },
1422
+ {
1423
+ "id": "holdout03c-044",
1424
+ "language": "de",
1425
+ "ocr_noise": false,
1426
+ "features": [
1427
+ "repeats",
1428
+ "paragraphs"
1429
+ ],
1430
+ "text": "Frida Östberg besucht Uppsala.\n\nFrida Östberg bestätigt den Termin bei Blue Cedar Cooperative.",
1431
+ "spans": [
1432
+ {
1433
+ "start": 0,
1434
+ "end": 13,
1435
+ "label": "PERSON",
1436
+ "text": "Frida Östberg"
1437
+ },
1438
+ {
1439
+ "start": 22,
1440
+ "end": 29,
1441
+ "label": "CITY",
1442
+ "text": "Uppsala"
1443
+ },
1444
+ {
1445
+ "start": 32,
1446
+ "end": 45,
1447
+ "label": "PERSON",
1448
+ "text": "Frida Östberg"
1449
+ },
1450
+ {
1451
+ "start": 71,
1452
+ "end": 93,
1453
+ "label": "ORGANIZATION",
1454
+ "text": "Blue Cedar Cooperative"
1455
+ }
1456
+ ],
1457
+ "expected": "[PERSON] besucht [CITY].\n\n[PERSON] bestätigt den Termin bei [ORGANIZATION]."
1458
+ },
1459
+ {
1460
+ "id": "holdout03c-045",
1461
+ "language": "en",
1462
+ "ocr_noise": false,
1463
+ "features": [
1464
+ "repeats",
1465
+ "ambiguous_geography"
1466
+ ],
1467
+ "text": "In Uppsala, Frida Östberg signed for Blue Cedar Cooperative. The representative Frida Östberg returned to Uppsala.",
1468
+ "spans": [
1469
+ {
1470
+ "start": 3,
1471
+ "end": 10,
1472
+ "label": "CITY",
1473
+ "text": "Uppsala"
1474
+ },
1475
+ {
1476
+ "start": 12,
1477
+ "end": 25,
1478
+ "label": "PERSON",
1479
+ "text": "Frida Östberg"
1480
+ },
1481
+ {
1482
+ "start": 37,
1483
+ "end": 59,
1484
+ "label": "ORGANIZATION",
1485
+ "text": "Blue Cedar Cooperative"
1486
+ },
1487
+ {
1488
+ "start": 80,
1489
+ "end": 93,
1490
+ "label": "PERSON",
1491
+ "text": "Frida Östberg"
1492
+ },
1493
+ {
1494
+ "start": 106,
1495
+ "end": 113,
1496
+ "label": "CITY",
1497
+ "text": "Uppsala"
1498
+ }
1499
+ ],
1500
+ "expected": "In [CITY], [PERSON] signed for [ORGANIZATION]. The representative [PERSON] returned to [CITY]."
1501
+ },
1502
+ {
1503
+ "id": "holdout03c-046",
1504
+ "language": "de",
1505
+ "ocr_noise": true,
1506
+ "features": [
1507
+ "line_wrap",
1508
+ "organization"
1509
+ ],
1510
+ "text": "Name: Frida-\nÖstberg\nOrt: Upp-\nsala\nArbeitgeber: Blue Cedar Cooperative.",
1511
+ "spans": [
1512
+ {
1513
+ "start": 6,
1514
+ "end": 20,
1515
+ "label": "PERSON",
1516
+ "text": "Frida-\nÖstberg"
1517
+ },
1518
+ {
1519
+ "start": 26,
1520
+ "end": 35,
1521
+ "label": "CITY",
1522
+ "text": "Upp-\nsala"
1523
+ },
1524
+ {
1525
+ "start": 49,
1526
+ "end": 71,
1527
+ "label": "ORGANIZATION",
1528
+ "text": "Blue Cedar Cooperative"
1529
+ }
1530
+ ],
1531
+ "expected": "Name: [PERSON]\nOrt: [CITY]\nArbeitgeber: [ORGANIZATION]."
1532
+ },
1533
+ {
1534
+ "id": "holdout03c-047",
1535
+ "language": "en",
1536
+ "ocr_noise": true,
1537
+ "features": [
1538
+ "ocr_spelling",
1539
+ "organization"
1540
+ ],
1541
+ "text": "Scanned record: Fr1da Östberg resides in Upps4l4 and works at Blue Cedar Cooperative.",
1542
+ "spans": [
1543
+ {
1544
+ "start": 16,
1545
+ "end": 29,
1546
+ "label": "PERSON",
1547
+ "text": "Fr1da Östberg"
1548
+ },
1549
+ {
1550
+ "start": 41,
1551
+ "end": 48,
1552
+ "label": "CITY",
1553
+ "text": "Upps4l4"
1554
+ },
1555
+ {
1556
+ "start": 62,
1557
+ "end": 84,
1558
+ "label": "ORGANIZATION",
1559
+ "text": "Blue Cedar Cooperative"
1560
+ }
1561
+ ],
1562
+ "expected": "Scanned record: [PERSON] resides in [CITY] and works at [ORGANIZATION]."
1563
+ },
1564
+ {
1565
+ "id": "holdout03c-048",
1566
+ "language": "de",
1567
+ "ocr_noise": false,
1568
+ "features": [
1569
+ "religious_reference",
1570
+ "no_inferred_affiliation"
1571
+ ],
1572
+ "text": "Das Thema bahai wird in einem Bericht von Blue Cedar Cooperative erwähnt. Für Frida Östberg wird keine Religionszugehörigkeit behauptet.",
1573
+ "spans": [
1574
+ {
1575
+ "start": 10,
1576
+ "end": 15,
1577
+ "label": "RELIGION",
1578
+ "text": "bahai"
1579
+ },
1580
+ {
1581
+ "start": 42,
1582
+ "end": 64,
1583
+ "label": "ORGANIZATION",
1584
+ "text": "Blue Cedar Cooperative"
1585
+ },
1586
+ {
1587
+ "start": 78,
1588
+ "end": 91,
1589
+ "label": "PERSON",
1590
+ "text": "Frida Östberg"
1591
+ }
1592
+ ],
1593
+ "expected": "Das Thema [RELIGION] wird in einem Bericht von [ORGANIZATION] erwähnt. Für [PERSON] wird keine Religionszugehörigkeit behauptet."
1594
+ },
1595
+ {
1596
+ "id": "holdout03c-049",
1597
+ "language": "en",
1598
+ "ocr_noise": false,
1599
+ "features": [
1600
+ "negative",
1601
+ "religious_reference"
1602
+ ],
1603
+ "text": "Worksheet 105: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details.",
1604
+ "spans": [],
1605
+ "expected": "Worksheet 105: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details."
1606
+ },
1607
+ {
1608
+ "id": "holdout03c-050",
1609
+ "language": "de",
1610
+ "ocr_noise": false,
1611
+ "features": [
1612
+ "organization",
1613
+ "geography"
1614
+ ],
1615
+ "text": "Auskunft: Owen Fairchild arbeitet für Oakwell Public Library in Leicester.",
1616
+ "spans": [
1617
+ {
1618
+ "start": 10,
1619
+ "end": 24,
1620
+ "label": "PERSON",
1621
+ "text": "Owen Fairchild"
1622
+ },
1623
+ {
1624
+ "start": 38,
1625
+ "end": 60,
1626
+ "label": "ORGANIZATION",
1627
+ "text": "Oakwell Public Library"
1628
+ },
1629
+ {
1630
+ "start": 64,
1631
+ "end": 73,
1632
+ "label": "CITY",
1633
+ "text": "Leicester"
1634
+ }
1635
+ ],
1636
+ "expected": "Auskunft: [PERSON] arbeitet für [ORGANIZATION] in [CITY]."
1637
+ },
1638
+ {
1639
+ "id": "holdout03c-051",
1640
+ "language": "en",
1641
+ "ocr_noise": false,
1642
+ "features": [
1643
+ "organization",
1644
+ "geography"
1645
+ ],
1646
+ "text": "The contact Owen Fairchild represents Oakwell Public Library near Leicester.",
1647
+ "spans": [
1648
+ {
1649
+ "start": 12,
1650
+ "end": 26,
1651
+ "label": "PERSON",
1652
+ "text": "Owen Fairchild"
1653
+ },
1654
+ {
1655
+ "start": 38,
1656
+ "end": 60,
1657
+ "label": "ORGANIZATION",
1658
+ "text": "Oakwell Public Library"
1659
+ },
1660
+ {
1661
+ "start": 66,
1662
+ "end": 75,
1663
+ "label": "CITY",
1664
+ "text": "Leicester"
1665
+ }
1666
+ ],
1667
+ "expected": "The contact [PERSON] represents [ORGANIZATION] near [CITY]."
1668
+ },
1669
+ {
1670
+ "id": "holdout03c-052",
1671
+ "language": "de",
1672
+ "ocr_noise": false,
1673
+ "features": [
1674
+ "affiliation",
1675
+ "contacts"
1676
+ ],
1677
+ "text": "Owen Fairchild nennt buddhistisch als Zugehörigkeit. E-Mail: case106.review@example.invalid; Telefon: +49 541 730106.",
1678
+ "spans": [
1679
+ {
1680
+ "start": 0,
1681
+ "end": 14,
1682
+ "label": "PERSON",
1683
+ "text": "Owen Fairchild"
1684
+ },
1685
+ {
1686
+ "start": 21,
1687
+ "end": 33,
1688
+ "label": "RELIGION",
1689
+ "text": "buddhistisch"
1690
+ },
1691
+ {
1692
+ "start": 61,
1693
+ "end": 91,
1694
+ "label": "EMAIL",
1695
+ "text": "case106.review@example.invalid"
1696
+ },
1697
+ {
1698
+ "start": 102,
1699
+ "end": 116,
1700
+ "label": "PHONE",
1701
+ "text": "+49 541 730106"
1702
+ }
1703
+ ],
1704
+ "expected": "[PERSON] nennt [RELIGION] als Zugehörigkeit. E-Mail: [EMAIL]; Telefon: [PHONE]."
1705
+ },
1706
+ {
1707
+ "id": "holdout03c-053",
1708
+ "language": "en",
1709
+ "ocr_noise": false,
1710
+ "features": [
1711
+ "affiliation",
1712
+ "contacts"
1713
+ ],
1714
+ "text": "Owen Fairchild explicitly names Buddhism as their faith. Write to case106.review@example.invalid or call +49 541 730106.",
1715
+ "spans": [
1716
+ {
1717
+ "start": 0,
1718
+ "end": 14,
1719
+ "label": "PERSON",
1720
+ "text": "Owen Fairchild"
1721
+ },
1722
+ {
1723
+ "start": 32,
1724
+ "end": 40,
1725
+ "label": "RELIGION",
1726
+ "text": "Buddhism"
1727
+ },
1728
+ {
1729
+ "start": 66,
1730
+ "end": 96,
1731
+ "label": "EMAIL",
1732
+ "text": "case106.review@example.invalid"
1733
+ },
1734
+ {
1735
+ "start": 105,
1736
+ "end": 119,
1737
+ "label": "PHONE",
1738
+ "text": "+49 541 730106"
1739
+ }
1740
+ ],
1741
+ "expected": "[PERSON] explicitly names [RELIGION] as their faith. Write to [EMAIL] or call [PHONE]."
1742
+ },
1743
+ {
1744
+ "id": "holdout03c-054",
1745
+ "language": "de",
1746
+ "ocr_noise": false,
1747
+ "features": [
1748
+ "repeats",
1749
+ "paragraphs"
1750
+ ],
1751
+ "text": "Owen Fairchild besucht Leicester.\n\nOwen Fairchild bestätigt den Termin bei Oakwell Public Library.",
1752
+ "spans": [
1753
+ {
1754
+ "start": 0,
1755
+ "end": 14,
1756
+ "label": "PERSON",
1757
+ "text": "Owen Fairchild"
1758
+ },
1759
+ {
1760
+ "start": 23,
1761
+ "end": 32,
1762
+ "label": "CITY",
1763
+ "text": "Leicester"
1764
+ },
1765
+ {
1766
+ "start": 35,
1767
+ "end": 49,
1768
+ "label": "PERSON",
1769
+ "text": "Owen Fairchild"
1770
+ },
1771
+ {
1772
+ "start": 75,
1773
+ "end": 97,
1774
+ "label": "ORGANIZATION",
1775
+ "text": "Oakwell Public Library"
1776
+ }
1777
+ ],
1778
+ "expected": "[PERSON] besucht [CITY].\n\n[PERSON] bestätigt den Termin bei [ORGANIZATION]."
1779
+ },
1780
+ {
1781
+ "id": "holdout03c-055",
1782
+ "language": "en",
1783
+ "ocr_noise": false,
1784
+ "features": [
1785
+ "repeats",
1786
+ "ambiguous_geography"
1787
+ ],
1788
+ "text": "In Leicester, Owen Fairchild signed for Oakwell Public Library. The representative Owen Fairchild returned to Leicester.",
1789
+ "spans": [
1790
+ {
1791
+ "start": 3,
1792
+ "end": 12,
1793
+ "label": "CITY",
1794
+ "text": "Leicester"
1795
+ },
1796
+ {
1797
+ "start": 14,
1798
+ "end": 28,
1799
+ "label": "PERSON",
1800
+ "text": "Owen Fairchild"
1801
+ },
1802
+ {
1803
+ "start": 40,
1804
+ "end": 62,
1805
+ "label": "ORGANIZATION",
1806
+ "text": "Oakwell Public Library"
1807
+ },
1808
+ {
1809
+ "start": 83,
1810
+ "end": 97,
1811
+ "label": "PERSON",
1812
+ "text": "Owen Fairchild"
1813
+ },
1814
+ {
1815
+ "start": 110,
1816
+ "end": 119,
1817
+ "label": "CITY",
1818
+ "text": "Leicester"
1819
+ }
1820
+ ],
1821
+ "expected": "In [CITY], [PERSON] signed for [ORGANIZATION]. The representative [PERSON] returned to [CITY]."
1822
+ },
1823
+ {
1824
+ "id": "holdout03c-056",
1825
+ "language": "de",
1826
+ "ocr_noise": true,
1827
+ "features": [
1828
+ "line_wrap",
1829
+ "organization"
1830
+ ],
1831
+ "text": "Name: Owen-\nFairchild\nOrt: Leic-\nester\nArbeitgeber: Oakwell Public Library.",
1832
+ "spans": [
1833
+ {
1834
+ "start": 6,
1835
+ "end": 21,
1836
+ "label": "PERSON",
1837
+ "text": "Owen-\nFairchild"
1838
+ },
1839
+ {
1840
+ "start": 27,
1841
+ "end": 38,
1842
+ "label": "CITY",
1843
+ "text": "Leic-\nester"
1844
+ },
1845
+ {
1846
+ "start": 52,
1847
+ "end": 74,
1848
+ "label": "ORGANIZATION",
1849
+ "text": "Oakwell Public Library"
1850
+ }
1851
+ ],
1852
+ "expected": "Name: [PERSON]\nOrt: [CITY]\nArbeitgeber: [ORGANIZATION]."
1853
+ },
1854
+ {
1855
+ "id": "holdout03c-057",
1856
+ "language": "en",
1857
+ "ocr_noise": true,
1858
+ "features": [
1859
+ "ocr_spelling",
1860
+ "organization"
1861
+ ],
1862
+ "text": "Scanned record: Owen Fa1rch1ld resides in Leicester and works at Oakwell Public Library.",
1863
+ "spans": [
1864
+ {
1865
+ "start": 16,
1866
+ "end": 30,
1867
+ "label": "PERSON",
1868
+ "text": "Owen Fa1rch1ld"
1869
+ },
1870
+ {
1871
+ "start": 42,
1872
+ "end": 51,
1873
+ "label": "CITY",
1874
+ "text": "Leicester"
1875
+ },
1876
+ {
1877
+ "start": 65,
1878
+ "end": 87,
1879
+ "label": "ORGANIZATION",
1880
+ "text": "Oakwell Public Library"
1881
+ }
1882
+ ],
1883
+ "expected": "Scanned record: [PERSON] resides in [CITY] and works at [ORGANIZATION]."
1884
+ },
1885
+ {
1886
+ "id": "holdout03c-058",
1887
+ "language": "de",
1888
+ "ocr_noise": false,
1889
+ "features": [
1890
+ "religious_reference",
1891
+ "no_inferred_affiliation"
1892
+ ],
1893
+ "text": "Das Thema buddhistisch wird in einem Bericht von Oakwell Public Library erwähnt. Für Owen Fairchild wird keine Religionszugehörigkeit behauptet.",
1894
+ "spans": [
1895
+ {
1896
+ "start": 10,
1897
+ "end": 22,
1898
+ "label": "RELIGION",
1899
+ "text": "buddhistisch"
1900
+ },
1901
+ {
1902
+ "start": 49,
1903
+ "end": 71,
1904
+ "label": "ORGANIZATION",
1905
+ "text": "Oakwell Public Library"
1906
+ },
1907
+ {
1908
+ "start": 85,
1909
+ "end": 99,
1910
+ "label": "PERSON",
1911
+ "text": "Owen Fairchild"
1912
+ }
1913
+ ],
1914
+ "expected": "Das Thema [RELIGION] wird in einem Bericht von [ORGANIZATION] erwähnt. Für [PERSON] wird keine Religionszugehörigkeit behauptet."
1915
+ },
1916
+ {
1917
+ "id": "holdout03c-059",
1918
+ "language": "en",
1919
+ "ocr_noise": false,
1920
+ "features": [
1921
+ "negative",
1922
+ "religious_reference"
1923
+ ],
1924
+ "text": "Worksheet 106: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details.",
1925
+ "spans": [],
1926
+ "expected": "Worksheet 106: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details."
1927
+ },
1928
+ {
1929
+ "id": "holdout03c-060",
1930
+ "language": "de",
1931
+ "ocr_noise": false,
1932
+ "features": [
1933
+ "organization",
1934
+ "geography"
1935
+ ],
1936
+ "text": "Auskunft: Nele Brückner arbeitet für Westhaven Water Authority in Würzburg.",
1937
+ "spans": [
1938
+ {
1939
+ "start": 10,
1940
+ "end": 23,
1941
+ "label": "PERSON",
1942
+ "text": "Nele Brückner"
1943
+ },
1944
+ {
1945
+ "start": 37,
1946
+ "end": 62,
1947
+ "label": "ORGANIZATION",
1948
+ "text": "Westhaven Water Authority"
1949
+ },
1950
+ {
1951
+ "start": 66,
1952
+ "end": 74,
1953
+ "label": "CITY",
1954
+ "text": "Würzburg"
1955
+ }
1956
+ ],
1957
+ "expected": "Auskunft: [PERSON] arbeitet für [ORGANIZATION] in [CITY]."
1958
+ },
1959
+ {
1960
+ "id": "holdout03c-061",
1961
+ "language": "en",
1962
+ "ocr_noise": false,
1963
+ "features": [
1964
+ "organization",
1965
+ "geography"
1966
+ ],
1967
+ "text": "The contact Nele Brückner represents Westhaven Water Authority near Würzburg.",
1968
+ "spans": [
1969
+ {
1970
+ "start": 12,
1971
+ "end": 25,
1972
+ "label": "PERSON",
1973
+ "text": "Nele Brückner"
1974
+ },
1975
+ {
1976
+ "start": 37,
1977
+ "end": 62,
1978
+ "label": "ORGANIZATION",
1979
+ "text": "Westhaven Water Authority"
1980
+ },
1981
+ {
1982
+ "start": 68,
1983
+ "end": 76,
1984
+ "label": "CITY",
1985
+ "text": "Würzburg"
1986
+ }
1987
+ ],
1988
+ "expected": "The contact [PERSON] represents [ORGANIZATION] near [CITY]."
1989
+ },
1990
+ {
1991
+ "id": "holdout03c-062",
1992
+ "language": "de",
1993
+ "ocr_noise": false,
1994
+ "features": [
1995
+ "affiliation",
1996
+ "contacts"
1997
+ ],
1998
+ "text": "Nele Brückner nennt katholisch als Zugehörigkeit. E-Mail: case107.review@example.invalid; Telefon: +49 541 730107.",
1999
+ "spans": [
2000
+ {
2001
+ "start": 0,
2002
+ "end": 13,
2003
+ "label": "PERSON",
2004
+ "text": "Nele Brückner"
2005
+ },
2006
+ {
2007
+ "start": 20,
2008
+ "end": 30,
2009
+ "label": "RELIGION",
2010
+ "text": "katholisch"
2011
+ },
2012
+ {
2013
+ "start": 58,
2014
+ "end": 88,
2015
+ "label": "EMAIL",
2016
+ "text": "case107.review@example.invalid"
2017
+ },
2018
+ {
2019
+ "start": 99,
2020
+ "end": 113,
2021
+ "label": "PHONE",
2022
+ "text": "+49 541 730107"
2023
+ }
2024
+ ],
2025
+ "expected": "[PERSON] nennt [RELIGION] als Zugehörigkeit. E-Mail: [EMAIL]; Telefon: [PHONE]."
2026
+ },
2027
+ {
2028
+ "id": "holdout03c-063",
2029
+ "language": "en",
2030
+ "ocr_noise": false,
2031
+ "features": [
2032
+ "affiliation",
2033
+ "contacts"
2034
+ ],
2035
+ "text": "Nele Brückner explicitly names Catholicism as their faith. Write to case107.review@example.invalid or call +49 541 730107.",
2036
+ "spans": [
2037
+ {
2038
+ "start": 0,
2039
+ "end": 13,
2040
+ "label": "PERSON",
2041
+ "text": "Nele Brückner"
2042
+ },
2043
+ {
2044
+ "start": 31,
2045
+ "end": 42,
2046
+ "label": "RELIGION",
2047
+ "text": "Catholicism"
2048
+ },
2049
+ {
2050
+ "start": 68,
2051
+ "end": 98,
2052
+ "label": "EMAIL",
2053
+ "text": "case107.review@example.invalid"
2054
+ },
2055
+ {
2056
+ "start": 107,
2057
+ "end": 121,
2058
+ "label": "PHONE",
2059
+ "text": "+49 541 730107"
2060
+ }
2061
+ ],
2062
+ "expected": "[PERSON] explicitly names [RELIGION] as their faith. Write to [EMAIL] or call [PHONE]."
2063
+ },
2064
+ {
2065
+ "id": "holdout03c-064",
2066
+ "language": "de",
2067
+ "ocr_noise": false,
2068
+ "features": [
2069
+ "repeats",
2070
+ "paragraphs"
2071
+ ],
2072
+ "text": "Nele Brückner besucht Würzburg.\n\nNele Brückner bestätigt den Termin bei Westhaven Water Authority.",
2073
+ "spans": [
2074
+ {
2075
+ "start": 0,
2076
+ "end": 13,
2077
+ "label": "PERSON",
2078
+ "text": "Nele Brückner"
2079
+ },
2080
+ {
2081
+ "start": 22,
2082
+ "end": 30,
2083
+ "label": "CITY",
2084
+ "text": "Würzburg"
2085
+ },
2086
+ {
2087
+ "start": 33,
2088
+ "end": 46,
2089
+ "label": "PERSON",
2090
+ "text": "Nele Brückner"
2091
+ },
2092
+ {
2093
+ "start": 72,
2094
+ "end": 97,
2095
+ "label": "ORGANIZATION",
2096
+ "text": "Westhaven Water Authority"
2097
+ }
2098
+ ],
2099
+ "expected": "[PERSON] besucht [CITY].\n\n[PERSON] bestätigt den Termin bei [ORGANIZATION]."
2100
+ },
2101
+ {
2102
+ "id": "holdout03c-065",
2103
+ "language": "en",
2104
+ "ocr_noise": false,
2105
+ "features": [
2106
+ "repeats",
2107
+ "ambiguous_geography"
2108
+ ],
2109
+ "text": "In Würzburg, Nele Brückner signed for Westhaven Water Authority. The representative Nele Brückner returned to Würzburg.",
2110
+ "spans": [
2111
+ {
2112
+ "start": 3,
2113
+ "end": 11,
2114
+ "label": "CITY",
2115
+ "text": "Würzburg"
2116
+ },
2117
+ {
2118
+ "start": 13,
2119
+ "end": 26,
2120
+ "label": "PERSON",
2121
+ "text": "Nele Brückner"
2122
+ },
2123
+ {
2124
+ "start": 38,
2125
+ "end": 63,
2126
+ "label": "ORGANIZATION",
2127
+ "text": "Westhaven Water Authority"
2128
+ },
2129
+ {
2130
+ "start": 84,
2131
+ "end": 97,
2132
+ "label": "PERSON",
2133
+ "text": "Nele Brückner"
2134
+ },
2135
+ {
2136
+ "start": 110,
2137
+ "end": 118,
2138
+ "label": "CITY",
2139
+ "text": "Würzburg"
2140
+ }
2141
+ ],
2142
+ "expected": "In [CITY], [PERSON] signed for [ORGANIZATION]. The representative [PERSON] returned to [CITY]."
2143
+ },
2144
+ {
2145
+ "id": "holdout03c-066",
2146
+ "language": "de",
2147
+ "ocr_noise": true,
2148
+ "features": [
2149
+ "line_wrap",
2150
+ "organization"
2151
+ ],
2152
+ "text": "Name: Nele-\nBrückner\nOrt: Würz-\nburg\nArbeitgeber: Westhaven Water Authority.",
2153
+ "spans": [
2154
+ {
2155
+ "start": 6,
2156
+ "end": 20,
2157
+ "label": "PERSON",
2158
+ "text": "Nele-\nBrückner"
2159
+ },
2160
+ {
2161
+ "start": 26,
2162
+ "end": 36,
2163
+ "label": "CITY",
2164
+ "text": "Würz-\nburg"
2165
+ },
2166
+ {
2167
+ "start": 50,
2168
+ "end": 75,
2169
+ "label": "ORGANIZATION",
2170
+ "text": "Westhaven Water Authority"
2171
+ }
2172
+ ],
2173
+ "expected": "Name: [PERSON]\nOrt: [CITY]\nArbeitgeber: [ORGANIZATION]."
2174
+ },
2175
+ {
2176
+ "id": "holdout03c-067",
2177
+ "language": "en",
2178
+ "ocr_noise": true,
2179
+ "features": [
2180
+ "ocr_spelling",
2181
+ "organization"
2182
+ ],
2183
+ "text": "Scanned record: Nele Brückner resides in Würzburg and works at Westhaven Water Authority.",
2184
+ "spans": [
2185
+ {
2186
+ "start": 16,
2187
+ "end": 29,
2188
+ "label": "PERSON",
2189
+ "text": "Nele Brückner"
2190
+ },
2191
+ {
2192
+ "start": 41,
2193
+ "end": 49,
2194
+ "label": "CITY",
2195
+ "text": "Würzburg"
2196
+ },
2197
+ {
2198
+ "start": 63,
2199
+ "end": 88,
2200
+ "label": "ORGANIZATION",
2201
+ "text": "Westhaven Water Authority"
2202
+ }
2203
+ ],
2204
+ "expected": "Scanned record: [PERSON] resides in [CITY] and works at [ORGANIZATION]."
2205
+ },
2206
+ {
2207
+ "id": "holdout03c-068",
2208
+ "language": "de",
2209
+ "ocr_noise": false,
2210
+ "features": [
2211
+ "religious_reference",
2212
+ "no_inferred_affiliation"
2213
+ ],
2214
+ "text": "Das Thema katholisch wird in einem Bericht von Westhaven Water Authority erwähnt. Für Nele Brückner wird keine Religionszugehörigkeit behauptet.",
2215
+ "spans": [
2216
+ {
2217
+ "start": 10,
2218
+ "end": 20,
2219
+ "label": "RELIGION",
2220
+ "text": "katholisch"
2221
+ },
2222
+ {
2223
+ "start": 47,
2224
+ "end": 72,
2225
+ "label": "ORGANIZATION",
2226
+ "text": "Westhaven Water Authority"
2227
+ },
2228
+ {
2229
+ "start": 86,
2230
+ "end": 99,
2231
+ "label": "PERSON",
2232
+ "text": "Nele Brückner"
2233
+ }
2234
+ ],
2235
+ "expected": "Das Thema [RELIGION] wird in einem Bericht von [ORGANIZATION] erwähnt. Für [PERSON] wird keine Religionszugehörigkeit behauptet."
2236
+ },
2237
+ {
2238
+ "id": "holdout03c-069",
2239
+ "language": "en",
2240
+ "ocr_noise": false,
2241
+ "features": [
2242
+ "negative",
2243
+ "religious_reference"
2244
+ ],
2245
+ "text": "Worksheet 107: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details.",
2246
+ "spans": [],
2247
+ "expected": "Worksheet 107: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details."
2248
+ },
2249
+ {
2250
+ "id": "holdout03c-070",
2251
+ "language": "de",
2252
+ "ocr_noise": false,
2253
+ "features": [
2254
+ "organization",
2255
+ "geography"
2256
+ ],
2257
+ "text": "Auskunft: Silas Hawthorne arbeitet für Juniper Legal Services in Portland.",
2258
+ "spans": [
2259
+ {
2260
+ "start": 10,
2261
+ "end": 25,
2262
+ "label": "PERSON",
2263
+ "text": "Silas Hawthorne"
2264
+ },
2265
+ {
2266
+ "start": 39,
2267
+ "end": 61,
2268
+ "label": "ORGANIZATION",
2269
+ "text": "Juniper Legal Services"
2270
+ },
2271
+ {
2272
+ "start": 65,
2273
+ "end": 73,
2274
+ "label": "CITY",
2275
+ "text": "Portland"
2276
+ }
2277
+ ],
2278
+ "expected": "Auskunft: [PERSON] arbeitet für [ORGANIZATION] in [CITY]."
2279
+ },
2280
+ {
2281
+ "id": "holdout03c-071",
2282
+ "language": "en",
2283
+ "ocr_noise": false,
2284
+ "features": [
2285
+ "organization",
2286
+ "geography"
2287
+ ],
2288
+ "text": "The contact Silas Hawthorne represents Juniper Legal Services near Portland.",
2289
+ "spans": [
2290
+ {
2291
+ "start": 12,
2292
+ "end": 27,
2293
+ "label": "PERSON",
2294
+ "text": "Silas Hawthorne"
2295
+ },
2296
+ {
2297
+ "start": 39,
2298
+ "end": 61,
2299
+ "label": "ORGANIZATION",
2300
+ "text": "Juniper Legal Services"
2301
+ },
2302
+ {
2303
+ "start": 67,
2304
+ "end": 75,
2305
+ "label": "CITY",
2306
+ "text": "Portland"
2307
+ }
2308
+ ],
2309
+ "expected": "The contact [PERSON] represents [ORGANIZATION] near [CITY]."
2310
+ },
2311
+ {
2312
+ "id": "holdout03c-072",
2313
+ "language": "de",
2314
+ "ocr_noise": false,
2315
+ "features": [
2316
+ "affiliation",
2317
+ "contacts"
2318
+ ],
2319
+ "text": "Silas Hawthorne nennt evangelisch als Zugehörigkeit. E-Mail: case108.review@example.invalid; Telefon: +49 541 730108.",
2320
+ "spans": [
2321
+ {
2322
+ "start": 0,
2323
+ "end": 15,
2324
+ "label": "PERSON",
2325
+ "text": "Silas Hawthorne"
2326
+ },
2327
+ {
2328
+ "start": 22,
2329
+ "end": 33,
2330
+ "label": "RELIGION",
2331
+ "text": "evangelisch"
2332
+ },
2333
+ {
2334
+ "start": 61,
2335
+ "end": 91,
2336
+ "label": "EMAIL",
2337
+ "text": "case108.review@example.invalid"
2338
+ },
2339
+ {
2340
+ "start": 102,
2341
+ "end": 116,
2342
+ "label": "PHONE",
2343
+ "text": "+49 541 730108"
2344
+ }
2345
+ ],
2346
+ "expected": "[PERSON] nennt [RELIGION] als Zugehörigkeit. E-Mail: [EMAIL]; Telefon: [PHONE]."
2347
+ },
2348
+ {
2349
+ "id": "holdout03c-073",
2350
+ "language": "en",
2351
+ "ocr_noise": false,
2352
+ "features": [
2353
+ "affiliation",
2354
+ "contacts"
2355
+ ],
2356
+ "text": "Silas Hawthorne explicitly names Protestantism as their faith. Write to case108.review@example.invalid or call +49 541 730108.",
2357
+ "spans": [
2358
+ {
2359
+ "start": 0,
2360
+ "end": 15,
2361
+ "label": "PERSON",
2362
+ "text": "Silas Hawthorne"
2363
+ },
2364
+ {
2365
+ "start": 33,
2366
+ "end": 46,
2367
+ "label": "RELIGION",
2368
+ "text": "Protestantism"
2369
+ },
2370
+ {
2371
+ "start": 72,
2372
+ "end": 102,
2373
+ "label": "EMAIL",
2374
+ "text": "case108.review@example.invalid"
2375
+ },
2376
+ {
2377
+ "start": 111,
2378
+ "end": 125,
2379
+ "label": "PHONE",
2380
+ "text": "+49 541 730108"
2381
+ }
2382
+ ],
2383
+ "expected": "[PERSON] explicitly names [RELIGION] as their faith. Write to [EMAIL] or call [PHONE]."
2384
+ },
2385
+ {
2386
+ "id": "holdout03c-074",
2387
+ "language": "de",
2388
+ "ocr_noise": false,
2389
+ "features": [
2390
+ "repeats",
2391
+ "paragraphs"
2392
+ ],
2393
+ "text": "Silas Hawthorne besucht Portland.\n\nSilas Hawthorne bestätigt den Termin bei Juniper Legal Services.",
2394
+ "spans": [
2395
+ {
2396
+ "start": 0,
2397
+ "end": 15,
2398
+ "label": "PERSON",
2399
+ "text": "Silas Hawthorne"
2400
+ },
2401
+ {
2402
+ "start": 24,
2403
+ "end": 32,
2404
+ "label": "CITY",
2405
+ "text": "Portland"
2406
+ },
2407
+ {
2408
+ "start": 35,
2409
+ "end": 50,
2410
+ "label": "PERSON",
2411
+ "text": "Silas Hawthorne"
2412
+ },
2413
+ {
2414
+ "start": 76,
2415
+ "end": 98,
2416
+ "label": "ORGANIZATION",
2417
+ "text": "Juniper Legal Services"
2418
+ }
2419
+ ],
2420
+ "expected": "[PERSON] besucht [CITY].\n\n[PERSON] bestätigt den Termin bei [ORGANIZATION]."
2421
+ },
2422
+ {
2423
+ "id": "holdout03c-075",
2424
+ "language": "en",
2425
+ "ocr_noise": false,
2426
+ "features": [
2427
+ "repeats",
2428
+ "ambiguous_geography"
2429
+ ],
2430
+ "text": "In Portland, Silas Hawthorne signed for Juniper Legal Services. The representative Silas Hawthorne returned to Portland.",
2431
+ "spans": [
2432
+ {
2433
+ "start": 3,
2434
+ "end": 11,
2435
+ "label": "CITY",
2436
+ "text": "Portland"
2437
+ },
2438
+ {
2439
+ "start": 13,
2440
+ "end": 28,
2441
+ "label": "PERSON",
2442
+ "text": "Silas Hawthorne"
2443
+ },
2444
+ {
2445
+ "start": 40,
2446
+ "end": 62,
2447
+ "label": "ORGANIZATION",
2448
+ "text": "Juniper Legal Services"
2449
+ },
2450
+ {
2451
+ "start": 83,
2452
+ "end": 98,
2453
+ "label": "PERSON",
2454
+ "text": "Silas Hawthorne"
2455
+ },
2456
+ {
2457
+ "start": 111,
2458
+ "end": 119,
2459
+ "label": "CITY",
2460
+ "text": "Portland"
2461
+ }
2462
+ ],
2463
+ "expected": "In [CITY], [PERSON] signed for [ORGANIZATION]. The representative [PERSON] returned to [CITY]."
2464
+ },
2465
+ {
2466
+ "id": "holdout03c-076",
2467
+ "language": "de",
2468
+ "ocr_noise": true,
2469
+ "features": [
2470
+ "line_wrap",
2471
+ "organization"
2472
+ ],
2473
+ "text": "Name: Silas-\nHawthorne\nOrt: Port-\nland\nArbeitgeber: Juniper Legal Services.",
2474
+ "spans": [
2475
+ {
2476
+ "start": 6,
2477
+ "end": 22,
2478
+ "label": "PERSON",
2479
+ "text": "Silas-\nHawthorne"
2480
+ },
2481
+ {
2482
+ "start": 28,
2483
+ "end": 38,
2484
+ "label": "CITY",
2485
+ "text": "Port-\nland"
2486
+ },
2487
+ {
2488
+ "start": 52,
2489
+ "end": 74,
2490
+ "label": "ORGANIZATION",
2491
+ "text": "Juniper Legal Services"
2492
+ }
2493
+ ],
2494
+ "expected": "Name: [PERSON]\nOrt: [CITY]\nArbeitgeber: [ORGANIZATION]."
2495
+ },
2496
+ {
2497
+ "id": "holdout03c-077",
2498
+ "language": "en",
2499
+ "ocr_noise": true,
2500
+ "features": [
2501
+ "ocr_spelling",
2502
+ "organization"
2503
+ ],
2504
+ "text": "Scanned record: S1las Hawth0rne resides in P0rtl4nd and works at Juniper Legal Services.",
2505
+ "spans": [
2506
+ {
2507
+ "start": 16,
2508
+ "end": 31,
2509
+ "label": "PERSON",
2510
+ "text": "S1las Hawth0rne"
2511
+ },
2512
+ {
2513
+ "start": 43,
2514
+ "end": 51,
2515
+ "label": "CITY",
2516
+ "text": "P0rtl4nd"
2517
+ },
2518
+ {
2519
+ "start": 65,
2520
+ "end": 87,
2521
+ "label": "ORGANIZATION",
2522
+ "text": "Juniper Legal Services"
2523
+ }
2524
+ ],
2525
+ "expected": "Scanned record: [PERSON] resides in [CITY] and works at [ORGANIZATION]."
2526
+ },
2527
+ {
2528
+ "id": "holdout03c-078",
2529
+ "language": "de",
2530
+ "ocr_noise": false,
2531
+ "features": [
2532
+ "religious_reference",
2533
+ "no_inferred_affiliation"
2534
+ ],
2535
+ "text": "Das Thema evangelisch wird in einem Bericht von Juniper Legal Services erwähnt. Für Silas Hawthorne wird keine Religionszugehörigkeit behauptet.",
2536
+ "spans": [
2537
+ {
2538
+ "start": 10,
2539
+ "end": 21,
2540
+ "label": "RELIGION",
2541
+ "text": "evangelisch"
2542
+ },
2543
+ {
2544
+ "start": 48,
2545
+ "end": 70,
2546
+ "label": "ORGANIZATION",
2547
+ "text": "Juniper Legal Services"
2548
+ },
2549
+ {
2550
+ "start": 84,
2551
+ "end": 99,
2552
+ "label": "PERSON",
2553
+ "text": "Silas Hawthorne"
2554
+ }
2555
+ ],
2556
+ "expected": "Das Thema [RELIGION] wird in einem Bericht von [ORGANIZATION] erwähnt. Für [PERSON] wird keine Religionszugehörigkeit behauptet."
2557
+ },
2558
+ {
2559
+ "id": "holdout03c-079",
2560
+ "language": "en",
2561
+ "ocr_noise": false,
2562
+ "features": [
2563
+ "negative",
2564
+ "religious_reference"
2565
+ ],
2566
+ "text": "Worksheet 108: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details.",
2567
+ "spans": [],
2568
+ "expected": "Worksheet 108: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details."
2569
+ },
2570
+ {
2571
+ "id": "holdout03c-080",
2572
+ "language": "de",
2573
+ "ocr_noise": false,
2574
+ "features": [
2575
+ "organization",
2576
+ "geography"
2577
+ ],
2578
+ "text": "Auskunft: Alva Rosenqvist arbeitet für Maple Ridge University in Bergen.",
2579
+ "spans": [
2580
+ {
2581
+ "start": 10,
2582
+ "end": 25,
2583
+ "label": "PERSON",
2584
+ "text": "Alva Rosenqvist"
2585
+ },
2586
+ {
2587
+ "start": 39,
2588
+ "end": 61,
2589
+ "label": "ORGANIZATION",
2590
+ "text": "Maple Ridge University"
2591
+ },
2592
+ {
2593
+ "start": 65,
2594
+ "end": 71,
2595
+ "label": "CITY",
2596
+ "text": "Bergen"
2597
+ }
2598
+ ],
2599
+ "expected": "Auskunft: [PERSON] arbeitet für [ORGANIZATION] in [CITY]."
2600
+ },
2601
+ {
2602
+ "id": "holdout03c-081",
2603
+ "language": "en",
2604
+ "ocr_noise": false,
2605
+ "features": [
2606
+ "organization",
2607
+ "geography"
2608
+ ],
2609
+ "text": "The contact Alva Rosenqvist represents Maple Ridge University near Bergen.",
2610
+ "spans": [
2611
+ {
2612
+ "start": 12,
2613
+ "end": 27,
2614
+ "label": "PERSON",
2615
+ "text": "Alva Rosenqvist"
2616
+ },
2617
+ {
2618
+ "start": 39,
2619
+ "end": 61,
2620
+ "label": "ORGANIZATION",
2621
+ "text": "Maple Ridge University"
2622
+ },
2623
+ {
2624
+ "start": 67,
2625
+ "end": 73,
2626
+ "label": "CITY",
2627
+ "text": "Bergen"
2628
+ }
2629
+ ],
2630
+ "expected": "The contact [PERSON] represents [ORGANIZATION] near [CITY]."
2631
+ },
2632
+ {
2633
+ "id": "holdout03c-082",
2634
+ "language": "de",
2635
+ "ocr_noise": false,
2636
+ "features": [
2637
+ "affiliation",
2638
+ "contacts"
2639
+ ],
2640
+ "text": "Alva Rosenqvist nennt jüdisch als Zugehörigkeit. E-Mail: case109.review@example.invalid; Telefon: +49 541 730109.",
2641
+ "spans": [
2642
+ {
2643
+ "start": 0,
2644
+ "end": 15,
2645
+ "label": "PERSON",
2646
+ "text": "Alva Rosenqvist"
2647
+ },
2648
+ {
2649
+ "start": 22,
2650
+ "end": 29,
2651
+ "label": "RELIGION",
2652
+ "text": "jüdisch"
2653
+ },
2654
+ {
2655
+ "start": 57,
2656
+ "end": 87,
2657
+ "label": "EMAIL",
2658
+ "text": "case109.review@example.invalid"
2659
+ },
2660
+ {
2661
+ "start": 98,
2662
+ "end": 112,
2663
+ "label": "PHONE",
2664
+ "text": "+49 541 730109"
2665
+ }
2666
+ ],
2667
+ "expected": "[PERSON] nennt [RELIGION] als Zugehörigkeit. E-Mail: [EMAIL]; Telefon: [PHONE]."
2668
+ },
2669
+ {
2670
+ "id": "holdout03c-083",
2671
+ "language": "en",
2672
+ "ocr_noise": false,
2673
+ "features": [
2674
+ "affiliation",
2675
+ "contacts"
2676
+ ],
2677
+ "text": "Alva Rosenqvist explicitly names Judaism as their faith. Write to case109.review@example.invalid or call +49 541 730109.",
2678
+ "spans": [
2679
+ {
2680
+ "start": 0,
2681
+ "end": 15,
2682
+ "label": "PERSON",
2683
+ "text": "Alva Rosenqvist"
2684
+ },
2685
+ {
2686
+ "start": 33,
2687
+ "end": 40,
2688
+ "label": "RELIGION",
2689
+ "text": "Judaism"
2690
+ },
2691
+ {
2692
+ "start": 66,
2693
+ "end": 96,
2694
+ "label": "EMAIL",
2695
+ "text": "case109.review@example.invalid"
2696
+ },
2697
+ {
2698
+ "start": 105,
2699
+ "end": 119,
2700
+ "label": "PHONE",
2701
+ "text": "+49 541 730109"
2702
+ }
2703
+ ],
2704
+ "expected": "[PERSON] explicitly names [RELIGION] as their faith. Write to [EMAIL] or call [PHONE]."
2705
+ },
2706
+ {
2707
+ "id": "holdout03c-084",
2708
+ "language": "de",
2709
+ "ocr_noise": false,
2710
+ "features": [
2711
+ "repeats",
2712
+ "paragraphs"
2713
+ ],
2714
+ "text": "Alva Rosenqvist besucht Bergen.\n\nAlva Rosenqvist bestätigt den Termin bei Maple Ridge University.",
2715
+ "spans": [
2716
+ {
2717
+ "start": 0,
2718
+ "end": 15,
2719
+ "label": "PERSON",
2720
+ "text": "Alva Rosenqvist"
2721
+ },
2722
+ {
2723
+ "start": 24,
2724
+ "end": 30,
2725
+ "label": "CITY",
2726
+ "text": "Bergen"
2727
+ },
2728
+ {
2729
+ "start": 33,
2730
+ "end": 48,
2731
+ "label": "PERSON",
2732
+ "text": "Alva Rosenqvist"
2733
+ },
2734
+ {
2735
+ "start": 74,
2736
+ "end": 96,
2737
+ "label": "ORGANIZATION",
2738
+ "text": "Maple Ridge University"
2739
+ }
2740
+ ],
2741
+ "expected": "[PERSON] besucht [CITY].\n\n[PERSON] bestätigt den Termin bei [ORGANIZATION]."
2742
+ },
2743
+ {
2744
+ "id": "holdout03c-085",
2745
+ "language": "en",
2746
+ "ocr_noise": false,
2747
+ "features": [
2748
+ "repeats",
2749
+ "ambiguous_geography"
2750
+ ],
2751
+ "text": "In Bergen, Alva Rosenqvist signed for Maple Ridge University. The representative Alva Rosenqvist returned to Bergen.",
2752
+ "spans": [
2753
+ {
2754
+ "start": 3,
2755
+ "end": 9,
2756
+ "label": "CITY",
2757
+ "text": "Bergen"
2758
+ },
2759
+ {
2760
+ "start": 11,
2761
+ "end": 26,
2762
+ "label": "PERSON",
2763
+ "text": "Alva Rosenqvist"
2764
+ },
2765
+ {
2766
+ "start": 38,
2767
+ "end": 60,
2768
+ "label": "ORGANIZATION",
2769
+ "text": "Maple Ridge University"
2770
+ },
2771
+ {
2772
+ "start": 81,
2773
+ "end": 96,
2774
+ "label": "PERSON",
2775
+ "text": "Alva Rosenqvist"
2776
+ },
2777
+ {
2778
+ "start": 109,
2779
+ "end": 115,
2780
+ "label": "CITY",
2781
+ "text": "Bergen"
2782
+ }
2783
+ ],
2784
+ "expected": "In [CITY], [PERSON] signed for [ORGANIZATION]. The representative [PERSON] returned to [CITY]."
2785
+ },
2786
+ {
2787
+ "id": "holdout03c-086",
2788
+ "language": "de",
2789
+ "ocr_noise": true,
2790
+ "features": [
2791
+ "line_wrap",
2792
+ "organization"
2793
+ ],
2794
+ "text": "Name: Alva-\nRosenqvist\nOrt: Ber-\ngen\nArbeitgeber: Maple Ridge University.",
2795
+ "spans": [
2796
+ {
2797
+ "start": 6,
2798
+ "end": 22,
2799
+ "label": "PERSON",
2800
+ "text": "Alva-\nRosenqvist"
2801
+ },
2802
+ {
2803
+ "start": 28,
2804
+ "end": 36,
2805
+ "label": "CITY",
2806
+ "text": "Ber-\ngen"
2807
+ },
2808
+ {
2809
+ "start": 50,
2810
+ "end": 72,
2811
+ "label": "ORGANIZATION",
2812
+ "text": "Maple Ridge University"
2813
+ }
2814
+ ],
2815
+ "expected": "Name: [PERSON]\nOrt: [CITY]\nArbeitgeber: [ORGANIZATION]."
2816
+ },
2817
+ {
2818
+ "id": "holdout03c-087",
2819
+ "language": "en",
2820
+ "ocr_noise": true,
2821
+ "features": [
2822
+ "ocr_spelling",
2823
+ "organization"
2824
+ ],
2825
+ "text": "Scanned record: Alva R0senqv1st resides in Bergen and works at Maple Ridge University.",
2826
+ "spans": [
2827
+ {
2828
+ "start": 16,
2829
+ "end": 31,
2830
+ "label": "PERSON",
2831
+ "text": "Alva R0senqv1st"
2832
+ },
2833
+ {
2834
+ "start": 43,
2835
+ "end": 49,
2836
+ "label": "CITY",
2837
+ "text": "Bergen"
2838
+ },
2839
+ {
2840
+ "start": 63,
2841
+ "end": 85,
2842
+ "label": "ORGANIZATION",
2843
+ "text": "Maple Ridge University"
2844
+ }
2845
+ ],
2846
+ "expected": "Scanned record: [PERSON] resides in [CITY] and works at [ORGANIZATION]."
2847
+ },
2848
+ {
2849
+ "id": "holdout03c-088",
2850
+ "language": "de",
2851
+ "ocr_noise": false,
2852
+ "features": [
2853
+ "religious_reference",
2854
+ "no_inferred_affiliation"
2855
+ ],
2856
+ "text": "Das Thema jüdisch wird in einem Bericht von Maple Ridge University erwähnt. Für Alva Rosenqvist wird keine Religionszugehörigkeit behauptet.",
2857
+ "spans": [
2858
+ {
2859
+ "start": 10,
2860
+ "end": 17,
2861
+ "label": "RELIGION",
2862
+ "text": "jüdisch"
2863
+ },
2864
+ {
2865
+ "start": 44,
2866
+ "end": 66,
2867
+ "label": "ORGANIZATION",
2868
+ "text": "Maple Ridge University"
2869
+ },
2870
+ {
2871
+ "start": 80,
2872
+ "end": 95,
2873
+ "label": "PERSON",
2874
+ "text": "Alva Rosenqvist"
2875
+ }
2876
+ ],
2877
+ "expected": "Das Thema [RELIGION] wird in einem Bericht von [ORGANIZATION] erwähnt. Für [PERSON] wird keine Religionszugehörigkeit behauptet."
2878
+ },
2879
+ {
2880
+ "id": "holdout03c-089",
2881
+ "language": "en",
2882
+ "ocr_noise": false,
2883
+ "features": [
2884
+ "negative",
2885
+ "religious_reference"
2886
+ ],
2887
+ "text": "Worksheet 109: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details.",
2888
+ "spans": [],
2889
+ "expected": "Worksheet 109: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details."
2890
+ },
2891
+ {
2892
+ "id": "holdout03c-090",
2893
+ "language": "de",
2894
+ "ocr_noise": false,
2895
+ "features": [
2896
+ "organization",
2897
+ "geography"
2898
+ ],
2899
+ "text": "Auskunft: Theo Merriweather arbeitet für Polar Systems Ltd. in York.",
2900
+ "spans": [
2901
+ {
2902
+ "start": 10,
2903
+ "end": 27,
2904
+ "label": "PERSON",
2905
+ "text": "Theo Merriweather"
2906
+ },
2907
+ {
2908
+ "start": 41,
2909
+ "end": 59,
2910
+ "label": "ORGANIZATION",
2911
+ "text": "Polar Systems Ltd."
2912
+ },
2913
+ {
2914
+ "start": 63,
2915
+ "end": 67,
2916
+ "label": "CITY",
2917
+ "text": "York"
2918
+ }
2919
+ ],
2920
+ "expected": "Auskunft: [PERSON] arbeitet für [ORGANIZATION] in [CITY]."
2921
+ },
2922
+ {
2923
+ "id": "holdout03c-091",
2924
+ "language": "en",
2925
+ "ocr_noise": false,
2926
+ "features": [
2927
+ "organization",
2928
+ "geography"
2929
+ ],
2930
+ "text": "The contact Theo Merriweather represents Polar Systems Ltd. near York.",
2931
+ "spans": [
2932
+ {
2933
+ "start": 12,
2934
+ "end": 29,
2935
+ "label": "PERSON",
2936
+ "text": "Theo Merriweather"
2937
+ },
2938
+ {
2939
+ "start": 41,
2940
+ "end": 59,
2941
+ "label": "ORGANIZATION",
2942
+ "text": "Polar Systems Ltd."
2943
+ },
2944
+ {
2945
+ "start": 65,
2946
+ "end": 69,
2947
+ "label": "CITY",
2948
+ "text": "York"
2949
+ }
2950
+ ],
2951
+ "expected": "The contact [PERSON] represents [ORGANIZATION] near [CITY]."
2952
+ },
2953
+ {
2954
+ "id": "holdout03c-092",
2955
+ "language": "de",
2956
+ "ocr_noise": false,
2957
+ "features": [
2958
+ "affiliation",
2959
+ "contacts"
2960
+ ],
2961
+ "text": "Theo Merriweather nennt muslimisch als Zugehörigkeit. E-Mail: case110.review@example.invalid; Telefon: +49 541 730110.",
2962
+ "spans": [
2963
+ {
2964
+ "start": 0,
2965
+ "end": 17,
2966
+ "label": "PERSON",
2967
+ "text": "Theo Merriweather"
2968
+ },
2969
+ {
2970
+ "start": 24,
2971
+ "end": 34,
2972
+ "label": "RELIGION",
2973
+ "text": "muslimisch"
2974
+ },
2975
+ {
2976
+ "start": 62,
2977
+ "end": 92,
2978
+ "label": "EMAIL",
2979
+ "text": "case110.review@example.invalid"
2980
+ },
2981
+ {
2982
+ "start": 103,
2983
+ "end": 117,
2984
+ "label": "PHONE",
2985
+ "text": "+49 541 730110"
2986
+ }
2987
+ ],
2988
+ "expected": "[PERSON] nennt [RELIGION] als Zugehörigkeit. E-Mail: [EMAIL]; Telefon: [PHONE]."
2989
+ },
2990
+ {
2991
+ "id": "holdout03c-093",
2992
+ "language": "en",
2993
+ "ocr_noise": false,
2994
+ "features": [
2995
+ "affiliation",
2996
+ "contacts"
2997
+ ],
2998
+ "text": "Theo Merriweather explicitly names Islam as their faith. Write to case110.review@example.invalid or call +49 541 730110.",
2999
+ "spans": [
3000
+ {
3001
+ "start": 0,
3002
+ "end": 17,
3003
+ "label": "PERSON",
3004
+ "text": "Theo Merriweather"
3005
+ },
3006
+ {
3007
+ "start": 35,
3008
+ "end": 40,
3009
+ "label": "RELIGION",
3010
+ "text": "Islam"
3011
+ },
3012
+ {
3013
+ "start": 66,
3014
+ "end": 96,
3015
+ "label": "EMAIL",
3016
+ "text": "case110.review@example.invalid"
3017
+ },
3018
+ {
3019
+ "start": 105,
3020
+ "end": 119,
3021
+ "label": "PHONE",
3022
+ "text": "+49 541 730110"
3023
+ }
3024
+ ],
3025
+ "expected": "[PERSON] explicitly names [RELIGION] as their faith. Write to [EMAIL] or call [PHONE]."
3026
+ },
3027
+ {
3028
+ "id": "holdout03c-094",
3029
+ "language": "de",
3030
+ "ocr_noise": false,
3031
+ "features": [
3032
+ "repeats",
3033
+ "paragraphs"
3034
+ ],
3035
+ "text": "Theo Merriweather besucht York.\n\nTheo Merriweather bestätigt den Termin bei Polar Systems Ltd..",
3036
+ "spans": [
3037
+ {
3038
+ "start": 0,
3039
+ "end": 17,
3040
+ "label": "PERSON",
3041
+ "text": "Theo Merriweather"
3042
+ },
3043
+ {
3044
+ "start": 26,
3045
+ "end": 30,
3046
+ "label": "CITY",
3047
+ "text": "York"
3048
+ },
3049
+ {
3050
+ "start": 33,
3051
+ "end": 50,
3052
+ "label": "PERSON",
3053
+ "text": "Theo Merriweather"
3054
+ },
3055
+ {
3056
+ "start": 76,
3057
+ "end": 94,
3058
+ "label": "ORGANIZATION",
3059
+ "text": "Polar Systems Ltd."
3060
+ }
3061
+ ],
3062
+ "expected": "[PERSON] besucht [CITY].\n\n[PERSON] bestätigt den Termin bei [ORGANIZATION]."
3063
+ },
3064
+ {
3065
+ "id": "holdout03c-095",
3066
+ "language": "en",
3067
+ "ocr_noise": false,
3068
+ "features": [
3069
+ "repeats",
3070
+ "ambiguous_geography"
3071
+ ],
3072
+ "text": "In York, Theo Merriweather signed for Polar Systems Ltd.. The representative Theo Merriweather returned to York.",
3073
+ "spans": [
3074
+ {
3075
+ "start": 3,
3076
+ "end": 7,
3077
+ "label": "CITY",
3078
+ "text": "York"
3079
+ },
3080
+ {
3081
+ "start": 9,
3082
+ "end": 26,
3083
+ "label": "PERSON",
3084
+ "text": "Theo Merriweather"
3085
+ },
3086
+ {
3087
+ "start": 38,
3088
+ "end": 56,
3089
+ "label": "ORGANIZATION",
3090
+ "text": "Polar Systems Ltd."
3091
+ },
3092
+ {
3093
+ "start": 77,
3094
+ "end": 94,
3095
+ "label": "PERSON",
3096
+ "text": "Theo Merriweather"
3097
+ },
3098
+ {
3099
+ "start": 107,
3100
+ "end": 111,
3101
+ "label": "CITY",
3102
+ "text": "York"
3103
+ }
3104
+ ],
3105
+ "expected": "In [CITY], [PERSON] signed for [ORGANIZATION]. The representative [PERSON] returned to [CITY]."
3106
+ },
3107
+ {
3108
+ "id": "holdout03c-096",
3109
+ "language": "de",
3110
+ "ocr_noise": true,
3111
+ "features": [
3112
+ "line_wrap",
3113
+ "organization"
3114
+ ],
3115
+ "text": "Name: Theo-\nMerriweather\nOrt: Yo-\nrk\nArbeitgeber: Polar Systems Ltd..",
3116
+ "spans": [
3117
+ {
3118
+ "start": 6,
3119
+ "end": 24,
3120
+ "label": "PERSON",
3121
+ "text": "Theo-\nMerriweather"
3122
+ },
3123
+ {
3124
+ "start": 30,
3125
+ "end": 36,
3126
+ "label": "CITY",
3127
+ "text": "Yo-\nrk"
3128
+ },
3129
+ {
3130
+ "start": 50,
3131
+ "end": 68,
3132
+ "label": "ORGANIZATION",
3133
+ "text": "Polar Systems Ltd."
3134
+ }
3135
+ ],
3136
+ "expected": "Name: [PERSON]\nOrt: [CITY]\nArbeitgeber: [ORGANIZATION]."
3137
+ },
3138
+ {
3139
+ "id": "holdout03c-097",
3140
+ "language": "en",
3141
+ "ocr_noise": true,
3142
+ "features": [
3143
+ "ocr_spelling",
3144
+ "organization"
3145
+ ],
3146
+ "text": "Scanned record: The0 Merr1weather resides in Y0rk and works at Polar Systems Ltd..",
3147
+ "spans": [
3148
+ {
3149
+ "start": 16,
3150
+ "end": 33,
3151
+ "label": "PERSON",
3152
+ "text": "The0 Merr1weather"
3153
+ },
3154
+ {
3155
+ "start": 45,
3156
+ "end": 49,
3157
+ "label": "CITY",
3158
+ "text": "Y0rk"
3159
+ },
3160
+ {
3161
+ "start": 63,
3162
+ "end": 81,
3163
+ "label": "ORGANIZATION",
3164
+ "text": "Polar Systems Ltd."
3165
+ }
3166
+ ],
3167
+ "expected": "Scanned record: [PERSON] resides in [CITY] and works at [ORGANIZATION]."
3168
+ },
3169
+ {
3170
+ "id": "holdout03c-098",
3171
+ "language": "de",
3172
+ "ocr_noise": false,
3173
+ "features": [
3174
+ "religious_reference",
3175
+ "no_inferred_affiliation"
3176
+ ],
3177
+ "text": "Das Thema muslimisch wird in einem Bericht von Polar Systems Ltd. erwähnt. Für Theo Merriweather wird keine Religionszugehörigkeit behauptet.",
3178
+ "spans": [
3179
+ {
3180
+ "start": 10,
3181
+ "end": 20,
3182
+ "label": "RELIGION",
3183
+ "text": "muslimisch"
3184
+ },
3185
+ {
3186
+ "start": 47,
3187
+ "end": 65,
3188
+ "label": "ORGANIZATION",
3189
+ "text": "Polar Systems Ltd."
3190
+ },
3191
+ {
3192
+ "start": 79,
3193
+ "end": 96,
3194
+ "label": "PERSON",
3195
+ "text": "Theo Merriweather"
3196
+ }
3197
+ ],
3198
+ "expected": "Das Thema [RELIGION] wird in einem Bericht von [ORGANIZATION] erwähnt. Für [PERSON] wird keine Religionszugehörigkeit behauptet."
3199
+ },
3200
+ {
3201
+ "id": "holdout03c-099",
3202
+ "language": "en",
3203
+ "ocr_noise": false,
3204
+ "features": [
3205
+ "negative",
3206
+ "religious_reference"
3207
+ ],
3208
+ "text": "Worksheet 110: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details.",
3209
+ "spans": [],
3210
+ "expected": "Worksheet 110: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details."
3211
+ },
3212
+ {
3213
+ "id": "holdout03c-100",
3214
+ "language": "de",
3215
+ "ocr_noise": false,
3216
+ "features": [
3217
+ "organization",
3218
+ "geography"
3219
+ ],
3220
+ "text": "Auskunft: Maja Kirschbaum arbeitet für Sonnenbogen Verein in Lüneburg.",
3221
+ "spans": [
3222
+ {
3223
+ "start": 10,
3224
+ "end": 25,
3225
+ "label": "PERSON",
3226
+ "text": "Maja Kirschbaum"
3227
+ },
3228
+ {
3229
+ "start": 39,
3230
+ "end": 57,
3231
+ "label": "ORGANIZATION",
3232
+ "text": "Sonnenbogen Verein"
3233
+ },
3234
+ {
3235
+ "start": 61,
3236
+ "end": 69,
3237
+ "label": "CITY",
3238
+ "text": "Lüneburg"
3239
+ }
3240
+ ],
3241
+ "expected": "Auskunft: [PERSON] arbeitet für [ORGANIZATION] in [CITY]."
3242
+ },
3243
+ {
3244
+ "id": "holdout03c-101",
3245
+ "language": "en",
3246
+ "ocr_noise": false,
3247
+ "features": [
3248
+ "organization",
3249
+ "geography"
3250
+ ],
3251
+ "text": "The contact Maja Kirschbaum represents Sonnenbogen Verein near Lüneburg.",
3252
+ "spans": [
3253
+ {
3254
+ "start": 12,
3255
+ "end": 27,
3256
+ "label": "PERSON",
3257
+ "text": "Maja Kirschbaum"
3258
+ },
3259
+ {
3260
+ "start": 39,
3261
+ "end": 57,
3262
+ "label": "ORGANIZATION",
3263
+ "text": "Sonnenbogen Verein"
3264
+ },
3265
+ {
3266
+ "start": 63,
3267
+ "end": 71,
3268
+ "label": "CITY",
3269
+ "text": "Lüneburg"
3270
+ }
3271
+ ],
3272
+ "expected": "The contact [PERSON] represents [ORGANIZATION] near [CITY]."
3273
+ },
3274
+ {
3275
+ "id": "holdout03c-102",
3276
+ "language": "de",
3277
+ "ocr_noise": false,
3278
+ "features": [
3279
+ "affiliation",
3280
+ "contacts"
3281
+ ],
3282
+ "text": "Maja Kirschbaum nennt konfessionslos als Zugehörigkeit. E-Mail: case111.review@example.invalid; Telefon: +49 541 730111.",
3283
+ "spans": [
3284
+ {
3285
+ "start": 0,
3286
+ "end": 15,
3287
+ "label": "PERSON",
3288
+ "text": "Maja Kirschbaum"
3289
+ },
3290
+ {
3291
+ "start": 22,
3292
+ "end": 36,
3293
+ "label": "RELIGION",
3294
+ "text": "konfessionslos"
3295
+ },
3296
+ {
3297
+ "start": 64,
3298
+ "end": 94,
3299
+ "label": "EMAIL",
3300
+ "text": "case111.review@example.invalid"
3301
+ },
3302
+ {
3303
+ "start": 105,
3304
+ "end": 119,
3305
+ "label": "PHONE",
3306
+ "text": "+49 541 730111"
3307
+ }
3308
+ ],
3309
+ "expected": "[PERSON] nennt [RELIGION] als Zugehörigkeit. E-Mail: [EMAIL]; Telefon: [PHONE]."
3310
+ },
3311
+ {
3312
+ "id": "holdout03c-103",
3313
+ "language": "en",
3314
+ "ocr_noise": false,
3315
+ "features": [
3316
+ "affiliation",
3317
+ "contacts"
3318
+ ],
3319
+ "text": "Maja Kirschbaum explicitly names atheism as their faith. Write to case111.review@example.invalid or call +49 541 730111.",
3320
+ "spans": [
3321
+ {
3322
+ "start": 0,
3323
+ "end": 15,
3324
+ "label": "PERSON",
3325
+ "text": "Maja Kirschbaum"
3326
+ },
3327
+ {
3328
+ "start": 33,
3329
+ "end": 40,
3330
+ "label": "RELIGION",
3331
+ "text": "atheism"
3332
+ },
3333
+ {
3334
+ "start": 66,
3335
+ "end": 96,
3336
+ "label": "EMAIL",
3337
+ "text": "case111.review@example.invalid"
3338
+ },
3339
+ {
3340
+ "start": 105,
3341
+ "end": 119,
3342
+ "label": "PHONE",
3343
+ "text": "+49 541 730111"
3344
+ }
3345
+ ],
3346
+ "expected": "[PERSON] explicitly names [RELIGION] as their faith. Write to [EMAIL] or call [PHONE]."
3347
+ },
3348
+ {
3349
+ "id": "holdout03c-104",
3350
+ "language": "de",
3351
+ "ocr_noise": false,
3352
+ "features": [
3353
+ "repeats",
3354
+ "paragraphs"
3355
+ ],
3356
+ "text": "Maja Kirschbaum besucht Lüneburg.\n\nMaja Kirschbaum bestätigt den Termin bei Sonnenbogen Verein.",
3357
+ "spans": [
3358
+ {
3359
+ "start": 0,
3360
+ "end": 15,
3361
+ "label": "PERSON",
3362
+ "text": "Maja Kirschbaum"
3363
+ },
3364
+ {
3365
+ "start": 24,
3366
+ "end": 32,
3367
+ "label": "CITY",
3368
+ "text": "Lüneburg"
3369
+ },
3370
+ {
3371
+ "start": 35,
3372
+ "end": 50,
3373
+ "label": "PERSON",
3374
+ "text": "Maja Kirschbaum"
3375
+ },
3376
+ {
3377
+ "start": 76,
3378
+ "end": 94,
3379
+ "label": "ORGANIZATION",
3380
+ "text": "Sonnenbogen Verein"
3381
+ }
3382
+ ],
3383
+ "expected": "[PERSON] besucht [CITY].\n\n[PERSON] bestätigt den Termin bei [ORGANIZATION]."
3384
+ },
3385
+ {
3386
+ "id": "holdout03c-105",
3387
+ "language": "en",
3388
+ "ocr_noise": false,
3389
+ "features": [
3390
+ "repeats",
3391
+ "ambiguous_geography"
3392
+ ],
3393
+ "text": "In Lüneburg, Maja Kirschbaum signed for Sonnenbogen Verein. The representative Maja Kirschbaum returned to Lüneburg.",
3394
+ "spans": [
3395
+ {
3396
+ "start": 3,
3397
+ "end": 11,
3398
+ "label": "CITY",
3399
+ "text": "Lüneburg"
3400
+ },
3401
+ {
3402
+ "start": 13,
3403
+ "end": 28,
3404
+ "label": "PERSON",
3405
+ "text": "Maja Kirschbaum"
3406
+ },
3407
+ {
3408
+ "start": 40,
3409
+ "end": 58,
3410
+ "label": "ORGANIZATION",
3411
+ "text": "Sonnenbogen Verein"
3412
+ },
3413
+ {
3414
+ "start": 79,
3415
+ "end": 94,
3416
+ "label": "PERSON",
3417
+ "text": "Maja Kirschbaum"
3418
+ },
3419
+ {
3420
+ "start": 107,
3421
+ "end": 115,
3422
+ "label": "CITY",
3423
+ "text": "Lüneburg"
3424
+ }
3425
+ ],
3426
+ "expected": "In [CITY], [PERSON] signed for [ORGANIZATION]. The representative [PERSON] returned to [CITY]."
3427
+ },
3428
+ {
3429
+ "id": "holdout03c-106",
3430
+ "language": "de",
3431
+ "ocr_noise": true,
3432
+ "features": [
3433
+ "line_wrap",
3434
+ "organization"
3435
+ ],
3436
+ "text": "Name: Maja-\nKirschbaum\nOrt: Lüne-\nburg\nArbeitgeber: Sonnenbogen Verein.",
3437
+ "spans": [
3438
+ {
3439
+ "start": 6,
3440
+ "end": 22,
3441
+ "label": "PERSON",
3442
+ "text": "Maja-\nKirschbaum"
3443
+ },
3444
+ {
3445
+ "start": 28,
3446
+ "end": 38,
3447
+ "label": "CITY",
3448
+ "text": "Lüne-\nburg"
3449
+ },
3450
+ {
3451
+ "start": 52,
3452
+ "end": 70,
3453
+ "label": "ORGANIZATION",
3454
+ "text": "Sonnenbogen Verein"
3455
+ }
3456
+ ],
3457
+ "expected": "Name: [PERSON]\nOrt: [CITY]\nArbeitgeber: [ORGANIZATION]."
3458
+ },
3459
+ {
3460
+ "id": "holdout03c-107",
3461
+ "language": "en",
3462
+ "ocr_noise": true,
3463
+ "features": [
3464
+ "ocr_spelling",
3465
+ "organization"
3466
+ ],
3467
+ "text": "Scanned record: Maja K1rschbaum resides in Lüneburg and works at Sonnenbogen Verein.",
3468
+ "spans": [
3469
+ {
3470
+ "start": 16,
3471
+ "end": 31,
3472
+ "label": "PERSON",
3473
+ "text": "Maja K1rschbaum"
3474
+ },
3475
+ {
3476
+ "start": 43,
3477
+ "end": 51,
3478
+ "label": "CITY",
3479
+ "text": "Lüneburg"
3480
+ },
3481
+ {
3482
+ "start": 65,
3483
+ "end": 83,
3484
+ "label": "ORGANIZATION",
3485
+ "text": "Sonnenbogen Verein"
3486
+ }
3487
+ ],
3488
+ "expected": "Scanned record: [PERSON] resides in [CITY] and works at [ORGANIZATION]."
3489
+ },
3490
+ {
3491
+ "id": "holdout03c-108",
3492
+ "language": "de",
3493
+ "ocr_noise": false,
3494
+ "features": [
3495
+ "religious_reference",
3496
+ "no_inferred_affiliation"
3497
+ ],
3498
+ "text": "Das Thema konfessionslos wird in einem Bericht von Sonnenbogen Verein erwähnt. Für Maja Kirschbaum wird keine Religionszugehörigkeit behauptet.",
3499
+ "spans": [
3500
+ {
3501
+ "start": 10,
3502
+ "end": 24,
3503
+ "label": "RELIGION",
3504
+ "text": "konfessionslos"
3505
+ },
3506
+ {
3507
+ "start": 51,
3508
+ "end": 69,
3509
+ "label": "ORGANIZATION",
3510
+ "text": "Sonnenbogen Verein"
3511
+ },
3512
+ {
3513
+ "start": 83,
3514
+ "end": 98,
3515
+ "label": "PERSON",
3516
+ "text": "Maja Kirschbaum"
3517
+ }
3518
+ ],
3519
+ "expected": "Das Thema [RELIGION] wird in einem Bericht von [ORGANIZATION] erwähnt. Für [PERSON] wird keine Religionszugehörigkeit behauptet."
3520
+ },
3521
+ {
3522
+ "id": "holdout03c-109",
3523
+ "language": "en",
3524
+ "ocr_noise": false,
3525
+ "features": [
3526
+ "negative",
3527
+ "religious_reference"
3528
+ ],
3529
+ "text": "Worksheet 111: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details.",
3530
+ "spans": [],
3531
+ "expected": "Worksheet 111: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details."
3532
+ },
3533
+ {
3534
+ "id": "holdout03c-110",
3535
+ "language": "de",
3536
+ "ocr_noise": false,
3537
+ "features": [
3538
+ "organization",
3539
+ "geography"
3540
+ ],
3541
+ "text": "Auskunft: Ronan Whitmore arbeitet für Beacon Valley Labs in Bath.",
3542
+ "spans": [
3543
+ {
3544
+ "start": 10,
3545
+ "end": 24,
3546
+ "label": "PERSON",
3547
+ "text": "Ronan Whitmore"
3548
+ },
3549
+ {
3550
+ "start": 38,
3551
+ "end": 56,
3552
+ "label": "ORGANIZATION",
3553
+ "text": "Beacon Valley Labs"
3554
+ },
3555
+ {
3556
+ "start": 60,
3557
+ "end": 64,
3558
+ "label": "CITY",
3559
+ "text": "Bath"
3560
+ }
3561
+ ],
3562
+ "expected": "Auskunft: [PERSON] arbeitet für [ORGANIZATION] in [CITY]."
3563
+ },
3564
+ {
3565
+ "id": "holdout03c-111",
3566
+ "language": "en",
3567
+ "ocr_noise": false,
3568
+ "features": [
3569
+ "organization",
3570
+ "geography"
3571
+ ],
3572
+ "text": "The contact Ronan Whitmore represents Beacon Valley Labs near Bath.",
3573
+ "spans": [
3574
+ {
3575
+ "start": 12,
3576
+ "end": 26,
3577
+ "label": "PERSON",
3578
+ "text": "Ronan Whitmore"
3579
+ },
3580
+ {
3581
+ "start": 38,
3582
+ "end": 56,
3583
+ "label": "ORGANIZATION",
3584
+ "text": "Beacon Valley Labs"
3585
+ },
3586
+ {
3587
+ "start": 62,
3588
+ "end": 66,
3589
+ "label": "CITY",
3590
+ "text": "Bath"
3591
+ }
3592
+ ],
3593
+ "expected": "The contact [PERSON] represents [ORGANIZATION] near [CITY]."
3594
+ },
3595
+ {
3596
+ "id": "holdout03c-112",
3597
+ "language": "de",
3598
+ "ocr_noise": false,
3599
+ "features": [
3600
+ "affiliation",
3601
+ "contacts"
3602
+ ],
3603
+ "text": "Ronan Whitmore nennt christlich als Zugehörigkeit. E-Mail: case112.review@example.invalid; Telefon: +49 541 730112.",
3604
+ "spans": [
3605
+ {
3606
+ "start": 0,
3607
+ "end": 14,
3608
+ "label": "PERSON",
3609
+ "text": "Ronan Whitmore"
3610
+ },
3611
+ {
3612
+ "start": 21,
3613
+ "end": 31,
3614
+ "label": "RELIGION",
3615
+ "text": "christlich"
3616
+ },
3617
+ {
3618
+ "start": 59,
3619
+ "end": 89,
3620
+ "label": "EMAIL",
3621
+ "text": "case112.review@example.invalid"
3622
+ },
3623
+ {
3624
+ "start": 100,
3625
+ "end": 114,
3626
+ "label": "PHONE",
3627
+ "text": "+49 541 730112"
3628
+ }
3629
+ ],
3630
+ "expected": "[PERSON] nennt [RELIGION] als Zugehörigkeit. E-Mail: [EMAIL]; Telefon: [PHONE]."
3631
+ },
3632
+ {
3633
+ "id": "holdout03c-113",
3634
+ "language": "en",
3635
+ "ocr_noise": false,
3636
+ "features": [
3637
+ "affiliation",
3638
+ "contacts"
3639
+ ],
3640
+ "text": "Ronan Whitmore explicitly names Christianity as their faith. Write to case112.review@example.invalid or call +49 541 730112.",
3641
+ "spans": [
3642
+ {
3643
+ "start": 0,
3644
+ "end": 14,
3645
+ "label": "PERSON",
3646
+ "text": "Ronan Whitmore"
3647
+ },
3648
+ {
3649
+ "start": 32,
3650
+ "end": 44,
3651
+ "label": "RELIGION",
3652
+ "text": "Christianity"
3653
+ },
3654
+ {
3655
+ "start": 70,
3656
+ "end": 100,
3657
+ "label": "EMAIL",
3658
+ "text": "case112.review@example.invalid"
3659
+ },
3660
+ {
3661
+ "start": 109,
3662
+ "end": 123,
3663
+ "label": "PHONE",
3664
+ "text": "+49 541 730112"
3665
+ }
3666
+ ],
3667
+ "expected": "[PERSON] explicitly names [RELIGION] as their faith. Write to [EMAIL] or call [PHONE]."
3668
+ },
3669
+ {
3670
+ "id": "holdout03c-114",
3671
+ "language": "de",
3672
+ "ocr_noise": false,
3673
+ "features": [
3674
+ "repeats",
3675
+ "paragraphs"
3676
+ ],
3677
+ "text": "Ronan Whitmore besucht Bath.\n\nRonan Whitmore bestätigt den Termin bei Beacon Valley Labs.",
3678
+ "spans": [
3679
+ {
3680
+ "start": 0,
3681
+ "end": 14,
3682
+ "label": "PERSON",
3683
+ "text": "Ronan Whitmore"
3684
+ },
3685
+ {
3686
+ "start": 23,
3687
+ "end": 27,
3688
+ "label": "CITY",
3689
+ "text": "Bath"
3690
+ },
3691
+ {
3692
+ "start": 30,
3693
+ "end": 44,
3694
+ "label": "PERSON",
3695
+ "text": "Ronan Whitmore"
3696
+ },
3697
+ {
3698
+ "start": 70,
3699
+ "end": 88,
3700
+ "label": "ORGANIZATION",
3701
+ "text": "Beacon Valley Labs"
3702
+ }
3703
+ ],
3704
+ "expected": "[PERSON] besucht [CITY].\n\n[PERSON] bestätigt den Termin bei [ORGANIZATION]."
3705
+ },
3706
+ {
3707
+ "id": "holdout03c-115",
3708
+ "language": "en",
3709
+ "ocr_noise": false,
3710
+ "features": [
3711
+ "repeats",
3712
+ "ambiguous_geography"
3713
+ ],
3714
+ "text": "In Bath, Ronan Whitmore signed for Beacon Valley Labs. The representative Ronan Whitmore returned to Bath.",
3715
+ "spans": [
3716
+ {
3717
+ "start": 3,
3718
+ "end": 7,
3719
+ "label": "CITY",
3720
+ "text": "Bath"
3721
+ },
3722
+ {
3723
+ "start": 9,
3724
+ "end": 23,
3725
+ "label": "PERSON",
3726
+ "text": "Ronan Whitmore"
3727
+ },
3728
+ {
3729
+ "start": 35,
3730
+ "end": 53,
3731
+ "label": "ORGANIZATION",
3732
+ "text": "Beacon Valley Labs"
3733
+ },
3734
+ {
3735
+ "start": 74,
3736
+ "end": 88,
3737
+ "label": "PERSON",
3738
+ "text": "Ronan Whitmore"
3739
+ },
3740
+ {
3741
+ "start": 101,
3742
+ "end": 105,
3743
+ "label": "CITY",
3744
+ "text": "Bath"
3745
+ }
3746
+ ],
3747
+ "expected": "In [CITY], [PERSON] signed for [ORGANIZATION]. The representative [PERSON] returned to [CITY]."
3748
+ },
3749
+ {
3750
+ "id": "holdout03c-116",
3751
+ "language": "de",
3752
+ "ocr_noise": true,
3753
+ "features": [
3754
+ "line_wrap",
3755
+ "organization"
3756
+ ],
3757
+ "text": "Name: Ronan-\nWhitmore\nOrt: Ba-\nth\nArbeitgeber: Beacon Valley Labs.",
3758
+ "spans": [
3759
+ {
3760
+ "start": 6,
3761
+ "end": 21,
3762
+ "label": "PERSON",
3763
+ "text": "Ronan-\nWhitmore"
3764
+ },
3765
+ {
3766
+ "start": 27,
3767
+ "end": 33,
3768
+ "label": "CITY",
3769
+ "text": "Ba-\nth"
3770
+ },
3771
+ {
3772
+ "start": 47,
3773
+ "end": 65,
3774
+ "label": "ORGANIZATION",
3775
+ "text": "Beacon Valley Labs"
3776
+ }
3777
+ ],
3778
+ "expected": "Name: [PERSON]\nOrt: [CITY]\nArbeitgeber: [ORGANIZATION]."
3779
+ },
3780
+ {
3781
+ "id": "holdout03c-117",
3782
+ "language": "en",
3783
+ "ocr_noise": true,
3784
+ "features": [
3785
+ "ocr_spelling",
3786
+ "organization"
3787
+ ],
3788
+ "text": "Scanned record: R0nan Wh1tm0re resides in B4th and works at Beacon Valley Labs.",
3789
+ "spans": [
3790
+ {
3791
+ "start": 16,
3792
+ "end": 30,
3793
+ "label": "PERSON",
3794
+ "text": "R0nan Wh1tm0re"
3795
+ },
3796
+ {
3797
+ "start": 42,
3798
+ "end": 46,
3799
+ "label": "CITY",
3800
+ "text": "B4th"
3801
+ },
3802
+ {
3803
+ "start": 60,
3804
+ "end": 78,
3805
+ "label": "ORGANIZATION",
3806
+ "text": "Beacon Valley Labs"
3807
+ }
3808
+ ],
3809
+ "expected": "Scanned record: [PERSON] resides in [CITY] and works at [ORGANIZATION]."
3810
+ },
3811
+ {
3812
+ "id": "holdout03c-118",
3813
+ "language": "de",
3814
+ "ocr_noise": false,
3815
+ "features": [
3816
+ "religious_reference",
3817
+ "no_inferred_affiliation"
3818
+ ],
3819
+ "text": "Das Thema christlich wird in einem Bericht von Beacon Valley Labs erwähnt. Für Ronan Whitmore wird keine Religionszugehörigkeit behauptet.",
3820
+ "spans": [
3821
+ {
3822
+ "start": 10,
3823
+ "end": 20,
3824
+ "label": "RELIGION",
3825
+ "text": "christlich"
3826
+ },
3827
+ {
3828
+ "start": 47,
3829
+ "end": 65,
3830
+ "label": "ORGANIZATION",
3831
+ "text": "Beacon Valley Labs"
3832
+ },
3833
+ {
3834
+ "start": 79,
3835
+ "end": 93,
3836
+ "label": "PERSON",
3837
+ "text": "Ronan Whitmore"
3838
+ }
3839
+ ],
3840
+ "expected": "Das Thema [RELIGION] wird in einem Bericht von [ORGANIZATION] erwähnt. Für [PERSON] wird keine Religionszugehörigkeit behauptet."
3841
+ },
3842
+ {
3843
+ "id": "holdout03c-119",
3844
+ "language": "en",
3845
+ "ocr_noise": false,
3846
+ "features": [
3847
+ "negative",
3848
+ "religious_reference"
3849
+ ],
3850
+ "text": "Worksheet 112: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details.",
3851
+ "spans": [],
3852
+ "expected": "Worksheet 112: a name alone establishes no religious affiliation. The plain record contains no named person, place, organization or contact details."
3853
+ }
3854
+ ]
ocr/anonymization/fixtures/holdout03c/freeze.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": "frozen-holdout/v1",
3
+ "cases": 120,
4
+ "sha256": "0174fb41d34d0a4db18877c68fb70149eaa67c05da86608d5da4ff8765a52c15",
5
+ "source_head": "176176a6e13dfb1deb1a6f197b7fa46515b0e1f6",
6
+ "model_revision": "ba967ee030b7e679dd5422cb16fcd1b379da38e1",
7
+ "control_prompts": {
8
+ "PERSON": "person",
9
+ "CITY": "location",
10
+ "RELIGION": "religion",
11
+ "EMAIL": "email address",
12
+ "PHONE": "phone number",
13
+ "ORGANIZATION": "organization"
14
+ },
15
+ "threshold": 0.5,
16
+ "evaluation_run_before_freeze": false,
17
+ "design": "12 novel entity sets x 10 bilingual contexts; 60 de / 60 en; 24 OCR-noise cases. Synthetic/template-based, not a population sample. No holdout-driven tuning permitted before first recorded result."
18
+ }
ocr/anonymization/fixtures/holdout03d/README.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gate 03d independent qualification corpus
2
+
3
+ Frozen before first model evaluation. 160 individually authored synthetic cases,
4
+ 80 German and 80 English, in ten strata of sixteen: explicit affiliation,
5
+ non-affiliation, belief names, general topics, negation/quotation, contextual
6
+ ambiguity, OCR corruption, multiple entities, varied formats/repetition and
7
+ negative controls. No real personal data. These are authored by the implementing
8
+ agent, not an externally blinded or population-representative sample.
9
+
10
+ Gold spans protect religious terminology and explicit status phrases as text;
11
+ they do not assert any person's religion. Religion names in general topics,
12
+ quotations and negations are protected. Status phrases include their negation
13
+ where the phrase expresses non-affiliation. Ambiguous adjectives in a clearly
14
+ religious context are protected; mathematical/marketing metaphors are negatives.
15
+ Deliberately corrupted OCR spellings retain their exact source spans. Person,
16
+ geography, organization and contact spans use the existing category meanings.
17
+
18
+ `cases.json` and `freeze.json` are the immutable first-evaluation inputs. No model
19
+ inference was used to create or annotate them. The new configuration was selected
20
+ using only the previous 40+10+120 development cases. The first result must be kept
21
+ regardless of leakage, false positives or blocked documents. Once inspected,
22
+ this corpus is development evidence for any future tuning. A zero-canary finite
23
+ sample cannot establish universal privacy safety. Automatic release stays off.
24
+
25
+ Reproduce in a checkout of the recorded freeze commit, using the pinned locked
26
+ CPU dependencies and independently integrity-verified local model cache:
27
+
28
+ ```sh
29
+ HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 HF_HUB_DISABLE_TELEMETRY=1 \
30
+ python -m ocr.anonymization.qualify03d \
31
+ --cache /private/model-cache --output /private/new-qualification-result.json
32
+ ```
33
+
34
+ The command refuses to overwrite an existing result and returns nonzero on a
35
+ failed recognition gate, after saving the full first result and per-case evidence.
ocr/anonymization/fixtures/holdout03d/cases.json ADDED
@@ -0,0 +1,2730 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "holdout03d-001",
4
+ "language": "de",
5
+ "scenario": "affiliation",
6
+ "ocr_noise": false,
7
+ "text": "Im Aufnahmebogen steht: Bekenntnis römisch-katholisch.",
8
+ "spans": [
9
+ {
10
+ "start": 35,
11
+ "end": 53,
12
+ "label": "RELIGION",
13
+ "text": "römisch-katholisch"
14
+ }
15
+ ],
16
+ "expected": "Im Aufnahmebogen steht: Bekenntnis [RELIGION]."
17
+ },
18
+ {
19
+ "id": "holdout03d-002",
20
+ "language": "en",
21
+ "scenario": "affiliation",
22
+ "ocr_noise": false,
23
+ "text": "Faith field on the admission card: Roman Catholic.",
24
+ "spans": [
25
+ {
26
+ "start": 35,
27
+ "end": 49,
28
+ "label": "RELIGION",
29
+ "text": "Roman Catholic"
30
+ }
31
+ ],
32
+ "expected": "Faith field on the admission card: [RELIGION]."
33
+ },
34
+ {
35
+ "id": "holdout03d-003",
36
+ "language": "de",
37
+ "scenario": "affiliation",
38
+ "ocr_noise": false,
39
+ "text": "Tabea Hornfels beschreibt ihre Tradition als sunnitisch.",
40
+ "spans": [
41
+ {
42
+ "start": 0,
43
+ "end": 14,
44
+ "label": "PERSON",
45
+ "text": "Tabea Hornfels"
46
+ },
47
+ {
48
+ "start": 45,
49
+ "end": 55,
50
+ "label": "RELIGION",
51
+ "text": "sunnitisch"
52
+ }
53
+ ],
54
+ "expected": "[PERSON] beschreibt ihre Tradition als [RELIGION]."
55
+ },
56
+ {
57
+ "id": "holdout03d-004",
58
+ "language": "en",
59
+ "scenario": "affiliation",
60
+ "ocr_noise": false,
61
+ "text": "Ewan Fairbrook identifies as Shia Muslim in the consent note.",
62
+ "spans": [
63
+ {
64
+ "start": 0,
65
+ "end": 14,
66
+ "label": "PERSON",
67
+ "text": "Ewan Fairbrook"
68
+ },
69
+ {
70
+ "start": 29,
71
+ "end": 40,
72
+ "label": "RELIGION",
73
+ "text": "Shia Muslim"
74
+ }
75
+ ],
76
+ "expected": "[PERSON] identifies as [RELIGION] in the consent note."
77
+ },
78
+ {
79
+ "id": "holdout03d-005",
80
+ "language": "de",
81
+ "scenario": "affiliation",
82
+ "ocr_noise": false,
83
+ "text": "Als Glaubensrichtung wurde Jesidentum eingetragen.",
84
+ "spans": [
85
+ {
86
+ "start": 27,
87
+ "end": 37,
88
+ "label": "RELIGION",
89
+ "text": "Jesidentum"
90
+ }
91
+ ],
92
+ "expected": "Als Glaubensrichtung wurde [RELIGION] eingetragen."
93
+ },
94
+ {
95
+ "id": "holdout03d-006",
96
+ "language": "en",
97
+ "scenario": "affiliation",
98
+ "ocr_noise": false,
99
+ "text": "The registrant selected Quaker under faith community.",
100
+ "spans": [
101
+ {
102
+ "start": 24,
103
+ "end": 30,
104
+ "label": "RELIGION",
105
+ "text": "Quaker"
106
+ }
107
+ ],
108
+ "expected": "The registrant selected [RELIGION] under faith community."
109
+ },
110
+ {
111
+ "id": "holdout03d-007",
112
+ "language": "de",
113
+ "scenario": "affiliation",
114
+ "ocr_noise": false,
115
+ "text": "Religiöse Selbstauskunft: Sikhismus; Veröffentlichung gesperrt.",
116
+ "spans": [
117
+ {
118
+ "start": 26,
119
+ "end": 35,
120
+ "label": "RELIGION",
121
+ "text": "Sikhismus"
122
+ }
123
+ ],
124
+ "expected": "Religiöse Selbstauskunft: [RELIGION]; Veröffentlichung gesperrt."
125
+ },
126
+ {
127
+ "id": "holdout03d-008",
128
+ "language": "en",
129
+ "scenario": "affiliation",
130
+ "ocr_noise": false,
131
+ "text": "The private survey records Hinduism as the participant's belief.",
132
+ "spans": [
133
+ {
134
+ "start": 27,
135
+ "end": 35,
136
+ "label": "RELIGION",
137
+ "text": "Hinduism"
138
+ }
139
+ ],
140
+ "expected": "The private survey records [RELIGION] as the participant's belief."
141
+ },
142
+ {
143
+ "id": "holdout03d-009",
144
+ "language": "de",
145
+ "scenario": "affiliation",
146
+ "ocr_noise": false,
147
+ "text": "Die Person gehört nach eigener Auskunft dem Judentum an.",
148
+ "spans": [
149
+ {
150
+ "start": 44,
151
+ "end": 52,
152
+ "label": "RELIGION",
153
+ "text": "Judentum"
154
+ }
155
+ ],
156
+ "expected": "Die Person gehört nach eigener Auskunft dem [RELIGION] an."
157
+ },
158
+ {
159
+ "id": "holdout03d-010",
160
+ "language": "en",
161
+ "scenario": "affiliation",
162
+ "ocr_noise": false,
163
+ "text": "The speaker described her religion as Buddhism.",
164
+ "spans": [
165
+ {
166
+ "start": 38,
167
+ "end": 46,
168
+ "label": "RELIGION",
169
+ "text": "Buddhism"
170
+ }
171
+ ],
172
+ "expected": "The speaker described her religion as [RELIGION]."
173
+ },
174
+ {
175
+ "id": "holdout03d-011",
176
+ "language": "de",
177
+ "scenario": "affiliation",
178
+ "ocr_noise": false,
179
+ "text": "Auf Seite zwei lautet die Angabe zur Religion Bahai.",
180
+ "spans": [
181
+ {
182
+ "start": 46,
183
+ "end": 51,
184
+ "label": "RELIGION",
185
+ "text": "Bahai"
186
+ }
187
+ ],
188
+ "expected": "Auf Seite zwei lautet die Angabe zur Religion [RELIGION]."
189
+ },
190
+ {
191
+ "id": "holdout03d-012",
192
+ "language": "en",
193
+ "scenario": "affiliation",
194
+ "ocr_noise": false,
195
+ "text": "In a handwritten footnote the applicant wrote Jainism.",
196
+ "spans": [
197
+ {
198
+ "start": 46,
199
+ "end": 53,
200
+ "label": "RELIGION",
201
+ "text": "Jainism"
202
+ }
203
+ ],
204
+ "expected": "In a handwritten footnote the applicant wrote [RELIGION]."
205
+ },
206
+ {
207
+ "id": "holdout03d-013",
208
+ "language": "de",
209
+ "scenario": "affiliation",
210
+ "ocr_noise": false,
211
+ "text": "Konfession des Antragstellers: evangelisch-lutherisch.",
212
+ "spans": [
213
+ {
214
+ "start": 31,
215
+ "end": 53,
216
+ "label": "RELIGION",
217
+ "text": "evangelisch-lutherisch"
218
+ }
219
+ ],
220
+ "expected": "Konfession des Antragstellers: [RELIGION]."
221
+ },
222
+ {
223
+ "id": "holdout03d-014",
224
+ "language": "en",
225
+ "scenario": "affiliation",
226
+ "ocr_noise": false,
227
+ "text": "The confidential intake identifies the resident as Seventh-day Adventist.",
228
+ "spans": [
229
+ {
230
+ "start": 51,
231
+ "end": 72,
232
+ "label": "RELIGION",
233
+ "text": "Seventh-day Adventist"
234
+ }
235
+ ],
236
+ "expected": "The confidential intake identifies the resident as [RELIGION]."
237
+ },
238
+ {
239
+ "id": "holdout03d-015",
240
+ "language": "de",
241
+ "scenario": "affiliation",
242
+ "ocr_noise": false,
243
+ "text": "Raban Moosfeld bekennt sich zum Christentum.",
244
+ "spans": [
245
+ {
246
+ "start": 0,
247
+ "end": 14,
248
+ "label": "PERSON",
249
+ "text": "Raban Moosfeld"
250
+ },
251
+ {
252
+ "start": 32,
253
+ "end": 43,
254
+ "label": "RELIGION",
255
+ "text": "Christentum"
256
+ }
257
+ ],
258
+ "expected": "[PERSON] bekennt sich zum [RELIGION]."
259
+ },
260
+ {
261
+ "id": "holdout03d-016",
262
+ "language": "en",
263
+ "scenario": "affiliation",
264
+ "ocr_noise": false,
265
+ "text": "Iona Crestwell states that she practices Zoroastrianism.",
266
+ "spans": [
267
+ {
268
+ "start": 0,
269
+ "end": 14,
270
+ "label": "PERSON",
271
+ "text": "Iona Crestwell"
272
+ },
273
+ {
274
+ "start": 41,
275
+ "end": 55,
276
+ "label": "RELIGION",
277
+ "text": "Zoroastrianism"
278
+ }
279
+ ],
280
+ "expected": "[PERSON] states that she practices [RELIGION]."
281
+ },
282
+ {
283
+ "id": "holdout03d-017",
284
+ "language": "de",
285
+ "scenario": "nonaffiliation",
286
+ "ocr_noise": false,
287
+ "text": "Die Antwort im Konfessionsfeld lautet konfessionsfrei.",
288
+ "spans": [
289
+ {
290
+ "start": 38,
291
+ "end": 53,
292
+ "label": "RELIGION",
293
+ "text": "konfessionsfrei"
294
+ }
295
+ ],
296
+ "expected": "Die Antwort im Konfessionsfeld lautet [RELIGION]."
297
+ },
298
+ {
299
+ "id": "holdout03d-018",
300
+ "language": "en",
301
+ "scenario": "nonaffiliation",
302
+ "ocr_noise": false,
303
+ "text": "Religious status: no religious affiliation.",
304
+ "spans": [
305
+ {
306
+ "start": 18,
307
+ "end": 42,
308
+ "label": "RELIGION",
309
+ "text": "no religious affiliation"
310
+ }
311
+ ],
312
+ "expected": "Religious status: [RELIGION]."
313
+ },
314
+ {
315
+ "id": "holdout03d-019",
316
+ "language": "de",
317
+ "scenario": "nonaffiliation",
318
+ "ocr_noise": false,
319
+ "text": "Antrag: Die Bewohnerin ist religionslos.",
320
+ "spans": [
321
+ {
322
+ "start": 27,
323
+ "end": 39,
324
+ "label": "RELIGION",
325
+ "text": "religionslos"
326
+ }
327
+ ],
328
+ "expected": "Antrag: Die Bewohnerin ist [RELIGION]."
329
+ },
330
+ {
331
+ "id": "holdout03d-020",
332
+ "language": "en",
333
+ "scenario": "nonaffiliation",
334
+ "ocr_noise": false,
335
+ "text": "On the registration slip the visitor wrote atheist.",
336
+ "spans": [
337
+ {
338
+ "start": 43,
339
+ "end": 50,
340
+ "label": "RELIGION",
341
+ "text": "atheist"
342
+ }
343
+ ],
344
+ "expected": "On the registration slip the visitor wrote [RELIGION]."
345
+ },
346
+ {
347
+ "id": "holdout03d-021",
348
+ "language": "de",
349
+ "scenario": "nonaffiliation",
350
+ "ocr_noise": false,
351
+ "text": "Selbstbeschreibung im versiegelten Fragebogen: agnostisch.",
352
+ "spans": [
353
+ {
354
+ "start": 47,
355
+ "end": 57,
356
+ "label": "RELIGION",
357
+ "text": "agnostisch"
358
+ }
359
+ ],
360
+ "expected": "Selbstbeschreibung im versiegelten Fragebogen: [RELIGION]."
361
+ },
362
+ {
363
+ "id": "holdout03d-022",
364
+ "language": "en",
365
+ "scenario": "nonaffiliation",
366
+ "ocr_noise": false,
367
+ "text": "The individual describes himself as agnostic.",
368
+ "spans": [
369
+ {
370
+ "start": 36,
371
+ "end": 44,
372
+ "label": "RELIGION",
373
+ "text": "agnostic"
374
+ }
375
+ ],
376
+ "expected": "The individual describes himself as [RELIGION]."
377
+ },
378
+ {
379
+ "id": "holdout03d-023",
380
+ "language": "de",
381
+ "scenario": "nonaffiliation",
382
+ "ocr_noise": false,
383
+ "text": "Zur Frage nach der Religion: ohne Konfession.",
384
+ "spans": [
385
+ {
386
+ "start": 29,
387
+ "end": 44,
388
+ "label": "RELIGION",
389
+ "text": "ohne Konfession"
390
+ }
391
+ ],
392
+ "expected": "Zur Frage nach der Religion: [RELIGION]."
393
+ },
394
+ {
395
+ "id": "holdout03d-024",
396
+ "language": "en",
397
+ "scenario": "nonaffiliation",
398
+ "ocr_noise": false,
399
+ "text": "The recorded answer was nonreligious, with no additional details.",
400
+ "spans": [
401
+ {
402
+ "start": 24,
403
+ "end": 36,
404
+ "label": "RELIGION",
405
+ "text": "nonreligious"
406
+ }
407
+ ],
408
+ "expected": "The recorded answer was [RELIGION], with no additional details."
409
+ },
410
+ {
411
+ "id": "holdout03d-025",
412
+ "language": "de",
413
+ "scenario": "nonaffiliation",
414
+ "ocr_noise": false,
415
+ "text": "Für den Eintrag gilt keine Religion als wörtliche Antwort.",
416
+ "spans": [
417
+ {
418
+ "start": 21,
419
+ "end": 35,
420
+ "label": "RELIGION",
421
+ "text": "keine Religion"
422
+ }
423
+ ],
424
+ "expected": "Für den Eintrag gilt [RELIGION] als wörtliche Antwort."
425
+ },
426
+ {
427
+ "id": "holdout03d-026",
428
+ "language": "en",
429
+ "scenario": "nonaffiliation",
430
+ "ocr_noise": false,
431
+ "text": "Affiliation was entered as religiously unaffiliated.",
432
+ "spans": [
433
+ {
434
+ "start": 27,
435
+ "end": 51,
436
+ "label": "RELIGION",
437
+ "text": "religiously unaffiliated"
438
+ }
439
+ ],
440
+ "expected": "Affiliation was entered as [RELIGION]."
441
+ },
442
+ {
443
+ "id": "holdout03d-027",
444
+ "language": "de",
445
+ "scenario": "nonaffiliation",
446
+ "ocr_noise": false,
447
+ "text": "Die vertrauliche Karte vermerkt konfessionslos neben dem Geburtsjahr.",
448
+ "spans": [
449
+ {
450
+ "start": 32,
451
+ "end": 46,
452
+ "label": "RELIGION",
453
+ "text": "konfessionslos"
454
+ }
455
+ ],
456
+ "expected": "Die vertrauliche Karte vermerkt [RELIGION] neben dem Geburtsjahr."
457
+ },
458
+ {
459
+ "id": "holdout03d-028",
460
+ "language": "en",
461
+ "scenario": "nonaffiliation",
462
+ "ocr_noise": false,
463
+ "text": "Personal belief: atheism; this field must remain private.",
464
+ "spans": [
465
+ {
466
+ "start": 17,
467
+ "end": 24,
468
+ "label": "RELIGION",
469
+ "text": "atheism"
470
+ }
471
+ ],
472
+ "expected": "Personal belief: [RELIGION]; this field must remain private."
473
+ },
474
+ {
475
+ "id": "holdout03d-029",
476
+ "language": "de",
477
+ "scenario": "nonaffiliation",
478
+ "ocr_noise": false,
479
+ "text": "Beim Interview wurde Atheismus als eigene Haltung genannt.",
480
+ "spans": [
481
+ {
482
+ "start": 21,
483
+ "end": 30,
484
+ "label": "RELIGION",
485
+ "text": "Atheismus"
486
+ }
487
+ ],
488
+ "expected": "Beim Interview wurde [RELIGION] als eigene Haltung genannt."
489
+ },
490
+ {
491
+ "id": "holdout03d-030",
492
+ "language": "en",
493
+ "scenario": "nonaffiliation",
494
+ "ocr_noise": false,
495
+ "text": "The resident's own answer is no religion.",
496
+ "spans": [
497
+ {
498
+ "start": 29,
499
+ "end": 40,
500
+ "label": "RELIGION",
501
+ "text": "no religion"
502
+ }
503
+ ],
504
+ "expected": "The resident's own answer is [RELIGION]."
505
+ },
506
+ {
507
+ "id": "holdout03d-031",
508
+ "language": "de",
509
+ "scenario": "nonaffiliation",
510
+ "ocr_noise": false,
511
+ "text": "Als Weltanschauung wird Agnostizismus angegeben.",
512
+ "spans": [
513
+ {
514
+ "start": 24,
515
+ "end": 37,
516
+ "label": "RELIGION",
517
+ "text": "Agnostizismus"
518
+ }
519
+ ],
520
+ "expected": "Als Weltanschauung wird [RELIGION] angegeben."
521
+ },
522
+ {
523
+ "id": "holdout03d-032",
524
+ "language": "en",
525
+ "scenario": "nonaffiliation",
526
+ "ocr_noise": false,
527
+ "text": "The form marks the applicant non-religious.",
528
+ "spans": [
529
+ {
530
+ "start": 29,
531
+ "end": 42,
532
+ "label": "RELIGION",
533
+ "text": "non-religious"
534
+ }
535
+ ],
536
+ "expected": "The form marks the applicant [RELIGION]."
537
+ },
538
+ {
539
+ "id": "holdout03d-033",
540
+ "language": "de",
541
+ "scenario": "belief_names",
542
+ "ocr_noise": false,
543
+ "text": "Ein Glossar definiert Daoismus auf der ersten Seite.",
544
+ "spans": [
545
+ {
546
+ "start": 22,
547
+ "end": 30,
548
+ "label": "RELIGION",
549
+ "text": "Daoismus"
550
+ }
551
+ ],
552
+ "expected": "Ein Glossar definiert [RELIGION] auf der ersten Seite."
553
+ },
554
+ {
555
+ "id": "holdout03d-034",
556
+ "language": "en",
557
+ "scenario": "belief_names",
558
+ "ocr_noise": false,
559
+ "text": "The index contains an entry for Shintoism.",
560
+ "spans": [
561
+ {
562
+ "start": 32,
563
+ "end": 41,
564
+ "label": "RELIGION",
565
+ "text": "Shintoism"
566
+ }
567
+ ],
568
+ "expected": "The index contains an entry for [RELIGION]."
569
+ },
570
+ {
571
+ "id": "holdout03d-035",
572
+ "language": "de",
573
+ "scenario": "belief_names",
574
+ "ocr_noise": false,
575
+ "text": "Die Karteikarte trägt nur das Stichwort Islam.",
576
+ "spans": [
577
+ {
578
+ "start": 40,
579
+ "end": 45,
580
+ "label": "RELIGION",
581
+ "text": "Islam"
582
+ }
583
+ ],
584
+ "expected": "Die Karteikarte trägt nur das Stichwort [RELIGION]."
585
+ },
586
+ {
587
+ "id": "holdout03d-036",
588
+ "language": "en",
589
+ "scenario": "belief_names",
590
+ "ocr_noise": false,
591
+ "text": "A dictionary heading reads Judaism.",
592
+ "spans": [
593
+ {
594
+ "start": 27,
595
+ "end": 34,
596
+ "label": "RELIGION",
597
+ "text": "Judaism"
598
+ }
599
+ ],
600
+ "expected": "A dictionary heading reads [RELIGION]."
601
+ },
602
+ {
603
+ "id": "holdout03d-037",
604
+ "language": "de",
605
+ "scenario": "belief_names",
606
+ "ocr_noise": false,
607
+ "text": "Im Inhaltsverzeichnis stehen Hinduismus und Buddhismus.",
608
+ "spans": [
609
+ {
610
+ "start": 29,
611
+ "end": 39,
612
+ "label": "RELIGION",
613
+ "text": "Hinduismus"
614
+ },
615
+ {
616
+ "start": 44,
617
+ "end": 54,
618
+ "label": "RELIGION",
619
+ "text": "Buddhismus"
620
+ }
621
+ ],
622
+ "expected": "Im Inhaltsverzeichnis stehen [RELIGION] und [RELIGION]."
623
+ },
624
+ {
625
+ "id": "holdout03d-038",
626
+ "language": "en",
627
+ "scenario": "belief_names",
628
+ "ocr_noise": false,
629
+ "text": "The glossary compares Christianity with Taoism.",
630
+ "spans": [
631
+ {
632
+ "start": 22,
633
+ "end": 34,
634
+ "label": "RELIGION",
635
+ "text": "Christianity"
636
+ },
637
+ {
638
+ "start": 40,
639
+ "end": 46,
640
+ "label": "RELIGION",
641
+ "text": "Taoism"
642
+ }
643
+ ],
644
+ "expected": "The glossary compares [RELIGION] with [RELIGION]."
645
+ },
646
+ {
647
+ "id": "holdout03d-039",
648
+ "language": "de",
649
+ "scenario": "belief_names",
650
+ "ocr_noise": false,
651
+ "text": "Das Museum beschriftet einen Abschnitt mit Jainismus.",
652
+ "spans": [
653
+ {
654
+ "start": 43,
655
+ "end": 52,
656
+ "label": "RELIGION",
657
+ "text": "Jainismus"
658
+ }
659
+ ],
660
+ "expected": "Das Museum beschriftet einen Abschnitt mit [RELIGION]."
661
+ },
662
+ {
663
+ "id": "holdout03d-040",
664
+ "language": "en",
665
+ "scenario": "belief_names",
666
+ "ocr_noise": false,
667
+ "text": "The catalog classifies the manuscript under Sikhism.",
668
+ "spans": [
669
+ {
670
+ "start": 44,
671
+ "end": 51,
672
+ "label": "RELIGION",
673
+ "text": "Sikhism"
674
+ }
675
+ ],
676
+ "expected": "The catalog classifies the manuscript under [RELIGION]."
677
+ },
678
+ {
679
+ "id": "holdout03d-041",
680
+ "language": "de",
681
+ "scenario": "belief_names",
682
+ "ocr_noise": false,
683
+ "text": "Der Buchtitel erwähnt Zoroastrismus, ohne Personen zu benennen.",
684
+ "spans": [
685
+ {
686
+ "start": 22,
687
+ "end": 35,
688
+ "label": "RELIGION",
689
+ "text": "Zoroastrismus"
690
+ }
691
+ ],
692
+ "expected": "Der Buchtitel erwähnt [RELIGION], ohne Personen zu benennen."
693
+ },
694
+ {
695
+ "id": "holdout03d-042",
696
+ "language": "en",
697
+ "scenario": "belief_names",
698
+ "ocr_noise": false,
699
+ "text": "A subject heading names Baháʼí Faith.",
700
+ "spans": [
701
+ {
702
+ "start": 24,
703
+ "end": 36,
704
+ "label": "RELIGION",
705
+ "text": "Baháʼí Faith"
706
+ }
707
+ ],
708
+ "expected": "A subject heading names [RELIGION]."
709
+ },
710
+ {
711
+ "id": "holdout03d-043",
712
+ "language": "de",
713
+ "scenario": "belief_names",
714
+ "ocr_noise": false,
715
+ "text": "Die Übersicht behandelt Katholizismus in einem historischen Kapitel.",
716
+ "spans": [
717
+ {
718
+ "start": 24,
719
+ "end": 37,
720
+ "label": "RELIGION",
721
+ "text": "Katholizismus"
722
+ }
723
+ ],
724
+ "expected": "Die Übersicht behandelt [RELIGION] in einem historischen Kapitel."
725
+ },
726
+ {
727
+ "id": "holdout03d-044",
728
+ "language": "en",
729
+ "scenario": "belief_names",
730
+ "ocr_noise": false,
731
+ "text": "The bibliography has a subsection titled Protestantism.",
732
+ "spans": [
733
+ {
734
+ "start": 41,
735
+ "end": 54,
736
+ "label": "RELIGION",
737
+ "text": "Protestantism"
738
+ }
739
+ ],
740
+ "expected": "The bibliography has a subsection titled [RELIGION]."
741
+ },
742
+ {
743
+ "id": "holdout03d-045",
744
+ "language": "de",
745
+ "scenario": "belief_names",
746
+ "ocr_noise": false,
747
+ "text": "Auf dem Deckblatt steht Orthodoxie.",
748
+ "spans": [
749
+ {
750
+ "start": 24,
751
+ "end": 34,
752
+ "label": "RELIGION",
753
+ "text": "Orthodoxie"
754
+ }
755
+ ],
756
+ "expected": "Auf dem Deckblatt steht [RELIGION]."
757
+ },
758
+ {
759
+ "id": "holdout03d-046",
760
+ "language": "en",
761
+ "scenario": "belief_names",
762
+ "ocr_noise": false,
763
+ "text": "A lecture outline begins with Orthodox Christianity.",
764
+ "spans": [
765
+ {
766
+ "start": 30,
767
+ "end": 51,
768
+ "label": "RELIGION",
769
+ "text": "Orthodox Christianity"
770
+ }
771
+ ],
772
+ "expected": "A lecture outline begins with [RELIGION]."
773
+ },
774
+ {
775
+ "id": "holdout03d-047",
776
+ "language": "de",
777
+ "scenario": "belief_names",
778
+ "ocr_noise": false,
779
+ "text": "Das Handbuch unterscheidet Sikhismus von Jainismus.",
780
+ "spans": [
781
+ {
782
+ "start": 27,
783
+ "end": 36,
784
+ "label": "RELIGION",
785
+ "text": "Sikhismus"
786
+ },
787
+ {
788
+ "start": 41,
789
+ "end": 50,
790
+ "label": "RELIGION",
791
+ "text": "Jainismus"
792
+ }
793
+ ],
794
+ "expected": "Das Handbuch unterscheidet [RELIGION] von [RELIGION]."
795
+ },
796
+ {
797
+ "id": "holdout03d-048",
798
+ "language": "en",
799
+ "scenario": "belief_names",
800
+ "ocr_noise": false,
801
+ "text": "The encyclopedia provides a chapter about Confucianism.",
802
+ "spans": [
803
+ {
804
+ "start": 42,
805
+ "end": 54,
806
+ "label": "RELIGION",
807
+ "text": "Confucianism"
808
+ }
809
+ ],
810
+ "expected": "The encyclopedia provides a chapter about [RELIGION]."
811
+ },
812
+ {
813
+ "id": "holdout03d-049",
814
+ "language": "de",
815
+ "scenario": "general_topics",
816
+ "ocr_noise": false,
817
+ "text": "Die Ausstellung erklärt das Fasten im Islam; Besucherangaben fehlen.",
818
+ "spans": [
819
+ {
820
+ "start": 38,
821
+ "end": 43,
822
+ "label": "RELIGION",
823
+ "text": "Islam"
824
+ }
825
+ ],
826
+ "expected": "Die Ausstellung erklärt das Fasten im [RELIGION]; Besucherangaben fehlen."
827
+ },
828
+ {
829
+ "id": "holdout03d-050",
830
+ "language": "en",
831
+ "scenario": "general_topics",
832
+ "ocr_noise": false,
833
+ "text": "The essay examines pilgrimage in Hinduism without naming a follower.",
834
+ "spans": [
835
+ {
836
+ "start": 33,
837
+ "end": 41,
838
+ "label": "RELIGION",
839
+ "text": "Hinduism"
840
+ }
841
+ ],
842
+ "expected": "The essay examines pilgrimage in [RELIGION] without naming a follower."
843
+ },
844
+ {
845
+ "id": "holdout03d-051",
846
+ "language": "de",
847
+ "scenario": "general_topics",
848
+ "ocr_noise": false,
849
+ "text": "Historische Randnotiz: Christentum im frühen Mittelalter.",
850
+ "spans": [
851
+ {
852
+ "start": 23,
853
+ "end": 34,
854
+ "label": "RELIGION",
855
+ "text": "Christentum"
856
+ }
857
+ ],
858
+ "expected": "Historische Randnotiz: [RELIGION] im frühen Mittelalter."
859
+ },
860
+ {
861
+ "id": "holdout03d-052",
862
+ "language": "en",
863
+ "scenario": "general_topics",
864
+ "ocr_noise": false,
865
+ "text": "The radio program discusses Buddhism as a topic, not the presenter's identity.",
866
+ "spans": [
867
+ {
868
+ "start": 28,
869
+ "end": 36,
870
+ "label": "RELIGION",
871
+ "text": "Buddhism"
872
+ }
873
+ ],
874
+ "expected": "The radio program discusses [RELIGION] as a topic, not the presenter's identity."
875
+ },
876
+ {
877
+ "id": "holdout03d-053",
878
+ "language": "de",
879
+ "scenario": "general_topics",
880
+ "ocr_noise": false,
881
+ "text": "Auf der Leseliste steht ein Werk zum Judentum.",
882
+ "spans": [
883
+ {
884
+ "start": 37,
885
+ "end": 45,
886
+ "label": "RELIGION",
887
+ "text": "Judentum"
888
+ }
889
+ ],
890
+ "expected": "Auf der Leseliste steht ein Werk zum [RELIGION]."
891
+ },
892
+ {
893
+ "id": "holdout03d-054",
894
+ "language": "en",
895
+ "scenario": "general_topics",
896
+ "ocr_noise": false,
897
+ "text": "A public seminar on Jainism does not establish anyone's affiliation.",
898
+ "spans": [
899
+ {
900
+ "start": 20,
901
+ "end": 27,
902
+ "label": "RELIGION",
903
+ "text": "Jainism"
904
+ }
905
+ ],
906
+ "expected": "A public seminar on [RELIGION] does not establish anyone's affiliation."
907
+ },
908
+ {
909
+ "id": "holdout03d-055",
910
+ "language": "de",
911
+ "scenario": "general_topics",
912
+ "ocr_noise": false,
913
+ "text": "Die Wandtafel erläutert Bahai als historischen Gegenstand.",
914
+ "spans": [
915
+ {
916
+ "start": 24,
917
+ "end": 29,
918
+ "label": "RELIGION",
919
+ "text": "Bahai"
920
+ }
921
+ ],
922
+ "expected": "Die Wandtafel erläutert [RELIGION] als historischen Gegenstand."
923
+ },
924
+ {
925
+ "id": "holdout03d-056",
926
+ "language": "en",
927
+ "scenario": "general_topics",
928
+ "ocr_noise": false,
929
+ "text": "The podcast's subject is Sikhism, with no personal disclosures.",
930
+ "spans": [
931
+ {
932
+ "start": 25,
933
+ "end": 32,
934
+ "label": "RELIGION",
935
+ "text": "Sikhism"
936
+ }
937
+ ],
938
+ "expected": "The podcast's subject is [RELIGION], with no personal disclosures."
939
+ },
940
+ {
941
+ "id": "holdout03d-057",
942
+ "language": "de",
943
+ "scenario": "general_topics",
944
+ "ocr_noise": false,
945
+ "text": "Kapitelüberschrift: Atheismus und gesellschaftlicher Wandel.",
946
+ "spans": [
947
+ {
948
+ "start": 20,
949
+ "end": 29,
950
+ "label": "RELIGION",
951
+ "text": "Atheismus"
952
+ }
953
+ ],
954
+ "expected": "Kapitelüberschrift: [RELIGION] und gesellschaftlicher Wandel."
955
+ },
956
+ {
957
+ "id": "holdout03d-058",
958
+ "language": "en",
959
+ "scenario": "general_topics",
960
+ "ocr_noise": false,
961
+ "text": "The chart compares atheism and agnosticism at a conceptual level.",
962
+ "spans": [
963
+ {
964
+ "start": 19,
965
+ "end": 26,
966
+ "label": "RELIGION",
967
+ "text": "atheism"
968
+ },
969
+ {
970
+ "start": 31,
971
+ "end": 42,
972
+ "label": "RELIGION",
973
+ "text": "agnosticism"
974
+ }
975
+ ],
976
+ "expected": "The chart compares [RELIGION] and [RELIGION] at a conceptual level."
977
+ },
978
+ {
979
+ "id": "holdout03d-059",
980
+ "language": "de",
981
+ "scenario": "general_topics",
982
+ "ocr_noise": false,
983
+ "text": "Ein Forschungstext beschreibt Shintoismus ohne individuelle Angaben.",
984
+ "spans": [
985
+ {
986
+ "start": 30,
987
+ "end": 41,
988
+ "label": "RELIGION",
989
+ "text": "Shintoismus"
990
+ }
991
+ ],
992
+ "expected": "Ein Forschungstext beschreibt [RELIGION] ohne individuelle Angaben."
993
+ },
994
+ {
995
+ "id": "holdout03d-060",
996
+ "language": "en",
997
+ "scenario": "general_topics",
998
+ "ocr_noise": false,
999
+ "text": "The librarian assigned Taoism as a subject keyword.",
1000
+ "spans": [
1001
+ {
1002
+ "start": 23,
1003
+ "end": 29,
1004
+ "label": "RELIGION",
1005
+ "text": "Taoism"
1006
+ }
1007
+ ],
1008
+ "expected": "The librarian assigned [RELIGION] as a subject keyword."
1009
+ },
1010
+ {
1011
+ "id": "holdout03d-061",
1012
+ "language": "de",
1013
+ "scenario": "general_topics",
1014
+ "ocr_noise": false,
1015
+ "text": "Die Zeitleiste beschreibt den Protestantismus als Bewegung.",
1016
+ "spans": [
1017
+ {
1018
+ "start": 30,
1019
+ "end": 45,
1020
+ "label": "RELIGION",
1021
+ "text": "Protestantismus"
1022
+ }
1023
+ ],
1024
+ "expected": "Die Zeitleiste beschreibt den [RELIGION] als Bewegung."
1025
+ },
1026
+ {
1027
+ "id": "holdout03d-062",
1028
+ "language": "en",
1029
+ "scenario": "general_topics",
1030
+ "ocr_noise": false,
1031
+ "text": "The audio guide explains Catholicism to an unspecified audience.",
1032
+ "spans": [
1033
+ {
1034
+ "start": 25,
1035
+ "end": 36,
1036
+ "label": "RELIGION",
1037
+ "text": "Catholicism"
1038
+ }
1039
+ ],
1040
+ "expected": "The audio guide explains [RELIGION] to an unspecified audience."
1041
+ },
1042
+ {
1043
+ "id": "holdout03d-063",
1044
+ "language": "de",
1045
+ "scenario": "general_topics",
1046
+ "ocr_noise": false,
1047
+ "text": "Ein allgemeiner Artikel nennt konfessionslos als statistische Kategorie.",
1048
+ "spans": [
1049
+ {
1050
+ "start": 30,
1051
+ "end": 44,
1052
+ "label": "RELIGION",
1053
+ "text": "konfessionslos"
1054
+ }
1055
+ ],
1056
+ "expected": "Ein allgemeiner Artikel nennt [RELIGION] als statistische Kategorie."
1057
+ },
1058
+ {
1059
+ "id": "holdout03d-064",
1060
+ "language": "en",
1061
+ "scenario": "general_topics",
1062
+ "ocr_noise": false,
1063
+ "text": "A terminology sheet explains non-religious; it contains no respondent records.",
1064
+ "spans": [
1065
+ {
1066
+ "start": 29,
1067
+ "end": 42,
1068
+ "label": "RELIGION",
1069
+ "text": "non-religious"
1070
+ }
1071
+ ],
1072
+ "expected": "A terminology sheet explains [RELIGION]; it contains no respondent records."
1073
+ },
1074
+ {
1075
+ "id": "holdout03d-065",
1076
+ "language": "de",
1077
+ "scenario": "negation_quotation",
1078
+ "ocr_noise": false,
1079
+ "text": "Im Protokoll steht: »Ich bin nicht muslimisch.«",
1080
+ "spans": [
1081
+ {
1082
+ "start": 35,
1083
+ "end": 45,
1084
+ "label": "RELIGION",
1085
+ "text": "muslimisch"
1086
+ }
1087
+ ],
1088
+ "expected": "Im Protokoll steht: »Ich bin nicht [RELIGION].«"
1089
+ },
1090
+ {
1091
+ "id": "holdout03d-066",
1092
+ "language": "en",
1093
+ "scenario": "negation_quotation",
1094
+ "ocr_noise": false,
1095
+ "text": "The witness said, \"I am not Christian.\"",
1096
+ "spans": [
1097
+ {
1098
+ "start": 28,
1099
+ "end": 37,
1100
+ "label": "RELIGION",
1101
+ "text": "Christian"
1102
+ }
1103
+ ],
1104
+ "expected": "The witness said, \"I am not [RELIGION].\""
1105
+ },
1106
+ {
1107
+ "id": "holdout03d-067",
1108
+ "language": "de",
1109
+ "scenario": "negation_quotation",
1110
+ "ocr_noise": false,
1111
+ "text": "Sie widerspricht dem Eintrag katholisch ausdrücklich.",
1112
+ "spans": [
1113
+ {
1114
+ "start": 29,
1115
+ "end": 39,
1116
+ "label": "RELIGION",
1117
+ "text": "katholisch"
1118
+ }
1119
+ ],
1120
+ "expected": "Sie widerspricht dem Eintrag [RELIGION] ausdrücklich."
1121
+ },
1122
+ {
1123
+ "id": "holdout03d-068",
1124
+ "language": "en",
1125
+ "scenario": "negation_quotation",
1126
+ "ocr_noise": false,
1127
+ "text": "The interviewee rejected the label atheist.",
1128
+ "spans": [
1129
+ {
1130
+ "start": 35,
1131
+ "end": 42,
1132
+ "label": "RELIGION",
1133
+ "text": "atheist"
1134
+ }
1135
+ ],
1136
+ "expected": "The interviewee rejected the label [RELIGION]."
1137
+ },
1138
+ {
1139
+ "id": "holdout03d-069",
1140
+ "language": "de",
1141
+ "scenario": "negation_quotation",
1142
+ "ocr_noise": false,
1143
+ "text": "Das Wort »buddhistisch« ist ein Zitat aus einem Lehrbuch.",
1144
+ "spans": [
1145
+ {
1146
+ "start": 10,
1147
+ "end": 22,
1148
+ "label": "RELIGION",
1149
+ "text": "buddhistisch"
1150
+ }
1151
+ ],
1152
+ "expected": "Das Wort »[RELIGION]« ist ein Zitat aus einem Lehrbuch."
1153
+ },
1154
+ {
1155
+ "id": "holdout03d-070",
1156
+ "language": "en",
1157
+ "scenario": "negation_quotation",
1158
+ "ocr_noise": false,
1159
+ "text": "\"Jewish\" appears in a quotation, not a claim about the editor.",
1160
+ "spans": [
1161
+ {
1162
+ "start": 1,
1163
+ "end": 7,
1164
+ "label": "RELIGION",
1165
+ "text": "Jewish"
1166
+ }
1167
+ ],
1168
+ "expected": "\"[RELIGION]\" appears in a quotation, not a claim about the editor."
1169
+ },
1170
+ {
1171
+ "id": "holdout03d-071",
1172
+ "language": "de",
1173
+ "scenario": "negation_quotation",
1174
+ "ocr_noise": false,
1175
+ "text": "Niemand behauptet hier, die Autorin sei hinduistisch.",
1176
+ "spans": [
1177
+ {
1178
+ "start": 40,
1179
+ "end": 52,
1180
+ "label": "RELIGION",
1181
+ "text": "hinduistisch"
1182
+ }
1183
+ ],
1184
+ "expected": "Niemand behauptet hier, die Autorin sei [RELIGION]."
1185
+ },
1186
+ {
1187
+ "id": "holdout03d-072",
1188
+ "language": "en",
1189
+ "scenario": "negation_quotation",
1190
+ "ocr_noise": false,
1191
+ "text": "No one alleged that the resident was Muslim.",
1192
+ "spans": [
1193
+ {
1194
+ "start": 37,
1195
+ "end": 43,
1196
+ "label": "RELIGION",
1197
+ "text": "Muslim"
1198
+ }
1199
+ ],
1200
+ "expected": "No one alleged that the resident was [RELIGION]."
1201
+ },
1202
+ {
1203
+ "id": "holdout03d-073",
1204
+ "language": "de",
1205
+ "scenario": "negation_quotation",
1206
+ "ocr_noise": false,
1207
+ "text": "»Ohne Konfession« wurde als mögliche Antwort vorgelesen.",
1208
+ "spans": [
1209
+ {
1210
+ "start": 1,
1211
+ "end": 16,
1212
+ "label": "RELIGION",
1213
+ "text": "Ohne Konfession"
1214
+ }
1215
+ ],
1216
+ "expected": "»[RELIGION]« wurde als mögliche Antwort vorgelesen."
1217
+ },
1218
+ {
1219
+ "id": "holdout03d-074",
1220
+ "language": "en",
1221
+ "scenario": "negation_quotation",
1222
+ "ocr_noise": false,
1223
+ "text": "The clerk read the option \"no religion\" aloud.",
1224
+ "spans": [
1225
+ {
1226
+ "start": 27,
1227
+ "end": 38,
1228
+ "label": "RELIGION",
1229
+ "text": "no religion"
1230
+ }
1231
+ ],
1232
+ "expected": "The clerk read the option \"[RELIGION]\" aloud."
1233
+ },
1234
+ {
1235
+ "id": "holdout03d-075",
1236
+ "language": "de",
1237
+ "scenario": "negation_quotation",
1238
+ "ocr_noise": false,
1239
+ "text": "Nicht evangelisch, sondern agnostisch: so lautet die Korrektur.",
1240
+ "spans": [
1241
+ {
1242
+ "start": 6,
1243
+ "end": 17,
1244
+ "label": "RELIGION",
1245
+ "text": "evangelisch"
1246
+ },
1247
+ {
1248
+ "start": 27,
1249
+ "end": 37,
1250
+ "label": "RELIGION",
1251
+ "text": "agnostisch"
1252
+ }
1253
+ ],
1254
+ "expected": "Nicht [RELIGION], sondern [RELIGION]: so lautet die Korrektur."
1255
+ },
1256
+ {
1257
+ "id": "holdout03d-076",
1258
+ "language": "en",
1259
+ "scenario": "negation_quotation",
1260
+ "ocr_noise": false,
1261
+ "text": "The note corrects Catholic to nonreligious.",
1262
+ "spans": [
1263
+ {
1264
+ "start": 18,
1265
+ "end": 26,
1266
+ "label": "RELIGION",
1267
+ "text": "Catholic"
1268
+ },
1269
+ {
1270
+ "start": 30,
1271
+ "end": 42,
1272
+ "label": "RELIGION",
1273
+ "text": "nonreligious"
1274
+ }
1275
+ ],
1276
+ "expected": "The note corrects [RELIGION] to [RELIGION]."
1277
+ },
1278
+ {
1279
+ "id": "holdout03d-077",
1280
+ "language": "de",
1281
+ "scenario": "negation_quotation",
1282
+ "ocr_noise": false,
1283
+ "text": "Die Rezension zitiert Judentum, ohne einen Glauben zuzuschreiben.",
1284
+ "spans": [
1285
+ {
1286
+ "start": 22,
1287
+ "end": 30,
1288
+ "label": "RELIGION",
1289
+ "text": "Judentum"
1290
+ }
1291
+ ],
1292
+ "expected": "Die Rezension zitiert [RELIGION], ohne einen Glauben zuzuschreiben."
1293
+ },
1294
+ {
1295
+ "id": "holdout03d-078",
1296
+ "language": "en",
1297
+ "scenario": "negation_quotation",
1298
+ "ocr_noise": false,
1299
+ "text": "A reviewer underlined \"Baha'i\" in the supplied excerpt.",
1300
+ "spans": [
1301
+ {
1302
+ "start": 23,
1303
+ "end": 29,
1304
+ "label": "RELIGION",
1305
+ "text": "Baha'i"
1306
+ }
1307
+ ],
1308
+ "expected": "A reviewer underlined \"[RELIGION]\" in the supplied excerpt."
1309
+ },
1310
+ {
1311
+ "id": "holdout03d-079",
1312
+ "language": "de",
1313
+ "scenario": "negation_quotation",
1314
+ "ocr_noise": false,
1315
+ "text": "Die Behauptung einer islamischen Zugehörigkeit wurde zurückgewiesen.",
1316
+ "spans": [
1317
+ {
1318
+ "start": 21,
1319
+ "end": 32,
1320
+ "label": "RELIGION",
1321
+ "text": "islamischen"
1322
+ }
1323
+ ],
1324
+ "expected": "Die Behauptung einer [RELIGION] Zugehörigkeit wurde zurückgewiesen."
1325
+ },
1326
+ {
1327
+ "id": "holdout03d-080",
1328
+ "language": "en",
1329
+ "scenario": "negation_quotation",
1330
+ "ocr_noise": false,
1331
+ "text": "The statement \"Buddhist\" was disputed in the correction sheet.",
1332
+ "spans": [
1333
+ {
1334
+ "start": 15,
1335
+ "end": 23,
1336
+ "label": "RELIGION",
1337
+ "text": "Buddhist"
1338
+ }
1339
+ ],
1340
+ "expected": "The statement \"[RELIGION]\" was disputed in the correction sheet."
1341
+ },
1342
+ {
1343
+ "id": "holdout03d-081",
1344
+ "language": "de",
1345
+ "scenario": "ambiguity",
1346
+ "ocr_noise": false,
1347
+ "text": "Ein orthodoxes Sortierverfahren ist hier eine mathematische Metapher.",
1348
+ "spans": [],
1349
+ "expected": "Ein orthodoxes Sortierverfahren ist hier eine mathematische Metapher."
1350
+ },
1351
+ {
1352
+ "id": "holdout03d-082",
1353
+ "language": "en",
1354
+ "scenario": "ambiguity",
1355
+ "ocr_noise": false,
1356
+ "text": "Her catholic tastes include jazz and astronomy.",
1357
+ "spans": [],
1358
+ "expected": "Her catholic tastes include jazz and astronomy."
1359
+ },
1360
+ {
1361
+ "id": "holdout03d-083",
1362
+ "language": "de",
1363
+ "scenario": "ambiguity",
1364
+ "ocr_noise": false,
1365
+ "text": "Die evangelikale Werbemetapher beschreibt keine Religionszugehörigkeit.",
1366
+ "spans": [],
1367
+ "expected": "Die evangelikale Werbemetapher beschreibt keine Religionszugehörigkeit."
1368
+ },
1369
+ {
1370
+ "id": "holdout03d-084",
1371
+ "language": "en",
1372
+ "scenario": "ambiguity",
1373
+ "ocr_noise": false,
1374
+ "text": "An evangelical sales pitch is the example in this marketing exercise.",
1375
+ "spans": [],
1376
+ "expected": "An evangelical sales pitch is the example in this marketing exercise."
1377
+ },
1378
+ {
1379
+ "id": "holdout03d-085",
1380
+ "language": "de",
1381
+ "scenario": "ambiguity",
1382
+ "ocr_noise": false,
1383
+ "text": "Der Patient wurde orthodox getauft, wie der geschützte Eintrag berichtet.",
1384
+ "spans": [
1385
+ {
1386
+ "start": 18,
1387
+ "end": 26,
1388
+ "label": "RELIGION",
1389
+ "text": "orthodox"
1390
+ }
1391
+ ],
1392
+ "expected": "Der Patient wurde [RELIGION] getauft, wie der geschützte Eintrag berichtet."
1393
+ },
1394
+ {
1395
+ "id": "holdout03d-086",
1396
+ "language": "en",
1397
+ "scenario": "ambiguity",
1398
+ "ocr_noise": false,
1399
+ "text": "The baptismal record identifies the denomination as Orthodox.",
1400
+ "spans": [
1401
+ {
1402
+ "start": 52,
1403
+ "end": 60,
1404
+ "label": "RELIGION",
1405
+ "text": "Orthodox"
1406
+ }
1407
+ ],
1408
+ "expected": "The baptismal record identifies the denomination as [RELIGION]."
1409
+ },
1410
+ {
1411
+ "id": "holdout03d-087",
1412
+ "language": "de",
1413
+ "scenario": "ambiguity",
1414
+ "ocr_noise": false,
1415
+ "text": "Konfession: evangelisch; Algorithmus: konventionell.",
1416
+ "spans": [
1417
+ {
1418
+ "start": 12,
1419
+ "end": 23,
1420
+ "label": "RELIGION",
1421
+ "text": "evangelisch"
1422
+ }
1423
+ ],
1424
+ "expected": "Konfession: [RELIGION]; Algorithmus: konventionell."
1425
+ },
1426
+ {
1427
+ "id": "holdout03d-088",
1428
+ "language": "en",
1429
+ "scenario": "ambiguity",
1430
+ "ocr_noise": false,
1431
+ "text": "The faith survey selected Evangelical under denomination.",
1432
+ "spans": [
1433
+ {
1434
+ "start": 26,
1435
+ "end": 37,
1436
+ "label": "RELIGION",
1437
+ "text": "Evangelical"
1438
+ }
1439
+ ],
1440
+ "expected": "The faith survey selected [RELIGION] under denomination."
1441
+ },
1442
+ {
1443
+ "id": "holdout03d-089",
1444
+ "language": "de",
1445
+ "scenario": "ambiguity",
1446
+ "ocr_noise": false,
1447
+ "text": "Eine säkulare Rechtsordnung nennt keine persönlichen Bekenntnisse.",
1448
+ "spans": [],
1449
+ "expected": "Eine säkulare Rechtsordnung nennt keine persönlichen Bekenntnisse."
1450
+ },
1451
+ {
1452
+ "id": "holdout03d-090",
1453
+ "language": "en",
1454
+ "scenario": "ambiguity",
1455
+ "ocr_noise": false,
1456
+ "text": "The meeting has an unaffiliated chair and no membership declarations.",
1457
+ "spans": [],
1458
+ "expected": "The meeting has an unaffiliated chair and no membership declarations."
1459
+ },
1460
+ {
1461
+ "id": "holdout03d-091",
1462
+ "language": "de",
1463
+ "scenario": "ambiguity",
1464
+ "ocr_noise": false,
1465
+ "text": "Als Religion ist christlich vermerkt, nicht als Personenname.",
1466
+ "spans": [
1467
+ {
1468
+ "start": 17,
1469
+ "end": 27,
1470
+ "label": "RELIGION",
1471
+ "text": "christlich"
1472
+ }
1473
+ ],
1474
+ "expected": "Als Religion ist [RELIGION] vermerkt, nicht als Personenname."
1475
+ },
1476
+ {
1477
+ "id": "holdout03d-092",
1478
+ "language": "en",
1479
+ "scenario": "ambiguity",
1480
+ "ocr_noise": false,
1481
+ "text": "The religion field is Christian, distinct from the given-name column.",
1482
+ "spans": [
1483
+ {
1484
+ "start": 22,
1485
+ "end": 31,
1486
+ "label": "RELIGION",
1487
+ "text": "Christian"
1488
+ }
1489
+ ],
1490
+ "expected": "The religion field is [RELIGION], distinct from the given-name column."
1491
+ },
1492
+ {
1493
+ "id": "holdout03d-093",
1494
+ "language": "de",
1495
+ "scenario": "ambiguity",
1496
+ "ocr_noise": false,
1497
+ "text": "Die goldene Regel im Handwerk ist kein personenbezogenes Religionsmerkmal.",
1498
+ "spans": [],
1499
+ "expected": "Die goldene Regel im Handwerk ist kein personenbezogenes Religionsmerkmal."
1500
+ },
1501
+ {
1502
+ "id": "holdout03d-094",
1503
+ "language": "en",
1504
+ "scenario": "ambiguity",
1505
+ "ocr_noise": false,
1506
+ "text": "The orthodox spelling of a variable is a programming concern.",
1507
+ "spans": [],
1508
+ "expected": "The orthodox spelling of a variable is a programming concern."
1509
+ },
1510
+ {
1511
+ "id": "holdout03d-095",
1512
+ "language": "de",
1513
+ "scenario": "ambiguity",
1514
+ "ocr_noise": false,
1515
+ "text": "Im Glaubensregister steht jüdisch als vertrauliche Angabe.",
1516
+ "spans": [
1517
+ {
1518
+ "start": 26,
1519
+ "end": 33,
1520
+ "label": "RELIGION",
1521
+ "text": "jüdisch"
1522
+ }
1523
+ ],
1524
+ "expected": "Im Glaubensregister steht [RELIGION] als vertrauliche Angabe."
1525
+ },
1526
+ {
1527
+ "id": "holdout03d-096",
1528
+ "language": "en",
1529
+ "scenario": "ambiguity",
1530
+ "ocr_noise": false,
1531
+ "text": "The theological syllabus labels a tradition Reformed.",
1532
+ "spans": [
1533
+ {
1534
+ "start": 44,
1535
+ "end": 52,
1536
+ "label": "RELIGION",
1537
+ "text": "Reformed"
1538
+ }
1539
+ ],
1540
+ "expected": "The theological syllabus labels a tradition [RELIGION]."
1541
+ },
1542
+ {
1543
+ "id": "holdout03d-097",
1544
+ "language": "de",
1545
+ "scenario": "ocr_noise",
1546
+ "ocr_noise": true,
1547
+ "text": "Religion laut Scan: Buddh1smus.",
1548
+ "spans": [
1549
+ {
1550
+ "start": 20,
1551
+ "end": 30,
1552
+ "label": "RELIGION",
1553
+ "text": "Buddh1smus"
1554
+ }
1555
+ ],
1556
+ "expected": "Religion laut Scan: [RELIGION]."
1557
+ },
1558
+ {
1559
+ "id": "holdout03d-098",
1560
+ "language": "en",
1561
+ "scenario": "ocr_noise",
1562
+ "ocr_noise": true,
1563
+ "text": "The scanned belief field reads Christlanity.",
1564
+ "spans": [
1565
+ {
1566
+ "start": 31,
1567
+ "end": 43,
1568
+ "label": "RELIGION",
1569
+ "text": "Christlanity"
1570
+ }
1571
+ ],
1572
+ "expected": "The scanned belief field reads [RELIGION]."
1573
+ },
1574
+ {
1575
+ "id": "holdout03d-099",
1576
+ "language": "de",
1577
+ "scenario": "ocr_noise",
1578
+ "ocr_noise": true,
1579
+ "text": "Konfession im OCR-Text: konfessions-\nlos.",
1580
+ "spans": [
1581
+ {
1582
+ "start": 24,
1583
+ "end": 40,
1584
+ "label": "RELIGION",
1585
+ "text": "konfessions-\nlos"
1586
+ }
1587
+ ],
1588
+ "expected": "Konfession im OCR-Text: [RELIGION]."
1589
+ },
1590
+ {
1591
+ "id": "holdout03d-100",
1592
+ "language": "en",
1593
+ "scenario": "ocr_noise",
1594
+ "ocr_noise": true,
1595
+ "text": "Faith from the scan: non-\nreligious.",
1596
+ "spans": [
1597
+ {
1598
+ "start": 21,
1599
+ "end": 35,
1600
+ "label": "RELIGION",
1601
+ "text": "non-\nreligious"
1602
+ }
1603
+ ],
1604
+ "expected": "Faith from the scan: [RELIGION]."
1605
+ },
1606
+ {
1607
+ "id": "holdout03d-101",
1608
+ "language": "de",
1609
+ "scenario": "ocr_noise",
1610
+ "ocr_noise": true,
1611
+ "text": "Die Lesart katho1isch ist im Faksimile markiert.",
1612
+ "spans": [
1613
+ {
1614
+ "start": 11,
1615
+ "end": 21,
1616
+ "label": "RELIGION",
1617
+ "text": "katho1isch"
1618
+ }
1619
+ ],
1620
+ "expected": "Die Lesart [RELIGION] ist im Faksimile markiert."
1621
+ },
1622
+ {
1623
+ "id": "holdout03d-102",
1624
+ "language": "en",
1625
+ "scenario": "ocr_noise",
1626
+ "ocr_noise": true,
1627
+ "text": "Belief value after OCR: Hinduisrn.",
1628
+ "spans": [
1629
+ {
1630
+ "start": 24,
1631
+ "end": 33,
1632
+ "label": "RELIGION",
1633
+ "text": "Hinduisrn"
1634
+ }
1635
+ ],
1636
+ "expected": "Belief value after OCR: [RELIGION]."
1637
+ },
1638
+ {
1639
+ "id": "holdout03d-103",
1640
+ "language": "de",
1641
+ "scenario": "ocr_noise",
1642
+ "ocr_noise": true,
1643
+ "text": "Im Formular wurde agnos-\ntisch getrennt gesetzt.",
1644
+ "spans": [
1645
+ {
1646
+ "start": 18,
1647
+ "end": 30,
1648
+ "label": "RELIGION",
1649
+ "text": "agnos-\ntisch"
1650
+ }
1651
+ ],
1652
+ "expected": "Im Formular wurde [RELIGION] getrennt gesetzt."
1653
+ },
1654
+ {
1655
+ "id": "holdout03d-104",
1656
+ "language": "en",
1657
+ "scenario": "ocr_noise",
1658
+ "ocr_noise": true,
1659
+ "text": "The scanned heading says Juda-\nism.",
1660
+ "spans": [
1661
+ {
1662
+ "start": 25,
1663
+ "end": 34,
1664
+ "label": "RELIGION",
1665
+ "text": "Juda-\nism"
1666
+ }
1667
+ ],
1668
+ "expected": "The scanned heading says [RELIGION]."
1669
+ },
1670
+ {
1671
+ "id": "holdout03d-105",
1672
+ "language": "de",
1673
+ "scenario": "ocr_noise",
1674
+ "ocr_noise": true,
1675
+ "text": "Eine Zeile enthält lslam mit verwechseltem Anfangsbuchstaben.",
1676
+ "spans": [
1677
+ {
1678
+ "start": 19,
1679
+ "end": 24,
1680
+ "label": "RELIGION",
1681
+ "text": "lslam"
1682
+ }
1683
+ ],
1684
+ "expected": "Eine Zeile enthält [RELIGION] mit verwechseltem Anfangsbuchstaben."
1685
+ },
1686
+ {
1687
+ "id": "holdout03d-106",
1688
+ "language": "en",
1689
+ "scenario": "ocr_noise",
1690
+ "ocr_noise": true,
1691
+ "text": "The OCR extracted S1khism in the private religion column.",
1692
+ "spans": [
1693
+ {
1694
+ "start": 18,
1695
+ "end": 25,
1696
+ "label": "RELIGION",
1697
+ "text": "S1khism"
1698
+ }
1699
+ ],
1700
+ "expected": "The OCR extracted [RELIGION] in the private religion column."
1701
+ },
1702
+ {
1703
+ "id": "holdout03d-107",
1704
+ "language": "de",
1705
+ "scenario": "ocr_noise",
1706
+ "ocr_noise": true,
1707
+ "text": "Die handschriftliche Angabe lautet buddhis-\ntisch.",
1708
+ "spans": [
1709
+ {
1710
+ "start": 35,
1711
+ "end": 49,
1712
+ "label": "RELIGION",
1713
+ "text": "buddhis-\ntisch"
1714
+ }
1715
+ ],
1716
+ "expected": "Die handschriftliche Angabe lautet [RELIGION]."
1717
+ },
1718
+ {
1719
+ "id": "holdout03d-108",
1720
+ "language": "en",
1721
+ "scenario": "ocr_noise",
1722
+ "ocr_noise": true,
1723
+ "text": "The line-broken subject is Zoroas-\ntrianism.",
1724
+ "spans": [
1725
+ {
1726
+ "start": 27,
1727
+ "end": 43,
1728
+ "label": "RELIGION",
1729
+ "text": "Zoroas-\ntrianism"
1730
+ }
1731
+ ],
1732
+ "expected": "The line-broken subject is [RELIGION]."
1733
+ },
1734
+ {
1735
+ "id": "holdout03d-109",
1736
+ "language": "de",
1737
+ "scenario": "ocr_noise",
1738
+ "ocr_noise": true,
1739
+ "text": "Aus dem Scan: Fenja Torwald, Bekenntnis Judenturn.",
1740
+ "spans": [
1741
+ {
1742
+ "start": 14,
1743
+ "end": 27,
1744
+ "label": "PERSON",
1745
+ "text": "Fenja Torwald"
1746
+ },
1747
+ {
1748
+ "start": 40,
1749
+ "end": 49,
1750
+ "label": "RELIGION",
1751
+ "text": "Judenturn"
1752
+ }
1753
+ ],
1754
+ "expected": "Aus dem Scan: [PERSON], Bekenntnis [RELIGION]."
1755
+ },
1756
+ {
1757
+ "id": "holdout03d-110",
1758
+ "language": "en",
1759
+ "scenario": "ocr_noise",
1760
+ "ocr_noise": true,
1761
+ "text": "The noisy note names Orin Wexford and Cath0licism.",
1762
+ "spans": [
1763
+ {
1764
+ "start": 21,
1765
+ "end": 33,
1766
+ "label": "PERSON",
1767
+ "text": "Orin Wexford"
1768
+ },
1769
+ {
1770
+ "start": 38,
1771
+ "end": 49,
1772
+ "label": "RELIGION",
1773
+ "text": "Cath0licism"
1774
+ }
1775
+ ],
1776
+ "expected": "The noisy note names [PERSON] and [RELIGION]."
1777
+ },
1778
+ {
1779
+ "id": "holdout03d-111",
1780
+ "language": "de",
1781
+ "scenario": "ocr_noise",
1782
+ "ocr_noise": true,
1783
+ "text": "Der OCR-Wert konfesslonsfrei bleibt vertraulich.",
1784
+ "spans": [
1785
+ {
1786
+ "start": 13,
1787
+ "end": 28,
1788
+ "label": "RELIGION",
1789
+ "text": "konfesslonsfrei"
1790
+ }
1791
+ ],
1792
+ "expected": "Der OCR-Wert [RELIGION] bleibt vertraulich."
1793
+ },
1794
+ {
1795
+ "id": "holdout03d-112",
1796
+ "language": "en",
1797
+ "scenario": "ocr_noise",
1798
+ "ocr_noise": true,
1799
+ "text": "Scanned status text: agn0stic.",
1800
+ "spans": [
1801
+ {
1802
+ "start": 21,
1803
+ "end": 29,
1804
+ "label": "RELIGION",
1805
+ "text": "agn0stic"
1806
+ }
1807
+ ],
1808
+ "expected": "Scanned status text: [RELIGION]."
1809
+ },
1810
+ {
1811
+ "id": "holdout03d-113",
1812
+ "language": "de",
1813
+ "scenario": "multiple_entities",
1814
+ "ocr_noise": false,
1815
+ "text": "Silja Dornbach aus Rostock meldet Judentum beim Morgenkai Institut.",
1816
+ "spans": [
1817
+ {
1818
+ "start": 0,
1819
+ "end": 14,
1820
+ "label": "PERSON",
1821
+ "text": "Silja Dornbach"
1822
+ },
1823
+ {
1824
+ "start": 19,
1825
+ "end": 26,
1826
+ "label": "CITY",
1827
+ "text": "Rostock"
1828
+ },
1829
+ {
1830
+ "start": 34,
1831
+ "end": 42,
1832
+ "label": "RELIGION",
1833
+ "text": "Judentum"
1834
+ },
1835
+ {
1836
+ "start": 48,
1837
+ "end": 66,
1838
+ "label": "ORGANIZATION",
1839
+ "text": "Morgenkai Institut"
1840
+ }
1841
+ ],
1842
+ "expected": "[PERSON] aus [CITY] meldet [RELIGION] beim [ORGANIZATION]."
1843
+ },
1844
+ {
1845
+ "id": "holdout03d-114",
1846
+ "language": "en",
1847
+ "scenario": "multiple_entities",
1848
+ "ocr_noise": false,
1849
+ "text": "Nolan Briarwood in York lists Jainism on the Cobalt Quay Trust form.",
1850
+ "spans": [
1851
+ {
1852
+ "start": 0,
1853
+ "end": 15,
1854
+ "label": "PERSON",
1855
+ "text": "Nolan Briarwood"
1856
+ },
1857
+ {
1858
+ "start": 19,
1859
+ "end": 23,
1860
+ "label": "CITY",
1861
+ "text": "York"
1862
+ },
1863
+ {
1864
+ "start": 30,
1865
+ "end": 37,
1866
+ "label": "RELIGION",
1867
+ "text": "Jainism"
1868
+ },
1869
+ {
1870
+ "start": 45,
1871
+ "end": 62,
1872
+ "label": "ORGANIZATION",
1873
+ "text": "Cobalt Quay Trust"
1874
+ }
1875
+ ],
1876
+ "expected": "[PERSON] in [CITY] lists [RELIGION] on the [ORGANIZATION] form."
1877
+ },
1878
+ {
1879
+ "id": "holdout03d-115",
1880
+ "language": "de",
1881
+ "scenario": "multiple_entities",
1882
+ "ocr_noise": false,
1883
+ "text": "Bernsteinpfad Stiftung vermerkt Henrik Wiesenau als konfessionslos.",
1884
+ "spans": [
1885
+ {
1886
+ "start": 0,
1887
+ "end": 22,
1888
+ "label": "ORGANIZATION",
1889
+ "text": "Bernsteinpfad Stiftung"
1890
+ },
1891
+ {
1892
+ "start": 32,
1893
+ "end": 47,
1894
+ "label": "PERSON",
1895
+ "text": "Henrik Wiesenau"
1896
+ },
1897
+ {
1898
+ "start": 52,
1899
+ "end": 66,
1900
+ "label": "RELIGION",
1901
+ "text": "konfessionslos"
1902
+ }
1903
+ ],
1904
+ "expected": "[ORGANIZATION] vermerkt [PERSON] als [RELIGION]."
1905
+ },
1906
+ {
1907
+ "id": "holdout03d-116",
1908
+ "language": "en",
1909
+ "scenario": "multiple_entities",
1910
+ "ocr_noise": false,
1911
+ "text": "The Silver Estuary Council stores Ada Merefield's atheism response.",
1912
+ "spans": [
1913
+ {
1914
+ "start": 4,
1915
+ "end": 26,
1916
+ "label": "ORGANIZATION",
1917
+ "text": "Silver Estuary Council"
1918
+ },
1919
+ {
1920
+ "start": 34,
1921
+ "end": 47,
1922
+ "label": "PERSON",
1923
+ "text": "Ada Merefield"
1924
+ },
1925
+ {
1926
+ "start": 50,
1927
+ "end": 57,
1928
+ "label": "RELIGION",
1929
+ "text": "atheism"
1930
+ }
1931
+ ],
1932
+ "expected": "The [ORGANIZATION] stores [PERSON]'s [RELIGION] response."
1933
+ },
1934
+ {
1935
+ "id": "holdout03d-117",
1936
+ "language": "de",
1937
+ "scenario": "multiple_entities",
1938
+ "ocr_noise": false,
1939
+ "text": "Ida Talbrück nennt Islam; Kontakt: ida.tal@example.invalid.",
1940
+ "spans": [
1941
+ {
1942
+ "start": 0,
1943
+ "end": 12,
1944
+ "label": "PERSON",
1945
+ "text": "Ida Talbrück"
1946
+ },
1947
+ {
1948
+ "start": 19,
1949
+ "end": 24,
1950
+ "label": "RELIGION",
1951
+ "text": "Islam"
1952
+ },
1953
+ {
1954
+ "start": 35,
1955
+ "end": 58,
1956
+ "label": "EMAIL",
1957
+ "text": "ida.tal@example.invalid"
1958
+ }
1959
+ ],
1960
+ "expected": "[PERSON] nennt [RELIGION]; Kontakt: [EMAIL]."
1961
+ },
1962
+ {
1963
+ "id": "holdout03d-118",
1964
+ "language": "en",
1965
+ "scenario": "multiple_entities",
1966
+ "ocr_noise": false,
1967
+ "text": "Lyle Ashcombe wrote Buddhism; contact lyle.a@example.invalid.",
1968
+ "spans": [
1969
+ {
1970
+ "start": 0,
1971
+ "end": 13,
1972
+ "label": "PERSON",
1973
+ "text": "Lyle Ashcombe"
1974
+ },
1975
+ {
1976
+ "start": 20,
1977
+ "end": 28,
1978
+ "label": "RELIGION",
1979
+ "text": "Buddhism"
1980
+ },
1981
+ {
1982
+ "start": 38,
1983
+ "end": 60,
1984
+ "label": "EMAIL",
1985
+ "text": "lyle.a@example.invalid"
1986
+ }
1987
+ ],
1988
+ "expected": "[PERSON] wrote [RELIGION]; contact [EMAIL]."
1989
+ },
1990
+ {
1991
+ "id": "holdout03d-119",
1992
+ "language": "de",
1993
+ "scenario": "multiple_entities",
1994
+ "ocr_noise": false,
1995
+ "text": "Rückfrage an Arvid Sandborn: +49 381 482701; Status religionslos.",
1996
+ "spans": [
1997
+ {
1998
+ "start": 13,
1999
+ "end": 27,
2000
+ "label": "PERSON",
2001
+ "text": "Arvid Sandborn"
2002
+ },
2003
+ {
2004
+ "start": 29,
2005
+ "end": 43,
2006
+ "label": "PHONE",
2007
+ "text": "+49 381 482701"
2008
+ },
2009
+ {
2010
+ "start": 52,
2011
+ "end": 64,
2012
+ "label": "RELIGION",
2013
+ "text": "religionslos"
2014
+ }
2015
+ ],
2016
+ "expected": "Rückfrage an [PERSON]: [PHONE]; Status [RELIGION]."
2017
+ },
2018
+ {
2019
+ "id": "holdout03d-120",
2020
+ "language": "en",
2021
+ "scenario": "multiple_entities",
2022
+ "ocr_noise": false,
2023
+ "text": "Call Mira Fenwick at +44 1904 582701; belief Hinduism.",
2024
+ "spans": [
2025
+ {
2026
+ "start": 5,
2027
+ "end": 17,
2028
+ "label": "PERSON",
2029
+ "text": "Mira Fenwick"
2030
+ },
2031
+ {
2032
+ "start": 21,
2033
+ "end": 36,
2034
+ "label": "PHONE",
2035
+ "text": "+44 1904 582701"
2036
+ },
2037
+ {
2038
+ "start": 45,
2039
+ "end": 53,
2040
+ "label": "RELIGION",
2041
+ "text": "Hinduism"
2042
+ }
2043
+ ],
2044
+ "expected": "Call [PERSON] at [PHONE]; belief [RELIGION]."
2045
+ },
2046
+ {
2047
+ "id": "holdout03d-121",
2048
+ "language": "de",
2049
+ "scenario": "multiple_entities",
2050
+ "ocr_noise": false,
2051
+ "text": "Erfurt: Svea Morgenau korrigiert katholisch zu agnostisch.",
2052
+ "spans": [
2053
+ {
2054
+ "start": 0,
2055
+ "end": 6,
2056
+ "label": "CITY",
2057
+ "text": "Erfurt"
2058
+ },
2059
+ {
2060
+ "start": 8,
2061
+ "end": 21,
2062
+ "label": "PERSON",
2063
+ "text": "Svea Morgenau"
2064
+ },
2065
+ {
2066
+ "start": 33,
2067
+ "end": 43,
2068
+ "label": "RELIGION",
2069
+ "text": "katholisch"
2070
+ },
2071
+ {
2072
+ "start": 47,
2073
+ "end": 57,
2074
+ "label": "RELIGION",
2075
+ "text": "agnostisch"
2076
+ }
2077
+ ],
2078
+ "expected": "[CITY]: [PERSON] korrigiert [RELIGION] zu [RELIGION]."
2079
+ },
2080
+ {
2081
+ "id": "holdout03d-122",
2082
+ "language": "en",
2083
+ "scenario": "multiple_entities",
2084
+ "ocr_noise": false,
2085
+ "text": "Bristol: Owen Thornley replaced Christianity with no religion.",
2086
+ "spans": [
2087
+ {
2088
+ "start": 0,
2089
+ "end": 7,
2090
+ "label": "CITY",
2091
+ "text": "Bristol"
2092
+ },
2093
+ {
2094
+ "start": 9,
2095
+ "end": 22,
2096
+ "label": "PERSON",
2097
+ "text": "Owen Thornley"
2098
+ },
2099
+ {
2100
+ "start": 32,
2101
+ "end": 44,
2102
+ "label": "RELIGION",
2103
+ "text": "Christianity"
2104
+ },
2105
+ {
2106
+ "start": 50,
2107
+ "end": 61,
2108
+ "label": "RELIGION",
2109
+ "text": "no religion"
2110
+ }
2111
+ ],
2112
+ "expected": "[CITY]: [PERSON] replaced [RELIGION] with [RELIGION]."
2113
+ },
2114
+ {
2115
+ "id": "holdout03d-123",
2116
+ "language": "de",
2117
+ "scenario": "multiple_entities",
2118
+ "ocr_noise": false,
2119
+ "text": "Jona Falkenau und Jona Falkenau erscheinen mit Sikhismus zweimal.",
2120
+ "spans": [
2121
+ {
2122
+ "start": 0,
2123
+ "end": 13,
2124
+ "label": "PERSON",
2125
+ "text": "Jona Falkenau"
2126
+ },
2127
+ {
2128
+ "start": 18,
2129
+ "end": 31,
2130
+ "label": "PERSON",
2131
+ "text": "Jona Falkenau"
2132
+ },
2133
+ {
2134
+ "start": 47,
2135
+ "end": 56,
2136
+ "label": "RELIGION",
2137
+ "text": "Sikhismus"
2138
+ }
2139
+ ],
2140
+ "expected": "[PERSON] und [PERSON] erscheinen mit [RELIGION] zweimal."
2141
+ },
2142
+ {
2143
+ "id": "holdout03d-124",
2144
+ "language": "en",
2145
+ "scenario": "multiple_entities",
2146
+ "ocr_noise": false,
2147
+ "text": "Esme Rowanby repeated Taoism, then signed Esme Rowanby again.",
2148
+ "spans": [
2149
+ {
2150
+ "start": 0,
2151
+ "end": 12,
2152
+ "label": "PERSON",
2153
+ "text": "Esme Rowanby"
2154
+ },
2155
+ {
2156
+ "start": 22,
2157
+ "end": 28,
2158
+ "label": "RELIGION",
2159
+ "text": "Taoism"
2160
+ },
2161
+ {
2162
+ "start": 42,
2163
+ "end": 54,
2164
+ "label": "PERSON",
2165
+ "text": "Esme Rowanby"
2166
+ }
2167
+ ],
2168
+ "expected": "[PERSON] repeated [RELIGION], then signed [PERSON] again."
2169
+ },
2170
+ {
2171
+ "id": "holdout03d-125",
2172
+ "language": "de",
2173
+ "scenario": "multiple_entities",
2174
+ "ocr_noise": false,
2175
+ "text": "Interner Datensatz des Hafenbogen Archivs: Kiel, evangelisch.",
2176
+ "spans": [
2177
+ {
2178
+ "start": 23,
2179
+ "end": 41,
2180
+ "label": "ORGANIZATION",
2181
+ "text": "Hafenbogen Archivs"
2182
+ },
2183
+ {
2184
+ "start": 43,
2185
+ "end": 47,
2186
+ "label": "CITY",
2187
+ "text": "Kiel"
2188
+ },
2189
+ {
2190
+ "start": 49,
2191
+ "end": 60,
2192
+ "label": "RELIGION",
2193
+ "text": "evangelisch"
2194
+ }
2195
+ ],
2196
+ "expected": "Interner Datensatz des [ORGANIZATION]: [CITY], [RELIGION]."
2197
+ },
2198
+ {
2199
+ "id": "holdout03d-126",
2200
+ "language": "en",
2201
+ "scenario": "multiple_entities",
2202
+ "ocr_noise": false,
2203
+ "text": "A Maple Tide Institute record from Durham mentions Judaism.",
2204
+ "spans": [
2205
+ {
2206
+ "start": 2,
2207
+ "end": 22,
2208
+ "label": "ORGANIZATION",
2209
+ "text": "Maple Tide Institute"
2210
+ },
2211
+ {
2212
+ "start": 35,
2213
+ "end": 41,
2214
+ "label": "CITY",
2215
+ "text": "Durham"
2216
+ },
2217
+ {
2218
+ "start": 51,
2219
+ "end": 58,
2220
+ "label": "RELIGION",
2221
+ "text": "Judaism"
2222
+ }
2223
+ ],
2224
+ "expected": "A [ORGANIZATION] record from [CITY] mentions [RELIGION]."
2225
+ },
2226
+ {
2227
+ "id": "holdout03d-127",
2228
+ "language": "de",
2229
+ "scenario": "multiple_entities",
2230
+ "ocr_noise": false,
2231
+ "text": "Liv Hohenrain nennt Bonn und Bahai; liv.h@example.invalid bleibt intern.",
2232
+ "spans": [
2233
+ {
2234
+ "start": 0,
2235
+ "end": 13,
2236
+ "label": "PERSON",
2237
+ "text": "Liv Hohenrain"
2238
+ },
2239
+ {
2240
+ "start": 20,
2241
+ "end": 24,
2242
+ "label": "CITY",
2243
+ "text": "Bonn"
2244
+ },
2245
+ {
2246
+ "start": 29,
2247
+ "end": 34,
2248
+ "label": "RELIGION",
2249
+ "text": "Bahai"
2250
+ },
2251
+ {
2252
+ "start": 36,
2253
+ "end": 57,
2254
+ "label": "EMAIL",
2255
+ "text": "liv.h@example.invalid"
2256
+ }
2257
+ ],
2258
+ "expected": "[PERSON] nennt [CITY] und [RELIGION]; [EMAIL] bleibt intern."
2259
+ },
2260
+ {
2261
+ "id": "holdout03d-128",
2262
+ "language": "en",
2263
+ "scenario": "multiple_entities",
2264
+ "ocr_noise": false,
2265
+ "text": "Reed Alderwick, Bath, agnosticism; reed.a@example.invalid is private.",
2266
+ "spans": [
2267
+ {
2268
+ "start": 0,
2269
+ "end": 14,
2270
+ "label": "PERSON",
2271
+ "text": "Reed Alderwick"
2272
+ },
2273
+ {
2274
+ "start": 16,
2275
+ "end": 20,
2276
+ "label": "CITY",
2277
+ "text": "Bath"
2278
+ },
2279
+ {
2280
+ "start": 22,
2281
+ "end": 33,
2282
+ "label": "RELIGION",
2283
+ "text": "agnosticism"
2284
+ },
2285
+ {
2286
+ "start": 35,
2287
+ "end": 57,
2288
+ "label": "EMAIL",
2289
+ "text": "reed.a@example.invalid"
2290
+ }
2291
+ ],
2292
+ "expected": "[PERSON], [CITY], [RELIGION]; [EMAIL] is private."
2293
+ },
2294
+ {
2295
+ "id": "holdout03d-129",
2296
+ "language": "de",
2297
+ "scenario": "formats_repetition",
2298
+ "ocr_noise": false,
2299
+ "text": "| Bekenntnis | Islam | Freigabe | nein |",
2300
+ "spans": [
2301
+ {
2302
+ "start": 15,
2303
+ "end": 20,
2304
+ "label": "RELIGION",
2305
+ "text": "Islam"
2306
+ }
2307
+ ],
2308
+ "expected": "| Bekenntnis | [RELIGION] | Freigabe | nein |"
2309
+ },
2310
+ {
2311
+ "id": "holdout03d-130",
2312
+ "language": "en",
2313
+ "scenario": "formats_repetition",
2314
+ "ocr_noise": false,
2315
+ "text": "{\"belief\":\"Buddhism\",\"public\":false}",
2316
+ "spans": [
2317
+ {
2318
+ "start": 11,
2319
+ "end": 19,
2320
+ "label": "RELIGION",
2321
+ "text": "Buddhism"
2322
+ }
2323
+ ],
2324
+ "expected": "{\"belief\":\"[RELIGION]\",\"public\":false}"
2325
+ },
2326
+ {
2327
+ "id": "holdout03d-131",
2328
+ "language": "de",
2329
+ "scenario": "formats_repetition",
2330
+ "ocr_noise": false,
2331
+ "text": "Notiz A: konfessionsfrei. Notiz B: konfessionsfrei.",
2332
+ "spans": [
2333
+ {
2334
+ "start": 9,
2335
+ "end": 24,
2336
+ "label": "RELIGION",
2337
+ "text": "konfessionsfrei"
2338
+ },
2339
+ {
2340
+ "start": 35,
2341
+ "end": 50,
2342
+ "label": "RELIGION",
2343
+ "text": "konfessionsfrei"
2344
+ }
2345
+ ],
2346
+ "expected": "Notiz A: [RELIGION]. Notiz B: [RELIGION]."
2347
+ },
2348
+ {
2349
+ "id": "holdout03d-132",
2350
+ "language": "en",
2351
+ "scenario": "formats_repetition",
2352
+ "ocr_noise": false,
2353
+ "text": "Row one: atheist; row two: atheist.",
2354
+ "spans": [
2355
+ {
2356
+ "start": 9,
2357
+ "end": 16,
2358
+ "label": "RELIGION",
2359
+ "text": "atheist"
2360
+ },
2361
+ {
2362
+ "start": 27,
2363
+ "end": 34,
2364
+ "label": "RELIGION",
2365
+ "text": "atheist"
2366
+ }
2367
+ ],
2368
+ "expected": "Row one: [RELIGION]; row two: [RELIGION]."
2369
+ },
2370
+ {
2371
+ "id": "holdout03d-133",
2372
+ "language": "de",
2373
+ "scenario": "formats_repetition",
2374
+ "ocr_noise": false,
2375
+ "text": "RELIGION=Judentum; QUELLE=Selbstauskunft",
2376
+ "spans": [
2377
+ {
2378
+ "start": 9,
2379
+ "end": 17,
2380
+ "label": "RELIGION",
2381
+ "text": "Judentum"
2382
+ }
2383
+ ],
2384
+ "expected": "RELIGION=[RELIGION]; QUELLE=Selbstauskunft"
2385
+ },
2386
+ {
2387
+ "id": "holdout03d-134",
2388
+ "language": "en",
2389
+ "scenario": "formats_repetition",
2390
+ "ocr_noise": false,
2391
+ "text": "BELIEF=Shinto; SOURCE=self-report",
2392
+ "spans": [
2393
+ {
2394
+ "start": 7,
2395
+ "end": 13,
2396
+ "label": "RELIGION",
2397
+ "text": "Shinto"
2398
+ }
2399
+ ],
2400
+ "expected": "BELIEF=[RELIGION]; SOURCE=self-report"
2401
+ },
2402
+ {
2403
+ "id": "holdout03d-135",
2404
+ "language": "de",
2405
+ "scenario": "formats_repetition",
2406
+ "ocr_noise": false,
2407
+ "text": "• Weltanschauung: Agnostizismus\n• Weitergabe: gesperrt",
2408
+ "spans": [
2409
+ {
2410
+ "start": 18,
2411
+ "end": 31,
2412
+ "label": "RELIGION",
2413
+ "text": "Agnostizismus"
2414
+ }
2415
+ ],
2416
+ "expected": "• Weltanschauung: [RELIGION]\n• Weitergabe: gesperrt"
2417
+ },
2418
+ {
2419
+ "id": "holdout03d-136",
2420
+ "language": "en",
2421
+ "scenario": "formats_repetition",
2422
+ "ocr_noise": false,
2423
+ "text": "1. Faith: Sikhism\n2. Access: restricted",
2424
+ "spans": [
2425
+ {
2426
+ "start": 10,
2427
+ "end": 17,
2428
+ "label": "RELIGION",
2429
+ "text": "Sikhism"
2430
+ }
2431
+ ],
2432
+ "expected": "1. Faith: [RELIGION]\n2. Access: restricted"
2433
+ },
2434
+ {
2435
+ "id": "holdout03d-137",
2436
+ "language": "de",
2437
+ "scenario": "formats_repetition",
2438
+ "ocr_noise": false,
2439
+ "text": "Seitenrand Katholizismus / Fußnote Katholizismus.",
2440
+ "spans": [
2441
+ {
2442
+ "start": 11,
2443
+ "end": 24,
2444
+ "label": "RELIGION",
2445
+ "text": "Katholizismus"
2446
+ },
2447
+ {
2448
+ "start": 35,
2449
+ "end": 48,
2450
+ "label": "RELIGION",
2451
+ "text": "Katholizismus"
2452
+ }
2453
+ ],
2454
+ "expected": "Seitenrand [RELIGION] / Fußnote [RELIGION]."
2455
+ },
2456
+ {
2457
+ "id": "holdout03d-138",
2458
+ "language": "en",
2459
+ "scenario": "formats_repetition",
2460
+ "ocr_noise": false,
2461
+ "text": "Margin Hinduism; footnote Hinduism.",
2462
+ "spans": [
2463
+ {
2464
+ "start": 7,
2465
+ "end": 15,
2466
+ "label": "RELIGION",
2467
+ "text": "Hinduism"
2468
+ },
2469
+ {
2470
+ "start": 26,
2471
+ "end": 34,
2472
+ "label": "RELIGION",
2473
+ "text": "Hinduism"
2474
+ }
2475
+ ],
2476
+ "expected": "Margin [RELIGION]; footnote [RELIGION]."
2477
+ },
2478
+ {
2479
+ "id": "holdout03d-139",
2480
+ "language": "de",
2481
+ "scenario": "formats_repetition",
2482
+ "ocr_noise": false,
2483
+ "text": "Eingabefeld «ohne Konfession» — nur intern.",
2484
+ "spans": [
2485
+ {
2486
+ "start": 13,
2487
+ "end": 28,
2488
+ "label": "RELIGION",
2489
+ "text": "ohne Konfession"
2490
+ }
2491
+ ],
2492
+ "expected": "Eingabefeld «[RELIGION]» — nur intern."
2493
+ },
2494
+ {
2495
+ "id": "holdout03d-140",
2496
+ "language": "en",
2497
+ "scenario": "formats_repetition",
2498
+ "ocr_noise": false,
2499
+ "text": "Checkbox ☑ non-religious; do not publish.",
2500
+ "spans": [
2501
+ {
2502
+ "start": 11,
2503
+ "end": 24,
2504
+ "label": "RELIGION",
2505
+ "text": "non-religious"
2506
+ }
2507
+ ],
2508
+ "expected": "Checkbox ☑ [RELIGION]; do not publish."
2509
+ },
2510
+ {
2511
+ "id": "holdout03d-141",
2512
+ "language": "de",
2513
+ "scenario": "formats_repetition",
2514
+ "ocr_noise": false,
2515
+ "text": "Christentum → Atheismus ist als Änderung notiert.",
2516
+ "spans": [
2517
+ {
2518
+ "start": 0,
2519
+ "end": 11,
2520
+ "label": "RELIGION",
2521
+ "text": "Christentum"
2522
+ },
2523
+ {
2524
+ "start": 14,
2525
+ "end": 23,
2526
+ "label": "RELIGION",
2527
+ "text": "Atheismus"
2528
+ }
2529
+ ],
2530
+ "expected": "[RELIGION] → [RELIGION] ist als Änderung notiert."
2531
+ },
2532
+ {
2533
+ "id": "holdout03d-142",
2534
+ "language": "en",
2535
+ "scenario": "formats_repetition",
2536
+ "ocr_noise": false,
2537
+ "text": "Christianity → agnosticism is the recorded correction.",
2538
+ "spans": [
2539
+ {
2540
+ "start": 0,
2541
+ "end": 12,
2542
+ "label": "RELIGION",
2543
+ "text": "Christianity"
2544
+ },
2545
+ {
2546
+ "start": 15,
2547
+ "end": 26,
2548
+ "label": "RELIGION",
2549
+ "text": "agnosticism"
2550
+ }
2551
+ ],
2552
+ "expected": "[RELIGION] → [RELIGION] is the recorded correction."
2553
+ },
2554
+ {
2555
+ "id": "holdout03d-143",
2556
+ "language": "de",
2557
+ "scenario": "formats_repetition",
2558
+ "ocr_noise": false,
2559
+ "text": "Bekenntnis\tBuddhismus\tgeprüft=offen",
2560
+ "spans": [
2561
+ {
2562
+ "start": 11,
2563
+ "end": 21,
2564
+ "label": "RELIGION",
2565
+ "text": "Buddhismus"
2566
+ }
2567
+ ],
2568
+ "expected": "Bekenntnis\t[RELIGION]\tgeprüft=offen"
2569
+ },
2570
+ {
2571
+ "id": "holdout03d-144",
2572
+ "language": "en",
2573
+ "scenario": "formats_repetition",
2574
+ "ocr_noise": false,
2575
+ "text": "religion\tJainism\treview=pending",
2576
+ "spans": [
2577
+ {
2578
+ "start": 9,
2579
+ "end": 16,
2580
+ "label": "RELIGION",
2581
+ "text": "Jainism"
2582
+ }
2583
+ ],
2584
+ "expected": "religion\t[RELIGION]\treview=pending"
2585
+ },
2586
+ {
2587
+ "id": "holdout03d-145",
2588
+ "language": "de",
2589
+ "scenario": "negative_controls",
2590
+ "ocr_noise": false,
2591
+ "text": "Die Lieferung enthält zwölf Schrauben und einen Lieferschein.",
2592
+ "spans": [],
2593
+ "expected": "Die Lieferung enthält zwölf Schrauben und einen Lieferschein."
2594
+ },
2595
+ {
2596
+ "id": "holdout03d-146",
2597
+ "language": "en",
2598
+ "scenario": "negative_controls",
2599
+ "ocr_noise": false,
2600
+ "text": "A blue folder sits beside the empty filing tray.",
2601
+ "spans": [],
2602
+ "expected": "A blue folder sits beside the empty filing tray."
2603
+ },
2604
+ {
2605
+ "id": "holdout03d-147",
2606
+ "language": "de",
2607
+ "scenario": "negative_controls",
2608
+ "ocr_noise": false,
2609
+ "text": "Das Formular enthält keine ausgefüllten persönlichen Felder.",
2610
+ "spans": [],
2611
+ "expected": "Das Formular enthält keine ausgefüllten persönlichen Felder."
2612
+ },
2613
+ {
2614
+ "id": "holdout03d-148",
2615
+ "language": "en",
2616
+ "scenario": "negative_controls",
2617
+ "ocr_noise": false,
2618
+ "text": "The spelling correction changes a comma to a semicolon.",
2619
+ "spans": [],
2620
+ "expected": "The spelling correction changes a comma to a semicolon."
2621
+ },
2622
+ {
2623
+ "id": "holdout03d-149",
2624
+ "language": "de",
2625
+ "scenario": "negative_controls",
2626
+ "ocr_noise": false,
2627
+ "text": "Der Kalender zeigt nur Wochentage ohne Termine.",
2628
+ "spans": [],
2629
+ "expected": "Der Kalender zeigt nur Wochentage ohne Termine."
2630
+ },
2631
+ {
2632
+ "id": "holdout03d-150",
2633
+ "language": "en",
2634
+ "scenario": "negative_controls",
2635
+ "ocr_noise": false,
2636
+ "text": "The maintenance note records a loose hinge.",
2637
+ "spans": [],
2638
+ "expected": "The maintenance note records a loose hinge."
2639
+ },
2640
+ {
2641
+ "id": "holdout03d-151",
2642
+ "language": "de",
2643
+ "scenario": "negative_controls",
2644
+ "ocr_noise": false,
2645
+ "text": "Eine Überschrift erklärt die Bedienung der Taschenlampe.",
2646
+ "spans": [],
2647
+ "expected": "Eine Überschrift erklärt die Bedienung der Taschenlampe."
2648
+ },
2649
+ {
2650
+ "id": "holdout03d-152",
2651
+ "language": "en",
2652
+ "scenario": "negative_controls",
2653
+ "ocr_noise": false,
2654
+ "text": "A measurement table lists height and width without identifiers.",
2655
+ "spans": [],
2656
+ "expected": "A measurement table lists height and width without identifiers."
2657
+ },
2658
+ {
2659
+ "id": "holdout03d-153",
2660
+ "language": "de",
2661
+ "scenario": "negative_controls",
2662
+ "ocr_noise": false,
2663
+ "text": "Das Prüfprotokoll enthält ausschließlich Materialnummern aus Buchstaben.",
2664
+ "spans": [],
2665
+ "expected": "Das Prüfprotokoll enthält ausschließlich Materialnummern aus Buchstaben."
2666
+ },
2667
+ {
2668
+ "id": "holdout03d-154",
2669
+ "language": "en",
2670
+ "scenario": "negative_controls",
2671
+ "ocr_noise": false,
2672
+ "text": "The anonymous inventory includes pencils and unmarked boxes.",
2673
+ "spans": [],
2674
+ "expected": "The anonymous inventory includes pencils and unmarked boxes."
2675
+ },
2676
+ {
2677
+ "id": "holdout03d-155",
2678
+ "language": "de",
2679
+ "scenario": "negative_controls",
2680
+ "ocr_noise": false,
2681
+ "text": "Die Zutatenliste nennt Mehl, Wasser und Salz.",
2682
+ "spans": [],
2683
+ "expected": "Die Zutatenliste nennt Mehl, Wasser und Salz."
2684
+ },
2685
+ {
2686
+ "id": "holdout03d-156",
2687
+ "language": "en",
2688
+ "scenario": "negative_controls",
2689
+ "ocr_noise": false,
2690
+ "text": "The diagram shows arrows connecting unlabeled circles.",
2691
+ "spans": [],
2692
+ "expected": "The diagram shows arrows connecting unlabeled circles."
2693
+ },
2694
+ {
2695
+ "id": "holdout03d-157",
2696
+ "language": "de",
2697
+ "scenario": "negative_controls",
2698
+ "ocr_noise": false,
2699
+ "text": "Der Absatz behandelt die Grammatik unpersönlicher Sätze.",
2700
+ "spans": [],
2701
+ "expected": "Der Absatz behandelt die Grammatik unpersönlicher Sätze."
2702
+ },
2703
+ {
2704
+ "id": "holdout03d-158",
2705
+ "language": "en",
2706
+ "scenario": "negative_controls",
2707
+ "ocr_noise": false,
2708
+ "text": "A draft contains only instructions for replacing printer paper.",
2709
+ "spans": [],
2710
+ "expected": "A draft contains only instructions for replacing printer paper."
2711
+ },
2712
+ {
2713
+ "id": "holdout03d-159",
2714
+ "language": "de",
2715
+ "scenario": "negative_controls",
2716
+ "ocr_noise": false,
2717
+ "text": "Die leere Tabelle hat Spalten für Farbe und Gewicht.",
2718
+ "spans": [],
2719
+ "expected": "Die leere Tabelle hat Spalten für Farbe und Gewicht."
2720
+ },
2721
+ {
2722
+ "id": "holdout03d-160",
2723
+ "language": "en",
2724
+ "scenario": "negative_controls",
2725
+ "ocr_noise": false,
2726
+ "text": "No religious status, personal name, address or contact is supplied.",
2727
+ "spans": [],
2728
+ "expected": "No religious status, personal name, address or contact is supplied."
2729
+ }
2730
+ ]
ocr/anonymization/fixtures/holdout03d/freeze.json ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": "frozen-holdout03d/v1",
3
+ "cases": 160,
4
+ "sha256": "7180e176f6d8bd259da56ad729ede6fd2c798aca15a786ba0ff0adcd862f21a1",
5
+ "source_head": "4c5cbbc30801f5911dcf52e1997a70404026d425",
6
+ "model_revision": "ba967ee030b7e679dd5422cb16fcd1b379da38e1",
7
+ "prompts": {
8
+ "PERSON": "person",
9
+ "CITY": "location",
10
+ "RELIGION": "religion",
11
+ "EMAIL": "email address",
12
+ "PHONE": "phone number",
13
+ "ORGANIZATION": "organization"
14
+ },
15
+ "threshold": 0.5,
16
+ "religion_terms": [
17
+ "religionslos",
18
+ "konfessionslos",
19
+ "konfessionsfrei",
20
+ "ohne Konfession",
21
+ "nicht religiös",
22
+ "no religious affiliation",
23
+ "no religion",
24
+ "nonreligious",
25
+ "non-religious",
26
+ "atheist",
27
+ "atheists",
28
+ "atheism",
29
+ "atheistisch",
30
+ "Atheismus",
31
+ "agnostic",
32
+ "agnostics",
33
+ "agnosticism",
34
+ "agnostisch",
35
+ "Agnostizismus",
36
+ "Christentum",
37
+ "Christianity",
38
+ "Christen",
39
+ "Christian",
40
+ "Christians",
41
+ "christlich",
42
+ "christliche",
43
+ "christlichen",
44
+ "Katholizismus",
45
+ "Catholicism",
46
+ "katholisch",
47
+ "katholische",
48
+ "katholischen",
49
+ "Catholic",
50
+ "Catholics",
51
+ "Protestantismus",
52
+ "Protestantism",
53
+ "Protestant",
54
+ "Protestants",
55
+ "evangelisch",
56
+ "evangelische",
57
+ "evangelischen",
58
+ "Judentum",
59
+ "Judaism",
60
+ "jüdisch",
61
+ "jüdische",
62
+ "jüdischen",
63
+ "Jewish",
64
+ "Jude",
65
+ "Jüdin",
66
+ "Juden",
67
+ "Jews",
68
+ "Islam",
69
+ "Muslim",
70
+ "Muslims",
71
+ "muslimisch",
72
+ "muslimische",
73
+ "muslimischen",
74
+ "islamisch",
75
+ "islamische",
76
+ "islamischen",
77
+ "Buddhismus",
78
+ "Buddhism",
79
+ "Buddhist",
80
+ "Buddhists",
81
+ "buddhistisch",
82
+ "buddhistische",
83
+ "buddhistischen",
84
+ "Hinduismus",
85
+ "Hinduism",
86
+ "Hindu",
87
+ "Hindus",
88
+ "hinduistisch",
89
+ "hinduistische",
90
+ "hinduistischen",
91
+ "Sikhismus",
92
+ "Sikhism",
93
+ "Sikh",
94
+ "Sikhs",
95
+ "sikhistisch",
96
+ "Jainismus",
97
+ "Jainism",
98
+ "Jain",
99
+ "Jains",
100
+ "jainistisch",
101
+ "Bahai",
102
+ "Baháʼí Faith",
103
+ "Bahá'í",
104
+ "Baha'i",
105
+ "Shinto",
106
+ "Shintoismus",
107
+ "Shintoism",
108
+ "Taoismus",
109
+ "Taoism",
110
+ "Daoismus",
111
+ "Daoism",
112
+ "Zoroastrismus",
113
+ "Zoroastrianism",
114
+ "Orthodox Christianity",
115
+ "Orthodoxie"
116
+ ],
117
+ "implementation_sha256": {
118
+ "ocr/anonymization/recognizer.py": "f695a30f0ff8540ba345d5557f0bf2bfc225635b228010da6ee6f5ccbc71ce45",
119
+ "ocr/anonymization/normalization.py": "3b2598200933a86cedf2dd20affb911c4ad1ac4f7fdc00182c4044b7a2873f10",
120
+ "ocr/anonymization/detectors.py": "c543532c579e8880e4915b3e669063f11e8e39034886f889e9e4e20942bf6a10",
121
+ "ocr/anonymization/terminology.py": "370b61beb1b5339549f2c8143a31b546f4a32dbbabd26c061f009341de16babd",
122
+ "ocr/anonymization/engine.py": "d784774d57e35b25fdac5bef095df4859db9d606e83759625526cbf90cfc8763",
123
+ "ocr/anonymization/replacement.py": "1f048dc237a3355c400a6f085b13848e1c0c49fe2f7f9d5ab8203114ef3e4cd8",
124
+ "ocr/anonymization/evaluate.py": "b9ed1453d6b7c704be3eef18b2a32b9cf8e857075355c42025e5d3bdc863d6b0",
125
+ "ocr/anonymization/qualify03d.py": "ead5767304b8e10e46b855ab0235c2b6f97ad4fe82271d366bfd730de0e1ebb0"
126
+ },
127
+ "evaluation_run_before_freeze": false,
128
+ "design": "160 individually authored synthetic cases, 80 DE / 80 EN, 10 scenario strata. No repeated 03c entity-set templates. Selected configuration based only on 40+10+120 development suites. First evaluation and corpus retained even on failure."
129
+ }
ocr/anonymization/fixtures/qualification.json ADDED
@@ -0,0 +1,647 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "text": "Lina Wolkenstein wohnt in Berlin.",
4
+ "expected": "[PERSON] wohnt in [CITY].",
5
+ "spans": [
6
+ {
7
+ "start": 0,
8
+ "end": 16,
9
+ "label": "PERSON",
10
+ "text": "Lina Wolkenstein"
11
+ },
12
+ {
13
+ "start": 26,
14
+ "end": 32,
15
+ "label": "CITY",
16
+ "text": "Berlin"
17
+ }
18
+ ],
19
+ "id": "baseline-00",
20
+ "language": "de",
21
+ "ocr_noise": false
22
+ },
23
+ {
24
+ "text": "Hans Müller kauft ein.",
25
+ "expected": "[PERSON] kauft ein.",
26
+ "spans": [
27
+ {
28
+ "start": 0,
29
+ "end": 11,
30
+ "label": "PERSON",
31
+ "text": "Hans Müller"
32
+ }
33
+ ],
34
+ "id": "baseline-01",
35
+ "language": "de",
36
+ "ocr_noise": false
37
+ },
38
+ {
39
+ "text": "Das Auto von Dr. Klaus Schmidt.",
40
+ "expected": "Das Auto von Dr. [PERSON].",
41
+ "spans": [
42
+ {
43
+ "start": 17,
44
+ "end": 30,
45
+ "label": "PERSON",
46
+ "text": "Klaus Schmidt"
47
+ }
48
+ ],
49
+ "id": "baseline-02",
50
+ "language": "de",
51
+ "ocr_noise": false
52
+ },
53
+ {
54
+ "text": "Friedrich Wilhelm geht nach Hause.",
55
+ "expected": "[PERSON] geht nach Hause.",
56
+ "spans": [
57
+ {
58
+ "start": 0,
59
+ "end": 17,
60
+ "label": "PERSON",
61
+ "text": "Friedrich Wilhelm"
62
+ }
63
+ ],
64
+ "id": "baseline-03",
65
+ "language": "de",
66
+ "ocr_noise": false
67
+ },
68
+ {
69
+ "text": "Ich kenne Frau Anne-Marie Schneider nicht.",
70
+ "expected": "Ich kenne Frau [PERSON] nicht.",
71
+ "spans": [
72
+ {
73
+ "start": 15,
74
+ "end": 35,
75
+ "label": "PERSON",
76
+ "text": "Anne-Marie Schneider"
77
+ }
78
+ ],
79
+ "id": "baseline-04",
80
+ "language": "de",
81
+ "ocr_noise": false
82
+ },
83
+ {
84
+ "text": "John Doe is a good man.",
85
+ "expected": "[PERSON] is a good man.",
86
+ "spans": [
87
+ {
88
+ "start": 0,
89
+ "end": 8,
90
+ "label": "PERSON",
91
+ "text": "John Doe"
92
+ }
93
+ ],
94
+ "id": "baseline-05",
95
+ "language": "en",
96
+ "ocr_noise": false
97
+ },
98
+ {
99
+ "text": "Alice and Bob went to the store.",
100
+ "expected": "[PERSON] and [PERSON] went to the store.",
101
+ "spans": [
102
+ {
103
+ "start": 0,
104
+ "end": 5,
105
+ "label": "PERSON",
106
+ "text": "Alice"
107
+ },
108
+ {
109
+ "start": 10,
110
+ "end": 13,
111
+ "label": "PERSON",
112
+ "text": "Bob"
113
+ }
114
+ ],
115
+ "id": "baseline-06",
116
+ "language": "en",
117
+ "ocr_noise": false
118
+ },
119
+ {
120
+ "text": "Is Mary Johnson there?",
121
+ "expected": "Is [PERSON] there?",
122
+ "spans": [
123
+ {
124
+ "start": 3,
125
+ "end": 15,
126
+ "label": "PERSON",
127
+ "text": "Mary Johnson"
128
+ }
129
+ ],
130
+ "id": "baseline-07",
131
+ "language": "en",
132
+ "ocr_noise": false
133
+ },
134
+ {
135
+ "text": "Meeting with Mr. Charles Montgomery.",
136
+ "expected": "Meeting with Mr. [PERSON].",
137
+ "spans": [
138
+ {
139
+ "start": 17,
140
+ "end": 35,
141
+ "label": "PERSON",
142
+ "text": "Charles Montgomery"
143
+ }
144
+ ],
145
+ "id": "baseline-08",
146
+ "language": "en",
147
+ "ocr_noise": false
148
+ },
149
+ {
150
+ "text": "Elizabeth Taylor was famous.",
151
+ "expected": "[PERSON] was famous.",
152
+ "spans": [
153
+ {
154
+ "start": 0,
155
+ "end": 16,
156
+ "label": "PERSON",
157
+ "text": "Elizabeth Taylor"
158
+ }
159
+ ],
160
+ "id": "baseline-09",
161
+ "language": "en",
162
+ "ocr_noise": false
163
+ },
164
+ {
165
+ "text": "I live in New York.",
166
+ "expected": "I live in [CITY].",
167
+ "spans": [
168
+ {
169
+ "start": 10,
170
+ "end": 18,
171
+ "label": "CITY",
172
+ "text": "New York"
173
+ }
174
+ ],
175
+ "id": "baseline-10",
176
+ "language": "en",
177
+ "ocr_noise": false
178
+ },
179
+ {
180
+ "text": "Der Zug nach München fährt ab.",
181
+ "expected": "Der Zug nach [CITY] fährt ab.",
182
+ "spans": [
183
+ {
184
+ "start": 13,
185
+ "end": 20,
186
+ "label": "CITY",
187
+ "text": "München"
188
+ }
189
+ ],
190
+ "id": "baseline-11",
191
+ "language": "de",
192
+ "ocr_noise": false
193
+ },
194
+ {
195
+ "text": "Paris is beautiful in spring.",
196
+ "expected": "[CITY] is beautiful in spring.",
197
+ "spans": [
198
+ {
199
+ "start": 0,
200
+ "end": 5,
201
+ "label": "CITY",
202
+ "text": "Paris"
203
+ }
204
+ ],
205
+ "id": "baseline-12",
206
+ "language": "en",
207
+ "ocr_noise": false
208
+ },
209
+ {
210
+ "text": "Flug von Frankfurt nach Tokyo.",
211
+ "expected": "Flug von [CITY] nach [CITY].",
212
+ "spans": [
213
+ {
214
+ "start": 9,
215
+ "end": 18,
216
+ "label": "CITY",
217
+ "text": "Frankfurt"
218
+ },
219
+ {
220
+ "start": 24,
221
+ "end": 29,
222
+ "label": "CITY",
223
+ "text": "Tokyo"
224
+ }
225
+ ],
226
+ "id": "baseline-13",
227
+ "language": "de",
228
+ "ocr_noise": false
229
+ },
230
+ {
231
+ "text": "Welcome to Los Angeles, California.",
232
+ "expected": "Welcome to [CITY], [CITY].",
233
+ "spans": [
234
+ {
235
+ "start": 11,
236
+ "end": 22,
237
+ "label": "CITY",
238
+ "text": "Los Angeles"
239
+ },
240
+ {
241
+ "start": 24,
242
+ "end": 34,
243
+ "label": "CITY",
244
+ "text": "California"
245
+ }
246
+ ],
247
+ "id": "baseline-14",
248
+ "language": "en",
249
+ "ocr_noise": false
250
+ },
251
+ {
252
+ "text": "Ihre Religionszugehörigkeit ist evangelisch.",
253
+ "expected": "Ihre Religionszugehörigkeit ist [RELIGION].",
254
+ "spans": [
255
+ {
256
+ "start": 32,
257
+ "end": 43,
258
+ "label": "RELIGION",
259
+ "text": "evangelisch"
260
+ }
261
+ ],
262
+ "id": "baseline-15",
263
+ "language": "de",
264
+ "ocr_noise": false
265
+ },
266
+ {
267
+ "text": "Er ist katholisch.",
268
+ "expected": "Er ist [RELIGION].",
269
+ "spans": [
270
+ {
271
+ "start": 7,
272
+ "end": 17,
273
+ "label": "RELIGION",
274
+ "text": "katholisch"
275
+ }
276
+ ],
277
+ "id": "baseline-16",
278
+ "language": "de",
279
+ "ocr_noise": false
280
+ },
281
+ {
282
+ "text": "They practice Judaism.",
283
+ "expected": "They practice [RELIGION].",
284
+ "spans": [
285
+ {
286
+ "start": 14,
287
+ "end": 21,
288
+ "label": "RELIGION",
289
+ "text": "Judaism"
290
+ }
291
+ ],
292
+ "id": "baseline-17",
293
+ "language": "en",
294
+ "ocr_noise": false
295
+ },
296
+ {
297
+ "text": "He is a devout Muslim.",
298
+ "expected": "He is a devout [RELIGION].",
299
+ "spans": [
300
+ {
301
+ "start": 15,
302
+ "end": 21,
303
+ "label": "RELIGION",
304
+ "text": "Muslim"
305
+ }
306
+ ],
307
+ "id": "baseline-18",
308
+ "language": "en",
309
+ "ocr_noise": false
310
+ },
311
+ {
312
+ "text": "Buddhist monks live there.",
313
+ "expected": "[RELIGION] monks live there.",
314
+ "spans": [
315
+ {
316
+ "start": 0,
317
+ "end": 8,
318
+ "label": "RELIGION",
319
+ "text": "Buddhist"
320
+ }
321
+ ],
322
+ "id": "baseline-19",
323
+ "language": "en",
324
+ "ocr_noise": false
325
+ },
326
+ {
327
+ "text": "John Doe from New York met Lina Wolkenstein in Berlin.",
328
+ "expected": "[PERSON] from [CITY] met [PERSON] in [CITY].",
329
+ "spans": [
330
+ {
331
+ "start": 0,
332
+ "end": 8,
333
+ "label": "PERSON",
334
+ "text": "John Doe"
335
+ },
336
+ {
337
+ "start": 14,
338
+ "end": 22,
339
+ "label": "CITY",
340
+ "text": "New York"
341
+ },
342
+ {
343
+ "start": 27,
344
+ "end": 43,
345
+ "label": "PERSON",
346
+ "text": "Lina Wolkenstein"
347
+ },
348
+ {
349
+ "start": 47,
350
+ "end": 53,
351
+ "label": "CITY",
352
+ "text": "Berlin"
353
+ }
354
+ ],
355
+ "id": "baseline-20",
356
+ "language": "mixed",
357
+ "ocr_noise": false
358
+ },
359
+ {
360
+ "text": "David is Catholic and lives in Rome.",
361
+ "expected": "[PERSON] is [RELIGION] and lives in [CITY].",
362
+ "spans": [
363
+ {
364
+ "start": 0,
365
+ "end": 5,
366
+ "label": "PERSON",
367
+ "text": "David"
368
+ },
369
+ {
370
+ "start": 9,
371
+ "end": 17,
372
+ "label": "RELIGION",
373
+ "text": "Catholic"
374
+ },
375
+ {
376
+ "start": 31,
377
+ "end": 35,
378
+ "label": "CITY",
379
+ "text": "Rome"
380
+ }
381
+ ],
382
+ "id": "baseline-21",
383
+ "language": "en",
384
+ "ocr_noise": false
385
+ },
386
+ {
387
+ "text": "John likes John's new car.",
388
+ "expected": "[PERSON] likes [PERSON]'s new car.",
389
+ "spans": [
390
+ {
391
+ "start": 0,
392
+ "end": 4,
393
+ "label": "PERSON",
394
+ "text": "John"
395
+ },
396
+ {
397
+ "start": 11,
398
+ "end": 15,
399
+ "label": "PERSON",
400
+ "text": "John"
401
+ }
402
+ ],
403
+ "id": "baseline-22",
404
+ "language": "en",
405
+ "ocr_noise": false
406
+ },
407
+ {
408
+ "text": "Berlin, oh Berlin, my favorite city.",
409
+ "expected": "[CITY], oh [CITY], my favorite city.",
410
+ "spans": [
411
+ {
412
+ "start": 0,
413
+ "end": 6,
414
+ "label": "CITY",
415
+ "text": "Berlin"
416
+ },
417
+ {
418
+ "start": 11,
419
+ "end": 17,
420
+ "label": "CITY",
421
+ "text": "Berlin"
422
+ }
423
+ ],
424
+ "id": "baseline-23",
425
+ "language": "en",
426
+ "ocr_noise": false
427
+ },
428
+ {
429
+ "text": "Washington is the capital.",
430
+ "expected": "[CITY] is the capital.",
431
+ "spans": [
432
+ {
433
+ "start": 0,
434
+ "end": 10,
435
+ "label": "CITY",
436
+ "text": "Washington"
437
+ }
438
+ ],
439
+ "id": "baseline-24",
440
+ "language": "en",
441
+ "ocr_noise": false
442
+ },
443
+ {
444
+ "text": "George Washington was the president.",
445
+ "expected": "[PERSON] was the president.",
446
+ "spans": [
447
+ {
448
+ "start": 0,
449
+ "end": 17,
450
+ "label": "PERSON",
451
+ "text": "George Washington"
452
+ }
453
+ ],
454
+ "id": "baseline-25",
455
+ "language": "en",
456
+ "ocr_noise": false
457
+ },
458
+ {
459
+ "text": "I visited the Jordan river.",
460
+ "expected": "I visited the [CITY] river.",
461
+ "spans": [
462
+ {
463
+ "start": 14,
464
+ "end": 20,
465
+ "label": "CITY",
466
+ "text": "Jordan"
467
+ }
468
+ ],
469
+ "id": "baseline-26",
470
+ "language": "en",
471
+ "ocr_noise": false
472
+ },
473
+ {
474
+ "text": "Michael Jordan is a basketball player.",
475
+ "expected": "[PERSON] is a basketball player.",
476
+ "spans": [
477
+ {
478
+ "start": 0,
479
+ "end": 14,
480
+ "label": "PERSON",
481
+ "text": "Michael Jordan"
482
+ }
483
+ ],
484
+ "id": "baseline-27",
485
+ "language": "en",
486
+ "ocr_noise": false
487
+ },
488
+ {
489
+ "text": "Mr. Smith-\nson",
490
+ "expected": "Mr. [PERSON]",
491
+ "spans": [
492
+ {
493
+ "start": 4,
494
+ "end": 14,
495
+ "label": "PERSON",
496
+ "text": "Smith-\nson"
497
+ }
498
+ ],
499
+ "id": "baseline-28",
500
+ "language": "en",
501
+ "ocr_noise": true
502
+ },
503
+ {
504
+ "text": "Ber-\nlin",
505
+ "expected": "[CITY]",
506
+ "spans": [
507
+ {
508
+ "start": 0,
509
+ "end": 8,
510
+ "label": "CITY",
511
+ "text": "Ber-\nlin"
512
+ }
513
+ ],
514
+ "id": "baseline-29",
515
+ "language": "de",
516
+ "ocr_noise": true
517
+ },
518
+ {
519
+ "text": "Jchn D0e went home.",
520
+ "expected": "[PERSON] went home.",
521
+ "spans": [
522
+ {
523
+ "start": 0,
524
+ "end": 8,
525
+ "label": "PERSON",
526
+ "text": "Jchn D0e"
527
+ }
528
+ ],
529
+ "id": "baseline-30",
530
+ "language": "en",
531
+ "ocr_noise": true
532
+ },
533
+ {
534
+ "text": "Welcome to Nevv Y0rk.",
535
+ "expected": "Welcome to [CITY].",
536
+ "spans": [
537
+ {
538
+ "start": 11,
539
+ "end": 20,
540
+ "label": "CITY",
541
+ "text": "Nevv Y0rk"
542
+ }
543
+ ],
544
+ "id": "baseline-31",
545
+ "language": "en",
546
+ "ocr_noise": true
547
+ },
548
+ {
549
+ "text": "The cat sat on the mat.",
550
+ "expected": "The cat sat on the mat.",
551
+ "spans": [],
552
+ "id": "baseline-32",
553
+ "language": "en",
554
+ "ocr_noise": false
555
+ },
556
+ {
557
+ "text": "12345 is a number.",
558
+ "expected": "12345 is a number.",
559
+ "spans": [],
560
+ "id": "baseline-33",
561
+ "language": "en",
562
+ "ocr_noise": false
563
+ },
564
+ {
565
+ "text": "This is a simple test.",
566
+ "expected": "This is a simple test.",
567
+ "spans": [],
568
+ "id": "baseline-34",
569
+ "language": "en",
570
+ "ocr_noise": false
571
+ },
572
+ {
573
+ "text": "My email is test@example.com.",
574
+ "expected": "My email is [EMAIL].",
575
+ "spans": [
576
+ {
577
+ "start": 12,
578
+ "end": 28,
579
+ "label": "EMAIL",
580
+ "text": "test@example.com"
581
+ }
582
+ ],
583
+ "id": "baseline-35",
584
+ "language": "en",
585
+ "ocr_noise": false
586
+ },
587
+ {
588
+ "text": "Call me at 555-0199.",
589
+ "expected": "Call me at [PHONE].",
590
+ "spans": [
591
+ {
592
+ "start": 11,
593
+ "end": 19,
594
+ "label": "PHONE",
595
+ "text": "555-0199"
596
+ }
597
+ ],
598
+ "id": "baseline-36",
599
+ "language": "en",
600
+ "ocr_noise": false
601
+ },
602
+ {
603
+ "text": "I work for Google Inc.",
604
+ "expected": "I work for [ORGANIZATION].",
605
+ "spans": [
606
+ {
607
+ "start": 11,
608
+ "end": 22,
609
+ "label": "ORGANIZATION",
610
+ "text": "Google Inc."
611
+ }
612
+ ],
613
+ "id": "baseline-37",
614
+ "language": "en",
615
+ "ocr_noise": false
616
+ },
617
+ {
618
+ "text": "Contact info@company.de",
619
+ "expected": "Contact [EMAIL]",
620
+ "spans": [
621
+ {
622
+ "start": 8,
623
+ "end": 23,
624
+ "label": "EMAIL",
625
+ "text": "info@company.de"
626
+ }
627
+ ],
628
+ "id": "baseline-38",
629
+ "language": "en",
630
+ "ocr_noise": false
631
+ },
632
+ {
633
+ "text": "The UN is an organization.",
634
+ "expected": "The [ORGANIZATION] is an organization.",
635
+ "spans": [
636
+ {
637
+ "start": 4,
638
+ "end": 6,
639
+ "label": "ORGANIZATION",
640
+ "text": "UN"
641
+ }
642
+ ],
643
+ "id": "baseline-39",
644
+ "language": "en",
645
+ "ocr_noise": false
646
+ }
647
+ ]
ocr/anonymization/fixtures/validation.json ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "validation-00",
4
+ "language": "de",
5
+ "text": "Mara Kühn arbeitet bei Siemens in Dresden.",
6
+ "spans": [
7
+ {
8
+ "start": 0,
9
+ "end": 9,
10
+ "label": "PERSON",
11
+ "text": "Mara Kühn"
12
+ },
13
+ {
14
+ "start": 23,
15
+ "end": 30,
16
+ "label": "ORGANIZATION",
17
+ "text": "Siemens"
18
+ },
19
+ {
20
+ "start": 34,
21
+ "end": 41,
22
+ "label": "CITY",
23
+ "text": "Dresden"
24
+ }
25
+ ],
26
+ "expected": "[PERSON] arbeitet bei [ORGANIZATION] in [CITY].",
27
+ "ocr_noise": false
28
+ },
29
+ {
30
+ "id": "validation-01",
31
+ "language": "en",
32
+ "text": "Emma Wilson works for Acme Corporation in London.",
33
+ "spans": [
34
+ {
35
+ "start": 0,
36
+ "end": 11,
37
+ "label": "PERSON",
38
+ "text": "Emma Wilson"
39
+ },
40
+ {
41
+ "start": 22,
42
+ "end": 38,
43
+ "label": "ORGANIZATION",
44
+ "text": "Acme Corporation"
45
+ },
46
+ {
47
+ "start": 42,
48
+ "end": 48,
49
+ "label": "CITY",
50
+ "text": "London"
51
+ }
52
+ ],
53
+ "expected": "[PERSON] works for [ORGANIZATION] in [CITY].",
54
+ "ocr_noise": false
55
+ },
56
+ {
57
+ "id": "validation-02",
58
+ "language": "de",
59
+ "text": "Sie ist muslimisch. Kontakt: mara.kuehn@example.org, +49 351 7654321.",
60
+ "spans": [
61
+ {
62
+ "start": 8,
63
+ "end": 18,
64
+ "label": "RELIGION",
65
+ "text": "muslimisch"
66
+ },
67
+ {
68
+ "start": 29,
69
+ "end": 51,
70
+ "label": "EMAIL",
71
+ "text": "mara.kuehn@example.org"
72
+ },
73
+ {
74
+ "start": 53,
75
+ "end": 68,
76
+ "label": "PHONE",
77
+ "text": "+49 351 7654321"
78
+ }
79
+ ],
80
+ "expected": "Sie ist [RELIGION]. Kontakt: [EMAIL], [PHONE].",
81
+ "ocr_noise": false
82
+ },
83
+ {
84
+ "id": "validation-03",
85
+ "language": "en",
86
+ "text": "His religion is Hinduism. Contact emma@example.net or +1 212 555 0188.",
87
+ "spans": [
88
+ {
89
+ "start": 16,
90
+ "end": 24,
91
+ "label": "RELIGION",
92
+ "text": "Hinduism"
93
+ },
94
+ {
95
+ "start": 34,
96
+ "end": 50,
97
+ "label": "EMAIL",
98
+ "text": "emma@example.net"
99
+ },
100
+ {
101
+ "start": 54,
102
+ "end": 69,
103
+ "label": "PHONE",
104
+ "text": "+1 212 555 0188"
105
+ }
106
+ ],
107
+ "expected": "His religion is [RELIGION]. Contact [EMAIL] or [PHONE].",
108
+ "ocr_noise": false
109
+ },
110
+ {
111
+ "id": "validation-04",
112
+ "language": "de",
113
+ "text": "Nora Winter besucht Köln. Nora Winter bleibt dort.",
114
+ "spans": [
115
+ {
116
+ "start": 0,
117
+ "end": 11,
118
+ "label": "PERSON",
119
+ "text": "Nora Winter"
120
+ },
121
+ {
122
+ "start": 20,
123
+ "end": 24,
124
+ "label": "CITY",
125
+ "text": "Köln"
126
+ },
127
+ {
128
+ "start": 26,
129
+ "end": 37,
130
+ "label": "PERSON",
131
+ "text": "Nora Winter"
132
+ }
133
+ ],
134
+ "expected": "[PERSON] besucht [CITY]. [PERSON] bleibt dort.",
135
+ "ocr_noise": false
136
+ },
137
+ {
138
+ "id": "validation-05",
139
+ "language": "en",
140
+ "text": "Jordan is a river, and Jordan Miller is a person.",
141
+ "spans": [
142
+ {
143
+ "start": 23,
144
+ "end": 36,
145
+ "label": "PERSON",
146
+ "text": "Jordan Miller"
147
+ }
148
+ ],
149
+ "expected": "Jordan is a river, and [PERSON] is a person.",
150
+ "ocr_noise": false
151
+ },
152
+ {
153
+ "id": "validation-06",
154
+ "language": "de",
155
+ "text": "Religion ist ein Gesprächsthema. Keine Zugehörigkeit ist angegeben.",
156
+ "spans": [],
157
+ "expected": "Religion ist ein Gesprächsthema. Keine Zugehörigkeit ist angegeben.",
158
+ "ocr_noise": false
159
+ },
160
+ {
161
+ "id": "validation-07",
162
+ "language": "en",
163
+ "text": "A name alone does not establish religious affiliation.",
164
+ "spans": [],
165
+ "expected": "A name alone does not establish religious affiliation.",
166
+ "ocr_noise": false
167
+ },
168
+ {
169
+ "id": "validation-08",
170
+ "language": "de",
171
+ "text": "Frau Eva-Luise Schröder wohnt in Ham-\nburg.",
172
+ "spans": [
173
+ {
174
+ "start": 5,
175
+ "end": 23,
176
+ "label": "PERSON",
177
+ "text": "Eva-Luise Schröder"
178
+ },
179
+ {
180
+ "start": 33,
181
+ "end": 42,
182
+ "label": "CITY",
183
+ "text": "Ham-\nburg"
184
+ }
185
+ ],
186
+ "expected": "Frau [PERSON] wohnt in [CITY].",
187
+ "ocr_noise": true
188
+ },
189
+ {
190
+ "id": "validation-09",
191
+ "language": "en",
192
+ "text": "M4ry Br0wn lives in L0ndon.",
193
+ "spans": [
194
+ {
195
+ "start": 0,
196
+ "end": 10,
197
+ "label": "PERSON",
198
+ "text": "M4ry Br0wn"
199
+ },
200
+ {
201
+ "start": 20,
202
+ "end": 26,
203
+ "label": "CITY",
204
+ "text": "L0ndon"
205
+ }
206
+ ],
207
+ "expected": "[PERSON] lives in [CITY].",
208
+ "ocr_noise": true
209
+ }
210
+ ]
ocr/anonymization/historical.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verify immutable historical artifacts; never evaluate updated code as an old holdout."""
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+ FROZEN_ARTIFACT_SHA256 = {
8
+ "docs/anonymization-evaluation/gate03c-holdout-first.json": "257daa6d43285925c4899471c3f615293293fa0fa8ed17545ebb9d8e99802e83",
9
+ "docs/anonymization-evaluation/gate03c-holdout-first.provenance.json": "bf19f6ae26e146aa6e04aaed9a54705c0f910af308ea33a8c5eacfe4d1724fbb",
10
+ "docs/anonymization-evaluation/gate03d-holdout-first.json": "08f86d2b78871683835c51b627e251c66b83fc33f828d8a6c8e9742c8bd47d9f",
11
+ "docs/anonymization-evaluation/gate03d-holdout-first.provenance.json": "9d5fa15c5427fcfa499262b20453ed4adfe59da86709dffc745ec205e0661bca",
12
+ "ocr/anonymization/fixtures/holdout03c/cases.json": "0174fb41d34d0a4db18877c68fb70149eaa67c05da86608d5da4ff8765a52c15",
13
+ "ocr/anonymization/fixtures/holdout03c/freeze.json": "c707239993099dab4a48b83f6c2aac58674de5423ede75cd1aced17d290708a6",
14
+ "ocr/anonymization/fixtures/holdout03d/cases.json": "7180e176f6d8bd259da56ad729ede6fd2c798aca15a786ba0ff0adcd862f21a1",
15
+ "ocr/anonymization/fixtures/holdout03d/freeze.json": "448dc5d7b0f3ef00f7d0f90084866f7f2e85311aac6af32ed0ad3d2658f8b1f5",
16
+ }
17
+
18
+
19
+ def verify_historical():
20
+ repo = Path(__file__).parents[2]
21
+ for name, value in FROZEN_ARTIFACT_SHA256.items():
22
+ if hashlib.sha256((repo / name).read_bytes()).hexdigest() != value:
23
+ raise ValueError("historical_evidence_changed")
24
+ root = Path(__file__).parent / "fixtures/holdout03d"
25
+ freeze = json.loads((root / "freeze.json").read_bytes())
26
+ report = json.loads(
27
+ (repo / "docs/anonymization-evaluation/gate03d-holdout-first.json").read_bytes()
28
+ )
29
+ cases = json.loads((root / "cases.json").read_bytes())
30
+ if (
31
+ report["implementation_sha256"] != freeze["implementation_sha256"]
32
+ or report["corpus_sha256"] != freeze["sha256"]
33
+ or {p["case_id"] for p in report["per_case"]} != {c["id"] for c in cases}
34
+ ):
35
+ raise ValueError("historical_evidence_changed")
36
+ return root, freeze, cases, report
ocr/anonymization/holdout.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """First-result, frozen synthetic holdout; refuses prompt/weight changes."""
2
+
3
+ import hashlib
4
+ import importlib.metadata
5
+ import json
6
+ import resource
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from .atomic_output import write_candidate
13
+ from .detectors import detect_contacts
14
+ from .evaluate import score_cases
15
+ from .model import MODEL_REVISION
16
+ from .recognizer import GLiNERRecognizer
17
+
18
+
19
+ def run(cache, output):
20
+ root = Path(__file__).parent / "fixtures/holdout03c"
21
+ frozen = json.loads((root / "freeze.json").read_text())
22
+ data = (root / "cases.json").read_bytes()
23
+ if hashlib.sha256(data).hexdigest() != frozen["sha256"]:
24
+ raise ValueError("holdout_integrity_failed")
25
+ denied = []
26
+
27
+ def no_network(event, args):
28
+ if event in ("socket.connect", "socket.bind", "socket.getaddrinfo"):
29
+ denied.append(event)
30
+ raise PermissionError("qualification_network_disabled")
31
+
32
+ sys.addaudithook(no_network)
33
+ started = time.perf_counter()
34
+ recognizer = GLiNERRecognizer(cache)
35
+ loading_ms = (time.perf_counter() - started) * 1000
36
+ if (
37
+ recognizer.prompts != frozen["control_prompts"]
38
+ or recognizer.threshold != frozen["threshold"]
39
+ or MODEL_REVISION != frozen["model_revision"]
40
+ ):
41
+ raise ValueError("holdout_control_changed")
42
+ recognizer.recognize("A plain synthetic warmup sentence.")
43
+ report = dict(
44
+ schema_version="holdout03c-first-control/v1",
45
+ corpus_sha256=frozen["sha256"],
46
+ code_sha=subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
47
+ freeze_commit=subprocess.check_output(
48
+ ["git", "log", "-1", "--format=%H", "--", str(root / "cases.json")], text=True
49
+ ).strip(),
50
+ model_revision=MODEL_REVISION,
51
+ prompts=recognizer.prompts,
52
+ threshold=recognizer.threshold,
53
+ loading_ms=loading_ms,
54
+ runtime_versions={
55
+ k: importlib.metadata.version(k)
56
+ for k in ("gliner", "torch", "transformers", "numpy", "pydantic")
57
+ },
58
+ evaluation=score_cases(recognizer, json.loads(data), detector=detect_contacts),
59
+ peak_rss_mib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024,
60
+ denied_network_calls=denied,
61
+ automated_release_eligible=False,
62
+ )
63
+ write_candidate(output, (json.dumps(report, indent=2) + "\n").encode())
64
+
65
+
66
+ if __name__ == "__main__":
67
+ import argparse
68
+
69
+ parser = argparse.ArgumentParser(
70
+ description="Evaluate frozen holdout once with existing control"
71
+ )
72
+ parser.add_argument("--cache", required=True)
73
+ parser.add_argument("--output", required=True)
74
+ args = parser.parse_args()
75
+ try:
76
+ run(args.cache, args.output)
77
+ except Exception:
78
+ raise SystemExit("holdout_evaluation_failed") from None
79
+ print("holdout_evaluation_recorded_check_qualification_result")
ocr/anonymization/host_adapter.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Host security contract. No identity provider or request-identity fallback."""
2
+
3
+ import time
4
+ from typing import Protocol
5
+
6
+ from pydantic import Field
7
+
8
+ from ocr.schemas import SafeError
9
+
10
+ from .release_policy import ReleaseAuthority
11
+ from .schemas import Destination, StrictModel, VerifiedReview
12
+
13
+
14
+ class AuthenticatedIdentity(StrictModel):
15
+ subject: str = Field(pattern=r"^[a-zA-Z0-9:_-]{1,128}$")
16
+ session_id: str = Field(pattern=r"^[a-zA-Z0-9:_-]{1,128}$")
17
+ expires_at: int
18
+
19
+
20
+ class CompletedReview(StrictModel):
21
+ attestation: VerifiedReview
22
+ completed: bool
23
+ revoked: bool
24
+ expires_at: int
25
+
26
+
27
+ class ExportAuthorizationRecord(StrictModel):
28
+ artifact_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
29
+ candidate_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
30
+ review_id: str
31
+ review_evidence_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
32
+ sensitivity: str
33
+ destination_id: str
34
+ expires_at: int
35
+ revoked: bool
36
+
37
+
38
+ class HostSecurityAdapter(Protocol):
39
+ """Implement against authenticated server context and authoritative stores.
40
+
41
+ credentials are opaque authentication evidence, never a requested subject.
42
+ Review content/evidence must be immutable; revocation is separate mutable state.
43
+ Host ACLs and destination policy are looked up on EVERY operation.
44
+ """
45
+
46
+ def authenticate(self, credential: object) -> AuthenticatedIdentity: ...
47
+ def load_review(self, review_id: str) -> CompletedReview: ...
48
+ def candidate_exists(self, candidate_digest: str) -> bool: ...
49
+ def load_export(self, artifact_digest: str) -> ExportAuthorizationRecord: ...
50
+ def current_acl(
51
+ self, identity: AuthenticatedIdentity, scope: str, sensitivity: str, destination_id: str
52
+ ) -> bool: ...
53
+ def destination(self, destination_id: str) -> Destination: ...
54
+ def corrected_release_allowed(self, candidate, review: VerifiedReview) -> bool: ...
55
+ def legacy_release_allowed(self, candidate, review: VerifiedReview) -> bool: ...
56
+
57
+
58
+ class HostAuthorizationBridge:
59
+ def __init__(self, host: HostSecurityAdapter):
60
+ self.host = host
61
+
62
+ def identity(self, credential):
63
+ identity = AuthenticatedIdentity.model_validate(self.host.authenticate(credential))
64
+ if identity.expires_at <= time.time():
65
+ raise SafeError("host_authorization_denied")
66
+ return identity
67
+
68
+ def review(self, credential, review_id):
69
+ try:
70
+ identity = self.identity(credential)
71
+ record = CompletedReview.model_validate(self.host.load_review(review_id))
72
+ review = record.attestation
73
+ if (
74
+ not record.completed
75
+ or record.revoked
76
+ or record.expires_at <= time.time()
77
+ or review.reviewer_subject != identity.subject
78
+ or review.review_id != review_id
79
+ or self.host.candidate_exists(review.candidate_digest) is not True
80
+ ):
81
+ raise ValueError()
82
+ destination = Destination.model_validate(self.host.destination(review.destination_id))
83
+ if (
84
+ self.host.current_acl(
85
+ identity,
86
+ "anonymization:release",
87
+ review.sensitivity,
88
+ destination.destination_id,
89
+ )
90
+ is not True
91
+ ):
92
+ raise ValueError()
93
+ return review
94
+ except Exception:
95
+ raise SafeError("host_authorization_denied") from None
96
+
97
+ def acl(self, review, scope, destination):
98
+ # Identity/ACL already checked with the current session by review(). This
99
+ # extra check binds the live operator policy to the approval snapshot.
100
+ return (
101
+ Destination.model_validate(self.host.destination(destination.destination_id))
102
+ == destination
103
+ )
104
+
105
+ def corrected_release(self, candidate, review):
106
+ # Missing production integration denies corrected candidates.
107
+ try:
108
+ return self.host.corrected_release_allowed(candidate, review) is True
109
+ except Exception:
110
+ return False
111
+
112
+ def legacy_release(self, candidate, review):
113
+ # v1/v2 require an explicit authoritative source/schema eligibility policy.
114
+ try:
115
+ return self.host.legacy_release_allowed(candidate, review) is True
116
+ except Exception:
117
+ return False
118
+
119
+ def authority(self, key, ledger, destination_ids, **kwargs):
120
+ return ReleaseAuthority(
121
+ key,
122
+ self.acl,
123
+ verify_review=self.review,
124
+ consume_approval=ledger.consume,
125
+ correction_policy=self.corrected_release,
126
+ legacy_policy=self.legacy_release,
127
+ destinations=tuple(self.host.destination(i) for i in destination_ids),
128
+ **kwargs,
129
+ )
130
+
131
+ def transfer_authorized(self, credential, export_digest, sensitivity, destination_id):
132
+ """Separate current permission; a release approval alone never calls this."""
133
+ try:
134
+ identity = self.identity(credential)
135
+ destination = Destination.model_validate(self.host.destination(destination_id))
136
+ if (
137
+ destination.destination_class != "private"
138
+ or sensitivity not in destination.allowed_sensitivities
139
+ ):
140
+ return False
141
+ record = ExportAuthorizationRecord.model_validate(self.host.load_export(export_digest))
142
+ review = CompletedReview.model_validate(self.host.load_review(record.review_id))
143
+ if (
144
+ record.artifact_digest != export_digest
145
+ or record.sensitivity != sensitivity
146
+ or record.destination_id != destination_id
147
+ or record.revoked
148
+ or record.expires_at <= time.time()
149
+ or not review.completed
150
+ or review.revoked
151
+ or review.expires_at <= time.time()
152
+ or review.attestation.review_id != record.review_id
153
+ or review.attestation.candidate_digest != record.candidate_digest
154
+ or review.attestation.evidence_digest != record.review_evidence_digest
155
+ or review.attestation.sensitivity != sensitivity
156
+ or review.attestation.destination_id != destination_id
157
+ or self.host.candidate_exists(record.candidate_digest) is not True
158
+ ):
159
+ return False
160
+ return (
161
+ self.host.current_acl(
162
+ identity, "anonymization:private-transfer", sensitivity, destination_id
163
+ )
164
+ is True
165
+ )
166
+ except Exception:
167
+ raise SafeError("host_authorization_denied") from None
ocr/anonymization/investigate.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit opt-in experiment on repository SYNTHETIC fixtures only.
2
+
3
+ Unlike aggregate evaluation, this artifact intentionally contains synthetic gold
4
+ and prediction text for diagnosis. Never use this command with private documents.
5
+ """
6
+
7
+ import json
8
+ import resource
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from .detectors import detect
13
+ from .evaluate import score_cases
14
+ from .model import MODEL_ID, MODEL_REVISION
15
+ from .normalization import dehyphenate
16
+ from .recognizer import DEFAULT_PROMPTS, GLiNERRecognizer
17
+ from .replacement import resolve_overlaps, validate_entities
18
+
19
+ REVIEWED_PROMPTS = DEFAULT_PROMPTS | {"CITY": "city", "ORGANIZATION": "organization"}
20
+
21
+ EXPERIMENTS = (
22
+ ("reviewed_baseline", {}, 0.5),
23
+ ("lower_threshold", {}, 0.35),
24
+ ("geographic_prompt", {"CITY": "location"}, 0.5),
25
+ (
26
+ "geographic_and_company_prompt",
27
+ {"CITY": "location", "ORGANIZATION": "company or organization"},
28
+ 0.5,
29
+ ),
30
+ )
31
+
32
+
33
+ class Recorded:
34
+ def __init__(self, recognizer):
35
+ self.recognizer = recognizer
36
+ self.predictions = {}
37
+
38
+ def recognize(self, text):
39
+ found = self.recognizer.recognize(text)
40
+ self.predictions[text] = found
41
+ return found
42
+
43
+
44
+ def run(cache, output):
45
+ start = time.perf_counter()
46
+ model = GLiNERRecognizer(cache)
47
+ loading_ms = (time.perf_counter() - start) * 1000
48
+ root = Path(__file__).parent / "fixtures"
49
+ suites = {
50
+ n: json.loads((root / f"{n}.json").read_text()) for n in ("qualification", "validation")
51
+ }
52
+ report = dict(
53
+ model=MODEL_ID,
54
+ revision=MODEL_REVISION,
55
+ loading_ms=loading_ms,
56
+ experiments={},
57
+ baseline_diagnosis=[],
58
+ )
59
+ model.recognize("The cat sat on the mat.")
60
+ for name, changes, threshold in EXPERIMENTS:
61
+ model.prompts = REVIEWED_PROMPTS | changes
62
+ model.threshold = threshold
63
+ recorder = Recorded(model)
64
+ scores = {n: score_cases(recorder, cases) for n, cases in suites.items()}
65
+ report["experiments"][name] = dict(
66
+ prompts=model.prompts.copy(), threshold=threshold, suites=scores
67
+ )
68
+ if name != "reviewed_baseline":
69
+ continue
70
+ for suite, cases in suites.items():
71
+ for case in cases:
72
+ raw = recorder.predictions[case["text"]]
73
+ combined = resolve_overlaps(
74
+ validate_entities(case["text"], raw + detect(case["text"]))
75
+ )
76
+ gold = {(s["start"], s["end"], s["label"]) for s in case["spans"]}
77
+ predicted = {(s.start, s.end, s.label) for s in combined}
78
+ errors = {
79
+ label: dict(
80
+ false_positives=[
81
+ s.model_dump()
82
+ for s in combined
83
+ if s.label == label and (s.start, s.end, s.label) not in gold
84
+ ],
85
+ false_negatives=[
86
+ s
87
+ for s in case["spans"]
88
+ if s["label"] == label
89
+ and (s["start"], s["end"], label) not in predicted
90
+ ],
91
+ )
92
+ for label in ("CITY", "ORGANIZATION")
93
+ }
94
+ if not any(v["false_positives"] or v["false_negatives"] for v in errors.values()):
95
+ continue
96
+ normalized, _ = dehyphenate(case["text"])
97
+ item = dict(
98
+ suite=suite,
99
+ case_id=case["id"],
100
+ text=case["text"],
101
+ normalized=normalized,
102
+ gold=case["spans"],
103
+ predictions=[s.model_dump() for s in combined],
104
+ errors=errors,
105
+ )
106
+ uncovered_city = [
107
+ s
108
+ for s in case["spans"]
109
+ if s["label"] == "CITY"
110
+ and any(
111
+ case["text"][i].isalnum()
112
+ and not any(p.start <= i < p.end for p in combined)
113
+ for i in range(s["start"], s["end"])
114
+ )
115
+ ]
116
+ if uncovered_city:
117
+ tokenizer = model.model.data_processor.transformer_tokenizer
118
+ ids = tokenizer(normalized)["input_ids"]
119
+ item["uncovered_city"] = uncovered_city
120
+ item["tokens"] = tokenizer.convert_ids_to_tokens(ids)
121
+ item["low_threshold_predictions"] = model.model.predict_entities(
122
+ normalized, list(model.prompts.values()), threshold=0.1, flat_ner=True
123
+ )
124
+ report["baseline_diagnosis"].append(item)
125
+ report["peak_rss_mib"] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
126
+ Path(output).write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
127
+
128
+
129
+ if __name__ == "__main__":
130
+ import argparse
131
+
132
+ parser = argparse.ArgumentParser(
133
+ description="Compare fixed experiments on synthetic repository corpora"
134
+ )
135
+ parser.add_argument("--cache", required=True)
136
+ parser.add_argument("--output", required=True)
137
+ args = parser.parse_args()
138
+ try:
139
+ run(args.cache, args.output)
140
+ except Exception:
141
+ raise SystemExit("synthetic_recognition_investigation_failed") from None
142
+ print("synthetic_recognition_investigation_completed")
ocr/anonymization/model.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pinned artifacts for native GLiNER; setup is the only network-enabled operation."""
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from ocr.schemas import SafeError
8
+
9
+ MODEL_ID = "VAGOsolutions/SauerkrautLM-GLiNER"
10
+ MODEL_REVISION = "ba967ee030b7e679dd5422cb16fcd1b379da38e1"
11
+ FILES = (
12
+ "gliner_config.json",
13
+ "pytorch_model.bin",
14
+ "special_tokens_map.json",
15
+ "tokenizer.json",
16
+ "tokenizer_config.json",
17
+ )
18
+
19
+
20
+ def file_hash(path):
21
+ h = hashlib.sha256()
22
+ with path.open("rb") as stream:
23
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
24
+ h.update(chunk)
25
+ return h.hexdigest()
26
+
27
+
28
+ def verify_cache(path):
29
+ try:
30
+ path = Path(path)
31
+ manifest = json.loads((path / "manifest.json").read_text())
32
+ expected = json.loads(Path(__file__).with_name("artifacts.json").read_text())
33
+ if manifest != expected:
34
+ raise ValueError()
35
+ if manifest["model"] != MODEL_ID or manifest["revision"] != MODEL_REVISION:
36
+ raise ValueError()
37
+ if set(manifest["files"]) != set(FILES):
38
+ raise ValueError()
39
+ for name in FILES:
40
+ if file_hash(path / name) != manifest["files"][name]:
41
+ raise ValueError()
42
+ return path
43
+ except Exception:
44
+ raise SafeError("anonymization_model_unavailable") from None
45
+
46
+
47
+ def install(path):
48
+ """Explicit operator command. Download the fixed snapshot, record file integrity."""
49
+ from huggingface_hub import snapshot_download
50
+
51
+ path = Path(path)
52
+ if (path / "manifest.json").exists():
53
+ return verify_cache(path)
54
+ if path.exists() and any(p.name not in {*FILES, ".cache"} for p in path.iterdir()):
55
+ raise SafeError("anonymization_model_setup_directory_not_empty")
56
+ snapshot_download(
57
+ MODEL_ID,
58
+ revision=MODEL_REVISION,
59
+ local_dir=path,
60
+ allow_patterns=list(FILES),
61
+ )
62
+ manifest = {
63
+ "model": MODEL_ID,
64
+ "revision": MODEL_REVISION,
65
+ "files": {name: file_hash(path / name) for name in FILES},
66
+ }
67
+ (path / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
68
+ return verify_cache(path)
69
+
70
+
71
+ if __name__ == "__main__":
72
+ import argparse
73
+
74
+ parser = argparse.ArgumentParser(description="Explicit pinned local anonymization model setup")
75
+ parser.add_argument("--cache", required=True)
76
+ args = parser.parse_args()
77
+ try:
78
+ install(args.cache)
79
+ except Exception:
80
+ raise SystemExit("anonymization_model_setup_failed") from None
81
+ print(f"Cached {MODEL_ID}@{MODEL_REVISION}")
ocr/anonymization/normalization.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One reversible source-coordinate view for hyphenated OCR line wraps."""
2
+
3
+ import re
4
+
5
+ LINE_WRAP = re.compile(r"(?<=[^\W\d_])-\r?\n(?=[^\W\d_])", re.UNICODE)
6
+
7
+
8
+ def dehyphenate(text):
9
+ removed = {i for match in LINE_WRAP.finditer(text) for i in range(match.start(), match.end())}
10
+ positions = tuple(i for i in range(len(text)) if i not in removed)
11
+ return "".join(text[i] for i in positions), positions
ocr/anonymization/outcomes.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Development accuracy and actual candidate coverage, with explicit abstentions.
2
+
3
+ The historical evaluator and its saved first reports are retained unchanged.
4
+ This reporting interface separates blocked evidence from emitted candidate text.
5
+ """
6
+
7
+ from collections import Counter
8
+
9
+ from ocr.schemas import SafeError
10
+
11
+ from .detectors import detect
12
+ from .evaluate import metrics, score_cases
13
+ from .schemas import LABELS
14
+
15
+ OUTCOMES = (
16
+ "complete_no_measured_leakage",
17
+ "candidate_partial_mask",
18
+ "candidate_fully_unmasked",
19
+ "blocked_conflicting_spans",
20
+ "blocked_model_runtime",
21
+ "other_incomplete",
22
+ )
23
+ RUNTIME_FAILURES = frozenset(
24
+ ("anonymization_model_unavailable", "anonymization_inference_failed", "anonymization_failed")
25
+ )
26
+
27
+
28
+ def score_outcomes(recognizer, cases, *, detector=detect):
29
+ class SafeRecognizer:
30
+ def recognize(self, text):
31
+ try:
32
+ return recognizer.recognize(text)
33
+ except SafeError:
34
+ raise
35
+ except Exception:
36
+ raise SafeError("anonymization_inference_failed") from None
37
+
38
+ def safe_detector(text):
39
+ try:
40
+ return detector(text)
41
+ except SafeError:
42
+ raise
43
+ except Exception:
44
+ raise SafeError("anonymization_failed") from None
45
+
46
+ details = []
47
+ accuracy = score_cases(SafeRecognizer(), cases, detector=safe_detector, case_reports=details)
48
+ # Historical aggregate coverage counts incomplete documents as uncovered.
49
+ # Name that conservative scope explicitly; it is not emitted-candidate leakage.
50
+ for old, new in (
51
+ ("unredacted_canaries", "uncovered_source_canaries_including_incomplete"),
52
+ ("literal_unredacted_canaries", "literal_presence_or_incomplete"),
53
+ ("partial_canaries", "partially_covered_source_canaries_including_incomplete"),
54
+ ("fully_uncovered_canaries", "fully_uncovered_source_canaries_including_incomplete"),
55
+ ("alphanumeric_canary_coverage", "all_document_source_coverage"),
56
+ ("qualification_passed", "conservative_accuracy_gate_passed"),
57
+ ):
58
+ accuracy[new] = accuracy.pop(old)
59
+ accuracy["scope"] = (
60
+ "all source spans; incomplete processing counts as missing, not leaked output"
61
+ )
62
+ counts, partial, full, candidate_spans = Counter(), Counter(), Counter(), Counter()
63
+ for case, detail in zip(cases, details, strict=True):
64
+ error = detail["error_code"]
65
+ if error:
66
+ outcome = (
67
+ "blocked_conflicting_spans"
68
+ if error == "ambiguous_entity_overlap"
69
+ else "blocked_model_runtime"
70
+ if error in RUNTIME_FAILURES
71
+ else "other_incomplete"
72
+ )
73
+ detail["candidate_returned"] = False
74
+ detail["candidate_canaries"] = []
75
+ else:
76
+ flags = []
77
+ for span in detail["canaries"]:
78
+ missing = span["uncovered_positions"]
79
+ total = sum(c.isalnum() for c in span["text"])
80
+ if missing:
81
+ kind = "fully_unmasked" if len(missing) == total else "partial_mask"
82
+ (full if kind == "fully_unmasked" else partial)[span["label"]] += 1
83
+ flags.append(
84
+ dict(label=span["label"], start=span["start"], end=span["end"], kind=kind)
85
+ )
86
+ detail["candidate_returned"] = True
87
+ detail["candidate_canaries"] = flags
88
+ outcome = (
89
+ "candidate_fully_unmasked"
90
+ if any(f["kind"] == "fully_unmasked" for f in flags)
91
+ else "candidate_partial_mask"
92
+ if flags
93
+ else "complete_no_measured_leakage"
94
+ )
95
+ gold = {(s["start"], s["end"], s["label"]) for s in case["spans"]}
96
+ predicted = {(s["start"], s["end"], s["label"]) for s in detail["combined_predictions"]}
97
+ for labelset, kind in (
98
+ (gold & predicted, "tp"),
99
+ (predicted - gold, "fp"),
100
+ (gold - predicted, "fn"),
101
+ ):
102
+ for _, _, label in labelset:
103
+ candidate_spans[(label, kind)] += 1
104
+ detail["outcome"] = outcome
105
+ counts[outcome] += 1
106
+ returned = sum(counts[k] for k in OUTCOMES[:3])
107
+ safe = counts["blocked_conflicting_spans"] + counts["blocked_model_runtime"]
108
+ return dict(
109
+ schema_version="development-candidate-outcomes/v1",
110
+ accuracy=accuracy,
111
+ candidate_outcomes={k: counts[k] for k in OUTCOMES},
112
+ candidate_return_rate=returned / len(cases),
113
+ safe_abstention_rate=safe / len(cases),
114
+ incomplete_rate=(len(cases) - returned) / len(cases),
115
+ no_measured_leakage_rate_among_returned=counts[OUTCOMES[0]] / returned
116
+ if returned
117
+ else None,
118
+ candidate_partial_canaries={k: partial[k] for k in LABELS},
119
+ candidate_fully_unmasked_canaries={k: full[k] for k in LABELS},
120
+ returned_candidate_entity_metrics=metrics(candidate_spans),
121
+ per_case=details,
122
+ automated_release_eligible=False,
123
+ recognition_qualified=False, # development evidence is not independent qualification
124
+ )
ocr/anonymization/private_review.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authorized in-process review views. No HTTP endpoint, exporter or IdP.
2
+
3
+ Only a real host adapter can attest bindings, ACLs and immutable source storage.
4
+ Views remain classified; showing a view or source page never completes review or
5
+ creates release permission. Original pages must be reviewed, not only detected spans.
6
+ """
7
+
8
+ import hashlib
9
+ import re
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Literal, Protocol
13
+
14
+ from pydantic import Field, model_validator
15
+
16
+ from ocr.schemas import SafeError
17
+
18
+ from .host_adapter import HostAuthorizationBridge, HostSecurityAdapter
19
+ from .release_policy import digest
20
+ from .schemas import (
21
+ CorrectedCandidate,
22
+ LegacyReleaseCandidate,
23
+ RecognitionProvenance,
24
+ ReleaseCandidate,
25
+ StrictModel,
26
+ )
27
+
28
+
29
+ class ReviewBinding(StrictModel):
30
+ review_ref: str = Field(pattern=r"^[a-zA-Z0-9_-]{1,128}$")
31
+ environment_id: str = Field(pattern=r"^[a-zA-Z0-9_-]{1,128}$")
32
+ sensitivity: Literal["D0", "D1", "D2"]
33
+ expires_at: int
34
+ revoked: bool
35
+
36
+
37
+ class SourcePageReference(StrictModel):
38
+ evidence_ref: str = Field(pattern=r"^[a-f0-9]{64}$")
39
+ page_index: int = Field(ge=0)
40
+ sensitivity: Literal["D0", "D1", "D2"]
41
+ sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
42
+ characters: int = Field(ge=0, le=2_000_000)
43
+
44
+
45
+ class PrivateReviewMaterial(StrictModel):
46
+ binding: ReviewBinding
47
+ evidence_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
48
+ sources: tuple[SourcePageReference, ...] = Field(min_length=1, max_length=100, repr=False)
49
+ candidate: CorrectedCandidate | ReleaseCandidate | LegacyReleaseCandidate | None = Field(
50
+ repr=False
51
+ )
52
+ candidate_digest: str | None = Field(pattern=r"^[a-f0-9]{64}$")
53
+ provenance: RecognitionProvenance | None
54
+ blocking_reason: (
55
+ Literal["conflicting_spans", "model_runtime_failure", "other_incomplete"] | None
56
+ )
57
+ anonymous: Literal[False] = False
58
+ automated_release_eligible: Literal[False] = False
59
+ release_review_required: Literal[True] = True
60
+
61
+ @model_validator(mode="after")
62
+ def consistent_record(self):
63
+ sources = {p.page_index: p for p in self.sources}
64
+ if (
65
+ len(sources) != len(self.sources)
66
+ or sum(p.characters for p in self.sources) > 2_000_000
67
+ or any(p.sensitivity != self.binding.sensitivity for p in self.sources)
68
+ ):
69
+ raise ValueError("invalid_private_review_material")
70
+ if self.candidate is None:
71
+ if (
72
+ self.candidate_digest is not None
73
+ or self.blocking_reason is None
74
+ or self.provenance is None
75
+ ):
76
+ raise ValueError("invalid_private_review_material")
77
+ else:
78
+ if (
79
+ self.candidate_digest != digest(self.candidate)
80
+ or self.blocking_reason is not None
81
+ or self.candidate.sensitivity != self.binding.sensitivity
82
+ or len(self.sources) != len(self.candidate.pages)
83
+ or {page.page_index for page in self.candidate.pages} != set(sources)
84
+ ):
85
+ raise ValueError("invalid_private_review_material")
86
+ if type(self.candidate) in (ReleaseCandidate, CorrectedCandidate):
87
+ if self.provenance != self.candidate.provenance:
88
+ raise ValueError("invalid_private_review_material")
89
+ elif self.provenance is not None:
90
+ raise ValueError("legacy_provenance_unavailable")
91
+ for page in self.candidate.pages:
92
+ if page.page_index not in sources or any(
93
+ not 0 <= tag.start < tag.end <= sources[page.page_index].characters
94
+ for tag in page.tags
95
+ ):
96
+ raise ValueError("invalid_private_review_material")
97
+ return self
98
+
99
+ @property
100
+ def categories(self):
101
+ return (
102
+ tuple(sorted({t.label for p in self.candidate.pages for t in p.tags}))
103
+ if self.candidate
104
+ else ()
105
+ )
106
+
107
+ @property
108
+ def review_flags(self):
109
+ # No fixture/gold knowledge or model success ever removes these flags.
110
+ ocr = self.candidate is not None and any(
111
+ "status-ocr" in t.normalization for p in self.candidate.pages for t in p.tags
112
+ )
113
+ return (
114
+ (
115
+ "recognition_incomplete_possible",
116
+ "model_scores_uncalibrated",
117
+ "review_complete_source_pages",
118
+ )
119
+ + (("processing_blocked",) if self.candidate is None else self.candidate.review_reasons)
120
+ + (
121
+ ("legacy_configuration_unknown",)
122
+ if type(self.candidate) is LegacyReleaseCandidate
123
+ else ()
124
+ )
125
+ ) + (("ocr_status_normalization",) if ocr else ())
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class ClassifiedPageView:
130
+ sensitivity: Literal["D0", "D1", "D2"]
131
+ page_index: int
132
+ text: str = field(repr=False)
133
+
134
+
135
+ class PrivateReviewHost(HostSecurityAdapter, Protocol):
136
+ def review_binding(self, review_ref: str) -> ReviewBinding: ...
137
+ def private_environment(self, environment_id: str) -> bool: ...
138
+ def load_review_material(self, review_ref: str) -> PrivateReviewMaterial: ...
139
+ def read_classified_page(self, evidence_ref: str, page_index: int) -> bytes: ...
140
+
141
+
142
+ class PrivateReviewBridge:
143
+ def __init__(self, host: PrivateReviewHost):
144
+ self.host = host
145
+ self.authorization = HostAuthorizationBridge(host)
146
+
147
+ def _binding(self, credential, review_ref):
148
+ if type(review_ref) is not str or not re.fullmatch(r"[a-zA-Z0-9_-]{1,128}", review_ref):
149
+ raise ValueError()
150
+ identity = self.authorization.identity(credential)
151
+ binding = ReviewBinding.model_validate(self.host.review_binding(review_ref))
152
+ if (
153
+ binding.review_ref != review_ref
154
+ or binding.revoked
155
+ or binding.expires_at <= time.time()
156
+ or self.host.private_environment(binding.environment_id) is not True
157
+ or self.host.current_acl(
158
+ identity, "anonymization:review", binding.sensitivity, binding.environment_id
159
+ )
160
+ is not True
161
+ ):
162
+ raise ValueError()
163
+ return binding
164
+
165
+ def open(self, credential, review_ref):
166
+ try:
167
+ binding = self._binding(credential, review_ref)
168
+ material = PrivateReviewMaterial.model_validate(
169
+ self.host.load_review_material(review_ref)
170
+ )
171
+ if (
172
+ material.candidate is not None
173
+ and self.host.candidate_exists(material.candidate_digest) is not True
174
+ ):
175
+ raise ValueError()
176
+ if material.binding != binding or self._binding(credential, review_ref) != binding:
177
+ raise ValueError()
178
+ return material
179
+ except Exception:
180
+ raise SafeError("private_review_access_denied") from None
181
+
182
+ def source_page(self, credential, review_ref, page_index):
183
+ try:
184
+ if type(page_index) is not int:
185
+ raise ValueError()
186
+ material = self.open(credential, review_ref)
187
+ source = next(p for p in material.sources if p.page_index == page_index)
188
+ data = self.host.read_classified_page(source.evidence_ref, page_index)
189
+ if (
190
+ not isinstance(data, bytes)
191
+ or len(data) > 8 * 1024 * 1024
192
+ or hashlib.sha256(data).hexdigest() != source.sha256
193
+ ):
194
+ raise ValueError()
195
+ text = data.decode("utf-8", errors="strict")
196
+ if len(text) != source.characters or self.open(credential, review_ref) != material:
197
+ raise ValueError()
198
+ return ClassifiedPageView(material.binding.sensitivity, page_index, text)
199
+ except Exception:
200
+ raise SafeError("private_review_access_denied") from None
ocr/anonymization/private_transport.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit local private delivery only. No URL client, redirects or fallback."""
2
+
3
+ import fcntl
4
+ import hashlib
5
+ import os
6
+ import stat
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from types import MappingProxyType
11
+ from typing import Literal
12
+
13
+ from pydantic import Field
14
+
15
+ from ocr.schemas import SafeError
16
+
17
+ from .schemas import Destination, StrictModel
18
+
19
+
20
+ class ReviewedExport(StrictModel):
21
+ schema_version: Literal["ocr-reviewed-export/v2"]
22
+ text: str
23
+ sensitivity: Literal["D0", "D1", "D2"]
24
+ anonymous: Literal[False]
25
+ release_review_required: Literal[False]
26
+ release_approved: Literal[True]
27
+ candidate_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
28
+ destination_id: str = Field(pattern=r"^[a-zA-Z0-9:_-]{1,128}$")
29
+ destination_class: Literal["private"]
30
+ transmission_authorized: Literal[False]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class LocalDestination:
35
+ policy: Destination
36
+ root: Path
37
+
38
+
39
+ class PrivateFileTransport:
40
+ def __init__(self, destinations, *, authorize_transfer):
41
+ if not callable(authorize_transfer):
42
+ raise SafeError("private_transport_not_configured")
43
+ self._authorize = authorize_transfer
44
+ registry = {}
45
+ for entry in destinations:
46
+ try:
47
+ root = Path(entry.root)
48
+ if (
49
+ not root.is_absolute()
50
+ or root.resolve() != root
51
+ or entry.policy.destination_class != "private"
52
+ ):
53
+ raise ValueError()
54
+ fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
55
+ try:
56
+ metadata = os.fstat(fd)
57
+ if metadata.st_uid != os.getuid() or metadata.st_mode & 0o077:
58
+ raise ValueError()
59
+ finally:
60
+ os.close(fd)
61
+ if entry.policy.destination_id in registry:
62
+ raise ValueError()
63
+ registry[entry.policy.destination_id] = (entry, metadata.st_dev, metadata.st_ino)
64
+ except Exception:
65
+ raise SafeError("private_destination_unavailable") from None
66
+ self._registry = MappingProxyType(registry)
67
+
68
+ def deliver(self, exported_json, *, credential, destination_id):
69
+ directory, temporary = None, None
70
+ try:
71
+ if (
72
+ destination_id not in self._registry
73
+ or not isinstance(exported_json, str)
74
+ or len(exported_json) > 8 * 1024 * 1024
75
+ ):
76
+ raise ValueError()
77
+ payload = ReviewedExport.model_validate_json(exported_json).model_dump()
78
+ entry, device, inode = self._registry[destination_id]
79
+ if (
80
+ payload.get("schema_version") != "ocr-reviewed-export/v2"
81
+ or payload.get("release_approved") is not True
82
+ or payload.get("transmission_authorized") is not False
83
+ or payload.get("destination_id") != destination_id
84
+ or payload.get("destination_class") != "private"
85
+ or payload.get("sensitivity") not in entry.policy.allowed_sensitivities
86
+ ):
87
+ raise ValueError()
88
+ data = exported_json.encode()
89
+ artifact_digest = hashlib.sha256(data).hexdigest()
90
+ if (
91
+ self._authorize(credential, artifact_digest, payload["sensitivity"], destination_id)
92
+ is not True
93
+ ):
94
+ raise ValueError()
95
+ directory = os.open(entry.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
96
+ fcntl.flock(directory, fcntl.LOCK_SH)
97
+ metadata = os.fstat(directory)
98
+ if (
99
+ not stat.S_ISDIR(metadata.st_mode)
100
+ or metadata.st_mode & 0o077
101
+ or metadata.st_uid != os.getuid()
102
+ or (metadata.st_dev, metadata.st_ino) != (device, inode)
103
+ ):
104
+ raise ValueError()
105
+ temporary = ".private-transfer-" + uuid.uuid4().hex
106
+ fd = os.open(
107
+ temporary,
108
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW,
109
+ 0o600,
110
+ dir_fd=directory,
111
+ )
112
+ with os.fdopen(fd, "wb") as stream:
113
+ if stream.write(data) != len(data):
114
+ raise ValueError()
115
+ stream.flush()
116
+ os.fsync(stream.fileno())
117
+ # Disk I/O may outlast a session/ACL/review/destination authorization.
118
+ # Recheck immediately before the atomic publication point. A real
119
+ # host must serialize revocation/publication if it needs stronger
120
+ # cross-store atomic semantics; this callback is not a transaction.
121
+ metadata = os.fstat(directory)
122
+ if (
123
+ metadata.st_mode & 0o077
124
+ or metadata.st_uid != os.getuid()
125
+ or self._authorize(
126
+ credential, artifact_digest, payload["sensitivity"], destination_id
127
+ )
128
+ is not True
129
+ ):
130
+ raise ValueError()
131
+ os.link(
132
+ temporary,
133
+ artifact_digest + ".json",
134
+ src_dir_fd=directory,
135
+ dst_dir_fd=directory,
136
+ follow_symlinks=False,
137
+ )
138
+ os.fsync(directory)
139
+ return dict(
140
+ destination_id=destination_id,
141
+ artifact_digest=artifact_digest,
142
+ sensitivity=payload["sensitivity"],
143
+ transport="local-private-file",
144
+ automated_release_eligible=False,
145
+ )
146
+ except Exception:
147
+ raise SafeError("private_transfer_denied_or_failed") from None
148
+ finally:
149
+ if directory is not None:
150
+ if temporary is not None:
151
+ try:
152
+ os.unlink(temporary, dir_fd=directory)
153
+ except OSError:
154
+ pass
155
+ os.close(directory)
ocr/anonymization/provenance.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Canonical configuration commitment; no raw terms/prompts in candidate metadata."""
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+
7
+ from ocr.schemas import SafeError
8
+
9
+ from .normalization import LINE_WRAP
10
+ from .replacement import REPLACEMENT_POLICY_VERSION
11
+ from .schemas import LABELS, RecognitionProvenance
12
+ from .terminology import TERMINOLOGY_REVISION
13
+
14
+
15
+ def provenance(recognizer, terminology, status_policy, placeholders):
16
+ try:
17
+ prompts = dict(recognizer.prompts)
18
+ threshold = recognizer.threshold
19
+ if (
20
+ set(prompts) != set(LABELS)
21
+ or any(
22
+ not isinstance(v, str) or not 1 <= len(v) <= 80 or not v.strip()
23
+ for v in prompts.values()
24
+ )
25
+ or len(set(prompts.values())) != len(LABELS)
26
+ or type(threshold) not in (float, int)
27
+ or not math.isfinite(threshold)
28
+ or not 0 < threshold < 1
29
+ ):
30
+ raise ValueError()
31
+ replacements = {k: f"[{k}]" for k in LABELS} if placeholders is None else dict(placeholders)
32
+ # replace() validates the complete placeholder syntax independently.
33
+ payload = dict(
34
+ schema_version="recognition-configuration/v1",
35
+ model_revision=recognizer.revision,
36
+ threshold=float(threshold),
37
+ prompts=prompts,
38
+ terminology=dict(version=TERMINOLOGY_REVISION, terms=sorted(terminology.terms)),
39
+ status=status_policy.configuration(),
40
+ replacement=dict(version=REPLACEMENT_POLICY_VERSION, placeholders=replacements),
41
+ normalization=dict(
42
+ line_wrap=LINE_WRAP.pattern,
43
+ unicode_casefold=True,
44
+ offset_mapping="source-codepoint-v1",
45
+ ),
46
+ contact_policy="contact-patterns-v1",
47
+ )
48
+ encoded = json.dumps(
49
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
50
+ ).encode()
51
+ return RecognitionProvenance(
52
+ model_revision=recognizer.revision,
53
+ configuration_digest=hashlib.sha256(encoded).hexdigest(),
54
+ )
55
+ except Exception:
56
+ raise SafeError("invalid_recognition_configuration") from None
ocr/anonymization/qualify03d.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """First-result evaluation of the frozen Gate03d synthetic holdout.
2
+
3
+ No prompt/threshold search, no remote access, no release authorization. Corpus,
4
+ configuration and implementation hashes are checked before model initialization.
5
+ An exclusive output is mandatory; the report is saved before acceptance is tested.
6
+ """
7
+
8
+ import hashlib
9
+ import importlib.metadata
10
+ import json
11
+ import resource
12
+ import statistics
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ from .atomic_output import write_candidate
20
+ from .detectors import detect
21
+ from .evaluate import score_cases
22
+ from .model import MODEL_REVISION
23
+ from .recognizer import DEFAULT_PROMPTS, GLiNERRecognizer
24
+ from .terminology import DEFAULT_TERMS
25
+
26
+
27
+ def verify_freeze():
28
+ root = Path(__file__).parent / "fixtures/holdout03d"
29
+ freeze = json.loads((root / "freeze.json").read_bytes())
30
+ data = (root / "cases.json").read_bytes()
31
+ repo = Path(__file__).parents[2]
32
+ if (
33
+ hashlib.sha256(data).hexdigest() != freeze["sha256"]
34
+ or freeze["model_revision"] != MODEL_REVISION
35
+ or freeze["prompts"] != DEFAULT_PROMPTS
36
+ or freeze["threshold"] != 0.5
37
+ or freeze["religion_terms"] != list(DEFAULT_TERMS)
38
+ or any(
39
+ hashlib.sha256((repo / name).read_bytes()).hexdigest() != digest
40
+ for name, digest in freeze["implementation_sha256"].items()
41
+ )
42
+ ):
43
+ raise ValueError("holdout_integrity_or_configuration_changed")
44
+ cases = json.loads(data)
45
+ if len(cases) != freeze["cases"] or len({c["id"] for c in cases}) != len(cases):
46
+ raise ValueError("holdout_integrity_failed")
47
+ return root, freeze, cases
48
+
49
+
50
+ def run(cache, output):
51
+ if Path(output).exists():
52
+ raise FileExistsError("first_result_already_exists")
53
+ root, freeze, cases = verify_freeze()
54
+ denied = []
55
+
56
+ def offline(event, args):
57
+ if event in ("socket.connect", "socket.bind", "socket.getaddrinfo"):
58
+ denied.append(event)
59
+ raise PermissionError("qualification_network_disabled")
60
+
61
+ sys.addaudithook(offline)
62
+ started = datetime.now(timezone.utc).isoformat()
63
+ start = time.perf_counter()
64
+ model = GLiNERRecognizer(cache)
65
+ load_ms = (time.perf_counter() - start) * 1000
66
+ model.recognize("A synthetic warmup sentence.")
67
+ details = []
68
+ evaluation = score_cases(model, cases, case_reports=details)
69
+ # Cost of the deterministic layer separately, with no extra model inference.
70
+ rule_ms = []
71
+ for case in cases:
72
+ start = time.perf_counter()
73
+ detect(case["text"])
74
+ rule_ms.append((time.perf_counter() - start) * 1000)
75
+ report = dict(
76
+ schema_version="gate03d-independent-first-selected/v1",
77
+ started_utc=started,
78
+ finished_utc=datetime.now(timezone.utc).isoformat(),
79
+ code_sha=subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
80
+ freeze_commit=subprocess.check_output(
81
+ ["git", "log", "-1", "--format=%H", "--", str(root / "cases.json")], text=True
82
+ ).strip(),
83
+ corpus_sha256=freeze["sha256"],
84
+ implementation_sha256=freeze["implementation_sha256"],
85
+ model_revision=MODEL_REVISION,
86
+ prompts=model.prompts,
87
+ threshold=model.threshold,
88
+ religion_terms=list(DEFAULT_TERMS),
89
+ runtime_versions={
90
+ k: importlib.metadata.version(k)
91
+ for k in ("gliner", "torch", "transformers", "numpy", "pydantic")
92
+ },
93
+ loading_ms=load_ms,
94
+ evaluation=evaluation,
95
+ per_case=details,
96
+ deterministic_latency_ms=dict(median=statistics.median(rule_ms), max=max(rule_ms)),
97
+ peak_rss_mib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024,
98
+ denied_network_calls=denied,
99
+ automated_release_eligible=False,
100
+ )
101
+ write_candidate(output, (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode())
102
+ return report
103
+
104
+
105
+ if __name__ == "__main__":
106
+ import argparse
107
+
108
+ parser = argparse.ArgumentParser(description="Evaluate frozen Gate03d holdout once")
109
+ parser.add_argument("--cache", required=True)
110
+ parser.add_argument("--output", required=True)
111
+ args = parser.parse_args()
112
+ result = run(args.cache, args.output)
113
+ print(
114
+ json.dumps(
115
+ dict(
116
+ qualification_passed=result["evaluation"]["qualification_passed"],
117
+ automated_release_eligible=False,
118
+ )
119
+ )
120
+ )
121
+ raise SystemExit(0 if result["evaluation"]["qualification_passed"] else 1)
ocr/anonymization/recognizer.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local native GLiNER runtime. Character offsets always refer to untouched text."""
2
+
3
+ import math
4
+ import re
5
+ import threading
6
+
7
+ from ocr.schemas import SafeError
8
+
9
+ from .model import MODEL_REVISION, verify_cache
10
+ from .normalization import dehyphenate
11
+ from .replacement import validate_entities
12
+ from .schemas import LABELS, Entity
13
+
14
+ DEFAULT_PROMPTS = {
15
+ "PERSON": "person",
16
+ # Privacy category includes geopolitical places, matching the existing
17
+ # qualification taxonomy (cities, states and named geographic locations).
18
+ "CITY": "location",
19
+ "RELIGION": "religion",
20
+ "EMAIL": "email address",
21
+ "PHONE": "phone number",
22
+ "ORGANIZATION": "organization",
23
+ }
24
+
25
+
26
+ class GLiNERRecognizer:
27
+ local = True
28
+ revision = MODEL_REVISION
29
+ categories = LABELS
30
+
31
+ def __init__(self, cache, *, device="cpu", threshold=0.5, prompts=None, threads=2):
32
+ self._lock = threading.Lock()
33
+ try:
34
+ self.prompts = dict(DEFAULT_PROMPTS if prompts is None else prompts)
35
+ except Exception:
36
+ raise SafeError("unsupported_required_categories") from None
37
+ if set(self.prompts) != set(LABELS) or (
38
+ any(
39
+ not isinstance(v, str) or not v.strip() or len(v) > 80
40
+ for v in self.prompts.values()
41
+ )
42
+ or len(set(self.prompts.values())) != len(LABELS)
43
+ ):
44
+ raise SafeError("unsupported_required_categories")
45
+ if (
46
+ type(threshold) not in (int, float)
47
+ or not math.isfinite(threshold)
48
+ or not 0 < threshold < 1
49
+ ):
50
+ raise SafeError("invalid_recognizer_configuration")
51
+ if device not in ("cpu", "cuda") or type(threads) is not int or not 1 <= threads <= 16:
52
+ raise SafeError("invalid_recognizer_configuration")
53
+ self.threshold = threshold
54
+ try:
55
+ path = verify_cache(cache)
56
+ import torch
57
+ from gliner import GLiNER
58
+
59
+ torch.set_num_threads(threads)
60
+ self.model = GLiNER.from_pretrained(
61
+ str(path),
62
+ local_files_only=True,
63
+ map_location=device,
64
+ _attn_implementation="eager",
65
+ strict=True,
66
+ )
67
+ self.model.eval()
68
+ except Exception:
69
+ raise SafeError("anonymization_model_unavailable") from None
70
+
71
+ def recognize(self, text):
72
+ if not isinstance(text, str) or len(text) > 2_000_000:
73
+ raise SafeError("anonymization_text_limit")
74
+ normalized, positions = dehyphenate(text)
75
+ predictions = self._recognize_view(normalized)
76
+ if normalized == text:
77
+ return predictions
78
+ mapped = []
79
+ for entity in predictions:
80
+ start = positions[entity.start]
81
+ end = positions[entity.end - 1] + 1
82
+ mapped.append(
83
+ entity.model_copy(
84
+ update={
85
+ "start": start,
86
+ "end": end,
87
+ "text": text[start:end],
88
+ "normalization": "dehyphenation-v1",
89
+ }
90
+ )
91
+ )
92
+ return validate_entities(text, mapped)
93
+
94
+ def _recognize_view(self, text):
95
+ if not isinstance(text, str) or len(text) > 2_000_000:
96
+ raise SafeError("anonymization_text_limit")
97
+ # Overlapping windows cover all original characters without normalizing OCR noise.
98
+ # Hard bounds prevent silent tokenizer/model truncation on unusual long tokens.
99
+ words = list(re.finditer(r"\S+", text))
100
+ if any(len(w[0]) > 256 for w in words):
101
+ raise SafeError("anonymization_token_limit")
102
+ entities = []
103
+ reverse = {v: k for k, v in self.prompts.items()}
104
+ try:
105
+ import torch
106
+
107
+ with self._lock, torch.inference_mode():
108
+ for start in range(0, len(words), 96):
109
+ window = words[start : start + 128]
110
+ left, right = window[0].start(), window[-1].end()
111
+ chunk = text[left:right]
112
+ encoded = self.model.data_processor.transformer_tokenizer(chunk)["input_ids"]
113
+ if len(encoded) > 1536:
114
+ raise SafeError("anonymization_token_limit")
115
+ raw = self.model.predict_entities(
116
+ chunk,
117
+ list(self.prompts.values()),
118
+ threshold=self.threshold,
119
+ flat_ner=True,
120
+ )
121
+ if not isinstance(raw, list):
122
+ raise ValueError()
123
+ for item in raw:
124
+ if not isinstance(item, dict) or set(item) != {
125
+ "start",
126
+ "end",
127
+ "text",
128
+ "label",
129
+ "score",
130
+ }:
131
+ raise ValueError()
132
+ # Validate runtime output before adding the original window offset.
133
+ local = Entity(
134
+ start=item["start"],
135
+ end=item["end"],
136
+ text=item["text"],
137
+ label=reverse[item["label"]],
138
+ score=item["score"],
139
+ source="multilingual_ner",
140
+ model_revision=self.revision,
141
+ )
142
+ validate_entities(chunk, [local])
143
+ entities.append(
144
+ local.model_copy(
145
+ update={"start": local.start + left, "end": local.end + left}
146
+ )
147
+ )
148
+ return validate_entities(text, entities)
149
+ except SafeError:
150
+ raise
151
+ except Exception:
152
+ raise SafeError("anonymization_inference_failed") from None