Robin Claude Sonnet 4.6 commited on
Commit
2288fd7
·
1 Parent(s): d6faa4c

feat: smarter fallback — heuristic sufficiency + result merging (v3.1)

Browse files

Previously: fallback fired only on entities==0, and replaced primary results.
Now: heuristic sufficiency check, with results MERGED (not replaced).

Sufficiency criterion (NERService._expected_min)
expected_min = max(length_floor, label_floor)
length_floor: text<30→1, <100→2, <300→3, ≥300→4
label_floor : ⌈len(labels)/3⌉, default 1
fallback fires when len(primary_entities) < expected_min

Override
ExtractRequest.min_entities (int, ≥0): client overrides the heuristic
min_entities=0 disables fallback entirely

Merge semantics
primary results are kept verbatim
fallback results appended; deduplication keeps highest-score per (start,end)
applies to both single-language fallback and mixed-language dual-run

Tests: 38 → 48 (heuristic, override, additive merge, EN/ZH symmetric paths)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (4) hide show
  1. app/main.py +3 -1
  2. app/models.py +11 -0
  3. app/ner.py +75 -33
  4. tests/test_extract.py +160 -70
app/main.py CHANGED
@@ -48,11 +48,12 @@ def health():
48
  @app.post("/api/v1/extract", response_model=ExtractResponse, tags=["NER"])
49
  def extract(req: ExtractRequest):
50
  logger.info(
51
- "extract request | text_len=%d labels=%s threshold=%s language=%s",
52
  len(req.text),
53
  req.labels or "(default)",
54
  req.threshold,
55
  req.language,
 
56
  )
57
  t0 = time.perf_counter()
58
  entities, labels_used = ner_service.extract(
@@ -60,6 +61,7 @@ def extract(req: ExtractRequest):
60
  req.labels,
61
  req.threshold,
62
  language=req.language,
 
63
  )
64
  elapsed_ms = (time.perf_counter() - t0) * 1000
65
 
 
48
  @app.post("/api/v1/extract", response_model=ExtractResponse, tags=["NER"])
49
  def extract(req: ExtractRequest):
50
  logger.info(
51
+ "extract request | text_len=%d labels=%s threshold=%s language=%s min_entities=%s",
52
  len(req.text),
53
  req.labels or "(default)",
54
  req.threshold,
55
  req.language,
56
+ req.min_entities,
57
  )
58
  t0 = time.perf_counter()
59
  entities, labels_used = ner_service.extract(
 
61
  req.labels,
62
  req.threshold,
63
  language=req.language,
64
+ min_entities=req.min_entities,
65
  )
66
  elapsed_ms = (time.perf_counter() - t0) * 1000
67
 
app/models.py CHANGED
@@ -35,6 +35,17 @@ class ExtractRequest(BaseModel):
35
  ),
36
  )
37
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  class Entity(BaseModel):
40
  text: str
 
35
  ),
36
  )
37
 
38
+ min_entities: int | None = Field(
39
+ default=None,
40
+ ge=0,
41
+ description=(
42
+ "Minimum entity count for the primary model to be considered 'sufficient'. "
43
+ "If the primary returns fewer than this, the fallback model is invoked and "
44
+ "its results are MERGED with the primary's (not replaced). "
45
+ "Leave null/omit to auto-calculate from text length and label count."
46
+ ),
47
+ )
48
+
49
 
50
  class Entity(BaseModel):
51
  text: str
app/ner.py CHANGED
@@ -1,19 +1,31 @@
1
  """
2
- NER 服务层 — 双模型路由 + 兜底策略
3
  ──────────────────────────────────────────────────────────────────────────────
4
  语言检测(两层):
5
  1. Unicode 脚本比例:快速,适合中文 / 阿拉伯文等脚本明显的语言
6
  2. langdetect 库兜底:覆盖纯英文及边界文本
7
 
8
- 路由 & 兜底规则
9
- ┌──────────┬──────────────────┬──────────────────────────────┐
10
- language 主模型 │ 兜底条件 │
11
- ├──────────┼──────────────────┼──────────────────────────────┤
12
- │ zh │ ChineseBERT │ 实体数=0 补充 GLiNER 结果
13
- en / ar │ GLiNER │ 实体数=0 → 补充 BERT 结果 │
14
- │ mixed │ GLiNER + BERT │ 同时运行两个模型,结果合并 │
15
- │ auto │ 先检测语言再路由 │ │
16
- └──────────┴──────────────────┴──────────────────────────────┘
 
 
 
 
 
 
 
 
 
 
 
 
17
  """
