File size: 19,332 Bytes
bae15d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
"""
Data Processor for Kalim Chatbot
Converts Excel files to structured JSON for the knowledge base
"""
import json
import re
from pathlib import Path
from typing import Dict, List, Optional, Any
from openpyxl import load_workbook

import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent))

from config.settings import (
    MAIN_EXCEL_FILE,
    SUPPLEMENTARY_EXCEL_FILE,
    MAJORS_JSON_FILE,
    LOCATIONS_JSON_FILE,
    HOLLAND_MAPPING_FILE,
    PROCESSED_DATA_DIR,
    HOLLAND_CODES,
    SECONDARY_TRACKS,
    GOVERNORATES
)


VALID_HOLLAND_CODES = frozenset("RIASEC")


def clean_text(text: Optional[str]) -> str:
    """Clean and normalize Arabic text."""
    if text is None:
        return ""
    text = str(text).strip()
    # Remove extra whitespace
    text = re.sub(r'\s+', ' ', text)
    return text


def parse_languages(row_data: Dict[str, Any]) -> List[str]:
    """Extract teaching languages from row data."""
    languages = []
    if row_data.get('arabic') and clean_text(row_data['arabic']):
        languages.append("العربية")
    if row_data.get('english') and clean_text(row_data['english']):
        languages.append("الانكليزية")
    if row_data.get('french') and clean_text(row_data['french']):
        languages.append("الفرنسية")
    if row_data.get('other_lang') and clean_text(row_data['other_lang']):
        other = clean_text(row_data['other_lang'])
        if other and other not in languages:
            languages.append(other)
    return languages


def parse_secondary_tracks(track_text: str) -> List[str]:
    """Parse allowed secondary tracks from text."""
    if not track_text:
        return []

    tracks = []
    track_text = clean_text(track_text).lower()

    # Map common variations to standard names
    track_mapping = {
        "علوم عامة": "علوم عامة",
        "علوم حياة": "علوم حياة",
        "اقتصاد واجتماع": "اقتصاد واجتماع",
        "اقتصاد": "اقتصاد واجتماع",
        "فلسفة وانسانيات": "فلسفة وانسانيات",
        "فلسفة": "فلسفة وانسانيات",
        "انسانيات": "فلسفة وانسانيات",
        "آداب": "فلسفة وانسانيات",
    }

    for key, value in track_mapping.items():
        if key in track_text and value not in tracks:
            tracks.append(value)

    return tracks


def parse_holland_codes(
    primary: Any, secondary: Any, tertiary: Any, triplet: Any
) -> Dict[str, str]:
    """Build the Holland code block for one major.

    The spreadsheet holds the three codes in separate columns (17-19) *and* a
    combined "الرمز الثلاثي" column (20). The two disagree on seven rows, and
    the combined column is not reliable — for 'هندسة زراعية – هندسة الحدائق' it
    repeats the neighbouring row's value. The individual columns are the ones
    the client validated their expected results against, so they win.

    The one case the individual columns cannot express is a malformed triplet:
    الطب is recorded as S/I/S, which repeats a letter and is not a valid RIASEC
    code, leaving one slot unmatchable. Only there do we fall back to column 20.

    Args:
        primary, secondary, tertiary: The individual code columns (17-19).
        triplet: The combined code column (20), used only as a repair.

    Returns:
        Dict with primary, secondary, tertiary and triplet keys.
    """
    parts = [clean_text(c).upper() for c in (primary, secondary, tertiary)]

    def well_formed(codes):
        return len(set(codes)) == 3 and all(c in VALID_HOLLAND_CODES for c in codes)

    if not well_formed(parts):
        combined = clean_text(triplet).upper().replace(" ", "")
        # Repair ONLY the documented shape: three valid letters with a repeat,
        # as in الطب (S/I/S). Any other malformed row — most importantly a
        # blank tertiary cell — must keep columns 17-19, because column 20
        # disagrees with them on seven rows and is not authoritative.
        all_filled = all(parts) and all(c in VALID_HOLLAND_CODES for c in parts)
        has_repeat = len(set(parts)) < 3
        if all_filled and has_repeat and well_formed(list(combined)):
            print(f"  Repaired malformed Holland code {''.join(parts)!r} -> {combined!r}")
            parts = list(combined)
        else:
            print(f"  WARNING: Holland codes {parts!r} are not well-formed and do not "
                  f"match the repairable shape (column 20 = {combined!r}); "
                  f"keeping columns 17-19.")

    return {
        "primary": parts[0],
        "secondary": parts[1],
        "tertiary": parts[2],
        "triplet": "".join(parts),
    }


