diff --git a/backend/services/citation_utils.py b/backend/services/citation_utils.py index 9f36b53a..691fea00 100644 --- a/backend/services/citation_utils.py +++ b/backend/services/citation_utils.py @@ -7,6 +7,9 @@ from __future__ import annotations +from dataclasses import dataclass, field +from typing import Any, Callable + PREVIEW_MAX_CHARS = 300 @@ -31,9 +34,7 @@ def build_sources(docs: list[str], metas: list[dict]) -> list[dict]: key = (meta.get("source", "unknown"), meta.get("chunk", 0)) if key not in seen: preview = ( - doc[:PREVIEW_MAX_CHARS] + "..." - if len(doc) > PREVIEW_MAX_CHARS - else doc + doc[:PREVIEW_MAX_CHARS] + "..." if len(doc) > PREVIEW_MAX_CHARS else doc ) seen[key] = { "source": meta.get("source", "unknown"), @@ -41,3 +42,262 @@ def build_sources(docs: list[str], metas: list[dict]) -> list[dict]: "preview": preview, } return list(seen.values()) + + +# --------------------------------------------------------------------------- +# Citation merging (issue #934) +# --------------------------------------------------------------------------- + + +# Default score-merge strategy: arithmetic mean. Exposed so callers can swap +# in another commutative Associative[Score, Score] -> Score with no internal +# mutation of the inputs. +def default_score_merger(scores: list[float]) -> float: + """Return the arithmetic mean of a non-empty list of scores. + + A None score contributes 0.0 to both sum and count. This mirrors the + behaviour of `rank_sources` (issue #933), where missing relevance + collapses to a neutral default rather than penalising the merge. + """ + if not scores: + return 0.0 + total = 0.0 + count = 0 + for s in scores: + if s is None: + continue + try: + total += float(s) + count += 1 + except (TypeError, ValueError): + continue + return total / count if count else 0.0 + + +def default_preview_merger(previews: list[str]) -> str: + """Pick the longest preview, breaking ties by earliest-seen order. + + Rationale: when the same logical source is cited by two retrieval + batches, the longer preview rarely loses information. + """ + best = "" + for p in previews: + if p is None: + continue + try: + text = str(p) + except Exception: + continue + if len(text) > len(best): + best = text + return best + + +def _normalise_source_entry(entry: Any) -> dict: + """Coerce arbitrary input into a citation-utils shaped dict. + + Accepts: + - dict (preferred shape) — shallow-copied so call-site mutates are + isolated. + - str (legacy shape) — promoted to {"source": str, "chunk": 0}. + Anything else falls back to {"source": "unknown", "chunk": 0}. + """ + if isinstance(entry, dict): + normalised = dict(entry) + # Coerce "chunk" to int — matches `_merge_key` semantics so that + # downstream callers never see a non-int chunk on a normalized + # citation entry. + raw_chunk = normalised.get("chunk", 0) + try: + normalised["chunk"] = int(raw_chunk) + except (TypeError, ValueError): + normalised["chunk"] = 0 + return normalised + if isinstance(entry, str): + return {"source": entry, "chunk": 0} + return {"source": "unknown", "chunk": 0} + + +def _merge_key(source: dict) -> tuple: + """Return the dedupe key for a source dict. + + Prefer (source, chunk) — the canonical primary key. Fall back to + ("unknown", 0) so malformed entries still merge together instead + of producing duplicates. + """ + name = str(source.get("source", "unknown")) + try: + chunk = int(source.get("chunk", 0)) + except (TypeError, ValueError): + chunk = 0 + return (name, chunk) + + +@dataclass +class MergeResult: + """Result of `merge_citations` — the merged list + provenance audit. + + `merged` is the canonical citation list. `sources` / `dedupe_keys` + preserve which input lists contributed which citation so a + reviewer can audit conflicts. + """ + + merged: list[dict] = field(default_factory=list) + duplicates: list[dict] = field(default_factory=list) + contributed_by: dict[tuple, list[int]] = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "merged": self.merged, + "duplicates": self.duplicates, + "contributed_by": { + f"{k[0]}#{k[1]}": v for k, v in self.contributed_by.items() + }, + } + + +def merge_citations( + *citation_lists: list[dict] | list[str] | list, + score_merger: Callable[[list[float]], float] = default_score_merger, + preview_merger: Callable[[list[str]], str] = default_preview_merger, + scores: list[list[float] | None] | None = None, +) -> MergeResult: + """Merge multiple citation lists, deduplicating by (source, chunk). + + Inputs may be: + - lists of citation dicts (canonical shape from `build_sources`) + - lists of strings (legacy compatibility path) + - mixed (each list independently can contain either shape) + + For each unique (source, chunk) the merged entry has: + - `source`, `chunk`: from the first contributing entry (deterministic) + - `preview`: `preview_merger` over all contributed previews + - `occurrence_count`: total number of times the citation was seen + - `score`: `score_merger` over all non-None contributed scores + - `contributors`: indices of input lists that contained this citation + + Conflict policy: the FIRST-seen entry wins structural fields ("source", + "chunk"). "preview" is chosen by the configurable preview merger + (longest-wins by default). "score" is averaged. Other ad-hoc keys on + the source dicts are preserved from the first contributor only. + + Args: + citation_lists: variadic; each positional argument is one citation + list. Passing zero lists yields an empty result. + score_merger: callable for combining per-list scores into one. + preview_merger: callable for combining per-list previews into one. + scores: optional parallel list of per-list score vectors. + None entries skip score contribution for that list. + Length must equal len(citation_lists) — if shorter, + missing lists are treated as None. + + Returns: + MergeResult dataclass with `.merged` (list of dicts, order: first- + seen across the input lists), `.duplicates` (audit: entries that + were dropped), and `.contributed_by` (mapping dedupe-key → list of + input-list indices). + """ + # Defensive copy of `scores` to avoid caller mutation surprises; pad + # with None to match the number of input lists. + if scores is None: + scores_list: list[list[float] | None] = [None] * len(citation_lists) + else: + scores_list = list(scores) + if len(scores_list) < len(citation_lists): + scores_list.extend([None] * (len(citation_lists) - len(scores_list))) + + merged: dict[tuple, dict] = {} + contributed_by: dict[tuple, list[int]] = {} + duplicates: list[dict] = [] + + for list_idx, raw_list in enumerate(citation_lists): + if not isinstance(raw_list, list): + continue + per_list_scores = scores_list[list_idx] + for entry_idx, raw_entry in enumerate(raw_list): + source = _normalise_source_entry(raw_entry) + key = _merge_key(source) + if key in merged: + # Duplicate path: aggregate counters + previews + scores. + existing = merged[key] + existing["occurrence_count"] = ( + int(existing.get("occurrence_count", 1)) + 1 + ) + existing.setdefault("contributors", []).append(list_idx) + contributed_by.setdefault(key, [list_idx]).append(list_idx) + + # Merge preview via callable. + p_collection = [ + existing.get("preview", ""), + source.get("preview", ""), + ] + if p_collection[1]: + existing["preview"] = preview_merger(p_collection) + else: + if not existing.get("preview"): + existing["preview"] = "" + + # Merge score via callable. + if per_list_scores is not None and entry_idx < len(per_list_scores): + incoming = per_list_scores[entry_idx] + prev_score = existing.get("score") + if prev_score is None: + existing["score"] = float(incoming) + else: + existing["score"] = score_merger( + [float(prev_score), float(incoming)] + ) + duplicates.append( + { + "list_idx": list_idx, + "entry_idx": entry_idx, + "key": key, + "merged_into": key, + } + ) + continue + + # New-path entry: clone fields defensively. + merged_entry = dict(source) + merged_entry["occurrence_count"] = 1 + merged_entry["contributors"] = [list_idx] + # Score assignment if available. + if per_list_scores is not None and entry_idx < len(per_list_scores): + try: + merged_entry["score"] = float(per_list_scores[entry_idx]) + except (TypeError, ValueError): + merged_entry["score"] = 0.0 + else: + # Inherit score already on the dict if present, else None. + if "score" in merged_entry: + try: + merged_entry["score"] = float(merged_entry["score"]) + except (TypeError, ValueError): + merged_entry["score"] = None + # If no score anywhere, no score key added (cleaner output + # for the legacy-string path that has no notion of score). + + # Ensure preview key always exists (downstream code checks + # `preview in s`). + merged_entry.setdefault("preview", source.get("preview", "")) + merged[key] = merged_entry + contributed_by[key] = [list_idx] + + return MergeResult( + merged=list(merged.values()), + duplicates=duplicates, + contributed_by=contributed_by, + ) + + +def normalize_citations(citations: list[dict] | list[str]) -> list[dict]: + """Coerce a heterogenous citation list into canonical dict shape. + + Convenience wrapper around `_normalise_source_entry` — leaves dicts + unchanged (shallow copy), promotes bare strings to + `{"source": str, "chunk": 0}`, never raises. Useful before unit + tests or before piping into `merge_citations`. + """ + if not isinstance(citations, list): + return [] + return [_normalise_source_entry(c) for c in citations] diff --git a/backend/tests/test_citation_merging.py b/backend/tests/test_citation_merging.py new file mode 100644 index 00000000..e6b46350 --- /dev/null +++ b/backend/tests/test_citation_merging.py @@ -0,0 +1,369 @@ +""" +Tests for citation merging (issue #934). + +Covers: +- merge_citations deduplicates by (source, chunk) across multiple lists. +- MergeResult exposes `merged`, `duplicates`, `contributed_by`. +- Score aggregation via default_score_merger (arithmetic mean). +- Preview selection via default_preview_merger (longest wins). +- Legacy string sources are upgrade-merged with dict sources. +- Conflict policy: first-seen entry wins structural fields; preview + / score are aggregated deterministically. +- Edge cases: empty inputs, single list, scores short by one, None + per-list scores, custom merger callables. +""" + +from __future__ import annotations + +from services.citation_utils import ( + MergeResult, + default_preview_merger, + default_score_merger, + merge_citations, + normalize_citations, +) + + +# ─── default_score_merger ─────────────────────────────────────── + + +class TestDefaultScoreMerger: + def test_empty_returns_zero(self): + assert default_score_merger([]) == 0.0 + + def test_single_value_pass_through(self): + assert default_score_merger([0.5]) == 0.5 + + def test_mean_of_two(self): + assert default_score_merger([0.2, 0.8]) == 0.5 + + def test_mean_of_three(self): + assert abs(default_score_merger([0.2, 0.4, 0.6]) - 0.4) < 1e-9 + + def test_none_scores_skipped(self): + # No contributing entries -> 0.0 (no division by zero). + assert default_score_merger([None, None]) == 0.0 + + def test_partial_none(self): + assert default_score_merger([0.4, None, 0.6]) == 0.5 + + def test_non_numeric_string_skipped(self): + assert default_score_merger(["garbage", 0.5]) == 0.5 + + +# ─── default_preview_merger ───────────────────────────────────── + + +class TestDefaultPreviewMerger: + def test_empty_returns_empty(self): + assert default_preview_merger([]) == "" + + def test_single_passes_through(self): + assert default_preview_merger(["Hello"]) == "Hello" + + def test_longest_wins(self): + assert default_preview_merger(["short", "longer preview"]) == "longer preview" + + def test_tie_keeps_earliest_seen(self): + # Both "foo" and "foo" have the same length — keep the first + # since the function only replaces when strictly greater. + assert default_preview_merger(["foo", "foo"]) == "foo" + + def test_none_entries_skipped(self): + assert default_preview_merger([None, "kept"]) == "kept" + + def test_all_none_returns_empty(self): + assert default_preview_merger([None, None]) == "" + + +# ─── merge_citations basics ───────────────────────────────────── + + +class TestMergeCitationsBasic: + def test_no_inputs_returns_empty(self): + result = merge_citations() + assert isinstance(result, MergeResult) + assert result.merged == [] + assert result.duplicates == [] + assert result.contributed_by == {} + + def test_single_list_passed_through(self): + sources = [{"source": "a.md", "chunk": 0, "preview": "alpha"}] + result = merge_citations(sources) + assert len(result.merged) == 1 + assert result.merged[0]["source"] == "a.md" + assert result.merged[0]["occurrence_count"] == 1 + assert result.contributed_by[("a.md", 0)] == [0] + + def test_two_disjoint_lists(self): + a = [{"source": "a.md", "chunk": 0}] + b = [{"source": "b.md", "chunk": 0}] + result = merge_citations(a, b) + sources = {m["source"] for m in result.merged} + assert sources == {"a.md", "b.md"} + assert len(result.duplicates) == 0 + + def test_overlapping_lists_dedupe(self): + a = [{"source": "a.md", "chunk": 0, "preview": "from a"}] + b = [{"source": "a.md", "chunk": 0, "preview": "from b longer"}] + result = merge_citations(a, b) + assert len(result.merged) == 1 + merged = result.merged[0] + assert merged["occurrence_count"] == 2 + # Preview takes the longest. + assert merged["preview"] == "from b longer" + # Both lists contributed. + assert sorted(result.contributed_by[("a.md", 0)]) == [0, 1] + # One duplicate recorded. + assert len(result.duplicates) == 1 + assert result.duplicates[0]["list_idx"] == 1 + + def test_different_chunks_same_file_kept(self): + a = [{"source": "doc.md", "chunk": 0}] + b = [{"source": "doc.md", "chunk": 1}] + result = merge_citations(a, b) + assert len(result.merged) == 2 + chunks = {m["chunk"] for m in result.merged} + assert chunks == {0, 1} + + def test_preserves_ad_hoc_fields(self): + a = [{"source": "a.md", "chunk": 0, "extra_field": "kept"}] + result = merge_citations(a) + assert result.merged[0].get("extra_field") == "kept" + + +# ─── merge order is deterministic ──────────────────────────────── + + +class TestMergeOrder: + def test_first_seen_order(self): + a = [{"source": "b.md", "chunk": 0}, {"source": "a.md", "chunk": 0}] + b = [{"source": "b.md", "chunk": 0}, {"source": "a.md", "chunk": 0}] + result = merge_citations(a, b) + sources_in_order = [m["source"] for m in result.merged] + assert sources_in_order == ["b.md", "a.md"] + + def test_duplicates_dont_promote_later_entry(self): + # Later list has a longer preview — does NOT move the entry to + # the back of the merged output. + a = [{"source": "x.md", "chunk": 0, "preview": "short"}] + b = [{"source": "y.md", "chunk": 0, "preview": "other"}] + c = [{"source": "x.md", "chunk": 0, "preview": "longer preview"}] + result = merge_citations(a, b, c) + assert result.merged[0]["source"] == "x.md" + assert result.merged[1]["source"] == "y.md" + assert result.merged[0]["preview"] == "longer preview" + + +# ─── Legacy string compatibility ──────────────────────────────── + + +class TestLegacyStringCompatibility: + def test_string_promoted_to_dict(self): + result = merge_citations(["legacy.md"]) + assert result.merged[0]["source"] == "legacy.md" + assert result.merged[0]["chunk"] == 0 + + def test_mixed_string_and_dict_lists(self): + result = merge_citations( + ["string.md"], + [{"source": "dict.md", "chunk": 2}], + ) + sources = {m["source"] for m in result.merged} + assert sources == {"string.md", "dict.md"} + + def test_duplicate_string_across_lists(self): + result = merge_citations(["legacy.md"], ["legacy.md"]) + assert len(result.merged) == 1 + assert result.merged[0]["occurrence_count"] == 2 + + +# ─── Score aggregation ────────────────────────────────────────── + + +class TestScoreAggregation: + def test_score_set_from_scores_param(self): + a = [{"source": "a.md", "chunk": 0}] + result = merge_citations(a, scores=[[0.8]]) + assert result.merged[0]["score"] == 0.8 + + def test_scores_aggregated_on_duplicate(self): + a = [{"source": "a.md", "chunk": 0}] + b = [{"source": "a.md", "chunk": 0}] + result = merge_citations(a, b, scores=[[0.6], [0.8]]) + assert result.merged[0]["score"] == 0.7 # mean(0.6, 0.8) + + def test_scores_implied_none_for_short_scores(self): + # scores shorter than citation_lists: padded with None. + a = [{"source": "a.md", "chunk": 0}] + b = [{"source": "a.md", "chunk": 0}] + result = merge_citations(a, b, scores=[[0.6]]) + # Second list contributes no score — existing score retained. + assert result.merged[0]["score"] == 0.6 + + def test_explicit_none_in_scores(self): + a = [{"source": "a.md", "chunk": 0, "score": 0.5}] + result = merge_citations(a, scores=[None]) + # None for the score stream → existing score on the dict kept. + assert result.merged[0]["score"] == 0.5 + + def test_missing_score_kept_none_when_absent(self): + # No "score" key on dict AND no scores param — score key absent. + a = [{"source": "a.md", "chunk": 0}] + result = merge_citations(a) + assert "score" not in result.merged[0] + + +# ─── Custom merger callables ───────────────────────────────────── + + +class TestCustomMergers: + def test_custom_score_merger_max(self): + a = [{"source": "a.md", "chunk": 0}] + b = [{"source": "a.md", "chunk": 0}] + result = merge_citations( + a, + b, + scores=[[0.2], [0.9]], + score_merger=max, + ) + assert result.merged[0]["score"] == 0.9 + + def test_custom_preview_merger_first_seen(self): + a = [{"source": "a.md", "chunk": 0, "preview": "first"}] + b = [{"source": "a.md", "chunk": 0, "preview": "second longer"}] + + def first_seen(previews): + for p in previews: + if p: + return p + return "" + + result = merge_citations(a, b, preview_merger=first_seen) + assert result.merged[0]["preview"] == "first" + + +# ─── Edge cases ───────────────────────────────────────────────── + + +class TestEdgeCases: + def test_empty_list_in_args(self): + # An empty positional list counts as a contributor but yields nothing. + a = [{"source": "a.md", "chunk": 0}] + result = merge_citations([], a, []) + assert len(result.merged) == 1 + assert result.contributed_by[("a.md", 0)] == [1] + + def test_non_list_arg_skipped(self): + result = merge_citations(None, [{"source": "a.md", "chunk": 0}]) # type: ignore[arg-type] + assert len(result.merged) == 1 + + def test_malformed_entry_normalised_to_unknown(self): + result = merge_citations([42, True]) # type: ignore[list-item] + assert len(result.merged) == 1 + assert result.merged[0]["source"] == "unknown" + assert result.merged[0]["chunk"] == 0 + + def test_non_numeric_chunk_coerced_to_zero(self): + result = merge_citations([{"source": "a.md", "chunk": "not_a_number"}]) + assert result.merged[0]["chunk"] == 0 + + def test_three_way_merge_partial_overlap(self): + a = [ + {"source": "a.md", "chunk": 0, "preview": "alpha"}, + {"source": "b.md", "chunk": 0, "preview": "beta"}, + ] + b = [{"source": "a.md", "chunk": 0, "preview": "alpha longer"}] + c = [ + {"source": "a.md", "chunk": 0, "preview": "alpha even longer still"}, + {"source": "c.md", "chunk": 0, "preview": "gamma"}, + ] + result = merge_citations(a, b, c) + sources = {m["source"] for m in result.merged} + assert sources == {"a.md", "b.md", "c.md"} + a_entry = next(m for m in result.merged if m["source"] == "a.md") + assert a_entry["occurrence_count"] == 3 + assert a_entry["preview"] == "alpha even longer still" + assert sorted(a_entry["contributors"]) == [0, 1, 2] + + def test_preview_merger_called_with_empty_returns_empty_string(self): + # Both entries have no "preview" key — merger returns "" via + # default_preview_merger's `best = ""` initial. + a = [{"source": "a.md", "chunk": 0}] + b = [{"source": "a.md", "chunk": 0}] + result = merge_citations(a, b) + assert "preview" in result.merged[0] + assert result.merged[0]["preview"] == "" + + +# ─── normalize_citations helper ───────────────────────────────── + + +class TestNormalizeCitations: + def test_empty_list(self): + assert normalize_citations([]) == [] + + def test_dict_list_passes_through_shallow_copy(self): + source = {"source": "a.md", "chunk": 0, "preview": "x"} + normalised = normalize_citations([source]) + assert normalised[0]["source"] == "a.md" + # Mutable: caller mutating input must not leak through. + source["source"] = "mutated.md" + assert normalised[0]["source"] == "a.md" + + def test_string_promoted(self): + result = normalize_citations(["legacy.md"]) + assert result[0] == {"source": "legacy.md", "chunk": 0} + + def test_non_list_returns_empty(self): + assert normalize_citations("not a list") == [] # type: ignore[arg-type] + + def test_mixed_entries(self): + result = normalize_citations(["legacy.md", {"source": "dict.md", "chunk": 1}]) + assert {r["source"] for r in result} == {"legacy.md", "dict.md"} + assert result[1]["chunk"] == 1 + + +# ─── MergeResult.to_dict serialisation ────────────────────────── + + +class TestMergeResultSerialisation: + def test_to_dict_has_expected_keys(self): + result = merge_citations( + [{"source": "a.md", "chunk": 0}], + [{"source": "a.md", "chunk": 0}], + ) + serialised = result.to_dict() + assert set(serialised.keys()) == {"merged", "duplicates", "contributed_by"} + assert "a.md#0" in serialised["contributed_by"] + + def test_to_dict_empty_when_no_inputs(self): + result = merge_citations() + assert result.to_dict() == { + "merged": [], + "duplicates": [], + "contributed_by": {}, + } + + +# ─── Backward compat: build_sources unchanged + merge interplay ── + + +class TestBuildSourcesInterplay: + """Verify that merge_citations accepts output of build_sources.""" + + def test_merge_two_build_sources_outputs(self): + from services.citation_utils import build_sources + + docs_a = ["alpha chunk 0 text"] + docs_b = ["alpha chunk 0 different text longer"] + metas_a = [{"source": "alpha.md", "chunk": 0}] + metas_b = [{"source": "alpha.md", "chunk": 0}] + + a = build_sources(docs_a, metas_a) + b = build_sources(docs_b, metas_b) + result = merge_citations(a, b) + assert len(result.merged) == 1 + assert result.merged[0]["occurrence_count"] == 2 + # The longer preview (from b) wins. + assert result.merged[0]["preview"] == "alpha chunk 0 different text longer" diff --git a/scripts/merge_citations.py b/scripts/merge_citations.py new file mode 100644 index 00000000..972fa4e8 --- /dev/null +++ b/scripts/merge_citations.py @@ -0,0 +1,145 @@ +"""merge_citations.py — offline CLI for merging citation lists. + +Reads one or more JSON files, each containing a citation list (dicts or +legacy strings), and merges them with `merge_citations` from +backend/services/citation_utils.py. Emits a JSON document (the +`MergeResult.to_dict` payload) by default, or `--plain` for a +human-readable report. + +Example +------- + + $ python scripts/merge_citations.py list_a.json list_b.json --plain + Merged 3 unique citations from 2 list(s) (2 duplicates dropped): + 1. [seen=2] a.md#0 (preview: "alpha chunk 0 text"...) + 2. [seen=1] b.md#0 (preview: "beta text"...) + 3. [seen=1] c.md#0 (preview: "gamma text"...) + +Exit codes: 0 success · 1 input file missing JSON list · 2 file not found · +3 no input files given. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# Resolve backend/services regardless of CWD so the CLI can be invoked +# from a checkout root or from inside scripts/. +_HERE = Path(__file__).resolve().parent +_BACKEND = _HERE.parent / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from services.citation_utils import merge_citations # noqa: E402 + + +def _read_json_list(path: Path) -> list: + """Read a JSON file and return its content as a list. + + Raises ValueError if the file does not contain a JSON array. + """ + try: + with path.open("r", encoding="utf-8") as fh: + data = json.load(fh) + except json.JSONDecodeError as exc: + raise ValueError(f"{path}: not valid JSON ({exc.msg})") from exc + if not isinstance(data, list): + raise ValueError(f"{path}: expected a JSON array, got {type(data).__name__}") + return data + + +def _format_plain(merged_count: int, list_count: int, dup_count: int, result) -> str: + lines = [ + f"Merged {merged_count} unique citations from {list_count} " + f"list(s) ({dup_count} duplicates dropped):" + ] + for idx, entry in enumerate(result.merged, start=1): + chunk = entry.get("chunk", 0) + seen = entry.get("occurrence_count", 1) + preview = entry.get("preview", "") + preview_short = preview if len(preview) <= 30 else preview[:27] + "..." + lines.append( + f" {idx:>2}. [seen={seen}] {entry.get('source', '?')}#{chunk} " + f'(preview: "{preview_short}"...)' + ) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="merge_citations", + description="Merge multiple JSON citation lists into one deduped list.", + ) + parser.add_argument( + "inputs", + nargs="+", + help="One or more JSON file paths. Each file must contain a JSON " + "array of citation dicts (or legacy strings).", + ) + parser.add_argument( + "--plain", + action="store_true", + help="Emit plaintext human-readable report instead of JSON.", + ) + parser.add_argument( + "--scores", + nargs="*", + default=None, + help="Optional space-separated score JSON files, parallel to --inputs. " + "Each must contain a numeric array the same length as the " + "corresponding input list. Use 'none' to skip a slot.", + ) + args = parser.parse_args(argv) + + citation_lists: list[list] = [] + for input_path in args.inputs: + path = Path(input_path) + if not path.exists(): + print(f"error: file not found: {path}", file=sys.stderr) + return 2 + try: + citation_lists.append(_read_json_list(path)) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + # Scores are optional and may be None entries. + scores: list[list[float] | None] | None = None + if args.scores is not None: + scores = [] + for score_path in args.scores: + if score_path.lower() == "none": + scores.append(None) + continue + path = Path(score_path) + if not path.exists(): + print(f"error: score file not found: {path}", file=sys.stderr) + return 2 + try: + with path.open("r", encoding="utf-8") as fh: + scores.append(json.load(fh)) + except json.JSONDecodeError as exc: + print(f"error: {path}: {exc.msg}", file=sys.stderr) + return 1 + + result = merge_citations(*citation_lists, scores=scores) + + if args.plain: + print( + _format_plain( + len(result.merged), + len(citation_lists), + len(result.duplicates), + result, + ) + ) + else: + print(json.dumps(result.to_dict(), indent=2, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())