Spaces:
Running
Running
File size: 9,975 Bytes
9199ba5 f7da604 9199ba5 f7da604 9199ba5 f7da604 9199ba5 f7da604 9199ba5 f7da604 9199ba5 f7da604 9199ba5 f7da604 9199ba5 f7da604 9199ba5 a564d6f 9199ba5 f7da604 9199ba5 a564d6f f7da604 9199ba5 f7da604 9199ba5 f7da604 9199ba5 f7da604 a564d6f 9199ba5 f7da604 9199ba5 f7da604 9199ba5 f7da604 9199ba5 f7da604 9199ba5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 | #!/usr/bin/env python3
"""Identify possible near-duplicate questions in questions/.
Run before opening a pull request, after validate_questions.py:
python scripts/check_duplicates.py
Only pending items are reviewed. Existing corpus items are the set that a
submission is checked against, and corpus-vs-corpus pairs are not reported.
The review applies one of two methods per pending item, selected by its
`category` field.
Defined categories
For the categories defined in the schema, the script prints every question
already filed under that category and the contributor reviews the list
directly.
The `other` category
`other` is the fallback value used when no defined category applies, so it
provides no meaningful grouping. Pending items in this category are
evaluated using the embedding-based similarity check.
Cross-category check
`category` is a topic label, not a claim identity, so the same claim can be
filed under neighbouring categories. As a safety net, pending items are
also compared by embedding against items outside their own category, and
reported only at or above CROSS_CATEGORY_THRESHOLD.
All methods are advisory. Two questions may concern the same event and remain
individually valid if they test different claims. The script reports
information for the contributor to assess and does not block a submission.
Pass --all to print the entire corpus grouped by category.
The embedding paths download a small (~64MB) local model on first use and
cache it. No API key is required and no data leaves the local machine.
"""
import argparse
import json
import sys
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
QUESTIONS_DIR = ROOT / "questions"
PENDING_DIR = QUESTIONS_DIR / "pending"
PENDING_ID = "cb-pending"
UNGROUPED_CATEGORY = "other"
CATEGORY_THRESHOLD = 0.6
CROSS_CATEGORY_THRESHOLD = 0.8
def load_questions() -> list[dict]:
"""Load every item under questions/, tagging each with its source file."""
items = []
for path in sorted(QUESTIONS_DIR.rglob("*.jsonl")):
if path.name == "template.jsonl":
continue
for lineno, line in enumerate(path.read_text().splitlines(), start=1):
line = line.strip()
if not line:
continue
item = json.loads(line)
item["_path"] = path
item["_pending"] = PENDING_DIR in path.parents
items.append(item)
return items
def is_pending(item: dict) -> bool:
return bool(item.get("_pending")) or item.get("id") == PENDING_ID
def label(item: dict) -> str:
"""Mark the contributor's own items so they are distinguishable in a long list."""
return "-> " if is_pending(item) else " "
def category_of(item: dict) -> str:
return item.get("category", UNGROUPED_CATEGORY)
def display_id(item: dict) -> str:
"""cb-pending is shared by every pending item, so show the file too."""
item_id = item.get("id", "?")
if is_pending(item):
return f"{item_id} ({item['_path'].name})"
return item_id
def embed(items: list[dict], required: bool):
"""Return a normalized embedding matrix, or None if the model is absent.
`required` distinguishes the two callers. The `other` check cannot work
without embeddings. The cross-category check is a safety net on top of
a review that has already happened, so it degrades to a warning
instead of blocking the run.
"""
try:
from fastembed import TextEmbedding
import numpy as np
except ImportError:
if required:
sys.exit("Missing dependency: pip install fastembed numpy")
print(
" skipped — fastembed/numpy not installed.\n"
" Install with 'pip install fastembed numpy' to enable the\n"
" cross-category check for miscategorised duplicates."
)
return None
model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5")
vectors = np.array(list(model.embed([i["question"] for i in items])))
return vectors / np.linalg.norm(vectors, axis=1, keepdims=True)
def report_pairs(pairs: list, heading: str, note: str) -> None:
print(f"\n{heading}\n")
for score, a, b in pairs:
same_claim = (
a.get("claim_id")
and b.get("claim_id")
and a["claim_id"] == b["claim_id"]
)
suffix = " (same claim_id — expected)" if same_claim else ""
print(f" similarity {score:.2f}{suffix}")
for item in (a, b):
marker = "->" if is_pending(item) else " "
cat = category_of(item)
print(f" {marker} [{cat}] {display_id(item)}: {item['question']}")
print()
print(note)
def review_by_category(items: list[dict], categories: set[str]) -> None:
"""Report every item in the given categories for manual review."""
by_category = defaultdict(list)
for item in items:
by_category[category_of(item)].append(item)
for category in sorted(categories):
group = by_category.get(category, [])
print(f"\n{category} — {len(group)} question(s)")
print("-" * (len(category) + 20))
for item in group:
print(f" {label(item)}{display_id(item)}: {item['question']}")
claim_id = item.get("claim_id")
if claim_id:
print(f" [claim: {claim_id}]")
def review_other_by_similarity(items: list[dict]) -> int:
"""Embedding check within `other`, which has no meaningful grouping."""
other = [i for i in items if category_of(i) == UNGROUPED_CATEGORY]
pending = [i for i in other if is_pending(i)]
if not pending or len(other) < 2:
print(f"\nFewer than 2 questions in '{UNGROUPED_CATEGORY}', nothing to compare.")
return 0
print(f"\n{UNGROUPED_CATEGORY} — embedding {len(other)} question(s)...")
normalized = embed(other, required=True)
similarity = normalized @ normalized.T
flagged = []
for i in range(len(other)):
for j in range(i + 1, len(other)):
if not (is_pending(other[i]) or is_pending(other[j])):
continue
score = float(similarity[i, j])
if score >= CATEGORY_THRESHOLD:
flagged.append((score, other[i], other[j]))
flagged.sort(key=lambda x: -x[0])
if not flagged:
print(f"No pairs above similarity {CATEGORY_THRESHOLD}. Looks distinct.")
return 0
report_pairs(
flagged,
f"{len(flagged)} pair(s) in '{UNGROUPED_CATEGORY}' worth a second look:",
"These are informational, not errors. 'other' has no grouping to read,\n"
"so similarity is the only signal available there.",
)
return len(flagged)
def review_across_categories(items: list[dict]) -> int:
"""Safety net for claims filed under different categories."""
pending = [i for i in items if is_pending(i)]
if not pending or len(items) < 2:
return 0
print(
f"\ncross-category — embedding {len(items)} question(s) "
f"at threshold {CROSS_CATEGORY_THRESHOLD}..."
)
normalized = embed(items, required=False)
if normalized is None:
return 0
similarity = normalized @ normalized.T
index = {id(item): n for n, item in enumerate(items)}
flagged = []
for a in pending:
for b in items:
if a is b or category_of(a) == category_of(b):
continue
# Avoid reporting a pending-pending pair twice.
if is_pending(b) and index[id(b)] < index[id(a)]:
continue
score = float(similarity[index[id(a)], index[id(b)]])
if score >= CROSS_CATEGORY_THRESHOLD:
flagged.append((score, a, b))
flagged.sort(key=lambda x: -x[0])
if not flagged:
print(
f"No cross-category pairs above {CROSS_CATEGORY_THRESHOLD}."
)
return 0
report_pairs(
flagged,
f"{len(flagged)} cross-category pair(s) worth checking:",
"These sit in different categories but are worded very similarly. Check\n"
"whether one of them is miscategorised, or whether they test genuinely\n"
"different claims about related topics.",
)
return len(flagged)
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--all",
action="store_true",
help="print the whole corpus grouped by category, not just your categories",
)
args = parser.parse_args()
items = load_questions()
if not items:
print("No questions found in questions/.")
return 0
pending = [i for i in items if is_pending(i)]
if args.all:
categories = {category_of(i) for i in items}
elif pending:
categories = {category_of(i) for i in pending}
print(
f"{len(pending)} pending item(s) in {len(categories)} "
f"categor{'y' if len(categories) == 1 else 'ies'}. "
"Your items are marked with ->."
)
else:
print("No pending items found. Nothing to check.")
return 0
grouped = sorted(categories - {UNGROUPED_CATEGORY})
if grouped:
review_by_category(items, set(grouped))
print(
"\nRead the list(s) above and check whether your question is already\n"
"covered. Same event but a different claim is fine. Same question\n"
"reworded is not — reuse the existing item, or set claim_id to group\n"
"them if you are deliberately adding another framing."
)
if UNGROUPED_CATEGORY in categories:
review_other_by_similarity(items)
if pending:
review_across_categories(items)
return 0
if __name__ == "__main__":
raise SystemExit(main())
|