Class: Phone::CallLogEventToRecordMatcher
- Inherits:
-
BaseService
- Object
- BaseService
- Phone::CallLogEventToRecordMatcher
- Defined in:
- app/services/phone/call_log_event_to_record_matcher.rb
Overview
Links recorded audio (CallRecord) to the connected (TALKING) call-log legs
that produced it.
A single answered phone call routinely appears as SEVERAL CDR legs — ring,
transfer, consult — each under its own cdr_call_id, but Switchvox records it
ONCE. So the matcher must not assume one recording per leg, and it must not let
whichever leg it happens to evaluate first claim the audio: a 6-second ring leg
would otherwise grab the 4-minute conversation's recording, starving the real
talking leg (which then reads as a "missing recording" in
Report::CallRecordingGaps).
It therefore scores every plausible (leg, recording) pair and assigns GLOBALLY:
- Candidate signal — a recording is a candidate for a leg's call when the
recording's Switchvox account id is one of the call's account ids
(call_logs.from/to_account_id), OR a phone number on the recording is the
call's external party (from_number/to_number) or the leg's extension.
Account-id is the most reliable key (same id space on both sides) and
survives cases where the recording is labelled with a different extension
than the CDR leg. - Gate — the recording must fall inside the call's (slack-widened) time window
and within a TWO-SIDED duration tolerance of the leg's talk time
(CallLogEventToRecordMatcher.duration_tolerance). The old one-sidedduration_secs >= 0.95 * talk
floor let a tiny leg own an arbitrarily long recording and rejected
sub-minute recordings that ran 1–3s short of the CDR's reported talk time. - Assignment — across the whole batch, assign recordings to legs least-cost
first (cost = |recording length − talk time|), each used at most once, so a
recording lands on the leg whose talk time it actually fits.
Defined Under Namespace
Classes: LegContext, Result
Constant Summary collapse
- WINDOW_SLACK =
Recordings begin a touch before the CDR leg's wall clock and audio can run a
little past it; widen the call window by this much on each side. 90.seconds
Class Method Summary collapse
-
.duration_tolerance(talk_seconds) ⇒ Integer
A recording is only a candidate for a leg when its length is within this many seconds of the leg's talk time — the larger of 90s or 150% of the talk time.
Instance Method Summary collapse
-
#candidate_recordings(cle, exclude_linked: true, context: nil) ⇒ ActiveRecord::Relation<CallRecord>
Recordings that could belong to this leg's call.
- #load_call_log_events(options) ⇒ ActiveRecord::Relation<CallLogEvent>
-
#parse_display_string(display) ⇒ Array(String, String)
Pull the agent extension and trailing talk time out of a leg's display string, e.g.
-
#process(options = {}) ⇒ Result
Match every unmatched TALKING leg in the batch to its best free recording.
-
#reset_call_record_link(call_log_events = nil) ⇒ void
Clear the leg→recording links (e.g. to re-run matching after a fix).
Class Method Details
.duration_tolerance(talk_seconds) ⇒ Integer
A recording is only a candidate for a leg when its length is within this many
seconds of the leg's talk time — the larger of 90s or 150% of the talk time.
Two-sided on purpose: it blocks a short ring/consult leg from claiming a long
conversation's recording, while staying loose enough for hold/setup overhead
and the 1–3s rounding that makes a sub-minute recording read shorter than the
CDR's talk time.
53 |
# File 'app/services/phone/call_log_event_to_record_matcher.rb', line 53 def self.duration_tolerance(talk_seconds) = [90, (talk_seconds * 1.5).round].max |
Instance Method Details
#candidate_recordings(cle, exclude_linked: true, context: nil) ⇒ ActiveRecord::Relation<CallRecord>
Recordings that could belong to this leg's call.
exclude_linked: true (the matcher) drops recordings already attached to
another leg — it wants free audio. The gaps report passes false to ask the
weaker question "does the archive hold ANY audio for this call?", which is how
it tells a genuinely missing recording from one a sibling leg already claimed.
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'app/services/phone/call_log_event_to_record_matcher.rb', line 82 def candidate_recordings(cle, exclude_linked: true, context: nil) ctx = context || leg_context(cle) return CallRecord.none if ctx.nil? || (ctx.numbers.empty? && ctx.account_ids.empty?) t = CallRecord.arel_table signals = [] unless ctx.numbers.empty? signals << t[:origin_number].in(ctx.numbers) signals << t[:destination_number].in(ctx.numbers) end unless ctx.account_ids.empty? signals << t[:switchvox_from_account_id].in(ctx.account_ids) signals << t[:switchvox_to_account_id].in(ctx.account_ids) end rel = CallRecord.select(:id, :duration_secs, :created_at) .where(created_at: ctx.window) .where(signals.reduce(:or)) return rel unless exclude_linked rel.where.not(id: CallLogEvent.where.not(call_record_id: nil).select(:call_record_id)) end |
#load_call_log_events(options) ⇒ ActiveRecord::Relation<CallLogEvent>
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'app/services/phone/call_log_event_to_record_matcher.rb', line 107 def load_call_log_events() call_log_events = if [:call_log_events].present? [:call_log_events] elsif [:call_log_event_ids].present? CallLogEvent.where(id: [:call_log_event_ids]) elsif [:call_log_ids].present? CallLogEvent.where(call_log_id: [:call_log_ids]) else CallLogEvent.where(call_record_id: nil) end # Filter only leg type TALKING call_log_events = call_log_events.where(leg_type: 'TALKING').order(:start_time).reverse_order # Include call_logs (leg_context reads start_time / total_duration / account ids) call_log_events = call_log_events.includes(:call_log) # Filter by start time call_log_events = call_log_events.where(CallLogEvent[:start_time].gteq([:start_time])) if [:start_time] # Filter by limit call_log_events = call_log_events.limit([:limit]) if [:limit].present? logger.info "Loaded #{call_log_events.size} call log event(s) to process" call_log_events end |
#parse_display_string(display) ⇒ Array(String, String)
Pull the agent extension and trailing talk time out of a leg's display string,
e.g. "Talked to Mary M. Godawa <836> for 3 minutes, 44 seconds" -> ["836",
"3 minutes, 44 seconds"]. Outbound legs bracket the dialed number instead.
147 148 149 |
# File 'app/services/phone/call_log_event_to_record_matcher.rb', line 147 def parse_display_string(display) display.to_s.scan(/\ATalked\sto.*<(\d{3,})>\sfor\s(.*)\z/).flatten end |
#process(options = {}) ⇒ Result
Match every unmatched TALKING leg in the batch to its best free recording.
60 61 62 63 64 65 66 67 68 69 |
# File 'app/services/phone/call_log_event_to_record_matcher.rb', line 60 def process( = {}) legs = load_call_log_events().to_a edges = build_edges(legs) matched = persist_assignments(assign_globally(edges)) logger.info "CallLogEventToRecordMatcher matched #{matched}/#{legs.size} leg(s)" Result.new(records_processed: legs.size, records_matched: matched, records_unmatched: legs.size - matched) end |
#reset_call_record_link(call_log_events = nil) ⇒ void
This method returns an undefined value.
Clear the leg→recording links (e.g. to re-run matching after a fix). Operates
on CallLogEvent, which owns call_record_id; the default must therefore be
CallLogEvent.all, not CallRecord.all (that column doesn't exist there, so
the no-arg path used to raise).
137 138 139 |
# File 'app/services/phone/call_log_event_to_record_matcher.rb', line 137 def reset_call_record_link(call_log_events = nil) (call_log_events || CallLogEvent.all).update_all(call_record_id: nil) end |