module AiChatCore
AiChatCore context — channel log, chat history, and plan prompt assembly. Public: build_plan_prompt, build_context_lines, build_chat_history_lines, channel_log_entries, format_context_line, truncate_context_line, chat_history_snapshot, append_chat_history, sanitize_text Depends: ai_agent, ai_channel_log, ai_chat_history, ai_context, ai_plugin_storage, rabbot_db Tests: test/lib/ai_chat_core_test.rb
AiChatCore plan — !aip gatekeeping, async dispatch, agent run, and finalize. Public: resolve_cooldown_sec, cooldown_message, request_preview, build_execute_plan, build_continuation_plan, process_plan_request, process_plan_answer, finalize_plan!, resolve_model, failure_outcome Depends: ai_access, ai_guard, ai_model, ai_plan_agent, ai_plan_session, ai_rate_limit, aip_report, bot_options, rabbot_db, ai_chat_core/context Tests: test/lib/ai_chat_core_test.rb, test/plugins/aip_test.rb
Constants
- CHAT_HISTORY_KEY
- COOLDOWN_OPTIONS_KEY
- MAX_CHAT_CONTEXT
- MAX_CHAT_HISTORY
- MAX_CONTEXT_LINE_CHARS
- PLAN_INSTRUCTION
- PLUGIN
- STOP_WORDS
Public Class Methods
Source
# File lib/ai/chat_core/context.rb, line 100 def self.append_chat_history(channel, asker:, question:, answer:, network: nil, store: RabbotDb) channel = store.normalize_channel(channel) return if channel.empty? || network.to_s.strip.empty? network = store.normalize_network(network) entry = { asker: asker.to_s.strip, question: sanitize_text(question), answer: sanitize_text(answer) } return if entry[:asker].empty? || entry[:question].empty? || entry[:answer].empty? entries = Array( RabbotDb.get_plugin_json( plugin: PLUGIN, key: CHAT_HISTORY_KEY, network: network, channel: channel, default: [] ) ) entries << { "asker" => entry[:asker], "question" => entry[:question], "answer" => entry[:answer] } entries = entries.last(MAX_CHAT_HISTORY) RabbotDb.set_plugin_json( plugin: PLUGIN, key: CHAT_HISTORY_KEY, value: entries, network: network, channel: channel ) end
Source
# File lib/ai/chat_core/context.rb, line 34 def self.build_chat_history_lines(channel, question, network: nil, store: RabbotDb) channel = channel.to_s return [] if channel.empty? entries = chat_history_snapshot(channel, network: network, store: store) selected = AiChatHistory.select_entries(entries, question, stop_words: STOP_WORDS, max_context: MAX_CHAT_CONTEXT) selected.flat_map { |entry| AiChatHistory.format_exchange(entry, max_chars: MAX_CONTEXT_LINE_CHARS) } end
Source
# File lib/ai/chat_core/context.rb, line 23 def self.build_context_lines(channel, question, network: nil, store: RabbotDb, asker: nil) channel = channel.to_s return [] if channel.empty? entries = channel_log_entries(network: network, channel: channel, store: store) entries = AiContext.regular_context_channel_entries(entries) AiContext.select_context_entries(entries, question, asker: asker, stop_words: STOP_WORDS).map do |entry| format_context_line(entry) end end
Source
# File lib/ai/chat_core/plan.rb, line 90 def self.build_continuation_plan(response, asker:, channel:, network:, channel_obj:, user:, pending:) text = response.to_s.strip if text.empty? return { immediate_reply: "Usage: !aip <answer eg a|b|c|1|2|3>", async: false } end unless channel_obj && user && network && AiPlanSession.admin_allowed?( network: network, channel: channel_obj.name, nick: asker, is_op: channel_obj.opped?(user) ) return { immediate_reply: AiPlanSession.admin_denial_message, async: false } end { immediate_reply: nil, async: true, async_ack: "AIP answer from #{asker}: #{request_preview(text)}", channel: channel, asker: asker, network: network, continuation: true, response: text, pending: pending } end
Source
# File lib/ai/chat_core/plan.rb, line 36 def self.build_execute_plan(question, asker:, channel:, network: nil, channel_obj: nil, user: nil, yaml_default: nil, store: RabbotDb) pending = AiPlanSession.pending(network: network, channel: channel, store: store) if pending return build_continuation_plan( question, asker: asker, channel: channel, network: network, channel_obj: channel_obj, user: user, pending: pending ) end if channel_obj && user && network && !AiAccess.user_allowed?(channel: channel_obj, user: user, network: network, nick: asker, irc: true) return { immediate_reply: AiAccess.denial_message, async: false } end text = question.to_s.strip if text.empty? return { immediate_reply: "Usage: !aip <question or plan topic>", async: false } end if AiGuard.system_request?(text) return { immediate_reply: AiGuard.rejection_message, async: false } end if network && channel && asker cooldown = resolve_cooldown_sec(network: network, channel: channel) if AiRateLimit.rate_limited?( network: network, channel: channel, nick: asker, cooldown_sec: cooldown, store: store ) return { immediate_reply: cooldown_message(cooldown_sec: cooldown), async: false } end end { immediate_reply: nil, async: true, async_ack: AipReport.format_request_ack(asker: asker, question: text), channel: channel, asker: asker, network: network, question: text, yaml_default: yaml_default } end
Source
# File lib/ai/chat_core/context.rb, line 11 def self.build_plan_prompt(question:, channel:, asker:, network: nil, store: RabbotDb) context_lines = build_context_lines(channel, question, network: network, store: store, asker: asker) chat_lines = build_chat_history_lines(channel, question, network: network, store: store) AiAgent.build_agent_prompt( question: "#{PLAN_INSTRUCTION}\n\n#{question.to_s.strip}", context_lines: context_lines, chat_history_lines: chat_lines, asker: asker, channel: channel ) end
Source
# File lib/ai/chat_core/context.rb, line 43 def self.channel_log_entries(network:, channel:, store: RabbotDb) network = store.normalize_network(network) channel = store.normalize_channel(channel) return [] if channel.empty? Array( AiPluginStorage.get_json( key: AiChannelLog::CHANNEL_LOG_KEY, network: network, channel: channel, default: [], store: store ) ).map do |entry| { nick: entry["nick"].to_s, text: entry["text"].to_s, is_command: entry["is_command"] == true } end end
Source
# File lib/ai/chat_core/context.rb, line 78 def self.chat_history_snapshot(channel, network: nil, store: RabbotDb) channel = store.normalize_channel(channel) return [] if channel.empty? || network.to_s.strip.empty? network = store.normalize_network(network) Array( RabbotDb.get_plugin_json( plugin: PLUGIN, key: CHAT_HISTORY_KEY, network: network, channel: channel, default: [] ) ).map do |entry| { asker: entry["asker"].to_s, question: entry["question"].to_s, answer: entry["answer"].to_s } end end
Source
# File lib/ai/chat_core/plan.rb, line 25 def self.cooldown_message(cooldown_sec:) "Slow down — wait #{cooldown_sec}s before another !aip request." end
Source
# File lib/ai/chat_core/plan.rb, line 268 def self.failure_outcome(message) { success: false, needs_input: false, reply: AipReport.format_error(message) } end
Source
# File lib/ai/chat_core/plan.rb, line 227 def self.finalize_plan!(channel:, asker:, question:, answer:, network:, model:, total_tokens:, duration_s:, usage_fetch: nil, store: RabbotDb) AiPlanSession.clear_pending!(network: network, channel: channel, store: store) filtered = AiGuard.filter_output(answer) append_chat_history( channel, asker: asker, question: question, answer: filtered, network: network, store: store ) usage_percents = usage_fetch&.call reply = AipReport.format_plan_reply( preview: request_preview(question), body: filtered, model: model, total_tokens: total_tokens, duration_s: duration_s, usage_percents: usage_percents ) if network && channel && asker AiRateLimit.record_request(network: network, channel: channel, nick: asker, store: store) end { success: true, needs_input: false, reply: reply } end
Source
# File lib/ai/chat_core/context.rb, line 65 def self.format_context_line(entry) nick = (entry[:nick] || entry["nick"]).to_s text = truncate_context_line((entry[:text] || entry["text"]).to_s) "#{nick}: #{text}" end
Source
# File lib/ai/chat_core/plan.rb, line 167 def self.process_plan_answer(response:, channel:, asker:, network: nil, yaml_default: nil, model: nil, runner: AiPlanAgent.method(:default_runner), usage_fetch: nil, store: RabbotDb) pending = AiPlanSession.pending(network: network, channel: channel, store: store) return failure_outcome("No pending !aip plan question in this channel.") unless pending answer_text = AiPlanSession.resolve_answer(response, questions: pending[:questions]) pending_model = pending[:model].to_s.strip model = if pending_model.empty? resolve_model( network: network, channel: channel, yaml_default: yaml_default, model: model ) else pending_model end result = AiPlanAgent.run_plan( answer_text, model: model, session_id: pending[:session_id], runner: runner ) return failure_outcome(result.text) unless result.success? if result.needs_input? AiPlanSession.save_pending!( network: network, channel: channel, session_id: result.session_id || pending[:session_id], asker: pending[:asker], question: pending[:question], questions: result.questions, model: model, store: store ) first = result.questions.first reply = AipReport.format_question_reply( prompt: first[:prompt].to_s.strip.empty? ? result.text : first[:prompt], options: first[:options] ) return { success: true, needs_input: true, reply: reply } end finalize_plan!( channel: channel, asker: pending[:asker], question: pending[:question], answer: result.text, network: network, model: result.model, total_tokens: result.total_tokens, duration_s: result.duration_s, usage_fetch: usage_fetch, store: store ) end
Source
# File lib/ai/chat_core/plan.rb, line 119 def self.process_plan_request(question:, channel:, asker:, network: nil, yaml_default: nil, model: nil, runner: AiPlanAgent.method(:default_runner), usage_fetch: nil, store: RabbotDb) model = resolve_model(network: network, channel: channel, yaml_default: yaml_default, model: model) prompt = build_plan_prompt( question: question, channel: channel, asker: asker, network: network, store: store ) result = AiPlanAgent.run_plan(prompt, model: model, runner: runner) return failure_outcome(result.text) unless result.success? if result.needs_input? AiPlanSession.save_pending!( network: network, channel: channel, session_id: result.session_id, asker: asker, question: question, questions: result.questions, model: model, store: store ) first = result.questions.first reply = AipReport.format_question_reply( prompt: first[:prompt].to_s.strip.empty? ? result.text : first[:prompt], options: first[:options] ) return { success: true, needs_input: true, reply: reply } end finalize_plan!( channel: channel, asker: asker, question: question, answer: result.text, network: network, model: result.model, total_tokens: result.total_tokens, duration_s: result.duration_s, usage_fetch: usage_fetch, store: store ) end
Source
# File lib/ai/chat_core/plan.rb, line 29 def self.request_preview(question) text = question.to_s.strip return text if text.length <= 80 "#{text[0, 77]}..." end
Source
# File lib/ai/chat_core/plan.rb, line 12 def self.resolve_cooldown_sec(network:, channel:) return AiRateLimit::DEFAULT_COOLDOWN_SEC if network.to_s.strip.empty? || channel.to_s.strip.empty? value = BotOptions.get( network: network, channel: channel, key: COOLDOWN_OPTIONS_KEY, default: AiRateLimit::DEFAULT_COOLDOWN_SEC.to_s ) sec = value.to_i sec.positive? ? sec : AiRateLimit::DEFAULT_COOLDOWN_SEC end
Source
# File lib/ai/chat_core/plan.rb, line 257 def self.resolve_model(network:, channel:, yaml_default: nil, model: nil) return model if model AiModel.resolve( network: network, channel: channel, yaml_default: yaml_default, fallback: AiModel::DEFAULT ) end
Source
# File lib/ai/chat_core/context.rb, line 137 def self.sanitize_text(text) text.to_s.gsub(/[\x00-\x1f]/, " ").squeeze(" ").strip end
Source
# File lib/ai/chat_core/context.rb, line 71 def self.truncate_context_line(text) line = text.to_s.strip return line if line.length <= MAX_CONTEXT_LINE_CHARS "#{line[0, MAX_CONTEXT_LINE_CHARS - 3]}..." end