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 +79 -17
- app/labels.py +54 -0
- app/main.py +25 -9
- app/models.py +33 -2
- app/ner.py +90 -5
- reports/comparison_report.md +281 -0
- scripts/compare_models.py +402 -0
- tests/test_extract.py +187 -133
|
@@ -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://
|
| 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 |
-
"
|
| 32 |
-
|
| 33 |
-
"
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
```
|
| 36 |
|
| 37 |
-
**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
```json
|
| 40 |
{
|
| 41 |
"entities": [
|
| 42 |
-
{"text": "Elon Musk", "label": "person",
|
| 43 |
-
{"text": "SpaceX", "label": "organization", "score": 0.97, "start": 18, "end": 24},
|
| 44 |
-
{"text": "Hawthorne", "label": "location",
|
| 45 |
-
{"text": "California", "label": "location",
|
| 46 |
-
]
|
|
|
|
| 47 |
}
|
| 48 |
```
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
## Environment Variables
|
| 51 |
|
| 52 |
-
| Variable
|
| 53 |
-
|---
|
| 54 |
-
| `MODEL_NAME`
|
| 55 |
-
| `PORT`
|
| 56 |
-
| `MODEL_CACHE_DIR` | `/app/model_cache`
|
| 57 |
-
| `HF_ENDPOINT`
|
| 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 |
|
|
@@ -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
|
|
@@ -22,29 +22,45 @@ async def lifespan(app: FastAPI):
|
|
| 22 |
ner_service = None
|
| 23 |
|
| 24 |
|
| 25 |
-
app = FastAPI(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
elapsed_ms = (time.perf_counter() - t0) * 1000
|
| 44 |
|
| 45 |
logger.info(
|
| 46 |
-
"extract response | entities=%
|
| 47 |
-
|
| 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)
|
|
@@ -1,10 +1,39 @@
|
|
|
|
|
|
|
|
| 1 |
from pydantic import BaseModel, Field
|
| 2 |
|
| 3 |
|
| 4 |
class ExtractRequest(BaseModel):
|
| 5 |
text: str
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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(
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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` ้ฟๆไผฏ่ฏญๆๆๆ้ใ
|
|
@@ -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)
|
|
@@ -1,8 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
Unit tests โ no real model loaded (
|
| 3 |
-
Covers:
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 44 |
c, mock_ner = client
|
| 45 |
-
mock_ner.extract.return_value = []
|
| 46 |
-
resp = c.post("/api/v1/extract", json={"text": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 94 |
-
"labels": ["full name of a person",
|
| 95 |
-
"company or organization name"]})
|
| 96 |
assert resp.status_code == 200
|
| 97 |
-
|
| 98 |
-
|
| 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",
|
| 112 |
-
Entity(text="2024",
|
| 113 |
-
Entity(text="France",
|
| 114 |
-
|
| 115 |
-
text = "The summit was held in Paris in 2024, in France."
|
| 116 |
resp = c.post("/api/v1/extract",
|
| 117 |
-
json={"text":
|
| 118 |
"labels": ["geographical location", "date or year"]})
|
| 119 |
assert resp.status_code == 200
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
assert "Paris" in texts
|
| 123 |
-
assert "France" in texts
|
| 124 |
-
assert "2024" in texts
|
| 125 |
|
| 126 |
|
| 127 |
-
def
|
| 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":
|
| 136 |
"labels": ["company or organization name"],
|
| 137 |
"threshold": 0.8})
|
| 138 |
assert resp.status_code == 200
|
| 139 |
-
mock_ner.extract.assert_called_once_with(
|
| 140 |
-
|
| 141 |
-
|
| 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="ไบบๅๆๅงๅ",
|
| 153 |
-
Entity(text="ๅผ ๅ", label="ไบบๅๆๅงๅ",
|
| 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":
|
| 159 |
-
"labels": ["ไบบๅๆๅงๅ", "ๅ
ฌๅธๆ็ป็ปๆบๆๅ็งฐ"
|
|
|
|
| 160 |
assert resp.status_code == 200
|
| 161 |
-
|
| 162 |
-
|
| 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 |
-
|
| 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":
|
|
|
|
| 179 |
assert resp.status_code == 200
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
assert "ๅฐคๆฐ"
|
| 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="ๅฐๅๆๅๅธ",
|
| 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":
|
| 201 |
"labels": ["ๅฐๅๆๅๅธ", "ไบงๅๆๅ็ๅ็งฐ"]})
|
| 202 |
assert resp.status_code == 200
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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 |
-
|
| 231 |
-
|
| 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="ๆๆ",
|
| 243 |
-
Entity(text="Tesla",
|
| 244 |
-
Entity(text="ไธๆตท",
|
| 245 |
-
|
| 246 |
-
text = "ๆๆๅจไธๆตทๅ ๅ
ฅไบ Tesla๏ผๆไธบ้ฆๅธญๅทฅ็จๅธใ"
|
| 247 |
resp = c.post("/api/v1/extract",
|
| 248 |
-
json={"text":
|
| 249 |
-
"labels": ["ไบบๅๆๅงๅ",
|
| 250 |
-
"
|
| 251 |
-
"ๅฐๅๆๅๅธ",
|
| 252 |
-
"geographical location",
|
| 253 |
"company or organization name"]})
|
| 254 |
assert resp.status_code == 200
|
| 255 |
-
|
| 256 |
-
assert
|
| 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",
|
| 268 |
-
Entity(text="็่ณ",
|
| 269 |
-
|
| 270 |
-
text = "ไปๅจ OpenAI ๅทฅไฝ๏ผๅไบ็่ณไนๅจๅไธ้จ้จใ"
|
| 271 |
resp = c.post("/api/v1/extract",
|
| 272 |
-
json={"text":
|
| 273 |
"labels": ["person", "organization"]})
|
| 274 |
assert resp.status_code == 200
|
| 275 |
entities = resp.json()["entities"]
|
| 276 |
-
|
| 277 |
-
|
| 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)
|
|
|
|
|
|