Skip to content

feat(routing): make conversation assignment extensible via EvoExtensionPoints#124

Open
miltonsosa-info-unlp wants to merge 7 commits into
evolution-foundation:developfrom
miltonsosa-info-unlp:feat/extensible-routing-strategy
Open

feat(routing): make conversation assignment extensible via EvoExtensionPoints#124
miltonsosa-info-unlp wants to merge 7 commits into
evolution-foundation:developfrom
miltonsosa-info-unlp:feat/extensible-routing-strategy

Conversation

@miltonsosa-info-unlp

Copy link
Copy Markdown
Contributor

Summary

Today AgentAssignmentService#find_assignee hardcodes InboxRoundRobinService.
There is no way for a deployer to change the assignment algorithm without forking the repo.

This PR makes the assignment algorithm a declared extension point.
Any deployer drops one initializer file to replace the algorithm entirely —
the community default (Round Robin) is unchanged for anyone who doesn't.


What this PR does

  • Extracts Strategies::RoundRobin — a thin facade over the existing
    InboxRoundRobinService. No logic change; this becomes the fallback when no
    override is registered.

  • Declares :routing_strategy in EvoExtensionPoints (v2.0.0 → v2.1.0) — a
    single hook resolved by AgentAssignmentService at runtime.

  • Wires AgentAssignmentService#find_assignee to the extension point with
    Strategies::RoundRobin as the fallback — three lines, zero behavior change
    without an override.

  • Ships Strategies::BalancedLoad — a ready-to-use strategy that assigns to the
    online agent with the fewest open conversations (single GROUP BY SQL query,
    no N+1, UUID-safe). Activatable in one initializer file; not the default.


Activating a custom strategy

Drop a file in config/initializers/ — nothing else changes:

# config/initializers/custom_routing.rb

EvoExtensionPoints.replace(:routing_strategy) do |conversation, allowed_agent_ids:|
  AutoAssignment::Strategies::BalancedLoad.call(
    conversation,
    allowed_agent_ids: allowed_agent_ids
  )
end

That is the complete integration. No migrations, no UI changes, no core modifications.

EvoExtensionPoints is loaded eagerly in config/application.rb (same pattern as EvoFlow::EVENT_NAMES) — no require needed in the deployer's initializer.


Writing your own strategy

Any object that responds to .call(conversation, allowed_agent_ids: [String]) → User | nil
qualifies. See app/services/auto_assignment/strategies/README.md
for the full interface contract, implementation guidance, and a commented example
that scores agents by workload and time dedicated to conversations today.


