Source code for rhoknp.units.sentence

import logging
import re
from typing import TYPE_CHECKING, Optional

try:
    from typing import override  # type: ignore[attr-defined]
except ImportError:
    from typing_extensions import override

from rhoknp.cohesion import EntityManager, Pas
from rhoknp.props.named_entity import NamedEntity
from rhoknp.units.base_phrase import BasePhrase
from rhoknp.units.clause import Clause
from rhoknp.units.morpheme import Morpheme
from rhoknp.units.phrase import Phrase
from rhoknp.units.unit import Unit
from rhoknp.utils.comment import extract_did_and_sid, is_comment_line

if TYPE_CHECKING:
    from rhoknp.units.document import Document

logger = logging.getLogger(__name__)


[docs] class Sentence(Unit): """文クラス. Args: text: 文の文字列. """ EOS = "EOS" SID_PAT = re.compile(r"^(?P<sid>(?P<did>[a-zA-Z\d\-_]*?)-?\d*)$") SID_PAT_KWDLC = re.compile(r"^(?P<sid>(?P<did>w\d{6}-\d{10})(-\d+){1,2})$") SID_PAT_WAC = re.compile(r"^(?P<sid>(?P<did>wiki\d{8})(-\d{2})(-\d{2})?)$") count = 0 def __init__(self, text: str | None = None) -> None: super().__init__() if text is not None: self.text = text.replace("\r", "").replace("\n", "") Clause.count = 0 Phrase.count = 0 BasePhrase.count = 0 Morpheme.count = 0 EntityManager.reset() # parent unit self._document: "Document" | None = None # child units self._clauses: list[Clause] | None = None self._phrases: list[Phrase] | None = None self._morphemes: list[Morpheme] | None = None self.sent_id: str = "" self.doc_id: str = "" self.misc_comment: str = "" self.named_entities: list[NamedEntity] = [] self.index = self.count #: 文書全体におけるインデックス. Sentence.count += 1 @override def __post_init__(self) -> None: super().__post_init__() # Find named entities in the sentence. self.named_entities = [] if not self.is_knp_required(): for base_phrase in self.base_phrases: if "NE" not in base_phrase.features: continue assert isinstance(base_phrase.features["NE"], str) ne_value = base_phrase.features["NE"].replace(">", r"\>") fstring = f"<NE:{ne_value}>" candidate_morphemes = self.morphemes[: base_phrase.morphemes[-1].index + 1] named_entity = NamedEntity.from_fstring(fstring, candidate_morphemes) if named_entity is not None: self.named_entities.append(named_entity) @override def __hash__(self) -> int: return hash((self.sent_id, self.text)) @override def __eq__(self, other: object) -> bool: if not isinstance(other, type(self)): return False return self.sent_id == other.sent_id and self.text == other.text @property def global_index(self) -> int: """文書全体におけるインデックス.""" return self.index @property def parent_unit(self) -> Optional["Document"]: """上位の言語単位(文書).未登録なら None.""" return self._document @property def child_units(self) -> list[Clause] | list[Phrase] | list[Morpheme] | None: """下位の言語単位(節もしくは形態素)のリスト.解析結果にアクセスできないなら None. .. note:: KNP によって解析済みなら節, Jumanpp によって解析済みなら形態素のリストを返却. KNP による素性が付与されていない場合は節境界が判断できないため文節を返却. """ if self._clauses is not None: return self._clauses elif self._phrases is not None: return self._phrases elif self._morphemes is not None: return self._morphemes return None @property def did(self) -> str: """文書 ID(doc_id のエイリアス).""" return self.doc_id @did.setter def did(self, did: str) -> None: """文書 ID(doc_id のエイリアス). Args: did: 文書 ID. """ self.doc_id = did @property def sid(self) -> str: """文 ID(sent_id のエイリアス).""" return self.sent_id @sid.setter def sid(self, sid: str) -> None: """文 ID(sent_id のエイリアス). Args: sid: 文 ID. """ self.sent_id = sid @property def document(self) -> "Document": """文書. Raises: AttributeError: 解析結果にアクセスできない場合. """ if self._document is None: raise AttributeError("document has not been set") return self._document @document.setter def document(self, document: "Document") -> None: """文書. Args: document: 文書. """ self._document = document @property def clauses(self) -> list[Clause]: """節のリスト. Raises: AttributeError: 解析結果にアクセスできない場合. """ if self._clauses is None: raise AttributeError("clauses have not been set") return self._clauses @clauses.setter def clauses(self, clauses: list[Clause]) -> None: """節のリスト. Args: clauses: 節のリスト. """ for clause in clauses: clause.sentence = self self._clauses = clauses @property def phrases(self) -> list[Phrase]: """文節のリスト. Raises: AttributeError: 解析結果にアクセスできない場合. """ if self._phrases is not None: return self._phrases if self._clauses is not None: return [phrase for clause in self.clauses for phrase in clause.phrases] raise AttributeError("phrases have not been set") @phrases.setter def phrases(self, phrases: list[Phrase]) -> None: """文節のリスト. Args: phrases: 文節のリスト. """ for phrase in phrases: phrase.sentence = self self._phrases = phrases @property def base_phrases(self) -> list[BasePhrase]: """基本句のリスト. Raises: AttributeError: 解析結果にアクセスできない場合. """ return [base_phrase for phrase in self.phrases for base_phrase in phrase.base_phrases] @property def morphemes(self) -> list[Morpheme]: """形態素のリスト. Raises: AttributeError: 解析結果にアクセスできない場合. """ if self._clauses is not None: return [morpheme for clause in self.clauses for morpheme in clause.morphemes] if self._phrases is not None: return [morpheme for phrase in self.phrases for morpheme in phrase.morphemes] if self._morphemes is not None: return self._morphemes raise AttributeError("morphemes have not been set") @morphemes.setter def morphemes(self, morphemes: list[Morpheme]) -> None: """形態素のリスト. Args: morphemes: 形態素のリスト. """ for morpheme in morphemes: morpheme.sentence = self self._morphemes = morphemes @property def comment(self) -> str: """コメント行.""" ret = "" if self.sent_id: ret += f"S-ID:{self.sent_id} " if self.misc_comment: ret += f"{self.misc_comment} " if ret != "": ret = "# " + ret return ret.rstrip(" ") @comment.setter def comment(self, comment: str) -> None: """コメント行. Args: comment: コメント行. """ doc_id, sent_id, rest = extract_did_and_sid( comment, patterns=[self.SID_PAT_KWDLC, self.SID_PAT_WAC, self.SID_PAT] ) if sent_id is not None: self.sent_id = sent_id if doc_id is not None: self.doc_id = doc_id self.misc_comment = rest @property def pas_list(self) -> list[Pas]: """述語項構造のリスト. Raises: AttributeError: 解析結果にアクセスできない場合. """ return [base_phrase.pas for base_phrase in self.base_phrases if not base_phrase.pas.is_empty()]
[docs] @classmethod def from_raw_text(cls, text: str, post_init: bool = True) -> "Sentence": """文クラスのインスタンスを文の文字列から初期化. Args: text: 文の文字列. post_init: インスタンス作成後の追加処理を行うなら True. Example: >>> from rhoknp import Sentence >>> text = "天気が良かったので散歩した。" >>> sent = Sentence(text) """ sentence = cls(text="") for line in text.split("\n"): if line.strip() == "": continue if is_comment_line(line): sentence.comment = line else: sentence.text += line.replace("\r", "") if post_init is True: sentence.__post_init__() return sentence
[docs] @classmethod def from_jumanpp(cls, jumanpp_text: str, post_init: bool = True) -> "Sentence": """文クラスのインスタンスを Juman++ の解析結果から初期化. Args: jumanpp_text: Juman++ の解析結果. post_init: インスタンス作成後の追加処理を行うなら True. Raises: ValueError: 解析結果読み込み中にエラーが発生した場合. Example: >>> from rhoknp import Sentence >>> jumanpp_text = \"\"\" ... # S-ID:1 ... 天気 てんき 天気 名詞 6 普通名詞 1 * 0 * 0 "代表表記:天気/てんき カテゴリ:抽象物" ... が が が 助詞 9 格助詞 1 * 0 * 0 NIL ... 良かった よかった 良い 形容詞 3 * 0 イ形容詞アウオ段 18 タ形 8 "代表表記:良い/よい 反義:形容詞:悪い/わるい" ... ので ので のだ 助動詞 5 * 0 ナ形容詞 21 ダ列タ系連用テ形 12 NIL ... 散歩 さんぽ 散歩 名詞 6 サ変名詞 2 * 0 * 0 "代表表記:散歩/さんぽ ドメイン:レクリエーション カテゴリ:抽象物" ... した した する 動詞 2 * 0 サ変動詞 16 タ形 10 "代表表記:する/する 自他動詞:自:成る/なる 付属動詞候補(基本)" ... 。 。 。 特殊 1 句点 1 * 0 * 0 NIL ... EOS ... \"\"\" >>> sent = Sentence.from_jumanpp(jumanpp_text) """ sentence = cls() morphemes: list[Morpheme] = [] jumanpp_lines: list[str] = [] for line in jumanpp_text.split("\n"): if line.strip() == "": continue if is_comment_line(line): sentence.comment = line continue if Morpheme.is_morpheme_line(line): if jumanpp_lines: morphemes.append(Morpheme.from_jumanpp("\n".join(jumanpp_lines))) jumanpp_lines = [] jumanpp_lines.append(line) continue if Morpheme.is_homograph_line(line): jumanpp_lines.append(line) continue if line.strip() == cls.EOS: break raise ValueError(f"malformed line: {line}") else: logger.warning(f"sentence does not end with EOS: {jumanpp_lines}") if jumanpp_lines: morphemes.append(Morpheme.from_jumanpp("\n".join(jumanpp_lines))) sentence.morphemes = morphemes if post_init is True: sentence.__post_init__() return sentence
[docs] @classmethod def from_knp(cls, knp_text: str, post_init: bool = True) -> "Sentence": """文クラスのインスタンスを KNP の解析結果から初期化. Args: knp_text: KNP の解析結果. post_init: インスタンス作成後の追加処理を行うなら True. Raises: ValueError: 解析結果読み込み中にエラーが発生した場合. Example: >>> from rhoknp import Sentence >>> knp_text = \"\"\" ... # S-ID:1 ... * 1D ... + 1D ... 天気 てんき 天気 名詞 6 普通名詞 1 * 0 * 0 "代表表記:天気/てんき カテゴリ:抽象物" ... が が が 助詞 9 格助詞 1 * 0 * 0 NIL ... * 2D ... + 2D <節-区切><節-主辞> ... 良かった よかった 良い 形容詞 3 * 0 イ形容詞アウオ段 18 タ形 8 "代表表記:良い/よい 反義:形容詞:悪い/わるい" ... ので ので のだ 助動詞 5 * 0 ナ形容詞 21 ダ列タ系連用テ形 12 NIL ... * -1D ... + -1D <節-区切><節-主辞> ... 散歩 さんぽ 散歩 名詞 6 サ変名詞 2 * 0 * 0 "代表表記:散歩/さんぽ ドメイン:レクリエーション カテゴリ:抽象物" ... した した する 動詞 2 * 0 サ変動詞 16 タ形 10 "代表表記:する/する 自他動詞:自:成る/なる 付属動詞候補(基本)" ... 。 。 。 特殊 1 句点 1 * 0 * 0 NIL ... EOS ... \"\"\" >>> sent = Sentence.from_knp(knp_text) """ lines = knp_text.split("\n") sentence = cls() has_clause_boundary = any("節-区切" in line for line in lines if BasePhrase.is_base_phrase_line(line)) clauses: list[Clause] = [] phrases: list[Phrase] = [] child_lines: list[str] = [] is_clause_end = False for line in lines: if line.strip() == "": continue if is_comment_line(line): sentence.comment = line continue if Phrase.is_phrase_line(line): if has_clause_boundary and is_clause_end and child_lines: clauses.append(Clause.from_knp("\n".join(child_lines))) child_lines = [] is_clause_end = False elif has_clause_boundary is False and child_lines: phrases.append(Phrase.from_knp("\n".join(child_lines))) child_lines = [] child_lines.append(line) continue if BasePhrase.is_base_phrase_line(line): if "節-区切" in line: is_clause_end = True child_lines.append(line) continue if Morpheme.is_morpheme_line(line) or Morpheme.is_homograph_line(line): child_lines.append(line) continue if line.strip() == cls.EOS: break raise ValueError(f"malformed line: {line}") else: logger.warning(f"sentence does not end with EOS: {child_lines}") if child_lines: if has_clause_boundary: clauses.append(Clause.from_knp("\n".join(child_lines))) else: phrases.append(Phrase.from_knp("\n".join(child_lines))) if has_clause_boundary: sentence.clauses = clauses else: sentence.phrases = phrases if post_init is True: sentence.__post_init__() return sentence
[docs] def has_document(self) -> bool: """文書が設定されていたら True.""" return self._document is not None
[docs] def is_jumanpp_required(self) -> bool: """Juman++ による形態素解析がまだなら True.""" return self._morphemes is None and self._phrases is None and self._clauses is None
[docs] def is_knp_required(self) -> bool: """KNP による構文解析がまだなら True.""" return self._phrases is None and self._clauses is None
[docs] def is_clause_tag_required(self) -> bool: """KNP による節-主辞・節-区切のタグ付与がまだなら True.""" return self._clauses is None
[docs] def to_raw_text(self) -> str: """生テキストフォーマットに変換.""" ret = "" if self.comment != "": ret += self.comment + "\n" ret += self.text.rstrip("\n") + "\n" return ret
[docs] def to_jumanpp(self) -> str: """Juman++ フォーマットに変換. Raises: AttributeError: 解析結果にアクセスできない場合. """ ret = "" if self.comment != "": ret += self.comment + "\n" ret += "".join(morpheme.to_jumanpp() for morpheme in self.morphemes) + self.EOS + "\n" return ret
[docs] def to_knp(self) -> str: """KNP フォーマットに変換. Raises: AttributeError: 解析結果にアクセスできない場合. """ ret = "" if self.comment != "": ret += self.comment + "\n" ret += "".join(child.to_knp() for child in self._clauses or self.phrases) ret += self.EOS + "\n" return ret
[docs] def reparse(self) -> "Sentence": """文を再構築. .. note:: 解析結果に対する編集を有効にする際に実行する必要がある. """ if not self.is_knp_required(): return Sentence.from_knp(self.to_knp()) if not self.is_jumanpp_required(): return Sentence.from_jumanpp(self.to_jumanpp()) return Sentence.from_raw_text(self.to_raw_text())