def parse_locations(row_data: Dict[str, Any], start_col: int = 22) -> List[Dict[str, str]]:
    """Parse geographic locations from row data."""
    locations = []

    # The Excel has repeating pattern: المحافظة, القضاء, الفرع
    # Starting from column 22 (0-indexed: 21)
    # Pattern repeats up to 10 times

    # Check first location set (columns 22-24 in 1-indexed = 21-23 in 0-indexed)
    gov_col = row_data.get('gov_1', '')
    district_col = row_data.get('district_1', '')
    branch_col = row_data.get('branch_1', '')

    if gov_col or branch_col:
        location = {
            "governorate": clean_text(gov_col) if gov_col else "",
            "district": clean_text(district_col) if district_col else "",
            "branch": clean_text(branch_col) if branch_col else ""
        }
        if location["governorate"] or location["branch"]:
            locations.append(location)

    # Check additional location sets
    for i in range(2, 11):
        gov = row_data.get(f'gov_{i}', '')
        district = row_data.get(f'district_{i}', '')
        branch = row_data.get(f'branch_{i}', '')

        if gov or branch:
            location = {
                "governorate": clean_text(gov) if gov else "",
                "district": clean_text(district) if district else "",
                "branch": clean_text(branch) if branch else ""
            }
            if location["governorate"] or location["branch"]:
                locations.append(location)

    return locations


# Localities that appear in branch addresses but are not district names, so
# they cannot be derived from GOVERNORATES.
_EXTRA_BRANCH_KEYWORDS = {
    "الحدث": "جبل لبنان",
    "البوشرية": "جبل لبنان",
}


def _branch_keyword_map() -> Dict[str, str]:
    """Branch-address keyword -> governorate, derived from GOVERNORATES.

    This used to be a hand-copied dict that omitted nine districts present in
    the config map (بشري، جزين، حاصبيا، مرجعيون، بنت جبيل، راشيا،
    البقاع الغربي، الهرمل، الضنية), so a branch in any of them got no
    governorate and became invisible to filter_by_location.
    """
    keywords = {}
    for gov, districts in GOVERNORATES.items():
        if gov == "الكل":
            continue
        keywords[gov] = gov
        for district in districts:
            keywords[district] = gov
    keywords.update(_EXTRA_BRANCH_KEYWORDS)
    return keywords


def extract_governorate_from_branch(branch_text: str) -> Optional[str]:
    """Infer a governorate from a free-text branch address.

    Matching is most-specific-first: a district or locality name beats a bare
    governorate name, and a longer keyword beats a shorter one. First-match
    ordering used to resolve "الحدث – بيروت" to بيروت, which is the same class
    of bug that put Mount Lebanon campuses into Beirut results at query time.
    """
    if not branch_text:
        return None

    branch_lower = branch_text.lower()
    matches = [(kw, gov) for kw, gov in _branch_keyword_map().items()
               if kw in branch_lower]
    if not matches:
        return None

    # Sort key: districts/localities (not governorate names) first, then the
    # longest keyword.
    matches.sort(key=lambda kv: (kv[0] in GOVERNORATES, -len(kv[0])))
    return matches[0][1]


