flowify-api / topic_segmenter.py
Yuvvan Talreja
Fixes
cf104cb
Raw
History Blame Contribute Delete
26.6 kB
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from nltk.tokenize import sent_tokenize, word_tokenize
from nltk.stem import WordNetLemmatizer
from keybert import KeyBERT
import re
from collections import defaultdict
from sklearn.cluster import AgglomerativeClustering
class TopicSegmenter:
def __init__(self, window_size=3, similarity_threshold=0.2, context_size=2, min_segment_size=2, topic_similarity_threshold=0.3,
max_topics=8, hierarchical_threshold=0.6, model_name="all-MiniLM-L6-v2"):
self.window_size = window_size
self.similarity_threshold = similarity_threshold
self.context_size = context_size
self.min_segment_size = min_segment_size
self.topic_similarity_threshold = topic_similarity_threshold
self.max_topics = max_topics # Maximum number of main topics
self.hierarchical_threshold = hierarchical_threshold # Threshold for hierarchical clustering
try:
# Explicitly set the model to a lightweight and compatible one
import os
os.environ["TOKENIZERS_PARALLELISM"] = "false" # Prevent parallelism warning
# Try using sentence-transformers directly first
try:
from sentence_transformers import SentenceTransformer
embedding_model = SentenceTransformer(model_name)
self.keyword_extractor = KeyBERT(model=embedding_model)
except (ImportError, ValueError, RuntimeError) as e:
# Fallback to default model with lower-level settings
self.keyword_extractor = KeyBERT(model='distilbert-base-nli-mean-tokens')
except Exception as e:
import logging
logging.error(f"Error initializing KeyBERT model: {str(e)}")
# Last resort fallback to simple keyword extraction without a model
self.keyword_extractor = None
self.vectorizer = TfidfVectorizer(
stop_words='english',
min_df=1,
max_df=0.95,
ngram_range=(1, 2)
)
self.segment_vectorizer = TfidfVectorizer(
stop_words='english',
min_df=1,
max_df=1.0,
ngram_range=(1, 2)
)
self.lemmatizer = WordNetLemmatizer()
self.topic_history = []
self.topic_hierarchy = {} # Store parent-child relationships
self.fitted_vectorizer = None
def preprocess_text(self, text):
# First, split the text by timestamps
timestamp_chunks = re.findall(r'\[(\d+)\](.*?)(?=\[\d+\]|$)', text, re.DOTALL)
all_sentences = []
original_sentences = []
timestamp_mapping = [] # To track which timestamp each sentence belongs to
for timestamp, chunk_text in timestamp_chunks:
# Split each chunk into sentences
chunk_sentences = sent_tokenize(chunk_text.strip())
for sentence in chunk_sentences:
if sentence.strip():
# Keep track of the original sentence
original_sentences.append(sentence.strip())
# Clean and process the sentence for analysis
clean_sentence = re.sub(r'^[^:]+:', '', sentence).strip()
clean_sentence = re.sub(r'[^\w\s]', '', clean_sentence.lower())
words = word_tokenize(clean_sentence)
lemmatized = [self.lemmatizer.lemmatize(word) for word in words]
processed_sentence = ' '.join(lemmatized)
all_sentences.append(processed_sentence)
timestamp_mapping.append(int(timestamp))
return all_sentences, original_sentences, timestamp_mapping
def get_topic_fingerprint(self, segment_text):
cleaned_text = ' '.join([re.sub(r'^[^:]+:', '', sent).strip() for sent in segment_text])
if self.fitted_vectorizer is None:
self.fitted_vectorizer = self.segment_vectorizer.fit([cleaned_text])
tfidf_matrix = self.fitted_vectorizer.transform([cleaned_text])
else:
tfidf_matrix = self.fitted_vectorizer.transform([cleaned_text])
return tfidf_matrix.toarray()[0]
def compare_with_previous_topics(self, current_segment):
if not self.topic_history:
return None, 0.0
current_fingerprint = self.get_topic_fingerprint(current_segment)
max_similarity = 0.0
best_match_idx = None
for idx, (topic_fingerprint, _, _, _, _) in enumerate(self.topic_history):
if len(topic_fingerprint) != len(current_fingerprint):
continue
similarity = cosine_similarity([topic_fingerprint], [current_fingerprint])[0][0]
if similarity > max_similarity:
max_similarity = similarity
best_match_idx = idx
return best_match_idx, max_similarity
def extract_keywords(self, sentences, top_n=3):
if isinstance(sentences, list):
text = ' '.join(sentences)
else:
text = sentences
# If KeyBERT was not properly initialized, use TF-IDF as a fallback
if self.keyword_extractor is None:
try:
# Use scikit-learn's TF-IDF for keyword extraction as fallback
from sklearn.feature_extraction.text import TfidfVectorizer
# Create a temporary vectorizer for this text only
temp_vectorizer = TfidfVectorizer(
max_features=50,
stop_words='english',
ngram_range=(1, 2)
)
# Fit and transform on this specific text
tfidf_matrix = temp_vectorizer.fit_transform([text])
feature_names = temp_vectorizer.get_feature_names_out()
# Get top keywords based on TF-IDF scores
tfidf_scores = tfidf_matrix.toarray()[0]
scored_tokens = [(feature_names[i], tfidf_scores[i]) for i in range(len(feature_names))]
sorted_tokens = sorted(scored_tokens, key=lambda x: x[1], reverse=True)
return [token for token, score in sorted_tokens[:top_n]]
except Exception as e:
import logging
logging.error(f"Error in TF-IDF fallback keyword extraction: {str(e)}")
# If everything fails, just return some generic keywords
return ["topic", "section", "content"]
try:
keywords = self.keyword_extractor.extract_keywords(
text,
keyphrase_ngram_range=(1, 2), # Allow for 1-2 word keyphrases
stop_words='english',
top_n=top_n
)
sorted_keywords = sorted(keywords, key=lambda x: x[1], reverse=True)
return [kw[0] for kw in sorted_keywords[:top_n]]
except Exception as e:
import logging
logging.error(f"Error in KeyBERT keyword extraction: {str(e)}")
# Fall back to TF-IDF method
return self.extract_keywords(text, top_n)
def detect_speaker_changes(self, text):
"""
Detect points in the transcript where speakers change.
Returns a list of indices where speaker changes occur.
"""
speaker_pattern = re.compile(r'(?:^|\s)(?:I\'m|And I\'m|Hey I\'m|I am)\s+([A-Z][a-z]+)', re.MULTILINE)
matches = list(speaker_pattern.finditer(text))
speaker_boundaries = []
for match in matches:
# Find the sentence containing this speaker introduction
sentences = sent_tokenize(text[:match.end()])
if sentences:
speaker_boundaries.append(len(sentences) - 1)
return speaker_boundaries
def find_closest_timestamp(self, sentence, transcript):
"""
Find the timestamp after the most recent timestamp before the sentence.
"""
chunks = re.findall(r'\[(\d+)\](.*?)(?=\[\d+\]|$)', transcript, re.DOTALL)
sentence = sentence.strip().lower()
previous_timestamp = 0
current_timestamp = 0
for i, (timestamp, text) in enumerate(chunks):
current_timestamp = int(timestamp)
text = text.strip().lower()
if sentence in text or text in sentence:
if i > 0 and i < len(chunks):
return current_timestamp
return previous_timestamp
previous_timestamp = current_timestamp
return current_timestamp
def calculate_topic_importance(self, segment, total_segments):
"""
Calculate importance score for a topic based on:
1. Length of the segment
2. Position in the transcript
3. Keyword significance
"""
# Length factor - longer segments likely more important
length_score = min(1.0, len(segment) / 10) # Cap at 1.0 for segments of 10+ sentences
# Position factor - beginning and end often contain important information
position_in_doc = self.topic_history.index(segment) / total_segments
position_score = 1.0 - min(abs(position_in_doc - 0.0), abs(position_in_doc - 1.0))
# Keyword significance - look for key terms indicating importance
importance_indicators = ['important', 'key', 'main', 'primary', 'critical', 'essential',
'significant', 'revolutionary', 'breakthrough', 'innovative']
text = ' '.join(segment)
significance_score = 0.0
for indicator in importance_indicators:
if indicator in text.lower():
significance_score += 0.2 # Add 0.2 for each indicator found
significance_score = min(1.0, significance_score) # Cap at 1.0
# Combined score, weighted
combined_score = (0.4 * length_score) + (0.3 * position_score) + (0.3 * significance_score)
return combined_score
def cluster_topics(self, segments):
"""
Cluster similar topics together to form a hierarchy
"""
if len(segments) <= 1:
return segments, []
# Extract features for each segment
feature_vectors = []
for segment_idx, segment in enumerate(segments):
feature_vectors.append(self.get_topic_fingerprint(segment))
feature_matrix = np.array(feature_vectors)
# Use hierarchical clustering with compatible parameters
num_clusters = min(self.max_topics, len(segments))
# Try different parameter combinations based on scikit-learn version compatibility
try:
# First attempt with basic parameters
clustering = AgglomerativeClustering(
n_clusters=num_clusters
)
labels = clustering.fit_predict(feature_matrix)
except Exception as e:
print(f"Clustering with default parameters failed: {str(e)}")
# Fallback to manual clustering based on similarity
labels = self.manual_clustering(feature_matrix, num_clusters)
# Group segments by cluster
clusters = defaultdict(list)
for segment_idx, cluster_id in enumerate(labels):
clusters[cluster_id].append(segment_idx)
# Create parent-child relationships
parent_child_map = {}
for cluster_id, segment_indices in clusters.items():
# If cluster has multiple segments, create a parent topic
if len(segment_indices) > 1:
# Combine segments to create a parent topic
all_content = []
for idx in segment_indices:
all_content.extend(segments[idx])
# Extract the most representative keywords
keywords = self.extract_keywords(all_content, top_n=3)
parent_topic = f"{', '.join(keywords)}"
# Add parent-child relationships
for idx in segment_indices:
parent_child_map[idx] = parent_topic
return labels, parent_child_map
def manual_clustering(self, feature_matrix, num_clusters):
"""
Implement a simple similarity-based clustering as fallback
"""
n_samples = feature_matrix.shape[0]
# If we have very few samples, each gets its own cluster
if n_samples <= num_clusters:
return np.arange(n_samples)
# Calculate similarity matrix
similarity = cosine_similarity(feature_matrix)
labels = np.arange(n_samples)
current_num_clusters = n_samples
while current_num_clusters > num_clusters:
max_similarity = -1
merge_i, merge_j = 0, 0
for i in range(n_samples):
for j in range(i+1, n_samples):
if labels[i] == labels[j]:
continue
if similarity[i, j] > max_similarity:
max_similarity = similarity[i, j]
merge_i, merge_j = i, j
old_label = labels[merge_j]
new_label = labels[merge_i]
for i in range(n_samples):
if labels[i] == old_label:
labels[i] = new_label
unique_labels = np.unique(labels)
mapping = {old: new for new, old in enumerate(unique_labels)}
labels = np.array([mapping[label] for label in labels])
current_num_clusters -= 1
return labels
def segment_transcript(self, text):
cleaned_sentences, original_sentences, timestamp_mapping = self.preprocess_text(text)
if len(cleaned_sentences) == 0:
return [], [], [], {}
similarity_matrix = self.calculate_similarity_matrix(cleaned_sentences)
content_boundaries = self.detect_topic_boundaries(similarity_matrix)
speaker_boundaries = self.detect_speaker_changes(text)
timestamp_boundaries = []
for i in range(1, len(timestamp_mapping)):
if timestamp_mapping[i] - timestamp_mapping[i-1] > 5: # If there's a gap of more than 5 seconds
timestamp_boundaries.append(i)
all_boundaries = sorted(set(content_boundaries + speaker_boundaries + timestamp_boundaries))
initial_segments = []
initial_segment_texts = []
current_topic_id = 0
self.topic_history = [] # Reset topic history
start_idx = 0
for boundary in all_boundaries + [len(original_sentences)]:
if boundary - start_idx < self.min_segment_size:
continue
current_segment = original_sentences[start_idx:boundary]
segment_timestamps = timestamp_mapping[start_idx:boundary]
closest_timestamp = segment_timestamps[0] if segment_timestamps else 0
# Extract keywords directly from content without predefined categories
keywords = self.extract_keywords(current_segment, top_n=4)
# Create topic name directly from the top keywords - completely dynamic
topic_name = f"{', '.join(keywords[:2])}"
# Calculate importance score
topic_importance = len(current_segment) / 3 # Simple approach: longer segments are more important
# Create a topic fingerprint
topic_fingerprint = self.get_topic_fingerprint(current_segment)
# Store topic information
self.topic_history.append((topic_fingerprint, topic_name, [current_segment], closest_timestamp, topic_importance))
initial_segments.append(current_segment)
initial_segment_texts.append(topic_name)
start_idx = boundary
# Cluster segments to form hierarchical topics
if len(initial_segments) > 1:
cluster_labels, parent_child_map = self.cluster_topics(initial_segments)
else:
cluster_labels = [0] if initial_segments else []
parent_child_map = {}
# Organize segments into hierarchical structure
final_segments = []
topic_mappings = []
topic_hierarchies = {}
# Group by parent topics
parent_topic_groups = defaultdict(list)
for i, segment in enumerate(initial_segments):
topic_id = i
# Check if this segment belongs to a parent topic
if i in parent_child_map:
parent_topic = parent_child_map[i]
parent_id = len(self.topic_history)
# If this is the first time seeing this parent topic
if parent_topic not in parent_topic_groups:
# Add the parent topic to our history
all_content = []
for j, seg in enumerate(initial_segments):
if j in parent_child_map and parent_child_map[j] == parent_topic:
all_content.extend(seg)
parent_fingerprint = self.get_topic_fingerprint(all_content)
parent_timestamp = self.topic_history[i][3] # Use timestamp of first segment
parent_importance = 1.0 # Parent topics are most important
self.topic_history.append((parent_fingerprint, parent_topic, [all_content], parent_timestamp, parent_importance))
topic_hierarchies[parent_id] = [j for j, seg in enumerate(initial_segments)
if j in parent_child_map and parent_child_map[j] == parent_topic]
# Find the parent ID
for j, (_, name, _, _, _) in enumerate(self.topic_history):
if name == parent_topic:
parent_id = j
break
# Add to parent topic group
parent_topic_groups[parent_topic].append((segment, topic_id))
else:
# This is already a main topic
final_segments.append(segment)
topic_mappings.append(topic_id)
# Add grouped segments under their parent topics
for parent_topic, segments_and_ids in parent_topic_groups.items():
# Find the parent ID
parent_id = None
for i, (_, name, _, _, _) in enumerate(self.topic_history):
if name == parent_topic:
parent_id = i
break
if parent_id is not None:
# Add parent as a segment
parent_content = []
for segment, _ in segments_and_ids:
parent_content.extend(segment)
final_segments.append(parent_content)
topic_mappings.append(parent_id)
# Add children segments
for segment, child_id in segments_and_ids:
final_segments.append(segment)
topic_mappings.append(child_id)
# For topics that don't have many segments, merge them if they're similar
if len(final_segments) > self.max_topics * 2:
merged_segments = []
merged_mappings = []
i = 0
while i < len(final_segments):
current_segment = final_segments[i]
current_mapping = topic_mappings[i]
# If this is a short segment, try to merge with next
if len(current_segment) <= 2 and i + 1 < len(final_segments):
next_segment = final_segments[i+1]
combined = current_segment + next_segment
# Use keywords to see if they're related
current_keywords = self.extract_keywords(current_segment)
next_keywords = self.extract_keywords(next_segment)
# Check for keyword overlap
overlap = any(kw in next_keywords for kw in current_keywords)
if overlap:
# Merge the segments
merged_segments.append(combined)
merged_mappings.append(current_mapping) # Keep the first mapping
i += 2 # Skip both segments
continue
# If no merge, keep as is
merged_segments.append(current_segment)
merged_mappings.append(current_mapping)
i += 1
final_segments = merged_segments
topic_mappings = merged_mappings
return final_segments, topic_mappings, self.topic_history, topic_hierarchies
def calculate_similarity_matrix(self, sentences):
if not sentences:
return np.array([[]])
tfidf_matrix = self.vectorizer.fit_transform(sentences)
return cosine_similarity(tfidf_matrix)
def detect_topic_boundaries(self, similarity_matrix):
boundaries = []
n_sentences = len(similarity_matrix)
if n_sentences <= 2 * self.window_size:
return []
for i in range(self.window_size, n_sentences - self.window_size):
prev_window = similarity_matrix[i-self.window_size:i, i-self.window_size:i]
prev_similarity = np.mean(prev_window)
next_window = similarity_matrix[i:i+self.window_size, i:i+self.window_size]
next_similarity = np.mean(next_window)
cross_window = similarity_matrix[i-self.window_size:i, i:i+self.window_size]
cross_similarity = np.mean(cross_window)
# Stricter conditions for detecting boundaries
if (cross_similarity < self.similarity_threshold and
cross_similarity < prev_similarity * 0.7 and # Increased constraint
cross_similarity < next_similarity * 0.7 and # Increased constraint
(len(boundaries) == 0 or i - boundaries[-1] >= self.min_segment_size)):
boundaries.append(i)
return boundaries
if __name__ == "__main__":
transcript = """
[0] Hey I'm Stanley, I'm a Stanford CS major. I did fun at Engineering at Facebook. [4] Hey I'm Andy, I'm also a Stanford CS major and I did platform engineering at Facebook. [8] I'm Evan, I was on the founding team of Vivo, the music video service. [12] And I'm Tony, I was a product editor at Square. [15] And the four of us came together about six months ago to work on software for small business owners. [20] But we didn't have a need at first so we just went out and talked to all the small business owners we could find. [24] [6] After over a hundred interviews, we came across a really interesting problem with small business restaurants in an area like this. [30] Yeah, so it turns out restaurants in Palo Alto don't deliver even though they really [34] want to, but they can't afford it. [36] But their consumers are craving for it, but the places that the consumers love just can't [41] deliver. [42] And we also found out about these delivery drivers who had a ton of spare time and they [45] all wanted an extra cash during that downtime. [48] Right, so that's when we built an initial product, PaloAltodelivery.com, and how it [53] worked is the customer goes to the website and places it all in order that gets automatically [59] [32] since the restaurant [60] And then we as the dispatchers with some pretty neat routing and badging algorithms were able to send the drivers efficiently to [66] Get the orders to the customers at a really fast time. Yeah, and the four of us actually started off as delivery drivers and [74] Over time we hired more as we grew and in our first month of launch with not much marketing [80] We got over 150 paying customers in the Palo Alto area, which was really awesome [84] And from that we generated over $10,000 in sales
"""
segmenter = TopicSegmenter(
window_size=2, # Smaller window for more granular segmentation
similarity_threshold=0.15, # Lower threshold to detect more subtle changes
context_size=1,
min_segment_size=2, # Smaller minimum size for segments
topic_similarity_threshold=0.25, # Lower threshold to better distinguish topics
max_topics=5, # Target number of main topics
hierarchical_threshold=0.6 # Threshold for hierarchical clustering
)
segments, topic_mappings, topic_history, topic_hierarchies = segmenter.segment_transcript(transcript)
print("Topic Segmentation Analysis:\n")
print(f"Found {len(set(topic_mappings))} distinct topics")
# Print hierarchical topic structure
print("\nTopic Hierarchy:")
for parent_id, child_ids in topic_hierarchies.items():
parent_name = topic_history[parent_id][1]
print(f"Main Topic: {parent_name}")
for child_id in child_ids:
child_name = topic_history[child_id][1]
print(f" - Subtopic: {child_name}")
print("\nDetailed Segments:")
for i, (segment, topic_id) in enumerate(zip(segments, topic_mappings)):
print(f"Segment {i+1} (Part of {topic_history[topic_id][1]}):")
print(f"Closest Timestamp: {topic_history[topic_id][3]} seconds")
print("-" * 50)
print("\n".join(segment[:2]) + "..." if len(segment) > 2 else "\n".join(segment))
print("-" * 50 + "\n")