Class: SpreeMenuChat::AnswerGenerator

Inherits:
Object
  • Object
show all
Defined in:
app/services/spree_menu_chat/answer_generator.rb

Overview

Turns a customer's question into either an answer grounded in this store's real, currently-embedded menu/FAQ content, or a canned "I don't have that" fallback — never a guess from Gemini's general knowledge, and never a raw error bubbled up to the storefront widget.

The read-only guardrail (see the plan's Guardrails section) is enforced structurally here, not just described in the system prompt: neither #call nor #stream (M4's streaming counterpart, used by ChatController) ever builds or passes a tools/function-declarations argument to SpreeMenuChat::LlmClient#generate/#generate_stream, and no write-capable Spree model (Spree::Order, Spree::LineItem, Spree::Cart, ...) is referenced anywhere in this class or SpreeMenuChat::Retriever. Gemini has no mechanism to invoke anything beyond generating text, so there is nothing for a prompt-injection attempt (in the user's question, or smuggled into embedded content) to hijack into an action — at worst it can talk the model into an off-topic reply, which the system prompt below asks it to refuse.

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(question, store:) ⇒ AnswerGenerator

Returns a new instance of AnswerGenerator.



24
25
26
27
# File 'app/services/spree_menu_chat/answer_generator.rb', line 24

def initialize(question, store:)
  @question = question
  @store = store
end

Class Method Details

.call(question, store: Spree::Store.default) ⇒ Object



20
21
22
# File 'app/services/spree_menu_chat/answer_generator.rb', line 20

def self.call(question, store: Spree::Store.default)
  new(question, store: store).call
end

.stream(question, store: Spree::Store.default, &block) ⇒ Object

M4 — streaming counterpart of #call, used by ChatController. Yields each real text fragment to the block as Gemini produces it. If there's no relevant context, or generation fails, or Gemini never actually yields any text (e.g. blocked by safety filtering — the streaming analog of #call's extract_text(response) || fallback), yields the same no-context fallback as a single fragment instead — callers (ChatController's SSE writer) never need to special-case "no answer" versus "one short answer," they just iterate.



48
49
50
# File 'app/services/spree_menu_chat/answer_generator.rb', line 48

def self.stream(question, store: Spree::Store.default, &block)
  new(question, store: store).stream(&block)
end

Instance Method Details

#callObject



29
30
31
32
33
34
35
36
37
38
# File 'app/services/spree_menu_chat/answer_generator.rb', line 29

def call
  return budget_exceeded_fallback if SpreeMenuChat::TokenBudget.exceeded?(store: @store)

  @context = SpreeMenuChat::Retriever.call(@question, store: @store)
  return no_context_fallback if @context.empty?

  response = generate
  record_tokens(response&.dig('usageMetadata', 'totalTokenCount'))
  extract_text(response) || no_context_fallback
end

#stream(&block) ⇒ Object



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'app/services/spree_menu_chat/answer_generator.rb', line 52

def stream(&block)
  if SpreeMenuChat::TokenBudget.exceeded?(store: @store)
    block.call(budget_exceeded_fallback)
    return
  end

  @context = SpreeMenuChat::Retriever.call(@question, store: @store)
  if @context.empty?
    block.call(no_context_fallback)
    return
  end

  yielded_any = false
  begin
    total_tokens = SpreeMenuChat::LlmClient.for_store(@store).generate_stream(prompt, system_instruction: system_instruction) do |text|
      yielded_any = true
      block.call(text)
    end
    record_tokens(total_tokens)
  rescue SpreeMenuChat::RequestError, SpreeMenuChat::LlmClient::MissingCredentialsError => e
    SpreeMenuChat::Alerting.capture(e, context: { area: 'answer_generator_stream', store_id: @store.id })
  end
  block.call(no_context_fallback) unless yielded_any
end