18
 
19
  import threading
@@ -254,18 +266,38 @@ class NERService:
254
  self._zh_backend = ChineseBERTBackend(self._zh_name, self._cache_dir)
255
  return self._zh_backend
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  # ── 兜底合并 ──────────────────────────────────────────────────────────────
258
 
 
259
  def _merge(
260
- self,
261
  primary: tuple[list[Entity], list[str]],
262
  fallback: tuple[list[Entity], list[str]],
263
  ) -> tuple[list[Entity], list[str]]:
264
- """合并两个模型的结果,去重后按位置排序。"""
 
 
 
265
  p_ents, p_labels = primary
266
  f_ents, f_labels = fallback
267
  merged = _deduplicate(p_ents + f_ents)
268
- used = list(dict.fromkeys(p_labels + f_labels)) # 保序去重
269
  return merged, used
270
 
271
  # ── 主入口 ────────────────────────────────────────────────────────────────
@@ -276,42 +308,52 @@ class NERService:
276
  labels: list[str],
277
  threshold: float,
278
  language: str = "auto",
 
279
  ) -> tuple[list[Entity], list[str]]:
280
  """
281
  返回 (entities, labels_used)。
282
 
283
- 路由逻辑
284
  auto → 检测语言 → 路由
285
- zh → BERT 主,GLiNER 兜底(主模型无结果时补充)
286
- en/ar → GLiNER 主,BERT 兜底(主模型无结果时补充)
287
- mixed → 两模型同时运行,结果合并去重
 
 
 
 
288
  """
289
  if not text:
290
  return [], labels
291
 
292
  lang = language if language != "auto" else detect_language(text)
293
 
 
294
  if lang == "mixed":
295
- # 同时运行两个模型,合并结果
296
- en_result = self._en().predict(text, labels, threshold)
297
- zh_result = self._zh().predict(text, labels, threshold)
298
- return self._merge(en_result, zh_result)
299
 
 
300
  if lang == "zh":
301
- primary_result = self._zh().predict(text, labels, threshold)
302
- if not primary_result[0]: # 主模型无结果 GLiNER 兜底
303
- fallback_result = self._en().predict(text, labels, threshold)
304
- if fallback_result[0]:
305
- return fallback_result
 
 
 
 
 
 
 
306
  return primary_result
307
 
308
- # en / ar / 其他
309
- primary_result = self._en().predict(text, labels, threshold)
310
- if not primary_result[0]: # 主模型无结果 → BERT 兜底
311
- fallback_result = self._zh().predict(text, labels, threshold)
312
- if fallback_result[0]:
313
- return fallback_result
314
- return primary_result
315
 
316
  def warmup(self) -> None:
317
  """启动时预热两个模型,首个请求无需等待。"""
 
1
  """
2
+ NER 服务层 — 双模型路由 + 兜底合并
3
  ──────────────────────────────────────────────────────────────────────────────
4
  语言检测(两层):
5
  1. Unicode 脚本比例:快速,适合中文 / 阿拉伯文等脚本明显的语言
6
  2. langdetect 库兜底:覆盖纯英文及边界文本
7
 
8
+ 充分性判定(替代粗暴的 ==0)
9
+ expected_min = max( length_floor, label_floor )
10
+ length_floor: text<30→1, <100→2, <300→3, ≥300→4
11
+ label_floor : ⌈len(labels)/3⌉,无 labels 时为 1
12
+ 主模型实体数 < expected_min → 触发兜底
13
+ 调用方可在请求里直接传 min_entities 覆盖启发式
14
+
15
+ 兜底合并(关键:相加而非替换):
16
+ 1. 主模型先跑一遍,结果保留
17
+ 2. 若不充分,兜底模型再跑一遍
18
+ 3. 两份结果合并 → 按 (start, end) 去重,同一 span 保留得分最高的
19
+
20
+ 路由:
21
+ ┌──────────┬──────────────────────────┐
22
+ │ language │ 主模型 → 兜底模型 │
23
+ ├──────────┼──────────────────────────┤
24
+ │ zh │ BERT-Chinese → GLiNER │
25
+ │ en / ar │ GLiNER → BERT-Chinese │
26
+ │ mixed │ 两个模型同时运行后合并 │
27
+ │ auto │ 先检测语言再路由 │
28
+ └──────────┴──────────────────────────┘
29
  """
