File size: 18,475 Bytes
d1d57e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
from typing import List, Dict, Any

import pandas as pd
from nltk.metrics.distance import edit_distance
import spacy
from spacy.tokens import Doc
import constants
import json

# Register custom extension for tracking original index
if not Doc.has_extension("original_index"):
    Doc.set_extension("original_index", default=None)

def group_sections(df):
    df = classify_sections_by_title(df, threshold=0.8)
    df, suspected_splits = classify_revisions_by_content_modified(df, threshold=0.99)
    grouped_df = group_titles_using_content(df)
    section_splits_df = validate_section_splits(grouped_df, suspected_splits)
    return grouped_df, section_splits_df

def identify_section_splits(df, length_threshold: int = 100):
    if constants.GROUPED_IDX not in df.columns:
        raise ValueError("The input file must contain 'grouped_idx'")

    df[constants.CHANGED_CONTENT] = df[constants.CHANGED_CONTENT].fillna('').astype(str)

    # Group by 'grouped_idx'
    grouped = df.groupby('grouped_idx', sort=False)
    revision_id_counts = df['Revision ID'].value_counts()

    print("Identified potential section splits:")
    print("Revision ID | Group Index | Length Change")
    print("-" * 50)

    for group_index, group in grouped:
        previous_length = None

        for idx in group.index:  # Ensure iteration is in original order
            current_length = len(df.at[idx, constants.CHANGED_CONTENT])
            revision_id = df.at[idx, 'Revision ID']
            if previous_length is not None:
                length_difference = previous_length - current_length
                # Check if the length difference exceeds the threshold
                if length_difference > length_threshold and revision_id_counts[revision_id] > 1:
                    print(f"{revision_id:<12} | {group_index:<11} | {length_difference}")

            # Update previous length
            previous_length = current_length

def levenshtein_similarity(title_a, title_b):
    """
    Calculate the Levenshtein similarity between two strings.
    """
    title_a, title_b = str(title_a), str(title_b)
    distance = edit_distance(title_a, title_b)
    max_length = max(len(title_a), len(title_b))
    return 1 - (distance / max_length) if max_length > 0 else 0

def classify_sections_by_title(df, threshold=0.8):
    """
    Classify section titles based on Levenshtein similarity.
    """
    section_sets = []
    section_indices = []

    for section in df['Section']:
        max_avg_similarity = 0
        max_set_index = None

        for idx, section_set in enumerate(section_sets):
            similarities = [levenshtein_similarity(section, existing_section) for existing_section in section_set]
            avg_similarity = sum(similarities) / len(similarities)
            if avg_similarity > max_avg_similarity:
                max_avg_similarity = avg_similarity
                max_set_index = idx

        if max_avg_similarity > threshold and max_set_index is not None:
            section_sets[max_set_index].add(section)
            section_indices.append(max_set_index + 1)
        else:
            section_sets.append({section})
            section_indices.append(len(section_sets))

    df[constants.SECTION_IDX_TITLE] = section_indices
    return df

