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 ADDED
@@ -0,0 +1,37 @@
1
+ """Public CCRT API. Source accounts stay separate from proposed event links."""
2
+
3
+ from ccrt._version import __version__
4
+ from ccrt.client import UCDPClient, UCDPError
5
+ from ccrt.loaders import ValidationError, load_acled_csv, load_csv, load_ucdp_csv
6
+ from ccrt.merge import Candidate, MatchConfig, Reconciliation, reconcile
7
+ from ccrt.report import ReviewReport, summarize_review
8
+ from ccrt.review import Decision, Review
9
+ from ccrt.schema import Dataset, SourceRecord, ValidationIssue
10
+ from ccrt.storage import load_dataset, load_review, load_run, save_dataset, save_review, save_run
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "SourceRecord",
15
+ "Dataset",
16
+ "ValidationIssue",
17
+ "ValidationError",
18
+ "UCDPClient",
19
+ "UCDPError",
20
+ "load_ucdp_csv",
21
+ "load_acled_csv",
22
+ "load_csv",
23
+ "MatchConfig",
24
+ "Candidate",
25
+ "Reconciliation",
26
+ "reconcile",
27
+ "Decision",
28
+ "Review",
29
+ "ReviewReport",
30
+ "summarize_review",
31
+ "load_dataset",
32
+ "save_dataset",
33
+ "load_run",
34
+ "save_run",
35
+ "load_review",
36
+ "save_review",
37
+ ]
ccrt/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from ccrt.cli import main
2
+
3
+ raise SystemExit(main())
ccrt/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
ccrt/cli.py ADDED
@@ -0,0 +1,205 @@
1
+ """Small CLI using the same library APIs as notebooks."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from ccrt._version import __version__
8
+ from ccrt.client import UCDPClient, UCDPError
9
+ from ccrt.config import ucdp_api_key
10
+ from ccrt.export import read_review_csv, write_csv, write_parquet, write_records_csv
11
+ from ccrt.interactive import review_interactive
12
+ from ccrt.loaders import load_acled_csv, load_csv, load_ucdp_csv
13
+ from ccrt.merge import MatchConfig, reconcile
14
+ from ccrt.report import format_review_report, summarize_review
15
+ from ccrt.review import Review
16
+ from ccrt.schema import canonical_json
17
+ from ccrt.storage import (
18
+ atomic_write,
19
+ load_dataset,
20
+ load_review,
21
+ load_run,
22
+ save_dataset,
23
+ save_review,
24
+ save_run,
25
+ )
26
+
27
+
28
+ def main(argv=None):
29
+ parser = argparse.ArgumentParser(prog="ccrt", description="Reviewable conflict record reconciliation")
30
+ parser.add_argument("--version", action="version", version=__version__)
31
+ commands = parser.add_subparsers(dest="command", required=True)
32
+ imp = commands.add_parser("import", help="Normalize a source CSV into a CCRT JSON bundle")
33
+ imp.add_argument("source", choices=["ucdp", "acled", "generic"])
34
+ imp.add_argument("input")
35
+ imp.add_argument("output")
36
+ imp.add_argument("--version", required=True, help="GED release or export snapshot label")
37
+ imp.add_argument("--source-name", help="Source label for generic CSV")
38
+ imp.add_argument(
39
+ "--collect-errors", action="store_true", help="Retain invalid rows in the validation report"
40
+ )
41
+ fetch = commands.add_parser("fetch-ucdp", help="Fetch GED using UCDP_API_KEY or a local .env")
42
+ fetch.add_argument("output")
43
+ fetch.add_argument("--version", required=True)
44
+ fetch.add_argument("--country", type=int, action="append", help="Gleditsch-Ward country ID; repeatable")
45
+ fetch.add_argument("--start-date")
46
+ fetch.add_argument("--end-date")
47
+ fetch.add_argument("--max-pages", type=int, default=5000)
48
+ fetch.add_argument(
49
+ "--env-file", help="Read UCDP_API_KEY from this file when it is not set in the environment"
50
+ )
51
+ match = commands.add_parser("match", help="Create all candidate links and a review table")
52
+ match.add_argument("left")
53
+ match.add_argument("right")
54
+ match.add_argument("output_dir")
55
+ match.add_argument("--max-days", type=int, default=3)
56
+ match.add_argument("--max-km", type=float, default=25)
57
+ match.add_argument("--include-coarse-locations", action="store_true")
58
+ match.add_argument("--country-alias", action="append", default=[], metavar="FROM=TO")
59
+ review = commands.add_parser("review", help="Import edited review CSV or record one decision")
60
+ review.add_argument("run")
61
+ review.add_argument("output")
62
+ review.add_argument("--existing", help="Continue a saved review JSON")
63
+ method = review.add_mutually_exclusive_group(required=True)
64
+ method.add_argument("--csv", help="Edited review CSV")
65
+ method.add_argument("--candidate")
66
+ review.add_argument("--status", choices=["accepted", "rejected", "unresolved"])
67
+ review.add_argument("--reviewer", default="")
68
+ review.add_argument("--note", default="")
69
+ interactive = commands.add_parser(
70
+ "review-interactive", help="Review source accounts in the terminal and save each decision"
71
+ )
72
+ interactive.add_argument("run", help="Match run.json")
73
+ interactive.add_argument("output", help="Review JSON; created or resumed in place")
74
+ interactive.add_argument("--from-existing", help="Seed a new review JSON from an earlier review")
75
+ interactive.add_argument("--reviewer", required=True, help="Name recorded with decisions")
76
+ interactive.add_argument("--left-id", help="Inspect one left-side source record and its candidates")
77
+ interactive.add_argument("--all", action="store_true", help="Include already decided groups")
78
+ tui = commands.add_parser("review-tui", help="Experimental full-screen terminal review dashboard")
79
+ tui.add_argument("run", help="Match run.json")
80
+ tui.add_argument("output", help="Review JSON; created or resumed in place")
81
+ tui.add_argument("--from-existing", help="Seed a new review JSON from an earlier review")
82
+ tui.add_argument("--reviewer", required=True, help="Name recorded with decisions")
83
+ tui.add_argument("--left-id", help="Start at a left-side source record")
84
+ report = commands.add_parser("report", help="Summarize review coverage and accepted-link diagnostics")
85
+ report.add_argument("run", help="Match run.json")
86
+ report.add_argument("--review", help="Saved review JSON; omitted means all unresolved")
87
+ report.add_argument("--output", help="Also save a new JSON report")
88
+ export = commands.add_parser("export", help="Export normalized data or a reviewed run")
89
+ export.add_argument("input")
90
+ export.add_argument("output")
91
+ export.add_argument("--format", choices=["csv", "parquet", "review-csv"], required=True)
92
+ export.add_argument("--review", help="Saved review JSON for review-csv")
93
+ args = parser.parse_args(argv)
94
+ try:
95
+ if args.command in {"import", "fetch-ucdp", "review", "export"} and Path(args.output).exists():
96
+ raise FileExistsError(f"output exists: {args.output}")
97
+ if args.command == "import":
98
+ options = {"version": args.version, "errors": "collect" if args.collect_errors else "raise"}
99
+ if args.source == "generic":
100
+ if not args.source_name:
101
+ raise ValueError("generic import requires --source-name")
102
+ dataset = load_csv(args.input, args.source_name, **options)
103
+ else:
104
+ dataset = {"ucdp": load_ucdp_csv, "acled": load_acled_csv}[args.source](args.input, **options)
105
+ save_dataset(dataset, args.output)
106
+ print(f"Imported {len(dataset)} records; {len(dataset.issues)} validation issues")
107
+ elif args.command == "fetch-ucdp":
108
+ with UCDPClient(api_key=ucdp_api_key(args.env_file)) as client:
109
+ dataset = client.fetch(
110
+ version=args.version,
111
+ countries=args.country,
112
+ start_date=args.start_date,
113
+ end_date=args.end_date,
114
+ max_pages=args.max_pages,
115
+ )
116
+ save_dataset(dataset, args.output)
117
+ print(f"Fetched {len(dataset)} records; {len(dataset.issues)} validation issues")
118
+ elif args.command == "match":
119
+ aliases = {}
120
+ for value in args.country_alias:
121
+ if "=" not in value:
122
+ raise ValueError("country aliases use FROM=TO")
123
+ key, target = value.split("=", 1)
124
+ aliases[key] = target
125
+ result = reconcile(
126
+ load_dataset(args.left),
127
+ load_dataset(args.right),
128
+ config=MatchConfig(args.max_days, args.max_km, args.include_coarse_locations, aliases),
129
+ )
130
+ directory = Path(args.output_dir)
131
+ directory.mkdir(parents=True, exist_ok=False)
132
+ save_run(result, directory / "run.json")
133
+ save_review(Review.create(result), result, directory / "review.json")
134
+ write_csv(result, directory / "candidates.csv")
135
+ print(f"{len(result.candidates)} candidate links; {len(result.unlinked)} unlinked records")
136
+ elif args.command == "review":
137
+ result = load_run(args.run)
138
+ if args.csv:
139
+ if args.existing or args.status or args.reviewer or args.note:
140
+ raise ValueError("--csv cannot be combined with single-decision options")
141
+ review = read_review_csv(args.csv, result)
142
+ else:
143
+ if not args.status:
144
+ raise ValueError("--candidate requires --status")
145
+ review = load_review(args.existing, result) if args.existing else Review.create(result)
146
+ review = review.decide(args.candidate, args.status, reviewer=args.reviewer, note=args.note)
147
+ save_review(review, result, args.output)
148
+ print(f"Saved {len(review.decisions)} review decisions")
149
+ elif args.command in {"review-interactive", "review-tui"}:
150
+ if args.command == "review-tui" and (not sys.stdin.isatty() or not sys.stdout.isatty()):
151
+ raise ValueError("review-tui needs an interactive terminal")
152
+ result = load_run(args.run)
153
+ output = Path(args.output)
154
+ if output.exists():
155
+ if args.from_existing:
156
+ raise ValueError("output already exists; omit --from-existing to resume it")
157
+ current = load_review(output, result)
158
+ else:
159
+ current = (
160
+ load_review(args.from_existing, result) if args.from_existing else Review.create(result)
161
+ )
162
+ save_review(current, result, output)
163
+
164
+ def save(updated):
165
+ save_review(updated, result, output, overwrite=True)
166
+
167
+ if args.command == "review-tui":
168
+ from ccrt.tui import run_tui
169
+
170
+ run_tui(result, current, reviewer=args.reviewer, left_id=args.left_id, save=save)
171
+ else:
172
+ review_interactive(
173
+ result,
174
+ current,
175
+ reviewer=args.reviewer,
176
+ left_id=args.left_id,
177
+ show_all=args.all,
178
+ save=save,
179
+ )
180
+ elif args.command == "report":
181
+ result = load_run(args.run)
182
+ review = load_review(args.review, result) if args.review else None
183
+ summary = summarize_review(result, review)
184
+ if args.output:
185
+ atomic_write(args.output, canonical_json(summary.to_dict()) + "\n")
186
+ print(format_review_report(summary))
187
+ else:
188
+ if args.format == "review-csv":
189
+ result = load_run(args.input)
190
+ review = load_review(args.review, result) if args.review else None
191
+ write_csv(result, args.output, review=review)
192
+ else:
193
+ if args.review:
194
+ raise ValueError("--review only applies to --format review-csv")
195
+ dataset = load_dataset(args.input)
196
+ writer = write_parquet if args.format == "parquet" else write_records_csv
197
+ writer(dataset, args.output)
198
+ print(f"Wrote {args.output}")
199
+ except (OSError, ValueError, UCDPError, ImportError) as exc:
200
+ parser.exit(2, f"ccrt: {exc}\n")
201
+ return 0
202
+
203
+
204
+ if __name__ == "__main__":
205
+ raise SystemExit(main())
ccrt/client.py ADDED
@@ -0,0 +1,166 @@
1
+ """Bounded, token-authenticated UCDP GED client. No network access on import."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from email.utils import parsedate_to_datetime
10
+
11
+ import httpx
12
+
13
+ from ccrt.loaders.base import parse_date
14
+ from ccrt.loaders.ucdp import check_version, from_api_records
15
+
16
+
17
+ class UCDPError(RuntimeError):
18
+ """A sanitized error: response bodies, headers and tokens are never included."""
19
+
20
+
21
+ class UCDPClient:
22
+ def __init__(self, api_key=None, *, timeout=30.0, retries=3, transport=None):
23
+ self._api_key = api_key if api_key is not None else os.environ.get("UCDP_API_KEY", "")
24
+ if not self._api_key or not self._api_key.strip():
25
+ raise UCDPError("Set UCDP_API_KEY or pass api_key to UCDPClient")
26
+ if not math.isfinite(timeout) or timeout <= 0 or type(retries) is not int or not 0 <= retries <= 5:
27
+ raise ValueError("timeout must be finite and positive; retries must be an integer from 0 to 5")
28
+ self._retries = retries
29
+ self._client = httpx.Client(timeout=timeout, transport=transport, follow_redirects=False)
30
+
31
+ def close(self):
32
+ self._client.close()
33
+
34
+ def __enter__(self):
35
+ return self
36
+
37
+ def __exit__(self, *args):
38
+ self.close()
39
+
40
+ def _page(self, version, params):
41
+ for attempt in range(self._retries + 1):
42
+ delay = min(2**attempt, 30)
43
+ try:
44
+ response = self._client.get(
45
+ f"https://ucdpapi.pcr.uu.se/api/gedevents/{version}",
46
+ params=params,
47
+ headers={"x-ucdp-access-token": self._api_key},
48
+ )
49
+ except httpx.TransportError:
50
+ if attempt == self._retries:
51
+ raise UCDPError("UCDP connection failed after bounded retries") from None
52
+ else:
53
+ status = response.status_code
54
+ if status in {401, 403}:
55
+ raise UCDPError("UCDP rejected the access token (authentication/authorization)")
56
+ if status == 200:
57
+ try:
58
+ payload = response.json()
59
+ except ValueError:
60
+ raise UCDPError("UCDP returned invalid JSON") from None
61
+ if not isinstance(payload, dict):
62
+ raise UCDPError("UCDP returned an invalid response object")
63
+ return payload
64
+ if status not in {429, 500, 502, 503, 504}:
65
+ raise UCDPError(f"UCDP request failed (HTTP {status})")
66
+ if attempt == self._retries:
67
+ raise UCDPError(f"UCDP request exhausted retries (HTTP {status})")
68
+ retry_after = response.headers.get("Retry-After")
69
+ if retry_after:
70
+ try:
71
+ delay = max(0, float(retry_after))
72
+ except ValueError:
73
+ try:
74
+ when = parsedate_to_datetime(retry_after)
75
+ delay = max(0, (when - datetime.now(timezone.utc)).total_seconds())
76
+ except (TypeError, ValueError, OverflowError):
77
+ delay = min(2**attempt, 30)
78
+ if not math.isfinite(delay) or delay > 30:
79
+ raise UCDPError("UCDP requested a longer retry delay; try again later")
80
+ time.sleep(delay)
81
+ raise AssertionError("unreachable")
82
+
83
+ def fetch(
84
+ self,
85
+ *,
86
+ version,
87
+ countries=None,
88
+ start_date=None,
89
+ end_date=None,
90
+ types_of_violence=None,
91
+ page_size=1000,
92
+ max_pages=5000,
93
+ ):
94
+ """Fetch a complete bounded query. Date filters apply to UCDP date_end.
95
+
96
+ Country values are UCDP/Gleditsch-Ward numeric IDs, not ISO codes.
97
+ A partial response is never returned as a complete Dataset.
98
+ """
99
+ check_version(version)
100
+ if type(page_size) is not int or not 1 <= page_size <= 1000:
101
+ raise ValueError("page_size must be an integer from 1 to 1000")
102
+ if type(max_pages) is not int or not 1 <= max_pages <= 5000:
103
+ raise ValueError("max_pages must be an integer from 1 to 5000")
104
+ params = {"pagesize": page_size}
105
+ for name, values in (("Country", countries), ("TypeOfViolence", types_of_violence)):
106
+ if values is not None:
107
+ values = list(values)
108
+ if not values or any(type(v) is not int or v < 1 for v in values):
109
+ raise ValueError(f"{name} must contain positive integer IDs")
110
+ if name == "TypeOfViolence" and any(v not in {1, 2, 3} for v in values):
111
+ raise ValueError("TypeOfViolence must contain 1, 2, or 3")
112
+ params[name] = ",".join(str(v) for v in sorted(set(values)))
113
+ dates = []
114
+ for name, value in (("StartDate", start_date), ("EndDate", end_date)):
115
+ parsed = parse_date(value) if value is not None else None
116
+ dates.append(parsed)
117
+ if parsed:
118
+ params[name] = parsed.isoformat()
119
+ if all(dates) and dates[0] > dates[1]:
120
+ raise ValueError("start_date exceeds end_date")
121
+ rows, expected, seen_ids = [], None, set()
122
+ # The live GED endpoint indexes pages from zero. Starting at one silently
123
+ # skips the first page, even though its documentation examples use page=1.
124
+ page = 0
125
+ while True:
126
+ payload = self._page(version, {**params, "page": page})
127
+ total, pages, results = (
128
+ payload.get("TotalCount"),
129
+ payload.get("TotalPages"),
130
+ payload.get("Result"),
131
+ )
132
+ if (
133
+ type(total) is not int
134
+ or total < 0
135
+ or type(pages) is not int
136
+ or pages < 0
137
+ or not isinstance(results, list)
138
+ or any(not isinstance(r, dict) for r in results)
139
+ ):
140
+ raise UCDPError("UCDP response has invalid pagination or records")
141
+ if (total > 0 and pages == 0) or (total == 0 and pages > 1):
142
+ raise UCDPError("UCDP response has inconsistent pagination")
143
+ if expected is None:
144
+ expected = (total, pages)
145
+ if pages > max_pages:
146
+ raise UCDPError("Query exceeds max_pages; narrow the query or increase the bound")
147
+ elif expected != (total, pages):
148
+ raise UCDPError("UCDP result count changed during pagination; retry the query")
149
+ if page < pages and not results:
150
+ raise UCDPError("UCDP returned an empty intermediate page")
151
+ for row in results:
152
+ identifier = str(row.get("id", ""))
153
+ if identifier in seen_ids:
154
+ raise UCDPError("UCDP repeated a record during pagination; retry the query")
155
+ seen_ids.add(identifier)
156
+ rows.extend(results)
157
+ if page + 1 >= pages:
158
+ break
159
+ page += 1
160
+ if len(rows) != expected[0]:
161
+ raise UCDPError("UCDP result count does not match retrieved records")
162
+ return from_api_records(
163
+ rows,
164
+ version=version,
165
+ metadata={"query": params, "retrieved_at": datetime.now(timezone.utc).isoformat()},
166
+ )
ccrt/config.py ADDED
@@ -0,0 +1,23 @@
1
+ """Credential lookup for the CLI's UCDP fetch command."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from dotenv import dotenv_values
7
+
8
+
9
+ def ucdp_api_key(env_file: str | Path | None = None) -> str | None:
10
+ """Use the process environment first, then a local .env file.
11
+
12
+ Reads only UCDP_API_KEY; it does not import the rest of the file into the
13
+ process environment or expand variables inside the token.
14
+ """
15
+ key = os.environ.get("UCDP_API_KEY", "").strip()
16
+ if key:
17
+ return key
18
+ path = Path(env_file) if env_file is not None else Path.cwd() / ".env"
19
+ if not path.is_file():
20
+ if env_file is not None:
21
+ raise FileNotFoundError(f"environment file not found: {path}")
22
+ return None
23
+ return (dotenv_values(path, interpolate=False).get("UCDP_API_KEY") or "").strip() or None
ccrt/evaluation.py ADDED
@@ -0,0 +1,30 @@
1
+ """Candidate retrieval metrics for an EXHAUSTIVELY labeled, bounded pair universe."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Evaluation:
8
+ true_positives: int
9
+ false_positives: int
10
+ false_negatives: int
11
+ precision: float | None
12
+ recall: float | None
13
+
14
+
15
+ def evaluate_candidates(result, true_pairs):
16
+ """true_pairs contains all true (left source ID, right source ID) links in these inputs.
17
+
18
+ Missing positive labels are treated as negatives, so partially reviewed samples
19
+ must not be passed to this function. This evaluates retrieval, not casualty truth.
20
+ """
21
+ truth = set(true_pairs)
22
+ left_ids = {r.source_id for r in result.left}
23
+ right_ids = {r.source_id for r in result.right}
24
+ if any(a not in left_ids or b not in right_ids for a, b in truth):
25
+ raise ValueError("truth contains source IDs outside the evaluation datasets")
26
+ proposed = {(c.left.source_id, c.right.source_id) for c in result.candidates}
27
+ tp = len(proposed & truth)
28
+ fp = len(proposed - truth)
29
+ fn = len(truth - proposed)
30
+ return Evaluation(tp, fp, fn, tp / (tp + fp) if tp + fp else None, tp / (tp + fn) if tp + fn else None)
ccrt/export.py ADDED
@@ -0,0 +1,152 @@
1
+ """Research tables with original claims and explicit review state."""
2
+
3
+ import csv
4
+ import io
5
+ import json
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+
9
+ from ccrt.review import Review
10
+ from ccrt.schema import SourceRecord, canonical_json
11
+ from ccrt.storage import atomic_write, dataset_dict, dataset_from_dict
12
+
13
+
14
+ def write_records_csv(dataset, path, *, overwrite=False):
15
+ """Flat normalized table; JSON bundle is the lossless interchange format."""
16
+ stream = io.StringIO(newline="")
17
+ writer = csv.DictWriter(stream, fieldnames=list(SourceRecord.__dataclass_fields__))
18
+ writer.writeheader()
19
+ writer.writerows(r.to_dict() for r in dataset)
20
+ atomic_write(path, stream.getvalue(), overwrite=overwrite)
21
+
22
+
23
+ def write_parquet(dataset, path, *, overwrite=False):
24
+ try:
25
+ import pyarrow as pa
26
+ import pyarrow.parquet as pq
27
+ except ImportError:
28
+ raise ImportError('Install Parquet support with: pip install "ccrt[tables]"') from None
29
+ path = Path(path)
30
+ if path.exists() and not overwrite:
31
+ raise FileExistsError(f"output exists: {path}")
32
+ integers = {
33
+ "date_precision",
34
+ "geo_precision",
35
+ "total_deaths_low",
36
+ "total_deaths_best",
37
+ "total_deaths_high",
38
+ "civilian_deaths",
39
+ }
40
+ fields = [
41
+ (
42
+ name,
43
+ pa.int64()
44
+ if name in integers
45
+ else pa.float64()
46
+ if name in {"latitude", "longitude"}
47
+ else pa.string(),
48
+ )
49
+ for name in SourceRecord.__dataclass_fields__
50
+ ]
51
+ metadata = dataset_dict(dataset)
52
+ metadata.pop("records")
53
+ schema = pa.schema(fields, metadata={b"ccrt": canonical_json(metadata).encode("utf-8")})
54
+ table = pa.Table.from_pylist([r.to_dict() for r in dataset], schema=schema)
55
+ # Serialize before touching the target; null-only and empty columns keep fixed types.
56
+ sink = pa.BufferOutputStream()
57
+ pq.write_table(table, sink)
58
+ from ccrt.storage import atomic_write_bytes
59
+
60
+ atomic_write_bytes(path, sink.getvalue().to_pybytes(), overwrite=overwrite)
61
+
62
+
63
+ def read_parquet(path):
64
+ try:
65
+ import pyarrow.parquet as pq
66
+ except ImportError:
67
+ raise ImportError('Install Parquet support with: pip install "ccrt[tables]"') from None
68
+ table = pq.read_table(path)
69
+ if b"ccrt" not in (table.schema.metadata or {}):
70
+ raise ValueError("Parquet file has no CCRT provenance; import it through a source adapter")
71
+ payload = json.loads(table.schema.metadata[b"ccrt"])
72
+ payload["records"] = table.to_pylist()
73
+ return dataset_from_dict(payload)
74
+
75
+
76
+ def write_csv(result, path, *, review=None, overwrite=False):
77
+ """Write candidates and unlinked accounts. Several rows may refer to one record."""
78
+ review = review or Review.create(result)
79
+ review.validate(result)
80
+ decisions = {d.candidate_id: d for d in review.decisions}
81
+ evidence = [
82
+ "candidate_id",
83
+ "status",
84
+ "reviewer",
85
+ "note",
86
+ "reviewed_at",
87
+ "run_id",
88
+ "days_apart",
89
+ "distance_km",
90
+ "match_score",
91
+ "warnings",
92
+ "unlinked_reason",
93
+ ]
94
+ columns = list(SourceRecord.__dataclass_fields__)
95
+ fields = evidence + [f"{side}_{field}" for side in ("left", "right") for field in columns]
96
+ stream = io.StringIO(newline="")
97
+ writer = csv.DictWriter(stream, fieldnames=fields)
98
+ writer.writeheader()
99
+ run_id = result.run_id
100
+ for candidate in result.candidates:
101
+ writer.writerow(
102
+ {
103
+ **asdict(decisions[candidate.candidate_id]),
104
+ "run_id": run_id,
105
+ "days_apart": candidate.days_apart,
106
+ "distance_km": round(candidate.distance_km, 3),
107
+ "match_score": round(candidate.score, 6),
108
+ "warnings": ";".join(candidate.warnings),
109
+ **{f"left_{k}": v for k, v in candidate.left.to_dict().items()},
110
+ **{f"right_{k}": v for k, v in candidate.right.to_dict().items()},
111
+ }
112
+ )
113
+ for item in result.unlinked:
114
+ side = "left" if item.record.source == result.left.source else "right"
115
+ writer.writerow(
116
+ {
117
+ "status": "unlinked",
118
+ "run_id": run_id,
119
+ "unlinked_reason": item.reason,
120
+ **{f"{side}_{k}": v for k, v in item.record.to_dict().items()},
121
+ }
122
+ )
123
+ atomic_write(path, stream.getvalue(), overwrite=overwrite)
124
+
125
+
126
+ def read_review_csv(path, result):
127
+ """Read only review fields from a previously exported candidate CSV."""
128
+ from datetime import datetime, timezone
129
+
130
+ from ccrt.review import Decision
131
+
132
+ decisions = []
133
+ with open(path, newline="", encoding="utf-8-sig") as stream:
134
+ reader = csv.DictReader(stream)
135
+ required = {"candidate_id", "status", "reviewer", "note", "reviewed_at", "run_id"}
136
+ if not required <= set(reader.fieldnames or ()):
137
+ raise ValueError("review CSV is missing review columns")
138
+ for row in reader:
139
+ if row["run_id"] != result.run_id:
140
+ raise ValueError(f"review CSV line {reader.line_num}: belongs to a different run")
141
+ if row["status"] == "unlinked" and not row["candidate_id"]:
142
+ continue
143
+ if row["status"] != "unresolved" and not row["reviewed_at"]:
144
+ row["reviewed_at"] = datetime.now(timezone.utc).isoformat()
145
+ try:
146
+ decisions.append(Decision(**{k: row[k] for k in required if k != "run_id"}))
147
+ except ValueError as exc:
148
+ pair = f"{row.get('left_source_id', '?')}/{row.get('right_source_id', '?')}"
149
+ raise ValueError(f"review CSV line {reader.line_num} ({pair}): {exc}") from exc
150
+ review = Review(result.run_id, tuple(decisions))
151
+ review.validate(result)
152
+ return review