amplegest / analysis /transcript_parse.py
Viney's picture
feat: analyst-edge screens for events, wording, Q&A and earnings quality
5b9b478
Raw
History Blame Contribute Delete
10.7 kB
"""analysis/transcript_parse.py β€” structural parsing of flattened earnings-call text.
Pure regex, zero embeddings, zero LLM. Operates on the Alpha Vantage transcript
format produced by ingestion/transcript.py: one line per speaker segment,
either "Speaker: content" or "Speaker (Title): content".
Reconstructs:
- prepared remarks vs Q&A boundary
- speaker roles (operator / management / analyst)
- analyst question β†’ management answer exchanges
Degrades gracefully: if the structure cannot be recovered, the full text is
treated as management speech and the Q&A exchange list is empty.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
# A speaker's role routinely carries its own parentheses β€” the common form is
# "Jane Roe (Analyst (Example Bank)):". A title pattern that stops at the first
# closing bracket never reaches the colon on those lines, so the turn was not
# recognised as a speaker change at all and got appended to whoever spoke last.
# One level of nesting is enough for every format observed.
_TITLE = r"(?:[^()]|\([^()]*\))"
# "Speaker: content" or "Speaker (Title): content" at line start.
_SPEAKER_RE = re.compile(
rf"^([A-Z][\w.\-' ]{{0,60}}?)(?:\s*\(({_TITLE}{{1,80}})\))?:\s+(.*)$"
)
# Marks the transition from prepared remarks to analyst Q&A.
_QA_BOUNDARY_RE = re.compile(
r"question-and-answer session"
r"|question and answer session"
r"|ready to (?:start|begin) the q\s*&\s*a"
r"|open (?:up )?the (?:call|line|floor)s? for questions"
r"|poll (?:the audience|the lines?|for) ?(?:for questions|questions)?"
r"|(?:take|taking) (?:your|the first) questions?"
r"|first question comes? from",
re.IGNORECASE,
)
# Operator hand-offs that name the analyst and their firm.
_ANALYST_INTRO_RE = re.compile(
r"(?:line of|comes? from(?: the line of)?)\s+([A-Z][\w.\-' ]+?)\s+(?:with|from|at)\s+",
)
_MIN_SEGMENTS = 5
@dataclass
class Segment:
speaker: str
text: str
role: str = "unknown" # operator | management | analyst | unknown
@dataclass
class QAExchange:
analyst: str
question: str
answer: str
@dataclass
class ParsedCall:
period: str
prepared_text: str # management prepared remarks (pre-Q&A)
management_text: str # prepared remarks + all answers
qa: list[QAExchange] = field(default_factory=list)
n_segments: int = 0
def _last_name(name: str) -> str:
parts = name.strip().rstrip(".").split()
return parts[-1].lower() if parts else ""
def _split_segments(text: str) -> list[Segment]:
segments: list[Segment] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
m = _SPEAKER_RE.match(line)
if m:
title = m.group(2) or ""
speaker = m.group(1).strip()
for seg in _split_inline_speakers(speaker, title, m.group(3).strip()):
segments.append(seg)
elif segments:
segments[-1].text += " " + line
return segments
# Some providers put a whole exchange on one line: the operator's hand-off and
# the analyst's question share it, as in
#
# Operator: Our first question comes from Jane Roe with Example Bank.
# Jane Roe (Analyst (Example Bank)): I have two, one for...
#
# arriving as a single line. Matching only at line start then buried every
# analyst turn inside the operator's segment, so the call parsed to zero
# questions despite being fully structured. Requiring a multi-word capitalised
# name followed by a parenthesised role keeps ordinary prose ("the ratio (as
# defined): ...") from being mistaken for a speaker change.
_INLINE_SPEAKER_RE = re.compile(
rf"(?<=\s)((?:[A-Z][\w.\-']*\s){{1,3}}[A-Z][\w.\-']*)\s*\(({_TITLE}{{1,80}})\):\s+"
)
def _split_inline_speakers(speaker: str, title: str, body: str) -> list[Segment]:
"""Split one line into every speaker turn it actually contains."""
def _make(name: str, role_title: str, content: str) -> Segment:
seg = Segment(speaker=name.strip(), text=content.strip())
if role_title and re.search(r"analyst", role_title, re.IGNORECASE):
seg.role = "analyst"
return seg
matches = list(_INLINE_SPEAKER_RE.finditer(body))
if not matches:
return [_make(speaker, title, body)]
out = [_make(speaker, title, body[: matches[0].start()])]
for index, match in enumerate(matches):
end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
out.append(_make(match.group(1), match.group(2), body[match.end():end]))
# A hand-off line can leave the operator with nothing but the introduction;
# keep it only when it carries text of its own.
return [seg for seg in out if seg.text]
def parse_call(period: str, text: str) -> ParsedCall:
"""Parse one flattened transcript into roles and Q&A exchanges.
Never raises. Falls back to a Q&A-free ParsedCall whose prepared/management
text is the full transcript when structure cannot be recovered.
"""
segments = _split_segments(text or "")
if len(segments) < _MIN_SEGMENTS:
full = (text or "").strip()
return ParsedCall(
period=period, prepared_text=full, management_text=full,
qa=[], n_segments=len(segments),
)
# Locate the prepared-remarks β†’ Q&A boundary. Operator intros often
# announce "there will be a question-and-answer session" before anyone
# has spoken β€” only accept a boundary once a non-operator segment exists.
qa_start: int | None = None
for i, seg in enumerate(segments):
if not _QA_BOUNDARY_RE.search(seg.text):
continue
has_speech_before = any(
s.speaker.lower() != "operator" for s in segments[:i]
)
if has_speech_before:
qa_start = i
break
# Structural fallback when no announcement phrase matched.
#
# Transcript providers word the hand-off differently, and some drop the
# announcement entirely β€” Apple's calls are a standing example, where the
# host moves straight to the first analyst. The section is still plainly
# there in the structure: an operator turn, then a voice that has not
# spoken yet. That new voice is the first analyst, so the operator turn
# before it is the boundary. Requiring prior speech keeps an operator's
# opening housekeeping from being mistaken for the Q&A.
if qa_start is None:
heard: set[str] = set()
for i, seg in enumerate(segments):
if seg.speaker.lower() != "operator":
heard.add(_last_name(seg.speaker))
continue
if not heard:
continue
following = next(
(s for s in segments[i + 1:] if s.speaker.lower() != "operator"),
None,
)
if following is not None and _last_name(following.speaker) not in heard:
qa_start = i
break
# Last resort: a call with no operator at all.
#
# Some issuers run their own Q&A β€” an investor-relations host reads the
# questions and there is never an "Operator" turn to key off. Tesla's calls
# are the standing example. What still holds is the shape: prepared remarks
# from a handful of known voices, then a voice that has not been heard yet
# asking something. The question mark is what separates that from a second
# executive joining the prepared remarks.
if qa_start is None:
heard = set()
for i, seg in enumerate(segments):
name = _last_name(seg.speaker)
# Two prior voices β‰ˆ host plus at least one executive, i.e. the
# prepared section is genuinely under way.
if len(heard) >= 2 and name not in heard and "?" in seg.text:
qa_start = i
break
heard.add(name)
# Analyst roster from Operator hand-offs (anywhere in the call).
roster: set[str] = set()
for seg in segments:
if seg.speaker.lower() == "operator":
seg.role = "operator"
for m in _ANALYST_INTRO_RE.finditer(seg.text):
roster.add(_last_name(m.group(1)))
# Management = non-operator speakers heard before the Q&A boundary.
pre_qa_end = qa_start if qa_start is not None else len(segments)
management: set[str] = {
_last_name(seg.speaker)
for seg in segments[:pre_qa_end]
if seg.role not in ("operator", "analyst") and seg.speaker
}
# Assign roles.
prev_role = ""
for i, seg in enumerate(segments):
if seg.role in ("operator", "analyst"):
prev_role = seg.role
continue
key = _last_name(seg.speaker)
if key in management:
seg.role = "management"
elif key in roster:
seg.role = "analyst"
elif qa_start is not None and i > qa_start:
# Unknown speaker in Q&A: question-shaped or operator hand-off β†’ analyst.
if seg.text.rstrip().endswith("?") or prev_role == "operator":
seg.role = "analyst"
else:
seg.role = "management"
else:
seg.role = "management"
prev_role = seg.role
prepared_parts = [
seg.text for seg in segments[:pre_qa_end] if seg.role == "management"
]
management_parts = [seg.text for seg in segments if seg.role == "management"]
# Pair analyst turns with the management turns that follow them.
qa: list[QAExchange] = []
if qa_start is not None:
current: QAExchange | None = None
for seg in segments[qa_start:]:
if seg.role == "analyst":
if current is None or current.answer:
if current is not None and current.answer:
qa.append(current)
current = QAExchange(analyst=seg.speaker, question=seg.text, answer="")
else:
current.question += " " + seg.text
elif seg.role == "management" and current is not None:
current.answer = (current.answer + " " + seg.text).strip()
elif seg.role == "operator" and current is not None and current.answer:
qa.append(current)
current = None
if current is not None and current.answer:
qa.append(current)
return ParsedCall(
period=period,
prepared_text="\n".join(prepared_parts).strip(),
management_text="\n".join(management_parts).strip(),
qa=qa,
n_segments=len(segments),
)