Functional transparency: what changes (and what doesn't)

Scenario Before this PR After this PR
No initializer registered Round Robin (hardcoded) Round Robin (via fallback) — identical behavior
Custom initializer present Not possible without a fork Custom strategy executes
Log output Silent [AgentAssignment] routing via RoundRobin for conversation 42

The log line makes the active strategy observable in production without extra tooling.
With a custom override it logs [AgentAssignment] routing via EvoExtensionPoints[:routing_strategy].


Backward compatibility

Zero behavior change in the community release.

Without a registered override, impl_for(:routing_strategy) returns nil and the
fallback path executes Strategies::RoundRobin, which delegates to the same
InboxRoundRobinService as the current code. Every existing spec passes unchanged.

The private methods round_robin_manage_service and round_robin_key are removed
from AgentAssignmentService — they had no callers outside the class and their
private visibility was explicit. If any deployment subclasses AgentAssignmentService
and calls these methods directly, a NoMethodError will surface; the fix is to call
AutoAssignment::Strategies::RoundRobin.call(...) directly.


Changed files

app/services/auto_assignment/strategies/base.rb                    NEW — interface contract module
app/services/auto_assignment/strategies/round_robin.rb             NEW — RoundRobin facade (default)
app/services/auto_assignment/strategies/balanced_load.rb           NEW — BalancedLoad strategy
app/services/auto_assignment/strategies/README.md                  NEW — how to implement a custom strategy
app/services/auto_assignment/agent_assignment_service.rb           MODIFIED — wire extension point, remove dead methods
config/application.rb                                              MODIFIED — eagerly load EvoExtensionPoints before initializers run
lib/evo_extension_points.rb                                        MODIFIED — add :routing_strategy, bump v2.0.0 → v2.1.0
lib/evo_extension_points/routing_strategy.rb                       NEW — no-op module (CI contract_check guard-rail)
EXTENSION_POINTS.md                                                MODIFIED — document :routing_strategy as section 6
spec/services/auto_assignment/strategies/round_robin_spec.rb       NEW — regression guard + contract compliance
spec/services/auto_assignment/strategies/balanced_load_spec.rb     NEW — full coverage including N+1 guard
spec/lib/evo_extension_points/routing_strategy_spec.rb             NEW — KNOWN_KEYS, nil default, override cycle
spec/lib/evo_extension_points_spec.rb                              MODIFIED — version assertion updated to 2.1.0
spec/services/auto_assignment/agent_assignment_service_spec.rb     MODIFIED — without/with override scenarios

Security

Pure internal refactor. Zero new HTTP endpoints, zero auth surface changes.
No new gems, no migrations, no environment variables.
Extension point overrides execute only code explicitly registered at process boot —
the registration is a closed, auditable list of EvoExtensionPoints.replace(...) calls
in the deployer's own initializers.


Test plan

bundle exec rspec \
  spec/services/auto_assignment/ \
  spec/lib/evo_extension_points/ \
  --format documentation

Expected: all green. Covers regression (RoundRobin parity), contract compliance,
extension point wiring (with/without override), BalancedLoad logic including N+1 guard.


Linked issue / context

This replaces PRs #102, #104, and #109 (stacked, fragmented approach).
Those PRs are superseded by this single self-contained change.

- Strategies::Base defines interface contract (User.find_by only)

- Strategies::RoundRobin delegates to InboxRoundRobinService

- Regression spec ensures identical output for same inputs
- Separate interface contract from implementation guidance in Base:
  move User.find_by policy note under explicit 'Implementation guidance'
  section so the contract boundary is clear
- RoundRobin now includes AutoAssignment::Strategies::Base to create an
  explicit code-level relationship instead of doc-only coupling
…nts v2.1.0)

- Declare EvoExtensionPoints::RoutingStrategy no-op module
- Add :routing_strategy to KNOWN_KEYS; bump contract version 2.0.0 -> 2.1.0
- Wire AgentAssignmentService#find_assignee to extension point with
  Strategies::RoundRobin fallback and Rails.logger info line
- Document :routing_strategy in EXTENSION_POINTS.md (v2.1.0 section)
- Add spec/lib/evo_extension_points/routing_strategy_spec.rb (6 examples)
- Add spec/services/auto_assignment/agent_assignment_service_spec.rb (5 examples)
- Update evo_extension_points_spec.rb version assertion to 2.1.0

All 44 specs pass. contract_check guard-rail (EVO-1287) passes.
- Fix stale comment: 'five sub-modules' -> 'six sub-modules'
- Remove dead private methods: round_robin_manage_service, round_robin_key
- Improve log readability: Proc override now logs as EvoExtensionPoints[:routing_strategy]
- Add :routing_strategy example to EXTENSION_POINTS.md consumer section
… extension point

- routing_strategy.rb: note that strategy classes must include Strategies::Base
- EXTENSION_POINTS.md: clarify Strategies::Base as formal Ruby contract module
  for internal classes; Proc-based consumers are not required to include it
…oot overrides

- Load EvoExtensionPoints in config/application.rb before initializers run
  (Zeitwerk does not autoload lib/ during initializer phase; same pattern
  used for EvoFlow::EVENT_NAMES).  Deployers can now drop a routing
  initializer without a require.
- Add before { EvoExtensionPoints.reset! } in the 'without override'
  context so the spec is not affected by any initializer that registers
  an override at process boot.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @miltonsosa-info-unlp, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants