ccrt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ccrt/__init__.py +37 -0
- ccrt/__main__.py +3 -0
- ccrt/_version.py +1 -0
- ccrt/cli.py +205 -0
- ccrt/client.py +166 -0
- ccrt/config.py +23 -0
- ccrt/evaluation.py +30 -0
- ccrt/export.py +152 -0
- ccrt/interactive.py +160 -0
- ccrt/loaders/__init__.py +5 -0
- ccrt/loaders/acled.py +69 -0
- ccrt/loaders/base.py +144 -0
- ccrt/loaders/ucdp.py +75 -0
- ccrt/merge.py +186 -0
- ccrt/report.py +170 -0
- ccrt/review.py +56 -0
- ccrt/schema.py +162 -0
- ccrt/storage.py +168 -0
- ccrt/tui.py +349 -0
- ccrt-0.1.0.dist-info/METADATA +118 -0
- ccrt-0.1.0.dist-info/RECORD +25 -0
- ccrt-0.1.0.dist-info/WHEEL +5 -0
- ccrt-0.1.0.dist-info/entry_points.txt +2 -0
- ccrt-0.1.0.dist-info/licenses/LICENSE +21 -0
- ccrt-0.1.0.dist-info/top_level.txt +1 -0
ccrt/tui.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Optional full-screen terminal dashboard for reviewing candidate links.
|
|
2
|
+
|
|
3
|
+
The module uses only the standard-library curses package and can be removed
|
|
4
|
+
without changing the matching or review data formats.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
import textwrap
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
|
|
13
|
+
from ccrt.interactive import _coordinates, _date
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import curses as _curses
|
|
17
|
+
except ImportError: # Optional on platforms without curses.
|
|
18
|
+
_curses = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ReviewDashboard:
|
|
22
|
+
"""Navigation and decisions, independent of the terminal renderer."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, result, review, *, reviewer, save, left_id=None):
|
|
25
|
+
review.validate(result)
|
|
26
|
+
if not reviewer.strip():
|
|
27
|
+
raise ValueError("--reviewer needs a nonempty name")
|
|
28
|
+
self.result = result
|
|
29
|
+
self.review = review
|
|
30
|
+
self.reviewer = reviewer
|
|
31
|
+
self.save = save
|
|
32
|
+
self.groups = defaultdict(list)
|
|
33
|
+
for candidate in result.candidates:
|
|
34
|
+
self.groups[candidate.left.source_id].append(candidate)
|
|
35
|
+
if left_id is not None and left_id not in self.groups:
|
|
36
|
+
raise ValueError(f"no candidate group for left source ID {left_id}")
|
|
37
|
+
self.decisions = {decision.candidate_id: decision for decision in review.decisions}
|
|
38
|
+
self.show_all = False
|
|
39
|
+
self.group_ids = []
|
|
40
|
+
self.group_index = 0
|
|
41
|
+
self.candidate_index = 0
|
|
42
|
+
self.detail_scroll = 0
|
|
43
|
+
self.message = "Arrows: groups/candidates a/r/u: decide e: note f: filter g: jump q: quit"
|
|
44
|
+
self.refresh(start=left_id)
|
|
45
|
+
|
|
46
|
+
def refresh(self, *, start=None):
|
|
47
|
+
current = start or (self.group_ids[self.group_index] if self.group_ids else None)
|
|
48
|
+
self.group_ids = sorted(
|
|
49
|
+
(
|
|
50
|
+
key
|
|
51
|
+
for key, items in self.groups.items()
|
|
52
|
+
if key == current
|
|
53
|
+
or self.show_all
|
|
54
|
+
or any(self.decisions[c.candidate_id].status == "unresolved" for c in items)
|
|
55
|
+
),
|
|
56
|
+
key=lambda key: (-self.groups[key][0].score, key),
|
|
57
|
+
)
|
|
58
|
+
self.group_index = self.group_ids.index(current) if current in self.group_ids else 0
|
|
59
|
+
self.candidate_index = 0
|
|
60
|
+
self.detail_scroll = 0
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def candidates(self):
|
|
64
|
+
return self.groups[self.group_ids[self.group_index]] if self.group_ids else []
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def candidate(self):
|
|
68
|
+
return self.candidates[self.candidate_index] if self.candidates else None
|
|
69
|
+
|
|
70
|
+
def move_group(self, delta):
|
|
71
|
+
if self.group_ids:
|
|
72
|
+
self.group_index = (self.group_index + delta) % len(self.group_ids)
|
|
73
|
+
self.candidate_index = 0
|
|
74
|
+
self.detail_scroll = 0
|
|
75
|
+
|
|
76
|
+
def move_candidate(self, delta):
|
|
77
|
+
if self.candidates:
|
|
78
|
+
self.candidate_index = (self.candidate_index + delta) % len(self.candidates)
|
|
79
|
+
self.detail_scroll = 0
|
|
80
|
+
|
|
81
|
+
def jump(self, left_id):
|
|
82
|
+
if left_id not in self.groups:
|
|
83
|
+
self.message = f"No candidate group for {left_id}"
|
|
84
|
+
return
|
|
85
|
+
self.refresh(start=left_id)
|
|
86
|
+
self.message = f"Jumped to {left_id}"
|
|
87
|
+
|
|
88
|
+
def toggle_filter(self):
|
|
89
|
+
self.show_all = not self.show_all
|
|
90
|
+
self.refresh()
|
|
91
|
+
self.message = "Showing all groups" if self.show_all else "Showing unresolved groups"
|
|
92
|
+
|
|
93
|
+
def decide(self, status, *, note=None):
|
|
94
|
+
candidate = self.candidate
|
|
95
|
+
if candidate is None:
|
|
96
|
+
return
|
|
97
|
+
old = self.decisions[candidate.candidate_id]
|
|
98
|
+
updated = self.review.decide(
|
|
99
|
+
candidate.candidate_id,
|
|
100
|
+
status,
|
|
101
|
+
reviewer=self.reviewer,
|
|
102
|
+
note=old.note if note is None else note,
|
|
103
|
+
)
|
|
104
|
+
self.save(updated)
|
|
105
|
+
self.review = updated
|
|
106
|
+
self.decisions[candidate.candidate_id] = next(
|
|
107
|
+
decision for decision in updated.decisions if decision.candidate_id == candidate.candidate_id
|
|
108
|
+
)
|
|
109
|
+
self.message = f"Saved {status}: {candidate.left.source_id}/{candidate.right.source_id}"
|
|
110
|
+
|
|
111
|
+
def set_note(self, note):
|
|
112
|
+
candidate = self.candidate
|
|
113
|
+
if candidate is not None:
|
|
114
|
+
self.decide(self.decisions[candidate.candidate_id].status, note=note)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _put(screen, y, x, value, width, attr=0):
|
|
118
|
+
if width < 1 or y < 0 or x < 0:
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
screen.addnstr(y, x, str(value).replace("\n", " "), width, attr)
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
# curses.error is raised when a terminal rejects a bottom-right write.
|
|
124
|
+
if _curses is None or not isinstance(exc, _curses.error):
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _field(lines, label, value, width):
|
|
129
|
+
value = " ".join(str("not supplied" if value is None or value == "" else value).split())
|
|
130
|
+
prefix = f"{label}: "
|
|
131
|
+
lines.extend(textwrap.wrap(prefix + value, width=max(12, width), subsequent_indent=" ") or [prefix])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _account_lines(lines, record, width):
|
|
135
|
+
raw = record.raw
|
|
136
|
+
lines.append(f"{record.source.upper()} {record.source_id}")
|
|
137
|
+
_field(lines, "Date", _date(record), width)
|
|
138
|
+
_field(lines, "Place", raw.get("where_description") or raw.get("location"), width)
|
|
139
|
+
_field(lines, "Coordinates", _coordinates(record), width)
|
|
140
|
+
_field(lines, "Actor 1", record.actor1, width)
|
|
141
|
+
_field(lines, "Actor 2", record.actor2, width)
|
|
142
|
+
_field(lines, "Deaths", record.total_deaths_best, width)
|
|
143
|
+
_field(lines, "Location precision", record.geo_precision, width)
|
|
144
|
+
for label, key in (
|
|
145
|
+
("Notes", "notes"),
|
|
146
|
+
("Source articles", "source_article"),
|
|
147
|
+
("Original sources", "source_original"),
|
|
148
|
+
):
|
|
149
|
+
if raw.get(key):
|
|
150
|
+
_field(lines, label, raw[key], width)
|
|
151
|
+
lines.append("")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def detail_lines(dashboard, width):
|
|
155
|
+
candidate = dashboard.candidate
|
|
156
|
+
if candidate is None:
|
|
157
|
+
return ["No candidate groups in this view. Press f to show all groups."]
|
|
158
|
+
decision = dashboard.decisions[candidate.candidate_id]
|
|
159
|
+
lines = []
|
|
160
|
+
_account_lines(lines, candidate.left, width)
|
|
161
|
+
_account_lines(lines, candidate.right, width)
|
|
162
|
+
lines.append("COMPARISON")
|
|
163
|
+
_field(
|
|
164
|
+
lines,
|
|
165
|
+
"Distance",
|
|
166
|
+
f"{candidate.distance_km:.3f} km (limit {dashboard.result.config.max_km:g} km)",
|
|
167
|
+
width,
|
|
168
|
+
)
|
|
169
|
+
_field(
|
|
170
|
+
lines,
|
|
171
|
+
"Date gap",
|
|
172
|
+
f"{candidate.days_apart} days (limit {dashboard.result.config.max_days} days)",
|
|
173
|
+
width,
|
|
174
|
+
)
|
|
175
|
+
_field(lines, "Proximity score", f"{candidate.score:.3f} (ranking only)", width)
|
|
176
|
+
_field(lines, "Warnings", ", ".join(candidate.warnings) or "none", width)
|
|
177
|
+
_field(lines, "Decision", decision.status, width)
|
|
178
|
+
_field(lines, "Reviewer note", decision.note, width)
|
|
179
|
+
lines.append("")
|
|
180
|
+
lines.extend(
|
|
181
|
+
textwrap.wrap(
|
|
182
|
+
"Events can share coordinates. Check the dates, actors, and notes.",
|
|
183
|
+
width=max(12, width),
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
return lines
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _draw(screen, dashboard, curses):
|
|
190
|
+
height, width = screen.getmaxyx()
|
|
191
|
+
screen.erase()
|
|
192
|
+
if height < 20 or width < 95:
|
|
193
|
+
_put(screen, 0, 0, "CCRT review dashboard", width - 1, curses.A_BOLD)
|
|
194
|
+
_put(screen, 2, 0, "Resize terminal to at least 95 columns x 20 rows. Press q to quit.", width - 1)
|
|
195
|
+
screen.refresh()
|
|
196
|
+
return
|
|
197
|
+
left_width = 23
|
|
198
|
+
middle_width = 39
|
|
199
|
+
detail_x = left_width + middle_width + 2
|
|
200
|
+
detail_width = width - detail_x - 1
|
|
201
|
+
visible = height - 6
|
|
202
|
+
counts = defaultdict(int)
|
|
203
|
+
for decision in dashboard.review.decisions:
|
|
204
|
+
counts[decision.status] += 1
|
|
205
|
+
accent = curses.color_pair(1) if curses.has_colors() else 0
|
|
206
|
+
_put(screen, 0, 0, " CCRT/Review ", width - 1, curses.A_REVERSE | curses.A_BOLD | accent)
|
|
207
|
+
_put(
|
|
208
|
+
screen,
|
|
209
|
+
1,
|
|
210
|
+
1,
|
|
211
|
+
f"Reviewer {dashboard.reviewer} | {counts['accepted']} accepted {counts['rejected']} rejected"
|
|
212
|
+
f" {counts['unresolved']} unresolved | {len(dashboard.group_ids)} groups",
|
|
213
|
+
width - 2,
|
|
214
|
+
)
|
|
215
|
+
_put(screen, 3, 1, "UCDP EVENTS", left_width - 2, curses.A_BOLD)
|
|
216
|
+
_put(screen, 3, left_width + 1, "ACLED CANDIDATES", middle_width - 2, curses.A_BOLD)
|
|
217
|
+
_put(screen, 3, detail_x, "SOURCE ACCOUNTS", detail_width, curses.A_BOLD)
|
|
218
|
+
for y in range(3, height - 2):
|
|
219
|
+
_put(screen, y, left_width, "|", 1)
|
|
220
|
+
_put(screen, y, detail_x - 1, "|", 1)
|
|
221
|
+
if dashboard.group_ids:
|
|
222
|
+
start = max(0, dashboard.group_index - visible // 2)
|
|
223
|
+
start = min(start, max(0, len(dashboard.group_ids) - visible))
|
|
224
|
+
for offset, index in enumerate(range(start, min(start + visible, len(dashboard.group_ids)))):
|
|
225
|
+
group_id = dashboard.group_ids[index]
|
|
226
|
+
pending = sum(
|
|
227
|
+
dashboard.decisions[c.candidate_id].status == "unresolved" for c in dashboard.groups[group_id]
|
|
228
|
+
)
|
|
229
|
+
marker = ">" if index == dashboard.group_index else " "
|
|
230
|
+
value = f"{marker} {group_id} ({pending} open)"
|
|
231
|
+
attr = curses.A_REVERSE if index == dashboard.group_index else 0
|
|
232
|
+
_put(screen, 4 + offset, 1, value, left_width - 2, attr)
|
|
233
|
+
candidates = dashboard.candidates
|
|
234
|
+
start = max(0, dashboard.candidate_index - visible // 2)
|
|
235
|
+
start = min(start, max(0, len(candidates) - visible))
|
|
236
|
+
for offset, index in enumerate(range(start, min(start + visible, len(candidates)))):
|
|
237
|
+
candidate = candidates[index]
|
|
238
|
+
status = dashboard.decisions[candidate.candidate_id].status
|
|
239
|
+
code = {"accepted": "A", "rejected": "R", "unresolved": "?"}[status]
|
|
240
|
+
marker = ">" if index == dashboard.candidate_index else " "
|
|
241
|
+
value = (
|
|
242
|
+
f"{marker} {code} {candidate.right.source_id} {candidate.score:.3f}"
|
|
243
|
+
f" {candidate.distance_km:.1f}km"
|
|
244
|
+
)
|
|
245
|
+
attr = curses.A_REVERSE if index == dashboard.candidate_index else 0
|
|
246
|
+
if curses.has_colors() and index != dashboard.candidate_index:
|
|
247
|
+
attr = curses.color_pair({"accepted": 2, "rejected": 3, "unresolved": 4}[status])
|
|
248
|
+
_put(screen, 4 + offset, left_width + 1, value, middle_width - 2, attr)
|
|
249
|
+
lines = detail_lines(dashboard, detail_width)
|
|
250
|
+
max_scroll = max(0, len(lines) - visible)
|
|
251
|
+
dashboard.detail_scroll = min(dashboard.detail_scroll, max_scroll)
|
|
252
|
+
for offset, line in enumerate(lines[dashboard.detail_scroll : dashboard.detail_scroll + visible]):
|
|
253
|
+
_put(screen, 4 + offset, detail_x, line, detail_width)
|
|
254
|
+
_put(
|
|
255
|
+
screen,
|
|
256
|
+
height - 2,
|
|
257
|
+
0,
|
|
258
|
+
" Left/Right group Up/Down candidate PgUp/PgDn scroll a/r/u decide e note f filter g jump q quit ",
|
|
259
|
+
width - 1,
|
|
260
|
+
curses.A_REVERSE,
|
|
261
|
+
)
|
|
262
|
+
_put(screen, height - 1, 1, dashboard.message, width - 2)
|
|
263
|
+
screen.refresh()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _prompt(screen, curses, label):
|
|
267
|
+
height, width = screen.getmaxyx()
|
|
268
|
+
screen.move(height - 1, 0)
|
|
269
|
+
screen.clrtoeol()
|
|
270
|
+
_put(screen, height - 1, 0, label, width - 1)
|
|
271
|
+
screen.refresh()
|
|
272
|
+
curses.echo()
|
|
273
|
+
try:
|
|
274
|
+
curses.curs_set(1)
|
|
275
|
+
except curses.error:
|
|
276
|
+
pass
|
|
277
|
+
try:
|
|
278
|
+
value = screen.getstr(height - 1, min(len(label), width - 2), 1000)
|
|
279
|
+
return value.decode("utf-8", errors="replace").strip()
|
|
280
|
+
finally:
|
|
281
|
+
curses.noecho()
|
|
282
|
+
try:
|
|
283
|
+
curses.curs_set(0)
|
|
284
|
+
except curses.error:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _loop(screen, dashboard, curses):
|
|
289
|
+
if curses.has_colors():
|
|
290
|
+
curses.start_color()
|
|
291
|
+
try:
|
|
292
|
+
curses.use_default_colors()
|
|
293
|
+
background = -1
|
|
294
|
+
except curses.error:
|
|
295
|
+
background = curses.COLOR_BLACK
|
|
296
|
+
for number, foreground in (
|
|
297
|
+
(1, curses.COLOR_CYAN),
|
|
298
|
+
(2, curses.COLOR_GREEN),
|
|
299
|
+
(3, curses.COLOR_RED),
|
|
300
|
+
(4, curses.COLOR_YELLOW),
|
|
301
|
+
):
|
|
302
|
+
curses.init_pair(number, foreground, background)
|
|
303
|
+
try:
|
|
304
|
+
curses.curs_set(0)
|
|
305
|
+
except curses.error:
|
|
306
|
+
pass
|
|
307
|
+
screen.keypad(True)
|
|
308
|
+
while True:
|
|
309
|
+
_draw(screen, dashboard, curses)
|
|
310
|
+
key = screen.getch()
|
|
311
|
+
if key in (ord("q"), ord("Q")):
|
|
312
|
+
return
|
|
313
|
+
if key in (curses.KEY_LEFT, ord("h")):
|
|
314
|
+
dashboard.move_group(-1)
|
|
315
|
+
elif key in (curses.KEY_RIGHT, ord("l")):
|
|
316
|
+
dashboard.move_group(1)
|
|
317
|
+
elif key in (curses.KEY_UP, ord("k")):
|
|
318
|
+
dashboard.move_candidate(-1)
|
|
319
|
+
elif key in (curses.KEY_DOWN, ord("j")):
|
|
320
|
+
dashboard.move_candidate(1)
|
|
321
|
+
elif key == curses.KEY_PPAGE:
|
|
322
|
+
dashboard.detail_scroll = max(0, dashboard.detail_scroll - 8)
|
|
323
|
+
elif key == curses.KEY_NPAGE:
|
|
324
|
+
dashboard.detail_scroll += 8
|
|
325
|
+
elif key in (ord("a"), ord("r"), ord("u")):
|
|
326
|
+
dashboard.decide({ord("a"): "accepted", ord("r"): "rejected", ord("u"): "unresolved"}[key])
|
|
327
|
+
elif key == ord("e") and dashboard.candidate is not None:
|
|
328
|
+
dashboard.set_note(_prompt(screen, curses, "Reviewer note (blank clears): "))
|
|
329
|
+
elif key == ord("f"):
|
|
330
|
+
dashboard.toggle_filter()
|
|
331
|
+
elif key == ord("g"):
|
|
332
|
+
value = _prompt(screen, curses, "UCDP/source ID: ")
|
|
333
|
+
if value:
|
|
334
|
+
dashboard.jump(value)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def run_tui(result, review, *, reviewer, save, left_id=None):
|
|
338
|
+
"""Start the optional dashboard in an interactive terminal."""
|
|
339
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
340
|
+
raise ValueError("review-tui needs an interactive terminal; use review-interactive for pipes")
|
|
341
|
+
if _curses is None:
|
|
342
|
+
raise ValueError("review-tui requires a terminal with Python curses support") from None
|
|
343
|
+
dashboard = ReviewDashboard(result, review, reviewer=reviewer, save=save, left_id=left_id)
|
|
344
|
+
try:
|
|
345
|
+
_curses.wrapper(lambda screen: _loop(screen, dashboard, _curses))
|
|
346
|
+
except KeyboardInterrupt:
|
|
347
|
+
return
|
|
348
|
+
except _curses.error as exc:
|
|
349
|
+
raise ValueError(f"terminal could not start review-tui: {exc}") from exc
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ccrt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Experimental toolkit for reviewing candidate links between conflict incident records
|
|
5
|
+
Author: Fabian Figueroa
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/FabianFig/ccrt
|
|
8
|
+
Project-URL: Issues, https://github.com/FabianFig/ccrt/issues
|
|
9
|
+
Keywords: conflict,record-linkage,ucdp,acled,research
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: httpx<1,>=0.27
|
|
23
|
+
Requires-Dist: python-dotenv<2,>=1
|
|
24
|
+
Provides-Extra: tables
|
|
25
|
+
Requires-Dist: pandas>=2.2; extra == "tables"
|
|
26
|
+
Requires-Dist: pyarrow>=15; extra == "tables"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.9; extra == "dev"
|
|
30
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
31
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
32
|
+
Requires-Dist: nbformat>=5.10; extra == "dev"
|
|
33
|
+
Requires-Dist: nbclient>=0.10; extra == "dev"
|
|
34
|
+
Requires-Dist: ipykernel>=6; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# Conflict Casualty Reconciliation Toolkit (CCRT)
|
|
38
|
+
|
|
39
|
+
CCRT compares source accounts of conflict incidents. It imports UCDP GED and ACLED
|
|
40
|
+
records, proposes possible links, supports human review, and exports results while
|
|
41
|
+
preserving each source's original claims.
|
|
42
|
+
|
|
43
|
+
**Status:** `0.1.0` is an experimental research library.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
CCRT supports Python 3.10–3.14. To install a published release:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python -m pip install ccrt
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Install `ccrt[tables]` for pandas and Parquet support. From a checkout, use
|
|
54
|
+
`python -m pip install -e .`, or `python -m pip install -e '.[tables,dev]'` for the
|
|
55
|
+
project's development checks.
|
|
56
|
+
|
|
57
|
+
## Quickstart
|
|
58
|
+
|
|
59
|
+
The example files contain invented incidents in Exampleland. From the repository
|
|
60
|
+
root, with an unused `work/` directory:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
mkdir -p work
|
|
64
|
+
ccrt import ucdp examples/ucdp_synthetic.csv work/ucdp.json --version 26.1
|
|
65
|
+
ccrt import acled examples/acled_synthetic.csv work/acled.json --version synthetic-2026-09-24
|
|
66
|
+
ccrt match work/ucdp.json work/acled.json work/matches
|
|
67
|
+
ccrt report work/matches/run.json --review work/matches/review.json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The match run contains three candidate links and four unlinked source records.
|
|
71
|
+
Inspect `work/matches/candidates.csv`, then use the [usage guide](docs/usage.md) to
|
|
72
|
+
record decisions and export reviewed results. CCRT does not overwrite existing
|
|
73
|
+
outputs.
|
|
74
|
+
|
|
75
|
+
## Python API
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from ccrt import MatchConfig, load_acled_csv, load_ucdp_csv, reconcile
|
|
79
|
+
|
|
80
|
+
ucdp = load_ucdp_csv("examples/ucdp_synthetic.csv", version="26.1")
|
|
81
|
+
acled = load_acled_csv("examples/acled_synthetic.csv", version="synthetic-2026-09-24")
|
|
82
|
+
result = reconcile(ucdp, acled, config=MatchConfig(max_days=3, max_km=25))
|
|
83
|
+
|
|
84
|
+
for candidate in result.candidates:
|
|
85
|
+
print(candidate.left.source_id, candidate.right.source_id, candidate.score)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The [API reference](docs/api.md) covers review decisions, saved runs, reports, and
|
|
89
|
+
exports.
|
|
90
|
+
|
|
91
|
+
## Sources and capabilities
|
|
92
|
+
|
|
93
|
+
- UCDP GED 25.1 and 26.1 CSV files with modern field names, and authenticated GED
|
|
94
|
+
API access.
|
|
95
|
+
- ACLED event CSV exports with current lowercase field names. The supplied version
|
|
96
|
+
is a label for the exact export snapshot.
|
|
97
|
+
- Source-preserving JSON bundles, candidate generation, CSV and terminal review,
|
|
98
|
+
review reports, CSV export, and optional Parquet export.
|
|
99
|
+
|
|
100
|
+
See [source mappings](docs/source-mappings.md) for supported fields, precision
|
|
101
|
+
handling, and provenance. The [teaching notebook](examples/reconciliation_walkthrough.ipynb)
|
|
102
|
+
provides another offline example.
|
|
103
|
+
|
|
104
|
+
## Limitations
|
|
105
|
+
|
|
106
|
+
CCRT compares source accounts; it does not establish objective incident identity.
|
|
107
|
+
Candidate scores are uncalibrated proximity rankings, and generating a candidate
|
|
108
|
+
does not confirm correspondence. Real-world linkage accuracy has not been
|
|
109
|
+
independently evaluated. CCRT does not reconcile casualty totals or estimate
|
|
110
|
+
underreporting. See [validation notes](docs/validation.md) for software checks and
|
|
111
|
+
research limits.
|
|
112
|
+
|
|
113
|
+
## Contributing and licence
|
|
114
|
+
|
|
115
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md), the [changelog](CHANGELOG.md), and the
|
|
116
|
+
[release procedure](docs/releasing.md). The code and synthetic examples are under
|
|
117
|
+
the [MIT licence](LICENSE). UCDP and ACLED data have separate access, attribution,
|
|
118
|
+
and redistribution terms; obtain source access and check the terms for your snapshot.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
ccrt/__init__.py,sha256=uFFoUQpSU5orZCnTh37Wq1dGL7aXbp4QaXH_8ulHdk8,1045
|
|
2
|
+
ccrt/__main__.py,sha256=S0BYDZBiP__Qc2xpoQCvXphAZERBBVWnJuiDpP1Fu3s,52
|
|
3
|
+
ccrt/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
4
|
+
ccrt/cli.py,sha256=oe_Hvy98BB5X_Q8P1wrE4w5joEB9erupm_sIihIWLmQ,10654
|
|
5
|
+
ccrt/client.py,sha256=xGdfjFlehAq5Zkl6434uG6O2iBAqqTW0iTB3a8H7p-A,7467
|
|
6
|
+
ccrt/config.py,sha256=krGpm8JVF4Jris-S41e3DESnBFoAQwMaalZ256Z9Deo,821
|
|
7
|
+
ccrt/evaluation.py,sha256=GYkhkFQ64t0_ywqzo-FhXpSrbjSsyoGOXGMm1IvmyGs,1182
|
|
8
|
+
ccrt/export.py,sha256=AbY4MNtJkwWxCmMwtch3Pu0MdBLDIwG3SN8HYLoA7HY,5854
|
|
9
|
+
ccrt/interactive.py,sha256=TV9a4eGyKU_zeJRUI1DWIkFYXEqaW-2kHg_WrycMI5U,6893
|
|
10
|
+
ccrt/merge.py,sha256=hsqb9Z_FagH3Gz5sls295DILW-T7LlB0epbMF-hc29Q,6826
|
|
11
|
+
ccrt/report.py,sha256=fLjYf6gTfPjYjvWFNxCX-a7hSPguYl8hjPz4Dtoy7Dw,7246
|
|
12
|
+
ccrt/review.py,sha256=mWwdtBIm4D8HxHyRb8oDAQpVYBnDZ3tE5sQQS05S-O0,2208
|
|
13
|
+
ccrt/schema.py,sha256=xv7CPAQAhoyO4pTi5VFNHWKZB0K7FqurKugjbtqnAdY,6086
|
|
14
|
+
ccrt/storage.py,sha256=C7bjhUOD_1q0LoQPiuzwnekFydaXBsGCxsQglEbNnd8,5646
|
|
15
|
+
ccrt/tui.py,sha256=HEQXAmvkUUl0DnZvu_Nigmb92vHQtb287KfwMvjoEtc,13777
|
|
16
|
+
ccrt/loaders/__init__.py,sha256=w3QJVZn_aAah7TRRdTi50hMy5ccwgUBr7LSM5BZ8S-s,224
|
|
17
|
+
ccrt/loaders/acled.py,sha256=TR2j1E0rYLoDv6xdFHd6kM9wJb6s6VFrMjTGeMgTFYk,2231
|
|
18
|
+
ccrt/loaders/base.py,sha256=j8WAtTUMl9jpJGqQXzKCs4KaQvX_h80IFmVu9bL60gg,5406
|
|
19
|
+
ccrt/loaders/ucdp.py,sha256=c1WdSaOvF3pI03BkD76BJtpKpSOHiJZbSdp4cYQemE8,2619
|
|
20
|
+
ccrt-0.1.0.dist-info/licenses/LICENSE,sha256=9YcilvTlVUEkAU2AGRryIa5Cm8ifuarTcJNnJysqPgk,1072
|
|
21
|
+
ccrt-0.1.0.dist-info/METADATA,sha256=ogoB2_DPetdbQouA-twn20GGbqD6-Q6zO__plsak2lU,4691
|
|
22
|
+
ccrt-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
23
|
+
ccrt-0.1.0.dist-info/entry_points.txt,sha256=AuzMBnYrcAv0YiFeALcmq5KGG1bk8XuF27hDlS6IXYU,39
|
|
24
|
+
ccrt-0.1.0.dist-info/top_level.txt,sha256=Le69kL4hkte_9YHyXhkdW3ZpK75LnHDyvhKQ5GkQBTk,5
|
|
25
|
+
ccrt-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fabian Figueroa
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ccrt
|