Class: Assistant::ChatService
- Inherits:
-
Object
- Object
- Assistant::ChatService
- Includes:
- PromptComposer
- Defined in:
- app/services/assistant/chat_service.rb
Overview
Service for AI-powered assistant chat using RubyLLM's acts_as_chat.
Uses tool-based architecture: the LLM calls registered tools (DB, content search, etc.)
rather than generating raw SQL. Conversation history is managed by RubyLLM automatically.
Defined Under Namespace
Classes: Result
Constant Summary collapse
- THINKING_BUDGET_LOW =
Extended Thinking configuration — gives reasoning models a scratchpad
for multi-step problems (SQL construction, analytical reasoning).
Legacy Anthropic models use token budgets; current Gemini uses effort
levels and does not receive these budget values. 4_000- THINKING_BUDGET_MEDIUM =
Simple tool queries
8_000- THINKING_BUDGET_HIGH =
Analytical queries with JOINs/aggregation
16_000- THINKING_BUDGET_MAX =
Complex multi-step reasoning (Opus only)
64_000- EFFORT_RANK =
Relative ordering of thinking-effort tiers, lowest → highest. Lets the
router pick the higher of a query-driven floor and a model's configured
default, so a :max-default model is never capped at :high. { low: 0, medium: 1, high: 2, max: 3 }.freeze
- THINKING_QUERY_PATTERNS =
Patterns that indicate the query would benefit from extended thinking
/\b(compare|analyze|trend|correlat|calculate|forecast|predict|why|root.?cause|deep.?dive|break.?down|step.?by.?step|optimize|investigate|audit|reconcil|year.?over.?year|month.?over.?month)\b/i- LLM_NETWORK_RETRY_EXCEPTIONS =
Transient provider / TLS failures (AppSignal #4527: Faraday::SSLError SSL_read EOF).
[ Faraday::SSLError, Faraday::ConnectionFailed, Faraday::TimeoutError, OpenSSL::SSL::SSLError, RubyLLM::ServiceUnavailableError, # HTTP 502/503/504 — upstream gateway transient RubyLLM::OverloadedError # HTTP 529 — Anthropic "service overloaded" transient ].freeze
- MODELS =
Available models with their configurations.
Model IDs come from AiModelConstants — the single source of truth.
supports_thinking: whether the model supports RubyLLM's with_thinking (extended reasoning)
thinking_effort_default: the default effort level when thinking is activated (:low, :medium, :high, :max) { 'claude-haiku' => { id: AiModelConstants.id(:anthropic_haiku), provider: :anthropic, label: 'Claude Haiku 4.5 (Fast)', cost: :low, supports_thinking: false }, 'claude-sonnet' => { id: AiModelConstants.id(:anthropic_sonnet), provider: :anthropic, label: 'Claude Sonnet 5 (Balanced)', cost: :medium, supports_thinking: true, thinking_effort_default: :medium }, 'claude-opus' => { id: AiModelConstants.id(:anthropic_opus), provider: :anthropic, label: 'Claude Opus 5 (Best — highest cost)', cost: :high, supports_thinking: true, thinking_effort_default: :high }, # Same Opus model, opened up to the 1M-token context window via the # Anthropic context-1m beta header (see configure_conversation). Top rung # of the complexity-escalation ladder for marathon / huge-context sessions. 'claude-opus-1m' => { id: AiModelConstants.id(:anthropic_opus), provider: :anthropic, label: 'Claude Opus 5 (1M context)', cost: :high, supports_thinking: true, thinking_effort_default: :high, context_1m: true }, 'gpt-5' => { id: AiModelConstants.id(:openai_gpt5), provider: :openai, label: 'GPT-5 (OpenAI)', cost: :medium, supports_thinking: false }, 'gpt-5.5' => { id: AiModelConstants.id(:openai_gpt55), provider: :openai, label: 'GPT-5.5 (OpenAI Latest)', cost: :medium, supports_thinking: false }, 'gpt-5-mini' => { id: AiModelConstants.id(:openai_gpt5_mini), provider: :openai, label: 'GPT-5 Mini (Fast)', cost: :low, supports_thinking: false }, 'gemini-flash' => { id: AiModelConstants.id(:gemini_flash), provider: :gemini, label: 'Gemini 3.7 Flash (Recommended)', cost: :low, supports_thinking: true, thinking_effort_default: :low }, 'gemini-pro' => { id: AiModelConstants.id(:gemini_pro), provider: :gemini, label: 'Gemini 3.7 Flash · High Reasoning (Google)', cost: :medium, supports_thinking: true, thinking_effort_default: :high }, # Kimi K3 via the direct Moonshot API — open-weight opt-in preview. # Explicit picker choice ONLY: not reachable from auto_select_model, # absent from the ComplexityEscalator ladder, and opt_in: true keeps it # out of model affinity — switching the dropdown back to 'auto' leaves # Kimi immediately instead of "keeping it for context continuity". # Sonnet-level pricing ($3/$15 per Mtok) → :medium tier; automatic # server-side context caching bills stable prefixes at the cache-read # rate. K3 fixes sampling params server-side, so # AiModelConstants.rejects_sampling_params? skips with_temperature for # it. Requires kimi_ai.api_key in credentials; a failed call surfaces # in-chat — no silent fallback, so spend can't leak onto another model # unnoticed. 'kimi-k3' => { id: AiModelConstants.id(:kimi), provider: :moonshotai, label: 'Kimi K3 (Moonshot · opt-in preview)', cost: :medium, supports_thinking: false, opt_in: true }, # Grok 4.5 via native xAI API — opt-in preview. Same affinity / auto- # select isolation as kimi-k3. Strong agentic/tool-calling pitch at # $2/$6 per Mtok (under Sonnet list, far under Opus) with 500k context # and $0.30 cache-read on stable prefixes. Reasoning is available on # the API; RubyLLM's with_thinking path for xAI is not wired the same # as Anthropic adaptive thinking yet, so supports_thinking stays false # until we prove the effort param. Requires xai_api.api_key. 'grok-4.6' => { id: AiModelConstants.id(:grok), provider: :xai, label: 'Grok 4.6 (xAI · opt-in preview)', cost: :medium, supports_thinking: false, opt_in: true }, # Open-weight test fleet via OpenRouter — same opt-in isolation as # kimi-k3/grok-4.6 (explicit picker choice only; no auto-select, no # escalation ladder, no model affinity). One gateway for all three so # the briefing evaluation is apples-to-apples: shared privacy prefs + # session pinning (apply_openrouter_provider_options!), Auto Exacto # host ordering on tool calls, exact usage.cost accounting. Pricing # and rationale: AiModelConstants + doc/development/OPENROUTER.md. # supports_thinking stays false until the OpenRouter reasoning param # is proven per model (same stance as grok-4.6). # fallback_models: OpenRouter's server-side `models` array — on any # error (model outage, empty eligible pool, rate limit) the request # advances to the next model, billed at whichever served, with the # same provider prefs applied. Host-level failover WITHIN a model # needs nothing here — OpenRouter load balances GLM's ~30 endpoints # on its own. The response's `model` field reports what actually # answered and RubyLLM parses it into the message, so ai_usage_logs # prices a fallback day as whoever ran. # # Kimi K2.6, NOT the A/B runner-up Qwen3.7 Plus: adopting zdr: true # (below) narrows every request to attested zero-retention endpoints, # and Qwen publishes exactly one endpoint, which isn't one — the # array would have been unroutable on the day it was needed. Measured # 2026-08-12 against /api/v1/endpoints/zdr: GLM 21 eligible hosts # (of 32), Kimi 15 (of 21), Qwen 0 (of 1) — counting only hosts that # also declare tool_choice, since we send require_parameters. 'glm-5.2' => { id: AiModelConstants.id(:openrouter_glm), provider: :openrouter, label: 'GLM 5.2 (Z.AI · opt-in preview)', cost: :low, supports_thinking: false, opt_in: true, fallback_models: [AiModelConstants.id(:openrouter_kimi)] }, 'qwen3.7-plus' => { id: AiModelConstants.id(:openrouter_qwen), provider: :openrouter, label: 'Qwen3.7 Plus (Alibaba · opt-in preview)', cost: :low, supports_thinking: false, opt_in: true }, 'kimi-k2.6' => { id: AiModelConstants.id(:openrouter_kimi), provider: :openrouter, label: 'Kimi K2.6 (Moonshot via OpenRouter · opt-in preview)', cost: :low, supports_thinking: false, opt_in: true } }.freeze
- DEFAULT_MODEL =
Default model.
'gemini-flash'- CONTEXT_1M_BETA =
Anthropic beta token that unlocks Opus's 1M-token context window, applied
only to the 'claude-opus-1m' model (context_1m: true) via with_headers.
Validated live against api.anthropic.com on 2026-06-03 — accepted (HTTP 200),
as was adaptive thinking at effort=max on the Opus tier (now claude-opus-5). 'context-1m-2025-08-07'- MAX_PLAN_COST_USD =
Note:
plan_cost underestimates because run_plan_step_executor returns only the FINAL
API round's tokens (not the cumulative total across tool-call rounds within a step).
Real per-step cost is typically 5-10× higher than reported. The primary cost guard is
the ToolLoopGuard's per-step call limit, not this cap.Hard cap on estimated plan execution cost (USD) across isolated step + assembly LLM calls.
2.00- MAX_PLAN_STEP_DURATION =
Wall-clock timeout per plan step — driven from ToolLoopGuard so both
the outer Timeout and the inner guard share a single source of truth. Assistant::ToolLoopGuard::MAX_STEP_DURATION.seconds
- STEP_RESULT_MAX_CHARS =
How much of a plan step's result travels to the next step and the final
assembly. Named for what it does: nothing summarizes the overflow, it is
simply cut (see PlanOrchestrator#clamp_step_result_for_chain). The old
name — STEP_RESULT_SUMMARIZE_THRESHOLD — described a cheap-model
summarization pass that does not exist, which is how 2,000 chars looked
survivable for years.2,000 discarded most of every data-gathering step. Measured against the
queries the sales briefing actually runs for one rep (conv 4843,
2026-08-14): 23 quote follow-ups render 8,609 chars and 10 lapsed trade
accounts 2,645 — 11,254 for the single step that runs both, so ~83% was
cut and the briefing told the rep its own data was "truncated in the tool
output". The support briefing hit the same wall from the other side: nine
consolidated queries were cut mid-table right before the call statistics,
and the assembly filled the hole by inventing numbers (conv 4821).12,000 clears that measured worst case with headroom. The ceiling on
accumulation is per-step, so a 10-step plan tops out around 120k chars
(~30k tokens) of step results — comfortable for the 128k+ context models
that run plans, and MAX_PLAN_COST_USD still bounds spend independently. 12_000- MID_TURN_COMPACT_THRESHOLD =
Mid-turn compaction thresholds (see install_mid_turn_compaction!)
2_000- MID_TURN_KEEP_CHARS =
Mid turn keep chars.
600- MID_TURN_COMPACT_AFTER_MESSAGES =
Messages (assistant + tool, not rounds) before the mid-turn pass arms.
3- MID_TURN_MIN_MESSAGES =
Below this transcript length there is nothing worth compacting.
6- MID_TURN_SKIP_PREFIXES =
Content already shrunk by another pass. '[Compacted' / '[Truncated' are
the pre-2026-08 raw-cut formats — kept so a transcript replayed across
the deploy isn't compacted twice; new output is JSON carrying_truncated
and is skipped by MID_TURN_COMPACT_THRESHOLD instead. ['[Compacted', '[Truncated', '[Already retrieved'].freeze
- COMPLEX_QUERY_PATTERNS =
Keywords indicating complex analytical or reasoning queries (need better models)
/\b(why|trend|pattern|anomaly|recommend|insight|correlation|predict|forecast|explain|root.?cause|deep.?dive|strategic|analyze|summarize|evaluate|pros?.and.cons|trade.?off)\b/i- COMPARISON_QUERY_PATTERNS =
Keywords indicating multi-step comparison or research queries (need balanced models)
/\b(compare|vs|versus|between|difference|change|growth|decline|year.?over.?year|month.?over.?month|yoy|mom|research|investigate|audit)\b/i- SIMPLE_QUERY_PATTERNS =
Keywords indicating simple lookup or factual queries (fast models are fine)
/\b(show|list|get|total|count|how many|what is|what are|sum|average|find|look up|search|where is|who is|when did)\b/i- COMPOSE_QUERY_PATTERNS =
Phrases that indicate the user is drafting/composing a short message (email,
follow-up, outreach, internal summary). These are quick content-generation
tasks where Flash is fast and good enough — Pro's extended thinking is
wasted budget here, and on long prompts (e.g. pasted email threads) we'd
otherwise route them to Pro and time out. /\b(reply|respond|send|email|follow.?up|outreach|reach out|thank.?you note|summary email)\b/i- WRITING_QUERY_PATTERNS =
Phrases that indicate long-form editorial work (blog posts, articles, FAQs,
rewrites). Flash produces noticeably weaker prose here — see /assistant/1639,
where a Buffalo bathroom blog post written under Flash drew "wrote very poorly"
feedback from the editor. Content-authoring tasks now route to Claude Sonnet:
every Gemini tier proved slow and unreliable on long HTML body edits — the
old gemini-3.1-pro preview intermittently 400'd (#3808) and ground out the
full 600s plan-step timeout on complex edits (#4714, conv 3098), which is why
the Gemini Pro snapshots were dropped from the registry entirely.
Opus is intentionally excluded as too expensive for routine editorial work. Regexp.new( '\b(rewrite|polish|copyedit|copy.?edit|long.?form|article|blog post|blog ?article|blog ?entry|' \ 'essay|narrative|edit blog|write the blog|draft the blog|update the blog|update the article|' \ 'expand this section|tighten this|story|landing page copy|product description|press release|' \ 'case study|whitepaper|white ?paper|content brief|seo copy|meta description|page copy|' \ 'h(?:ero|eading) copy|body copy|email template|email campaign|email blast|email copy|' \ 'email design|newsletter|technical.?articles?|troubleshooting (?:guides?|sources?)|' \ 'knowledge consolidation)\b', Regexp::IGNORECASE )
- CONTENT_AUTHORING_SERVICES =
Tool services whose presence marks a content-authoring turn. When the
classifier routes a turn to these, it gets Claude regardless of the query
wording (covers follow-ups like "now add a CTA" that lack writing keywords). %w[ blog_management email_management technical_article_management ].freeze
- EXTENDED_TURN_SERVICES =
Services whose turns legitimately run long and emit large output, so they
get MAX_TURN_DURATION_AUTHORING (600s) and the 32k output budget rather
than the 360s telemetry cap.A superset of CONTENT_AUTHORING_SERVICES on purpose. pdf_tools belongs on
the budget list — pdf_generate assembles a whole document across many tool
calls and was being halted mid-build at 430s against the 360s cap
(AppSignal #6189) — but must NOT join CONTENT_AUTHORING_SERVICES, which
additionally forces the turn onto Claude regardless of query wording.
Widening that constant would have quietly changed PDF model routing. (CONTENT_AUTHORING_SERVICES + %w[pdf_tools]).freeze
- WRITING_MODEL_DEFAULT =
Model we auto-route content-authoring work to. The one place we deliberately
auto-pick Anthropic — Claude is materially more reliable + faster at HTML
body editing than any Gemini tier. Opus stays opt-in (cost)
for general editorial; blog editing is the exception — see BLOG_AUTHORING_MODEL. 'claude-sonnet'- WRITING_MODEL_CLAUDE =
Writing model claude.
'claude-sonnet'- BLOG_AUTHORING_MODEL =
Blog editing is the heaviest content-authoring workload: large HTML bodies,
many block-level tool calls, long multi-turn sessions. On Gemini — and even
Sonnet — these turns repeatedly tripped the body-less Gemini 400 (#3808) and
the 600s plan-step timeout (#4714), and large posts got shredded by mid-turn
compaction — leaving the model editing from truncated HTML and looping until
it timed out (convs 3105/3109, Julia). Route blog editing to Opus 5 on the
1M-token context window from the FIRST turn so the model has both the
capability and the context headroom to finish without choking, instead of
starting cheap and escalating only after it has already failed. Cost is the
deliberate tradeoff for blog work specifically — email/general editorial
stay on Sonnet. Defined as a constant so the tier is easy to retune. 'claude-opus-1m'- GATEWAY_FALLBACK_MODEL =
Where an OpenRouter turn goes when the gateway itself is unreachable —
a different provider on purpose, so a single vendor outage can't take the
briefing down. See apply_gateway_fallback! for why it isn't gemini-flash. 'claude-sonnet'- BLOG_AUTHORING_SERVICES =
Classifier tool services that mark a blog authoring turn (vs. email).
%w[blog_management].freeze
- BLOG_AUTHORING_PATTERNS =
Query wording that signals blog editing even without a classifier tool hint
(e.g. tests, or a turn the classifier abstained on). Deliberately blog-ONLY:
generic "article"/"the article" wording is left to content_authoring_turn? →
Sonnet, so a plain editorial edit isn't forced onto the pricier Opus-1M tier.Also matches a pasted WarmlyYours blog-post URL (…/posts/) and the
"for this/the blog" lead-in: the common way an editor kicks off a blog task
is to paste the post URL ("for this blog https://…/posts/…/preview"), which
carries no other blog keyword and otherwise fell through to Gemini and hit
the intermittent body-less 400 (#3808, conv 3150). %r{} so the /posts/ path
needs no escaping. %r{\b(blog post|blog ?article|blog ?entry|edit (?:the )?blog|update (?:the )?blog|write (?:the )?blog|draft (?:the )?blog|rewrite (?:the )?blog(?: post| article| entry)?|for (?:this|the) blog)\b|/posts/[\w-]+}i- WRITING_ELIGIBLE_MODELS =
Writing eligible models.
[WRITING_MODEL_DEFAULT, WRITING_MODEL_CLAUDE, BLOG_AUTHORING_MODEL].uniq.freeze
- MODEL_COST_TIER =
Cost tiers for model affinity decisions.
Switching models mid-conversation loses accumulated reasoning context,
so we only switch when escalating to a higher tier (never laterally). MODELS.transform_values { |c| c[:cost] }.freeze
Constants included from PromptComposer
PromptComposer::AGENT_PROMPTS_DIR, PromptComposer::ANALYTICS_SERVICES, PromptComposer::DOMAIN_TOOL_REQUIREMENTS, PromptComposer::INSTRUCTIONS_TEMPLATE_PATH, PromptComposer::MESSAGE_DOMAIN_PATTERNS
Instance Attribute Summary collapse
-
#model_key ⇒ String
readonly
The concrete model key this turn resolved to (e.g. +'claude-sonnet'+).
Class Method Summary collapse
-
.auto_select_candidate(query, history_length: 0, current_model: nil) ⇒ Hash
Regex/heuristic model selection used when the classifier abstained or was not run.
-
.auto_select_model(query, history_length: 0, current_model: nil, classifier_result: nil, active_services: []) ⇒ Hash
Auto-select the best model based on query complexity.
-
.available_models ⇒ Array<Hash>
Returns the list of models available in the UI picker, with Auto first.
-
.compact_consumed_tool_results!(messages) ⇒ Integer
Shrink already-consumed tool results in a live transcript, in place.
-
.estimate_tokens(text) ⇒ Integer
Rough token estimate (1 token ≈ 4 chars for English).
-
.label_for_model(model_key) ⇒ String
Resolve a stored model preference / llm_model_name (e.g. 'gemini-pro') to a human-readable label that includes the actual underlying model id (e.g. "Gemini 3.5 Flash · Reasoning").
Instance Method Summary collapse
-
#call(&block) ⇒ Result
Execute the chat with streaming response.
-
#complete_only(&block) ⇒ Result
Retry path after emergency compaction: reconfigure the conversation and call complete() directly.
-
#emit_status(message) ⇒ void
protected
Emit a status update for the UI (non-content, just progress indicator).
-
#initialize(conversation:, user_message:, model: 'auto', tool_services: [], permitted_services: [], user_context: {}, on_status: nil, cancel_check: nil, attachments: [], log_metadata: {}) ⇒ ChatService
constructor
A new instance of ChatService.
-
#stream_content(content) ⇒ void
protected
Stream content to client AND capture for conversation history.
-
#with_instrumented_llm_call(feature:, source: 'sunny') { ... } ⇒ Object
protected
Wraps an LLM call with PaperTrail audit context, CurrentScope user, instrumentation metadata, and transient network retries.
Constructor Details
#initialize(conversation:, user_message:, model: 'auto', tool_services: [], permitted_services: [], user_context: {}, on_status: nil, cancel_check: nil, attachments: [], log_metadata: {}) ⇒ ChatService
Returns a new instance of ChatService.
507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 |
# File 'app/services/assistant/chat_service.rb', line 507 def initialize(conversation:, user_message:, model: 'auto', tool_services: [], permitted_services: [], user_context: {}, on_status: nil, cancel_check: nil, attachments: [], log_metadata: {}) @conversation = conversation @user_message = @log_metadata = || {} @tool_services = Array(tool_services).compact_blank @permitted_services = Array(permitted_services).compact_blank @user_context = user_context || {} @on_status = on_status @cancel_check = cancel_check @attachments = Array().select do |p| if p.respond_to?(:exist?) p.exist? # Pathname — check local file elsif p.to_s.start_with?('http://', 'https://') true # URL — pass through to RubyLLM else File.exist?(p.to_s) # String path — check local file end end @auto_selected = false @model_selection_reason = nil # Derive role from user context for tool access control. # user_context is a serialized Hash from the controller with 'is_admin' and 'is_manager' keys. @user_role = if @user_context['is_admin'] :admin elsif @user_context['is_manager'] :manager else :employee end # Resolve data domain access from the user's CanCanCan roles. # This narrows which views/tables the AI tools can query. @account = Account.find_by(id: @user_context['account_id']) if @user_context['account_id'] @allowed_objects = @account ? Assistant::DataPolicy.allowed_objects_for_account(@account) : nil @analytics_domains = Array(@user_context['analytics_domains']) history_length = @conversation..count # Handle 'auto' model selection if model == 'auto' || !MODELS.key?(model) selection = self.class.auto_select_model( , history_length: history_length, current_model: @conversation.llm_model_name, active_services: Array(@conversation.tool_services) ) @model_key = selection[:model] @model_selection_reason = selection[:reason] @auto_selected = true else @model_key = model end # Complexity-aware upgrade: a session that STARTED cheap but has since # revealed its complexity — the model declared a multi-step plan, or the # conversation has grown long — climbs the model ladder. Only in auto mode # (never override an explicit user pick) and only while the user's monthly # budget allows; out of budget → stay on the cheap tier. See # Assistant::MonthlyBudget and # doc/tasks/202606031730_SUNNY_BUDGET_AND_AUTO_ESCALATION.md. # # Gate on the stored preference, not @auto_selected: the controller often # pre-resolves 'auto' to a concrete key before this point (one classifier # pass picks tools + tier), which would otherwise hide auto mode here. # # opt_in models are exempt, per their contract above. They are absent from # LADDER, and LADDER.index returns nil → rung 0, so a declared plan would # "upgrade" a deliberately-named model onto a Gemini/Opus rung. The exposed # callers are programmatic ones that pass model: while leaving # model_preference blank (DailyFocus::ChatRunner): harmless on a fresh # conversation, but a re-run reads back the stored execution_plan and would # silently move the briefing off the model it was pinned to. if auto_model_mode? && !MODELS.dig(@model_key, :opt_in) upgrade = Assistant::ComplexityEscalator.upgrade( current_model: @model_key, plan_step_count: Array(@conversation.execution_plan&.dig('steps')).size, history_length: history_length, user_context: @user_context ) if upgrade @model_key = upgrade[:model] @model_selection_reason = upgrade[:reason] end end @model_config = MODELS[@model_key] end |
Instance Attribute Details
#model_key ⇒ String (readonly)
The concrete model key this turn resolved to (e.g. +'claude-sonnet'+). For
an explicit pick this equals the requested model; for +'auto'+ it is the key
the complexity/affinity selector chose. Lets callers base a decision on the
backend that actually ran rather than the requested alias — e.g.
AssistantChatWorker's transient-400 recovery, so an +auto+ turn that
resolved to claude-sonnet retries on a different backend instead of
replaying the model that just 400'd.
665 666 667 |
# File 'app/services/assistant/chat_service.rb', line 665 def model_key @model_key end |
Class Method Details
.auto_select_candidate(query, history_length: 0, current_model: nil) ⇒ Hash
Regex/heuristic model selection used when the classifier abstained or was
not run.
440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 |
# File 'app/services/assistant/chat_service.rb', line 440 def self.auto_select_candidate(query, history_length: 0, current_model: nil) query_lower = query.downcase.strip token_count = estimate_tokens(query) is_writing = query_lower.match?(WRITING_QUERY_PATTERNS) is_complex = query_lower.match?(COMPLEX_QUERY_PATTERNS) is_comparison = query_lower.match?(COMPARISON_QUERY_PATTERNS) is_compose = query_lower.match?(COMPOSE_QUERY_PATTERNS) is_simple = query_lower.match?(SIMPLE_QUERY_PATTERNS) && !is_comparison && !is_complex && !is_writing long_conversation = history_length > 20 # Long-form editorial work (blog posts, articles, rewrites) must always run # on a Pro/Sonnet-tier model — Flash produces noticeably weaker prose. We # force_switch so a conversation that started on Flash doesn't hold writing # turns hostage via model affinity. Stay on Sonnet only if the conversation # is already on a Claude model; otherwise default to Claude Sonnet. if is_writing chosen = current_model == WRITING_MODEL_CLAUDE ? WRITING_MODEL_CLAUDE : WRITING_MODEL_DEFAULT return { model: chosen, reason: 'Writing/editorial task', force_switch: true } end # Compose/email tasks are short content generation, not analysis. Keep them # on Flash even when the prompt is long (pasted email threads inflate token # counts but don't require deep reasoning) — Pro burns most of # MAX_TURN_DURATION on extended thinking before any tool runs. return { model: 'gemini-flash', reason: 'Compose/email task', force_switch: true } if is_compose && !is_complex if is_complex || token_count > 200 { model: 'gemini-pro', reason: 'Complex analytical query' } elsif is_comparison || token_count > 80 { model: 'gemini-flash', reason: 'Multi-step query' } elsif is_simple && !long_conversation { model: 'gemini-flash', reason: 'Simple query' } else { model: 'gemini-flash', reason: long_conversation ? 'Long conversation context' : 'Standard query' } end end |
.auto_select_model(query, history_length: 0, current_model: nil, classifier_result: nil, active_services: []) ⇒ Hash
Auto-select the best model based on query complexity.
Works for both analytics and general assistant queries.
Design goals:
- Prefer the AI classifier's tier when present — it sees the whole prompt
holistically (multi-task structure, spelling variants, compound asks). - Fall back to regex-based candidate selection when the classifier abstained
or wasn't run (e.g. tests that bypass the LLM call). - Default to Gemini Flash for all queries — cheapest option with good quality.
- Escalate to the Gemini reasoning tier (same gemini-3.7-flash, higher
thinking-effort budget) only for genuinely complex analytical queries. - Claude models are otherwise opt-in (explicit user selection), keeping
Anthropic costs near zero for auto users — EXCEPT content-authoring
(blog/email) turns, which always route to Claude Sonnet because the
Gemini tiers are slow/unreliable on long HTML edits (see
content_authoring_turn? / WRITING_MODEL_CLAUDE). - Model affinity: if the conversation already uses a model, prefer keeping it
unless the new query demands a higher cost tier. Lateral switches lose
accumulated reasoning context for no benefit.
300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 |
# File 'app/services/assistant/chat_service.rb', line 300 def self.auto_select_model(query, history_length: 0, current_model: nil, classifier_result: nil, active_services: []) # Blog editing routes to Opus 5 (1M context) from the first turn — ahead of # everything else. These turns are large + tool-heavy and were choking on the # cheaper tiers (Gemini 400s #3808, 600s timeouts #4714, truncated-HTML # edit loops on large posts — convs 3105/3109). Give them the capable, # large-context model up front rather than escalating after failure. # # Detection is SESSION-aware, not just per-message: a blog session keeps Opus # on every turn even when the message itself carries no blog signal (e.g. # "retry", "yes confirmed", "clean up the HTML"). Without this, a short # continuation in a blog session silently fell back to Flash and thrashed # (conv 3117). active_services carries the conversation's enabled tool # services + forced chips. return { model: BLOG_AUTHORING_MODEL, reason: 'Blog session → Claude Opus 5 (1M context)' } if (query, classifier_result, active_services, current_model: current_model) # Other content-authoring (email/general editorial) turns route to Claude # Sonnet, ahead of the classifier/regex candidate AND model affinity. The # Gemini is slow/unreliable on long HTML edits — the dropped 3.1-pro-preview # snapshot 400'd intermittently (#3808) and burned the full 600s # plan-step timeout on complex edits (#4714, conv 3098). return { model: WRITING_MODEL_CLAUDE, reason: 'Content-authoring (email/editorial) → Claude' } if (query, classifier_result, active_services, current_model: current_model) candidate = if classifier_result&.model_tier candidate_from_classifier(classifier_result) else auto_select_candidate(query, history_length: history_length, current_model: current_model) end # Some intents (compose/email, writing) are strong enough signals that we # override model affinity. Compose pulls down to Flash so a long pasted # email thread doesn't burn the whole turn budget on Pro's extended # thinking (PR #618 / conv 1233). Writing pushes UP to Pro/Sonnet so we # never produce blog content on Flash (conv 1639 / Julia's feedback). return candidate.except(:force_switch) if candidate[:force_switch] # Affinity never preserves an opt_in model: it exists so auto-selected # tiers keep their context continuity, not to trap a session on a # preview the user explicitly opted into. Once the dropdown is back on # 'auto', routing returns to the standard tiers immediately. if current_model.present? && MODELS.key?(current_model) && !MODELS[current_model][:opt_in] candidate_tier = COST_TIER_RANK[MODEL_COST_TIER[candidate[:model]]] || 0 current_tier = COST_TIER_RANK[MODEL_COST_TIER[current_model]] || 0 return { model: current_model, reason: "#{candidate[:reason]} (keeping #{current_model} for context continuity)" } if candidate_tier <= current_tier end candidate end |
.available_models ⇒ Array<Hash>
Returns the list of models available in the UI picker, with Auto first.
599 600 601 602 603 604 605 |
# File 'app/services/assistant/chat_service.rb', line 599 def self.available_models auto_option = [{ key: 'auto', label: 'Auto (Smart Select)', cost: :auto, model_id: nil }] = MODELS.map do |key, config| { key: key, label: config[:label], cost: config[:cost], model_id: config[:id] } end auto_option + end |
.compact_consumed_tool_results!(messages) ⇒ Integer
Shrink already-consumed tool results in a live transcript, in place.
"Consumed" = anything before the LAST assistant message: the model has
already produced a response from those results, so re-sending the raw
payload on every remaining round of the turn buys nothing.
Two 2.0 corrections live here. RubyLLM 2.0 Messages are immutable
(+content+ is attr_reader), so a compacted result REPLACES its slot
instead of being mutated — the 1.x msg.content = raises NoMethodError
on 2.0, inside a before_message callback, which would take down any turn
long enough to trip it. And the shrink goes through truncate_result
rather than first(N): the raw cut landed mid-key/mid-value and then
prefixed plain text, so what the model re-read for the rest of the turn
was unparseable — the exact failure truncate_result exists to prevent,
applied to the copy the model is actively working from.
Shared with PlanOrchestrator#install_step_mid_turn_compaction!, which ran
a byte-identical copy of this loop.
1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 |
# File 'app/services/assistant/chat_service.rb', line 1774 def self.compact_consumed_tool_results!() return 0 if .size < MID_TURN_MIN_MESSAGES boundary_idx = .rindex { |msg| msg.role == :assistant } return 0 unless boundary_idx compacted = 0 .each_with_index do |msg, idx| break if idx >= boundary_idx next unless msg.role == :tool content_str = msg.content.to_s next if content_str.length <= MID_TURN_COMPACT_THRESHOLD next if MID_TURN_SKIP_PREFIXES.any? { |prefix| content_str.start_with?(prefix) } [idx] = RubyLLM::Message.new( **msg.to_h, content: Assistant::ChatToolBuilder.truncate_result(content_str, max_chars: MID_TURN_KEEP_CHARS) ) compacted += 1 end compacted end |
.estimate_tokens(text) ⇒ Integer
Rough token estimate (1 token ≈ 4 chars for English).
Used only for heuristic model-complexity selection, not billing.
482 483 484 485 486 |
# File 'app/services/assistant/chat_service.rb', line 482 def self.estimate_tokens(text) return 0 if text.blank? (text.length / 4.0).ceil end |
.label_for_model(model_key) ⇒ String
Resolve a stored model preference / llm_model_name (e.g. 'gemini-pro') to a
human-readable label that includes the actual underlying model id (e.g.
"Gemini 3.5 Flash · Reasoning"). Used by the chat picker and history badges so
users can see WHAT model actually ran a turn — not just the dropdown alias.
Returns the stored value verbatim when no MODELS entry matches.
615 616 617 618 619 620 621 622 |
# File 'app/services/assistant/chat_service.rb', line 615 def self.label_for_model(model_key) return 'Auto (Smart Select)' if model_key.to_s == 'auto' config = MODELS[model_key.to_s] return model_key.to_s if config.nil? config[:label] end |
Instance Method Details
#call(&block) ⇒ Result
Execute the chat with streaming response.
Messages auto-persist to assistant_messages via acts_as_chat
with token tracking, tool calls, and thinking traces.
Yields content chunks as they're generated.
673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 |
# File 'app/services/assistant/chat_service.rb', line 673 def call(&block) raise ArgumentError, 'Block required for streaming' unless block_given? @streamer = block @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) @full_response = +'' configure_conversation(reset_advisor_turn: true) # Tell the conversation who the actual sender is so AssistantMessage # can stamp sender_id on the persisted user message. @conversation.current_sender_id = @user_context['party_id'] # Stream the response — conversation.ask() auto-persists user + assistant messages. # The return value of ask() is the fully-assembled StreamAccumulator message with # correct input/output token counts (not the last streaming chunk, which has nil tokens). streamer_proc = build_streamer_proc = with_instrumented_llm_call(feature: 'assistant_chat') do with_tool_halt do if @attachments.present? (, @attachments, &streamer_proc) else @conversation.ask(, &streamer_proc) end end end halt_result = handle_halt(, streamer_proc, label: 'call') return halt_result if halt_result build_result() rescue Assistant::Cancelled, RubyLLM::CancelledError Rails.logger.info("[Assistant::ChatService] Cancelled by user (call) — conversation #{@conversation.id}") build_cancelled_result rescue RubyLLM::ContextLengthExceededError => e Rails.logger.error("[Assistant::ChatService] Context length exceeded: #{e.}") raise end |
#complete_only(&block) ⇒ Result
Retry path after emergency compaction: reconfigure the conversation and
call complete() directly. The user message is already persisted from the
prior attempt — to_llm replays it from DB. Skips ask() to avoid duplicates.
718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 |
# File 'app/services/assistant/chat_service.rb', line 718 def complete_only(&block) raise ArgumentError, 'Block required for streaming' unless block_given? @streamer = block @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) @full_response = +'' configure_conversation(reset_advisor_turn: false) @conversation.current_sender_id = @user_context['party_id'] streamer_proc = build_streamer_proc = with_instrumented_llm_call(feature: 'assistant_chat') do with_tool_halt do llm_chat = @conversation.to_llm # Recovery replays (body-less 400 #3808, blank-after-tools escalation, # emergency compaction) rebuild history from the DB, which stores the # user message text-only. Re-apply the attachments the worker still has # on disk so a vision turn that retries on another model doesn't answer # the image question blind. (llm_chat, , @attachments) if @attachments.present? llm_chat.complete(&streamer_proc) end end halt_result = handle_halt(, streamer_proc, label: 'complete_only') return halt_result if halt_result build_result() rescue Assistant::Cancelled, RubyLLM::CancelledError Rails.logger.info("[Assistant::ChatService] Cancelled by user (complete_only) — conversation #{@conversation.id}") build_cancelled_result end |
#emit_status(message) ⇒ void (protected)
This method returns an undefined value.
Emit a status update for the UI (non-content, just progress indicator).
Also used by Assistant::PlanOrchestrator (via Object#send).
1714 1715 1716 |
# File 'app/services/assistant/chat_service.rb', line 1714 def emit_status() @on_status&.call() end |
#stream_content(content) ⇒ void (protected)
This method returns an undefined value.
Stream content to client AND capture for conversation history.
Also used by Assistant::PlanOrchestrator (via Object#send).
1560 1561 1562 1563 |
# File 'app/services/assistant/chat_service.rb', line 1560 def stream_content(content) @full_response << content streamer.call(content) end |
#with_instrumented_llm_call(feature:, source: 'sunny') { ... } ⇒ Object (protected)
Wraps an LLM call with PaperTrail audit context, CurrentScope user, instrumentation
metadata, and transient network retries. Every LLM round (ask, complete, agent.ask)
should go through this so audit trail, cost logging, and retries are consistent.
Also used by Assistant::PlanOrchestrator (via Object#send). On a body-less
RubyLLM::BadRequestError it attaches the outgoing request shape to AppSignal
(#3808) before re-raising.
819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 |
# File 'app/services/assistant/chat_service.rb', line 819 def with_instrumented_llm_call(feature:, source: 'sunny', &) sender_id = @user_context['party_id'] || @conversation.user_id whodunnit = sender_id.to_s.presence || 'Sunny' account_id = Account.where(party_id: sender_id).pick(:id) # Multi-round agentic turns: the global instrumentation audience_member only # sees the FINAL tool-loop round's usage (RubyLLM returns the final # response up every recursive complete()), undercounting Sunny chat ~2x — # turns average ~18 billed rounds. assistant_chat is excluded from that # audience_member (MANUALLY_LOGGED_FEATURES); instead we sum this turn's # per-round assistant_messages in log_turn_usage! below, which reconcile # to the Anthropic Cost API within ~10%. sum_turn = feature == 'assistant_chat' # The ledger's OWN high-water mark, not the message table's. RubyLLM # creates a usage row when the attempt starts and only links it to a # message afterwards (chat_methods.rb persist_usage_entry → # link_usage_entries), so an attempt that fails before its message is # persisted keeps message_id NULL forever. Bounding the turn by message id # would silently drop exactly those rows — the billed failures this rollup # exists to stop losing. since_usage_id = sum_turn ? @conversation.ruby_llm_usages.maximum(:id).to_i : nil result = PaperTrail.request( whodunnit: whodunnit, controller_info: { source: source, sender_id: sender_id, sender_name: @user_context['full_name'], conversation_id: @conversation.id, conversation_url: "/en-US/assistant/#{@conversation.id}" } ) do CurrentScope.with_user_id(sender_id) do RubyLLM::Instrumentation.with( **(@log_metadata || {}), feature: feature, conversation_id: @conversation.id, log_subject: @conversation, log_account_id: account_id ) do with_llm_network_retries(&) end end end result rescue RubyLLM::BadRequestError => e # A 400 on a STREAMING turn arrives body-less, so RubyLLM surfaces the # generic "Invalid request - please check your input" with no provider # detail — which left AppSignal #3808 undiagnosable for months (the real # reason is in the REQUEST we sent, not the empty response). Snapshot the # outgoing request shape onto the AppSignal transaction so the NEXT # occurrence names the offending payload (after #1069 fixed the dominant # Opus-4.7+-temperature cause, any residual cause is otherwise opaque). # Diagnostics must never mask the real error — re-raise unconditionally. attach_llm_request_diagnostics(e) raise ensure # In `ensure`, not on the success path: a raised error or a user # cancellation unwinds past the normal return, and those attempts are # exactly the ones that consumed tokens without producing a message. # Logging only on success threw away the spend this rollup exists to # capture — the same loss the message_id filter used to cause one layer # down. log_turn_usage! swallows its own exceptions, so it cannot replace # the in-flight error. # # since_usage_id must be non-nil, not merely sum_turn: if the high-water # query itself raised, the boundary was never captured and a nil would # scope `id >= 1` — re-logging the conversation's entire history. log_turn_usage!(since_usage_id, account_id) if sum_turn && since_usage_id end |