def classify_revisions_by_content_modified(df, threshold=0.99, max_sentence_diff=4):
    """
    Classify revisions based on content similarity using SciSpaCy,
    collect suspected section-splits (sim>threshold AND diff>=max_sentence_diff),
    but defer final split validation until after grouping is complete.
    """
    import spacy
    import pandas as pd

    # Load model
    nlp = spacy.load("en_core_sci_md")
    nlp.max_length = 10000

    # Prepare text
    df[constants.CHANGED_CONTENT] = (
        df[constants.CHANGED_CONTENT]
        .fillna("")
        .astype(str)
    )

    # Build spaCy docs
    content_docs = []
    for text in df[constants.CHANGED_CONTENT]:
        if len(text) > nlp.max_length:
            print(
                f"Warning: Content length {len(text)} exceeds "
                f"nlp.max_length. Truncating to {nlp.max_length} chars."
            )
            text = text[:nlp.max_length]
        content_docs.append(nlp(text))

    section_sets    = []  # lists of Docs
    section_indices = []  # final SECTION_IDX_CONTENT values
    suspected_splits = []  # suspected split-event records (not yet validated)

    # Iterate through each revision‐doc
    for i, doc in enumerate(content_docs):
        doc._.original_index = i
        cur_sents = len(list(doc.sents))

        # Track best small‐diff match
        best_sim, best_idx, best_diff = 0.0, None, None

        # Compare to each existing section's last doc
        for sec_idx, sec in enumerate(section_sets):
            last_doc   = sec[-1]
            last_sents = len(list(last_doc.sents))
            sim        = doc.similarity(last_doc)
            diff       = abs(cur_sents - last_sents)

            # Record a SUSPECTED split event if sim high AND size-change large
            if sim > threshold and diff >= max_sentence_diff:
                suspected_splits.append({
                    "current_index":  i,
                    "previous_index": last_doc._.original_index,
                    "similarity":     sim,
                    "sentence_diff":  diff,
                })

            # Only consider for grouping if size-change is small
            if sim > best_sim and diff < max_sentence_diff:
                best_sim, best_idx, best_diff = sim, sec_idx, diff

        # Group-then-new logic (same as before)
        if best_idx is not None and best_sim > threshold:
            # high similarity + small diff ⇒ same section
            section_sets[best_idx].append(doc)
            section_indices.append(best_idx + 1)
        else:
            # otherwise ⇒ new section
            section_sets.append([doc])
            section_indices.append(len(section_sets))

    # Write section indices back to DataFrame
    df[constants.SECTION_IDX_CONTENT] = section_indices

    return df, suspected_splits

def validate_section_splits(grouped_df, suspected_splits):
    """
    Validate suspected section splits by checking if the items have different grouped indices.
    Prevent duplicate splits by ensuring:
    1. Each previous_idx is only used once (allows cascading X→Y→Z)
    2. Each current_idx (revision) only appears once in the final splits (prevents A→X, B→X duplicates)
    
    Args:
        grouped_df: DataFrame with grouped_idx column
        suspected_splits: List of suspected split records from classify_revisions_by_content_modified
    
    Returns:
        section_splits_df: DataFrame containing validated section splits
    """
    import pandas as pd
    
    if not suspected_splits:
        return pd.DataFrame()
    
    validated_splits = []
    used_previous_indices = set()  # Track previous_idx that have been used in confirmed splits
    used_current_indices = set()   # Track current_idx that have been used in confirmed splits
    
    for split in suspected_splits:
        current_idx = split["current_index"]
        previous_idx = split["previous_index"]
        
        # Skip if this previous_idx has already been used in a confirmed split
        if previous_idx in used_previous_indices:
            continue
            
        # Skip if this current_idx has already been used in a confirmed split
        # This prevents the same revision from appearing multiple times in splits
        if current_idx in used_current_indices:
            continue
            
        # Get grouped indices for both items
        current_grouped_idx = grouped_df.iloc[current_idx][constants.GROUPED_IDX]
        previous_grouped_idx = grouped_df.iloc[previous_idx][constants.GROUPED_IDX]
        
        # Validate split: different grouped indices = true section split
        if current_grouped_idx != previous_grouped_idx:
            validated_splits.append(split)
            # Mark both indices as used
            used_previous_indices.add(previous_idx)
            used_current_indices.add(current_idx)
    
    # Build enriched splits DataFrame (same format as original)
    if validated_splits:
        raw = pd.DataFrame(validated_splits)
        out = grouped_df.iloc[raw["current_index"]].copy()
        out["Previous Section"] = grouped_df.iloc[raw["previous_index"], grouped_df.columns.get_loc("Section")].values
        out["Previous Revision ID"] = grouped_df.iloc[raw["previous_index"], grouped_df.columns.get_loc("Revision ID")].values
        out["Previous Changed Content"] = grouped_df.iloc[raw["previous_index"], grouped_df.columns.get_loc(constants.CHANGED_CONTENT)].values
        out["Similarity"] = raw["similarity"].values
        out["Sentence Diff"] = raw["sentence_diff"].values
        
        # Add grouped index information for debugging/analysis
        out["Current Grouped Index"] = grouped_df.iloc[raw["current_index"], grouped_df.columns.get_loc(constants.GROUPED_IDX)].values
        out["Previous Grouped Index"] = grouped_df.iloc[raw["previous_index"], grouped_df.columns.get_loc(constants.GROUPED_IDX)].values
        
        section_splits_df = out
    else:
        section_splits_df = pd.DataFrame()
    
    return section_splits_df