30
 
31
  import threading
 
266
  self._zh_backend = ChineseBERTBackend(self._zh_name, self._cache_dir)
267
  return self._zh_backend
268
 
269
+ # ── 充分性判定 ────────────────────────────────────────────────────────────
270
+
271
+ @staticmethod
272
+ def _expected_min(text: str, labels: list[str]) -> int:
273
+ """
274
+ 启发式:根据文本长度和标签数计算最小期望实体数。
275
+ 取 length_floor 与 label_floor 中的较大值。
276
+ """
277
+ n = len(text)
278
+ if n < 30: length_floor = 1
279
+ elif n < 100: length_floor = 2
280
+ elif n < 300: length_floor = 3
281
+ else: length_floor = 4
282
+
283
+ label_floor = max(1, (len(labels) + 2) // 3) if labels else 1
284
+ return max(length_floor, label_floor)
285
+
286
  # ── 兜底合并 ──────────────────────────────────────────────────────────────
287
 
288
+ @staticmethod
289
  def _merge(
 
290
  primary: tuple[list[Entity], list[str]],
291
  fallback: tuple[list[Entity], list[str]],
292
  ) -> tuple[list[Entity], list[str]]:
293
+ """
294
+ 相加合并:保留主模型所有结果,再加上兜底模型的结果,
295
+ 按 (start, end) 去重(同一 span 保留得分最高),按位置排序。
296
+ """
297
  p_ents, p_labels = primary
298
  f_ents, f_labels = fallback
299
  merged = _deduplicate(p_ents + f_ents)
300
+ used = list(dict.fromkeys(p_labels + f_labels)) # 保序去重
301
  return merged, used
302
 
303
  # ── 主入口 ────────────────────────────────────────────────────────────────
 
308
  labels: list[str],
309
  threshold: float,
310
  language: str = "auto",
311
+ min_entities: int | None = None,
312
  ) -> tuple[list[Entity], list[str]]:
313
  """
314
  返回 (entities, labels_used)。
315
 
316
+ 路由:
317
  auto → 检测语言 → 路由
318
+ zh → BERT 主,GLiNER 兜底
319
+ en/ar → GLiNER 主,BERT 兜底
320
+ mixed → 两模型同时运行合并
321
+
322
+ 兜底触发条件(zh / en / ar):
323
+ 主模型实体数 < expected_min(默认启发式,可由 min_entities 覆盖)
324
+ 触发后:主结果 + 兜底结果一并返回,按 span 去重。
325
  """
326
  if not text:
327
  return [], labels
328
 
329
  lang = language if language != "auto" else detect_language(text)
330
 
331
+ # mixed 永远跑双模型并合并
332
  if lang == "mixed":
333
+ return self._merge(
334
+ self._en().predict(text, labels, threshold),
335
+ self._zh().predict(text, labels, threshold),
336
+ )
337
 
338
+ # 单语言:选主模型 + 兜底模型
339
  if lang == "zh":
340
+ primary, fallback = self._zh(), self._en()
341
+ else: # en / ar
342
+ primary, fallback = self._en(), self._zh()
343
+
344
+ primary_result = primary.predict(text, labels, threshold)
345
+
346
+ # 充分性判定
347
+ threshold_n = (
348
+ min_entities if min_entities is not None
349
+ else self._expected_min(text, labels)
350
+ )
351
+ if len(primary_result[0]) >= threshold_n:
352
  return primary_result
