Alleinzellgaenger commited on
Commit
1e6902f
·
2 Parent(s): 9ded9e1c070e10

Merge branch 'dev' of https://huggingface.co/spaces/Alleinzellgaenger/SokratesAI into dev

Browse files
.claude/sessions/2025-08-03-1200.md CHANGED
@@ -34,4 +34,37 @@ Refine academic paper chunking system to address:
34
  1. **Document Modification**: Original document gets cleaned (academic content removal)
35
  2. **Figure Handling**: Simple paragraph-ending regex can't handle figures interrupting text flow
36
  3. **Position Mapping**: Positions calculated on cleaned text, not original
37
- 4. **Highlighting Injection**: Blockquote injection modifies document structure
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  1. **Document Modification**: Original document gets cleaned (academic content removal)
35
  2. **Figure Handling**: Simple paragraph-ending regex can't handle figures interrupting text flow
36
  3. **Position Mapping**: Positions calculated on cleaned text, not original
37
+ 4. **Highlighting Injection**: Blockquote injection modifies document structure
38
+
39
+ ### Update - 2025-08-03 12:25
40
+
41
+ **Summary**: Successfully switched from broken markdown rendering to PDF viewer approach
42
+
43
+ **Git Changes**:
44
+ - Modified: frontend/src/components/DocumentProcessor.jsx, frontend/src/components/DocumentViewer.jsx
45
+ - Current branch: main (commit: a706099)
46
+
47
+ **Todo Progress**: 3 completed, 1 in progress, 0 pending
48
+ - ✓ Completed: Examine original PDF viewer components in backup files
49
+ - ✓ Completed: Commit current markdown approach as 'failed feature implementation'
50
+ - ✓ Completed: Switch DocumentViewer to use PDF instead of markdown
51
+ - 🔄 In Progress: Test the new PDF viewer implementation
52
+
53
+ **Issues Resolved**:
54
+ - Eliminated markdown rendering that was breaking document layout
55
+ - Removed document modification/cleaning that violated integrity principle
56
+ - Integrated proven PDF viewer from UploadPage.jsx component
57
+
58
+ **Solutions Implemented**:
59
+ - Replaced DocumentViewer.jsx with react-pdf implementation from UploadPage.jsx
60
+ - Added zoom controls, pagination, and smooth scrolling
61
+ - Updated DocumentProcessor.jsx to pass selectedFile instead of highlightedMarkdown
62
+ - Removed unused markdown utilities and highlighting logic
63
+ - Preserved original document completely - no text modification
64
+
65
+ **Code Changes**:
66
+ - DocumentViewer.jsx: Complete rewrite using react-pdf with zoom/navigation controls
67
+ - DocumentProcessor.jsx: Removed highlightedMarkdown logic, updated props
68
+ - Maintained all chunking functionality on right panel while fixing left panel display
69
+
70
+ **Next Steps**: Test PDF viewer functionality and implement visual chunk highlighting overlays if needed
backend/app.py CHANGED
@@ -14,7 +14,8 @@ from pydantic import BaseModel, Field
14
  from typing import Optional, List
15
  from langchain.chat_models import init_chat_model
16
  import anthropic
17
-
 
18
  # Load environment variables
19
  load_dotenv()
20
 
@@ -249,15 +250,24 @@ async def process_ocr_content(file_id: str):
249
  combined_markdown = '\n\n---\n\n'.join(all_page_markdown)
250
  print(f"📋 Combined document: {len(combined_markdown)} chars total")
251
 
252
- # Auto-chunk the entire document once
253
  document_chunks = []
254
  original_markdown = combined_markdown
255
  try:
256
- print(f"🧠 Auto-chunking entire document...")
257
- document_chunks, original_markdown = await auto_chunk_document(combined_markdown, client)
 
 
 
 
 
 
258
  print(f"📊 Document chunks found: {len(document_chunks)}")
259
  for i, chunk in enumerate(document_chunks):
260
- print(f" {i+1}. {chunk.get('topic', 'Unknown')}: {chunk.get('start_phrase', '')[:50]}...")
 
 
 
261
  except Exception as chunk_error:
262
  print(f"⚠️ Document chunking failed: {chunk_error}")
263
  document_chunks = []
@@ -327,9 +337,8 @@ async def get_image_base64(file_id: str, image_id: str):
327
 
328
  class ChunkSchema(BaseModel):
329
  """Schema for document chunks suitable for creating interactive lessons."""
330
- topic: str = Field(description="Brief topic name for the chunk")
331
- start_phrase: str = Field(description="First few words of the chunk (5-15 words)")
332
- end_phrase: str = Field(description="Last few words of the chunk (5-15 words)")
333
 
334
  class ChunkList(BaseModel):
335
  """Container for a list of document chunks."""
@@ -536,16 +545,373 @@ def programmatic_chunk_document(document_markdown):
536
  # The frontend will use the original document for highlighting
537
  return chunks, document_markdown
538
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  async def auto_chunk_document(document_markdown, client=None):
540
- """Auto-chunk a document - now using programmatic approach instead of LLM"""
541
- chunks, original_markdown = programmatic_chunk_document(document_markdown)
542
- return chunks, original_markdown
 
543
 
544
  # Get Fireworks API key
545
  fireworks_api_key = os.environ.get("FIREWORKS_API_KEY")
546
  if not fireworks_api_key:
547
- print("⚠️ No Fireworks API key found, falling back to regular chunking")
548
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
 
550
  try:
551
  # Initialize Fireworks LLM with structured output
@@ -558,98 +924,148 @@ async def auto_chunk_document(document_markdown, client=None):
558
  # Create structured LLM that returns ChunkList object
559
  structured_llm = llm.with_structured_output(ChunkList)
560
 