def classify_revisions_by_content(df, threshold=0.99, max_sentence_diff=4):
    """
    Original function - kept for backward compatibility.
    Classify revisions based on content similarity using SciSpaCy,
    record any section-splits (sim>threshold AND diff>=max_sentence_diff),
    but always prefer grouping into an existing section when diff<max_sentence_diff.
    """
    import spacy
    import pandas as pd

    # Load model
    nlp = spacy.load("en_core_sci_md")
    nlp.max_length = 10000

    # Prepare text
    df[constants.CHANGED_CONTENT] = (
        df[constants.CHANGED_CONTENT]
        .fillna("")
        .astype(str)
    )

    # Build spaCy docs
    content_docs = []
    for text in df[constants.CHANGED_CONTENT]:
        if len(text) > nlp.max_length:
            print(
                f"Warning: Content length {len(text)} exceeds "
                f"nlp.max_length. Truncating to {nlp.max_length} chars."
            )
            text = text[:nlp.max_length]
        content_docs.append(nlp(text))

    section_sets    = []  # lists of Docs
    section_indices = []  # final SECTION_IDX_CONTENT values
    splits          = []  # raw split-event records

    # Iterate through each revision‐doc
    for i, doc in enumerate(content_docs):
        doc._.original_index = i
        cur_sents = len(list(doc.sents))

        # Track best small‐diff match
        best_sim, best_idx, best_diff = 0.0, None, None

        # Compare to each existing section's last doc
        for sec_idx, sec in enumerate(section_sets):
            last_doc   = sec[-1]
            last_sents = len(list(last_doc.sents))
            sim        = doc.similarity(last_doc)
            diff       = abs(cur_sents - last_sents)

            # Record a split event if sim high AND size-change large
            if sim > threshold and diff >= max_sentence_diff:
                splits.append({
                    "current_index":  i,
                    "previous_index": last_doc._.original_index,
                    "similarity":     sim,
                    "sentence_diff":  diff,
                })

            # Only consider for grouping if size-change is small
            if sim > best_sim and diff < max_sentence_diff:
                best_sim, best_idx, best_diff = sim, sec_idx, diff

        # Group-then-new logic
        if best_idx is not None and best_sim > threshold:
            # high similarity + small diff ⇒ same section
            section_sets[best_idx].append(doc)
            section_indices.append(best_idx + 1)
        else:
            # otherwise ⇒ new section
            section_sets.append([doc])
            section_indices.append(len(section_sets))

    # Write section indices back to DataFrame
    df[constants.SECTION_IDX_CONTENT] = section_indices

    # Build enriched splits DataFrame
    if splits:
        raw = pd.DataFrame(splits)
        out = df.iloc[raw["current_index"]].copy()
        out["Previous Section"]        = df.iloc[raw["previous_index"],    df.columns.get_loc("Section")].values
        out["Previous Revision ID"]    = df.iloc[raw["previous_index"],    df.columns.get_loc("Revision ID")].values
        out["Previous Changed Content"] = df.iloc[raw["previous_index"],   df.columns.get_loc(constants.CHANGED_CONTENT)].values
        out["Similarity"]              = raw["similarity"].values
        out["Sentence Diff"]           = raw["sentence_diff"].values
        section_splits_df = out
    else:
        section_splits_df = pd.DataFrame()

    return df, section_splits_df

def group_titles_using_content(df):
    filtered_df = df[df['Section'] != "(Top)"]
    idx_dict = create_map_of_content_idx_to_relative_title_indexes(filtered_df)
    grouped_indices = unite_indices(idx_dict)
    title_to_group = {
        title_index: group_index
        for group_index, group_set in enumerate(grouped_indices, start=1)  # Groups are 1-indexed
        for title_index in group_set
    }

    # Step 5: Add the 'grouped_idx' column to the DataFrame
    df[constants.GROUPED_IDX] = df[constants.SECTION_IDX_TITLE].map(title_to_group).fillna(0).astype(int)
    return df

def create_map_of_content_idx_to_relative_title_indexes(filtered_df):
    """
    Create a mapping from content index to title indexes.
    :param filename_combined: str
        Path to the input CSV file.
    :return: dict
        Dictionary where content index maps to a list of title indexes.
    """
    return (
        filtered_df.groupby(constants.SECTION_IDX_CONTENT)[constants.SECTION_IDX_TITLE]
        .apply(list)
        .to_dict()
    )