353
 
354
+ # 不充分 兜底相加
355
+ fallback_result = fallback.predict(text, labels, threshold)
356
+ return self._merge(primary_result, fallback_result)
 
 
 
 
357
 
358
  def warmup(self) -> None:
359
  """启动时预热两个模型,首个请求无需等待。"""
tests/test_extract.py CHANGED
@@ -77,7 +77,9 @@ def test_extract_threshold_forwarded(client):
77
  c, mock_ner = client
78
  c.post("/api/v1/extract",
79
  json={"text": "Hello", "labels": ["person"], "threshold": 0.8})
80
- mock_ner.extract.assert_called_once_with("Hello", ["person"], 0.8, language="auto")
 
 
81
 
82
 
83
  def test_extract_invalid_threshold(client):
@@ -90,7 +92,25 @@ def test_extract_language_field_forwarded(client):
90
  c, mock_ner = client
91
  c.post("/api/v1/extract",
92
  json={"text": "北京协和医院", "labels": ["医院名称"], "language": "zh"})
93
- mock_ner.extract.assert_called_once_with("北京协和医院", ["医院名称"], 0.4, language="zh")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
 
96
  def test_extract_invalid_language(client):
@@ -232,7 +252,8 @@ def test_english_threshold_forwarded(client):
232
  "labels": ["company or organization name"],
233
  "threshold": 0.8, "language": "en"})
234
  mock_ner.extract.assert_called_once_with(
235
- "NASA explored the Moon.", ["company or organization name"], 0.8, language="en"
 
236
  )
237
 
238
 
@@ -351,101 +372,170 @@ def test_mixed_no_cross_language_contamination(client):
351
 
352
  # ── Fallback & merge (NERService unit tests, no HTTP) ────────────────────────
353
 
354
- def test_fallback_zh_empty_uses_en():
355
- """ZH 主模型返回空时,应使用 GLiNER 兜底。"""
 
356
  from app.ner import NERService
357
-
358
  svc = NERService.__new__(NERService)
359
- svc._en_lock = __import__("threading").Lock()
360
- svc._zh_lock = __import__("threading").Lock()
361
-
362
- # ZH backend: returns nothing
363
- zh_mock = MagicMock()
364
- zh_mock.predict.return_value = ([], [])
365
- # EN fallback: returns one entity
366
- en_mock = MagicMock()
367
- en_mock.predict.return_value = _ents(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  Entity(text="马云", label="person", score=0.75, start=0, end=2)
369
  )
370
- svc._zh_backend = zh_mock
371
- svc._en_backend = en_mock
372
-
373
  entities, _ = svc.extract("马云", [], 0.4, language="zh")
374
  assert any(e.text == "马云" for e in entities)
375
- zh_mock.predict.assert_called_once()
376
- en_mock.predict.assert_called_once() # 兜底被调用
377
 
378
 
379
- def test_fallback_zh_has_results_no_en_called():
380
- """ZH 主模型有结果时,调用 GLiNER 兜底。"""
381
- from app.ner import NERService
 
 
 
 
 
382
 
383
- svc = NERService.__new__(NERService)
384
- svc._en_lock = __import__("threading").Lock()
385
- svc._zh_lock = __import__("threading").Lock()
386
 
387
- zh_mock = MagicMock()
388
- zh_mock.predict.return_value = _ents(
389
- Entity(text="马云", label="person", score=0.92, start=0, end=2)
 
 
 
 
 
 
390
  )
391
- en_mock = MagicMock()
392
- svc._zh_backend = zh_mock
393
- svc._en_backend = en_mock
 
 
394
 
395
- svc.extract("马云", [], 0.4, language="zh")
396
- en_mock.predict.assert_not_called() # 不应调用兜底
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
 
 
 
 
398
 
399
- def test_mixed_runs_both_models_and_merges():
400
- """Mixed 语言应同时运行两个模型并合并结果。"""
401
- from app.ner import NERService
402
 
403
- svc = NERService.__new__(NERService)
404
- svc._en_lock = __import__("threading").Lock()
405
- svc._zh_lock = __import__("threading").Lock()
 
 
 
