nerserver / scripts /test_remote_api.py
Robin
fix(zh): slice entity text from original input to avoid BERT tokenizer spaces
f90826c
"""
ๅฏน่ฟœ็ซฏ HF Spaces ไธŠ้ƒจ็ฝฒ็š„ NER API ๅš็ซฏๅˆฐ็ซฏๆต‹่ฏ•๏ผŒ่ฆ†็›–ๆ‰€ๆœ‰่ทฏ็”ฑๅˆ†ๆ”ฏไธŽ่พน็•Œๆƒ…ๅ†ตใ€‚
ไธบๆฏไธช็”จไพ‹่ฎฐๅฝ•๏ผšHTTP ็Šถๆ€ใ€่ฏ†ๅˆซๅˆฐ็š„ๅฎžไฝ“ใ€่ฐƒ็”จ่€—ๆ—ถใ€่‡ชๅŠจๆฃ€ๆต‹็š„่ฏญ่จ€๏ผˆๅฆ‚ๆœ‰๏ผ‰ใ€‚
ๆœ€็ปˆ่พ“ๅ‡บ Markdown ๆŠฅๅ‘Š๏ผšreports/remote_api_test_report.md
"""
import io
import json
import time
import urllib.request
import urllib.error
from dataclasses import dataclass, field
from pathlib import Path
BASE_URL = "https://robinwu-nerserver.hf.space"
EXTRACT = f"{BASE_URL}/api/v1/extract"
HEALTH = f"{BASE_URL}/api/v1/health"
REPORT = Path("reports/remote_api_test_report.md")
# โ”€โ”€ ็”จไพ‹ๅฎšไน‰ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
#
# ๆฏไธช็”จไพ‹ๅญ—ๆฎต๏ผš
# id ็Ÿญ็ผ–ๅท
# group ๅˆ†็ป„๏ผˆ็”จไบŽๆŠฅๅ‘Šๅˆ†็ฑป๏ผ‰
# description ไธญๆ–‡ๆ่ฟฐ
# payload ไผ ็ป™ /api/v1/extract ็š„ JSON
# expected ๆœŸๆœ›ๅ‘ฝไธญ็š„ๅฎžไฝ“ๆ–‡ๆœฌ๏ผˆ็”จไบŽๅฌๅ›ž็އ็ปŸ่ฎก๏ผ›ๅฏไธบ็ฉบ้›†ๅˆ่กจ็คบไธๆ ก้ชŒ๏ผ‰
CASES: list[dict] = [
# โ”€โ”€ EN ่ทฏ็”ฑ โ”€โ”€
{
"id": "EN-01", "group": "EN โ€” GLiNER ไธป่ทฏๅพ„",
"description": "่‹ฑๆ–‡็Ÿญๅฅ๏ผŒๆ˜พๅผ language=en๏ผŒ่‡ชๅฎšไน‰ๆ ‡็ญพ",
"payload": {
"text": "Elon Musk founded SpaceX in Hawthorne, California in 2002.",
"labels": ["full name of a person", "company or organization name",
"geographical location", "date or year"],
"language": "en",
},
"expected": {"Elon Musk", "SpaceX", "Hawthorne", "California", "2002"},
},
{
"id": "EN-02", "group": "EN โ€” GLiNER ไธป่ทฏๅพ„",
"description": "่‹ฑๆ–‡้•ฟๆฎต๏ผŒlabels ็•™็ฉบ่งฆๅ‘้ป˜่ฎคๅŒ่ฏญๆ ‡็ญพ้›†",
"payload": {
"text": ("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."),
"language": "en",
},
"expected": {"Biden", "Chuck Schumer", "Washington D.C.", "Democratic Party"},
},
# โ”€โ”€ ZH ่ทฏ็”ฑ โ”€โ”€
{
"id": "ZH-01", "group": "ZH โ€” BERT ไธป่ทฏๅพ„",
"description": "ไธญๆ–‡็Žฐไปฃๅ•†ไธšๆ–‡ๆœฌ๏ผŒๆ˜พๅผ language=zh",
"payload": {
"text": "้˜ฟ้‡Œๅทดๅทด้›†ๅ›ขๅˆ›ๅง‹ไบบ้ฉฌไบ‘ไบŽ2019ๅนดๅธไปป่‘ฃไบ‹ๅฑ€ไธปๅธญ๏ผŒ็”ฑๅผ ๅ‹‡ๆŽฅไปปใ€‚"
"ๆ€ป้ƒจไฝไบŽๆญๅทž็š„้˜ฟ้‡Œๅทดๅทดๆ——ไธ‹ๆ‹ฅๆœ‰ๆท˜ๅฎใ€ๅคฉ็Œซใ€ๆ”ฏไป˜ๅฎ็ญ‰ไธšๅŠกๆฟๅ—ใ€‚",
"language": "zh",
},
"expected": {"้ฉฌไบ‘", "ๅผ ๅ‹‡", "้˜ฟ้‡Œๅทดๅทด", "ๆญๅทž"},
},
{
"id": "ZH-02", "group": "ZH โ€” BERT ไธป่ทฏๅพ„",
"description": "ไธญๆ–‡ๅŒป็–—ๅœบๆ™ฏ๏ผŒ่‡ชๅฎšไน‰ๅŒ่ฏญๆ ‡็ญพ",
"payload": {
"text": "ๅŒ—ไบฌๅๅ’ŒๅŒป้™ขๅฟƒๅ†…็ง‘ไธปไปป็Ž‹ๅปบๅ›ฝๆ•™ๆŽˆๅ›ข้˜Ÿ๏ผŒไบŽ2023ๅนดๆˆๅŠŸๅฎŒๆˆ้ฆ–ไพ‹"
"ๆœบๅ™จไบบ่พ…ๅŠฉๅ† ็ŠถๅŠจ่„‰ๆญๆกฅๆ‰‹ๆœฏ๏ผŒๆ‚ฃ่€…ๆฅ่‡ชๅฑฑไธœ็œๆตŽๅ—ๅธ‚ใ€‚",
"labels": ["ไบบๅๆˆ–ๅง“ๅ", "ๅŒป้™ขๆˆ–ๅŒป็–—ๆœบๆž„ๅ็งฐ", "ๅœฐๅๆˆ–ๅŸŽๅธ‚", "ๆ—ฅๆœŸๆˆ–ๅนดไปฝ"],
"language": "zh",
},
"expected": {"็Ž‹ๅปบๅ›ฝ", "ๅŒ—ไบฌๅๅ’ŒๅŒป้™ข", "ๆตŽๅ—"},
},
{
"id": "ZH-03", "group": "ZH โ€” BERT ่พน็•Œ่ฏ†ๅˆซ",
"description": "ๅคๅ…ธๆ–‡ๅญฆ่พน็•Œๆต‹่ฏ• โ€” ใ€Œๅฐคๆฐๆฅ่ฏทใ€ๅบ”ๅชๅ–ใ€Œๅฐคๆฐใ€",
"payload": {
"text": "ๅฐคๆฐๆฅ่ฏท๏ผŒ็Ž‹็†™ๅ‡ค็ฌ‘้“๏ผšไฝ ๆฅไบ†ใ€‚่ดพๆฏๅ‘ฝไบบๆ‘†้…’๏ผŒๅฎ็މๅ’Œ้ป›็މๅœจๅคง่ง‚ๅ›ญๆ•ฃๆญฅใ€‚",
"language": "zh",
},
"expected": {"ๅฐคๆฐ", "็Ž‹็†™ๅ‡ค", "่ดพๆฏ", "ๅฎ็މ", "้ป›็މ", "ๅคง่ง‚ๅ›ญ"},
"must_not_contain": {"ๅฐคๆฐๆฅ่ฏท", "็Ž‹็†™ๅ‡ค็ฌ‘้“"},
},
# โ”€โ”€ AR ่ทฏ็”ฑ โ”€โ”€
{
"id": "AR-01", "group": "AR โ€” GLiNER ไธป่ทฏๅพ„",
"description": "้˜ฟๆ‹‰ไผฏ่ฏญๆ–ฐ้—ป",
"payload": {
"text": ("ุฃุนู„ู† ุงู„ุฑุฆูŠุณ ู…ุญู…ุฏ ุจู† ุณู„ู…ุงู† ุนู† ุฅุทู„ุงู‚ ู…ุดุฑูˆุน ู†ูŠูˆู… ููŠ ุงู„ู…ู…ู„ูƒุฉ "
"ุงู„ุนุฑุจูŠุฉ ุงู„ุณุนูˆุฏูŠุฉ ุนุงู… 2017ุŒ ูˆุชุจู„ุบ ุชูƒู„ูุชู‡ 500 ู…ู„ูŠุงุฑ ุฏูˆู„ุงุฑ."),
"labels": ["full name of a person", "geographical location",
"project or initiative name", "date or year"],
"language": "ar",
},
"expected": {"ู…ุญู…ุฏ ุจู† ุณู„ู…ุงู†", "ุงู„ู…ู…ู„ูƒุฉ ุงู„ุนุฑุจูŠุฉ ุงู„ุณุนูˆุฏูŠุฉ"},
},
# โ”€โ”€ Mixed ่ทฏ็”ฑ๏ผˆๅŒ่ท‘ๅˆๅนถ๏ผ‰ โ”€โ”€
{
"id": "MIX-01", "group": "Mixed โ€” ๅŒๆจกๅž‹ๅˆๅนถ",
"description": "ไธญ่‹ฑๆททๅˆ ยท ่Œๅœบๅœบๆ™ฏ๏ผŒlanguage=mixed ๅผบๅˆถๅŒ่ท‘",
"payload": {
"text": "ๅผ ไผŸๅŠ ๅ…ฅไบ† Google ๅŒ—ไบฌ็ ”ๅ‘ไธญๅฟƒ๏ผŒ่ดŸ่ดฃ Android ็ณป็ปŸไผ˜ๅŒ–ใ€‚"
"ไป–็š„ๅŒไบ‹ Sarah Chen ๆฅ่‡ช Meta๏ผŒไธคไบบๅ…ฑๅŒๅ‚ไธŽไบ† 2024 ๅนด็š„ AI Summitใ€‚",
"language": "mixed",
},
"expected": {"ๅผ ไผŸ", "Google", "Sarah Chen", "Meta", "Android", "ๅŒ—ไบฌ", "2024"},
},
{
"id": "MIX-02", "group": "Mixed โ€” ๅŒๆจกๅž‹ๅˆๅนถ",
"description": "ๅญฆๆœฏๅœบๆ™ฏ๏ผŒlabels ็•™็ฉบ",
"payload": {
"text": "ๆธ…ๅŽๅคงๅญฆ่ฎก็ฎ—ๆœบ็ณปๆ•™ๆŽˆๆŽๆ˜Žๅœจ NeurIPS 2023 ๅ‘่กจไบ†ๅ…ณไบŽ "
"Transformer ๆžถๆž„็š„่ฎบๆ–‡๏ผŒๅˆไฝœ่€…ๆฅ่‡ช MIT ๅ’Œ Stanford Universityใ€‚",
"language": "mixed",
},
"expected": {"ๆŽๆ˜Ž", "ๆธ…ๅŽๅคงๅญฆ", "MIT", "Stanford University", "Transformer"},
},
# โ”€โ”€ auto ่‡ชๅŠจๆฃ€ๆต‹ โ”€โ”€
{
"id": "AUTO-01", "group": "auto โ€” ่‡ชๅŠจ่ฏญ่จ€ๆฃ€ๆต‹",
"description": "็บฏไธญๆ–‡ๆ–‡ๆœฌ๏ผŒๅบ”่ขซๆฃ€ๆต‹ไธบ zh",
"payload": {
"text": "้ฉฌไบ‘ๅˆ›็ซ‹ไบ†้˜ฟ้‡Œๅทดๅทด๏ผŒๆ€ป้ƒจๅœจๆญๅทžใ€‚",
},
"expected": {"้ฉฌไบ‘", "้˜ฟ้‡Œๅทดๅทด", "ๆญๅทž"},
},
{
"id": "AUTO-02", "group": "auto โ€” ่‡ชๅŠจ่ฏญ่จ€ๆฃ€ๆต‹",
"description": "็บฏ่‹ฑๆ–‡ๆ–‡ๆœฌ๏ผŒๅบ”่ขซๆฃ€ๆต‹ไธบ en",
"payload": {
"text": "Tim Cook is the CEO of Apple in Cupertino.",
},
"expected": {"Tim Cook", "Apple", "Cupertino"},
},
{
"id": "AUTO-03", "group": "auto โ€” ่‡ชๅŠจ่ฏญ่จ€ๆฃ€ๆต‹",
"description": "ไธญ่‹ฑๆททๅˆ๏ผŒๅบ”่ขซๆฃ€ๆต‹ไธบ mixed ๅนถๅŒ่ท‘ๅˆๅนถ",
"payload": {
"text": "ๆŽๅŽๅœจ Microsoft ๆ‹…ไปปๅทฅ็จ‹ๅธˆ๏ผŒๅธธ้ฉป Seattle ๅŠžๅ…ฌๅฎคใ€‚",
},
"expected": {"ๆŽๅŽ", "Microsoft", "Seattle"},
},
# โ”€โ”€ min_entities ่ฆ†็›– โ”€โ”€
{
"id": "MIN-01", "group": "min_entities ่ฆ†็›–ๅฏๅ‘ๅผ",
"description": "min_entities=10 ๅผบๅˆถๅ…œๅบ•๏ผˆ็Ÿญๆ–‡ๆœฌๅฏๅ‘ๅผๅชๆœŸๆœ› 1 ไธช๏ผ‰",
"payload": {
"text": "้ฉฌไบ‘",
"language": "zh",
"min_entities": 10,
},
"expected": {"้ฉฌไบ‘"},
},
{
"id": "MIN-02", "group": "min_entities ่ฆ†็›–ๅฏๅ‘ๅผ",
"description": "min_entities=0 ๅ…ณ้—ญๅ…œๅบ•",
"payload": {
"text": "้ฉฌไบ‘",
"language": "zh",
"min_entities": 0,
},
"expected": {"้ฉฌไบ‘"},
},
# โ”€โ”€ ้˜ˆๅ€ผๅ˜ๅŒ– โ”€โ”€
{
"id": "THR-01", "group": "Threshold ๅ˜ๅŒ–",
"description": "้ซ˜้˜ˆๅ€ผ 0.8 - ๆœŸๆœ›่ฟ”ๅ›žๆ›ดๅฐ‘ไฝ†ๆ›ด้ซ˜็ฝฎไฟกๅบฆ็š„ๅฎžไฝ“",
"payload": {
"text": "Tesla and SpaceX are companies founded by Elon Musk.",
"language": "en",
"threshold": 0.8,
},
"expected": {"Tesla", "SpaceX", "Elon Musk"},
},
# โ”€โ”€ ่พน็•Œ่ฏทๆฑ‚ โ”€โ”€
{
"id": "EDGE-01", "group": "Edge cases",
"description": "็ฉบๆ–‡ๆœฌ",
"payload": {"text": ""},
"expected": set(),
},
]
# โ”€โ”€ HTTP ่ฐƒ็”จ + ่ฎกๆ—ถ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@dataclass
class CallResult:
case_id: str
status: int
elapsed_ms: float
entities: list[dict] = field(default_factory=list)
labels_used: list[str] = field(default_factory=list)
error: str | None = None
def post_extract(payload: dict, timeout: int = 60) -> CallResult:
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
EXTRACT,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
t0 = time.perf_counter()
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
elapsed = (time.perf_counter() - t0) * 1000
data = json.loads(resp.read().decode())
return CallResult(
case_id="",
status=resp.status,
elapsed_ms=elapsed,
entities=data.get("entities", []),
labels_used=data.get("labels_used", []),
)
except urllib.error.HTTPError as e:
elapsed = (time.perf_counter() - t0) * 1000
return CallResult(case_id="", status=e.code, elapsed_ms=elapsed,
error=e.read().decode("utf-8", errors="replace"))
except Exception as e:
elapsed = (time.perf_counter() - t0) * 1000
return CallResult(case_id="", status=0, elapsed_ms=elapsed, error=str(e))
# โ”€โ”€ ๅฅๅบทๆฃ€ๆŸฅ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def check_health() -> tuple[bool, float, str]:
t0 = time.perf_counter()
try:
with urllib.request.urlopen(HEALTH, timeout=30) as resp:
elapsed = (time.perf_counter() - t0) * 1000
return resp.status == 200, elapsed, resp.read().decode()
except Exception as e:
return False, (time.perf_counter() - t0) * 1000, str(e)
# โ”€โ”€ ๆŠฅๅ‘Š็”Ÿๆˆ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def write_report(results: list[tuple[dict, CallResult]], health: tuple[bool, float, str]):
buf = io.StringIO()
w = buf.write
w("# ่ฟœ็ซฏ API ๆต‹่ฏ•ๆŠฅๅ‘Š\n\n")
w(f"- ๆœๅŠกๅœฐๅ€๏ผš`{BASE_URL}`\n")
w(f"- ๆต‹่ฏ•ๆ—ถ้—ด๏ผš{time.strftime('%Y-%m-%d %H:%M:%S')}\n")
ok, hms, hbody = health
w(f"- ๅฅๅบทๆฃ€ๆŸฅ๏ผš{'โœ“ OK' if ok else 'โœ— FAIL'} ({hms:.0f}ms) โ€” {hbody}\n")
w(f"- ็”จไพ‹ๆ€ปๆ•ฐ๏ผš{len(results)}\n\n")
# โ”€โ”€ ๆฑ‡ๆ€ป่กจ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
w("## ไธ€ใ€ๆฑ‡ๆ€ป\n\n")
w("| ็”จไพ‹ | ๆ่ฟฐ | HTTP | ๅฎžไฝ“ๆ•ฐ | ๅฌๅ›ž | ่€—ๆ—ถ |\n")
w("|---|---|---|---|---|---|\n")
total_ms = 0.0
pass_n = 0
for case, res in results:
expected = case.get("expected", set())
found = {e["text"] for e in res.entities}
hit = len(expected & found)
recall = f"{hit}/{len(expected)}" if expected else "โ€”"
ok_mark = "โœ“" if res.status == 200 else "โœ—"
w(f"| **{case['id']}** | {case['description']} | {ok_mark} {res.status} | "
f"{len(res.entities)} | {recall} | {res.elapsed_ms:.0f}ms |\n")
if res.status == 200:
pass_n += 1
total_ms += res.elapsed_ms
w(f"\n- ้€š่ฟ‡็އ๏ผš**{pass_n}/{len(results)}**\n")
w(f"- ็ดฏ่ฎก่€—ๆ—ถ๏ผš**{total_ms:.0f}ms**๏ผˆๅนณๅ‡ {total_ms/len(results):.0f}ms/่ฏทๆฑ‚๏ผ‰\n\n")
# โ”€โ”€ ๅˆ†็ป„่ฏฆๆƒ… โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
groups: dict[str, list] = {}
for case, res in results:
groups.setdefault(case["group"], []).append((case, res))
w("## ไบŒใ€ๅˆ†็ป„่ฏฆ็ป†็ป“ๆžœ\n\n")
for group_name, items in groups.items():
w(f"### {group_name}\n\n")
for case, res in items:
w(f"#### {case['id']} ยท {case['description']}\n\n")
w("**่ฏทๆฑ‚**\n```json\n")
w(json.dumps(case["payload"], ensure_ascii=False, indent=2))
w("\n```\n\n")
w(f"**ๅ“ๅบ”**๏ผšHTTP {res.status} ยท {res.elapsed_ms:.0f}ms ยท "
f"{len(res.entities)} ไธชๅฎžไฝ“\n\n")
if res.error:
w(f"```\nERROR: {res.error}\n```\n\n")
continue
if res.entities:
w("| ๆ–‡ๆœฌ | ๆ ‡็ญพ | ็ฝฎไฟกๅบฆ | ่ตทๆญข |\n|---|---|---|---|\n")
for e in res.entities:
w(f"| `{e['text']}` | {e['label']} | {e['score']:.2f} | "
f"{e['start']}โ€“{e['end']} |\n")
else:
w("_ๆœช่ฏ†ๅˆซๅˆฐๅฎžไฝ“_\n")
expected = case.get("expected", set())
if expected:
found = {e["text"] for e in res.entities}
hits = expected & found
misses = expected - found
w(f"\n**ๆœŸๆœ›ๅ‘ฝไธญ** {len(hits)}/{len(expected)}๏ผš")
w(", ".join(f"`{x}`" for x in expected) + " \n")
if misses:
w(f"**ๆœชๅ‘ฝไธญ**๏ผš{', '.join(f'`{x}`' for x in misses)} \n")
mnc = case.get("must_not_contain", set())
if mnc:
bad = {e["text"] for e in res.entities} & mnc
if bad:
w(f"\n> โš ๏ธ **่พน็•Œ้”™่ฏฏ**๏ผš{bad}\n")
else:
w(f"\n> โœ“ ่พน็•Œๆญฃ็กฎ๏ผˆๆœชๅ‡บ็Žฐ {mnc}๏ผ‰\n")
w("\n")
# โ”€โ”€ ๆ€ง่ƒฝ่šๅˆ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
w("## ไธ‰ใ€ๆŒ‰่ทฏ็”ฑๅˆ†็ป„ๆ€ง่ƒฝ\n\n")
by_group: dict[str, list[float]] = {}
for case, res in results:
if res.status == 200:
by_group.setdefault(case["group"], []).append(res.elapsed_ms)
w("| ๅˆ†็ป„ | ็”จไพ‹ๆ•ฐ | ๆœ€ๅฟซ | ๆœ€ๆ…ข | ๅนณๅ‡ |\n|---|---|---|---|---|\n")
for g, times in by_group.items():
w(f"| {g} | {len(times)} | {min(times):.0f}ms | "
f"{max(times):.0f}ms | {sum(times)/len(times):.0f}ms |\n")
REPORT.parent.mkdir(parents=True, exist_ok=True)
REPORT.write_text(buf.getvalue(), encoding="utf-8")
print(f"\nReport: {REPORT.resolve()}")
# โ”€โ”€ ไธป็จ‹ๅบ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def main():
print(f"Target: {BASE_URL}")
health = check_health()
print(f"Health: {'OK' if health[0] else 'FAIL'} ({health[1]:.0f}ms)")
if not health[0]:
print(f" -> {health[2]}")
return
results: list[tuple[dict, CallResult]] = []
for case in CASES:
print(f" {case['id']:8s} ", end="", flush=True)
res = post_extract(case["payload"])
res.case_id = case["id"]
results.append((case, res))
status = "OK" if res.status == 200 else f"FAIL({res.status})"
print(f"{status:8s} {res.elapsed_ms:6.0f}ms {len(res.entities)} entities")
write_report(results, health)
if __name__ == "__main__":
main()