# SPDX-FileCopyrightText: 2016-2026 PyThaiNLP Project
# SPDX-FileCopyrightText: 2020 Nakhun Chumpolsathien
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: Apache-2.0
"""
Tokenize Thai text into sentences with the ThaiSum sentence segmentor.
This is an implementation of the sentence segmentor by
Nakhun Chumpolsathien, 2020. The original code is from
https://github.com/nakhunchumpolsathien/ThaiSum
Cite:
.. code-block:: bibtex
@mastersthesis{chumpolsathien_2020,
title={Using Knowledge Distillation from Keyword Extraction
to Improve the Informativeness of Neural Cross-lingual
Summarization},
author={Chumpolsathien, Nakhun},
year={2020},
school={Beijing Institute of Technology}
}
"""
from __future__ import annotations
import math
import re
from pythainlp.tokenize import word_tokenize
_TH_ALPHABETS: str = "([ก-๙])"
_TH_CONJUNCTION: str = "(ทำให้|โดย|เพราะ|นอกจากนี้|แต่|กรณีที่|หลังจากนี้|ต่อมา|ภายหลัง|นับตั้งแต่|หลังจาก|ซึ่งเหตุการณ์|ผู้สื่อข่าวรายงานอีก|ส่วนที่|ส่วนสาเหตุ|ฉะนั้น|เพราะฉะนั้น|เพื่อ|เนื่องจาก|จากการสอบสวนทราบว่า|จากกรณี|จากนี้|อย่างไรก็ดี)"
_TH_CITE: str = "(กล่าวว่า|เปิดเผยว่า|รายงานว่า|ให้การว่า|เผยว่า|บนทวิตเตอร์ว่า|แจ้งว่า|พลเมืองดีว่า|อ้างว่า)"
_TH_KA_KRUB: str = "(ครับ|ค่ะ)"
_TH_STOP_AFTER: str = "(หรือไม่|โดยเร็ว|แล้ว|อีกด้วย)"
_TH_STOP_BEFORE: str = "(ล่าสุด|เบื้องต้น|ซึ่ง|ทั้งนี้|แม้ว่า|เมื่อ|แถมยัง|ตอนนั้น|จนเป็นเหตุให้|จากนั้น|อย่างไรก็ตาม|และก็|อย่างใดก็ตาม|เวลานี้|เช่น|กระทั่ง)"
_DIGIT: str = "([0-9])"
_TH_TITLE: str = "(นาย|นาง|นางสาว|เด็กชาย|เด็กหญิง|น.ส.|ด.ช.|ด.ญ.)"
# Phrases that contain a splitting word.
# Replace them with a placeholder before splitting, and restore them after.
# The order matters: a replacement can change what later ones match.
_PROTECT: tuple[tuple[str, str], ...] = (
("โดยเร็ว", "<rth_Doeirew>"),
("เพื่อน", "<rth_friend>"),
("แต่ง", "<rth_but>"),
("โดยสาร", "<rth_passenger>"),
("แล้วแต่", "<rth_leawtea>"),
("หรือเปล่า", "<rth_repraw>"),
("หรือไม่", "<rth_remai>"),
("จึงรุ่งเรืองกิจ", "<rth_tanatorn_lastname>"),
("ตั้งแต่", "<rth_tangtea>"),
("แต่ละ", "<rth_teala>"),
("วิตแล้ว", "<rth_chiwitleaw>"),
("โดยประ", "<rth_doipra>"),
("แต่หลังจากนั้น", "<rth_tealangjaknan>"),
("พรรคเพื่อ", "<for_party>"),
("แต่เนื่อง", "<rth_teaneung>"),
("เพื่อทำให้", "เพื่อ<rth_tamhai>"),
("ทำเพื่อ", "ทำ<rth_for>"),
("จึงทำให้", "จึง<tamhai>"),
("มาโดยตลอด", "<madoitalod>"),
("แต่อย่างใด", "<teayangdaikptam>"),
("แต่หลังจาก", "แต่<langjak>"),
("คงทำให้", "<rth_kongtamhai>"),
("แต่ทั้งนี้", "แต่<tangni>"),
("มีแต่", "มี<tea>"),
("เหตุที่ทำให้", "<hedteetamhai>"),
("โดยหลังจาก", "โดย<langjak>"),
("ซึ่งหลังจาก", "ซึ่ง<langjak>"),
("ตั้งโดย", "<rth_tangdoi>"),
("โดยตรง", "<rth_doitong>"),
("นั้นหรือ", "<rth_nanhlor>"),
("ซึ่งต้องทำให้", "ซึ่งต้อง<tamhai>"),
("ชื่อต่อมา", "ชื่อ<tomar>"),
("โดยเร่งด่วน", "<doi>เร่งด่วน"),
("ไม่ได้ทำให้", "ไม่ได้<tamhai>"),
("จะทำให้", "จะ<tamhai>"),
("จนทำให้", "จน<tamhai>"),
("เว้นแต่", "เว้น<rth_tea>"),
("ก็ทำให้", "ก็<tamhai>"),
(" ณ ตอนนั้น", " ณ <tonnan>"),
("บางส่วน", "บาง<rth_suan>"),
("หรือแม้แต่", "หรือ<rth_meatea>"),
("โดยทำให้", "โดย<tamhai>"),
("หรือเพราะ", "หรือ<rth_orbecause>"),
("มาแต่", "มา<rth_tea>"),
("แต่ไม่ทำให้", "แต่<maitamhai>"),
("ฉะนั้นเมื่อ", "ฉะนั้น<rth_moe>"),
("เพราะฉะนั้น", "เพราะ<rth_chanan>"),
("เพราะหลังจาก", "เพราะ<rth_langjak>"),
("สามารถทำให้", "สามารถ<rth_tamhai>"),
("อาจทำ", "อาจ<rth_tam>"),
("จะทำ", "จะ<rth_tam>"),
("และนอกจากนี้", "นอกจากนี้"),
("อีกทั้งเพื่อ", "อีกทั้ง<rth_for>"),
("ทั้งนี้เพื่อ", "ทั้งนี้<rth_for>"),
("เวลาต่อมา", "เวลา<rth_toma>"),
("อย่างไรก็ตาม", "อย่างไรก็ตาม"),
("อย่างไรก็ตามหลังจาก", "<stop>อย่างไรก็ตาม<rth_langjak>"),
("ซึ่งทำให้", "ซึ่ง<rth_tamhai>"),
("โดยประมาท", "<doi>ประมาท"),
("โดยธรรม", "<doi>ธรรม"),
("โดยสัจจริง", "<doi>สัจจริง"),
)
# Restore the placeholders; this is not the exact reverse of _PROTECT.
_RESTORE: tuple[tuple[str, str], ...] = (
("<rth_Doeirew>", "โดยเร็ว"),
("<rth_friend>", "เพื่อน"),
("<rth_but>", "แต่ง"),
("<rth_passenger>", "โดยสาร"),
("<rth_leawtea>", "แล้วแต่"),
("<rth_repraw>", "หรือเปล่า"),
("<rth_remai>", "หรือไม่"),
("<rth_tanatorn_lastname>", "จึงรุ่งเรืองกิจ"),
("<rth_tangtea>", "ตั้งแต่"),
("<rth_teala>", "แต่ละ"),
("<rth_chiwitleaw>", "วิตแล้ว"),
("<rth_doipra>", "โดยประ"),
("<rth_tealangjaknan>", "แต่หลังจากนั้น"),
("<for_party>", "พรรคเพื่อ"),
("<rth_teaneung>", "แต่เนื่อง"),
("เพื่อ<rth_tamhai>", "เพื่อทำให้"),
("ทำ<rth_for>", "ทำเพื่อ"),
("จึง<tamhai>", "จึงทำให้"),
("<madoitalod>", "มาโดยตลอด"),
("แต่<langjak>", "แต่หลังจาก"),
("แต่<tangni>", "แต่ทั้งนี้"),
("มี<tea>", "มีแต่"),
("<teayangdaikptam>", "แต่อย่างใด"),
("<rth_kongtamhai>", "คงทำให้"),
("<hedteetamhai>", "เหตุที่ทำให้"),
("โดย<langjak>", "โดยหลังจาก"),
("ซึ่ง<langjak>", "ซึ่งหลังจาก"),
("<rth_tangdoi>", "ตั้งโดย"),
("<rth_doitong>", "โดยตรง"),
("<rth_nanhlor>", "นั้นหรือ"),
("ซึ่งต้อง<tamhai>", "ซึ่งต้องทำให้"),
("ชื่อ<tomar>", "ชื่อต่อมา"),
("<doi>เร่งด่วน", "โดยเร่งด่วน"),
("ไม่ได้<tamhai>", "ไม่ได้ทำให้"),
("จะ<tamhai>", "จะทำให้"),
("จน<tamhai>", "จนทำให้"),
("เว้น<rth_tea>", "เว้นแต่"),
("ก็<tamhai>", "ก็ทำให้"),
(" ณ <tonnan>", " ณ ตอนนั้น"),
("บาง<rth_suan>", "บางส่วน"),
("หรือ<rth_meatea>", "หรือแม้แต่"),
("โดย<tamhai>", "โดยทำให้"),
("หรือ<rth_orbecause>", "หรือเพราะ"),
("มา<rth_tea>", "มาแต่"),
("แต่<maitamhai>", "แต่ไม่ทำให้"),
("ฉะนั้น<rth_moe>", "ฉะนั้นเมื่อ"),
("เพราะ<rth_chanan>", "เพราะฉะนั้น"),
("เพราะ<rth_langjak>", "เพราะหลังจาก"),
("สามารถ<rth_tamhai>", "สามารถทำให้"),
("อาจ<rth_tam>", "อาจทำ"),
("จะ<rth_tam>", "จะทำ"),
("อีกทั้ง<rth_for>", "อีกทั้งเพื่อ"),
("ทั้งนี้<rth_for>", "ทั้งนี้เพื่อ"),
("เวลา<rth_toma>", "เวลาต่อมา"),
("อย่างไรก็ตาม<rth_langjak>", "อย่างไรก็ตามหลังจาก"),
("ซึ่ง<rth_tamhai>", "ซึ่งทำให้"),
("<doi>ประมาท", "โดยประมาท"),
("<doi>ธรรม", "โดยธรรม"),
("<doi>สัจจริง", "โดยสัจจริง"),
)
# Words that start a new sentence unless a space follows soon after them:
# (keyword, distance from the keyword to the end of the tokens,
# distance from the keyword to the space) in token positions.
_SPLIT_KEYWORDS: tuple[tuple[str, int, int], ...] = (
("และ", 3, 5),
("หรือ", 3, 4),
("จึง", 2, 3),
)
# (pattern, replacement) pairs that mark sentence boundaries with "<stop>".
_BOUNDARY_RULES: tuple[tuple[re.Pattern[str], str], ...] = tuple(
(re.compile(pattern), repl)
for pattern, repl in (
(" " + _TH_STOP_BEFORE, "<stop>\\1"),
(_TH_KA_KRUB, "\\1<stop>"),
(_TH_CONJUNCTION, "<stop>\\1"),
(_TH_CITE, "\\1<stop>"),
(" " + _DIGIT + "[.]" + _TH_TITLE, "<stop>\\1.\\2"),
(" " + _DIGIT + _DIGIT + "[.]" + _TH_TITLE, "<stop>\\1\\2.\\3"),
(_TH_ALPHABETS + _TH_STOP_AFTER + " ", "\\1\\2<stop>"),
)
)
# Number of words in a sentence above which middle_cut() cuts it.
_MIDDLE_CUT_WORDS = 20
[docs]
def list_to_string(tokens: list[str]) -> str:
"""
Join a list of strings and collapse the whitespace.
:param list[str] tokens: list of strings
:return: joined string
:rtype: str
"""
string = "".join(tokens)
string = " ".join(string.split())
return string
def _remove_digit_spaces(sentence: str) -> str:
"""Remove a space before or after a digit."""
sentence_len = len(sentence)
for k in range(sentence_len):
if k == 0 or k + 1 >= sentence_len:
continue
if sentence[k].isdigit() and sentence[k - 1] == " ":
sentence = sentence[: k - 1] + sentence[k:]
sentence_len = len(sentence)
if (
k + 2 <= sentence_len
and sentence[k].isdigit()
and sentence[k + 1] == " "
):
sentence = sentence[: k + 1] + sentence[k + 2 :]
sentence_len = len(sentence)
return sentence
def _mark_middle_cuts(sentence: str, sentence_size: int) -> str:
"""Replace the spaces nearest to the partition points with "<stop>"."""
partition = math.floor(sentence_size / _MIDDLE_CUT_WORDS)
tokens = word_tokenize(sentence, keep_whitespace=True)
for i in range(partition):
middle_space = sentence_size / (partition + 1) * (i + 1)
spaces = [j for j, tok in enumerate(tokens) if tok == " "]
if spaces:
nearest = min(spaces, key=lambda j: abs(j - middle_space))
tokens[nearest] = "<stop>"
return list_to_string(tokens)
[docs]
def middle_cut(sentences: list[str]) -> list[str]:
"""
Split long sentences at the space nearest to the middle.
:param list[str] sentences: list of sentences
:return: list of sentences
:rtype: list[str]
"""
result_parts = []
for sentence in sentences:
sentence_size = len(word_tokenize(sentence, keep_whitespace=False))
cleaned = _remove_digit_spaces(sentence)
if sentence_size > _MIDDLE_CUT_WORDS:
result_parts.append(_mark_middle_cuts(cleaned, sentence_size))
else:
result_parts.append(cleaned)
all_sentences = (
s.strip() for part in result_parts for s in part.split("<stop>")
)
return list(filter(None, all_sentences))
def _find_split_positions(
tokens: list[str], keyword: str, end_gap: int, near_gap: int
) -> tuple[list[int], list[int]]:
"""
Find where to put "<stop>" around a keyword.
:param list[str] tokens: list of words
:param str keyword: keyword to look for
:param int end_gap: distance from the keyword to the end of the words
:param int near_gap: distance from the keyword to the next space
:return: positions of the spaces to replace with "<stop>",
and positions to insert "<stop>" before
:rtype: tuple[list[int], list[int]]
"""
last_position = len(tokens)
keyword_position = -1
space_position = -1
replace_positions: list[int] = []
insert_positions: list[int] = []
for i, tok in enumerate(tokens):
if tok == keyword:
keyword_position = i
if (
keyword_position != -1
and i > keyword_position
and tok == " "
and space_position == -1
and i - keyword_position != 1
):
space_position = i
if (
keyword_position != -1
and last_position - keyword_position == end_gap
):
insert_positions.append(last_position)
keyword_position = -1
space_position = -1
if space_position != -1:
if space_position - keyword_position < near_gap:
replace_positions.append(space_position)
else:
insert_positions.append(keyword_position)
keyword_position = -1
space_position = -1
return replace_positions, insert_positions
def _split_around_keyword(
text: str, keyword: str, end_gap: int, near_gap: int
) -> str:
tokens = word_tokenize(text.strip(), keep_whitespace=True)
replace_positions, insert_positions = _find_split_positions(
tokens, keyword, end_gap, near_gap
)
for position in replace_positions:
tokens[position] = "<stop>"
for position in insert_positions:
tokens.insert(position, "<stop>")
return list_to_string(tokens)
def _replace_all(text: str, pairs: tuple[tuple[str, str], ...]) -> str:
for old, new in pairs:
text = text.replace(old, new)
return text
def _mark_boundaries(text: str) -> str:
for pattern, repl in _BOUNDARY_RULES:
text = pattern.sub(repl, text)
# Move the closing quote before the final punctuation
text = text.replace(".”", "”.")
text = text.replace('."', '".')
text = text.replace('!"', '"!')
return text.replace('?"', '"?')
[docs]
class ThaiSentenceSegmentor:
"""Tokenize Thai text into sentences."""
[docs]
def split_into_sentences(
self, text: str, isMiddleCut: bool = False
) -> list[str]:
"""
Split text into sentences.
:param str text: text to be tokenized
:param bool isMiddleCut: also cut long sentences at the middle
:return: list of sentences
:rtype: list[str]
"""
text = f" {text} "
text = text.replace("\n", " ")
text = _replace_all(text, _PROTECT)
for keyword, end_gap, near_gap in _SPLIT_KEYWORDS:
if keyword in text:
text = _split_around_keyword(text, keyword, end_gap, near_gap)
text = _mark_boundaries(text)
text = _replace_all(text, _RESTORE)
text = text.replace("?", "?<stop>")
text = text.replace("!", "!<stop>")
text = text.replace("<prd>", ".")
sentences = text.split("<stop>")
sentences = [s for s in map(str.strip, sentences) if s and s != "nan"]
if isMiddleCut:
return middle_cut(sentences)
return sentences