406
 
407
- en_mock = MagicMock()
408
- en_mock.predict.return_value = _ents(
409
- Entity(text="Google", label="organization", score=0.95, start=5, end=11)
 
 
 
 
 
 
410
  )
411
- zh_mock = MagicMock()
412
- zh_mock.predict.return_value = _ents(
413
- Entity(text="张伟", label="person", score=0.91, start=0, end=2)
414
  )
415
- svc._en_backend = en_mock
416
- svc._zh_backend = zh_mock
417
 
418
- entities, _ = svc.extract("张伟加入 Google。", [], 0.4, language="mixed")
419
  texts = {e.text for e in entities}
420
- assert "Google" in texts
421
- assert "张伟" in texts
422
- en_mock.predict.assert_called_once()
423
- zh_mock.predict.assert_called_once()
424
 
425
 
426
- def test_mixed_deduplicates_overlapping_spans():
427
- """两个模型对同一 span 都命中时,只保留得分最高的。"""
428
- from app.ner import NERService
 
 
 
 
 
 
 
 
 
 
 
 
 
429
 
430
- svc = NERService.__new__(NERService)
431
- svc._en_lock = __import__("threading").Lock()
432
- svc._zh_lock = __import__("threading").Lock()
433
 
434
- en_mock = MagicMock()
435
- en_mock.predict.return_value = (
 
 
436
  [Entity(text="张伟", label="person", score=0.70, start=0, end=2)],
437
  ["person"],
438
  )
439
- zh_mock = MagicMock()
440
- zh_mock.predict.return_value = (
441
  [Entity(text="张伟", label="人名或姓名", score=0.92, start=0, end=2)],
442
  ["人名或姓名"],
443
  )
444
- svc._en_backend = en_mock
445
- svc._zh_backend = zh_mock
446
-
447
  entities, _ = svc.extract("张伟", [], 0.4, language="mixed")
448
- # 去重后只有 1 "张伟",且是得分更高的那条
449
- zhang_wei = [e for e in entities if e.text == "张伟"]
450
- assert len(zhang_wei) == 1
451
- assert zhang_wei[0].score == 0.92
 
77
  c, mock_ner = client
78
  c.post("/api/v1/extract",
79
  json={"text": "Hello", "labels": ["person"], "threshold": 0.8})
80
+ mock_ner.extract.assert_called_once_with(
81
+ "Hello", ["person"], 0.8, language="auto", min_entities=None
82
+ )
83
 
84
 
85
  def test_extract_invalid_threshold(client):
 
92
  c, mock_ner = client
93
  c.post("/api/v1/extract",
94
  json={"text": "北京协和医院", "labels": ["医院名称"], "language": "zh"})
95
+ mock_ner.extract.assert_called_once_with(
96
+ "北京协和医院", ["医院名称"], 0.4, language="zh", min_entities=None
97
+ )
98
+
99
+
100
+ def test_extract_min_entities_forwarded(client):
101
+ c, mock_ner = client
102
+ c.post("/api/v1/extract",
103
+ json={"text": "马云在杭州。", "language": "zh", "min_entities": 5})
104
+ mock_ner.extract.assert_called_once_with(
105
+ "马云在杭州。", [], 0.4, language="zh", min_entities=5
106
+ )
107
+
108
+
109
+ def test_extract_negative_min_entities_rejected(client):
110
+ c, _ = client
111
+ resp = c.post("/api/v1/extract",
112
+ json={"text": "x", "min_entities": -1})
113
+ assert resp.status_code == 422
114
 
115
 
116
  def test_extract_invalid_language(client):
 
252
  "labels": ["company or organization name"],
253
  "threshold": 0.8, "language": "en"})
254
  mock_ner.extract.assert_called_once_with(
255
+ "NASA explored the Moon.", ["company or organization name"], 0.8,
256
+ language="en", min_entities=None,
257
  )
258
 
259
 
 
372
 
373
  # ── Fallback & merge (NERService unit tests, no HTTP) ────────────────────────
374
 
