From 313f10eafb80f877b2e42dbd8bb769a828a3c9f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ana=C3=AFs=20Dubois?= Date: Mon, 10 Nov 2025 16:07:46 +0100 Subject: [PATCH 1/4] Setting display constants --- src/interactive_ui.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/interactive_ui.py b/src/interactive_ui.py index d7b3450..9ad15d5 100644 --- a/src/interactive_ui.py +++ b/src/interactive_ui.py @@ -21,6 +21,14 @@ from search_conversations import ConversationSearcher +# Constants +SESSION_DISPLAY_LIMIT = 20 +PROJECT_LENGTH = 30 +MAJOR_SEPARATOR_WIDTH = 60 +PROGRESS_BAR_WIDTH = 40 +RECENT_SESSIONS_LIMIT = 5 + + class InteractiveUI: """Interactive terminal UI for easier conversation extraction""" @@ -120,18 +128,18 @@ def show_sessions_menu(self) -> List[int]: print(f"\nāœ… Found {len(self.sessions)} conversations!\n") # Display sessions - for i, session_path in enumerate(self.sessions[:20], 1): # Show max 20 + for i, session_path in enumerate(self.sessions[:SESSION_DISPLAY_LIMIT], 1): # Show max SESSION_DISPLAY_LIMIT project = session_path.parent.name modified = datetime.fromtimestamp(session_path.stat().st_mtime) size_kb = session_path.stat().st_size / 1024 date_str = modified.strftime("%Y-%m-%d %H:%M") - print(f" {i:2d}. [{date_str}] {project[:30]:<30} ({size_kb:.1f} KB)") + print(f" {i:2d}. [{date_str}] {project[:PROJECT_LENGTH]:<{PROJECT_LENGTH}} ({size_kb:.1f} KB)") - if len(self.sessions) > 20: - print(f"\n ... and {len(self.sessions) - 20} more conversations") + if len(self.sessions) > SESSION_DISPLAY_LIMIT: + print(f"\n ... and {len(self.sessions) - SESSION_DISPLAY_LIMIT} more conversations") - print("\n" + "=" * 60) + print("\n" + "=" * MAJOR_SEPARATOR_WIDTH) print("\nOptions:") print(" A. Extract ALL conversations") print(" R. Extract 5 most RECENT") @@ -147,7 +155,7 @@ def show_sessions_menu(self) -> List[int]: elif choice == "A": return list(range(len(self.sessions))) elif choice == "R": - return list(range(min(5, len(self.sessions)))) + return list(range(min(RECENT_SESSIONS_LIMIT, len(self.sessions)))) elif choice == "S": selection = input("Enter conversation numbers (e.g., 1,3,5): ").strip() try: @@ -169,7 +177,7 @@ def show_sessions_menu(self) -> List[int]: def show_progress(self, current: int, total: int, message: str = ""): """Display a simple progress bar""" - bar_width = 40 + bar_width = PROGRESS_BAR_WIDTH progress = current / total if total > 0 else 0 filled = int(bar_width * progress) bar = "ā–ˆ" * filled + "ā–‘" * (bar_width - filled) From 9b008eb344891b4098b1633f5691611df443ea14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ana=C3=AFs=20Dubois?= Date: Mon, 10 Nov 2025 16:30:18 +0100 Subject: [PATCH 2/4] Setting search constants --- src/search_conversations.py | 85 +++++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 28 deletions(-) diff --git a/src/search_conversations.py b/src/search_conversations.py index b78f51a..1d4d3f6 100644 --- a/src/search_conversations.py +++ b/src/search_conversations.py @@ -30,6 +30,35 @@ print(" pip install spacy && python -m spacy download en_core_web_sm") +# Constants +MAJOR_SEPARATOR_WIDTH = 60 +DEFAULT_MAX_RESULTS = 20 +MAX_CONTENT_LENGTH = 200 +DEFAULT_CONTEXT_SIZE = 150 +DEFAULT_MAX_TOPICS = 5 +CONTENT_LENGTH_PROCESSING = 10 +MAX_NOUN_PHRASES_LENGTH = 3 +INDENT_NUMBER = 2 +MIN_TOPIC_PHRASE_COUNT = 1 + +# Other constants for relevance or similarity +RELEVANCE_THRESHOLD = 0.1 +MIN_RELEVANCE_COMPARED = 1.0 +MATCH_FACTOR_FOR_RELEVANCE = 0.2 +MATCH_BONUS = 0.5 +MIN_RELEVANCE_MULTIPLE_OCCURRENCES = 0.3 +MATCH_FACTOR_MULTIPLE_OCCURRENCES = 0.1 +MIN_RELEVANCE_OVERLAP = 0.4 +MATCH_FACTOR_OVERLAP = 0.4 +PROXIMITY_BONUS = 0.1 +MIN_TOKENS_FOR_PROXIMITY_BONUS = 1 +PROXIMITY_WINDOW_MULTIPLIER = 2 +ADDITIONAL_BOOST_EXACT_MATCH = 0.3 +MATCH_CONTEXT_STEP = 100 +SEMANTIC_SIMILARITY_THRESHOLD = 0.3 +CONTEXT_FALLBACK_MULTIPLIER = 2 + + @dataclass class SearchResult: """Represents a search result with context""" @@ -46,11 +75,11 @@ class SearchResult: def __str__(self) -> str: """User-friendly string representation""" return ( - f"\n{'=' * 60}\n" + f"\n{'=' * MAJOR_SEPARATOR_WIDTH}\n" f"File: {self.file_path.name}\n" f"Speaker: {self.speaker.title()}\n" f"Relevance: {self.relevance_score:.0%}\n" - f"{'=' * 60}\n" + f"{'=' * MAJOR_SEPARATOR_WIDTH}\n" f"{self.context}\n" ) @@ -133,7 +162,7 @@ def search( date_from: Optional[datetime] = None, date_to: Optional[datetime] = None, speaker_filter: Optional[str] = None, - max_results: int = 20, + max_results: int = DEFAULT_MAX_RESULTS, case_sensitive: bool = False, ) -> List[SearchResult]: """ @@ -272,7 +301,7 @@ def _search_smart( content, query, query_tokens, case_sensitive ) - if relevance > 0.1: # Threshold for inclusion + if relevance > RELEVANCE_THRESHOLD: # Threshold for inclusion # Extract context context = self._extract_context( content, query, case_sensitive @@ -292,7 +321,7 @@ def _search_smart( result = SearchResult( file_path=jsonl_file, conversation_id=conversation_id, - matched_content=content[:200], + matched_content=content[:MAX_CONTENT_LENGTH], context=context, speaker=speaker, timestamp=timestamp, @@ -349,7 +378,7 @@ def _search_exact( if search_query in search_content: # Calculate relevance based on match frequency match_count = search_content.count(search_query) - relevance = min(1.0, match_count * 0.2) + relevance = min(MIN_RELEVANCE_COMPARED, match_count * MATCH_FACTOR_FOR_RELEVANCE) context = self._extract_context( content, query, case_sensitive @@ -369,7 +398,7 @@ def _search_exact( result = SearchResult( file_path=jsonl_file, conversation_id=conversation_id, - matched_content=content[:200], + matched_content=content[:MAX_CONTENT_LENGTH], context=context, speaker=speaker, timestamp=timestamp, @@ -429,12 +458,12 @@ def _search_regex( if matches: # Calculate relevance based on match quality - relevance = min(1.0, len(matches) * 0.2) + relevance = min(MIN_RELEVANCE_COMPARED, len(matches) * MATCH_FACTOR_FOR_RELEVANCE) # Get context around first match first_match = matches[0] - start = max(0, first_match.start() - 100) - end = min(len(content), first_match.end() + 100) + start = max(0, first_match.start() - MATCH_CONTEXT_STEP) + end = min(len(content), first_match.end() + MATCH_CONTEXT_STEP) context = "..." + content[start:end] + "..." # Parse timestamp if present @@ -516,7 +545,7 @@ def _search_semantic( query_doc, query_tokens, content_doc ) - if similarity > 0.3: # Threshold for semantic matches + if similarity > SEMANTIC_SIMILARITY_THRESHOLD: # Threshold for semantic matches context = self._extract_context(content, query, False) # Parse timestamp if present @@ -533,7 +562,7 @@ def _search_semantic( result = SearchResult( file_path=jsonl_file, conversation_id=conversation_id, - matched_content=content[:200], + matched_content=content[:MAX_CONTENT_LENGTH], context=context, speaker=speaker, timestamp=timestamp, @@ -603,28 +632,28 @@ def _calculate_relevance( # Exact match bonus if query_lower in content_lower: - relevance += 0.5 + relevance += MATCH_BONUS # Additional bonus for multiple occurrences count = content_lower.count(query_lower) - relevance += min(0.3, count * 0.1) + relevance += min(MIN_RELEVANCE_MULTIPLE_OCCURRENCES, count * MATCH_FACTOR_MULTIPLE_OCCURRENCES) # Token overlap content_tokens = set(content_lower.split()) - self.stop_words if query_tokens and content_tokens: overlap = len(query_tokens & content_tokens) - relevance += min(0.4, overlap / len(query_tokens) * 0.4) + relevance += min(MIN_RELEVANCE_OVERLAP, overlap / len(query_tokens) * MATCH_FACTOR_OVERLAP) # Proximity bonus - are query terms near each other? - if len(query_tokens) > 1: + if len(query_tokens) > MIN_TOKENS_FOR_PROXIMITY_BONUS: # Check if all query tokens appear within a window words = content_lower.split() for i in range(len(words) - len(query_tokens)): - window = set(words[i : i + len(query_tokens) * 2]) + window = set(words[i : i + len(query_tokens) * PROXIMITY_WINDOW_MULTIPLIER]) if query_tokens.issubset(window): - relevance += 0.1 + relevance += PROXIMITY_BONUS break - return min(1.0, relevance) + return min(MIN_RELEVANCE_COMPARED, relevance) def _calculate_semantic_similarity( self, query_doc, query_tokens, content_doc @@ -654,12 +683,12 @@ def _calculate_semantic_similarity( # Boost for exact phrase matches if query_doc.text.lower() in content_doc.text.lower(): - base_similarity = min(1.0, base_similarity + 0.3) + base_similarity = min(MIN_RELEVANCE_COMPARED, base_similarity + ADDITIONAL_BOOST_EXACT_MATCH) return base_similarity def _extract_context( - self, content: str, query: str, case_sensitive: bool, context_size: int = 150 + self, content: str, query: str, case_sensitive: bool, context_size: int = DEFAULT_CONTEXT_SIZE ) -> str: """Extract context around the match for display.""" if not case_sensitive: @@ -670,8 +699,8 @@ def _extract_context( if pos == -1: # No exact match, return beginning of content - return content[: context_size * 2] + ( - "..." if len(content) > context_size * 2 else "" + return content[: context_size * CONTEXT_FALLBACK_MULTIPLIER] + ( + "..." if len(content) > context_size * CONTEXT_FALLBACK_MULTIPLIER else "" ) # Extract context around match @@ -707,7 +736,7 @@ def search_by_date_range( return self._filter_files_by_date(jsonl_files, date_from, date_to) def get_conversation_topics( - self, jsonl_file: Path, max_topics: int = 5 + self, jsonl_file: Path, max_topics: int = DEFAULT_MAX_TOPICS ) -> List[str]: """ Extract main topics from a conversation. @@ -736,13 +765,13 @@ def get_conversation_topics( return [] # Process with spaCy - full_text = " ".join(all_content[:10]) # Limit to avoid processing too much + full_text = " ".join(all_content[:CONTENT_LENGTH_PROCESSING]) # Limit to avoid processing too much doc = self.nlp(full_text) # Extract noun phrases as topics noun_phrases = [] for chunk in doc.noun_chunks: - if len(chunk.text.split()) <= 3: # Reasonable length + if len(chunk.text.split()) <= MAX_NOUN_PHRASES_LENGTH: # Reasonable length noun_phrases.append(chunk.text.lower()) # Count frequency @@ -754,7 +783,7 @@ def get_conversation_topics( sorted_phrases = sorted(phrase_counts.items(), key=lambda x: x[1], reverse=True) # Return top topics - return [phrase for phrase, count in sorted_phrases[:max_topics] if count > 1] + return [phrase for phrase, count in sorted_phrases[:max_topics] if count > MIN_TOPIC_PHRASE_COUNT] def create_search_index(search_dir: Path, output_file: Path) -> None: @@ -810,7 +839,7 @@ def create_search_index(search_dir: Path, output_file: Path) -> None: # Save index with open(output_file, "w") as f: - json.dump(index, f, indent=2) + json.dump(index, f, indent=INDENT_NUMBER) print(f"Created search index with {len(index['conversations'])} conversations") From 8d1ae64d1e46e26488893ca9d21a18bbab084cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ana=C3=AFs=20Dubois?= Date: Mon, 10 Nov 2025 17:51:52 +0100 Subject: [PATCH 3/4] Setting extraction constants --- src/extract_claude_logs.py | 80 +++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/src/extract_claude_logs.py b/src/extract_claude_logs.py index 8430825..a65820a 100644 --- a/src/extract_claude_logs.py +++ b/src/extract_claude_logs.py @@ -14,6 +14,22 @@ from typing import Dict, List, Optional, Tuple +# Constants +INDENT_NUMBER = 2 +MAJOR_SEPARATOR_WIDTH = 60 +MINOR_SEPARATOR_WIDTH = 40 +SESSION_ID_MAX_LENGTH = 8 +LINES_SHOWN_MESSAGE = 8 +LINES_PER_PAGE_MESSAGE = 30 +MAX_LINES_PER_MESSAGE_DISPLAY = 50 +MAX_LINE_LENGTH_DISPLAY = 100 +MIN_PREVIEW_TEXT_LENGTH = 3 +PREVIEW_TEXT_TRUNCATE_LENGTH = 100 +PREVIEW_ERROR_TRUNCATE_LENGTH = 30 +LIST_SEPARATOR_WIDTH = 80 +SEARCH_MAX_RESULTS_DEFAULT = 30 + + class ClaudeConversationExtractor: """Extract and convert Claude Code conversations from JSONL to markdown.""" @@ -122,7 +138,7 @@ def extract_conversation(self, jsonl_path: Path, detailed: bool = False) -> List conversation.append( { "role": "tool_use", - "content": f"šŸ”§ Tool: {tool_name}\nInput: {json.dumps(tool_input, indent=2)}", + "content": f"šŸ”§ Tool: {tool_name}\nInput: {json.dumps(tool_input, indent=INDENT_NUMBER)}", "timestamp": entry.get("timestamp", ""), } ) @@ -183,7 +199,7 @@ def _extract_text_content(self, content, detailed: bool = False) -> str: tool_name = item.get("name", "unknown") tool_input = item.get("input", {}) text_parts.append(f"\nšŸ”§ Using tool: {tool_name}") - text_parts.append(f"Input: {json.dumps(tool_input, indent=2)}\n") + text_parts.append(f"Input: {json.dumps(tool_input, indent=INDENT_NUMBER)}\n") return "\n".join(text_parts) else: return str(content) @@ -208,9 +224,9 @@ def display_conversation(self, jsonl_path: Path, detailed: bool = False) -> None # Clear screen and show header print("\033[2J\033[H", end="") # Clear screen - print("=" * 60) + print("=" * MAJOR_SEPARATOR_WIDTH) print(f"šŸ“„ Viewing: {jsonl_path.parent.name}") - print(f"Session: {session_id[:8]}...") + print(f"Session: {session_id[:SESSION_ID_MAX_LENGTH]}...") # Get timestamp from first message first_timestamp = messages[0].get("timestamp", "") @@ -221,12 +237,12 @@ def display_conversation(self, jsonl_path: Path, detailed: bool = False) -> None except Exception: pass - print("=" * 60) + print("=" * MAJOR_SEPARATOR_WIDTH) print("↑↓ to scroll • Q to quit • Enter to continue\n") # Display messages with pagination - lines_shown = 8 # Header lines - lines_per_page = 30 + lines_shown = LINES_SHOWN_MESSAGE # Header lines + lines_per_page = LINES_PER_PAGE_MESSAGE for i, msg in enumerate(messages): role = msg["role"] @@ -234,13 +250,13 @@ def display_conversation(self, jsonl_path: Path, detailed: bool = False) -> None # Format role display if role == "user" or role == "human": - print(f"\n{'─' * 40}") + print(f"\n{'─' * MINOR_SEPARATOR_WIDTH}") print(f"šŸ‘¤ HUMAN:") - print(f"{'─' * 40}") + print(f"{'─' * MINOR_SEPARATOR_WIDTH}") elif role == "assistant": - print(f"\n{'─' * 40}") + print(f"\n{'─' * MINOR_SEPARATOR_WIDTH}") print(f"šŸ¤– CLAUDE:") - print(f"{'─' * 40}") + print(f"{'─' * MINOR_SEPARATOR_WIDTH}") elif role == "tool_use": print(f"\nšŸ”§ TOOL USE:") elif role == "tool_result": @@ -252,12 +268,12 @@ def display_conversation(self, jsonl_path: Path, detailed: bool = False) -> None # Display content (limit very long messages) lines = content.split('\n') - max_lines_per_msg = 50 + max_lines_per_msg = MAX_LINES_PER_MESSAGE_DISPLAY for line_idx, line in enumerate(lines[:max_lines_per_msg]): # Wrap very long lines - if len(line) > 100: - line = line[:97] + "..." + if len(line) > MAX_LINE_LENGTH_DISPLAY: + line = line[:(MAX_LINE_LENGTH_DISPLAY - 3)] + "..." print(line) lines_shown += 1 @@ -275,9 +291,9 @@ def display_conversation(self, jsonl_path: Path, detailed: bool = False) -> None print(f"... [{len(lines) - max_lines_per_msg} more lines truncated]") lines_shown += 1 - print("\n" + "=" * 60) + print("\n" + "=" * MAJOR_SEPARATOR_WIDTH) print("šŸ“„ End of conversation") - print("=" * 60) + print("=" * MAJOR_SEPARATOR_WIDTH) input("\nPress Enter to continue...") except Exception as e: @@ -306,7 +322,7 @@ def save_as_markdown( date_str = datetime.now().strftime("%Y-%m-%d") time_str = "" - filename = f"claude-conversation-{date_str}-{session_id[:8]}.md" + filename = f"claude-conversation-{date_str}-{session_id[:SESSION_ID_MAX_LENGTH]}.md" output_path = self.output_dir / filename with open(output_path, "w", encoding="utf-8") as f: @@ -361,7 +377,7 @@ def save_as_json( else: date_str = datetime.now().strftime("%Y-%m-%d") - filename = f"claude-conversation-{date_str}-{session_id[:8]}.json" + filename = f"claude-conversation-{date_str}-{session_id[:SESSION_ID_MAX_LENGTH]}.json" output_path = self.output_dir / filename # Create JSON structure @@ -373,7 +389,7 @@ def save_as_json( } with open(output_path, "w", encoding="utf-8") as f: - json.dump(output, f, indent=2, ensure_ascii=False) + json.dump(output, f, indent=INDENT_NUMBER, ensure_ascii=False) return output_path @@ -398,7 +414,7 @@ def save_as_html( date_str = datetime.now().strftime("%Y-%m-%d") time_str = "" - filename = f"claude-conversation-{date_str}-{session_id[:8]}.html" + filename = f"claude-conversation-{date_str}-{session_id[:SESSION_ID_MAX_LENGTH]}.html" output_path = self.output_dir / filename # HTML template with modern styling @@ -407,7 +423,7 @@ def save_as_html( - Claude Conversation - {session_id[:8]} + Claude Conversation - {session_id[:SESSION_ID_MAX_LENGTH]}