561
- # Create chunking prompt
562
- prompt = f"""Imagine you are a teacher. You are given a document, and you have to decide how to dissect this document. Your task is to identify chunks of content by providing start and end phrases that can be used to create interactive lessons. Here's the document:
563
- DOCUMENT:
564
- {document_markdown}
565
 
566
- Rules:
567
- 1. Each chunk should contain 2-3 valuable lessons
568
- 2. start_phrase and end_phrase should be 5-15 words long
569
- 3. Focus on educational content (concepts, examples, key points)
570
- 4. More dense content should have more chunks, less dense content fewer chunks
571
- 5. Identify chunks that would make good interactive lessons
 
 
572
 
573
- Return a list of chunks with topic, start_phrase, and end_phrase for each. Importantly, you are passed Markdown text, so output the start and end phrases as Markdown text, and include punctuation. Never stop an end phrase in the middle of a sentence, always include the full sentence or phrase."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
 
575
  # Call Fireworks with structured output
576
- chunk_response = structured_llm.invoke(prompt)
577
- chunks = chunk_response.chunks
 
 
 
 
 
 
578
 
579
- # Find positions using fuzzy matching with detailed debugging
580
- positioned_chunks = []
581
- for i, chunk in enumerate(chunks):
582
- print(f"\n🔍 Processing chunk {i+1}: {chunk.topic}")
583
- print(f" Start phrase: '{chunk.start_phrase}'")
584
- print(f" End phrase: '{chunk.end_phrase}'")
585
 
586
- start_pos = fuzzy_find(document_markdown, chunk.start_phrase)
587
- end_phrase_start = fuzzy_find(document_markdown, chunk.end_phrase, start_pos or 0)
 
 
588
 
589
- print(f" Found start_pos: {start_pos}")
590
- print(f" Found end_phrase_start: {end_phrase_start}")
 
 
 
 
 
 
 
591
 
592
- # Add the length of the end_phrase plus a bit more to include punctuation
593
- if end_phrase_start is not None:
594
- end_pos = end_phrase_start + len(chunk.end_phrase)
595
- # Try to include punctuation that might follow
596
-
597
- # Look ahead for good stopping points, but be more careful about spaces
598
- max_extend = 15 # Don't go crazy far
599
- extended = 0
600
 
601
- while end_pos < len(document_markdown) and extended < max_extend:
602
- char = document_markdown[end_pos]
603
-
604
- # Good stopping points - include punctuation and stop
605
- if char in '.!?':
606
- end_pos += 1 # Include the punctuation
607
- break
608
- elif char in ';:,':
609
- end_pos += 1 # Include and stop
610
- break
611
- # Stop at paragraph breaks
612
- elif end_pos < len(document_markdown) - 1 and document_markdown[end_pos:end_pos+2] == '\n\n':
613
- break
614
- # Stop at LaTeX boundaries
615
- elif char == '$':
616
- break
617
- # Continue through normal chars and whitespace
618
- else:
619
- end_pos += 1
620
- extended += 1
621
- print(f" Final end_pos: {end_pos}")
622
- else:
623
- print(f" End phrase not found! Finding paragraph end...")
624
- end_pos = find_paragraph_end(document_markdown, start_pos)
625
-
626
- if start_pos is not None and end_pos is not None:
627
- # Show actual extracted text for debugging
628
- extracted_text = document_markdown[start_pos:end_pos]
629
- print(f" Extracted text: '{extracted_text[:100]}...'")
630
 
631
- if start_pos is not None:
632
- positioned_chunks.append({
633
- "topic": chunk.topic,
634
- "start_phrase": chunk.start_phrase,
635
- "end_phrase": chunk.end_phrase,
636
- "start_position": start_pos,
637
- "end_position": end_pos,
638
- "found_start": True,
639
- "found_end": end_pos is not None
640
- })
641
 
642
- # Sort chunks by position in document for chronological order
643
- positioned_chunks.sort(key=lambda chunk: chunk.get('start_position', 0))
644
- print(f"📊 Final sorted chunks: {len(positioned_chunks)}")
645
 
646
- return positioned_chunks
647
 
648
  except Exception as e:
649
  import traceback
650
  print(f"❌ Auto-chunking error: {e}")
651
  print(f"❌ Full traceback: {traceback.format_exc()}")
652
- return []
653
 
654
  @app.post("/chunk_page")
655
  async def chunk_page(request: dict):
@@ -688,6 +1104,7 @@ Rules:
688
  3. Focus on educational content (concepts, examples, key points)
689
  4. More dense content should have more chunks, less dense content fewer chunks
690
  5. Identify chunks that would make good interactive lessons
 
691
 
692
  Return a list of chunks with topic, start_phrase, and end_phrase for each."""
693
 
