File size: 15,664 Bytes
e6496c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
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
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
"""analysis/tone_drift.py β€” multi-quarter transcript drift signals for the Analyst Edge layer.

Pure Python + sentence-transformers, zero LLM calls. Extends the textdiff
approach (filings) to earnings-call transcripts across the last N quarters:

  1. tone_trend         β€” hedge/certainty word rate trajectory in management speech
  2. topic_arc          β€” lexicon term frequency rising/falling across 3+ calls
  3. recurring_evasion  β€” analyst question asked on 2+ calls, answered evasively
  4. topic_fade         β€” topic prominent in prior prepared remarks, absent now

Usage:
    from analysis.tone_drift import compute
    signals = compute("NVDA")
"""
from __future__ import annotations

import re
from collections import Counter

from analysis.signals import QuarterDelta
from analysis.textdiff import (
    _LEXICON,
    _KPI_PATTERNS,
    _detect_trend,
    _embed,
    _find_context_sentence,
    _split_sentences,
    _truncate,
)
from analysis.transcript_parse import ParsedCall, parse_call
from storage.sections_db import _transcript_sort_key, get_recent_transcripts

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------

# Spoken hedging β€” looser register than the written _HEDGE_WORDS in textdiff.
_SPOKEN_HEDGES = [
    "i think", "we believe", "sort of", "kind of", "we'll see",
    "hard to say", "too early", "it depends", "uncertain", "cautious",
    "headwind", "challenge", "moderate", "soften", "roughly",
    "somewhat", "a bit of", "remains to be seen",
]

_CERTAINTY_WORDS = [
    "we will", "confident", "strong", "record", "robust",
    "momentum", "accelerat", "very pleased", "outstanding", "exceed",
    "ahead of plan", "better than expected",
]

# Phrases that mark a non-answer to an analyst question.
_DEFLECTIONS = [
    "we don't guide", "we do not guide", "not going to guide",
    "too early to say", "too early to tell", "as i said", "as we said",
    "we don't disclose", "we do not disclose", "won't break out",
    "don't break out", "not going to get into", "stay tuned",
    "more to come", "we'll see how",
]

# Transcript-specific topics beyond the shared filing lexicon.
_TRANSCRIPT_EXTRA_TERMS: list[tuple[str, str]] = [
    (r"\bdemand\b",     "demand"),
    (r"\binventory\b",  "inventory"),
    (r"\bpricing\b",    "pricing"),
    (r"\bvisibility\b", "visibility"),
    (r"\bsupply\b",     "supply"),
    (r"\bchina\b",      "China"),
    (r"\bbacklog\b",    "backlog"),
]

_EVASION_SIM_THRESHOLD = 0.60   # cosine: two questions considered the same topic
_MIN_QUESTION_WORDS = 15
_MAX_QUESTIONS_PER_CALL = 30
_QUESTION_EMBED_WORDS = 120
_DIGIT_RATIO_THRESHOLD = 0.005  # answers with fewer digits than this are "non-quantitative"
_SHORT_ANSWER_WORDS = 80

_MAX_TOTAL = 6

