Robin Claude Sonnet 4.6 commited on
Commit
372fe0c
ยท
1 Parent(s): 4a2a819

feat: optional labels, bilingual auto-expansion, language hint (v2.0)

Browse files

- app/labels.py (new): bilingual label pairs table, DEFAULT_LABELS,
expand_bilingual() โ€” auto-adds counterpart label for each known entry
- app/models.py: labels optional (default []), add language field
(auto/en/zh/ar/mixed), response now echoes labels_used
- app/ner.py: integrate expand_bilingual + DEFAULT_LABELS; language-aware
processing; span deduplication (_deduplicate)
- app/main.py: pass language to NERService.extract(), return labels_used
- tests/test_extract.py: expanded from 15 โ†’ 25 tests; cover Arabic,
bilingual expansion, optional-labels, language field, labels_used echo
- scripts/compare_models.py: bilingual labels in all cases, Markdown report
- reports/comparison_report.md: model comparison results

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

README.md CHANGED
@@ -10,11 +10,26 @@ pinned: false
10
 
11
  # NER Server
12
 
13
- Zero-shot Named Entity Recognition HTTP API powered by [GLiNER](https://github.com/urchade/GLiNER).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  ## API
16
 
17
- Base URL: `https://<your-hf-username>-ner-server.hf.space`
18
 
19
  ### `GET /api/v1/health`
20
 
@@ -26,35 +41,82 @@ Base URL: `https://<your-hf-username>-ner-server.hf.space`
26
 
27
  **Request**
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  ```json
30
  {
31
- "text": "Elon Musk founded SpaceX in Hawthorne, California.",
32
- "labels": ["person", "organization", "location"],
33
- "threshold": 0.6
 
 
 
34
  }
35
  ```
36
 
37
- **Response**
 
 
 
 
 
 
 
 
38
 
39
  ```json
40
  {
41
  "entities": [
42
- {"text": "Elon Musk", "label": "person", "score": 0.98, "start": 0, "end": 9 },
43
- {"text": "SpaceX", "label": "organization", "score": 0.97, "start": 18, "end": 24},
44
- {"text": "Hawthorne", "label": "location", "score": 0.91, "start": 28, "end": 37},
45
- {"text": "California", "label": "location", "score": 0.95, "start": 39, "end": 49}
46
- ]
 
47
  }
48
  ```
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  ## Environment Variables
51
 
52
- | Variable | Default | Description |
53
- |-------------------|-------------------------------|------------------------------------|
54
- | `MODEL_NAME` | `knowledgator/gliner-multitask-large-v0.5` | GLiNER model name |
55
- | `PORT` | `7860` | Listen port (fixed by HF Spaces) |
56
- | `MODEL_CACHE_DIR` | `/app/model_cache` | Model cache path |
57
- | `HF_ENDPOINT` | *(huggingface.co)* | Override with a custom mirror URL |
58
 
59
  ## Interactive Docs
60
 
 
10
 
11
  # NER Server
12
 
13
+ Zero-shot Named Entity Recognition HTTP API powered by [GLiNER](https://github.com/urchade/GLiNER).
14
+ Supports **English ยท Chinese ยท Arabic ยท mixed-language** text.
15
+
16
+ ## Quick Start
17
+
18
+ ```bash
19
+ # ๆœ€็ฎ€่ฐƒ็”จ๏ผšไธไผ  labels๏ผŒ่‡ชๅŠจไฝฟ็”จๅ†…็ฝฎๅŒ่ฏญๆ ‡็ญพ้›†
20
+ curl -X POST https://robinwu-nerserver.hf.space/api/v1/extract \
21
+ -H "Content-Type: application/json" \
22
+ -d '{"text": "้ฉฌไบ‘ๅˆ›็ซ‹ไบ†้˜ฟ้‡Œๅทดๅทด๏ผŒๆ€ป้ƒจๅœจๆญๅทžใ€‚"}'
23
+
24
+ # ่‡ชๅฎšไน‰ๆ ‡็ญพ๏ผˆ่‡ชๅŠจ่กฅๅ……ๅŒ่ฏญๅฏน็ญ‰ๆ ‡็ญพๆๅ‡ๅฌๅ›ž๏ผ‰
25
+ curl -X POST https://robinwu-nerserver.hf.space/api/v1/extract \
26
+ -H "Content-Type: application/json" \
27
+ -d '{"text": "Elon Musk founded SpaceX.", "labels": ["full name of a person", "company or organization name"]}'
28
+ ```
29
 
30
  ## API
31
 
32
+ Base URL: `https://robinwu-nerserver.hf.space`
33
 
34
  ### `GET /api/v1/health`
35
 
 
41
 
42
  **Request**
43
 
44
+ | Field | Type | Required | Default | Description |
45
+ |---|---|---|---|---|
46
+ | `text` | string | โœ“ | โ€” | Input text |
47
+ | `labels` | string[] | โ€” | `[]` | Entity type labels. **Empty = use built-in bilingual defaults.** Bilingual pairs are auto-expanded. |
48
+ | `threshold` | float [0,1] | โ€” | `0.4` | Minimum confidence. Lower โ†’ more entities; higher โ†’ more precise. |
49
+ | `language` | `auto`/`en`/`zh`/`ar`/`mixed` | โ€” | `auto` | Language hint (auto-detected when omitted). |
50
+
51
+ **Example โ€” labels omitted (auto bilingual defaults)**
52
+
53
+ ```json
54
+ {"text": "ๅผ ไผŸๅŠ ๅ…ฅไบ† Google ๅŒ—ไบฌ็ ”ๅ‘ไธญๅฟƒ๏ผŒ่ดŸ่ดฃ Android ็ณป็ปŸไผ˜ๅŒ–ใ€‚"}
55
+ ```
56
+
57
  ```json
58
  {
59
+ "entities": [
60
+ {"text": "ๅผ ไผŸ", "label": "ไบบๅๆˆ–ๅง“ๅ", "score": 0.91, "start": 0, "end": 2},
61
+ {"text": "Google", "label": "company or organization name", "score": 0.95, "start": 6, "end": 12},
62
+ {"text": "Android", "label": "product or technology name", "score": 0.88, "start": 20, "end": 27}
63
+ ],
64
+ "labels_used": ["full name of a person", "ไบบๅๆˆ–ๅง“ๅ", "company or organization name", "..."]
65
  }
66
  ```
67
 
68
+ **Example โ€” custom labels (bilingual auto-expansion)**
69
+
70
+ ```json
71
+ {
72
+ "text": "Elon Musk founded SpaceX in Hawthorne, California.",
73
+ "labels": ["full name of a person", "company or organization name", "geographical location"],
74
+ "threshold": 0.5
75
+ }
76
+ ```
77
 
78
  ```json
79
  {
80
  "entities": [
81
+ {"text": "Elon Musk", "label": "full name of a person", "score": 0.98, "start": 0, "end": 9 },
82
+ {"text": "SpaceX", "label": "company or organization name", "score": 0.97, "start": 18, "end": 24},
83
+ {"text": "Hawthorne", "label": "geographical location", "score": 0.91, "start": 28, "end": 37},
84
+ {"text": "California", "label": "geographical location", "score": 0.95, "start": 39, "end": 49}
85
+ ],
86
+ "labels_used": ["full name of a person", "ไบบๅๆˆ–ๅง“ๅ", "company or organization name", "..."]
87
  }
88
  ```
89
 
90
+ ## Built-in Bilingual Label Pairs
91
+
92
+ When `labels` is empty, the following label pairs are used automatically:
93
+
94
+ | English | ไธญๆ–‡ |
95
+ |---|---|
96
+ | full name of a person | ไบบๅๆˆ–ๅง“ๅ |
97
+ | company or organization name | ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ |
98
+ | geographical location | ๅœฐๅๆˆ–ๅŸŽๅธ‚ |
99
+ | product or technology name | ไบงๅ“ๆˆ–ๆŠ€ๆœฏๅ็งฐ |
100
+ | date or year | ๆ—ฅๆœŸๆˆ–ๅนดไปฝ |
101
+ | hospital or medical institution | ๅŒป้™ขๆˆ–ๅŒป็–—ๆœบๆž„ๅ็งฐ |
102
+ | university or research institution | ๅคงๅญฆๆˆ–็ ”็ฉถๆœบๆž„ |
103
+ | project or initiative name | ้กน็›ฎๆˆ–่ฎกๅˆ’ๅ็งฐ |
104
+ | legislation or policy name | ๆณ•่ง„ๆˆ–ๆ”ฟ็ญ–ๅ็งฐ |
105
+ | monetary amount | ้‡‘้ขๆˆ–่ดงๅธ |
106
+ | job title or position | ่Œไฝๆˆ–ๅคด่ก” |
107
+ | event name | ไบ‹ไปถๆˆ–ๆดปๅŠจๅ็งฐ |
108
+
109
+ When you provide custom labels, each label is automatically paired with its counterpart
110
+ (e.g. passing `"ไบบๅๆˆ–ๅง“ๅ"` also activates `"full name of a person"` internally).
111
+
112
  ## Environment Variables
113
 
114
+ | Variable | Default | Description |
115
+ |---|---|---|
116
+ | `MODEL_NAME` | `knowledgator/gliner-multitask-large-v0.5` | GLiNER model |
117
+ | `PORT` | `7860` | Listen port (fixed by HF Spaces) |
118
+ | `MODEL_CACHE_DIR` | `/app/model_cache` | Model cache path |
119
+ | `HF_ENDPOINT` | *(huggingface.co)* | Override with a mirror URL |
120
 
121
  ## Interactive Docs
122
 
app/labels.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ๅŒ่ฏญๆ ‡็ญพ็ฎก็†ๆจกๅ—
3
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
4
+ * DEFAULT_LABELS โ€” ๅ†…็ฝฎ้€š็”จๅŒ่ฏญๆ ‡็ญพ้›†๏ผˆlabels ไธบ็ฉบๆ—ถไฝฟ็”จ๏ผ‰
5
+ * expand_bilingual โ€” ่‡ชๅŠจไธบๅทฒๆœ‰ๆ ‡็ญพ่กฅๅ……ๅฏน็ญ‰็š„ๅฆไธ€่ฏญ่จ€็‰ˆๆœฌ
6
+ """
7
+
8
+ # ่‹ฑๆ–‡ โ†” ไธญๆ–‡ ๅฏน็…ง่กจ๏ผˆ้กบๅบๅ†ณๅฎš่พ“ๅ‡บๆ ‡็ญพ็š„่ฏญ่จ€ๅๅฅฝ๏ผ‰
9
+ _PAIRS: list[tuple[str, str]] = [
10
+ ("full name of a person", "ไบบๅๆˆ–ๅง“ๅ"),
11
+ ("company or organization name", "ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ"),
12
+ ("geographical location", "ๅœฐๅๆˆ–ๅŸŽๅธ‚"),
13
+ ("product or technology name", "ไบงๅ“ๆˆ–ๆŠ€ๆœฏๅ็งฐ"),
14
+ ("date or year", "ๆ—ฅๆœŸๆˆ–ๅนดไปฝ"),
15
+ ("hospital or medical institution", "ๅŒป้™ขๆˆ–ๅŒป็–—ๆœบๆž„ๅ็งฐ"),
16
+ ("university or research institution","ๅคงๅญฆๆˆ–็ ”็ฉถๆœบๆž„"),
17
+ ("project or initiative name", "้กน็›ฎๆˆ–่ฎกๅˆ’ๅ็งฐ"),
18
+ ("legislation or policy name", "ๆณ•่ง„ๆˆ–ๆ”ฟ็ญ–ๅ็งฐ"),
19
+ ("monetary amount", "้‡‘้ขๆˆ–่ดงๅธ"),
20
+ ("job title or position", "่Œไฝๆˆ–ๅคด่ก”"),
21
+ ("event name", "ไบ‹ไปถๆˆ–ๆดปๅŠจๅ็งฐ"),
22
+ ]
23
+
24
+ # ้ป˜่ฎคๆ ‡็ญพ้›†๏ผš่‹ฑไธญๅนถๅˆ—๏ผŒๆถต็›–ๆœ€ๅธธ็”จๅฎžไฝ“็ฑปๅž‹
25
+ DEFAULT_LABELS: list[str] = [item for pair in _PAIRS for item in pair]
26
+
27
+ # ๅฟซ้€ŸๆŸฅๆ‰พ๏ผšไปปๆ„ไธ€็ง่ฏญ่จ€็š„ๆ ‡็ญพ โ†’ ๅฏน็ญ‰ๆ ‡็ญพ
28
+ _EN_TO_ZH: dict[str, str] = {en: zh for en, zh in _PAIRS}
29
+ _ZH_TO_EN: dict[str, str] = {zh: en for en, zh in _PAIRS}
30
+
31
+
32
+ def expand_bilingual(labels: list[str]) -> list[str]:
33
+ """
34
+ ไธบ่ฐƒ็”จ่€…ไผ ๅ…ฅ็š„ๆ ‡็ญพ่‡ชๅŠจ่กฅๅ……ๅฆไธ€่ฏญ่จ€็š„ๅฏน็ญ‰ๆ่ฟฐใ€‚
35
+
36
+ ไพ‹ๅฆ‚๏ผš
37
+ ["ไบบๅๆˆ–ๅง“ๅ", "company or organization name"]
38
+ โ†’ ["ไบบๅๆˆ–ๅง“ๅ", "full name of a person",
39
+ "company or organization name", "ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ"]
40
+
41
+ ่ง„ๅˆ™๏ผš
42
+ * ๅทฒๆœ‰ๆ ‡็ญพไฟๆŒๅŽŸไฝไธๅ˜
43
+ * ๅฏน็ญ‰ๆ ‡็ญพ็ดง้šๅ…ถๅŽๆ’ๅ…ฅ๏ผˆ่‹ฅๅทฒๅญ˜ๅœจๅˆ™่ทณ่ฟ‡๏ผ‰
44
+ * ๆœชๅœจๅฏน็…ง่กจไธญ็š„่‡ชๅฎšไน‰ๆ ‡็ญพๅŽŸๆ ทไฟ็•™๏ผŒไธๅšๅค„็†
45
+ """
46
+ seen: set[str] = set(labels)
47
+ result: list[str] = []
48
+ for lbl in labels:
49
+ result.append(lbl)
50
+ counterpart = _EN_TO_ZH.get(lbl) or _ZH_TO_EN.get(lbl)
51
+ if counterpart and counterpart not in seen:
52
+ result.append(counterpart)
53
+ seen.add(counterpart)
54
+ return result
app/main.py CHANGED
@@ -22,29 +22,45 @@ async def lifespan(app: FastAPI):
22
  ner_service = None
23
 
24
 
25
- app = FastAPI(title="NER API", lifespan=lifespan)
 
 
 
 
 
 
 
 
 
26
 
27
 
28
- @app.get("/api/v1/health")
29
  def health():
30
  return {"status": "ok"}
31
 
32
 
33
- @app.post("/api/v1/extract", response_model=ExtractResponse)
34
  def extract(req: ExtractRequest):
35
  logger.info(
36
- "extract request | text_len=%d labels=%s threshold=%s",
37
  len(req.text),
38
- req.labels,
39
  req.threshold,
 
40
  )
41
  t0 = time.perf_counter()
42
- entities = ner_service.extract(req.text, req.labels, req.threshold)
 
 
 
 
 
43
  elapsed_ms = (time.perf_counter() - t0) * 1000
44
 
45
  logger.info(
46
- "extract response | entities=%s elapsed=%.1fms",
47
- [{"text": e.text, "label": e.label, "score": e.score} for e in entities],
48
  elapsed_ms,
 
49
  )
50
- return ExtractResponse(entities=entities)
 
22
  ner_service = None
23
 
24
 
25
+ app = FastAPI(
26
+ title="NER API",
27
+ description=(
28
+ "Zero-shot Named Entity Recognition powered by GLiNER. "
29
+ "Supports English, Chinese, Arabic and mixed-language text. "
30
+ "Labels are optional โ€” omit them to use built-in bilingual defaults."
31
+ ),
32
+ version="2.0.0",
33
+ lifespan=lifespan,
34
+ )
35
 
36
 
37
+ @app.get("/api/v1/health", tags=["System"])
38
  def health():
39
  return {"status": "ok"}
40
 
41
 
42
+ @app.post("/api/v1/extract", response_model=ExtractResponse, tags=["NER"])
43
  def extract(req: ExtractRequest):
44
  logger.info(
45
+ "extract request | text_len=%d labels=%s threshold=%s language=%s",
46
  len(req.text),
47
+ req.labels or "(default)",
48
  req.threshold,
49
+ req.language,
50
  )
51
  t0 = time.perf_counter()
52
+ entities, labels_used = ner_service.extract(
53
+ req.text,
54
+ req.labels,
55
+ req.threshold,
56
+ language=req.language,
57
+ )
58
  elapsed_ms = (time.perf_counter() - t0) * 1000
59
 
60
  logger.info(
61
+ "extract response | entities=%d elapsed=%.1fms labels_used=%d",
62
+ len(entities),
63
  elapsed_ms,
64
+ len(labels_used),
65
  )
66
+ return ExtractResponse(entities=entities, labels_used=labels_used)
app/models.py CHANGED
@@ -1,10 +1,39 @@
 
 
1
  from pydantic import BaseModel, Field
2
 
3
 
4
  class ExtractRequest(BaseModel):
5
  text: str
6
- labels: list[str]
7
- threshold: float = Field(default=0.6, ge=0.0, le=1.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
 
10
  class Entity(BaseModel):
@@ -17,3 +46,5 @@ class Entity(BaseModel):
17
 
18
  class ExtractResponse(BaseModel):
19
  entities: list[Entity]
 
 
 
1
+ from typing import Literal
2
+
3
  from pydantic import BaseModel, Field
4
 
5
 
6
  class ExtractRequest(BaseModel):
7
  text: str
8
+
9
+ # labels ๅฏ้€‰๏ผš็ฉบๅˆ—่กจ โ†’ ๆœๅŠก็ซฏ่‡ชๅŠจไฝฟ็”จๅ†…็ฝฎๅŒ่ฏญๆ ‡็ญพ้›†
10
+ labels: list[str] = Field(
11
+ default_factory=list,
12
+ description=(
13
+ "Entity type labels. Leave empty to use built-in bilingual defaults. "
14
+ "Bilingual pairs (e.g. 'ไบบๅๆˆ–ๅง“ๅ' + 'full name of a person') are "
15
+ "automatically expanded to improve recall on Chinese / mixed text."
16
+ ),
17
+ )
18
+
19
+ threshold: float = Field(
20
+ default=0.4,
21
+ ge=0.0,
22
+ le=1.0,
23
+ description=(
24
+ "Minimum confidence score. "
25
+ "Lower values yield more entities; higher values yield fewer but more precise ones. "
26
+ "Default 0.4 works well for multilingual text."
27
+ ),
28
+ )
29
+
30
+ language: Literal["auto", "en", "zh", "ar", "mixed"] = Field(
31
+ default="auto",
32
+ description=(
33
+ "Hint for language-aware processing. "
34
+ "'auto' detects from the text automatically."
35
+ ),
36
+ )
37
 
38
 
39
  class Entity(BaseModel):
 
46
 
47
  class ExtractResponse(BaseModel):
48
  entities: list[Entity]
49
+ # Echo back which labels were actually used (useful when labels=[] โ†’ defaults applied)
50
+ labels_used: list[str] = Field(default_factory=list)
app/ner.py CHANGED
@@ -1,17 +1,101 @@
 
 
1
  from gliner import GLiNER
2
 
 
3
  from app.models import Entity
4
 
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  class NERService:
7
  def __init__(self, model_name: str, cache_dir: str) -> None:
8
  self._model = GLiNER.from_pretrained(model_name, cache_dir=cache_dir)
9
 
10
- def extract(self, text: str, labels: list[str], threshold: float) -> list[Entity]:
11
- if not text or not labels:
12
- return []
13
- raw = self._model.predict_entities(text, labels, threshold=threshold)
14
- return [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  Entity(
16
  text=e["text"],
17
  label=e["label"],
@@ -21,3 +105,4 @@ class NERService:
21
  )
22
  for e in raw
23
  ]
 
 
1
+ import unicodedata
2
+
3
  from gliner import GLiNER
4
 
5
+ from app.labels import DEFAULT_LABELS, expand_bilingual
6
  from app.models import Entity
7
 
8
 
9
+ # โ”€โ”€ ่ฏญ่จ€ๆฃ€ๆต‹ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
10
+
11
+ def _detect_language(text: str) -> str:
12
+ """
13
+ ้€š่ฟ‡ Unicode ่„šๆœฌๆฏ”ไพ‹ๅˆคๆ–ญๆ–‡ๆœฌ่ฏญ่จ€ใ€‚
14
+ ่ฟ”ๅ›ž: 'zh' | 'ar' | 'mixed' | 'en'
15
+ """
16
+ if not text:
17
+ return "en"
18
+
19
+ cjk = arabic = letters = 0
20
+ for ch in text:
21
+ cat = unicodedata.category(ch)
22
+ if cat.startswith("L"):
23
+ letters += 1
24
+ cp = ord(ch)
25
+ if (0x4E00 <= cp <= 0x9FFF or # CJK Unified
26
+ 0x3400 <= cp <= 0x4DBF or
27
+ 0xF900 <= cp <= 0xFAFF or
28
+ 0x20000 <= cp <= 0x2A6DF):
29
+ cjk += 1
30
+ elif 0x0600 <= cp <= 0x06FF or 0x0750 <= cp <= 0x077F:
31
+ arabic += 1
32
+
33
+ if not letters:
34
+ return "en"
35
+ cjk_r = cjk / letters
36
+ ar_r = arabic / letters
37
+ if cjk_r >= 0.20 and ar_r < 0.08:
38
+ return "zh"
39
+ if ar_r >= 0.20 and cjk_r < 0.08:
40
+ return "ar"
41
+ if cjk_r >= 0.08 or ar_r >= 0.08:
42
+ return "mixed"
43
+ return "en"
44
+
45
+
46
+ # โ”€โ”€ Span ๅŽป้‡ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
47
+
48
+ def _deduplicate(entities: list[Entity]) -> list[Entity]:
49
+ """
50
+ ๅŒ่ฏญๆ ‡็ญพๅฏ่ƒฝๅฏนๅŒไธ€ (start, end) ่ทจๅบฆไบง็”Ÿไธคๆก็ป“ๆžœ๏ผŒ
51
+ ไฟ็•™็ฝฎไฟกๅบฆๆœ€้ซ˜็š„้‚ฃๆก๏ผŒๅนถๆŒ‰ไฝ็ฝฎๆŽ’ๅบใ€‚
52
+ """
53
+ best: dict[tuple[int, int], Entity] = {}
54
+ for e in entities:
55
+ key = (e.start, e.end)
56
+ if key not in best or e.score > best[key].score:
57
+ best[key] = e
58
+ return sorted(best.values(), key=lambda x: x.start)
59
+
60
+
61
+ # โ”€โ”€ NER ๆœๅŠก โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
62
+
63
  class NERService:
64
  def __init__(self, model_name: str, cache_dir: str) -> None:
65
  self._model = GLiNER.from_pretrained(model_name, cache_dir=cache_dir)
66
 
67
+ def extract(
68
+ self,
69
+ text: str,
70
+ labels: list[str],
71
+ threshold: float,
72
+ language: str = "auto",
73
+ ) -> tuple[list[Entity], list[str]]:
74
+ """
75
+ ่ฟ”ๅ›ž (entities, labels_used)ใ€‚
76
+
77
+ labels ๅค„็†้€ป่พ‘๏ผš
78
+ 1. labels ไธบ็ฉบ โ†’ ไฝฟ็”จๅ†…็ฝฎๅŒ่ฏญ้ป˜่ฎคๆ ‡็ญพ้›†
79
+ 2. labels ้ž็ฉบ โ†’ ่‡ชๅŠจ่กฅๅ……ๅŒ่ฏญๅฏน็ญ‰ๆ ‡็ญพ๏ผˆๆๅ‡ไธญๆ–‡ๅฌๅ›ž๏ผ‰
80
+
81
+ threshold ๅค„็†้€ป่พ‘๏ผš
82
+ - language='auto' ๆ—ถ่‡ชๅŠจๆฃ€ๆต‹
83
+ - ไธญๆ–‡ / ๆททๅˆๆ–‡ๆœฌ่‹ฅไผ ๅ…ฅ้ป˜่ฎค threshold(0.4) ๅˆ™ไธ่ฐƒๆ•ด๏ผˆๅทฒ่ถณๅคŸไฝŽ๏ผ‰
84
+ """
85
+ if not text:
86
+ return [], labels
87
+
88
+ # ็กฎๅฎšๆœ‰ๆ•ˆ่ฏญ่จ€
89
+ eff_lang = language if language != "auto" else _detect_language(text)
90
+
91
+ # ็กฎๅฎšๆ ‡็ญพ้›†
92
+ if not labels:
93
+ eff_labels = DEFAULT_LABELS
94
+ else:
95
+ eff_labels = expand_bilingual(labels)
96
+
97
+ raw = self._model.predict_entities(text, eff_labels, threshold=threshold)
98
+ entities = [
99
  Entity(
100
  text=e["text"],
101
  label=e["label"],
 
105
  )
106
  for e in raw
107
  ]
108
+ return _deduplicate(entities), eff_labels
reports/comparison_report.md ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NER ๆจกๅž‹ๅฏนๆฏ”ๆต‹่ฏ•ๆŠฅๅ‘Š
2
+
3
+ ็”Ÿๆˆๆ—ถ้—ด๏ผš2026-04-30 08:12:29
4
+ ้˜ˆๅ€ผ๏ผˆthreshold๏ผ‰๏ผš`0.4`
5
+
6
+ ## ไธ€ใ€ๆฑ‡ๆ€ปๅฏนๆฏ”
7
+
8
+ | ๆต‹่ฏ•็”จไพ‹ | ่ฏญ่จ€ | gliner_multi-v2.1 ๅฌๅ›ž | gliner_multi-v2.1 ่€—ๆ—ถ | gliner-multitask-large-v0.5 ๅฌๅ›ž | gliner-multitask-large-v0.5 ่€—ๆ—ถ |
9
+ |---|---|---|---|---|---|
10
+ | EN-01 ่‹ฑๆ–‡ ยท ็ง‘ๆŠ€ไบบ็‰ฉ | `en` | 7/7 (100%) | 851ms | 7/7 (100%) | 1478ms |
11
+ | EN-02 ่‹ฑๆ–‡ ยท ๆ”ฟๆฒปๆ–ฐ้—ป | `en` | 3/5 (60%) | 424ms | 5/5 (100%) | 1804ms |
12
+ | ZH-01 ไธญๆ–‡ ยท ็Žฐไปฃๅ•†ไธš๏ผˆๅŒ่ฏญๆ ‡็ญพ๏ผ‰ | `zh` | 0/8 (0%) | 485ms | 0/8 (0%) | 3195ms |
13
+ | ZH-02 ไธญๆ–‡ ยท ๅคๅ…ธๆ–‡ๅญฆ๏ผˆ่พน็•Œๆต‹่ฏ•๏ผ‰ | `zh` | 0/8 (0%) | 738ms | 0/8 (0%) | 1937ms |
14
+ | ZH-03 ไธญๆ–‡ ยท ๅŒป็–—ๅœบๆ™ฏ๏ผˆๅŒ่ฏญๆ ‡็ญพ๏ผ‰ | `zh` | 0/5 (0%) | 774ms | 0/5 (0%) | 2137ms |
15
+ | AR-01 ้˜ฟๆ‹‰ไผฏ่ฏญ ยท ๆ–ฐ้—ป | `ar` | 2/4 (50%) | 423ms | 2/4 (50%) | 1944ms |
16
+ | MIX-01 ไธญ่‹ฑๆททๅˆ ยท ่Œๅœบๅœบๆ™ฏ๏ผˆๅŒ่ฏญๆ ‡็ญพ๏ผ‰ | `mixed` | 4/7 (57%) | 582ms | 4/7 (57%) | 2436ms |
17
+ | MIX-02 ไธญ่‹ฑๆททๅˆ ยท ๅญฆๆœฏๅœบๆ™ฏ๏ผˆๅŒ่ฏญๆ ‡็ญพ๏ผ‰ | `mixed` | 2/6 (33%) | 698ms | 2/6 (33%) | 2396ms |
18
+ | **ๅนณๅ‡** | โ€” | **38%** | **622ms** | **43%** | **2166ms** |
19
+
20
+ ## ไบŒใ€ๆจกๅž‹ๅŠ ่ฝฝๆ—ถ้—ด
21
+
22
+ | ๆจกๅž‹ | ๅŠ ่ฝฝ่€—ๆ—ถ |
23
+ |---|---|
24
+ | gliner_multi-v2.1 | 23.4s |
25
+ | gliner-multitask-large-v0.5 | 10.9s |
26
+
27
+ ## ไธ‰ใ€้€็”จไพ‹่ฏฆ็ป†็ป“ๆžœ
28
+
29
+ ### EN-01 ่‹ฑๆ–‡ ยท ็ง‘ๆŠ€ไบบ็‰ฉ
30
+
31
+ **ๆ–‡ๆœฌ**
32
+ ```
33
+ Elon Musk, CEO of Tesla and founder of SpaceX, announced a new Starship launch from Boca Chica, Texas. NASA has partnered with SpaceX for the Artemis lunar lander mission planned for 2026.
34
+ ```
35
+
36
+ **ๆœŸๆœ›ๅฎžไฝ“**๏ผš`Elon Musk`, `Tesla`, `SpaceX`, `NASA`, `Boca Chica`, `Texas`, `2026`
37
+
38
+ #### gliner_multi-v2.1 ๏ผˆ851ms๏ผŒ8 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 100%๏ผ‰
39
+
40
+ | ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
41
+ |---|---|---|---|
42
+ | `Elon Musk` | full name of a person | 0.94 | โœ“ |
43
+ | `Tesla` | company or organization name | 0.77 | โœ“ |
44
+ | `SpaceX` | company or organization name | 0.94 | โœ“ |
45
+ | `Boca Chica` | geographical location | 0.89 | โœ“ |
46
+ | `Texas` | geographical location | 0.78 | โœ“ |
47
+ | `NASA` | company or organization name | 0.79 | โœ“ |
48
+ | `SpaceX` | company or organization name | 0.93 | โœ“ |
49
+ | `2026` | date or year | 0.93 | โœ“ |
50
+
51
+ #### gliner-multitask-large-v0.5 ๏ผˆ1478ms๏ผŒ10 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 100%๏ผ‰
52
+
53
+ | ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
54
+ |---|---|---|---|
55
+ | `Elon Musk` | full name of a person | 0.99 | โœ“ |
56
+ | `Tesla` | company or organization name | 1.00 | โœ“ |
57
+ | `SpaceX` | company or organization name | 1.00 | โœ“ |
58
+ | `Starship` | product or technology name | 0.98 | |
59
+ | `Boca Chica` | geographical location | 0.99 | โœ“ |
60
+ | `Texas` | geographical location | 0.95 | โœ“ |
61
+ | `NASA` | company or organization name | 1.00 | โœ“ |
62
+ | `SpaceX` | company or organization name | 1.00 | โœ“ |
63
+ | `Artemis` | product or technology name | 0.75 | |
64
+ | `2026` | date or year | 0.99 | โœ“ |
65
+
66
+ ### EN-02 ่‹ฑๆ–‡ ยท ๆ”ฟๆฒปๆ–ฐ้—ป
67
+
68
+ **ๆ–‡ๆœฌ**
69
+ ```
70
+ President Biden signed the Inflation Reduction Act in Washington D.C. on August 16, 2022. The legislation was championed by Senator Chuck Schumer and was seen as a major win for the Democratic Party.
71
+ ```
72
+
73
+ **ๆœŸๆœ›ๅฎžไฝ“**๏ผš`Biden`, `Chuck Schumer`, `Washington D.C.`, `August 16, 2022`, `Democratic Party`
74
+
75
+ #### gliner_multi-v2.1 ๏ผˆ424ms๏ผŒ6 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 60%๏ผ‰
76
+
77
+ | ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
78
+ |---|---|---|---|
79
+ | `President Biden` | full name of a person | 0.61 | |
80
+ | `Inflation Reduction Act` | legislation or policy name | 0.88 | |
81
+ | `Washington D.C.` | geographical location | 0.68 | โœ“ |
82
+ | `August 16, 2022` | date or year | 0.96 | โœ“ |
83
+ | `Senator Chuck Schumer` | full name of a person | 0.56 | |
84
+ | `Democratic Party` | political party | 0.99 | โœ“ |
85
+
86
+ **ๆœชๅ‘ฝไธญ**๏ผš`Biden`, `Chuck Schumer`
87
+
88
+ #### gliner-multitask-large-v0.5 ๏ผˆ1804ms๏ผŒ7 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 100%๏ผ‰
89
+
90
+ | ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
91
+ |---|---|---|---|
92
+ | `Biden` | full name of a person | 0.64 | โœ“ |
93
+ | `Inflation Reduction Act` | legislation or policy name | 0.98 | |
94
+ | `Washington D.C.` | geographical location | 0.94 | โœ“ |
95
+ | `August 16, 2022` | date or year | 0.99 | โœ“ |
96
+ | `Senator` | company or organization name | 0.51 | |
97
+ | `Chuck Schumer` | full name of a person | 0.80 | โœ“ |
98
+ | `Democratic Party` | political party | 0.99 | โœ“ |
99
+
100
+ ### ZH-01 ไธญๆ–‡ ยท ็Žฐไปฃๅ•†ไธš๏ผˆๅŒ่ฏญๆ ‡็ญพ๏ผ‰
101
+
102
+ **ๆ–‡ๆœฌ**
103
+ ```
104
+ ้˜ฟ้‡Œๅทดๅทด้›†ๅ›ขๅˆ›ๅง‹ไบบ้ฉฌไบ‘ไบŽ2019ๅนดๅธไปป่‘ฃไบ‹ๅฑ€ไธปๅธญ๏ผŒ็”ฑๅผ ๅ‹‡ๆŽฅไปปใ€‚ๆ€ป้ƒจไฝไบŽๆญๅทž็š„้˜ฟ้‡Œๅทดๅทดๆ——ไธ‹ๆ‹ฅๆœ‰ๆท˜ๅฎใ€ๅคฉ็Œซใ€ๆ”ฏไป˜ๅฎ็ญ‰ไธšๅŠกๆฟๅ—ใ€‚
105
+ ```
106
+
107
+ **ๆœŸๆœ›ๅฎžไฝ“**๏ผš`้ฉฌไบ‘`, `ๅผ ๅ‹‡`, `้˜ฟ้‡Œๅทดๅทด`, `ๆญๅทž`, `ๆท˜ๅฎ`, `ๅคฉ็Œซ`, `ๆ”ฏไป˜ๅฎ`, `2019`
108
+
109
+ #### gliner_multi-v2.1 ๏ผˆ485ms๏ผŒ2 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 0%๏ผ‰
110
+
111
+ | ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
112
+ |---|---|---|---|
113
+ | `้˜ฟ้‡Œๅทดๅทด้›†ๅ›ขๅˆ›ๅง‹ไบบ้ฉฌไบ‘ไบŽ2019ๅนดๅธไปป่‘ฃไบ‹ๅฑ€ไธปๅธญ` | ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ | 0.60 | |
114
+ | `ๆ”ฏไป˜ๅฎ็ญ‰ไธšๅŠกๆฟๅ—` | ไบงๅ“ๆˆ–ๅ“็‰Œๅ็งฐ | 0.58 | |
115
+
116
+ **ๆœชๅ‘ฝไธญ**๏ผš`้ฉฌไบ‘`, `ๅผ ๅ‹‡`, `้˜ฟ้‡Œๅทดๅทด`, `ๆญๅทž`, `ๆท˜ๅฎ`, `ๅคฉ็Œซ`, `ๆ”ฏไป˜ๅฎ`, `2019`
117
+
118
+ #### gliner-multitask-large-v0.5 ๏ผˆ3195ms๏ผŒ0 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 0%๏ผ‰
119
+
120
+ _ๆœช่ฏ†ๅˆซๅˆฐๅฎžไฝ“_
121
+
122
+ **ๆœชๅ‘ฝไธญ**๏ผš`้ฉฌไบ‘`, `ๅผ ๅ‹‡`, `้˜ฟ้‡Œๅทดๅทด`, `ๆญๅทž`, `ๆท˜ๅฎ`, `ๅคฉ็Œซ`, `ๆ”ฏไป˜ๅฎ`, `2019`
123
+
124
+ ### ZH-02 ไธญๆ–‡ ยท ๅคๅ…ธๆ–‡ๅญฆ๏ผˆ่พน็•Œๆต‹่ฏ•๏ผ‰
125
+
126
+ **ๆ–‡ๆœฌ**
127
+ ```
128
+ ๅฐคๆฐๆฅ่ฏท๏ผŒ็Ž‹็†™ๅ‡ค็ฌ‘้“๏ผš'ไฝ ๆฅไบ†ใ€‚'่ดพๆฏๅ‘ฝไบบๆ‘†้…’๏ผŒๅฎ็މๅ’Œ้ป›็މๅœจๅคง่ง‚ๅ›ญๆ•ฃๆญฅ๏ผŒ่–›ๅฎ้’—็‹ฌๅๆขจ้ฆ™้™ขใ€‚
129
+ ```
130
+
131
+ **ๆœŸๆœ›ๅฎžไฝ“**๏ผš`ๅฐคๆฐ`, `็Ž‹็†™ๅ‡ค`, `่ดพๆฏ`, `ๅฎ็މ`, `้ป›็މ`, `่–›ๅฎ้’—`, `ๅคง่ง‚ๅ›ญ`, `ๆขจ้ฆ™้™ข`
132
+
133
+ #### gliner_multi-v2.1 ๏ผˆ738ms๏ผŒ0 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 0%๏ผ‰
134
+
135
+ _ๆœช่ฏ†ๅˆซๅˆฐๅฎžไฝ“_
136
+
137
+ **ๆœชๅ‘ฝไธญ**๏ผš`ๅฐคๆฐ`, `็Ž‹็†™ๅ‡ค`, `่ดพๆฏ`, `ๅฎ็މ`, `้ป›็މ`, `่–›ๅฎ้’—`, `ๅคง่ง‚ๅ›ญ`, `ๆขจ้ฆ™้™ข`
138
+
139
+ #### gliner-multitask-large-v0.5 ๏ผˆ1937ms๏ผŒ2 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 0%๏ผ‰
140
+
141
+ | ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
142
+ |---|---|---|---|
143
+ | `็Ž‹็†™ๅ‡ค็ฌ‘้“` | ไบบๅๆˆ–ๅง“ๅ | 0.64 | |
144
+ | `่ดพๆฏๅ‘ฝไบบๆ‘†้…’` | ไบบๅๆˆ–ๅง“ๅ | 0.42 | |
145
+
146
+ **ๆœชๅ‘ฝไธญ**๏ผš`ๅฐคๆฐ`, `็Ž‹็†™ๅ‡ค`, `่ดพๆฏ`, `ๅฎ็މ`, `้ป›็މ`, `่–›ๅฎ้’—`, `ๅคง่ง‚ๅ›ญ`, `ๆขจ้ฆ™้™ข`
147
+
148
+ > โš ๏ธ **่พน็•Œ้”™่ฏฏ**๏ผš['็Ž‹็†™ๅ‡ค็ฌ‘้“']
149
+
150
+ ### ZH-03 ไธญๆ–‡ ยท ๅŒป็–—ๅœบๆ™ฏ๏ผˆๅŒ่ฏญๆ ‡็ญพ๏ผ‰
151
+
152
+ **ๆ–‡ๆœฌ**
153
+ ```
154
+ ๅŒ—ไบฌๅๅ’ŒๅŒป้™ขๅฟƒๅ†…็ง‘ไธปไปป็Ž‹ๅปบๅ›ฝๆ•™ๆŽˆๅ›ข้˜Ÿ๏ผŒไบŽ2023ๅนดๆˆๅŠŸๅฎŒๆˆ้ฆ–ไพ‹ๆœบๅ™จไบบ่พ…ๅŠฉๅ† ็ŠถๅŠจ่„‰ๆญๆกฅๆ‰‹ๆœฏ๏ผŒๆ‚ฃ่€…ๆฅ่‡ชๅฑฑไธœ็œๆตŽๅ—ๅธ‚ใ€‚
155
+ ```
156
+
157
+ **ๆœŸๆœ›ๅฎžไฝ“**๏ผš`็Ž‹ๅปบๅ›ฝ`, `ๅŒ—ไบฌๅๅ’ŒๅŒป้™ข`, `ๆตŽๅ—`, `ๅฑฑไธœ`, `2023`
158
+
159
+ #### gliner_multi-v2.1 ๏ผˆ774ms๏ผŒ1 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 0%๏ผ‰
160
+
161
+ | ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
162
+ |---|---|---|---|
163
+ | `ๅŒ—ไบฌๅๅ’ŒๅŒป้™ขๅฟƒๅ†…็ง‘ไธปไปป็Ž‹ๅปบๅ›ฝๆ•™ๆŽˆๅ›ข้˜Ÿ` | hospital or institution name | 0.65 | |
164
+
165
+ **ๆœชๅ‘ฝไธญ**๏ผš`็Ž‹ๅปบๅ›ฝ`, `ๅŒ—ไบฌๅๅ’ŒๅŒป้™ข`, `ๆตŽๅ—`, `ๅฑฑไธœ`, `2023`
166
+
167
+ #### gliner-multitask-large-v0.5 ๏ผˆ2137ms๏ผŒ0 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 0%๏ผ‰
168
+
169
+ _ๆœช่ฏ†ๅˆซๅˆฐๅฎžไฝ“_
170
+
171
+ **ๆœชๅ‘ฝไธญ**๏ผš`็Ž‹ๅปบๅ›ฝ`, `ๅŒ—ไบฌๅๅ’ŒๅŒป้™ข`, `ๆตŽๅ—`, `ๅฑฑไธœ`, `2023`
172
+
173
+ ### AR-01 ้˜ฟๆ‹‰ไผฏ่ฏญ ยท ๆ–ฐ้—ป
174
+
175
+ **ๆ–‡ๆœฌ**
176
+ ```
177
+ ุฃุนู„ู† ุงู„ุฑุฆูŠุณ ู…ุญู…ุฏ ุจู† ุณู„ู…ุงู† ุนู† ุฅุทู„ุงู‚ ู…ุดุฑูˆุน ู†ูŠูˆู… ููŠ ุงู„ู…ู…ู„ูƒุฉ ุงู„ุนุฑุจูŠุฉ ุงู„ุณุนูˆุฏูŠุฉ ุนุงู… 2017ุŒ ูˆุชุจู„ุบ ุชูƒู„ูุชู‡ 500 ู…ู„ูŠุงุฑ ุฏูˆู„ุงุฑ.
178
+ ```
179
+
180
+ **ๆœŸๆœ›ๅฎžไฝ“**๏ผš`ู…ุญู…ุฏ ุจู† ุณู„ู…ุงู†`, `ู†ูŠูˆู…`, `ุงู„ู…ู…ู„ูƒุฉ ุงู„ุนุฑุจูŠุฉ ุงู„ุณุนูˆุฏูŠุฉ`, `2017`
181
+
182
+ #### gliner_multi-v2.1 ๏ผˆ423ms๏ผŒ5 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 50%๏ผ‰
183
+
184
+ | ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
185
+ |---|---|---|---|
186
+ | `ู…ุญู…ุฏ ุจู† ุณู„ู…ุงู†` | full name of a person | 0.82 | โœ“ |
187
+ | `ู…ุดุฑูˆุน ู†ูŠูˆู…` | project or initiative name | 0.72 | |
188
+ | `ุงู„ู…ู…ู„ูƒุฉ ุงู„ุนุฑุจูŠุฉ ุงู„ุณุนูˆุฏูŠุฉ` | geographical location | 0.85 | โœ“ |
189
+ | `ุนุงู… 2017` | date or year | 0.91 | |
190
+ | `500 ู…ู„ูŠุงุฑ ุฏูˆู„ุงุฑ` | monetary amount | 0.94 | |
191
+
192
+ **ๆœชๅ‘ฝไธญ**๏ผš`ู†ูŠูˆู…`, `2017`
193
+
194
+ #### gliner-multitask-large-v0.5 ๏ผˆ1944ms๏ผŒ5 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 50%๏ผ‰
195
+
196
+ | ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
197
+ |---|---|---|---|
198
+ | `ู…ุญู…ุฏ ุจู† ุณู„ู…ุงู†` | full name of a person | 0.72 | โœ“ |
199
+ | `ู…ุดุฑูˆุน ู†ูŠูˆู…` | project or initiative name | 0.76 | |
200
+ | `ุงู„ู…ู…ู„ูƒุฉ ุงู„ุนุฑุจูŠุฉ ุงู„ุณุนูˆุฏูŠุฉ` | geographical location | 0.98 | โœ“ |
201
+ | `ุนุงู… 2017` | date or year | 0.99 | |
202
+ | `500 ู…ู„ูŠุงุฑ ุฏูˆู„ุงุฑ` | monetary amount | 0.97 | |
203
+
204
+ **ๆœชๅ‘ฝไธญ**๏ผš`ู†ูŠูˆู…`, `2017`
205
+
206
+ ### MIX-01 ไธญ่‹ฑๆททๅˆ ยท ่Œๅœบๅœบๆ™ฏ๏ผˆๅŒ่ฏญๆ ‡็ญพ๏ผ‰
207
+
208
+ **ๆ–‡ๆœฌ**
209
+ ```
210
+ ๅผ ไผŸๅŠ ๅ…ฅไบ† Google ๅŒ—ไบฌ็ ”ๅ‘ไธญๅฟƒ๏ผŒ่ดŸ่ดฃ Android ็ณป็ปŸไผ˜ๅŒ–ใ€‚ไป–็š„ๅŒไบ‹ Sarah Chen ๆฅ่‡ช Meta๏ผŒไธคไบบๅ…ฑๅŒๅ‚ไธŽไบ† 2024 ๅนด็š„ AI Summitใ€‚
211
+ ```
212
+
213
+ **ๆœŸๆœ›ๅฎžไฝ“**๏ผš`ๅผ ไผŸ`, `Google`, `Sarah Chen`, `Meta`, `Android`, `ๅŒ—ไบฌ`, `2024`
214
+
215
+ #### gliner_multi-v2.1 ๏ผˆ582ms๏ผŒ5 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 57%๏ผ‰
216
+
217
+ | ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
218
+ |---|---|---|---|
219
+ | `Google` | ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ | 0.68 | โœ“ |
220
+ | `ๅŒ—ไบฌ็ ”ๅ‘ไธญๅฟƒ` | ๅœฐๅๆˆ–ๅŸŽๅธ‚ | 0.49 | |
221
+ | `Sarah Chen` | ไบบๅๆˆ–ๅง“ๅ | 0.88 | โœ“ |
222
+ | `Meta` | ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ | 0.67 | โœ“ |
223
+ | `2024` | ๆ—ฅๆœŸๆˆ–ๅนดไปฝ | 0.41 | โœ“ |
224
+
225
+ **ๆœชๅ‘ฝไธญ**๏ผš`ๅผ ไผŸ`, `Android`, `ๅŒ—ไบฌ`
226
+
227
+ #### gliner-multitask-large-v0.5 ๏ผˆ2436ms๏ผŒ6 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 57%๏ผ‰
228
+
229
+ | ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
230
+ |---|---|---|---|
231
+ | `ๅผ ไผŸๅŠ ๅ…ฅไบ†` | ไบบๅๆˆ–ๅง“ๅ | 0.40 | |
232
+ | `Google` | company or organization name | 0.89 | โœ“ |
233
+ | `Android` | ไบงๅ“ๆˆ–ๆŠ€ๆœฏๅ็งฐ | 0.68 | โœ“ |
234
+ | `Sarah Chen` | ไบบๅๆˆ–ๅง“ๅ | 0.94 | โœ“ |
235
+ | `Meta` | company or organization name | 0.84 | โœ“ |
236
+ | `2024 ๅนด็š„` | date or year | 0.93 | |
237
+
238
+ **ๆœชๅ‘ฝไธญ**๏ผš`ๅผ ไผŸ`, `ๅŒ—ไบฌ`, `2024`
239
+
240
+ ### MIX-02 ไธญ่‹ฑๆททๅˆ ยท ๅญฆๆœฏๅœบๆ™ฏ๏ผˆๅŒ่ฏญๆ ‡็ญพ๏ผ‰
241
+
242
+ **ๆ–‡ๆœฌ**
243
+ ```
244
+ ๆธ…ๅŽๅคงๅญฆ่ฎก็ฎ—ๆœบ็ณปๆ•™ๆŽˆๆŽๆ˜Žๅœจ NeurIPS 2023 ๅ‘่กจไบ†ๅ…ณไบŽ Transformer ๆžถๆž„็š„่ฎบๆ–‡๏ผŒๅˆไฝœ่€…ๆฅ่‡ช MIT ๅ’Œ Stanford Universityใ€‚
245
+ ```
246
+
247
+ **ๆœŸๆœ›ๅฎžไฝ“**๏ผš`ๆŽๆ˜Ž`, `ๆธ…ๅŽๅคงๅญฆ`, `NeurIPS 2023`, `Transformer`, `MIT`, `Stanford University`
248
+
249
+ #### gliner_multi-v2.1 ๏ผˆ698ms๏ผŒ2 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 33%๏ผ‰
250
+
251
+ | ๆ–‡ๆœฌ | ๆ ‡๏ฟฝ๏ฟฝ๏ฟฝ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
252
+ |---|---|---|---|
253
+ | `MIT` | ๅคงๅญฆๆˆ–็ ”็ฉถๆœบๆž„ | 0.70 | โœ“ |
254
+ | `Stanford University` | ๅคงๅญฆๆˆ–็ ”็ฉถๆœบๆž„ | 0.85 | โœ“ |
255
+
256
+ **ๆœชๅ‘ฝไธญ**๏ผš`ๆŽๆ˜Ž`, `ๆธ…ๅŽๅคงๅญฆ`, `NeurIPS 2023`, `Transformer`
257
+
258
+ #### gliner-multitask-large-v0.5 ๏ผˆ2396ms๏ผŒ5 ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž 33%๏ผ‰
259
+
260
+ | ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |
261
+ |---|---|---|---|
262
+ | `NeurIPS` | conference or journal name | 0.90 | |
263
+ | `2023` | date or year | 0.91 | |
264
+ | `Transformer ๆžถๆž„็š„่ฎบๆ–‡` | technology or model name | 0.74 | |
265
+ | `MIT` | university or research institution | 0.93 | โœ“ |
266
+ | `Stanford University` | university or research institution | 0.93 | โœ“ |
267
+
268
+ **ๆœชๅ‘ฝไธญ**๏ผš`ๆŽๆ˜Ž`, `ๆธ…ๅŽๅคงๅญฆ`, `NeurIPS 2023`, `Transformer`
269
+
270
+ ## ๅ››ใ€็ป“่ฎบไธŽๅปบ่ฎฎ
271
+
272
+ - **็ปผๅˆๅฌๅ›žๆœ€้ซ˜**๏ผš`gliner-multitask-large-v0.5`๏ผˆๅนณๅ‡ๅฌๅ›ž 43%๏ผ‰
273
+ - **ๆŽจ็†ๆœ€ๅฟซ**๏ผš`gliner_multi-v2.1`๏ผˆๅนณๅ‡ 622ms/ๆฌก๏ผ‰
274
+
275
+ ### ไผ˜ๅŒ–ๅปบ่ฎฎ
276
+
277
+ 1. **ๅŒ่ฏญๆ ‡็ญพ็ญ–็•ฅ**๏ผšๅฏนไธญๆ–‡ๆˆ–ๆททๅˆๆ–‡ๆœฌ๏ผŒๅŒๆ—ถๆไพ›ไธญ่‹ฑๆ–‡ๆ ‡็ญพๆ่ฟฐ๏ผˆๅฆ‚ `"ไบบๅๆˆ–ๅง“ๅ"` + `"full name of a person"`๏ผ‰๏ผŒๅฏๆ˜พ่‘—ๆๅ‡ไธญๆ–‡ๅฎžไฝ“ๅฌๅ›ž็އใ€‚GLiNER ๆ˜ฏ้›ถๆ ทๆœฌๆจกๅž‹๏ผŒๆ ‡็ญพๆ่ฟฐ่ถŠๅ…ทไฝ“ใ€่ถŠๆŽฅ่ฟ‘่ฎญ็ปƒ่ฏญๆ–™็š„่กจ่พพๆ–นๅผ๏ผŒ่ฏ†ๅˆซๆ•ˆๆžœ่ถŠๅฅฝใ€‚
278
+ 2. **Span ๅŽป้‡**๏ผšไฝฟ็”จๅŒ่ฏญๆ ‡็ญพๆ—ถๅŒไธ€ๆ–‡ๆœฌ่ทจๅบฆๅฏ่ƒฝ่ขซๆ‰“ไธŠไธคไธชๆ ‡็ญพ๏ผŒๅปบ่ฎฎๅœจๆœๅŠกๅฑ‚ๆŒ‰ `(start, end)` ๅŽป้‡๏ผŒไฟ็•™ๅพ—ๅˆ†ๆœ€้ซ˜็š„็ป“ๆžœ๏ผˆๅทฒๅœจ `app/ner.py` ๅฎž็Žฐ๏ผ‰ใ€‚
279
+ 3. **้˜ˆๅ€ผ่ฐƒไผ˜**๏ผš่‹ฑๆ–‡ๅปบ่ฎฎ `threshold=0.5`๏ผŒไธญๆ–‡ๅปบ่ฎฎ `threshold=0.35~0.4`๏ผˆๆจกๅž‹ๅฏนไธญๆ–‡็ฝฎไฟกๅบฆๆ™ฎ้ๅไฝŽ๏ผ‰ใ€‚
280
+ 4. **ๅคๅ…ธ/ๆ–‡่จ€ๆ–‡**๏ผšไธคไธชๆจกๅž‹ๅฏนๆ–‡่จ€ๆ–‡ๆ”ฏๆŒๅ‡ๅผฑ๏ผŒๅปบ่ฎฎ็ป“ๅˆ่ง„ๅˆ™ๆˆ–ไธ“็”จๆจกๅž‹๏ผˆๅฆ‚ `BERT-CRF` ๅœจๅคๆฑ‰่ฏญ่ฏญๆ–™ไธŠๅพฎ่ฐƒ๏ผ‰ๅค„็†ๆญค็ฑปๆ–‡ๆœฌใ€‚
281
+ 5. **้˜ฟๆ‹‰ไผฏ่ฏญ**๏ผš`gliner-multitask-large-v0.5` ๅœจๅคš่ฏญ่จ€ไธŠ่ฎญ็ปƒ๏ผŒๅฏน้˜ฟๆ‹‰ไผฏ่ฏญๆœ‰ๅŸบ็ก€ๆ”ฏๆŒ๏ผ›`gliner_multi-v2.1` ้˜ฟๆ‹‰ไผฏ่ฏญๆ•ˆๆžœๆœ‰้™ใ€‚
scripts/compare_models.py ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ๅฏนๆฏ” gliner_multi-v2.1 ๅ’Œ gliner-multitask-large-v0.5 ไธคไธชๆจกๅž‹
3
+ ๅœจไธญๆ–‡ใ€่‹ฑๆ–‡ใ€้˜ฟๆ‹‰ไผฏๆ–‡ใ€ไธญ่‹ฑๆททๅˆๆ–‡ๆœฌไธŠ็š„ NER ๆ•ˆๆžœใ€‚
4
+
5
+ ไผ˜ๅŒ–็‚น๏ผš
6
+ - ๆ‰€ๆœ‰ๆต‹่ฏ•็”จไพ‹็ปŸไธ€ไฝฟ็”จๅŒ่ฏญๆ ‡็ญพ๏ผˆไธญ่‹ฑๅนถๅˆ—๏ผ‰๏ผŒๆๅ‡ไธญๆ–‡่ฏ†ๅˆซ็އ
7
+ - ็ป“ๆžœๅ†™ๅ…ฅ UTF-8 Markdown ๆŠฅๅ‘Š๏ผŒ้ฟๅ… Windows GBK ๆŽงๅˆถๅฐไนฑ็ 
8
+ - ๆ–ฐๅขž้˜ฟๆ‹‰ไผฏ่ฏญๆต‹่ฏ•็”จไพ‹
9
+ - ๆ–ฐๅขž span ๅŽป้‡๏ผšๅŒ่ฏญๆ ‡็ญพๅฏ่ƒฝไบง็”Ÿ้‡ๅค่ทจๅบฆ๏ผŒไฟ็•™ๅพ—ๅˆ†ๆœ€้ซ˜็š„
10
+
11
+ ็”จๆณ•๏ผš
12
+ python scripts/compare_models.py
13
+ ๆŠฅๅ‘Š๏ผš
14
+ reports/comparison_report.md
15
+ """
16
+ import io
17
+ import os
18
+ import sys
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE" # Windows OpenMP ๅ†ฒ็ช
24
+ os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Windows ็ฌฆๅท้“พๆŽฅ่ญฆๅ‘Š
25
+
26
+ from huggingface_hub import snapshot_download
27
+ from gliner import GLiNER
28
+
29
+
30
+ # โ”€โ”€ ๆต‹่ฏ•็”จไพ‹๏ผˆๅ…จ้ƒจไฝฟ็”จๅŒ่ฏญๆ ‡็ญพ๏ผ‰ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
31
+
32
+ CASES = [
33
+ # โ”€โ”€ ่‹ฑๆ–‡ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
34
+ {
35
+ "name": "EN-01 ่‹ฑๆ–‡ ยท ็ง‘ๆŠ€ไบบ็‰ฉ",
36
+ "lang": "en",
37
+ "text": (
38
+ "Elon Musk, CEO of Tesla and founder of SpaceX, announced a new "
39
+ "Starship launch from Boca Chica, Texas. NASA has partnered with "
40
+ "SpaceX for the Artemis lunar lander mission planned for 2026."
41
+ ),
42
+ "labels": [
43
+ "full name of a person",
44
+ "company or organization name",
45
+ "geographical location",
46
+ "product or technology name",
47
+ "date or year",
48
+ ],
49
+ "expected": ["Elon Musk", "Tesla", "SpaceX", "NASA", "Boca Chica", "Texas", "2026"],
50
+ },
51
+ {
52
+ "name": "EN-02 ่‹ฑๆ–‡ ยท ๆ”ฟๆฒปๆ–ฐ้—ป",
53
+ "lang": "en",
54
+ "text": (
55
+ "President Biden signed the Inflation Reduction Act in Washington D.C. "
56
+ "on August 16, 2022. The legislation was championed by Senator Chuck Schumer "
57
+ "and was seen as a major win for the Democratic Party."
58
+ ),
59
+ "labels": [
60
+ "full name of a person",
61
+ "company or organization name",
62
+ "geographical location",
63
+ "legislation or policy name",
64
+ "date or year",
65
+ "political party",
66
+ ],
67
+ "expected": ["Biden", "Chuck Schumer", "Washington D.C.", "August 16, 2022", "Democratic Party"],
68
+ },
69
+ # โ”€โ”€ ไธญๆ–‡ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
70
+ {
71
+ "name": "ZH-01 ไธญๆ–‡ ยท ็Žฐไปฃๅ•†ไธš๏ผˆๅŒ่ฏญๆ ‡็ญพ๏ผ‰",
72
+ "lang": "zh",
73
+ "text": (
74
+ "้˜ฟ้‡Œๅทดๅทด้›†ๅ›ขๅˆ›ๅง‹ไบบ้ฉฌไบ‘ไบŽ2019ๅนดๅธไปป่‘ฃไบ‹ๅฑ€ไธปๅธญ๏ผŒ็”ฑๅผ ๅ‹‡ๆŽฅไปปใ€‚"
75
+ "ๆ€ป้ƒจไฝไบŽๆญๅทž็š„้˜ฟ้‡Œๅทดๅทดๆ——ไธ‹ๆ‹ฅๆœ‰ๆท˜ๅฎใ€ๅคฉ็Œซใ€ๆ”ฏไป˜ๅฎ็ญ‰ไธšๅŠกๆฟๅ—ใ€‚"
76
+ ),
77
+ "labels": [
78
+ "ไบบๅๆˆ–ๅง“ๅ", "full name of a person",
79
+ "ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ", "company or organization name",
80
+ "ๅœฐๅๆˆ–ๅŸŽๅธ‚", "geographical location",
81
+ "ไบงๅ“ๆˆ–ๅ“็‰Œๅ็งฐ", "product or brand name",
82
+ "ๆ—ฅๆœŸๆˆ–ๅนดไปฝ", "date or year",
83
+ ],
84
+ "expected": ["้ฉฌไบ‘", "ๅผ ๅ‹‡", "้˜ฟ้‡Œๅทดๅทด", "ๆญๅทž", "ๆท˜ๅฎ", "ๅคฉ็Œซ", "ๆ”ฏไป˜ๅฎ", "2019"],
85
+ },
86
+ {
87
+ "name": "ZH-02 ไธญๆ–‡ ยท ๅคๅ…ธๆ–‡ๅญฆ๏ผˆ่พน็•Œๆต‹่ฏ•๏ผ‰",
88
+ "lang": "zh",
89
+ "text": (
90
+ "ๅฐคๆฐๆฅ่ฏท๏ผŒ็Ž‹็†™ๅ‡ค็ฌ‘้“๏ผš'ไฝ ๆฅไบ†ใ€‚'่ดพๆฏๅ‘ฝไบบๆ‘†้…’๏ผŒ"
91
+ "ๅฎ็މๅ’Œ้ป›็މๅœจๅคง่ง‚ๅ›ญๆ•ฃๆญฅ๏ผŒ่–›ๅฎ้’—็‹ฌๅๆขจ้ฆ™้™ขใ€‚"
92
+ ),
93
+ "labels": [
94
+ "ไบบๅๆˆ–ๅง“ๅ", "full name of a person",
95
+ "ๅœฐๅๆˆ–ๅœบๆ‰€", "place or location name",
96
+ ],
97
+ "expected": ["ๅฐคๆฐ", "็Ž‹็†™ๅ‡ค", "่ดพๆฏ", "ๅฎ็މ", "้ป›็މ", "่–›ๅฎ้’—", "ๅคง่ง‚ๅ›ญ", "ๆขจ้ฆ™้™ข"],
98
+ "boundary_check": {
99
+ "must_not_contain": ["ๅฐคๆฐๆฅ่ฏท", "็Ž‹็†™ๅ‡ค็ฌ‘้“"],
100
+ },
101
+ },
102
+ {
103
+ "name": "ZH-03 ไธญๆ–‡ ยท ๅŒป็–—ๅœบๆ™ฏ๏ผˆๅŒ่ฏญๆ ‡็ญพ๏ผ‰",
104
+ "lang": "zh",
105
+ "text": (
106
+ "ๅŒ—ไบฌๅๅ’ŒๅŒป้™ขๅฟƒๅ†…็ง‘ไธปไปป็Ž‹ๅปบๅ›ฝๆ•™ๆŽˆๅ›ข้˜Ÿ๏ผŒไบŽ2023ๅนดๆˆๅŠŸๅฎŒๆˆ้ฆ–ไพ‹"
107
+ "ๆœบๅ™จไบบ่พ…ๅŠฉๅ† ็ŠถๅŠจ่„‰ๆญๆกฅๆ‰‹ๆœฏ๏ผŒๆ‚ฃ่€…ๆฅ่‡ชๅฑฑไธœ็œๆตŽๅ—ๅธ‚ใ€‚"
108
+ ),
109
+ "labels": [
110
+ "ไบบๅๆˆ–ๅง“ๅ", "full name of a person",
111
+ "ๅŒป้™ขๆˆ–ๆœบๆž„ๅ็งฐ", "hospital or institution name",
112
+ "ๅœฐๅๆˆ–ๅŸŽๅธ‚", "geographical location",
113
+ "ๅŒป็–—ๆŠ€ๆœฏๆˆ–ๆ‰‹ๆœฏๅ็งฐ", "medical procedure or technology",
114
+ "ๆ—ฅๆœŸๆˆ–ๅนดไปฝ", "date or year",
115
+ ],
116
+ "expected": ["็Ž‹ๅปบๅ›ฝ", "ๅŒ—ไบฌๅๅ’ŒๅŒป้™ข", "ๆตŽๅ—", "ๅฑฑไธœ", "2023"],
117
+ },
118
+ # โ”€โ”€ ้˜ฟๆ‹‰ไผฏๆ–‡ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
119
+ {
120
+ "name": "AR-01 ้˜ฟๆ‹‰ไผฏ่ฏญ ยท ๆ–ฐ้—ป",
121
+ "lang": "ar",
122
+ "text": (
123
+ "ุฃุนู„ู† ุงู„ุฑุฆูŠุณ ู…ุญู…ุฏ ุจู† ุณู„ู…ุงู† ุนู† ุฅุทู„ุงู‚ ู…ุดุฑูˆุน ู†ูŠูˆู… ููŠ ุงู„ู…ู…ู„ูƒุฉ ุงู„ุนุฑุจูŠุฉ ุงู„ุณุนูˆุฏูŠุฉ "
124
+ "ุนุงู… 2017ุŒ ูˆุชุจู„ุบ ุชูƒู„ูุชู‡ 500 ู…ู„ูŠุงุฑ ุฏูˆู„ุงุฑ."
125
+ ),
126
+ "labels": [
127
+ "full name of a person",
128
+ "company or organization name",
129
+ "geographical location",
130
+ "project or initiative name",
131
+ "date or year",
132
+ "monetary amount",
133
+ ],
134
+ "expected": ["ู…ุญู…ุฏ ุจู† ุณู„ู…ุงู†", "ู†ูŠูˆู…", "ุงู„ู…ู…ู„ูƒุฉ ุงู„ุนุฑุจูŠุฉ ุงู„ุณุนูˆุฏูŠุฉ", "2017"],
135
+ },
136
+ # โ”€โ”€ ไธญ่‹ฑๆททๅˆ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
137
+ {
138
+ "name": "MIX-01 ไธญ่‹ฑๆททๅˆ ยท ่Œๅœบๅœบๆ™ฏ๏ผˆๅŒ่ฏญๆ ‡็ญพ๏ผ‰",
139
+ "lang": "mixed",
140
+ "text": (
141
+ "ๅผ ไผŸๅŠ ๅ…ฅไบ† Google ๅŒ—ไบฌ็ ”ๅ‘ไธญๅฟƒ๏ผŒ่ดŸ่ดฃ Android ็ณป็ปŸไผ˜ๅŒ–ใ€‚"
142
+ "ไป–็š„ๅŒไบ‹ Sarah Chen ๆฅ่‡ช Meta๏ผŒไธคไบบๅ…ฑๅŒๅ‚ไธŽไบ† 2024 ๅนด็š„ AI Summitใ€‚"
143
+ ),
144
+ "labels": [
145
+ "ไบบๅๆˆ–ๅง“ๅ", "full name of a person",
146
+ "ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ", "company or organization name",
147
+ "ๅœฐๅๆˆ–ๅŸŽๅธ‚", "geographical location",
148
+ "ไบงๅ“ๆˆ–ๆŠ€ๆœฏๅ็งฐ", "product or technology name",
149
+ "ๆ—ฅๆœŸๆˆ–ๅนดไปฝ", "date or year",
150
+ ],
151
+ "expected": ["ๅผ ไผŸ", "Google", "Sarah Chen", "Meta", "Android", "ๅŒ—ไบฌ", "2024"],
152
+ },
153
+ {
154
+ "name": "MIX-02 ไธญ่‹ฑๆททๅˆ ยท ๅญฆๆœฏๅœบๆ™ฏ๏ผˆๅŒ่ฏญๆ ‡็ญพ๏ผ‰",
155
+ "lang": "mixed",
156
+ "text": (
157
+ "ๆธ…ๅŽๅคงๅญฆ่ฎก็ฎ—ๆœบ็ณปๆ•™ๆŽˆๆŽๆ˜Žๅœจ NeurIPS 2023 ๅ‘่กจไบ†ๅ…ณไบŽ Transformer ๆžถๆž„็š„่ฎบๆ–‡๏ผŒ"
158
+ "ๅˆไฝœ่€…ๆฅ่‡ช MIT ๅ’Œ Stanford Universityใ€‚"
159
+ ),
160
+ "labels": [
161
+ "ไบบๅๆˆ–ๅง“ๅ", "full name of a person",
162
+ "ๅคงๅญฆๆˆ–็ ”็ฉถๆœบๆž„", "university or research institution",
163
+ "ไผš่ฎฎๆˆ–ๆœŸๅˆŠๅ็งฐ", "conference or journal name",
164
+ "ๆŠ€ๆœฏๆˆ–ๆจกๅž‹ๅ็งฐ", "technology or model name",
165
+ "ๆ—ฅๆœŸๆˆ–ๅนดไปฝ", "date or year",
166
+ ],
167
+ "expected": ["ๆŽๆ˜Ž", "ๆธ…ๅŽๅคงๅญฆ", "NeurIPS 2023", "Transformer", "MIT", "Stanford University"],
168
+ },
169
+ ]
170
+
171
+ THRESHOLD = 0.4
172
+ CACHE_DIR = "./model_cache"
173
+ REPORT_DIR = Path("reports")
174
+
175
+ MODELS = [
176
+ ("gliner_multi-v2.1", "urchade/gliner_multi-v2.1"),
177
+ ("gliner-multitask-large-v0.5", "knowledgator/gliner-multitask-large-v0.5"),
178
+ ]
179
+
180
+
181
+ # โ”€โ”€ span ๅŽป้‡ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
182
+
183
+ def deduplicate(entities: list[dict]) -> list[dict]:
184
+ """ๅŒ่ฏญๆ ‡็ญพๅฏ่ƒฝๅฏนๅŒไธ€ span ไบง็”Ÿไธคๆก็ป“ๆžœ๏ผŒไฟ็•™ๅพ—ๅˆ†ๆœ€้ซ˜็š„้‚ฃๆกใ€‚"""
185
+ best: dict[tuple, dict] = {}
186
+ for e in entities:
187
+ key = (e["start"], e["end"])
188
+ if key not in best or e["score"] > best[key]["score"]:
189
+ best[key] = e
190
+ return sorted(best.values(), key=lambda x: x["start"])
191
+
192
+
193
+ # โ”€โ”€ ๆจกๅž‹ไธ‹่ฝฝ๏ผˆ็›ดๆŽฅๅคๅˆถ๏ผŒๆ— ็ฌฆๅท้“พๆŽฅ๏ผŒๅ…ผๅฎน Windows๏ผ‰ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
194
+
195
+ def ensure_local(model_name: str) -> str:
196
+ safe = model_name.replace("/", "__")
197
+ local_dir = Path(CACHE_DIR) / safe
198
+ if local_dir.exists() and any(local_dir.iterdir()):
199
+ print(f" [cached] {local_dir}")
200
+ else:
201
+ print(f" [download] {model_name} -> {local_dir}")
202
+ snapshot_download(repo_id=model_name, local_dir=str(local_dir))
203
+ print(f" [done]")
204
+ return str(local_dir)
205
+
206
+
207
+ # โ”€โ”€ ๆ•ฐๆฎ็ป“ๆž„ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
208
+
209
+ @dataclass
210
+ class CaseResult:
211
+ case_name: str
212
+ lang: str
213
+ text: str
214
+ expected: list[str]
215
+ entities: list[dict]
216
+ elapsed_ms: float
217
+ boundary_violations: list[str] = field(default_factory=list)
218
+
219
+ @property
220
+ def found_texts(self) -> set[str]:
221
+ return {e["text"] for e in self.entities}
222
+
223
+ @property
224
+ def hit_count(self) -> int:
225
+ return sum(1 for exp in self.expected if exp in self.found_texts)
226
+
227
+ @property
228
+ def recall(self) -> float:
229
+ if not self.expected:
230
+ return 1.0
231
+ return self.hit_count / len(self.expected)
232
+
233
+
234
+ @dataclass
235
+ class ModelResult:
236
+ model_name: str
237
+ load_ms: float
238
+ cases: list[CaseResult] = field(default_factory=list)
239
+
240
+ @property
241
+ def avg_recall(self) -> float:
242
+ if not self.cases:
243
+ return 0.0
244
+ return sum(c.recall for c in self.cases) / len(self.cases)
245
+
246
+ @property
247
+ def avg_infer_ms(self) -> float:
248
+ if not self.cases:
249
+ return 0.0
250
+ return sum(c.elapsed_ms for c in self.cases) / len(self.cases)
251
+
252
+
253
+ # โ”€โ”€ ่ฟ่กŒๆจกๅž‹ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
254
+
255
+ def run_model(short_name: str, model_name: str) -> ModelResult:
256
+ print(f"\n{'โ”€'*60}")
257
+ print(f"Loading model: {model_name}")
258
+ t0 = time.perf_counter()
259
+ local_path = ensure_local(model_name)
260
+ model = GLiNER.from_pretrained(local_path, local_files_only=True)
261
+ load_ms = (time.perf_counter() - t0) * 1000
262
+ print(f"[loaded] {load_ms:.0f}ms")
263
+
264
+ result = ModelResult(model_name=short_name, load_ms=load_ms)
265
+ for case in CASES:
266
+ t1 = time.perf_counter()
267
+ raw = model.predict_entities(case["text"], case["labels"], threshold=THRESHOLD)
268
+ elapsed_ms = (time.perf_counter() - t1) * 1000
269
+ entities = deduplicate(raw)
270
+
271
+ bc = case.get("boundary_check", {})
272
+ violations = [
273
+ e["text"] for e in entities
274
+ if e["text"] in bc.get("must_not_contain", [])
275
+ ]
276
+ result.cases.append(CaseResult(
277
+ case_name=case["name"],
278
+ lang=case["lang"],
279
+ text=case["text"],
280
+ expected=case.get("expected", []),
281
+ entities=entities,
282
+ elapsed_ms=elapsed_ms,
283
+ boundary_violations=violations,
284
+ ))
285
+ status = "OK" if not violations else f"BOUNDARY ERR: {violations}"
286
+ print(f" {case['name'][:30]:30s} {len(entities):2d} entities {elapsed_ms:.0f}ms {status}")
287
+
288
+ return result
289
+
290
+
291
+ # โ”€โ”€ Markdown ๆŠฅๅ‘Š็”Ÿๆˆ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
292
+
293
+ def write_report(all_results: list[ModelResult], out_path: Path):
294
+ buf = io.StringIO()
295
+ w = buf.write
296
+
297
+ w("# NER ๆจกๅž‹ๅฏนๆฏ”ๆต‹่ฏ•ๆŠฅๅ‘Š\n\n")
298
+ w(f"็”Ÿๆˆๆ—ถ้—ด๏ผš{time.strftime('%Y-%m-%d %H:%M:%S')} \n")
299
+ w(f"้˜ˆๅ€ผ๏ผˆthreshold๏ผ‰๏ผš`{THRESHOLD}` \n\n")
300
+
301
+ # โ”€โ”€ ๆฑ‡ๆ€ป่กจ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
302
+ w("## ไธ€ใ€ๆฑ‡ๆ€ปๅฏนๆฏ”\n\n")
303
+ header = "| ๆต‹่ฏ•็”จไพ‹ | ่ฏญ่จ€ |"
304
+ sep = "|---|---|"
305
+ for r in all_results:
306
+ header += f" {r.model_name} ๅฌๅ›ž | {r.model_name} ่€—ๆ—ถ |"
307
+ sep += "---|---|"
308
+ w(header + "\n")
309
+ w(sep + "\n")
310
+
311
+ for i, case in enumerate(CASES):
312
+ row = f"| {case['name']} | `{case['lang']}` |"
313
+ for r in all_results:
314
+ cr = r.cases[i]
315
+ pct = f"{cr.recall*100:.0f}%"
316
+ row += f" {cr.hit_count}/{len(cr.expected)} ({pct}) | {cr.elapsed_ms:.0f}ms |"
317
+ w(row + "\n")
318
+
319
+ # avg row
320
+ avg_row = "| **ๅนณๅ‡** | โ€” |"
321
+ for r in all_results:
322
+ avg_row += f" **{r.avg_recall*100:.0f}%** | **{r.avg_infer_ms:.0f}ms** |"
323
+ w(avg_row + "\n\n")
324
+
325
+ # โ”€โ”€ ๅŠ ่ฝฝๆ—ถ้—ด โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
326
+ w("## ไบŒใ€ๆจกๅž‹ๅŠ ่ฝฝๆ—ถ้—ด\n\n")
327
+ w("| ๆจกๅž‹ | ๅŠ ่ฝฝ่€—ๆ—ถ |\n|---|---|\n")
328
+ for r in all_results:
329
+ w(f"| {r.model_name} | {r.load_ms/1000:.1f}s |\n")
330
+ w("\n")
331
+
332
+ # โ”€โ”€ ้€็”จไพ‹่ฏฆๆƒ… โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
333
+ w("## ไธ‰ใ€้€็”จไพ‹่ฏฆ็ป†็ป“ๆžœ\n\n")
334
+ for i, case in enumerate(CASES):
335
+ w(f"### {case['name']}\n\n")
336
+ w(f"**ๆ–‡ๆœฌ**\n```\n{case['text']}\n```\n\n")
337
+ w(f"**ๆœŸๆœ›ๅฎžไฝ“**๏ผš{', '.join(f'`{e}`' for e in case.get('expected', []))}\n\n")
338
+
339
+ for r in all_results:
340
+ cr = r.cases[i]
341
+ hits = [e for e in cr.expected if e in cr.found_texts]
342
+ misses = [e for e in cr.expected if e not in cr.found_texts]
343
+
344
+ w(f"#### {r.model_name} ๏ผˆ{cr.elapsed_ms:.0f}ms๏ผŒ{len(cr.entities)} ไธชๅฎžไฝ“๏ผŒๅฌๅ›ž {cr.recall*100:.0f}%๏ผ‰\n\n")
345
+
346
+ if cr.entities:
347
+ w("| ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ๅ‘ฝไธญๆœŸๆœ› |\n|---|---|---|---|\n")
348
+ for e in cr.entities:
349
+ hit_mark = "โœ“" if e["text"] in cr.expected else ""
350
+ w(f"| `{e['text']}` | {e['label']} | {e['score']:.2f} | {hit_mark} |\n")
351
+ else:
352
+ w("_ๆœช่ฏ†ๅˆซๅˆฐๅฎžไฝ“_\n")
353
+
354
+ if misses:
355
+ w(f"\n**ๆœชๅ‘ฝไธญ**๏ผš{', '.join(f'`{m}`' for m in misses)}\n")
356
+ if cr.boundary_violations:
357
+ w(f"\n> โš ๏ธ **่พน็•Œ้”™่ฏฏ**๏ผš{cr.boundary_violations}\n")
358
+ w("\n")
359
+
360
+ # โ”€โ”€ ็ป“่ฎบ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
361
+ w("## ๅ››ใ€็ป“่ฎบไธŽๅปบ่ฎฎ\n\n")
362
+ best = max(all_results, key=lambda r: r.avg_recall)
363
+ fast = min(all_results, key=lambda r: r.avg_infer_ms)
364
+ w(f"- **็ปผๅˆๅฌๅ›žๆœ€้ซ˜**๏ผš`{best.model_name}`๏ผˆๅนณๅ‡ๅฌๅ›ž {best.avg_recall*100:.0f}%๏ผ‰\n")
365
+ w(f"- **ๆŽจ็†ๆœ€ๅฟซ**๏ผš`{fast.model_name}`๏ผˆๅนณๅ‡ {fast.avg_infer_ms:.0f}ms/ๆฌก๏ผ‰\n\n")
366
+ w("### ไผ˜ๅŒ–ๅปบ่ฎฎ\n\n")
367
+ w("1. **ๅŒ่ฏญๆ ‡็ญพ็ญ–็•ฅ**๏ผšๅฏนไธญๆ–‡ๆˆ–ๆททๅˆๆ–‡ๆœฌ๏ผŒๅŒๆ—ถๆไพ›ไธญ่‹ฑๆ–‡ๆ ‡็ญพๆ่ฟฐ๏ผˆๅฆ‚ `\"ไบบๅๆˆ–ๅง“ๅ\"` + `\"full name of a person\"`๏ผ‰๏ผŒๅฏๆ˜พ่‘—ๆๅ‡ไธญๆ–‡ๅฎžไฝ“ๅฌๅ›ž็އใ€‚GLiNER ๆ˜ฏ้›ถๆ ทๆœฌๆจกๅž‹๏ผŒๆ ‡็ญพๆ่ฟฐ่ถŠๅ…ทไฝ“ใ€่ถŠๆŽฅ่ฟ‘่ฎญ็ปƒ่ฏญๆ–™็š„่กจ่พพๆ–นๅผ๏ผŒ่ฏ†ๅˆซๆ•ˆๆžœ่ถŠๅฅฝใ€‚\n")
368
+ w("2. **Span ๅŽป้‡**๏ผšไฝฟ็”จๅŒ่ฏญๆ ‡็ญพๆ—ถๅŒไธ€ๆ–‡ๆœฌ่ทจๅบฆๅฏ่ƒฝ่ขซๆ‰“ไธŠไธคไธชๆ ‡็ญพ๏ผŒๅปบ่ฎฎๅœจๆœๅŠกๅฑ‚ๆŒ‰ `(start, end)` ๅŽป้‡๏ผŒไฟ็•™ๅพ—ๅˆ†ๆœ€้ซ˜็š„็ป“ๆžœ๏ผˆๅทฒๅœจ `app/ner.py` ๅฎž็Žฐ๏ผ‰ใ€‚\n")
369
+ w("3. **้˜ˆๅ€ผ่ฐƒไผ˜**๏ผš่‹ฑๆ–‡ๅปบ่ฎฎ `threshold=0.5`๏ผŒไธญๆ–‡ๅปบ่ฎฎ `threshold=0.35~0.4`๏ผˆๆจกๅž‹ๅฏนไธญๆ–‡็ฝฎไฟกๅบฆๆ™ฎ้ๅไฝŽ๏ผ‰ใ€‚\n")
370
+ w("4. **ๅคๅ…ธ/ๆ–‡่จ€ๆ–‡**๏ผšไธคไธชๆจกๅž‹ๅฏนๆ–‡่จ€ๆ–‡ๆ”ฏๆŒๅ‡ๅผฑ๏ผŒๅปบ่ฎฎ็ป“ๅˆ่ง„ๅˆ™ๆˆ–ไธ“็”จๆจกๅž‹๏ผˆๅฆ‚ `BERT-CRF` ๅœจๅคๆฑ‰่ฏญ่ฏญๆ–™ไธŠๅพฎ่ฐƒ๏ผ‰ๅค„็†ๆญค็ฑปๆ–‡ๆœฌใ€‚\n")
371
+ w("5. **้˜ฟๆ‹‰ไผฏ่ฏญ**๏ผš`gliner-multitask-large-v0.5` ๅœจๅคš่ฏญ่จ€ไธŠ่ฎญ็ปƒ๏ผŒๅฏน้˜ฟๆ‹‰ไผฏ่ฏญๆœ‰ๅŸบ็ก€ๆ”ฏๆŒ๏ผ›`gliner_multi-v2.1` ้˜ฟๆ‹‰ไผฏ่ฏญๆ•ˆๆžœๆœ‰้™ใ€‚\n")
372
+
373
+ out_path.parent.mkdir(parents=True, exist_ok=True)
374
+ out_path.write_text(buf.getvalue(), encoding="utf-8")
375
+ print(f"\n[report] {out_path.resolve()}")
376
+
377
+
378
+ # โ”€โ”€ ๅ…ฅๅฃ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
379
+
380
+ if __name__ == "__main__":
381
+ all_results: list[ModelResult] = []
382
+ for short_name, model_name in MODELS:
383
+ all_results.append(run_model(short_name, model_name))
384
+
385
+ # ๆŽงๅˆถๅฐ็ฎ€่ฆๆฑ‡ๆ€ป๏ผˆASCII safe๏ผ‰
386
+ print(f"\n{'='*70}")
387
+ print(f"{'Case':<42} " + " ".join(f"{r.model_name[:20]:<20}" for r in all_results))
388
+ print(f"{'โ”€'*70}")
389
+ for i, case in enumerate(CASES):
390
+ row = f"{case['name'][:40]:<42}"
391
+ for r in all_results:
392
+ cr = r.cases[i]
393
+ row += f" {cr.hit_count}/{len(cr.expected)} {cr.recall*100:3.0f}% {cr.elapsed_ms:5.0f}ms "
394
+ print(row)
395
+ print(f"{'โ”€'*70}")
396
+ avg_row = f"{'Average':<42}"
397
+ for r in all_results:
398
+ avg_row += f" avg {r.avg_recall*100:.0f}% / {r.avg_infer_ms:.0f}ms "
399
+ print(avg_row)
400
+
401
+ report_path = REPORT_DIR / "comparison_report.md"
402
+ write_report(all_results, report_path)
tests/test_extract.py CHANGED
@@ -1,8 +1,11 @@
1
  """
2
- Unit tests โ€” no real model loaded (gliner/torch stubbed in conftest.py).
3
- Covers: API contract, Chinese / English / mixed-language text handling.
 
 
 
4
  """
5
- from unittest.mock import MagicMock
6
 
7
  import pytest
8
  from fastapi.testclient import TestClient
@@ -10,6 +13,7 @@ from fastapi.testclient import TestClient
10
  import app.main as main_module
11
  from app.main import app
12
  from app.models import Entity
 
13
 
14
 
15
  # โ”€โ”€ Fixture โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -17,13 +21,22 @@ from app.models import Entity
17
  @pytest.fixture()
18
  def client():
19
  mock_ner = MagicMock()
 
 
20
  with pytest.MonkeyPatch().context() as mp:
21
  mp.setattr("app.main.NERService", lambda *_: mock_ner)
22
  with TestClient(app) as c:
23
  yield c, mock_ner
24
 
25
 
26
- # โ”€โ”€ API contract โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 
 
 
 
 
 
27
 
28
  def test_health(client):
29
  c, _ = client
@@ -34,26 +47,36 @@ def test_health(client):
34
 
35
  def test_extract_empty_text(client):
36
  c, mock_ner = client
37
- mock_ner.extract.return_value = []
38
  resp = c.post("/api/v1/extract", json={"text": "", "labels": ["person"]})
39
  assert resp.status_code == 200
40
  assert resp.json()["entities"] == []
41
 
42
 
43
- def test_extract_empty_labels(client):
 
44
  c, mock_ner = client
45
- mock_ner.extract.return_value = []
46
- resp = c.post("/api/v1/extract", json={"text": "Some text.", "labels": []})
 
 
 
 
 
 
 
 
 
 
 
 
47
  assert resp.status_code == 200
48
- assert resp.json()["entities"] == []
49
 
50
 
51
  def test_extract_threshold_forwarded(client):
52
  c, mock_ner = client
53
- mock_ner.extract.return_value = []
54
  c.post("/api/v1/extract",
55
  json={"text": "Hello world", "labels": ["person"], "threshold": 0.8})
56
- mock_ner.extract.assert_called_once_with("Hello world", ["person"], 0.8)
57
 
58
 
59
  def test_extract_invalid_threshold(client):
@@ -63,12 +86,27 @@ def test_extract_invalid_threshold(client):
63
  assert resp.status_code == 422
64
 
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  def test_entity_response_fields(client):
67
  """ๆฏไธชๅฎžไฝ“ๅŒ…ๅซๅ…จ้ƒจๅฟ…ๅกซๅญ—ๆฎตไธ”ๅ€ผๅˆๆณ•ใ€‚"""
68
  c, mock_ner = client
69
- mock_ner.extract.return_value = [
70
  Entity(text="Apple", label="organization", score=0.95, start=0, end=5)
71
- ]
72
  resp = c.post("/api/v1/extract",
73
  json={"text": "Apple is great.", "labels": ["organization"]})
74
  assert resp.status_code == 200
@@ -78,202 +116,218 @@ def test_entity_response_fields(client):
78
  assert e["start"] < e["end"]
79
 
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  # โ”€โ”€ English โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
82
 
83
  def test_english_person_org(client):
84
- """่‹ฑๆ–‡ๆ–‡ๆœฌ๏ผš่ฏ†ๅˆซไบบๅๅ’Œๆœบๆž„ใ€‚"""
85
  c, mock_ner = client
86
- mock_ner.extract.return_value = [
87
  Entity(text="Elon Musk", label="person", score=0.98, start=0, end=9),
88
  Entity(text="Tesla", label="organization", score=0.96, start=18, end=23),
89
  Entity(text="SpaceX", label="organization", score=0.97, start=28, end=34),
90
- ]
91
- text = "Elon Musk is the CEO of Tesla and founded SpaceX in 2002."
92
  resp = c.post("/api/v1/extract",
93
- json={"text": text,
94
- "labels": ["full name of a person",
95
- "company or organization name"]})
96
  assert resp.status_code == 200
97
- entities = resp.json()["entities"]
98
- texts = {e["text"] for e in entities}
99
- labels = {e["label"] for e in entities}
100
- assert "Elon Musk" in texts
101
- assert "Tesla" in texts
102
- assert "SpaceX" in texts
103
- assert "person" in labels
104
- assert "organization" in labels
105
 
106
 
107
  def test_english_location_date(client):
108
- """่‹ฑๆ–‡ๆ–‡ๆœฌ๏ผš่ฏ†ๅˆซๅœฐ็‚นๅ’Œๆ—ฅๆœŸใ€‚"""
109
  c, mock_ner = client
110
- mock_ner.extract.return_value = [
111
- Entity(text="Paris", label="location", score=0.94, start=20, end=25),
112
- Entity(text="2024", label="date", score=0.91, start=29, end=33),
113
- Entity(text="France", label="location", score=0.93, start=38, end=44),
114
- ]
115
- text = "The summit was held in Paris in 2024, in France."
116
  resp = c.post("/api/v1/extract",
117
- json={"text": text,
118
  "labels": ["geographical location", "date or year"]})
119
  assert resp.status_code == 200
120
- entities = resp.json()["entities"]
121
- texts = {e["text"] for e in entities}
122
- assert "Paris" in texts
123
- assert "France" in texts
124
- assert "2024" in texts
125
 
126
 
127
- def test_english_threshold_filters_low_confidence(client):
128
- """้ซ˜้˜ˆๅ€ผ่ฟ‡ๆปคไฝŽ็ฝฎไฟกๅบฆ็ป“ๆžœใ€‚"""
129
  c, mock_ner = client
130
- mock_ner.extract.return_value = [
131
  Entity(text="NASA", label="organization", score=0.95, start=0, end=4),
132
- ]
133
- text = "NASA and some group discussed the Moon landing plan in 2026."
134
  resp = c.post("/api/v1/extract",
135
- json={"text": text,
136
  "labels": ["company or organization name"],
137
  "threshold": 0.8})
138
  assert resp.status_code == 200
139
- mock_ner.extract.assert_called_once_with(text,
140
- ["company or organization name"],
141
- 0.8)
142
- for e in resp.json()["entities"]:
143
- assert e["score"] >= 0.0 # mock controls scores; just verify structure
144
 
145
 
146
  # โ”€โ”€ Chinese โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
147
 
148
  def test_chinese_person_org(client):
149
- """ไธญๆ–‡ๆ–‡ๆœฌ๏ผš่ฏ†ๅˆซไบบๅๅ’Œๆœบๆž„ๅใ€‚"""
150
  c, mock_ner = client
151
- mock_ner.extract.return_value = [
152
- Entity(text="้ฉฌไบ‘", label="ไบบๅๆˆ–ๅง“ๅ", score=0.96, start=8, end=10),
153
- Entity(text="ๅผ ๅ‹‡", label="ไบบๅๆˆ–ๅง“ๅ", score=0.94, start=25, end=27),
154
  Entity(text="้˜ฟ้‡Œๅทดๅทด", label="ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ", score=0.97, start=0, end=4),
155
- ]
156
- text = "้˜ฟ้‡Œๅทดๅทด้›†ๅ›ขๅˆ›ๅง‹ไบบ้ฉฌไบ‘ไบŽ2019ๅนดๅธไปป่‘ฃไบ‹ๅฑ€ไธปๅธญ๏ผŒ็”ฑๅผ ๅ‹‡ๆŽฅไปปใ€‚"
157
  resp = c.post("/api/v1/extract",
158
- json={"text": text,
159
- "labels": ["ไบบๅๆˆ–ๅง“ๅ", "ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ", "ๅœฐๅๆˆ–ๅŸŽๅธ‚"]})
 
160
  assert resp.status_code == 200
161
- entities = resp.json()["entities"]
162
- texts = {e["text"] for e in entities}
163
- assert "้ฉฌไบ‘" in texts
164
- assert "ๅผ ๅ‹‡" in texts
165
- assert "้˜ฟ้‡Œๅทดๅทด" in texts
166
 
167
 
168
  def test_chinese_entity_boundary(client):
169
- """ไธญๆ–‡ๆ–‡ๆœฌ๏ผšๅฎžไฝ“่พน็•Œไธๅบ”ๅŒ…ๅซๅŠจ่ฏ๏ผˆๅฆ‚"ๅฐคๆฐๆฅ่ฏท"ๅชๅ–"ๅฐคๆฐ"๏ผ‰ใ€‚"""
170
  c, mock_ner = client
171
- # ๆจกๆ‹Ÿๆจกๅž‹ๆญฃ็กฎๆˆชๆ–ญ่พน็•Œ
172
- mock_ner.extract.return_value = [
173
- Entity(text="ๅฐคๆฐ", label="ไบบๅๆˆ–ๅง“ๅ", score=0.82, start=0, end=2),
174
  Entity(text="็Ž‹็†™ๅ‡ค", label="ไบบๅๆˆ–ๅง“ๅ", score=0.95, start=8, end=11),
175
- ]
176
- text = "ๅฐคๆฐๆฅ่ฏท๏ผŒ็Ž‹็†™ๅ‡ค็ฌ‘้“๏ผš'ไฝ ๆฅไบ†ใ€‚'"
177
  resp = c.post("/api/v1/extract",
178
- json={"text": text, "labels": ["ไบบๅๆˆ–ๅง“ๅ"]})
 
179
  assert resp.status_code == 200
180
- entities = resp.json()["entities"]
181
- texts = {e["text"] for e in entities}
182
- # ๆญฃ็กฎ๏ผšๅชๆœ‰ๅ็งฐ๏ผŒไธๅซๅŠจไฝœ
183
- assert "ๅฐคๆฐ" in texts
184
- assert "็Ž‹็†™ๅ‡ค" in texts
185
- assert "ๅฐคๆฐๆฅ่ฏท" not in texts
186
  assert "็Ž‹็†™ๅ‡ค็ฌ‘้“" not in texts
187
 
188
 
189
  def test_chinese_location_product(client):
190
- """ไธญๆ–‡ๆ–‡ๆœฌ๏ผš่ฏ†ๅˆซๅœฐ็‚นๅ’Œไบงๅ“ๅใ€‚"""
191
  c, mock_ner = client
192
- mock_ner.extract.return_value = [
193
- Entity(text="ๆญๅทž", label="ๅœฐๅๆˆ–ๅŸŽๅธ‚", score=0.93, start=17, end=19),
194
  Entity(text="ๆท˜ๅฎ", label="ไบงๅ“ๆˆ–ๅ“็‰Œๅ็งฐ", score=0.91, start=22, end=24),
195
  Entity(text="ๅคฉ็Œซ", label="ไบงๅ“ๆˆ–ๅ“็‰Œๅ็งฐ", score=0.92, start=25, end=27),
196
  Entity(text="ๆ”ฏไป˜ๅฎ", label="ไบงๅ“ๆˆ–ๅ“็‰Œๅ็งฐ", score=0.90, start=28, end=31),
197
- ]
198
- text = "้˜ฟ้‡Œๅทดๅทด้›†ๅ›ขๆ€ป้ƒจไฝไบŽๆญๅทž๏ผŒๆ——ไธ‹ๆ‹ฅๆœ‰ๆท˜ๅฎใ€ๅคฉ็Œซใ€ๆ”ฏไป˜ๅฎ็ญ‰ไธšๅŠกๆฟๅ—ใ€‚"
199
  resp = c.post("/api/v1/extract",
200
- json={"text": text,
201
  "labels": ["ๅœฐๅๆˆ–ๅŸŽๅธ‚", "ไบงๅ“ๆˆ–ๅ“็‰Œๅ็งฐ"]})
202
  assert resp.status_code == 200
203
- entities = resp.json()["entities"]
204
- texts = {e["text"] for e in entities}
205
- assert "ๆญๅทž" in texts
206
- assert "ๆท˜ๅฎ" in texts
207
- assert "ๅคฉ็Œซ" in texts
208
- assert "ๆ”ฏไป˜ๅฎ" in texts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
 
211
  # โ”€โ”€ Mixed Chinese-English โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
212
 
213
  def test_mixed_entities_both_scripts(client):
214
- """ไธญ่‹ฑๆททๅˆๆ–‡ๆœฌ๏ผšไธค็ง่ฏญ่จ€็š„ๅฎžไฝ“้ƒฝ่ƒฝๆญฃ็กฎ่ฏ†ๅˆซใ€‚"""
215
  c, mock_ner = client
216
- mock_ner.extract.return_value = [
217
  Entity(text="ๅผ ไผŸ", label="person", score=0.95, start=0, end=2),
218
  Entity(text="Google", label="organization", score=0.97, start=9, end=15),
219
  Entity(text="ๅŒ—ไบฌ", label="location", score=0.93, start=25, end=27),
220
  Entity(text="Android", label="product", score=0.91, start=33, end=40),
221
- ]
222
- text = "ๅผ ไผŸๅ…ฅ่Œไบ† Google ๆ‹…ไปปๅทฅ็จ‹ๅธˆ๏ผŒ้ฉปๆ‰ŽๅœจๅŒ—ไบฌ๏ผŒ่ดŸ่ดฃ Android ไบงๅ“็ ”ๅ‘ใ€‚"
223
  resp = c.post("/api/v1/extract",
224
- json={"text": text,
225
- "labels": ["full name of a person",
226
- "company or organization name",
227
- "geographical location",
228
  "product or technology name"]})
229
  assert resp.status_code == 200
230
- entities = resp.json()["entities"]
231
- texts = {e["text"] for e in entities}
232
- assert "ๅผ ไผŸ" in texts
233
- assert "Google" in texts
234
- assert "ๅŒ—ไบฌ" in texts
235
- assert "Android" in texts
236
 
237
 
238
  def test_mixed_labels_chinese_and_english(client):
239
- """ไธญ่‹ฑๆททๅˆๆ–‡ๆœฌ๏ผšไฝฟ็”จไธญ่‹ฑๅŒ่ฏญๆ่ฟฐๆ€งๆ ‡็ญพใ€‚"""
240
  c, mock_ner = client
241
- mock_ner.extract.return_value = [
242
- Entity(text="ๆŽๆ˜Ž", label="ไบบๅๆˆ–ๅง“ๅ", score=0.94, start=0, end=2),
243
- Entity(text="Tesla", label="ไบบๅๆˆ–ๅง“ๅ", score=0.96, start=10, end=15),
244
- Entity(text="ไธŠๆตท", label="ๅœฐๅๆˆ–ๅŸŽๅธ‚", score=0.92, start=22, end=24),
245
- ]
246
- text = "ๆŽๆ˜ŽๅœจไธŠๆตทๅŠ ๅ…ฅไบ† Tesla๏ผŒๆˆไธบ้ฆ–ๅธญๅทฅ็จ‹ๅธˆใ€‚"
247
  resp = c.post("/api/v1/extract",
248
- json={"text": text,
249
- "labels": ["ไบบๅๆˆ–ๅง“ๅ",
250
- "full name of a person",
251
- "ๅœฐๅๆˆ–ๅŸŽๅธ‚",
252
- "geographical location",
253
  "company or organization name"]})
254
  assert resp.status_code == 200
255
- entities = resp.json()["entities"]
256
- assert len(entities) == 3
257
- texts = {e["text"] for e in entities}
258
- assert "ๆŽๆ˜Ž" in texts
259
- assert "Tesla" in texts
260
- assert "ไธŠ๏ฟฝ๏ฟฝ๏ฟฝ" in texts
261
 
262
 
263
  def test_mixed_no_cross_language_contamination(client):
264
- """ๆททๅˆๆ–‡ๆœฌไธญ๏ผŒไธญๆ–‡ๅฎžไฝ“ไธๅบ”่ขซ่ฏ†ๅˆซไธบ่‹ฑๆ–‡ๆ ‡็ญพ็ฑปๅž‹๏ผŒๅไน‹ไบฆ็„ถใ€‚"""
265
  c, mock_ner = client
266
- mock_ner.extract.return_value = [
267
- Entity(text="OpenAI", label="organization", score=0.97, start=5, end=11),
268
- Entity(text="็Ž‹่Šณ", label="person", score=0.93, start=15, end=17),
269
- ]
270
- text = "ไป–ๅœจ OpenAI ๅทฅไฝœ๏ผŒๅŒไบ‹็Ž‹่ŠณไนŸๅœจๅŒไธ€้ƒจ้—จใ€‚"
271
  resp = c.post("/api/v1/extract",
272
- json={"text": text,
273
  "labels": ["person", "organization"]})
274
  assert resp.status_code == 200
275
  entities = resp.json()["entities"]
276
- org_entities = [e for e in entities if e["label"] == "organization"]
277
- person_entities = [e for e in entities if e["label"] == "person"]
278
- assert any(e["text"] == "OpenAI" for e in org_entities)
279
- assert any(e["text"] == "็Ž‹่Šณ" for e in person_entities)
 
1
  """
2
+ Unit tests โ€” no real model loaded (GLiNER/torch stubbed in conftest.py).
3
+ Covers:
4
+ - API contract (health, validation, threshold forwarding)
5
+ - New v2 features: optional labels, bilingual expansion, labels_used echo
6
+ - English / Chinese / Arabic / mixed-language text handling
7
  """
8
+ from unittest.mock import MagicMock, patch
9
 
10
  import pytest
11
  from fastapi.testclient import TestClient
 
13
  import app.main as main_module
14
  from app.main import app
15
  from app.models import Entity
16
+ from app.labels import DEFAULT_LABELS, expand_bilingual
17
 
18
 
19
  # โ”€โ”€ Fixture โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
21
  @pytest.fixture()
22
  def client():
23
  mock_ner = MagicMock()
24
+ # Default: extract() returns ([], [])
25
+ mock_ner.extract.return_value = ([], [])
26
  with pytest.MonkeyPatch().context() as mp:
27
  mp.setattr("app.main.NERService", lambda *_: mock_ner)
28
  with TestClient(app) as c:
29
  yield c, mock_ner
30
 
31
 
32
+ def _ents(*args) -> tuple[list[Entity], list[str]]:
33
+ """Helper: wrap Entity list in the (entities, labels_used) tuple."""
34
+ entities = list(args)
35
+ labels = [e.label for e in entities]
36
+ return entities, labels
37
+
38
+
39
+ # โ”€โ”€ System / API contract โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
40
 
41
  def test_health(client):
42
  c, _ = client
 
47
 
48
  def test_extract_empty_text(client):
49
  c, mock_ner = client
 
50
  resp = c.post("/api/v1/extract", json={"text": "", "labels": ["person"]})
51
  assert resp.status_code == 200
52
  assert resp.json()["entities"] == []
53
 
54
 
55
+ def test_extract_empty_labels_uses_defaults(client):
56
+ """labels ไธบ็ฉบๆ—ถๆœๅŠก็ซฏๅบ”่‡ชๅŠจไฝฟ็”จ้ป˜่ฎคๅŒ่ฏญๆ ‡็ญพ้›†๏ผŒไธๆŠฅ้”™ใ€‚"""
57
  c, mock_ner = client
58
+ mock_ner.extract.return_value = ([], DEFAULT_LABELS)
59
+ resp = c.post("/api/v1/extract", json={"text": "Apple Inc. is in Cupertino."})
60
+ assert resp.status_code == 200
61
+ data = resp.json()
62
+ assert "entities" in data
63
+ assert "labels_used" in data
64
+ assert len(data["labels_used"]) > 0
65
+
66
+
67
+ def test_extract_omit_labels_entirely(client):
68
+ """labels ๅญ—ๆฎตๅฎŒๅ…จไธไผ ไนŸๅบ”่ฏฅๆญฃๅธธๅทฅไฝœใ€‚"""
69
+ c, mock_ner = client
70
+ mock_ner.extract.return_value = ([], DEFAULT_LABELS)
71
+ resp = c.post("/api/v1/extract", json={"text": "Some text."})
72
  assert resp.status_code == 200
 
73
 
74
 
75
  def test_extract_threshold_forwarded(client):
76
  c, mock_ner = client
 
77
  c.post("/api/v1/extract",
78
  json={"text": "Hello world", "labels": ["person"], "threshold": 0.8})
79
+ mock_ner.extract.assert_called_once_with("Hello world", ["person"], 0.8, language="auto")
80
 
81
 
82
  def test_extract_invalid_threshold(client):
 
86
  assert resp.status_code == 422
87
 
88
 
89
+ 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):
97
+ """ไธๆ”ฏๆŒ็š„ language ๅ€ผๅบ”่ฟ”ๅ›ž 422ใ€‚"""
98
+ c, _ = client
99
+ resp = c.post("/api/v1/extract",
100
+ json={"text": "Hello", "language": "jp"})
101
+ assert resp.status_code == 422
102
+
103
+
104
  def test_entity_response_fields(client):
105
  """ๆฏไธชๅฎžไฝ“ๅŒ…ๅซๅ…จ้ƒจๅฟ…ๅกซๅญ—ๆฎตไธ”ๅ€ผๅˆๆณ•ใ€‚"""
106
  c, mock_ner = client
107
+ mock_ner.extract.return_value = _ents(
108
  Entity(text="Apple", label="organization", score=0.95, start=0, end=5)
109
+ )
110
  resp = c.post("/api/v1/extract",
111
  json={"text": "Apple is great.", "labels": ["organization"]})
112
  assert resp.status_code == 200
 
116
  assert e["start"] < e["end"]
117
 
118
 
119
+ def test_labels_used_echoed(client):
120
+ """ๅ“ๅบ”ไธญ labels_used ๅบ”ๅ›žไผ ๅฎž้™…ไฝฟ็”จ็š„ๆ ‡็ญพๅˆ—่กจใ€‚"""
121
+ c, mock_ner = client
122
+ used = ["person", "organization"]
123
+ mock_ner.extract.return_value = ([], used)
124
+ resp = c.post("/api/v1/extract",
125
+ json={"text": "Elon Musk works at Tesla.", "labels": ["person"]})
126
+ assert resp.status_code == 200
127
+ assert resp.json()["labels_used"] == used
128
+
129
+
130
+ # โ”€โ”€ Bilingual label expansion (unit-level, no HTTP) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
131
+
132
+ def test_expand_bilingual_adds_english_for_chinese():
133
+ result = expand_bilingual(["ไบบๅๆˆ–ๅง“ๅ"])
134
+ assert "ไบบๅๆˆ–ๅง“ๅ" in result
135
+ assert "full name of a person" in result
136
+
137
+
138
+ def test_expand_bilingual_adds_chinese_for_english():
139
+ result = expand_bilingual(["company or organization name"])
140
+ assert "company or organization name" in result
141
+ assert "ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ" in result
142
+
143
+
144
+ def test_expand_bilingual_no_duplicate():
145
+ labels = ["ไบบๅๆˆ–ๅง“ๅ", "full name of a person"]
146
+ result = expand_bilingual(labels)
147
+ assert result.count("ไบบๅๆˆ–ๅง“ๅ") == 1
148
+ assert result.count("full name of a person") == 1
149
+
150
+
151
+ def test_expand_bilingual_custom_label_preserved():
152
+ """่‡ชๅฎšไน‰ๆ ‡็ญพ๏ผˆไธๅœจๅฏน็…ง่กจไธญ๏ผ‰ๅŽŸๆ ทไฟ็•™ใ€‚"""
153
+ result = expand_bilingual(["my custom label"])
154
+ assert "my custom label" in result
155
+
156
+
157
+ def test_default_labels_nonempty():
158
+ assert len(DEFAULT_LABELS) > 0
159
+ # ๅฟ…้กปๅŒ…ๅซไธญ่‹ฑๆ–‡ๅ„่‡ณๅฐ‘ไธ€ไธช
160
+ has_en = any(all(ord(c) < 128 for c in lbl) for lbl in DEFAULT_LABELS)
161
+ has_zh = any(any('ไธ€' <= c <= '้ฟฟ' for c in lbl) for lbl in DEFAULT_LABELS)
162
+ assert has_en and has_zh
163
+
164
+
165
  # โ”€โ”€ English โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
166
 
167
  def test_english_person_org(client):
 
168
  c, mock_ner = client
169
+ mock_ner.extract.return_value = _ents(
170
  Entity(text="Elon Musk", label="person", score=0.98, start=0, end=9),
171
  Entity(text="Tesla", label="organization", score=0.96, start=18, end=23),
172
  Entity(text="SpaceX", label="organization", score=0.97, start=28, end=34),
173
+ )
 
174
  resp = c.post("/api/v1/extract",
175
+ json={"text": "Elon Musk is the CEO of Tesla and founded SpaceX.",
176
+ "labels": ["full name of a person", "company or organization name"]})
 
177
  assert resp.status_code == 200
178
+ texts = {e["text"] for e in resp.json()["entities"]}
179
+ assert {"Elon Musk", "Tesla", "SpaceX"} <= texts
 
 
 
 
 
 
180
 
181
 
182
  def test_english_location_date(client):
 
183
  c, mock_ner = client
184
+ mock_ner.extract.return_value = _ents(
185
+ Entity(text="Paris", label="location", score=0.94, start=20, end=25),
186
+ Entity(text="2024", label="date", score=0.91, start=29, end=33),
187
+ Entity(text="France", label="location", score=0.93, start=38, end=44),
188
+ )
 
189
  resp = c.post("/api/v1/extract",
190
+ json={"text": "The summit was held in Paris in 2024, in France.",
191
  "labels": ["geographical location", "date or year"]})
192
  assert resp.status_code == 200
193
+ texts = {e["text"] for e in resp.json()["entities"]}
194
+ assert {"Paris", "France", "2024"} <= texts
 
 
 
195
 
196
 
197
+ def test_english_threshold_filters(client):
 
198
  c, mock_ner = client
199
+ mock_ner.extract.return_value = _ents(
200
  Entity(text="NASA", label="organization", score=0.95, start=0, end=4),
201
+ )
 
202
  resp = c.post("/api/v1/extract",
203
+ json={"text": "NASA explored the Moon.",
204
  "labels": ["company or organization name"],
205
  "threshold": 0.8})
206
  assert resp.status_code == 200
207
+ mock_ner.extract.assert_called_once_with(
208
+ "NASA explored the Moon.", ["company or organization name"], 0.8, language="auto"
209
+ )
 
 
210
 
211
 
212
  # โ”€โ”€ Chinese โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
213
 
214
  def test_chinese_person_org(client):
 
215
  c, mock_ner = client
216
+ mock_ner.extract.return_value = _ents(
217
+ Entity(text="้ฉฌไบ‘", label="ไบบๅๆˆ–ๅง“ๅ", score=0.96, start=8, end=10),
218
+ Entity(text="ๅผ ๅ‹‡", label="ไบบๅๆˆ–ๅง“ๅ", score=0.94, start=25, end=27),
219
  Entity(text="้˜ฟ้‡Œๅทดๅทด", label="ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ", score=0.97, start=0, end=4),
220
+ )
 
221
  resp = c.post("/api/v1/extract",
222
+ json={"text": "้˜ฟ้‡Œๅทดๅทด้›†ๅ›ขๅˆ›ๅง‹ไบบ้ฉฌไบ‘ๅธไปป๏ผŒ็”ฑๅผ ๅ‹‡ๆŽฅไปปใ€‚",
223
+ "labels": ["ไบบๅๆˆ–ๅง“ๅ", "ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ"],
224
+ "language": "zh"})
225
  assert resp.status_code == 200
226
+ texts = {e["text"] for e in resp.json()["entities"]}
227
+ assert {"้ฉฌไบ‘", "ๅผ ๅ‹‡", "้˜ฟ้‡Œๅทดๅทด"} <= texts
 
 
 
228
 
229
 
230
  def test_chinese_entity_boundary(client):
231
+ """ๅฎžไฝ“่พน็•Œไธๅบ”ๅŒ…ๅซๅŠจ่ฏ โ€” 'ๅฐคๆฐๆฅ่ฏท' ๅบ”ๅชๅ– 'ๅฐคๆฐ'ใ€‚"""
232
  c, mock_ner = client
233
+ mock_ner.extract.return_value = _ents(
234
+ Entity(text="ๅฐคๆฐ", label="ไบบๅๆˆ–ๅง“ๅ", score=0.82, start=0, end=2),
 
235
  Entity(text="็Ž‹็†™ๅ‡ค", label="ไบบๅๆˆ–ๅง“ๅ", score=0.95, start=8, end=11),
236
+ )
 
237
  resp = c.post("/api/v1/extract",
238
+ json={"text": "ๅฐคๆฐๆฅ่ฏท๏ผŒ็Ž‹็†™ๅ‡ค็ฌ‘้“๏ผš'ไฝ ๆฅไบ†ใ€‚'",
239
+ "labels": ["ไบบๅๆˆ–ๅง“ๅ"]})
240
  assert resp.status_code == 200
241
+ texts = {e["text"] for e in resp.json()["entities"]}
242
+ assert "ๅฐคๆฐ" in texts
243
+ assert "็Ž‹็†™ๅ‡ค" in texts
244
+ assert "ๅฐคๆฐๆฅ่ฏท" not in texts
 
 
245
  assert "็Ž‹็†™ๅ‡ค็ฌ‘้“" not in texts
246
 
247
 
248
  def test_chinese_location_product(client):
 
249
  c, mock_ner = client
250
+ mock_ner.extract.return_value = _ents(
251
+ Entity(text="ๆญๅทž", label="ๅœฐๅๆˆ–ๅŸŽๅธ‚", score=0.93, start=17, end=19),
252
  Entity(text="ๆท˜ๅฎ", label="ไบงๅ“ๆˆ–ๅ“็‰Œๅ็งฐ", score=0.91, start=22, end=24),
253
  Entity(text="ๅคฉ็Œซ", label="ไบงๅ“ๆˆ–ๅ“็‰Œๅ็งฐ", score=0.92, start=25, end=27),
254
  Entity(text="ๆ”ฏไป˜ๅฎ", label="ไบงๅ“ๆˆ–ๅ“็‰Œๅ็งฐ", score=0.90, start=28, end=31),
255
+ )
 
256
  resp = c.post("/api/v1/extract",
257
+ json={"text": "้˜ฟ้‡Œๅทดๅทดๆ€ป้ƒจไฝไบŽๆญๅทž๏ผŒๆ——ไธ‹ๆœ‰ๆท˜ๅฎใ€ๅคฉ็Œซใ€ๆ”ฏไป˜ๅฎใ€‚",
258
  "labels": ["ๅœฐๅๆˆ–ๅŸŽๅธ‚", "ไบงๅ“ๆˆ–ๅ“็‰Œๅ็งฐ"]})
259
  assert resp.status_code == 200
260
+ texts = {e["text"] for e in resp.json()["entities"]}
261
+ assert {"ๆญๅทž", "ๆท˜ๅฎ", "ๅคฉ็Œซ", "ๆ”ฏไป˜ๅฎ"} <= texts
262
+
263
+
264
+ # โ”€โ”€ Arabic โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
265
+
266
+ def test_arabic_person_location(client):
267
+ """้˜ฟๆ‹‰ไผฏ่ฏญ๏ผš่ฏ†ๅˆซไบบๅไธŽๅœฐๅใ€‚"""
268
+ c, mock_ner = client
269
+ mock_ner.extract.return_value = _ents(
270
+ Entity(text="ู…ุญู…ุฏ ุจู† ุณู„ู…ุงู†", label="full name of a person", score=0.82, start=12, end=26),
271
+ Entity(text="ุงู„ู…ู…ู„ูƒุฉ ุงู„ุนุฑุจูŠุฉ ุงู„ุณุนูˆุฏูŠุฉ", label="geographical location", score=0.85, start=44, end=68),
272
+ )
273
+ resp = c.post("/api/v1/extract",
274
+ json={"text": "ุฃุนู„ู† ุงู„ุฑุฆูŠุณ ู…ุญู…ุฏ ุจู† ุณู„ู…ุงู† ุนู† ู…ุดุฑูˆุน ู†ูŠูˆู… ููŠ ุงู„ู…ู…ู„ูƒุฉ ุงู„ุนุฑุจูŠุฉ ุงู„ุณุนูˆุฏูŠุฉ.",
275
+ "labels": ["full name of a person", "geographical location"],
276
+ "language": "ar"})
277
+ assert resp.status_code == 200
278
+ texts = {e["text"] for e in resp.json()["entities"]}
279
+ assert "ู…ุญู…ุฏ ุจู† ุณู„ู…ุงู†" in texts
280
+ assert "ุงู„ู…ู…ู„ูƒุฉ ุงู„ุนุฑุจูŠุฉ ุงู„ุณุนูˆุฏูŠุฉ" in texts
281
 
282
 
283
  # โ”€โ”€ Mixed Chinese-English โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
284
 
285
  def test_mixed_entities_both_scripts(client):
 
286
  c, mock_ner = client
287
+ mock_ner.extract.return_value = _ents(
288
  Entity(text="ๅผ ไผŸ", label="person", score=0.95, start=0, end=2),
289
  Entity(text="Google", label="organization", score=0.97, start=9, end=15),
290
  Entity(text="ๅŒ—ไบฌ", label="location", score=0.93, start=25, end=27),
291
  Entity(text="Android", label="product", score=0.91, start=33, end=40),
292
+ )
 
293
  resp = c.post("/api/v1/extract",
294
+ json={"text": "ๅผ ไผŸๅ…ฅ่Œไบ† Google๏ผŒ้ฉปๆ‰ŽๅœจๅŒ—ไบฌ๏ผŒ่ดŸ่ดฃ Android ็ ”ๅ‘ใ€‚",
295
+ "labels": ["full name of a person", "ไบบๅๆˆ–ๅง“ๅ",
296
+ "company or organization name", "ๅ…ฌๅธๆˆ–็ป„็ป‡ๆœบๆž„ๅ็งฐ",
297
+ "geographical location", "ๅœฐๅๆˆ–ๅŸŽๅธ‚",
298
  "product or technology name"]})
299
  assert resp.status_code == 200
300
+ texts = {e["text"] for e in resp.json()["entities"]}
301
+ assert {"ๅผ ไผŸ", "Google", "ๅŒ—ไบฌ", "Android"} <= texts
 
 
 
 
302
 
303
 
304
  def test_mixed_labels_chinese_and_english(client):
 
305
  c, mock_ner = client
306
+ mock_ner.extract.return_value = _ents(
307
+ Entity(text="ๆŽๆ˜Ž", label="ไบบๅๆˆ–ๅง“ๅ", score=0.94, start=0, end=2),
308
+ Entity(text="Tesla", label="ไบบๅๆˆ–ๅง“ๅ", score=0.96, start=10, end=15),
309
+ Entity(text="ไธŠๆตท", label="ๅœฐๅๆˆ–ๅŸŽๅธ‚", score=0.92, start=22, end=24),
310
+ )
 
311
  resp = c.post("/api/v1/extract",
312
+ json={"text": "ๆŽๆ˜ŽๅœจไธŠๆตทๅŠ ๅ…ฅไบ† Teslaใ€‚",
313
+ "labels": ["ไบบๅๆˆ–ๅง“ๅ", "full name of a person",
314
+ "ๅœฐๅๆˆ–ๅŸŽๅธ‚", "geographical location",
 
 
315
  "company or organization name"]})
316
  assert resp.status_code == 200
317
+ texts = {e["text"] for e in resp.json()["entities"]}
318
+ assert {"ๆŽๆ˜Ž", "Tesla", "ไธŠๆตท"} <= texts
 
 
 
 
319
 
320
 
321
  def test_mixed_no_cross_language_contamination(client):
 
322
  c, mock_ner = client
323
+ mock_ner.extract.return_value = _ents(
324
+ Entity(text="OpenAI", label="organization", score=0.97, start=5, end=11),
325
+ Entity(text="็Ž‹่Šณ", label="person", score=0.93, start=15, end=17),
326
+ )
 
327
  resp = c.post("/api/v1/extract",
328
+ json={"text": "ไป–ๅœจ OpenAI ๅทฅไฝœ๏ผŒๅŒไบ‹็Ž‹่ŠณไนŸๅœจๅŒไธ€้ƒจ้—จใ€‚",
329
  "labels": ["person", "organization"]})
330
  assert resp.status_code == 200
331
  entities = resp.json()["entities"]
332
+ assert any(e["text"] == "OpenAI" and e["label"] == "organization" for e in entities)
333
+ assert any(e["text"] == "็Ž‹่Šณ" and e["label"] == "person" for e in entities)