@@ -697,39 +1114,21 @@ Return a list of chunks with topic, start_phrase, and end_phrase for each."""
697
  chunks = chunk_response.chunks
698
  print(f"📝 Received {len(chunks)} chunks from Fireworks")
699
 
700
- # Find positions using fuzzy matching
701
- positioned_chunks = []
702
- for chunk in chunks:
703
- start_pos = fuzzy_find(document_markdown, chunk.start_phrase)
704
- end_phrase_start = fuzzy_find(document_markdown, chunk.end_phrase, start_pos or 0)
705
- # Add the length of the end_phrase plus a bit more to include punctuation
706
- if end_phrase_start is not None:
707
- end_pos = end_phrase_start + len(chunk.end_phrase)
708
- # Try to include punctuation that might follow
709
- if end_pos < len(document_markdown) and document_markdown[end_pos] in '.!?;:,':
710
- end_pos += 1
711
- else:
712
- end_pos = None
713
-
714
- if start_pos is not None:
715
- positioned_chunks.append({
716
- "topic": chunk.topic,
717
- "start_phrase": chunk.start_phrase,
718
- "end_phrase": chunk.end_phrase,
719
- "start_position": start_pos,
720
- "end_position": end_pos,
721
- "found_start": True,
722
- "found_end": end_pos is not None
723
- })
724
- print(f"✅ Found chunk: {chunk.topic} at position {start_pos}")
725
- else:
726
- print(f"❌ Could not find chunk: {chunk.topic}")
727
 
728
- print(f"📊 Successfully positioned {len(positioned_chunks)}/{len(chunks)} chunks")
729
 
730
  return {
731
- "chunks": positioned_chunks,
732
- "total_found": len(positioned_chunks),
733
  "total_suggested": len(chunks)
734
  }
735
 
 
14
  from typing import Optional, List
15
  from langchain.chat_models import init_chat_model
16
  import anthropic
17
+ import google
18
+ from google import genai
19
  # Load environment variables
20
  load_dotenv()
21
 
 
250
  combined_markdown = '\n\n---\n\n'.join(all_page_markdown)
251
  print(f"📋 Combined document: {len(combined_markdown)} chars total")
252
 
253
+ # Auto-chunk the entire document once - try Gemini first, then fallback
254
  document_chunks = []
255
  original_markdown = combined_markdown
256
  try:
257
+ print(f"🧠 Auto-chunking entire document with Gemini...")
258
+ document_chunks, original_markdown = await gemini_chunk_document(combined_markdown)
259
+
260
+ # If Gemini failed, try the old Fireworks method
261
+ if not document_chunks:
262
+ print(f"🔄 Gemini failed, falling back to Fireworks...")
263
+ document_chunks, original_markdown = await auto_chunk_document(combined_markdown, client)
264
+
265
  print(f"📊 Document chunks found: {len(document_chunks)}")
266
  for i, chunk in enumerate(document_chunks):
267
+ topic = chunk.get('topic', 'Unknown')
268
+ preview = chunk.get('text', chunk.get('start_phrase', ''))[:50] + "..." if chunk.get('text', chunk.get('start_phrase', '')) else 'No content'
269
+ print(f" {i+1}. {topic}: {preview}")
270
+
271
  except Exception as chunk_error:
272
  print(f"⚠️ Document chunking failed: {chunk_error}")
273
  document_chunks = []
 
337
 
338
  class ChunkSchema(BaseModel):
339
  """Schema for document chunks suitable for creating interactive lessons."""
340
+ topic: str = Field(description="Brief descriptive name (2-6 words) for the educational content")
341
+ text: str = Field(description="Complete chunk text with exact markdown/LaTeX formatting preserved, containing 2-3 related educational concepts")
 
342
 
343
  class ChunkList(BaseModel):
344
  """Container for a list of document chunks."""
 
545
  # The frontend will use the original document for highlighting
546
  return chunks, document_markdown
547
 
548
+ def split_document_into_batches(document_markdown, max_chars=8000):
549
+ """Split document into manageable batches for LLM processing"""
550
+ if len(document_markdown) <= max_chars:
551
+ return [document_markdown]
552
+
553
+ batches = []
554
+ current_pos = 0
555
+
556
+ while current_pos < len(document_markdown):
557
+ # Try to find a good breaking point (paragraph boundary)
558
+ end_pos = min(current_pos + max_chars, len(document_markdown))
559
+
560
+ # If we're not at the end, try to break at a paragraph boundary
561
+ if end_pos < len(document_markdown):
562
+ # Look for \n\n within the last 1000 characters of this batch
563
+ search_start = max(end_pos - 1000, current_pos)
564
+ last_paragraph = document_markdown.rfind('\n\n', search_start, end_pos)
565
+
566
+ if last_paragraph != -1 and last_paragraph > current_pos:
567
+ end_pos = last_paragraph + 2 # Include the \n\n
568
+
569
+ batch = document_markdown[current_pos:end_pos]
570
+ batches.append(batch)
571
+ current_pos = end_pos
572
+
573
+ print(f"📄 Created batch {len(batches)}: {len(batch)} chars (pos {current_pos-len(batch)}-{current_pos})")
574
+
575
+ return batches
576
+
577
+ async def gemini_chunk_document(document_markdown):
578
+ """Auto-chunk a document using Google Gemini 2.5 Pro with reliable structured output"""
579
+
580
+ # Get Gemini API key
581
+ gemini_api_key = os.environ.get("GEMINI_API_KEY")
582
+ if not gemini_api_key:
583
+ print("⚠️ No Gemini API key found")
584
+ return None, document_markdown
585
+
586
+ print(f"📄 Document length: {len(document_markdown)} characters")
587
+
588
+ try:
589
+ # Initialize Gemini client
590
+ client = genai.Client(api_key=gemini_api_key)
591
+
592
+ # Split document into batches if it's too large (Gemini has token limits)
593
+ batches = split_document_into_batches(document_markdown, max_chars=12000) # Gemini can handle larger batches
594
+ print(f"📄 Split document into {len(batches)} batches for Gemini")
595
+
596
+ all_chunks = []
597
+
598
+ # Process each batch
599
+ for batch_idx, batch in enumerate(batches):
600
+ print(f"\n🔄 Processing batch {batch_idx + 1}/{len(batches)} ({len(batch)} chars) with Gemini")
601
+
602
+ try:
603
+ # Create the prompt for Gemini
604
+ prompt = f"""You are an educational content analyzer. Analyze this document section and break it into logical learning chunks.
605
+
606
+ Each chunk should:
607
+ - Contain 2-3 related educational concepts that naturally belong together
608
+ - Be 150-500 words (optimal for learning)
609
+ - Have clear educational value
610
+ - Preserve all markdown/LaTeX formatting exactly
611
+ - Skip: abstracts, acknowledgments, references, author info, page numbers
612
+
613
+ Return your response as a valid JSON object with this exact structure:
614
+ {{
615
+ "chunks": [
616
+ {{
617
+ "topic": "Brief descriptive name (2-6 words)",
618
+ "text": "Complete chunk text with exact formatting preserved"
619
+ }}
620
+ ]
621
+ }}
622
+
623
+ Document section to analyze:
624
+ {batch}
625
+
626
+ Important: Return ONLY the JSON object, no other text."""
627
+
628
+ # Call Gemini 2.5 Pro (disable thinking for faster/cheaper responses)
629
+ response = client.models.generate_content(
630
+ model="gemini-2.5-pro",
631
+ contents=prompt,
632
+ config=genai.types.GenerateContentConfig(
633
+ thinking_config=genai.types.ThinkingConfig(thinking_budget=-1)
634
+ )
635
+ )
636
+
637
+ # Extract and parse response
638
+ response_text = response.text.strip()
639
+ print(f"📋 Gemini response preview: {response_text}...")
640
+
641
+ # Clean up the response (remove code blocks if present)
642
+ clean_response = response_text
643
+ if clean_response.startswith('```json'):
644
+ clean_response = clean_response[7:]
645
+ if clean_response.endswith('```'):
646
+ clean_response = clean_response[:-3]
647
+ clean_response = clean_response.strip()
648
+
649
+ # Parse JSON
650
+ try:
651
+ json_data = json.loads(clean_response)
652
+
653
+ # Validate structure
654
+ if not isinstance(json_data, dict) or 'chunks' not in json_data:
655
+ print(f"❌ Invalid response structure from Gemini batch {batch_idx + 1}")
656
+ continue
657
+
658
+ chunks = json_data['chunks']
659
+ if not isinstance(chunks, list):
660
+ print(f"❌ 'chunks' is not a list in batch {batch_idx + 1}")
661
+ continue
662
+
663
+ # Process chunks
664
+ batch_chunks = []
665
+ for i, chunk in enumerate(chunks):
666
+ if not isinstance(chunk, dict) or 'topic' not in chunk or 'text' not in chunk:
667
+ print(f"❌ Invalid chunk structure in batch {batch_idx + 1}, chunk {i}")
668
+ continue
669
+
670
+ # Clean up text formatting
671
+ chunk_text = chunk['text']
672
+ # Replace literal \n with actual newlines
673
+ chunk_text = chunk_text.replace('\\n', '\n')
674
+
675
+ batch_chunks.append({
676
+ "topic": chunk['topic'],
677
+ "text": chunk_text,
678
+ "chunk_index": len(all_chunks) + len(batch_chunks)
679
+ })
680
+
681
+ print(f"✅ Processed chunk: {chunk['topic']}")
682
+
683
+ all_chunks.extend(batch_chunks)
684
+ print(f"📊 Batch {batch_idx + 1} added {len(batch_chunks)} chunks (total: {len(all_chunks)})")
685
+
686
+ except json.JSONDecodeError as e:
687
+ print(f"❌ JSON parsing failed for batch {batch_idx + 1}: {e}")
688
+ print(f"❌ Response was: {response_text}")
689
+ continue
690
+
691
+ except Exception as e:
692
+ print(f"❌ Error processing batch {batch_idx + 1} with Gemini: {e}")
693
+ continue
694
+
695
+ # Return results
696
+ if all_chunks:
697
+ print(f"✅ Gemini successfully processed document with {len(all_chunks)} total chunks")
698
+ return all_chunks, document_markdown
699
+ else:
700
+ print("❌ Gemini processing failed for all batches")
701
+ return None, document_markdown
702
+
703
+ except Exception as e:
704
+ print(f"❌ Gemini chunking error: {e}")
705
+ return None, document_markdown
706
+
707
  async def auto_chunk_document(document_markdown, client=None):
708
+ """Auto-chunk a document using LLM with batch processing for large documents"""
709
+
710
+ # Debug: Print document info
711
+ print(f"📄 Document length: {len(document_markdown)} characters")
712
 
713
  # Get Fireworks API key
714
  fireworks_api_key = os.environ.get("FIREWORKS_API_KEY")
715
  if not fireworks_api_key:
716
+ print("⚠️ No Fireworks API key found, falling back to programmatic chunking")
717
+ chunks, original_markdown = programmatic_chunk_document(document_markdown)
718
+ return chunks, original_markdown
719
+
720
+ # Split document into batches if it's too large
721
+ batches = split_document_into_batches(document_markdown, max_chars=8000)
722
+ print(f"📄 Split document into {len(batches)} batches")
723
+
724
+ all_chunks = []
725
+
726
+ # Process each batch
727
+ for batch_idx, batch in enumerate(batches):
728
+ print(f"\n🔄 Processing batch {batch_idx + 1}/{len(batches)} ({len(batch)} chars)")
729
+
730
+ # Try structured output with retry logic for this batch
731
+ max_retries = 3
732
+ batch_chunks = None
733
+
734
+ for attempt in range(max_retries):
735
+ try:
736
+ print(f"🚀 Batch {batch_idx + 1} Attempt {attempt + 1}/{max_retries}: Calling Fireworks...")
737
+
738
+ # Initialize LLM
739
+ llm = init_chat_model(
740
+ "accounts/fireworks/models/llama4-maverick-instruct-basic",
741
+ model_provider="fireworks",
742
+ api_key=fireworks_api_key
743
+ )
744
+
745
+ # Use regular LLM and manual JSON parsing
746
+ prompt = f"""You are an educational content analyzer. Break this document section into logical learning chunks.
747
+
748
+ IMPORTANT: Return your response as a valid JSON object with this exact structure:
749
+ {{
750
+ "chunks": [
751
+ {{
752
+ "topic": "Brief topic name",
753
+ "text": "Complete chunk text with exact formatting"
754
+ }}
755
+ ]
756
+ }}
757
+
758
+ Rules for chunking:
759
+ - Each chunk should contain 2-3 related educational concepts
760
+ - Keep chunks concise: 100-300 words (avoid very long text blocks)
761
+ - Preserve all markdown/LaTeX formatting exactly as written
762
+ - Skip: abstracts, acknowledgements, references, author information, page numbers
763
+ - Create separate chunks for figures/tables with their captions
764
+ - Never split mathematical expressions or LaTeX formulas
765
+ - Process ALL content in this section - don't skip any educational material
766
+ - Ensure all JSON strings are properly formatted (no unescaped quotes)
767
+
768
+ Document section to analyze:
769
+ {batch}
770
+
771
+ Return only the JSON object, no other text."""
772
+
773
+ # Call regular LLM
774
+ result = llm.invoke(prompt)
775
+ print(f"📋 Raw LLM response type: {type(result)}")
776
+
777
+ # Extract text content
778
+ if hasattr(result, 'content'):
779
+ response_text = result.content
780
+ elif hasattr(result, 'text'):
781
+ response_text = result.text
782
+ else:
783
+ response_text = str(result)
784
+
785
+ print(f"📋 Response text preview: {response_text}...")
786
+
787
+ # Try to parse JSON manually
788
+
789
+ try:
790
+ # Clean up the response - remove any markdown code blocks and fix common issues
791
+ clean_response = response_text.strip()
792
+ if clean_response.startswith('```json'):
793
+ clean_response = clean_response[7:]
794
+ if clean_response.endswith('```'):
795
+ clean_response = clean_response[:-3]
796
+ clean_response = clean_response.strip()
797
+
798
+ # Fix common JSON truncation issues
799
+ # If the response doesn't end properly, try to close it
800
+ if not clean_response.endswith('}'):
801
+ # Try to find the last complete chunk entry and close properly
802
+ last_brace = clean_response.rfind('}')
803
+ if last_brace != -1:
804
+ # Find if we're inside a chunks array
805
+ chunks_start = clean_response.find('"chunks": [')
806
+ if chunks_start != -1 and last_brace > chunks_start:
807
+ # Close the chunks array and main object
808
+ clean_response = clean_response[:last_brace+1] + '\n ]\n}'
809
+ else:
810
+ clean_response = clean_response[:last_brace+1]
811
+
812
+ print(f"📋 Cleaned response preview: {clean_response[:300]}...")
813
+ print(f"📋 Cleaned response ends with: '{clean_response[-50:]}'")
814
+
815
+ # Additional safety: ensure we have a complete JSON structure
816
+ if not (clean_response.startswith('{') and clean_response.endswith('}')):
817
+ print(f"❌ Response doesn't look like valid JSON structure")
818
+ continue
819
+
820
+ # Fix common JSON escape issues with LaTeX
821
+ # Replace single backslashes with double backslashes in JSON strings
822
+ # But be careful not to affect already-escaped sequences
823
+ def fix_latex_escapes(text):
824
+ # Find all JSON string values (between quotes)
825
+ def escape_in_string(match):
826
+ string_content = match.group(1)
827
+ # Escape single backslashes in LaTeX commands
828
+ # Handle \mathrm, \left, \%, etc. but preserve JSON escapes like \n, \t, \", \\
829
+ # Pattern: backslash followed by letters OR specific LaTeX symbols like %
830
+ fixed = re.sub(r'(?<!\\)\\(?=[a-zA-Z%])', r'\\\\', string_content)
831
+ return f'"{fixed}"'
832
+
833
+ # Apply to all JSON string values
834
+ return re.sub(r'"([^"\\]*(\\.[^"\\]*)*)"', escape_in_string, text)
835
+
836
+ clean_response = fix_latex_escapes(clean_response)
837
+ print(f"📋 After escape fixing: {clean_response[:200]}...")
838
+
839
+ # Parse JSON
840
+ json_data = json.loads(clean_response)
841
+ print(f"📋 Successfully parsed JSON: {type(json_data)}")
842
+
843
+ # Validate with Pydantic
844
+ chunk_response = ChunkList.model_validate(json_data)
845
+ print(f"📋 Pydantic validation successful: {type(chunk_response)}")
846
+
847
+ # Fix literal \n strings in chunk text (convert to actual newlines)
848
+ for chunk in chunk_response.chunks:
849
+ if hasattr(chunk, 'text') and chunk.text:
850
+ # Replace literal \n with actual newlines for paragraph breaks
851
+ # Be careful not to affect LaTeX commands that might contain 'n'
852
+ chunk.text = chunk.text.replace('\\n', '\n')
853
+
854
+ except json.JSONDecodeError as e:
855
+ print(f"❌ Attempt {attempt + 1}: JSON parsing failed: {e}")
856
+ print(f"❌ Response was: {response_text}")
857
+ continue
858
+ except Exception as e:
859
+ print(f"❌ Attempt {attempt + 1}: Pydantic validation failed: {e}")
860
+ continue
861
+
862
+ chunks = chunk_response.chunks
863
+ if not chunks or len(chunks) == 0:
864
+ print(f"⚠️ Attempt {attempt + 1}: No chunks returned")
865
+ continue
866
+
867
+ # Success! Process chunks
868
+ processed_chunks = []
869
+ for i, chunk in enumerate(chunks):
870
+ print(f"\n📝 Processing chunk {i+1}: {chunk.topic}")
871
+
872
+ if not hasattr(chunk, 'text') or not chunk.text.strip():
873
+ print(f"❌ Chunk missing or empty text: {chunk}")
874
+ continue
875
+
876
+ print(f" Text preview: '{chunk.text[:100]}...'")
877
+
878
+ processed_chunks.append({
879
+ "topic": chunk.topic,
880
+ "text": chunk.text,
881
+ "chunk_index": i
882
+ })
883
+
884
+ if processed_chunks:
885
+ print(f"✅ Successfully processed {len(processed_chunks)} chunks for batch {batch_idx + 1}")
886
+ batch_chunks = processed_chunks
887
+ break
888
+ else:
889
+ print(f"❌ Batch {batch_idx + 1} Attempt {attempt + 1}: No valid chunks processed")
890
+ continue
891
+
892
+ except Exception as e:
893
+ print(f"❌ Batch {batch_idx + 1} Attempt {attempt + 1} failed: {e}")
894
+ if attempt == max_retries - 1:
895
+ print(f"❌ All {max_retries} attempts failed for batch {batch_idx + 1}")
896
+
897
+ # Add successful batch chunks to all_chunks
898
+ if batch_chunks:
899
+ all_chunks.extend(batch_chunks)
900
+ print(f"📊 Total chunks so far: {len(all_chunks)}")
901
+ else:
902
+ print(f"⚠️ Batch {batch_idx + 1} failed completely, skipping...")
903
+
904
+ # Final results
905
+ if all_chunks:
906
+ print(f"✅ Successfully processed document with {len(all_chunks)} total chunks from {len(batches)} batches")
907
+ # Re-index all chunks sequentially
908
+ for i, chunk in enumerate(all_chunks):
909
+ chunk["chunk_index"] = i
910
+ return all_chunks, document_markdown
911
+ else:
912
+ print("🔄 All batches failed, falling back to programmatic chunking...")
913
+ chunks, original_markdown = programmatic_chunk_document(document_markdown)
914
+ return chunks, original_markdown
915
 
916
  try:
917
  # Initialize Fireworks LLM with structured output
 
924
  # Create structured LLM that returns ChunkList object
925
  structured_llm = llm.with_structured_output(ChunkList)
926
 
927
+ # Create improved chunking prompt that returns complete chunk text
928
+ prompt = f"""## Task
929
+ Analyze this academic document and create logical educational chunks. Each chunk should contain 2-3 related educational concepts or lessons that a student would naturally learn together.
 