375
+ def _build_svc():
376
+ """Construct a bare NERService with mocked backends and locks."""
377
+ import threading
378
  from app.ner import NERService
 
379
  svc = NERService.__new__(NERService)
380
+ svc._en_lock = threading.Lock()
381
+ svc._zh_lock = threading.Lock()
382
+ svc._en_backend = MagicMock()
383
+ svc._zh_backend = MagicMock()
384
+ return svc
385
+
386
+
387
+ # ── Sufficiency heuristic ────────────────────────────────────────────────────
388
+
389
+ def test_expected_min_short_text():
390
+ from app.ner import NERService
391
+ assert NERService._expected_min("马云", []) == 1
392
+
393
+
394
+ def test_expected_min_medium_text():
395
+ from app.ner import NERService
396
+ text = "x" * 50
397
+ assert NERService._expected_min(text, []) == 2
398
+
399
+
400
+ def test_expected_min_long_text():
401
+ from app.ner import NERService
402
+ text = "x" * 350
403
+ assert NERService._expected_min(text, []) == 4
404
+
405
+
406
+ def test_expected_min_label_floor_takes_over():
407
+ """9 个标签 → ⌈9/3⌉=3,超过短文本的 length_floor=1,最终取 3。"""
408
+ from app.ner import NERService
409
+ short_text = "马云"
410
+ labels = [f"l{i}" for i in range(9)]
411
+ assert NERService._expected_min(short_text, labels) == 3
412
+
413
+
414
+ # ── ZH branch fallback ───────────────────────────────────────────────────────
415
+
416
+ def test_zh_empty_triggers_fallback_and_adds():
417
+ """ZH 主模型 0 个 → 触发兜底 → 返回兜底结果。"""
418
+ svc = _build_svc()
419
+ svc._zh_backend.predict.return_value = ([], [])
420
+ svc._en_backend.predict.return_value = _ents(
421
  Entity(text="马云", label="person", score=0.75, start=0, end=2)
422
  )
 
 
 
423
  entities, _ = svc.extract("马云", [], 0.4, language="zh")
424
  assert any(e.text == "马云" for e in entities)
425
+ svc._zh_backend.predict.assert_called_once()
426
+ svc._en_backend.predict.assert_called_once()
427
 
428
 
429
+ def test_zh_sufficient_no_fallback():
430
+ """ZH 主模型实体数 ≥ expected_min(=1 短文本) → 不调用兜底。"""
431
+ svc = _build_svc()
432
+ svc._zh_backend.predict.return_value = _ents(
433
+ Entity(text="马云", label="person", score=0.92, start=0, end=2)
434
+ )
435
+ svc.extract("马云", [], 0.4, language="zh")
436
+ svc._en_backend.predict.assert_not_called()
437
 
 
 
 
438
 
439
+ def test_zh_insufficient_triggers_fallback_and_results_added():
440
+ """
441
+ 关键测试:ZH 返回 1 个,但文本长 → expected_min=4,不充分 →
442
+ 触发兜底,主结果 + 兜底结果一并返回(相加,不替换)。
443
+ """
444
+ svc = _build_svc()
445
+ long_text = "马云" + "x" * 350 # length_floor = 4
446
+ svc._zh_backend.predict.return_value = _ents(
447
+ Entity(text="马云", label="人名或姓名", score=0.95, start=0, end=2),
448
  )
449
+ svc._en_backend.predict.return_value = _ents(
450
+ Entity(text="Tesla", label="organization", score=0.90, start=10, end=15),
451
+ Entity(text="2024", label="date", score=0.88, start=20, end=24),
452
+ )
453
+ entities, _ = svc.extract(long_text, [], 0.4, language="zh")
454
 
