Skip to content

Commit 6f17b90

Browse files
committed
feat: 实现去重检测
- 基于字段精确匹配去重:批内去重 + 与种子数据去重 - SynthesisResult 新增 dedup_count 统计 - CLI 输出显示去重数量 - validate=False 时跳过去重 - 新增 3 个测试 (71 tests) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering)
1 parent eb98159 commit 6f17b90

3 files changed

Lines changed: 106 additions & 0 deletions

File tree

src/datasynth/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ def on_progress(current, total):
114114
click.echo(f"✓ 生成成功: {result.output_path}")
115115
click.echo(f" 生成数量: {result.generated_count}")
116116
click.echo(f" 失败数量: {result.failed_count}")
117+
if result.dedup_count:
118+
click.echo(f" 去重数量: {result.dedup_count}")
117119
click.echo(f" Token 用量: {result.total_tokens:,}")
118120
click.echo(f" 预计成本: ${result.estimated_cost:.4f}")
119121
click.echo(f" 耗时: {result.duration_seconds:.1f}s")

src/datasynth/synthesizer.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class SynthesisResult:
2424
output_path: str = ""
2525
generated_count: int = 0
2626
failed_count: int = 0
27+
dedup_count: int = 0
2728
total_tokens: int = 0
2829
estimated_cost: float = 0.0
2930
duration_seconds: float = 0.0
@@ -44,6 +45,11 @@ def __init__(self, config: Optional[SynthesisConfig] = None):
4445
self._client = None
4546
self._provider = None
4647

48+
@staticmethod
49+
def _fingerprint(sample: Dict[str, Any]) -> str:
50+
"""Create a dedup key from a sample by sorting and serializing."""
51+
return json.dumps(sample, sort_keys=True, ensure_ascii=False)
52+
4753
def _init_client(self):
4854
"""Initialize LLM client based on provider."""
4955
if self._client is not None:
@@ -107,9 +113,16 @@ def synthesize(
107113
# Determine target count
108114
count = target_count or self.config.target_count
109115

116+
# Build dedup index from seed samples
117+
seen: set[str] = set()
118+
if self.config.validate:
119+
for seed in seed_samples:
120+
seen.add(self._fingerprint(seed))
121+
110122
# Generate in batches
111123
all_samples = []
112124
failed = 0
125+
deduped = 0
113126
total_tokens = 0
114127
batches = (count + self.config.batch_size - 1) // self.config.batch_size
115128

@@ -143,6 +156,19 @@ def synthesize(
143156

144157
# Parse response
145158
samples = parse_generated_samples(response_text, data_schema)
159+
160+
# Dedup
161+
if self.config.validate:
162+
unique = []
163+
for s in samples:
164+
fp = self._fingerprint(s)
165+
if fp not in seen:
166+
seen.add(fp)
167+
unique.append(s)
168+
else:
169+
deduped += 1
170+
samples = unique
171+
146172
all_samples.extend(samples)
147173
batch_success = True
148174
break
@@ -167,6 +193,7 @@ def synthesize(
167193

168194
result.generated_count = len(all_samples)
169195
result.failed_count = failed
196+
result.dedup_count = deduped
170197
result.total_tokens = total_tokens
171198
result.estimated_cost = self._estimate_cost(total_tokens)
172199

tests/test_synthesizer.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,83 @@ def test_synthesize_retry_succeeds(self, tmp_path):
187187
assert result.failed_count == 0
188188
assert s._call_llm.call_count == 2
189189

190+
def test_dedup_within_batch(self, tmp_path):
191+
"""Test that duplicate samples within a batch are removed."""
192+
duped_response = json.dumps(
193+
[
194+
{"instruction": "Q1", "response": "A1"},
195+
{"instruction": "Q1", "response": "A1"}, # duplicate
196+
{"instruction": "Q2", "response": "A2"},
197+
],
198+
ensure_ascii=False,
199+
)
200+
cfg = SynthesisConfig(target_count=3, batch_size=3)
201+
s = DataSynthesizer(cfg)
202+
s._client = MagicMock()
203+
s._provider = "anthropic"
204+
s._call_llm = MagicMock(return_value=(duped_response, 300))
205+
206+
result = s.synthesize(
207+
schema=SAMPLE_SCHEMA,
208+
seed_samples=SAMPLE_SEEDS,
209+
output_path=str(tmp_path / "out.json"),
210+
target_count=3,
211+
)
212+
213+
assert result.generated_count == 2
214+
assert result.dedup_count == 1
215+
216+
def test_dedup_against_seeds(self, tmp_path):
217+
"""Test that samples matching seed data are removed."""
218+
# Return a sample identical to a seed
219+
seed_copy_response = json.dumps(
220+
[
221+
{"instruction": "什么是 AI?", "response": "AI 是人工智能的缩写..."}, # same as seed
222+
{"instruction": "新问题", "response": "新回答"},
223+
],
224+
ensure_ascii=False,
225+
)
226+
cfg = SynthesisConfig(target_count=2, batch_size=2)
227+
s = DataSynthesizer(cfg)
228+
s._client = MagicMock()
229+
s._provider = "anthropic"
230+
s._call_llm = MagicMock(return_value=(seed_copy_response, 200))
231+
232+
result = s.synthesize(
233+
schema=SAMPLE_SCHEMA,
234+
seed_samples=SAMPLE_SEEDS,
235+
output_path=str(tmp_path / "out.json"),
236+
target_count=2,
237+
)
238+
239+
assert result.generated_count == 1
240+
assert result.dedup_count == 1
241+
242+
def test_dedup_disabled(self, tmp_path):
243+
"""Test that dedup is skipped when validate=False."""
244+
duped_response = json.dumps(
245+
[
246+
{"instruction": "Q1", "response": "A1"},
247+
{"instruction": "Q1", "response": "A1"}, # duplicate
248+
],
249+
ensure_ascii=False,
250+
)
251+
cfg = SynthesisConfig(target_count=2, batch_size=2, validate=False)
252+
s = DataSynthesizer(cfg)
253+
s._client = MagicMock()
254+
s._provider = "anthropic"
255+
s._call_llm = MagicMock(return_value=(duped_response, 200))
256+
257+
result = s.synthesize(
258+
schema=SAMPLE_SCHEMA,
259+
seed_samples=SAMPLE_SEEDS,
260+
output_path=str(tmp_path / "out.json"),
261+
target_count=2,
262+
)
263+
264+
assert result.generated_count == 2 # duplicates kept
265+
assert result.dedup_count == 0
266+
190267
def test_synthesize_from_datarecipe(self, tmp_path):
191268
"""Test synthesize_from_datarecipe reads correct files."""
192269
# Set up directory structure

0 commit comments

Comments
 (0)