930
 
931
+ ## Step-by-Step Process
932
+ 1. **Scan the document** to identify main topics and educational concepts
933
+ 2. **Group related paragraphs** that teach connected ideas (even if separated by figures)
934
+ 3. **Create separate chunks** for figures/tables with their captions
935
+ 4. **Ensure each chunk** contains 2-3 educational lessons that build on each other
936
+ 5. **Preserve all formatting** exactly as written
937
+
938
+ ## Chunking Rules
939
 
940
+ ### Content Rules
941
+ - **Combine related content**: If a concept is split by a figure placement, reunite the related paragraphs in one chunk
942
+ - **2-3 educational lessons per chunk**: Each chunk should teach 2-3 connected concepts that logically belong together
943
+ - **Preserve complete thoughts**: Never split sentences, mathematical expressions, or LaTeX formulas
944
+ - **Skip metadata sections**: Exclude abstracts, acknowledgments, references, author info, page numbers
945
+
946
+ ### Formatting Rules
947
+ - **Preserve exactly**: All markdown, LaTeX, mathematical notation, and formatting
948
+ - **Include paragraph breaks**: Maintain original \\n\\n paragraph separations
949
+ - **Remove artifacts**: Strip page numbers, headers, footers, and formatting metadata
950
+
951
+ ### Special Elements
952
+ - **Figures/Tables/Images**: Create separate chunks containing the full caption and any accompanying text
953
+ - **Mathematical expressions**: Keep complete formulas together, never split LaTeX
954
+ - **Code blocks**: Preserve in their entirety with proper formatting
955
+
956
+ ## Output Format
957
+ Return a JSON object with this exact schema:
958
+
959
+ ```json
960
+ {{
961
+ "chunks": [
962
+ {{
963
+ "topic": "Brief descriptive name (2-6 words) for the educational content",
964
+ "text": "Complete chunk text with exact markdown/LaTeX formatting preserved"
965
+ }}
966
+ ]
967
+ }}
968
+ ```
969
+
970
+ ## Quality Criteria
971
+ **Good chunks:**
972
+ - Contain 2-3 related educational concepts
973
+ - Are 150-500 words (optimal learning unit size)
974
+ - Have clear educational value and logical flow
975
+ - Preserve all original formatting perfectly
976
+
977
+ **Avoid:**
978
+ - Single-sentence chunks
979
+ - Chunks with >5 unrelated concepts
980
+ - Split mathematical expressions
981
+ - Metadata or reference content
982
+
983
+ ## Examples
984
+
985
+ **Good chunk example:**
986
+ ```json
987
+ {{
988
+ "chunks": [
989
+ {{
990
+ "topic": "Gradient Descent Fundamentals",
991
+ "text": "## Gradient Descent Algorithm\\n\\nGradient descent is an optimization algorithm used to minimize functions...\\n\\n### Mathematical Formulation\\n\\nThe update rule is given by:\\n\\n$\\theta_{{t+1}} = \\theta_t - \\alpha \\nabla f(\\theta_t)$\\n\\nwhere $\\alpha$ is the learning rate..."
992
+ }}
993
+ ]
994
+ }}
995
+ ```
996
+
997
+ **Bad chunk example:**
998
+ ```json
999
+ {{
1000
+ "chunks": [
1001
+ {{
1002
+ "topic": "Introduction",
1003
+ "text": "This paper presents..."
1004
+ }}
1005
+ ]
1006
+ }}
1007
+ ```
1008
+ (Too brief, not educational content)
1009
+
1010
+ ---
1011
+
1012
+ ## Document to Process:
1013
+ {document_markdown}
1014
+
1015
+ Please analyze the document and return the JSON object with chunks following the above guidelines.
1016
+ """
1017
 
1018
  # Call Fireworks with structured output
1019
+ print("🚀 Calling Fireworks for document chunking...")
1020
+ try:
1021
+ chunk_response = structured_llm.invoke(prompt)
1022
+ print(f"📋 Raw response type: {type(chunk_response)}")
1023
+ print(f"📋 Raw response: {chunk_response}")
1024
+ except Exception as invoke_error:
1025
+ print(f"❌ Error during Fireworks invoke: {invoke_error}")
1026
+ return [], document_markdown
1027
 
1028
+ if chunk_response is None:
1029
+ print("❌ Received None response from Fireworks")
1030
+ return [], document_markdown
 
 
 
1031
 
1032
+ if not hasattr(chunk_response, 'chunks'):
1033
+ print(f"❌ Response missing 'chunks' attribute: {type(chunk_response)}")
1034
+ print(f"Response content: {chunk_response}")
1035
+ return [], document_markdown
1036
 
1037
+ chunks = chunk_response.chunks
1038
+ if not chunks:
1039
+ print("⚠️ No chunks returned from Fireworks")
1040
+ return [], document_markdown
1041
+
1042
+ # Process chunks with direct text (no fuzzy matching needed)
1043
+ processed_chunks = []
1044
+ for i, chunk in enumerate(chunks):
1045
+ print(f"\n📝 Processing chunk {i+1}: {chunk.topic}")
1046
 
1047
+ # Check if chunk has the expected 'text' attribute
1048
+ if not hasattr(chunk, 'text'):
1049
+ print(f"❌ Chunk missing 'text' attribute: {chunk}")
1050
+ continue
 
 
 
 
1051
 
1052
+ print(f" Text preview: '{chunk.text[:100]}...'")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1053
 
1054
+ processed_chunks.append({
1055
+ "topic": chunk.topic,
1056
+ "text": chunk.text,
1057
+ "chunk_index": i
1058
+ })
 
 
 
 
 
1059
 
1060
+ print(f"📊 Processed {len(processed_chunks)} chunks with direct text")
 
 
1061
 
1062
+ return processed_chunks, document_markdown
1063
 
1064
  except Exception as e:
1065
  import traceback
1066
  print(f"❌ Auto-chunking error: {e}")
1067
  print(f"❌ Full traceback: {traceback.format_exc()}")
1068
+ return [], document_markdown
1069
 
1070
  @app.post("/chunk_page")
1071
  async def chunk_page(request: dict):
 
1104
  3. Focus on educational content (concepts, examples, key points)
1105
  4. More dense content should have more chunks, less dense content fewer chunks
1106
  5. Identify chunks that would make good interactive lessons
1107
+ 6. SKIP chunks from abstract, references, author information, page numbers, etc.
1108
 
1109
  Return a list of chunks with topic, start_phrase, and end_phrase for each."""
