Class: VivlioStarter::CLI::IndexCommands::IndexCandidateExtractor

Inherits:
Object
  • Object
show all
Defined in:
lib/vivlio_starter/cli/index/index_candidate_extractor.rb

Overview

索引候補語自動抽出クラス

Constant Summary collapse

TERM_CHAR =

定義文から語を切り出すときに、語の一部として許す文字。

素の . で 20 文字を取ると、文の途中から機械的に切り出すことになり 「た章のみです。 は本の目次(章立て)」のような文の断片**が候補になる。 実測(本書 27 章)では候補 4,053 件のうち 1,359 件がこの類で、 スコア分布と順位を歪め、candidate_pool を上げても本物が出てこない原因だった。

/ . - は許す——PDF/X-1a Terminal.app 10-20行目 のような 正当な語を巻き込まないため。

%r{[^\s。、!?…「」『』()()\[\]{}#*|`>~::;;,,\\]}
DEFINITION_PATTERNS =

定義パターン(「〜とは」「〜について」など)

[
  /(#{TERM_CHAR}{2,20})とは[、,]?[^。]*(?:である|です|を意味|を指|という)/,
  /(#{TERM_CHAR}{2,20})(?:について|に関して)(?:は|の)/,
  /(#{TERM_CHAR}{2,20})(?:を|が)(?:定義|説明|解説)/,
  /「(#{TERM_CHAR}{2,20})」(?:とは|は|について)/,
  /(#{TERM_CHAR}{2,20})(?:の概念|の定義|の意味)/
].freeze
JUNK_TERM_PATTERN =

語として成立しない文字列。定義文の切り出しや名詞連続に混ざる残骸を落とす。 記法の断片(###MATTR **Linux |画像)、句読点をまたいだ文、 記号で始まる・終わる語が対象。

/[。、!?\r\n\t#*|`>~\[\]()()「」『』【】]|:{3}|\A[[:space:]\-.::]|[[:space:]\-.::]\z/
SINGLE_NOUN_MIN_LENGTH =

MeCab が 1 語と認識する複合語(「相対性理論」など)を拾う下限。

名詞連続の経路は 2 語以上を対象にするため、MeCab の辞書に 1 語として 載っている専門用語が丸ごと漏れていた(「特殊相対性理論」は 「特殊」+「相対性理論」の 2 語なので拾えるのに、「相対性理論」単体は漏れる)。 短い単独名詞まで拾うと「本」「方法」「場合」で埋まるため長さで絞る。

5
TECHNICAL_TERM_PATTERNS =

専門用語パターン(カタカナ語、英字語など)

[
  /[ァ-ヶー]{3,}/, # カタカナ3文字以上
  /[A-Z][a-zA-Z]{2,}/, # 英語の単語
  /[A-Z]{2,}/ # 略語(HTML, CSS など)
].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeIndexCandidateExtractor

Returns a new instance of IndexCandidateExtractor.



75
76
77
78
79
80
81
# File 'lib/vivlio_starter/cli/index/index_candidate_extractor.rb', line 75

def initialize
  @documents = {}
  @scoring = ScoringEngine.new
  @term_contexts = Hash.new { |h, k| h[k] = [] }
  @yomi_inferrer = YomiInferrer.new
  @context_width = load_context_width
end

Instance Attribute Details

#documentsObject (readonly)

Returns the value of attribute documents.



67
68
69
# File 'lib/vivlio_starter/cli/index/index_candidate_extractor.rb', line 67

def documents
  @documents
end

#scoringObject (readonly)

Returns the value of attribute scoring.



67
68
69
# File 'lib/vivlio_starter/cli/index/index_candidate_extractor.rb', line 67

def scoring
  @scoring
end

#term_contextsObject (readonly)

Returns the value of attribute term_contexts.



67
68
69
# File 'lib/vivlio_starter/cli/index/index_candidate_extractor.rb', line 67

def term_contexts
  @term_contexts
end

Instance Method Details

#all_candidatesObject

全ての候補語を取得



70
# File 'lib/vivlio_starter/cli/index/index_candidate_extractor.rb', line 70

def all_candidates = @scoring.terms

#extract_from_chapters!(chapters) ⇒ Object

全章を解析して索引候補を抽出

Parameters:

  • chapters (Array<String>)

    対象章のファイル名リスト



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/vivlio_starter/cli/index/index_candidate_extractor.rb', line 85

def extract_from_chapters!(chapters)
  Common.log_action('索引候補の自動抽出を開始します...')

  # ドキュメントを読み込み (contents/ 配下のみ)
  chapters.each do |chapter|
    md_file = File.join(Common::CONTENTS_DIR, "#{chapter}.md")

    unless File.exist?(md_file)
      Common.log_warn("索引候補抽出: contents/ に #{chapter}.md が見つからないためスキップします")
      next
    end

    content = File.read(md_file, encoding: 'utf-8')
    @documents[chapter] = content
  end

  # 各種抽出を実行
  extract_definition_patterns!
  extract_technical_terms!
  extract_noun_sequences! if @yomi_inferrer.available?

  # TF-IDF スコアリング
  calculate_tfidf_scores!

  Common.log_success("#{@scoring.terms.size} 件の候補語を抽出しました")
end

#score_terms(terms) ⇒ Hash{String => Float}

辞書に登録済みの用語へ、候補と同じ式でスコアを与える。

帯の判定(推奨候補・見直し候補)は登録語と未登録候補を同じ土俵で 並べて決めるので、候補として抽出されなかった語——手動マークアップや ライブラリ取込——にも順位が要る。

候補側と揃わない点が 1 つある: 定義パターンと名詞連続の性質は 本文走査で付くものなので、ここでは判定しない(語の形から分かる :technical だけ付ける)。そのぶん控えめなスコアになるため、 手動マークアップ由来の語は見直し候補へ出さない(呼び出し側の責務)。

Parameters:

  • terms (Array<Hash>)

    辞書の用語('term' と任意の 'pattern' を持つ)

Returns:

  • (Hash{String => Float})

    用語 → スコア(原稿に出現しない語は含まない)



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/vivlio_starter/cli/index/index_candidate_extractor.rb', line 125

def score_terms(terms)
  return {} if @documents.empty?

  engine = ScoringEngine.new
  doc_count = @documents.size
  contents = @documents.values

  terms.each do |entry|
    name = entry['term'].to_s
    next if name.empty?

    tf = 0
    df = 0
    pattern = term_regexp(entry)
    contents.each do |content|
      n = content.scan(pattern).size
      next if n.zero?

      tf += n
      df += 1
    end
    # 1 回も出てこない語は記録しない。observe は tf を見て黙るが mark は
    # 語の綴りだけで通るため、性質ボーナスだけのスコアが残っていた。
    # すると出現ゼロの語が「スコア: 15.0」と表示され、レビューで
    # 「[原稿に出現しません]」に振り分けられない(死語が生きて見える)。
    next if tf.zero?

    engine.mark(name, :technical) if TECHNICAL_TERM_PATTERNS.any? { name.match?(it) }
    engine.observe(name, tf:, df:, doc_count:)
  end

  engine.scores
end

#term_scoresObject

用語 → スコア。算出そのものは ScoringEngine が持つ(重みの二重管理を作らない)。



73
# File 'lib/vivlio_starter/cli/index/index_candidate_extractor.rb', line 73

def term_scores = @term_scores ||= @scoring.scores