def process_main_excel() -> List[Dict[str, Any]]:
    """Process the main Excel file and extract majors data."""
    print(f"Processing main Excel file: {MAIN_EXCEL_FILE}")

    wb = load_workbook(MAIN_EXCEL_FILE)
    ws = wb.active

    majors = []

    # Get headers from first row
    headers = []
    for col in range(1, ws.max_column + 1):
        headers.append(ws.cell(row=1, column=col).value or '')

    print(f"Found {ws.max_row - 1} rows of data")

    # Ids must be unique: get_major_by_id returns the first hit, and the
    # locations index is keyed by id, so a collision silently drops a major.
    used_ids = set()

    # Process each row
    for row_num in range(2, ws.max_row + 1):
        # Extract all cell values for this row
        row_values = {}
        for col in range(1, ws.max_column + 1):
            row_values[col] = ws.cell(row=row_num, column=col).value

        # Skip empty rows
        if not row_values.get(5):  # Major name column
            continue

        # Build structured major object
        # Parse ID safely (handle non-numeric values)
        raw_id = row_values.get(1)
        try:
            major_id = int(raw_id) if raw_id else row_num - 1
        except (ValueError, TypeError):
            major_id = row_num - 1

        if major_id in used_ids:
            original = major_id
            major_id = max(used_ids) + 1
            print(f"  Duplicate id {original} on row {row_num}; reassigned to {major_id}.")
            print(f"  WARNING: the supplementary sheet is keyed by the sheet's own id, "
                  f"so any record for id {original} is applied to the FIRST row that "
                  f"claimed it and not to this one. Two majors sharing an id in the "
                  f"source spreadsheet is a data-entry error worth fixing at source.")
        used_ids.add(major_id)

        major = {
            "id": major_id,
            "name_ar": clean_text(row_values.get(5)),
            "name_en": clean_text(row_values.get(6)),
            "name_fr": clean_text(row_values.get(7)),
            "faculty_category": clean_text(row_values.get(2)),
            "faculty_ar": clean_text(row_values.get(3)),
            "degree_level": clean_text(row_values.get(4)),
            "admission_requirements": clean_text(row_values.get(8)),
            "description": clean_text(row_values.get(9)),
            "teaching_languages": parse_languages({
                'arabic': row_values.get(10),
                'english': row_values.get(11),
                'french': row_values.get(12),
                'other_lang': row_values.get(13)
            }),
            "career_opportunities": clean_text(row_values.get(14)),
            "entry_mechanism": clean_text(row_values.get(15)),
            "allowed_tracks": parse_secondary_tracks(clean_text(row_values.get(16))),
            "holland_codes": parse_holland_codes(
                row_values.get(17), row_values.get(18), row_values.get(19), row_values.get(20)
            ),
            "holland_interpretation": clean_text(row_values.get(21)),
            "locations": []
        }

        # Process locations (columns 22 onwards have repeating pattern)
        # Pattern: المحافظة (gov), القضاء (district), الفرع (branch)
        # First set starts at column 22
        location_data = {
            'gov_1': row_values.get(22),
            'district_1': row_values.get(23),
            'branch_1': row_values.get(24),
        }

        # Additional location sets (pattern repeats)
        col_offset = 25
        for i in range(2, 11):
            if col_offset + 2 <= ws.max_column:
                location_data[f'gov_{i}'] = row_values.get(col_offset)
                location_data[f'district_{i}'] = row_values.get(col_offset + 1)
                location_data[f'branch_{i}'] = row_values.get(col_offset + 2)
                col_offset += 3

        major['locations'] = parse_locations(location_data)

        # If no governorate found, try to extract from branch address
        for loc in major['locations']:
            if not loc['governorate'] and loc['branch']:
                extracted_gov = extract_governorate_from_branch(loc['branch'])
                if extracted_gov:
                    loc['governorate'] = extracted_gov

        majors.append(major)

    print(f"Processed {len(majors)} majors")
    return majors


def process_supplementary_excel() -> Dict[int, Dict[str, Any]]:
    """Process supplementary Excel for additional/corrected data."""
    print(f"Processing supplementary Excel file: {SUPPLEMENTARY_EXCEL_FILE}")

    wb = load_workbook(SUPPLEMENTARY_EXCEL_FILE)
    ws = wb.active

    supplements = {}

    for row_num in range(2, ws.max_row + 1):
        row_id = ws.cell(row=row_num, column=1).value
        if not row_id:
            continue

        try:
            row_id = int(row_id)
        except (ValueError, TypeError):
            # The main sheet's id parse is guarded the same way; without this
            # one bad cell aborts the whole regeneration with a bare ValueError.
            print(f"  WARNING: non-numeric supplementary id {row_id!r} on row "
                  f"{row_num}; skipped.")
            continue

        # Extract Holland codes from supplementary file (more reliable)
        supplements[row_id] = {
            "holland_primary": clean_text(ws.cell(row=row_num, column=5).value).upper(),
            "holland_secondary": clean_text(ws.cell(row=row_num, column=6).value).upper(),
            "holland_tertiary": clean_text(ws.cell(row=row_num, column=7).value).upper(),
            "holland_triplet": clean_text(ws.cell(row=row_num, column=8).value).upper(),
            "teaching_lang_code": clean_text(ws.cell(row=row_num, column=11).value),
        }

        # Extract branch locations from supplementary (columns 12-17)
        branches = []
        for col in range(12, 18):
            branch = ws.cell(row=row_num, column=col).value
            if branch:
                branches.append(clean_text(branch))
        supplements[row_id]["branches"] = branches

    print(f"Processed {len(supplements)} supplementary records")
    return supplements