_STOPWORDS = frozenset(
    "a an and are as at be but by can could for from has have how i if in is it "
    "just like me my of on or our so that the then there this to was we what when "
    "which will with would you your about more very really them they those these "
    "going get got want wanted maybe think know kind sort little also any do does "
    "your guys thanks thank question congrats curious wondering color give us "
    "side year years quarter quarters talk talked talking look looking lot bit "
    "say said see seeing help understand grew growing mentioned should".split()
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _word_count(text: str) -> int:
    return len(text.split())


def _phrase_rate(text: str, phrases: list[str]) -> int:
    """Occurrences of any phrase per 10k words, rounded to int."""
    words = _word_count(text)
    if words == 0:
        return 0
    lower = text.lower()
    hits = sum(lower.count(p) for p in phrases)
    return round(hits / words * 10_000)


def _rep_sentence(text: str, phrases: list[str]) -> str:
    """Shortest quotable sentence containing one of the phrases."""
    matches = [
        s for s in _split_sentences(text)
        if any(p in s.lower() for p in phrases)
    ]
    if not matches:
        return ""
    return _truncate(min(matches, key=lambda s: len(s.split())), 60)


def _question_topic(question: str) -> str:
    """2-3 most frequent non-stopword tokens as a compact topic label."""
    tokens = re.findall(r"[a-z]{3,}", question.lower())
    counts = Counter(t for t in tokens if t not in _STOPWORDS)
    return " / ".join(t for t, _ in counts.most_common(3))


def _is_evasive(answer: str) -> tuple[bool, str]:
    """(evasive?, deflection phrase found or '')."""
    lower = answer.lower()
    for phrase in _DEFLECTIONS:
        if phrase in lower:
            return True, phrase
    words = answer.split()
    if words and len(words) < _SHORT_ANSWER_WORDS:
        digit_tokens = sum(1 for w in words if any(c.isdigit() for c in w))
        if digit_tokens / len(words) < _DIGIT_RATIO_THRESHOLD:
            return True, ""
    return False, ""


# ---------------------------------------------------------------------------
# 1. Management tone trend
# ---------------------------------------------------------------------------

def compute_tone_trend(calls: list[ParsedCall]) -> list[QuarterDelta]:
    """Hedge/certainty word rate trajectory across 3+ calls (management speech only)."""
    usable = [c for c in calls if _word_count(c.management_text) >= 500]
    if len(usable) < 3:
        return []

    deltas: list[QuarterDelta] = []
    series_specs = [
        (_SPOKEN_HEDGES, "hedging language", "hedge-word",
         {"rising": "more cautious", "falling": "more confident"}),
        (_CERTAINTY_WORDS, "confidence language", "certainty-word",
         {"rising": "more confident", "falling": "more cautious"}),
    ]

    for phrases, term, rate_label, direction_map in series_specs:
        rates = [_phrase_rate(c.management_text, phrases) for c in usable]
        trend = _detect_trend(rates)
        if trend is None:
            continue

        direction, run_str, _ = trend.split()
        run_quarters = int(run_str)
        reading = direction_map[direction]
        first, last = rates[0], rates[-1]
        net_change = abs(last - first) / (first or 1)
        sig = "HIGH" if (run_quarters >= 4 or net_change >= 0.5) else "MEDIUM"

        trajectory = "β†’".join(str(r) for r in rates)
        metric = (
            f"{rate_label} rate {trajectory} per 10k words over "
            f"{usable[0].period}β†’{usable[-1].period} ({trend}) β†’ {reading}"
        )
        deltas.append(QuarterDelta(
            kind="tone_trend",
            period_from=usable[0].period,
            period_to=usable[-1].period,
            before_text=_rep_sentence(usable[0].management_text, phrases),
            after_text=_rep_sentence(usable[-1].management_text, phrases),
            computed_metric=metric,
            source="transcript",
            significance=sig,
            term=term,
        ))

    return deltas


# ---------------------------------------------------------------------------
# 2. Topic emphasis arcs
# ---------------------------------------------------------------------------

def compute_topic_arcs(
    calls: list[ParsedCall],
    raw_texts: list[str],
) -> list[QuarterDelta]:
    """Lexicon terms whose mention count rises/falls monotonically across 3+ calls."""
    if len(calls) < 3:
        return []

    deltas: list[QuarterDelta] = []
    for pattern, label in _LEXICON + _TRANSCRIPT_EXTRA_TERMS:
        counts = [len(re.findall(pattern, t, re.IGNORECASE)) for t in raw_texts]
        if max(counts) < 3:
            continue  # noise floor
        trend = _detect_trend(counts)
        if trend is None:
            continue

        run_quarters = int(trend.split()[1])
        metric = (
            f"{counts[0]}β†’{counts[-1]} mentions over "
            f"{calls[0].period}β†’{calls[-1].period} ({trend})"
        )
        deltas.append(QuarterDelta(
            kind="topic_arc",
            period_from=calls[0].period,
            period_to=calls[-1].period,
            before_text=_find_context_sentence(raw_texts[0], pattern) if counts[0] else "",
            after_text=_find_context_sentence(raw_texts[-1], pattern) if counts[-1] else "",
            computed_metric=metric,
            source="transcript",
            significance="HIGH" if run_quarters >= 4 else "MEDIUM",
            term=label,
        ))

    deltas.sort(key=lambda d: {"HIGH": 0, "MEDIUM": 1}.get(d.significance, 2))
    return deltas[:3]


# ---------------------------------------------------------------------------
# 3. Recurring Q&A evasions
# ---------------------------------------------------------------------------

def compute_recurring_evasions(calls: list[ParsedCall]) -> list[QuarterDelta]:
    """Analyst question topics raised on 2+ calls where answers stay non-quantitative."""
    qa_calls = [c for c in calls if c.qa]
    if len(qa_calls) < 2:
        return []

    latest = qa_calls[-1]
    priors = qa_calls[:-1]

    def _select(call: ParsedCall) -> list:
        picked = [x for x in call.qa if _word_count(x.question) >= _MIN_QUESTION_WORDS]
        return picked[:_MAX_QUESTIONS_PER_CALL]

    latest_qs = _select(latest)
    prior_qs: list[tuple[str, object]] = []  # (period, QAExchange)
    for call in priors:
        prior_qs.extend((call.period, x) for x in _select(call))
    if not latest_qs or not prior_qs:
        return []

    texts = (
        [_truncate(x.question, _QUESTION_EMBED_WORDS) for x in latest_qs]
        + [_truncate(x.question, _QUESTION_EMBED_WORDS) for _, x in prior_qs]
    )
    vecs = _embed(texts)
    latest_vecs = vecs[: len(latest_qs)]
    prior_vecs = vecs[len(latest_qs):]
    sim = latest_vecs @ prior_vecs.T  # (n_latest, n_prior)

    deltas: list[QuarterDelta] = []
    used_prior: set[int] = set()

    for li, lx in enumerate(latest_qs):
        matched = [
            pi for pi in range(len(prior_qs))
            if pi not in used_prior and sim[li, pi] >= _EVASION_SIM_THRESHOLD
        ]
        if not matched:
            continue

        # prior_qs is in chronological call order, so cluster[0] is the earliest.
        cluster = [(prior_qs[pi][0], prior_qs[pi][1]) for pi in matched]
        cluster.append((latest.period, lx))
        periods = sorted({p for p, _ in cluster}, key=_transcript_sort_key)
        if len(periods) < 2:
            continue

        evasive_flags = [_is_evasive(x.answer) for _, x in cluster]
        n_evasive = sum(1 for flag, _ in evasive_flags if flag)
        # Require a majority of evasive answers, not just two outliers in a
        # large cluster of recurring questions.
        if n_evasive < 2 or n_evasive * 2 < len(cluster):
            continue
        used_prior.update(matched)

        deflection = next((p for flag, p in evasive_flags if flag and p), "")
        deflection_str = f" (deflection: '{deflection}')" if deflection else ""
        metric = (
            f"asked in {', '.join(periods)}; "
            f"{n_evasive}/{len(cluster)} answers non-quantitative{deflection_str}"
        )

        earliest_period, earliest_x = cluster[0]
        latest_evasive = next(
            (x for (_, x), (flag, _) in zip(reversed(cluster), reversed(evasive_flags)) if flag),
            lx,
        )
        answer_quote = (
            _rep_sentence(latest_evasive.answer, [deflection]) if deflection else ""
        ) or _truncate(latest_evasive.answer, 60)

        deltas.append(QuarterDelta(
            kind="recurring_evasion",
            period_from=periods[0],
            period_to=latest.period,
            before_text=_truncate(f"{earliest_x.analyst}: {earliest_x.question}", 60),
            after_text=answer_quote,
            computed_metric=metric,
            source="transcript",
            significance="HIGH" if len(periods) >= 3 else "MEDIUM",
            term=_question_topic(lx.question),
        ))

    deltas.sort(key=lambda d: {"HIGH": 0, "MEDIUM": 1}.get(d.significance, 2))
    return deltas[:2]


# ---------------------------------------------------------------------------
# 4. Prepared-remarks topic fades
# ---------------------------------------------------------------------------

def compute_topic_fades(calls: list[ParsedCall]) -> list[QuarterDelta]:
    """Topic with 2+ mentions in 2+ prior prepared remarks, absent from the latest."""
    usable = [c for c in calls if _word_count(c.prepared_text) >= 300]
    if len(usable) < 3:
        return []

    latest = usable[-1]
    priors = usable[:-1]

    deltas: list[QuarterDelta] = []
    seen_labels: set[str] = set()
    for pattern, label in _KPI_PATTERNS + _TRANSCRIPT_EXTRA_TERMS:
        if label in seen_labels:
            continue  # 'backlog' appears in both lists
        seen_labels.add(label)
        prior_counts = [
            (c, len(re.findall(pattern, c.prepared_text, re.IGNORECASE)))
            for c in priors
        ]
        prominent = [(c, n) for c, n in prior_counts if n >= 2]
        if len(prominent) < 2:
            continue
        if re.search(pattern, latest.prepared_text, re.IGNORECASE):
            continue

        periods_str = " and ".join(c.period for c, _ in prominent)
        counts_str = ", ".join(str(n) for _, n in prominent)
        last_prominent = prominent[-1][0]
        deltas.append(QuarterDelta(
            kind="topic_fade",
            period_from=prominent[0][0].period,
            period_to=latest.period,
            before_text=_find_context_sentence(last_prominent.prepared_text, pattern),
            after_text="",
            computed_metric=(
                f"'{label}' in prepared remarks of {periods_str} "
                f"({counts_str} mentions), absent in {latest.period}"
            ),
            source="transcript",
            significance="MEDIUM",
            term=label,
        ))

    return deltas[:2]


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------

def compute(ticker: str, n: int = 4) -> list[QuarterDelta]:
    """Compute all transcript drift signals for a ticker.

    Examines the last *n* non-empty transcripts. Returns an empty list if
    fewer than 2 are available or on any error β€” never raises.
    """
    try:
        return _compute_inner(ticker, n)
    except Exception as exc:
        import sys
        print(f"[tone_drift] Error computing deltas for {ticker}: {exc}", file=sys.stderr)
        return []


def _compute_inner(ticker: str, n: int) -> list[QuarterDelta]:
    transcripts = get_recent_transcripts(ticker.upper(), n=n)
    if len(transcripts) < 2:
        return []

    raw_texts = [text for _, text in transcripts]
    calls = [parse_call(period, text) for period, text in transcripts]

    all_deltas: list[QuarterDelta] = []
    all_deltas.extend(compute_tone_trend(calls))
    all_deltas.extend(compute_topic_arcs(calls, raw_texts))
    all_deltas.extend(compute_recurring_evasions(calls))
    all_deltas.extend(compute_topic_fades(calls))

    # Dedupe by (kind, term), HIGH first, global cap.
    seen: set[tuple[str, str]] = set()
    deduped: list[QuarterDelta] = []
    order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
    all_deltas.sort(key=lambda d: (order[d.significance], d.kind))
    for d in all_deltas:
        key = (d.kind, d.term)
        if key not in seen:
            seen.add(key)
            deduped.append(d)
    return deduped[:_MAX_TOTAL]