Skip to content

Commit 3317781

Browse files
authored
Merge branch 'main' into fix/1513-thumbnail-generation
2 parents 4569b82 + eb7fcaa commit 3317781

44 files changed

Lines changed: 2126 additions & 446 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Binary file not shown.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Semantic duplicate detection for a newly opened GitHub issue.
2+
3+
Reads {"current": {...}, "others": [...]} from issues_json (written by the
4+
calling workflow via the GitHub API) and writes a ranked list of likely
5+
duplicates to matches_json.
6+
7+
THRESHOLD was empirically calibrated against confirmed duplicate/non-duplicate
8+
issue pairs in AOSSIE-Org/PictoPy: real duplicates scored 0.56-0.85, unrelated
9+
issues (including ones sharing identical issue-template boilerplate) scored
10+
<= 0.31. 0.55 sits in the gap with margin on both sides.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import argparse
16+
import json
17+
from pathlib import Path
18+
19+
THRESHOLD = 0.55
20+
MAX_RESULTS = 3
21+
22+
23+
def issue_text(issue: dict) -> str:
24+
return f"{issue['title']} {issue['body']}".strip()
25+
26+
27+
def dot_product(a: list[float], b: list[float]) -> float:
28+
return sum(x * y for x, y in zip(a, b))
29+
30+
31+
def find_duplicates(
32+
current_embedding: list[float],
33+
other_embeddings: list[list[float]],
34+
others: list[dict],
35+
threshold: float = THRESHOLD,
36+
max_results: int = MAX_RESULTS,
37+
) -> list[dict]:
38+
# Embeddings are normalized, so dot product equals cosine similarity.
39+
# Sort by the raw score, not the rounded display value, so close scores
40+
# that round to the same tenth (e.g. 90.04 vs 90.01) still rank correctly.
41+
scored: list[tuple[float, dict]] = []
42+
for issue, embedding in zip(others, other_embeddings):
43+
score = dot_product(current_embedding, embedding) * 100
44+
if score >= threshold * 100:
45+
scored.append(
46+
(
47+
score,
48+
{
49+
"number": issue["number"],
50+
"title": issue["title"],
51+
"url": issue["url"],
52+
"state": issue["state"],
53+
"score": round(score, 1),
54+
},
55+
)
56+
)
57+
scored.sort(key=lambda item: -item[0])
58+
return [match for _, match in scored[:max_results]]
59+
60+
61+
def main() -> None:
62+
parser = argparse.ArgumentParser()
63+
parser.add_argument("issues_json", type=Path)
64+
parser.add_argument("matches_json", type=Path)
65+
parser.add_argument("--threshold", type=float, default=THRESHOLD)
66+
args = parser.parse_args()
67+
68+
data = json.loads(args.issues_json.read_text(encoding="utf-8"))
69+
current = data["current"]
70+
others = data["others"]
71+
72+
print(f"Threshold = {args.threshold}")
73+
print(f"Current issue: #{current['number']}")
74+
print(f"Candidate issues: {len(others)}")
75+
76+
if not others:
77+
args.matches_json.write_text("[]", encoding="utf-8")
78+
return
79+
80+
from sentence_transformers import SentenceTransformer
81+
82+
model = SentenceTransformer("all-MiniLM-L6-v2")
83+
embeddings = model.encode(
84+
[issue_text(current)] + [issue_text(o) for o in others],
85+
normalize_embeddings=True,
86+
).tolist()
87+
88+
current_embedding, other_embeddings = embeddings[0], embeddings[1:]
89+
90+
for issue, embedding in zip(others, other_embeddings):
91+
score = dot_product(current_embedding, embedding) * 100
92+
print(f"Issue #{issue['number']} | Score={score:.1f} | Title={issue['title']}")
93+
94+
matches = find_duplicates(
95+
current_embedding, other_embeddings, others, args.threshold
96+
)
97+
args.matches_json.write_text(json.dumps(matches), encoding="utf-8")
98+
99+
100+
if __name__ == "__main__":
101+
main()
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Run with: python .github/scripts/test_detect_duplicate_issue.py"""
2+
3+
from detect_duplicate_issue import dot_product, find_duplicates
4+
5+
6+
def test_dot_product_matches_cosine_similarity_for_normalized_vectors() -> None:
7+
assert dot_product([1.0, 0.0], [0.0, 1.0]) == 0.0
8+
assert dot_product([1.0, 0.0], [1.0, 0.0]) == 1.0
9+
10+
11+
def test_find_duplicates_filters_below_threshold_and_sorts_descending() -> None:
12+
others = [
13+
{"number": 1, "title": "unrelated", "url": "u1", "state": "open"},
14+
{"number": 2, "title": "near duplicate", "url": "u2", "state": "open"},
15+
{"number": 3, "title": "borderline duplicate", "url": "u3", "state": "closed"},
16+
]
17+
current_embedding = [1.0, 0.0]
18+
other_embeddings = [[0.3, 0.0], [0.9, 0.0], [0.6, 0.0]]
19+
20+
matches = find_duplicates(
21+
current_embedding, other_embeddings, others, threshold=0.55
22+
)
23+
24+
assert [m["number"] for m in matches] == [2, 3]
25+
assert matches[0]["score"] == 90.0
26+
27+
28+
def test_find_duplicates_ranks_by_raw_score_not_rounded_display_value() -> None:
29+
others = [
30+
{
31+
"number": 1,
32+
"title": "lower raw score, ties at 90.0 when rounded",
33+
"url": "u1",
34+
"state": "open",
35+
},
36+
{
37+
"number": 2,
38+
"title": "higher raw score, also ties at 90.0 when rounded",
39+
"url": "u2",
40+
"state": "open",
41+
},
42+
{"number": 3, "title": "clear top match", "url": "u3", "state": "open"},
43+
]
44+
current_embedding = [1.0]
45+
other_embeddings = [[0.9001], [0.9004], [0.95]]
46+
47+
matches = find_duplicates(
48+
current_embedding, other_embeddings, others, threshold=0.5, max_results=2
49+
)
50+
51+
assert [m["number"] for m in matches] == [3, 2]
52+
assert matches[1]["score"] == 90.0
53+
54+
55+
def test_find_duplicates_respects_max_results() -> None:
56+
others = [
57+
{"number": i, "title": "t", "url": "u", "state": "open"} for i in range(5)
58+
]
59+
current_embedding = [1.0]
60+
other_embeddings = [[0.9] for _ in others]
61+
62+
matches = find_duplicates(
63+
current_embedding, other_embeddings, others, threshold=0.5, max_results=3
64+
)
65+
66+
assert len(matches) == 3
67+
68+
69+
if __name__ == "__main__":
70+
test_dot_product_matches_cosine_similarity_for_normalized_vectors()
71+
test_find_duplicates_filters_below_threshold_and_sorts_descending()
72+
test_find_duplicates_ranks_by_raw_score_not_rounded_display_value()
73+
test_find_duplicates_respects_max_results()
74+
print("All tests passed.")

‎.github/workflows/auto-issue-comment.yml‎

Lines changed: 0 additions & 25 deletions
This file was deleted.

.github/workflows/duplicate_issue_detector.yaml renamed to .github/workflows/issue-triage.yml

Lines changed: 25 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Smart Duplicate Issue Detector (Semantic)
1+
name: Issue Triage
22

33
on:
44
issues:
@@ -8,18 +8,40 @@ permissions:
88
issues: write
99

1010
jobs:
11+
welcome-comment:
12+
if: |
13+
github.event.issue.author_association != 'OWNER' &&
14+
github.event.issue.author_association != 'MEMBER' &&
15+
github.event.issue.author_association != 'COLLABORATOR'
16+
runs-on: ubuntu-latest
17+
18+
steps:
19+
- name: Add comment to issue
20+
uses: actions/github-script@v7
21+
with:
22+
script: |
23+
github.rest.issues.createComment({
24+
owner: context.repo.owner,
25+
repo: context.repo.repo,
26+
issue_number: context.issue.number,
27+
body: "👋 Thanks for opening this issue! The maintainers will review it shortly. Till then, do not open any PR. This is a strict rule we like to follow around here :)"
28+
})
29+
1130
detect-duplicates:
1231
runs-on: ubuntu-latest
1332

1433
steps:
34+
- name: Checkout repo
35+
uses: actions/checkout@v4
36+
1537
- name: Set up Python
1638
uses: actions/setup-python@v5
1739
with:
1840
python-version: "3.11"
1941

2042
- name: Install dependencies
2143
run: |
22-
pip install --no-cache-dir sentence-transformers scikit-learn
44+
pip install --no-cache-dir sentence-transformers
2345
2446
- name: Fetch upstream issues (AOSSIE-Org/PictoPy)
2547
uses: actions/github-script@v7
@@ -58,67 +80,7 @@ jobs:
5880
fs.writeFileSync("issues.json", JSON.stringify(data));
5981
6082
- name: Run semantic similarity analysis
61-
run: |
62-
python << 'EOF'
63-
import json
64-
from sentence_transformers import SentenceTransformer
65-
from sklearn.metrics.pairwise import cosine_similarity
66-
67-
THRESHOLD = 0.82
68-
MAX_RESULTS = 3
69-
print(f"Threshold = {THRESHOLD}")
70-
71-
with open("issues.json") as f:
72-
data = json.load(f)
73-
74-
model = SentenceTransformer("all-MiniLM-L6-v2")
75-
76-
def text(issue):
77-
return f"{issue['title']} {issue['body']}".strip()
78-
79-
current_text = text(data["current"])
80-
others = data["others"]
81-
print(f"Current issue: #{data['current']['number']}")
82-
print(f"Candidate issues: {len(others)}")
83-
84-
if not others:
85-
with open("matches.json", "w") as f:
86-
json.dump([], f)
87-
exit()
88-
89-
embeddings = model.encode(
90-
[current_text] + [text(i) for i in others],
91-
normalize_embeddings=True
92-
)
93-
94-
current_vec = embeddings[0]
95-
other_vecs = embeddings[1:]
96-
97-
sims = cosine_similarity([current_vec], other_vecs)[0]
98-
99-
matches = []
100-
101-
for issue, score in zip(others, sims):
102-
print(
103-
f"Issue #{issue['number']} | "
104-
f"Score={score:.4f} | "
105-
f"Title={issue['title']}"
106-
)
107-
108-
if score >= THRESHOLD:
109-
matches.append({
110-
"number": issue["number"],
111-
"title": issue["title"],
112-
"url": issue["url"],
113-
"state": issue["state"],
114-
"score": round(float(score) * 100, 1)
115-
})
116-
117-
matches = sorted(matches, key=lambda x: x["score"], reverse=True)[:MAX_RESULTS]
118-
119-
with open("matches.json", "w") as f:
120-
json.dump(matches, f)
121-
EOF
83+
run: python .github/scripts/detect_duplicate_issue.py issues.json matches.json
12284

12385
- name: Comment and soft-label in fork (non-blocking)
12486
uses: actions/github-script@v7

‎.github/workflows/label-merge-conflicts.yml‎

Lines changed: 0 additions & 29 deletions
This file was deleted.

0 commit comments

Comments
 (0)