def merge_data(majors: List[Dict], supplements: Dict[int, Dict]) -> List[Dict]:
    """Merge main data with supplementary corrections.

    NOTE: Holland codes from MAIN Excel are authoritative and should NOT be
    overwritten by supplementary data, as the main Excel has verified codes.
    """
    for major in majors:
        major_id = major['id']
        if major_id in supplements:
            supp = supplements[major_id]

            # DO NOT overwrite Holland codes from supplementary file
            # The main Excel has the correct/verified Holland codes
            # Supplementary data may have incorrect codes that don't match

            # Parse teaching language code
            lang_code = supp.get('teaching_lang_code', '')
            if lang_code:
                folded = lang_code.lower()
                languages = []
                if 'ara' in folded:
                    languages.append("العربية")
                if 'eng' in folded:
                    languages.append("الانكليزية")
                if 'fre' in folded or 'fra' in folded:
                    languages.append("الفرنسية")
                if languages:
                    # Replace, not merge: the supplementary sheet exists to
                    # correct the main one, so "Eng" there means English only
                    # even when the main sheet listed three languages. (A
                    # free-text "other" language from column 13 is lost in that
                    # case; that is the documented trade-off, not an oversight.)
                    major['teaching_languages'] = languages
                else:
                    print(f"  WARNING: unrecognised language code {lang_code!r} for "
                          f"id {major_id}; keeping the main sheet's languages.")

            # Add branch information if missing
            if supp.get('branches') and not major['locations']:
                for branch in supp['branches']:
                    if branch:
                        gov = extract_governorate_from_branch(branch)
                        major['locations'].append({
                            "governorate": gov or "",
                            "district": "",
                            "branch": branch
                        })

    return majors


def generate_locations_data(majors: List[Dict]) -> Dict[str, Any]:
    """Generate locations reference data."""
    locations = {
        "governorates": GOVERNORATES,
        "major_locations": {}
    }

    # Map each major to its available locations. Sorted because set iteration
    # order varies between runs, which would otherwise make every regeneration
    # produce a large spurious diff in a version-controlled data file.
    for major in majors:
        govs = {loc['governorate'] for loc in major['locations'] if loc['governorate']}
        locations["major_locations"][major['id']] = sorted(govs)

    return locations


def generate_holland_mapping() -> Dict[str, Any]:
    """Generate Holland RIASEC mapping data."""
    return {
        "codes": HOLLAND_CODES,
        "code_list": ["R", "I", "A", "S", "E", "C"],
        "dropdown_options": [
            {"code": "R", "label": "R - الواقعي (Realistic)"},
            {"code": "I", "label": "I - الباحث (Investigative)"},
            {"code": "A", "label": "A - الفني (Artistic)"},
            {"code": "S", "label": "S - الاجتماعي (Social)"},
            {"code": "E", "label": "E - المغامر (Enterprising)"},
            {"code": "C", "label": "C - التقليدي (Conventional)"},
        ]
    }


def save_json(data: Any, filepath: Path) -> None:
    """Save data to JSON file with proper Arabic encoding."""
    filepath.parent.mkdir(parents=True, exist_ok=True)
    with open(filepath, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    print(f"Saved: {filepath}")


def main():
    """Main processing function."""
    print("=" * 60)
    print("Kalim Data Processor")
    print("=" * 60)

    # Ensure output directory exists
    PROCESSED_DATA_DIR.mkdir(parents=True, exist_ok=True)

    # Process Excel files
    majors = process_main_excel()
    supplements = process_supplementary_excel()

    # Merge data
    majors = merge_data(majors, supplements)

    # Generate additional data files
    locations = generate_locations_data(majors)
    holland_mapping = generate_holland_mapping()

    # Save to JSON files
    save_json({"majors": majors}, MAJORS_JSON_FILE)
    save_json(locations, LOCATIONS_JSON_FILE)
    save_json(holland_mapping, HOLLAND_MAPPING_FILE)

    print("=" * 60)
    print("Processing complete!")
    print(f"Total majors: {len(majors)}")
    print(f"Output files:")
    print(f"  - {MAJORS_JSON_FILE}")
    print(f"  - {LOCATIONS_JSON_FILE}")
    print(f"  - {HOLLAND_MAPPING_FILE}")
    print("=" * 60)

    return majors


if __name__ == "__main__":
    main()