455
+ texts = {e.text for e in entities}
456
+ # 主模型的"马云"必须保留,同时兜底的 Tesla / 2024 也加进来
457
+ assert "马云" in texts
458
+ assert "Tesla" in texts
459
+ assert "2024" in texts
460
+ assert len(entities) == 3 # 1 + 2 = 3,确实是相加
461
+
462
+
463
+ def test_user_min_entities_overrides_heuristic():
464
+ """请求里传 min_entities=5 时应覆盖启发式,主模型 3 个仍触发兜底。"""
465
+ svc = _build_svc()
466
+ svc._zh_backend.predict.return_value = _ents(
467
+ Entity(text="马云", label="person", score=0.95, start=0, end=2),
468
+ Entity(text="张勇", label="person", score=0.93, start=4, end=6),
469
+ Entity(text="杭州", label="location", score=0.91, start=8, end=10),
470
+ )
471
+ svc._en_backend.predict.return_value = _ents(
472
+ Entity(text="Tesla", label="organization", score=0.85, start=15, end=20),
473
+ )
474
+ entities, _ = svc.extract("马云、张勇、杭州 Tesla", [], 0.4,
475
+ language="zh", min_entities=5)
476
 
477
+ # 3 < 5 → 触发兜底;最终 3 + 1 = 4
478
+ assert len(entities) == 4
479
+ svc._en_backend.predict.assert_called_once()
480
 
 
 
 
481
 
482
+ def test_user_min_entities_zero_disables_fallback():
483
+ """min_entities=0 时主模型即使返回空也不触发兜底。"""
484
+ svc = _build_svc()
485
+ svc._zh_backend.predict.return_value = ([], [])
486
+ svc.extract("马云", [], 0.4, language="zh", min_entities=0)
487
+ svc._en_backend.predict.assert_not_called()
488
 
489
+
490
+ # ── EN branch fallback (symmetric) ───────────────────────────────────────────
491
+
492
+ def test_en_insufficient_triggers_zh_fallback_and_adds():
493
+ """EN 主模型不充分 → 调 ZH 兜底 → 结果相加。"""
494
+ svc = _build_svc()
495
+ long_text = "Tesla" + "x" * 350
496
+ svc._en_backend.predict.return_value = _ents(
497
+ Entity(text="Tesla", label="organization", score=0.95, start=0, end=5),
498
  )
499
+ svc._zh_backend.predict.return_value = _ents(
500
+ Entity(text="马云", label="人名或姓名", score=0.91, start=10, end=12),
 
501
  )
502
+ entities, _ = svc.extract(long_text, [], 0.4, language="en")
 
503
 
 
504
  texts = {e.text for e in entities}
505
+ assert "Tesla" in texts and "马云" in texts
506
+ assert len(entities) == 2
 
 
507
 
508
 
509
+ # ── Mixed: always merge both ─────────────────────────────────────────────────
510
+
511
+ def test_mixed_always_runs_both_models():
512
+ """mixed ���言无视充分性,永远跑两个模型并合并。"""
513
+ svc = _build_svc()
514
+ svc._en_backend.predict.return_value = _ents(
515
+ Entity(text="Google", label="organization", score=0.95, start=5, end=11)
516
+ )
517
+ svc._zh_backend.predict.return_value = _ents(
518
+ Entity(text="张伟", label="人名或姓名", score=0.91, start=0, end=2)
519
+ )
520
+ entities, _ = svc.extract("张伟加入 Google。", [], 0.4, language="mixed")
521
+ texts = {e.text for e in entities}
522
+ assert {"Google", "张伟"} <= texts
523
+ svc._en_backend.predict.assert_called_once()
524
+ svc._zh_backend.predict.assert_called_once()
525
 
 
 
 
526
 
527
+ def test_merge_deduplicates_overlapping_spans():
528
+ """两个模型对同一 span 都命中 → 保留得分最高的那条。"""
529
+ svc = _build_svc()
530
+ svc._en_backend.predict.return_value = (
531
  [Entity(text="张伟", label="person", score=0.70, start=0, end=2)],
532
  ["person"],
533
  )
534
+ svc._zh_backend.predict.return_value = (
 
535
  [Entity(text="张伟", label="人名或姓名", score=0.92, start=0, end=2)],
536
  ["人名或姓名"],
537
  )
 
 
 
538
  entities, _ = svc.extract("张伟", [], 0.4, language="mixed")
539
+ matches = [e for e in entities if e.text == "张伟"]
540
+ assert len(matches) == 1
541
+ assert matches[0].score == 0.92 # 高分胜出