このコミットが含まれているのは:
@@ -12,9 +12,35 @@ class GekanatorQuestionSuggestionsController < ApplicationController
|
||||
answer: params.require(:answer))
|
||||
|
||||
if suggestion.save
|
||||
render json: { id: suggestion.id }, status: :created
|
||||
render json: {
|
||||
id: suggestion.id,
|
||||
count: game.question_suggestions.count
|
||||
}, status: :created
|
||||
else
|
||||
render_validation_error suggestion
|
||||
end
|
||||
end
|
||||
|
||||
def ai_convert
|
||||
return head :not_found unless current_user&.admin?
|
||||
|
||||
suggestion = GekanatorQuestionSuggestion.find_by(id: params[:id])
|
||||
return head :not_found unless suggestion
|
||||
if Gekanator::AiRunBudget.exceeded_after_next_run?
|
||||
suggestion.gekanator_ai_runs.create!(
|
||||
model: 'budget_guard',
|
||||
status: 'blocked_budget',
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
estimated_cost_jpy: 0)
|
||||
return head :payment_required
|
||||
end
|
||||
|
||||
Gekanator::QuestionSuggestionAiConverter.call(
|
||||
suggestion: suggestion,
|
||||
user: current_user)
|
||||
head :no_content
|
||||
rescue NotImplementedError
|
||||
head :not_implemented
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
class GekanatorQuestionsController < ApplicationController
|
||||
def index
|
||||
return head :not_found unless current_user&.admin?
|
||||
|
||||
questions = GekanatorQuestion.accepted.order(priority_weight: :desc, id: :asc)
|
||||
|
||||
render json: {
|
||||
questions: questions.map { |question| question_json(question) }
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def question_json question
|
||||
{
|
||||
id: question_id_for(question),
|
||||
text: question.text,
|
||||
kind: question.kind,
|
||||
condition: condition_json(question.condition),
|
||||
source: question.source,
|
||||
priority_weight: question.priority_weight
|
||||
}
|
||||
end
|
||||
|
||||
def question_id_for question
|
||||
condition = condition_json(question.condition).deep_symbolize_keys
|
||||
|
||||
case condition[:type]
|
||||
when 'tag'
|
||||
"tag:#{ condition[:key] }"
|
||||
when 'source'
|
||||
"source:#{ condition[:host] }"
|
||||
when 'original-year'
|
||||
"original-year:#{ condition[:year] }"
|
||||
when 'original-month'
|
||||
"original-month:#{ condition[:month] }"
|
||||
when 'original-month-day'
|
||||
"original-month-day:#{ condition[:monthDay] || condition[:month_day] }"
|
||||
when 'title-length-greater-than'
|
||||
"title:length-greater-than:#{ condition[:length] }"
|
||||
when 'title-has-ascii'
|
||||
'title:ascii'
|
||||
else
|
||||
"catalog:#{ question.id }"
|
||||
end
|
||||
end
|
||||
|
||||
def condition_json condition
|
||||
json = condition.deep_dup.as_json
|
||||
|
||||
if json['type'] == 'original-month-day' && json['monthDay'].blank?
|
||||
json['monthDay'] = json.delete('month_day')
|
||||
end
|
||||
|
||||
json
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class GekanatorAiRun < ApplicationRecord
|
||||
STATUSES = ['pending', 'running', 'succeeded', 'failed', 'blocked_budget'].freeze
|
||||
|
||||
belongs_to :gekanator_question_suggestion
|
||||
|
||||
validates :model, presence: true, length: { maximum: 255 }
|
||||
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||
validates :input_tokens,
|
||||
presence: true,
|
||||
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
validates :output_tokens,
|
||||
presence: true,
|
||||
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
validates :estimated_cost_jpy,
|
||||
presence: true,
|
||||
numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
scope :this_month, lambda {
|
||||
where(created_at: Time.current.beginning_of_month..Time.current.end_of_month)
|
||||
}
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
class GekanatorQuestion < ApplicationRecord
|
||||
KINDS = ['tag', 'source', 'title', 'original_date'].freeze
|
||||
SOURCES = ['user_suggested', 'ai_generated', 'admin_curated'].freeze
|
||||
STATUSES = ['pending', 'accepted', 'rejected'].freeze
|
||||
|
||||
belongs_to :gekanator_question_suggestion, optional: true
|
||||
belongs_to :created_by, class_name: 'User', optional: true
|
||||
|
||||
validates :kind, presence: true, inclusion: { in: KINDS }
|
||||
validates :source, presence: true, inclusion: { in: SOURCES }
|
||||
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||
validates :text, presence: true, length: { maximum: 1000 }
|
||||
validates :condition, presence: true
|
||||
validates :priority_weight,
|
||||
presence: true,
|
||||
numericality: { greater_than: 0 }
|
||||
|
||||
scope :accepted, -> { where(status: 'accepted') }
|
||||
end
|
||||
@@ -1,9 +1,11 @@
|
||||
class GekanatorQuestionSuggestion < ApplicationRecord
|
||||
MAX_QUESTIONS_PER_GAME = 1
|
||||
MAX_QUESTIONS_PER_GAME = 3
|
||||
ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze
|
||||
|
||||
belongs_to :gekanator_game
|
||||
belongs_to :user
|
||||
has_many :gekanator_questions, dependent: :nullify
|
||||
has_many :gekanator_ai_runs, dependent: :destroy
|
||||
|
||||
validates :question_text, presence: true, length: { maximum: 1000 }
|
||||
validates :answer, presence: true, inclusion: { in: ANSWERS }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
module Gekanator
|
||||
class AiRunBudget
|
||||
MONTHLY_LIMIT_JPY = BigDecimal('450').freeze
|
||||
MAX_RUN_ESTIMATED_COST_JPY = BigDecimal('5').freeze
|
||||
|
||||
def self.remaining_monthly_budget_jpy
|
||||
MONTHLY_LIMIT_JPY - monthly_cost_jpy
|
||||
end
|
||||
|
||||
def self.monthly_cost_jpy
|
||||
GekanatorAiRun.this_month.sum(:estimated_cost_jpy)
|
||||
end
|
||||
|
||||
def self.exceeded?
|
||||
monthly_cost_jpy >= MONTHLY_LIMIT_JPY
|
||||
end
|
||||
|
||||
def self.exceeded_after_next_run?
|
||||
monthly_cost_jpy + MAX_RUN_ESTIMATED_COST_JPY >= MONTHLY_LIMIT_JPY
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
module Gekanator
|
||||
class QuestionSuggestionAiConverter
|
||||
def self.call(...) = new(...).call
|
||||
|
||||
def initialize suggestion:, user:
|
||||
@suggestion = suggestion
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call
|
||||
raise NotImplementedError, 'AI question conversion is not implemented yet.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :suggestion, :user
|
||||
end
|
||||
end
|
||||
@@ -66,9 +66,14 @@ Rails.application.routes.draw do
|
||||
namespace :gekanator do
|
||||
resources :games, only: [:create], controller: '/gekanator_games'
|
||||
resources :posts, only: [:index], controller: '/gekanator_posts'
|
||||
resources :questions, only: [:index], controller: '/gekanator_questions'
|
||||
resources :question_suggestions,
|
||||
only: [:create],
|
||||
controller: '/gekanator_question_suggestions'
|
||||
controller: '/gekanator_question_suggestions' do
|
||||
member do
|
||||
post :ai_convert
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
resources :users, only: [:create, :update] do
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class CreateGekanatorQuestions < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :gekanator_questions do |t|
|
||||
t.string :text, null: false
|
||||
t.string :kind, null: false
|
||||
t.json :condition, null: false
|
||||
t.string :source, null: false, default: 'ai_generated'
|
||||
t.string :status, null: false, default: 'pending'
|
||||
t.float :priority_weight, null: false, default: 1.0
|
||||
t.references :gekanator_question_suggestion,
|
||||
null: true,
|
||||
foreign_key: true
|
||||
t.references :created_by,
|
||||
null: true,
|
||||
foreign_key: { to_table: :users }
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateGekanatorAiRuns < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :gekanator_ai_runs do |t|
|
||||
t.string :model, null: false
|
||||
t.integer :input_tokens, null: false, default: 0
|
||||
t.integer :output_tokens, null: false, default: 0
|
||||
t.decimal :estimated_cost_jpy, precision: 8, scale: 3, null: false, default: 0
|
||||
t.string :status, null: false, default: 'pending'
|
||||
t.references :gekanator_question_suggestion, null: false, foreign_key: true
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
生成ファイル
+50
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_06_09_001000) do
|
||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -48,6 +48,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
||||
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
|
||||
end
|
||||
|
||||
create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "model", null: false
|
||||
t.integer "input_tokens", default: 0, null: false
|
||||
t.integer "output_tokens", default: 0, null: false
|
||||
t.decimal "estimated_cost_jpy", precision: 8, scale: 3, default: "0.0", null: false
|
||||
t.string "status", default: "pending", null: false
|
||||
t.bigint "gekanator_question_suggestion_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["gekanator_question_suggestion_id"], name: "index_gekanator_ai_runs_on_gekanator_question_suggestion_id"
|
||||
end
|
||||
|
||||
create_table "gekanator_games", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "user_id", null: false
|
||||
t.bigint "guessed_post_id", null: false
|
||||
@@ -75,6 +87,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
||||
t.index ["user_id"], name: "index_gekanator_question_suggestions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "gekanator_questions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "text", null: false
|
||||
t.string "kind", null: false
|
||||
t.json "condition", null: false
|
||||
t.string "source", default: "ai_generated", null: false
|
||||
t.string "status", default: "pending", null: false
|
||||
t.float "priority_weight", default: 1.0, null: false
|
||||
t.bigint "gekanator_question_suggestion_id"
|
||||
t.bigint "created_by_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["created_by_id"], name: "index_gekanator_questions_on_created_by_id"
|
||||
t.index ["gekanator_question_suggestion_id"], name: "index_gekanator_questions_on_gekanator_question_suggestion_id"
|
||||
end
|
||||
|
||||
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.binary "ip_address", limit: 16, null: false
|
||||
t.datetime "banned_at"
|
||||
@@ -164,6 +191,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
||||
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
|
||||
end
|
||||
|
||||
create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "post_id", null: false
|
||||
t.bigint "tag_id", null: false
|
||||
t.integer "begin_ms", null: false
|
||||
t.integer "end_ms", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms"
|
||||
t.index ["tag_id"], name: "fk_rails_8be3847903"
|
||||
t.check_constraint "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms"
|
||||
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
|
||||
end
|
||||
|
||||
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "post_id", null: false
|
||||
t.bigint "tag_id", null: false
|
||||
@@ -214,8 +254,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
||||
t.datetime "original_created_before"
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "version_no", null: false
|
||||
t.integer "video_ms"
|
||||
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
|
||||
t.index ["url"], name: "index_posts_on_url", unique: true
|
||||
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
|
||||
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
|
||||
end
|
||||
|
||||
@@ -366,6 +409,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at"
|
||||
t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42"
|
||||
t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at"
|
||||
t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id"
|
||||
t.index ["user_id"], name: "index_theatre_watching_users_on_user_id"
|
||||
@@ -505,11 +549,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
|
||||
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
|
||||
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
|
||||
add_foreign_key "gekanator_games", "users"
|
||||
add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade
|
||||
add_foreign_key "gekanator_question_suggestions", "users"
|
||||
add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
|
||||
add_foreign_key "gekanator_questions", "users", column: "created_by_id"
|
||||
add_foreign_key "material_versions", "materials"
|
||||
add_foreign_key "material_versions", "materials", column: "parent_id"
|
||||
add_foreign_key "material_versions", "tags"
|
||||
@@ -527,6 +574,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) do
|
||||
add_foreign_key "post_implications", "posts", column: "parent_post_id"
|
||||
add_foreign_key "post_similarities", "posts"
|
||||
add_foreign_key "post_similarities", "posts", column: "target_post_id"
|
||||
add_foreign_key "post_tag_sections", "posts"
|
||||
add_foreign_key "post_tag_sections", "tags"
|
||||
add_foreign_key "post_tags", "posts"
|
||||
add_foreign_key "post_tags", "tags"
|
||||
add_foreign_key "post_tags", "users", column: "created_user_id"
|
||||
|
||||
新しい課題から参照
ユーザをブロックする