module Gekanator class QuestionSuggestionAiConverter # Temporary heuristic converter for #361. # This creates pending ai_generated questions without external LLM calls; # accepted questions are still distributed only after admin approval. TITLE_LENGTH_RE = /\Aタイトルは\s*(\d+)\s*文字以上[??]\z/ ORIGINAL_YEAR_RE = /\Aオリジナルの投稿年は\s*(\d{4})\s*年[??]\z/ ORIGINAL_MONTH_RE = /\Aオリジナルの投稿月は\s*(\d{1,2})\s*月[??]\z/ ORIGINAL_MONTH_DAY_RE = /\Aオリジナルの投稿日は\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日[??]\z/ TITLE_CONTAINS_RE = /\A題名に「(.+?)」が含まれる[??]\z/ SOURCE_RE = /\A(.+?)\s+の投稿を思[ひい]浮かべて[ゐい]る[??]\z/ def self.call(...) = new(...).call def initialize suggestion:, user: @suggestion = suggestion @user = user end def call suggestion.with_lock do existing = existing_generated_question return existing if existing run = suggestion.gekanator_ai_runs.create!( model: 'heuristic_converter_v1', status: 'running', input_tokens: 0, output_tokens: 0, estimated_cost_jpy: 0) question_attributes = build_question question = question_attributes && GekanatorQuestion.create!( **question_attributes, source: 'ai_generated', status: 'pending', gekanator_question_suggestion: suggestion, created_by: user) run.update!(status: question ? 'succeeded' : 'failed') question end rescue => error run&.update!(status: 'failed') if run&.persisted? && run.status != 'failed' raise error end private attr_reader :suggestion, :user def existing_generated_question suggestion .gekanator_questions .where(source: 'ai_generated') .order(id: :desc) .first end def build_question text = normalized_text return nil if text.blank? structured_question_for(text) || post_similarity_question_for(text) end def normalized_text suggestion.question_text.to_s.gsub(/[[:space:]]+/, ' ').strip end def structured_question_for text case text when TITLE_LENGTH_RE length = Regexp.last_match(1).to_i return nil if length <= 0 { text:, kind: 'title', condition: { type: 'title-length-at-least', length: }, priority_weight: 0.95 } when /\A題名に英数字が混じって[ゐい]る[??]\z/ { text: '題名に英数字が混じってゐる?', kind: 'title', condition: { type: 'title-has-ascii' }, priority_weight: 0.95 } when ORIGINAL_YEAR_RE year = Regexp.last_match(1).to_i { text:, kind: 'original_date', condition: { type: 'original-year', year: }, priority_weight: 0.95 } when ORIGINAL_MONTH_RE month = Regexp.last_match(1).to_i return nil unless month.between?(1, 12) { text:, kind: 'original_date', condition: { type: 'original-month', month: }, priority_weight: 0.95 } when ORIGINAL_MONTH_DAY_RE month = Regexp.last_match(1).to_i day = Regexp.last_match(2).to_i return nil unless month.between?(1, 12) && day.between?(1, 31) { text:, kind: 'original_date', condition: { type: 'original-month-day', monthDay: "#{ month }-#{ day }" }, priority_weight: 0.95 } when TITLE_CONTAINS_RE title_text = Regexp.last_match(1).to_s.strip return nil if title_text.blank? { text: "題名に「#{ title_text }」が含まれる?", kind: 'title', condition: { type: 'title-contains', text: title_text }, priority_weight: 0.95 } when SOURCE_RE host = Regexp.last_match(1).to_s.strip return nil if host.blank? { text:, kind: 'source', condition: { type: 'source', host: }, priority_weight: 0.95 } else nil end end def post_similarity_question_for text return nil if suggestion.answer == 'unknown' { text:, kind: 'post_similarity', condition: { type: 'post-similarity', postId: suggestion.gekanator_game.correct_post_id, answer: suggestion.answer, threshold: 0.65 }, priority_weight: 1.0 } end end end