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.
-
#dedupe_duplicate_legs(legs) ⇒ Array<CallLogEvent>
Switchvox occasionally logs the same external TALKING leg twice for one call — once as "<+E164>" and once as "" — same
cdr_call_id, samestart_time, same talk time, differing only by the "+". - #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.
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'app/services/phone/call_log_event_to_record_matcher.rb', line 87 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 |
#dedupe_duplicate_legs(legs) ⇒ Array<CallLogEvent>
Switchvox occasionally logs the same external TALKING leg twice for one call —
once as "<+E164>" and once as "" — same cdr_call_id, same start_time,
same talk time, differing only by the "+". They are one conversation with one
recording, so collapse them to a single recordable leg (preferring the
bare-digit rendering). Without this, now that "<+E164>" parses, the twin would
be a second recordable leg that can never get its own audio and would read as a
perpetual "unlinked" gap in Report::CallRecordingGaps.
177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'app/services/phone/call_log_event_to_record_matcher.rb', line 177 def dedupe_duplicate_legs(legs) legs.group_by { |cle| duplicate_leg_key(cle) } .flat_map do |(_cdr, _at, number, talk_seconds), group| # Only collapse legs that resolve to the SAME external number AND talk # time — the "<+E164>"/"<E164>" twins share both, so a same-call/same-start # leg with a different duration is a distinct conversation, not a "+/bare" # twin, and must not be dropped. next group if number.nil? || talk_seconds.nil? [group.min_by { |cle| [cle.display.to_s.include?('<+') ? 1 : 0, cle.id] }] end end |
#load_call_log_events(options) ⇒ ActiveRecord::Relation<CallLogEvent>
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
# File 'app/services/phone/call_log_event_to_record_matcher.rb', line 117 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,
which Switchvox renders in E.164 with a leading "+" — e.g.
"Talked to <+19177638575> for 4 minutes, 57 seconds" -> ["19177638575", …].
The "+" is optional and not captured so the bare-digit and E.164 renderings of
the same number normalise to one caller-id (see #dedupe_duplicate_legs); a
regex that demanded a digit right after "<" left every "<+E164>" outbound leg
unparseable, so its recording could never link and silently orphaned.
163 164 165 |
# File 'app/services/phone/call_log_event_to_record_matcher.rb', line 163 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.
64 65 66 67 68 69 70 71 72 73 74 |
# File 'app/services/phone/call_log_event_to_record_matcher.rb', line 64 def process( = {}) legs = dedupe_duplicate_legs(load_call_log_events().to_a) assignments = assign_globally(build_edges(legs)) assignments = add_keyless_time_matches(legs, assignments) matched = persist_assignments(assignments) 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).
147 148 149 |
# File 'app/services/phone/call_log_event_to_record_matcher.rb', line 147 def reset_call_record_link(call_log_events = nil) (call_log_events || CallLogEvent.all).update_all(call_record_id: nil) end |