def unite_indices(data):
    """
    Unites overlapping indices from dictionary values into distinct sets.
    :param data: dict
        A dictionary where keys map to lists of indices.
    :return: list
        A list of sets with unified indices.
    """
    # Convert the dictionary values into sets
    sets = [set(value) for value in data.values()]

    # Iteratively merge sets with overlaps
    merged = []
    while sets:
        current = sets.pop(0)
        overlap_found = False

        for other_set in merged:
            if current & other_set:  # Check for overlap
                other_set.update(current)  # Merge overlapping sets
                overlap_found = True
                break

        if not overlap_found:
            merged.append(current)  # Add as a new set

    return merged

def check_section_name_changes(filename: str, threshold: float = 0.8) -> str:
    """
    Parse the output CSV by section index and check for significant section name changes.
    Log events where Levenshtein similarity is below the threshold.
    """
    df = pd.read_csv(filename)
    if 'Section from similarity' not in df.columns or 'Section' not in df.columns:
        raise ValueError("The input file must contain 'Section' and 'Section from similarity' columns.")

    df['Levenshtein Similarity from Previous'] = None
    grouped = df.groupby('Section from similarity', sort=False)

    for section_index, group in grouped:
        previous_name = None

        for idx in group.index:  # Use original indices to ensure order
            current_name = df.at[idx, 'Section']

            if previous_name is not None:
                distance = levenshtein_similarity(previous_name, current_name)
                df.at[idx, 'Levenshtein Distance from Previous'] = distance
            previous_name = current_name

    output_filename = filename.replace(".csv", "_with_levenshtein_distance.csv")
    df.to_csv(output_filename, index=False)
    print(f"Levenshtein distance calculation completed. Results saved to {output_filename}.")
    return output_filename

def generate_diff_json(df: pd.DataFrame, article_name: str, output_path: str = "diff_urls.json") -> List[Dict[str, Any]]:
    """
    Generate a JSON file listing Wikipedia diff URLs and associated section title changes.

    Each entry includes:
        - 'url': diff link
        - 'section_before': section title in previous revision
        - 'section_after': section title in current revision

    Grouping is done on both GROUPED_IDX and SECTION_IDX_CONTENT.

    Args:
        df (pd.DataFrame): DataFrame with revision data.
        article_name (str): Wikipedia article title.
        output_path (str): Path to save the output JSON file (default: diff_urls.json)
    """
    required_cols = [
        constants.GROUPED_IDX,
        constants.SECTION_IDX_CONTENT,
        constants.SECTION_IDX_TITLE,
        'Revision ID',
        'Section'
    ]
    if not all(col in df.columns for col in required_cols):
        raise ValueError(f"Missing one or more required columns: {required_cols}")

    title_slug = article_name.replace(" ", "_")
    diffs = []

    # Sort and group by both GROUPED_IDX and SECTION_IDX_CONTENT
    df_sorted = df.sort_values(by=[constants.GROUPED_IDX, constants.SECTION_IDX_CONTENT, 'Revision ID'])
    grouped = df_sorted.groupby([constants.GROUPED_IDX, constants.SECTION_IDX_CONTENT], sort=False)

    for (_, _), group_df in grouped:
        group_df = group_df.reset_index(drop=True)

        for i in range(1, len(group_df)):
            current_title_idx = group_df.loc[i, constants.SECTION_IDX_TITLE]
            prev_title_idx = group_df.loc[i - 1, constants.SECTION_IDX_TITLE]

            if current_title_idx != prev_title_idx:
                current_rev_id = group_df.loc[i, 'Revision ID']
                prev_rev_id = group_df.loc[i - 1, 'Revision ID']

                url = (
                    f"https://en.wikipedia.org/w/index.php"
                    f"?title={title_slug}"
                    f"&diff={current_rev_id}"
                    f"&oldid={prev_rev_id}"
                )

                diffs.append({
                    "url": url,
                    "section_before": group_df.loc[i - 1, 'Section'],
                    "section_after": group_df.loc[i, 'Section']
                })

    # Save JSON
    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(diffs, f, indent=2, ensure_ascii=False)

    print(f"Saved {len(diffs)} diff entries to {output_path}")
    return diffs