1110
 
 
1114
  chunks = chunk_response.chunks
1115
  print(f"📝 Received {len(chunks)} chunks from Fireworks")
1116
 
1117
+ # Process chunks with direct text (no fuzzy matching needed)
1118
+ processed_chunks = []
1119
+ for i, chunk in enumerate(chunks):
1120
+ processed_chunks.append({
1121
+ "topic": chunk.topic,
1122
+ "text": chunk.text,
1123
+ "chunk_index": i
1124
+ })
1125
+ print(f"✅ Processed chunk: {chunk.topic}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1126
 
1127
+ print(f"📊 Successfully processed {len(processed_chunks)} chunks")
1128
 
1129
  return {
1130
+ "chunks": processed_chunks,
1131
+ "total_found": len(processed_chunks),
1132
  "total_suggested": len(chunks)
1133
  }
1134
 
backend/requirements.txt CHANGED
@@ -10,3 +10,4 @@ langchain-core
10
  langchain-fireworks
11
  pydantic
12
  anthropic
 
 
10
  langchain-fireworks
11
  pydantic
12
  anthropic
13
+ google-genai
frontend/src/components/DocumentProcessor.jsx CHANGED
@@ -51,6 +51,7 @@ function DocumentProcessor() {
51
  containerRef,
52
  handleMouseDown
53
  } = usePanelResize(50);
 
54
 
55
  // Simplified startInteractiveLesson - no chat hook needed
56
  const handleStartInteractiveLesson = () => {
@@ -115,6 +116,7 @@ function DocumentProcessor() {
115
  {/* Left Panel - Document */}
116
  <div style={{ width: `${leftPanelWidth}%`, height: '100%' }}>
117
  <DocumentViewer
 
118
  selectedFile={selectedFile}
119
  documentData={documentData}
120
  />
 
51
  containerRef,
52
  handleMouseDown
53
  } = usePanelResize(50);
54
+ } = usePanelResize(50);
55
 
56
  // Simplified startInteractiveLesson - no chat hook needed
57
  const handleStartInteractiveLesson = () => {
 
116
  {/* Left Panel - Document */}
117
  <div style={{ width: `${leftPanelWidth}%`, height: '100%' }}>
118
  <DocumentViewer
119
+ selectedFile={selectedFile}
120
  selectedFile={selectedFile}
121
  documentData={documentData}
122
  />
test_fuzzy_find.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #%%
2
+ import matplotlib.pyplot as plt
3
+ from difflib import SequenceMatcher
4
+ import numpy as np
5
+
6
+ def fuzzy_find(text, pattern, start_pos=0):
7
+ """Find the best fuzzy match for pattern in text starting from start_pos"""
8
+ best_ratio = 0
9
+ best_pos = -1
10
+
11
+ # Search in sliding windows
12
+ pattern_len = len(pattern)
13
+ for i in range(start_pos, len(text) - pattern_len + 1):
14
+ window = text[i:i + pattern_len]
15
+ ratio = SequenceMatcher(None, pattern.lower(), window.lower()).ratio()
16
+
17
+ if ratio > best_ratio and ratio > 0.8: # Much stricter: 80% similarity
18
+ best_ratio = ratio
19
+ best_pos = i
20
+
21
+ return best_pos if best_pos != -1 else None
22
+
23
+ def analyze_fuzzy_ratios(markdown_text, chunk_text):
24
+ """
25
+ Analyze fuzzy matching ratios across the entire markdown text using a rolling window.
26
+ Returns positions and their corresponding similarity ratios.
27
+ """
28
+ chunk_len = len(chunk_text)
29
+ positions = []
30
+ ratios = []
31
+
32
+ # Rolling window over the entire markdown text
33
+ for i in range(len(markdown_text) - chunk_len + 1):
34
+ window = markdown_text[i:i + chunk_len]
35
+ ratio = SequenceMatcher(None, chunk_text.lower(), window.lower()).ratio()
36
+ positions.append(i)
37
+ ratios.append(ratio)
38
+
39
+ return positions, ratios
40
+
41
+ def plot_ratio_distribution(positions, ratios, chunk_text, markdown_file_path=None):
42
+ """
43
+ Create a plot showing the similarity ratio distribution across positions.
44
+ """
45
+ fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(15, 10))
46
+
47
+ # Main plot: ratio vs position
48
+ ax1.plot(positions, ratios, 'b-', alpha=0.7, linewidth=1)
49
+ ax1.axhline(y=0.8, color='r', linestyle='--', label='Fuzzy find threshold (0.8)')
50
+ ax1.set_xlabel('Position in Markdown Text')
51
+ ax1.set_ylabel('Similarity Ratio')
52
+ ax1.set_title(f'Fuzzy Match Similarity Ratios Across Text\n(Chunk length: {len(chunk_text)} chars)')
53
+ ax1.grid(True, alpha=0.3)
54
+ ax1.legend()
55
+
56
+ # Highlight maximum ratio
57
+ max_ratio = max(ratios)
58
+ max_pos = positions[ratios.index(max_ratio)]
59
+ ax1.plot(max_pos, max_ratio, 'ro', markersize=8, label=f'Max ratio: {max_ratio:.3f} at pos {max_pos}')
60
+ ax1.legend()
61
+
62
+ # Histogram of ratios
63
+ ax2.hist(ratios, bins=50, alpha=0.7, edgecolor='black')
64
+ ax2.axvline(x=0.8, color='r', linestyle='--', label='Fuzzy find threshold (0.8)')
65
+ ax2.axvline(x=max_ratio, color='g', linestyle='--', label=f'Maximum ratio: {max_ratio:.3f}')
66
+ ax2.set_xlabel('Similarity Ratio')
67
+ ax2.set_ylabel('Frequency')
68
+ ax2.set_title('Distribution of Similarity Ratios')
69
+ ax2.legend()
70
+ ax2.grid(True, alpha=0.3)
71
+
72
+ plt.tight_layout()
73
+ return fig, max_ratio, max_pos
74
+
75
+ def compare_texts(original_chunk, found_text, max_pos):
76
+ """
77
+ Compare the original chunk text with the text found by fuzzy_find.
78
+ Shows character-by-character differences and similarity analysis.
79
+ """
80
+ print("\n" + "="*80)
81
+ print("TEXT COMPARISON: Original Chunk vs Fuzzy Find Result")
82
+ print("="*80)
83
+
84
+ print(f"\nOriginal chunk length: {len(original_chunk)} characters")
85
+ print(f"Found text length: {len(found_text)} characters")
86
+ print(f"Found at position: {max_pos}")
87
+
88
+ # Calculate overall similarity
89
+ similarity = SequenceMatcher(None, original_chunk.lower(), found_text.lower()).ratio()
90
+ print(f"Overall similarity: {similarity:.4f} ({similarity*100:.2f}%)")
91
+
92
+ # Show first 200 characters of each
93
+ print(f"\nOriginal chunk (first 200 chars):")
94
+ print(f"'{original_chunk}{'...' if len(original_chunk) > 200 else ''}'")
95
+
96
+ print(f"\nFound text (first 200 chars):")
97
+ print(f"'{found_text}{'...' if len(found_text) > 200 else ''}'")
98
+
99
+ # Character-by-character analysis for first 100 characters
100
+ print(f"\nCharacter-by-character comparison (first 100 chars):")
101
+ print("Original: ", end="")
102
+ for i, char in enumerate(original_chunk[:100]):
103
+ if i < len(found_text) and char.lower() == found_text[i].lower():
104
+ print(char, end="") # Same character
105
+ else:
106
+ print(f"[{char}]", end="") # Different character
107
+ print()
108
+
109
+ print("Found: ", end="")
110
+ for i, char in enumerate(found_text[:100]):
111
+ if i < len(original_chunk) and char.lower() == original_chunk[i].lower():
112
+ print(char, end="") # Same character
113
+ else:
114
+ print(f"[{char}]", end="") # Different character
115
+ print()
116
+
117
+ # Analyze differences
118
+ matcher = SequenceMatcher(None, original_chunk, found_text)
119
+ differences = []
120
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
121
+ if tag != 'equal':
122
+ differences.append({
123
+ 'type': tag,
124
+ 'original_pos': (i1, i2),
125
+ 'found_pos': (j1, j2),
126
+ 'original_text': original_chunk[i1:i2],
127
+ 'found_text': found_text[j1:j2]
128
+ })
129
+
130
+ print(f"\nFound {len(differences)} differences:")
131
+ for i, diff in enumerate(differences[:10]): # Show first 10 differences
132
+ print(f"{i+1}. {diff['type'].upper()} at original[{diff['original_pos'][0]}:{diff['original_pos'][1]}] -> found[{diff['found_pos'][0]}:{diff['found_pos'][1]}]")
133
+ if diff['original_text']:
134
+ print(f" Original: '{diff['original_text'][:50]}{'...' if len(diff['original_text']) > 50 else ''}'")
135
+ if diff['found_text']:
136
+ print(f" Found: '{diff['found_text'][:50]}{'...' if len(diff['found_text']) > 50 else ''}'")
137
+
138
+ if len(differences) > 10:
139
+ print(f" ... and {len(differences) - 10} more differences")
140
+
141
+ return similarity, differences
142
+
143
+ def run_fuzzy_analysis():
144
+ """
145
+ Main function to run the fuzzy find analysis.
146
+ You can modify the markdown_text and chunk_text variables below.
147
+ """
148
+
149
+ # TODO: Replace these with your actual markdown content and chunk
150
+ markdown_text = """# An improved method for mobile characterisation of $\\delta^{13} \\mathrm{CH}_{4}$ source signatures and its application in Germany \n\nAntje Hoheisel ${ }^{1}$, Christiane Yeman ${ }^{1, a}$, Florian Dinger ${ }^{1,2}$, Henrik Eckhardt ${ }^{1}$, and Martina Schmidt ${ }^{1}$<br>${ }^{1}$ Institute of Environmental Physics, Heidelberg University, Heidelberg, Germany<br>${ }^{2}$ Max Planck Institute for Chemistry, Mainz, Germany<br>${ }^{a}$ now at: Laboratory of Ion Beam Physics, ETH Zurich, Zurich, Switzerland\n\nCorrespondence: Antje Hoheisel (antje.hoheisel@iup.uni-heidelberg.de)\nReceived: 7 August 2018 - Discussion started: 1 October 2018\nRevised: 17 January 2019 - Accepted: 28 January 2019 - Published: 22 February 2019\n\n\n#### Abstract\n\nThe carbon isotopic signature $\\left(\\delta^{13} \\mathrm{CH}_{4}\\right)$ of several methane sources in Germany (around Heidelberg and in North Rhine-Westphalia) were characterised. Mobile measurements of the plume of $\\mathrm{CH}_{4}$ sources are carried out using an analyser based on cavity ring-down spectroscopy (CRDS). To achieve precise results a CRDS analyser, which measures methane $\\left(\\mathrm{CH}_{4}\\right)$, carbon dioxide $\\left(\\mathrm{CO}_{2}\\right)$ and their ${ }^{13} \\mathrm{C}$-to- ${ }^{12} \\mathrm{C}$ ratios, was characterised especially with regard to cross sensitivities of composition differences of the gas matrix in air samples or calibration tanks. The two most important gases which affect $\\delta^{13} \\mathrm{CH}_{4}$ are water vapour $\\left(\\mathrm{H}_{2} \\mathrm{O}\\right)$ and ethane $\\left(\\mathrm{C}_{2} \\mathrm{H}_{6}\\right)$. To avoid the cross sensitivity with $\\mathrm{H}_{2} \\mathrm{O}$, the air is dried with a Nafion dryer during mobile measurements. $\\mathrm{C}_{2} \\mathrm{H}_{6}$ is typically abundant in natural gases and thus in methane plumes or samples originating from natural gas. $\\mathrm{A}_{2} \\mathrm{H}_{6}$ correction and calibration are essential to obtain accurate $\\delta^{13} \\mathrm{CH}_{4}$ results, which can deviate by up to $3 \\%$ depending on whether a $\\mathrm{C}_{2} \\mathrm{H}_{6}$ correction is applied.\n\nThe isotopic signature is determined with the Miller-Tans approach and the York fitting method. During 21 field campaigns the mean $\\delta^{13} \\mathrm{CH}_{4}$ signatures of three dairy farms $\\left(-63.9 \\pm 0.9 \\%_{e}\\right)$, a biogas plant $\\left(-62.4 \\pm 1.2 \\%_{e}\\right)$, a landfill $\\left(-58.7 \\pm 3.3 \\%_{e}\\right)$, a wastewater treatment plant $(-52.5 \\pm$ $1.4 \\%$ ), an active deep coal mine ( $-56.0 \\pm 2.3 \\%$ ) and two natural gas storage and gas compressor stations ( $-46.1 \\pm$ $0.8 \\%$ ) were recorded.\n\nIn addition, between December 2016 and November 2018 gas samples from the Heidelberg natural gas distribution network were measured with a mean $\\delta^{13} \\mathrm{CH}_{4}$ value of $-43.3 \\pm$ $0.8 \\%$. Contrary to previous measurements between 1991\n\n\n#### Abstract\n\nand 1996 by Levin et al. (1999), no strong seasonal cycle is shown.\n\n\n## 1 Introduction\n\nMethane $\\left(\\mathrm{CH}_{4}\\right)$ is the second most important anthropogenic greenhouse gas. The atmospheric growth rate of $\\mathrm{CH}_{4}$ has changed significantly during the last decades, stabilising at zero growth from 1999 to 2006 before beginning to increase again after 2007 (Dlugokencky et al., 2009). Several studies have focused on the recent $\\mathrm{CH}_{4}$ growth caused by changes in sources and sinks (Rigby et al., 2017; Turner et al., 2017).\n\nRecent studies by Schaefer et al. (2016), Rice et al. (2016) and Nisbet et al. (2016) have shown how the $\\delta^{13} \\mathrm{CH}_{4}$ measurements can help to understand the changes in global $\\mathrm{CH}_{4}$ increase rates and to assign the related source types. The stable carbon isotope ratio $\\left({ }^{13} \\mathrm{C} /{ }^{12} \\mathrm{C}\\right)$ of $\\mathrm{CH}_{4}$ sources varies due to the initial source material and the fractionation during production and release to the atmosphere. The source categories can be classified as pyrogenic (e.g. biomass burning), biogenic (e.g. wetlands and livestock) or thermogenic (e.g. a subcategory of fossil fuel extraction), which show different but also overlapping isotope ratio ranges. Various studies have shown that the assignment of isotopic signatures from different $\\mathrm{CH}_{4}$ sources remains uncertain due to large temporal variabilities and also regional specificities (e.g. Sherwood et al., 2017). This missing knowledge may result in large uncertainties when the $\\mathrm{CH}_{4}$ budget is determined on global or regional scales using isotope-based estimates. In addition to global studies, the use of $\\delta^{13} \\mathrm{CH}_{4}$ was already successfully"""
151
+
152
+ chunk_text = """## 1 Introduction\nMethane ($\mathrm{CH}_{4}$) is the second most important anthropogenic greenhouse gas. The atmospheric growth rate of $\mathrm{CH}_{4}$ has changed significantly during the last decades, stabilising at zero growth from 1999 to 2006 before beginning to increase again after 2007 (Dlugokencky et al., 2009). Several studies have focused on the recent $\mathrm{CH}_{4}$ growth caused by changes in sources and sinks (Rigby et al., 2017; Turner et al., 2017).\n\nRecent studies by Schaefer et al. (2016), Rice et al. (2016) and Nisbet et al. (2016) have shown how the $\delta^{13} \mathrm{CH}_{4}$ measurements can help to understand the changes in global $\mathrm{CH}_{4}$ increase rates and to assign the related source types. The stable carbon isotope ratio (${}^{13}\mathrm{C}$/${}^{12}\mathrm{C}$) of $\mathrm{CH}_{4}$ sources varies due to the initial source material and the fractionation during production and release to the atmosphere. The source categories can be classified as pyrogenic (e.g. biomass burning), biogenic (e.g. wetlands and livestock) or thermogenic (e.g. a subcategory of fossil fuel extraction), which show different but also overlapping isotope ratio ranges. Various studies have shown that the assignment of isotopic signatures from different $\mathrm{CH}_{4}$ sources remains uncertain due to large temporal variabilities and also regional specificities (e.g. Sherwood et al., 2017). This missing knowledge may result in large uncertainties when the $\mathrm{CH}_{4}$ budget is determined on global or regional scales using isotope-based estimates. In addition to global studies, the use of $\delta^{13}\mathrm{CH}_{4}$ was already successfully"""
153
+
154
+ print("Analyzing fuzzy matching ratios...")
155
+ print(f"Markdown text length: {len(markdown_text)} characters")
156
+ print(f"Chunk text length: {len(chunk_text)} characters")
157
+
158
+ # Run the analysis
159
+ positions, ratios = analyze_fuzzy_ratios(markdown_text, chunk_text)
160
+
161
+ # Create the plot
162
+ fig, max_ratio, max_pos = plot_ratio_distribution(positions, ratios, chunk_text)
163
+
164
+ # Print statistics
165
+ print(f"\nStatistics:")
166
+ print(f"Maximum similarity ratio: {max_ratio:.3f}")
167
+ print(f"Maximum ratio position: {max_pos}")
168
+ print(f"Number of positions above 0.8 threshold: {sum(1 for r in ratios if r > 0.8)}")
169
+ print(f"Mean ratio: {np.mean(ratios):.3f}")
170
+ print(f"Standard deviation: {np.std(ratios):.3f}")
171
+
172
+ # Test the original fuzzy_find function
173
+ result = fuzzy_find(markdown_text, chunk_text)
174
+ print(f"\nOriginal fuzzy_find result: {result}")
175
+ if result is not None:
176
+ print(f"Found match at position {result}")
177
+ else:
178
+ print("No match found above 0.8 threshold")
179
+
180
+ # Compare the found text with the original chunk
181
+ if max_ratio > 0: # If we found any match
182
+ found_text = markdown_text[max_pos:max_pos + len(chunk_text)]
183
+ text_similarity, differences = compare_texts(chunk_text, found_text, max_pos)
184
+ print(f"\nDetailed comparison similarity: {text_similarity:.4f}")
185
+ print(f"Number of character differences: {len(differences)}")
186
+
187
+ plt.show()
188
+ return positions, ratios, max_ratio, max_pos
189
+
190
+ if __name__ == "__main__":
191
+ # Run the analysis
192
+ positions, ratios, max_ratio, max_pos = run_fuzzy_analysis()
193
+
194
+ #%%