#
#  Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.
#

"""
Resume parsing module (aligned with SmartResume Pipeline architecture optimization)

Key optimizations (ref: arXiv:2510.09722):
    1. PDF text fusion: metadata + OCR dual-path extraction and fusion
    2. Layout-aware reconstruction: YOLOv10 layout segmentation + hierarchical sorting + line indexing
    3. Parallel task decomposition: basic info / work experience / education - 3-way parallel LLM extraction
    4. Index pointer mechanism: LLM returns line number ranges instead of generating full text, reducing hallucination
    5. Four-stage post-processing: source text re-extraction, domain normalization, context deduplication, source text validation

Compatibility:
    - chunk(filename, binary, callback, **kwargs) signature remains unchanged
    - Compatible with FACTORY[ParserType.RESUME.value] in task_executor.py
"""

import json
import re
import random
import datetime
import unicodedata
import concurrent.futures
from io import BytesIO
from typing import Optional
import numpy as np

# tiktoken for long random string filtering (ref: SmartResume should_remove strategy)
try:
    import tiktoken
    _tiktoken_encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")
except ImportError:
    _tiktoken_encoding = None

# Long random string pattern: 40+ char alphanumeric mixed strings (hash, token, tracking ID, etc.)
_LONG_RANDOM_PATTERN = re.compile(r'[a-zA-Z0-9\-~_]{40,}')

import logging as logger
from rag.nlp import rag_tokenizer
from deepdoc.parser.utils import get_text

# json_repair for fixing malformed JSON from LLM responses (ref: SmartResume fault-tolerance strategy)
try:
    import json_repair
except ImportError:
    json_repair = None

# YOLOv10 layout detector (lazy initialization to avoid loading model when unused)
_layout_recognizer = None


def _get_layout_recognizer():
    """
    Get YOLOv10 layout detector singleton (lazy loading)

    Uses the existing deepdoc LayoutRecognizer based on layout.onnx model.

    Returns:
        LayoutRecognizer instance, or None if loading fails
    """
    global _layout_recognizer
    if _layout_recognizer is None:
        try:
            from deepdoc.vision import LayoutRecognizer
            _layout_recognizer = LayoutRecognizer("layout")
            logger.info("YOLOv10 layout detector loaded successfully")
        except Exception as e:
            logger.warning(f"YOLOv10 layout detector loading failed, falling back to heuristic sorting: {e}")
            _layout_recognizer = False  # Mark as failed to avoid repeated attempts
    return _layout_recognizer if _layout_recognizer is not False else None

# ==================== Constants ====================

# Fields forbidden from being used as select fields in resume
FORBIDDEN_SELECT_FIELDS = [
    "name_pinyin_kwd", "edu_first_fea_kwd", "degree_kwd",
    "sch_rank_kwd", "edu_fea_kwd"
]

# Field name to description mapping (bilingual versions for chunk construction)
FIELD_MAP_ZH = {
    "name_kwd": "姓名/名字",
    "name_pinyin_kwd": "姓名拼音/名字拼音",
    "gender_kwd": "性别（男，女）",
    "age_int": "年龄/岁/年纪",
    "phone_kwd": "电话/手机/微信",
    "email_tks": "email/e-mail/邮箱",
    "position_name_tks": "职位/职能/岗位/职责",
    "expect_city_names_tks": "期望城市",
    "work_exp_flt": "工作年限/工作年份/N年经验/毕业了多少年",
    "corporation_name_tks": "最近就职(上班)的公司/上一家公司",
    "first_school_name_tks": "第一学历毕业学校",
    "first_degree_kwd": "第一学历",
    "highest_degree_kwd": "最高学历",
    "first_major_tks": "第一学历专业",
    "edu_first_fea_kwd": "第一学历标签",
    "degree_kwd": "过往学历",
    "major_tks": "学过的专业/过往专业",
    "school_name_tks": "学校/毕业院校",
    "sch_rank_kwd": "学校标签",
    "edu_fea_kwd": "教育标签",
    "corp_nm_tks": "就职过的公司/之前的公司/上过班的公司",
    "edu_end_int": "毕业年份",
    "industry_name_tks": "所在行业",
    "birth_dt": "生日/出生年份",
    "expect_position_name_tks": "期望职位/期望职能/期望岗位",
    "skill_tks": "技能/技术栈/编程语言/框架/工具",
    "language_tks": "语言能力/外语水平",
    "certificate_tks": "证书/资质/认证",
    "project_tks": "项目经验/项目名称",
    "work_desc_tks": "工作职责/工作描述",
    "project_desc_tks": "项目描述/项目职责",
    "self_evaluation_tks": "自我评价/个人优势/个人总结",
}

FIELD_MAP_EN = {
    "name_kwd": "Name",
    "name_pinyin_kwd": "Name Pinyin",
    "gender_kwd": "Gender (Male, Female)",
    "age_int": "Age",
    "phone_kwd": "Phone/Mobile/WeChat",
    "email_tks": "Email",
    "position_name_tks": "Position/Title/Role",
    "expect_city_names_tks": "Preferred City",
    "work_exp_flt": "Years of Experience",
    "corporation_name_tks": "Most Recent Company",
    "first_school_name_tks": "First Degree School",
    "first_degree_kwd": "First Degree",
    "highest_degree_kwd": "Highest Degree",
    "first_major_tks": "First Degree Major",
    "edu_first_fea_kwd": "First Degree Tag",
    "degree_kwd": "Past Degrees",
    "major_tks": "Past Majors",
    "school_name_tks": "School/University",
    "sch_rank_kwd": "School Tag",
    "edu_fea_kwd": "Education Tag",
    "corp_nm_tks": "Past Companies",
    "edu_end_int": "Graduation Year",
    "industry_name_tks": "Industry",
    "birth_dt": "Date of Birth",
    "expect_position_name_tks": "Preferred Position/Role",
    "skill_tks": "Skills/Tech Stack/Languages/Frameworks/Tools",
    "language_tks": "Language Proficiency",
    "certificate_tks": "Certificates/Qualifications",
    "project_tks": "Project Experience/Project Name",
    "work_desc_tks": "Job Responsibilities/Description",
    "project_desc_tks": "Project Description/Responsibilities",
    "self_evaluation_tks": "Self-Evaluation/Personal Strengths/Summary",
}


def _is_english(lang: str | None) -> bool:
    """Determine if the language parameter indicates English."""
    if not isinstance(lang, str):
        return False
    return lang.strip().lower() in ("english", "en")


def get_field_map(lang: str) -> dict:
    """Get the corresponding field mapping based on language parameter"""
    return FIELD_MAP_EN if _is_english(lang) else FIELD_MAP_ZH


# Backward compatible: default to Chinese version
FIELD_MAP = FIELD_MAP_ZH


# ==================== Parallel LLM Extraction Prompt Templates ====================
# Ref: SmartResume task decomposition strategy, splitting extraction into independent subtasks
# Each prompt ends with /no_think marker to suppress reasoning model's thinking output
# Prompts loaded from md files under rag/prompts/, supporting bilingual versions

from rag.prompts.template import load_prompt


def _load_resume_prompt(name: str, lang: str) -> str:
    """Load the corresponding version of resume prompt template based on language parameter

    Args:
        name: Prompt name (without language suffix), e.g. "resume_system"
        lang: Language parameter, e.g. "Chinese" or "English"
    Returns:
        Prompt template string
    """
    suffix = "_en" if _is_english(lang) else ""
    return load_prompt(f"{name}{suffix}")


def get_system_prompt(lang: str) -> str:
    """Get system prompt"""
    return _load_resume_prompt("resume_system", lang)


def get_basic_info_prompt(lang: str) -> str:
    """Get basic info extraction prompt"""
    return _load_resume_prompt("resume_basic_info", lang)


def get_work_exp_prompt(lang: str) -> str:
    """Get work experience extraction prompt"""
    return _load_resume_prompt("resume_work_exp", lang)


def get_education_prompt(lang: str) -> str:
    """Get education background extraction prompt"""
    return _load_resume_prompt("resume_education", lang)


def get_project_exp_prompt(lang: str) -> str:
    """Get project experience extraction prompt"""
    return _load_resume_prompt("resume_project_exp", lang)


# Backward compatible: default Chinese version constants (for possible external direct references)
SYSTEM_PROMPT = load_prompt("resume_system")
BASIC_INFO_PROMPT = load_prompt("resume_basic_info")
WORK_EXP_PROMPT = load_prompt("resume_work_exp")
EDUCATION_PROMPT = load_prompt("resume_education")
PROJECT_EXP_PROMPT = load_prompt("resume_project_exp")

# LLM call max retry count (ref: SmartResume retry strategy)
_LLM_MAX_RETRIES = 2


def _normalize_whitespace(text: str) -> str:
    """
    Unicode whitespace normalization (ref: SmartResume _clean_text_content)

    Replaces various Unicode spaces (\u00A0 non-breaking space, \u3000 fullwidth space,
    \u2000-\u200A various width spaces, etc.) with regular spaces,
    then applies NFKC normalization (fullwidth to halfwidth) and merges consecutive spaces.

    Args:
        text: Original text
    Returns:
        Normalized text
    """
    if not text:
        return ""
    # NFKC normalization (fullwidth to halfwidth, etc.)
    text = unicodedata.normalize('NFKC', text)
    # Unify various Unicode spaces to regular space
    text = re.sub(
        r'[\u0020\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\u00A7]',
        ' ', text
    )
    # Merge consecutive spaces
    text = re.sub(r' {2,}', ' ', text)
    return text.strip()


def _should_remove_random_str(match: re.Match) -> bool:
    """
    Determine if a matched long string is a meaningless random string (ref: SmartResume should_remove)

    Uses tiktoken encoding to judge: if token count exceeds 50% of original char count,
    it indicates a meaningless random string (hash, token, tracking ID, etc.) that should be removed.
    Normal English words have high token encoding efficiency, with token count far less than char count.

    Args:
        match: Regex match object
    Returns:
        True means it should be removed
    """
    if _tiktoken_encoding is None:
        # When tiktoken is unavailable, use simple heuristic: case/digit alternation frequency
        s = match.group(0)
        changes = sum(
            1 for i in range(1, len(s))
            if s[i].isdigit() != s[i-1].isdigit()
            or (s[i].isalpha() and s[i-1].isalpha() and s[i].isupper() != s[i-1].isupper())
        )
        return changes / len(s) > 0.3
    encoded = _tiktoken_encoding.encode(match.group(0))
    return len(encoded) > len(match.group(0)) * 0.5


def _clean_line_content(text: str) -> str:
    """
    Clean single line text content (Unicode normalization + long random string filtering)

    Args:
        text: Original line text
    Returns:
        Cleaned text
    """
    if not text:
        return ""
    # Unicode whitespace normalization
    text = _normalize_whitespace(text)
    # Filter long random strings (hash, token and other meaningless content)
    text = _LONG_RANDOM_PATTERN.sub(
        lambda m: '' if _should_remove_random_str(m) else m.group(0),
        text
    )
    # Clean up extra spaces after filtering
    text = re.sub(r' {2,}', ' ', text).strip()
    return text


# ==================== Phase 1: PDF Text Fusion and Layout Reconstruction ====================




def _is_noise_char(obj: dict) -> bool:
    """
    Determine if a PDF character object is a decorative layer noise character

    Uses a "body text whitelist" strategy instead of enumerating noise features,
    to handle noise patterns from different resume templates:

    Two reliable features of body text characters (either one means body text):
    1. Embedded font: Font name format is XXXXXX+FontName (contains '+'),
       indicating the font is embedded in the PDF, chosen by the document author
    2. Structure tag: Has PDF Tagged Structure tags (e.g., Span, P, NonStruct, etc.),
       indicating the character belongs to the document's semantic structure tree

    Common features of noise characters:
    - Uses system fonts (e.g., Helvetica, Arial), font name doesn't contain '+'
    - No structure tags (tag is None or non-semantic tags like 'OC')
    - Common in resume template background decorations, watermarks, tracking marks

    Args:
        obj: pdfplumber character/text object dictionary
    Returns:
        True means it's a noise character that should be filtered
    """
    # Whitelist condition 1: Embedded font (font name contains '+' prefix)
    fontname = obj.get("fontname", "")
    if "+" in fontname:
        return False  # Embedded font = body content

    # Whitelist condition 2: Has PDF structure tag
    tag = obj.get("tag")
    if tag in ("Span", "NonStruct", "P", "H1", "H2", "H3", "H4", "H5", "H6",
               "TD", "TH", "LI", "L", "Table", "TR", "Figure", "Caption"):
        return False  # Has semantic structure tag = body content

    # Doesn't meet any whitelist condition, treat as noise
    return True



def _extract_metadata_text(binary: bytes) -> list[dict]:
    """
    Extract text blocks from PDF metadata (with coordinate info)

    Strategy:
    1. Use whitelist strategy to filter decorative layer noise chars (embedded font or structure tag = body text)
    2. Safe fallback: if filtered chars are less than 30% of original, skip filtering to avoid false positives
    3. Use extract_words for word-level extraction (with real coordinates)
    4. Aggregate adjacent words into line-level text blocks by Y coordinate
    5. Additionally extract table content (many resumes use table layouts)

    Args:
        binary: PDF file binary content
    Returns:
        List of text blocks, each containing text, x0, top, x1, bottom, page fields
    """
    try:
        import pdfplumber
        blocks = []
        with pdfplumber.open(BytesIO(binary)) as pdf:
            for page_idx, page in enumerate(pdf.pages):
                page_width = page.width or 600

                # Filter decorative layer noise chars (whitelist strategy based on embedded font + structure tag)
                # Safe fallback: if filtered chars are less than 30% of original, the PDF's body text
                # may use non-embedded fonts without structure tags, skip filtering to avoid false positives
                try:
                    original_char_count = len(page.chars)
                    filtered_page = page.filter(
                        lambda obj: not _is_noise_char(obj)
                    )
                    filtered_char_count = len(filtered_page.chars)
                    if original_char_count > 0 and filtered_char_count < original_char_count * 0.3:
                        # Filtered out over 70% of chars, likely false positives, fall back to original page
                        filtered_page = page
                except Exception:
                    filtered_page = page

                # Use extract_words for extraction (with real coordinates)
                words = []
                try:
                    words = filtered_page.extract_words(
                        keep_blank_chars=False, use_text_flow=True
                    )
                except Exception:
                    pass

                if words:
                    # Aggregate adjacent words into line-level text blocks by Y coordinate
                    # Words on the same line: top coordinate difference within threshold
                    line_threshold = 5  # Y coordinate difference threshold (unit: PDF points)
                    current_line_words = [words[0]]

                    def _flush_line(line_words):
                        """Merge words in a line into a single text block"""
                        # Sort by x0 to ensure left-to-right order
                        line_words.sort(key=lambda w: float(w.get("x0", 0)))
                        texts = []
                        for w in line_words:
                            texts.append(w.get("text", ""))
                        merged_text = " ".join(texts)
                        if not merged_text.strip():
                            return None
                        return {
                            "text": merged_text.strip(),
                            "x0": float(min(w.get("x0", 0) for w in line_words)),
                            "top": float(min(w.get("top", 0) for w in line_words)),
                            "x1": float(max(w.get("x1", 0) for w in line_words)),
                            "bottom": float(max(w.get("bottom", 0) for w in line_words)),
                            "page": page_idx,
                        }

                    for w in words[1:]:
                        w_top = float(w.get("top", 0))
                        cur_top = float(current_line_words[0].get("top", 0))
                        if abs(w_top - cur_top) <= line_threshold:
                            current_line_words.append(w)
                        else:
                            block = _flush_line(current_line_words)
                            if block:
                                blocks.append(block)
                            current_line_words = [w]

                    # Process the last line
                    if current_line_words:
                        block = _flush_line(current_line_words)
                        if block:
                            blocks.append(block)
                else:
                    # Fall back to extract_text when extract_words fails
                    page_text = None
                    try:
                        page_text = page.extract_text()
                    except Exception:
                        pass
                    if page_text and page_text.strip():
                        raw_lines = page_text.split("\n")
                        line_height = 16
                        for i, line in enumerate(raw_lines):
                            cleaned = line.strip()
                            if not cleaned:
                                continue
                            blocks.append({
                                "text": cleaned,
                                "x0": 0,
                                "top": i * line_height,
                                "x1": page_width,
                                "bottom": i * line_height + line_height - 2,
                                "page": page_idx,
                            })

                # Extract table content from the page
                # Many resumes use table layouts (e.g., personal info section), extract_words may miss table structure
                try:
                    tables = page.extract_tables()
                    if tables:
                        page_blocks = [b for b in blocks if b["page"] == page_idx]
                        max_top = max((b["top"] for b in page_blocks), default=0) + 20
                        row_height = 16

                        for table in tables:
                            for row in table:
                                if not row:
                                    continue
                                cells = [str(c).strip() for c in row if c and str(c).strip()]
                                if not cells:
                                    continue
                                row_text = " | ".join(cells)
                                # Dedup: check if table content was already extracted by extract_words
                                is_dup = False
                                for pb in page_blocks:
                                    if all(c in pb["text"] for c in cells[:2]):
                                        is_dup = True
                                        break
                                if is_dup:
                                    continue
                                blocks.append({
                                    "text": row_text,
                                    "x0": 0,
                                    "top": max_top,
                                    "x1": page_width,
                                    "bottom": max_top + row_height - 2,
                                    "page": page_idx,
                                })
                                max_top += row_height
                except Exception as e:
                    logger.debug(f"PDF table extraction skipped (page {page_idx}): {e}")
        return blocks
    except Exception as e:
        logger.warning(f"PDF metadata extraction failed: {e}")
        return []

def _extract_ocr_text(binary: bytes, meta_blocks: list[dict] | None = None) -> list[dict]:
    """
    Extract OCR text blocks using blackout strategy (with coordinate info).

    Strategy (ref: SmartResume):
    1. Render PDF pages to images
    2. Black out regions already extracted by metadata
    3. Run OCR on the blacked-out image, only recognizing content metadata missed
    4. Eliminates duplication at source, no IoU dedup needed downstream

    Args:
        binary: PDF file binary content
        meta_blocks: Text blocks from metadata extraction, used to black out existing text regions
    Returns:
        List of text blocks, each containing text, x0, top, x1, bottom, page fields
    """
    if meta_blocks is None:
        meta_blocks = []
    try:
        import pdfplumber
        from deepdoc.vision.ocr import OCR
        import numpy as np

        ocr = OCR()
        blocks = []

        with pdfplumber.open(BytesIO(binary)) as pdf:
            for page_idx, page in enumerate(pdf.pages):
                # Render page to image (resolution=216 = 3x scale, since PDF default is 72 DPI)
                img = page.to_image(resolution=216)
                page_img = np.array(img.annotated)

                # Scale factor from PDF coordinates to image coordinates
                pdf_to_img_scale = 216.0 / 72.0  # = 3.0

                # Black out metadata-extracted text regions before OCR
                page_meta_blocks = [b for b in meta_blocks if b.get("page") == page_idx]
                if page_meta_blocks:
                    page_img = _blackout_text_regions(page_img, meta_blocks, page_idx, pdf_to_img_scale)

                ocr_result = ocr(page_img)
                if not ocr_result:
                    continue
                for box_info in ocr_result:
                    if isinstance(box_info, (list, tuple)) and len(box_info) >= 2:
                        coords = box_info[0]  # Coordinate points
                        text_info = box_info[1]
                        text = text_info[0] if isinstance(text_info, (list, tuple)) else str(text_info)
                        if text.strip() and isinstance(coords, (list, tuple)) and len(coords) >= 4:
                            # Extract bounding box from four corner points
                            xs = [p[0] for p in coords if isinstance(p, (list, tuple))]
                            ys = [p[1] for p in coords if isinstance(p, (list, tuple))]
                            if xs and ys:
                                blocks.append({
                                    "text": text.strip(),
                                    "x0": min(xs), "top": min(ys),
                                    "x1": max(xs), "bottom": max(ys),
                                    "page": page_idx,
                                })
        return blocks
    except Exception as e:
        logger.warning(f"OCR extraction failed: {e}")
        return []


def _fuse_text_blocks(meta_blocks: list[dict], ocr_blocks: list[dict]) -> list[dict]:
    """
    Fuse PDF metadata text and OCR text (blackout strategy version).

    Since the OCR phase already blacks out metadata-extracted regions, OCR only recognizes
    content that metadata missed. Therefore this function only needs to:
    1. Filter out garbled blocks from metadata
    2. Directly merge valid metadata blocks and OCR blocks (no IoU dedup needed)

    Args:
        meta_blocks: Text blocks from metadata extraction
        ocr_blocks: Text blocks from OCR extraction (already deduplicated via blackout strategy)
    Returns:
        Fused text block list
    """
    if not ocr_blocks:
        return meta_blocks
    if not meta_blocks:
        return ocr_blocks

    # Filter out garbled blocks from metadata
    valid_meta = []
    garbled_count = 0
    for b in meta_blocks:
        if _is_valid_line(b.get("text", "")):
            valid_meta.append(b)
        else:
            garbled_count += 1

    if garbled_count:
        logger.info(f"Detected {garbled_count} garbled blocks in metadata, filtered out")

    # Under blackout strategy, OCR won't re-recognize existing text, just merge directly
    fused = valid_meta + ocr_blocks
    return fused




def _layout_aware_reorder(blocks: list[dict]) -> list[dict]:
    """
    Layout-aware hierarchical sorting (ref: SmartResume Hierarchical Re-ordering)

    Two-level sorting strategy:
    1. Inter-segment sorting: first by page number, then by Y coordinate (top to bottom), same row by X coordinate (left to right)
    2. Intra-segment sorting: within each logical segment, sort by reading order

    For multi-column resumes, detect column positions by clustering X coordinates,
    then sort by column order.

    Args:
        blocks: Text block list (with coordinate info)
    Returns:
        Sorted text block list
    """
    if not blocks:
        return blocks

    # Group by page
    pages = {}
    for b in blocks:
        pg = b.get("page", 0)
        pages.setdefault(pg, []).append(b)

    sorted_blocks = []
    for pg in sorted(pages.keys()):
        page_blocks = pages[pg]

        # Detect multi-column layout: by X coordinate median
        if len(page_blocks) > 5:
            x_centers = [(b["x0"] + b["x1"]) / 2 for b in page_blocks]
            x_min, x_max = min(x_centers), max(x_centers)
            page_width = x_max - x_min if x_max > x_min else 1

            # Simple two-column detection: if text blocks are clearly distributed on left and right sides
            mid_x = (x_min + x_max) / 2
            left_count = sum(1 for x in x_centers if x < mid_x - page_width * 0.1)
            right_count = sum(1 for x in x_centers if x > mid_x + page_width * 0.1)

            if left_count > 3 and right_count > 3:
                # Multi-column layout: left column first then right column, each column top to bottom
                left_blocks = [b for b in page_blocks if (b["x0"] + b["x1"]) / 2 < mid_x]
                right_blocks = [b for b in page_blocks if (b["x0"] + b["x1"]) / 2 >= mid_x]
                left_blocks.sort(key=lambda b: (b["top"], b["x0"]))
                right_blocks.sort(key=lambda b: (b["top"], b["x0"]))
                sorted_blocks.extend(left_blocks)
                sorted_blocks.extend(right_blocks)
                continue

        # Single-column layout: top to bottom, same row left to right
        page_blocks.sort(key=lambda b: (b["top"], b["x0"]))
        sorted_blocks.extend(page_blocks)

    return sorted_blocks


def _build_indexed_text(blocks: list[dict]) -> tuple[str, list[str], list[dict]]:
    """

    Build indexed text with line numbers (ref: SmartResume Indexed Linearization)

    Merges sorted text blocks into lines and adds a unique index number to each line.
    Includes garbled line filtering logic and field label split repair.
    Also preserves coordinate info for each line, used for writing position_int etc. to chunks.

    Args:
        blocks: Sorted text block list
    Returns:
        (indexed_text, lines, line_positions) tuple:
        - indexed_text: Text string with line numbers
        - lines: Original line text list (without line numbers)
        - line_positions: Coordinate info for each line, format:
    """
    if not blocks:
        return "", [], []

    raw_lines = []
    raw_positions = []
    current_line_parts = []
    current_line_blocks = []
    current_top = blocks[0].get("top", 0)
    current_layoutno = blocks[0].get("layoutno", "")
    threshold = 10

    def _merge_line_position(line_blocks: list[dict]) -> dict:
        """Merge coordinates of all blocks in a line into outer bounding rectangle"""
        return {
            "page": line_blocks[0].get("page", 0),
            "x0": min(b.get("x0", 0) for b in line_blocks),
            "x1": max(b.get("x1", 0) for b in line_blocks),
            "top": min(b.get("top", 0) for b in line_blocks),
            "bottom": max(b.get("bottom", 0) for b in line_blocks),
        }

    for b in blocks:
        b_layoutno = b.get("layoutno", "")
        y_changed = abs(b.get("top", 0) - current_top) > threshold
        layout_changed = b_layoutno != current_layoutno and current_layoutno and b_layoutno
        if (y_changed or layout_changed) and current_line_parts:
            raw_lines.append(" ".join(current_line_parts))
            raw_positions.append(_merge_line_position(current_line_blocks))
            current_line_parts = []
            current_line_blocks = []
            current_top = b.get("top", 0)
            current_layoutno = b_layoutno
        current_line_parts.append(b["text"])
        current_line_blocks.append(b)

    if current_line_parts:
        raw_lines.append(" ".join(current_line_parts))
        raw_positions.append(_merge_line_position(current_line_blocks))

    # Filter empty and garbled lines (sync filter coordinates)
    lines = []
    line_positions = []
    for line, pos in zip(raw_lines, raw_positions):
        # Unicode normalization + long random string filtering (ref: SmartResume _clean_text_content)
        line = _clean_line_content(line)
        if not line:
            continue
        # Garbled detection: skip if valid chars (Chinese/ASCII letters/digits/common punctuation) ratio is too low
        if not _is_valid_line(line):
            continue
        lines.append(line)
        line_positions.append(pos)

    # Fix field label split issues
    # Coordinates are not affected, keep original positions
    lines = _fix_split_labels(lines)

    # Build indexed text with line numbers
    indexed_parts = [f"[{i}]: {line}" for i, line in enumerate(lines)]
    indexed_text = "\n".join(indexed_parts)

    return indexed_text, lines, line_positions

def _is_valid_line(line: str) -> bool:
    """
    Check if a text line is valid content (not garbled)

    Multi-dimensional detection:
    1. Valid character ratio (Chinese, ASCII alphanumeric, common punctuation)
    2. Single-character spacing anomaly detection (PDF custom font mapping causing "O U W Z_W V 2" pattern)
    3. Consecutive meaningless alphanumeric sequence detection

    Args:
        line: Text line to check
    Returns:
        True means valid line, False means garbled line
    """
    if len(line) <= 3:
        # Short lines may be valid content like names, keep them
        return True

    cid_count = len(re.findall(r'\(cid:\d+\)', line))
    if cid_count >= 3:
        return False
    # Valid characters: Chinese (incl. extension), ASCII alphanumeric, common punctuation and spaces, fullwidth chars, CJK punctuation
    valid_chars = re.findall(
        r'[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff'
        r'a-zA-Z0-9\s@.,:;!?()（）【】\-_/\\|·•'
        r'、，。：；！？\u201c\u201d\u2018\u2019《》'
        r'\uff01-\uff5e'
        r'\u3000-\u303f'
        r'#%&+=~`\u00b7\u2022\u2013\u2014'
        r']',
        line
    )
    ratio = len(valid_chars) / len(line) if len(line) > 0 else 0
    if ratio < 0.5:
        return False

    # Detect PDF custom font mapping causing single-character spacing anomaly pattern
    # Feature: lots of "single letter space single letter space" sequences, e.g. "O U W Z_W V 2 X 3"
    # Stats: ratio of space-separated single chars among non-space chars
    spaced_singles = re.findall(r'(?:^|\s)([a-zA-Z0-9])(?:\s|$)', line)
    non_space_len = len(line.replace(" ", ""))
    if non_space_len > 5 and len(spaced_singles) > 0:
        # If ratio of space-separated single chars to non-space chars is too high, classify as garbled
        single_ratio = len(spaced_singles) / non_space_len
        if single_ratio > 0.3:
            return False

    # Detect consecutive meaningless mixed-case alphanumeric sequences (e.g. "UJqZX9V2")
    # Normal English words don't have such frequent case alternation patterns
    garbled_seqs = re.findall(r'[a-zA-Z0-9]{4,}', line.replace(" ", ""))
    if garbled_seqs:
        garbled_count = 0
        for seq in garbled_seqs:
            # Count case alternations
            case_changes = sum(
                1 for i in range(1, len(seq))
                if (seq[i].isupper() != seq[i-1].isupper() and seq[i].isalpha() and seq[i-1].isalpha())
                or (seq[i].isdigit() != seq[i-1].isdigit())
            )
            # Too high alternation frequency = garbled sequence (normal words like "Spring" have only 1 alternation)
            if len(seq) >= 4 and case_changes / len(seq) > 0.5:
                garbled_count += 1
        # If garbled sequence ratio is too high
        if len(garbled_seqs) > 0 and garbled_count / len(garbled_seqs) > 0.4:
            return False

    return True


def _fix_split_labels(lines: list[str]) -> list[str]:
    """
    Fix field label split issues

    Some PDF layouts split field labels across line start/end, e.g.:
    - "名：陈晓俐 姓" -> should be fixed to "姓名：陈晓俐"
    - "别：男 性" -> should be fixed to "性别：男"

    Args:
        lines: Original line text list
    Returns:
        Fixed line text list
    """
    # Common split field label patterns: (line-end part, line-start part) -> full label
    split_patterns = {
        ("姓", "名"): "姓名",
        ("性", "别"): "性别",
        ("年", "龄"): "年龄",
        ("电", "话"): "电话",
        ("邮", "箱"): "邮箱",
        ("学", "历"): "学历",
        ("专", "业"): "专业",
        ("地", "址"): "地址",
        ("籍", "贯"): "籍贯",
        ("民", "族"): "民族",
    }

    fixed = []
    for line in lines:
        # Detect in-line split patterns: "X：content Y" where (Y, X) is a split pair
        for (suffix_char, prefix_char), full_label in split_patterns.items():
            # Pattern: "prefix_char：content suffix_char" (first half at line start, second half at line end)
            pattern = rf'^({re.escape(prefix_char)})\s*[:：]\s*(.+?)\s+{re.escape(suffix_char)}\s*$'
            m = re.match(pattern, line)
            if m:
                content = m.group(2).strip()
                line = f"{full_label}：{content}"
                break
            # Pattern: "suffix_char content prefix_char：" (second half at line start, first half at line end)
            pattern2 = rf'^{re.escape(suffix_char)}\s*[:：]?\s*(.+?)\s+{re.escape(prefix_char)}\s*$'
            m2 = re.match(pattern2, line)
            if m2:
                content = m2.group(1).strip()
                line = f"{full_label}：{content}"
                break
        fixed.append(line)
    return fixed





def extract_text(filename: str, binary: bytes) -> tuple[str, list[str], list[dict]]:
    """
    Extract text content based on file type (Pipeline Phase 1).

    PDF files use dual-path fusion + layout reconstruction + line indexing.
    Other formats fall back to simple text extraction.

    Args:
        filename: File name
        binary: File binary content
    Returns:
        (indexed_text, lines, line_positions) tuple:
        - indexed_text: Text with line number indices
        - lines: List of original line texts
        - line_positions: List of per-line coordinate info (empty list for non-PDF formats)
    """
    fname_lower = filename.lower()

    try:
        if fname_lower.endswith(".pdf"):
            # Dual-path extraction
            meta_blocks = _extract_metadata_text(binary)
            ocr_blocks = []

            # Determine whether OCR supplementation is needed:
            # 1. Metadata text too short (< 100 chars)
            # 2. High garbled text ratio in metadata (caused by custom font mapping)
            meta_text_len = sum(len(b["text"]) for b in meta_blocks)
            need_ocr = False

            if meta_text_len < 100:
                logger.info("PDF metadata text too short, enabling OCR supplementation")
                need_ocr = True
            else:
                # Check metadata text quality: calculate valid line ratio
                # If many lines are judged as garbled by _is_valid_line, the PDF font mapping has issues
                valid_line_count = 0
                total_line_count = 0
                for b in meta_blocks:
                    text = b.get("text", "").strip()
                    if not text:
                        continue
                    total_line_count += 1
                    if _is_valid_line(text):
                        valid_line_count += 1
                if total_line_count > 0:
                    valid_ratio = valid_line_count / total_line_count
                    if valid_ratio < 0.6:
                        logger.info(
                            f"PDF metadata text quality low (valid line ratio {valid_ratio:.1%}), enabling OCR supplementation"
                        )
                        need_ocr = True

            if need_ocr:
                # Blackout strategy: black out metadata-extracted regions before OCR
                ocr_blocks = _extract_ocr_text(binary, meta_blocks=meta_blocks)

            # Text fusion
            fused_blocks = _fuse_text_blocks(meta_blocks, ocr_blocks)

            # Layout-aware sorting (prefer YOLOv10 layout detection, fall back to heuristic on failure)
            sorted_blocks = _layout_detect_reorder(fused_blocks, binary)

            # Build line-indexed text (with coordinate info)
            return _build_indexed_text(sorted_blocks)

        elif fname_lower.endswith(".docx"):
            from docx import Document
            doc = Document(BytesIO(binary))
            lines = [p.text.strip() for p in doc.paragraphs if p.text.strip()]

            # Extract table content from DOCX
            # Reference: table handling in naive.py Docx class
            # Many resumes use table layouts for personal info; iterating only paragraphs would miss this content
            for table in doc.tables:
                for row in table.rows:
                    cells = []
                    for cell in row.cells:
                        cell_text = cell.text.strip()
                        if cell_text:
                            cells.append(cell_text)
                    if not cells:
                        continue
                    row_text = " | ".join(cells)
                    # Deduplicate: skip if this row text already exists in lines
                    if row_text not in lines:
                        lines.append(row_text)

            indexed = "\n".join(f"[{i}]: {line}" for i, line in enumerate(lines))
            # DOCX has no coordinate info, return empty list
            return indexed, lines, []

        else:
            text = get_text(filename, binary)
            lines = [line.strip() for line in text.split("\n") if line.strip()]
            indexed = "\n".join(f"[{i}]: {line}" for i, line in enumerate(lines))
            return indexed, lines, []

    except Exception:
        logger.exception(f"Text extraction failed: {filename}")
        return "", [], []


# ==================== Phase 2: Parallel LLM Structured Extraction ====================


def _clean_llm_json_response(response: str) -> str:
    """
    Clean LLM JSON response.

    Uses SmartResume's lightweight string extraction strategy:
    1. Remove markdown code block markers
    2. Remove <think>...</think> thinking tags (reasoning models may output these)
    3. text.find("{") and text.rfind("}") to locate valid JSON block

    Args:
        response: Raw LLM response text
    Returns:
        Cleaned JSON string
    """
    text = response.strip()
    # Remove markdown code block markers
    text = text.replace("```json", "").replace("```", "").strip()
    # Remove reasoning model thinking tags
    text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
    # Clean escaped quotes (SmartResume's approach)
    text = text.replace('\\"', '"')
    # SmartResume strategy: locate first { and last }
    start = text.find("{")
    end = text.rfind("}")
    if start != -1 and end != -1 and end > start:
        return text[start:end + 1]
    return text


def _parse_json_with_repair(text: str) -> dict:
    """
    Parse JSON string, attempt repair on failure (ref SmartResume's json_repair strategy).

    Repair strategies:
    1. Standard json.loads
    2. Replace Python-style booleans/None
    3. Use json_repair library

    Args:
        text: JSON string
    Returns:
        Parsed dictionary
    Raises:
        json.JSONDecodeError: Raised when all repair strategies fail
    """
    # First attempt: standard parsing
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass

    # Second attempt: replace Python-style values (ref SmartResume)
    repaired = text.replace("'", '"')
    repaired = repaired.replace('True', 'true')
    repaired = repaired.replace('False', 'false')
    repaired = repaired.replace('None', 'null')
    try:
        return json.loads(repaired)
    except json.JSONDecodeError:
        pass

    # Third attempt: use json_repair library
    if json_repair is not None:
        try:
            return json_repair.loads(text)
        except Exception:
            pass

    # All strategies failed
    raise json.JSONDecodeError("All JSON repair strategies failed", text, 0)


def _call_llm(prompt: str, tenant_id , lang: str) -> Optional[dict]:
    """
    Call LLM and parse JSON response (ref SmartResume's retry + fault-tolerance strategy).

    Retry mechanism:
    - Retry up to _LLM_MAX_RETRIES times
    - On retry, increase temperature and randomize seed for output diversity
    - Use json_repair on JSON parse failure

    Args:
        prompt: User prompt
        lang: Language
    Returns:
        Parsed dictionary, or None on failure

    """
    try:
        from api.db.services.llm_service import LLMBundle
        from common.constants import LLMType

        llm =  LLMBundle(tenant_id, LLMType.CHAT, lang=lang)

        for attempt in range(_LLM_MAX_RETRIES + 1):
            try:
                # Increase temperature on retry for diversity (ref SmartResume)
                temperature = 0.1 if attempt == 0 else 1.0
                gen_conf = {"temperature": temperature, "max_tokens": 2048}
                if attempt > 0:
                    gen_conf["seed"] = random.randint(0, 1000000)

                response = llm._run_coroutine_sync(
                    llm.async_chat(
                        system=get_system_prompt(lang),
                        history=[{"role": "user", "content": prompt}],
                        gen_conf=gen_conf,
                    )
                )
                cleaned = _clean_llm_json_response(response)
                return _parse_json_with_repair(cleaned)

            except json.JSONDecodeError as e:
                if attempt < _LLM_MAX_RETRIES:
                    logger.info(f"LLM JSON parse failed (attempt {attempt + 1}), retrying: {e}")
                    continue
                else:
                    logger.warning(f"LLM JSON parse failed (retries exhausted): {e}")
                    return None

    except Exception as e:
        logger.warning(f"LLM call failed: {e}")
        return None


def _normalize_for_comparison(text: str) -> str:
    """
    Normalize text for comparison (ref SmartResume's _normalize_for_comparison).

    Unify fullwidth/halfwidth, remove whitespace, Unicode normalization,
    so that "阿里巴巴" and "阿 里 巴 巴" can match.

    Args:
        text: Original text
    Returns:
        Normalized text
    """
    if not text:
        return ""
    # Unicode NFKC normalization (fullwidth to halfwidth, etc.)
    text = unicodedata.normalize("NFKC", text)
    # Remove all whitespace characters
    text = re.sub(r'\s+', '', text)
    return text.lower()

def _calc_single_exp_years(start_str: str, end_str: str) -> float:
    """
    Calculate years for a single experience entry.

    Args:
        start_str: Start date string
        end_str: End date string ("至今" etc. means current)
    Returns:
        Years (float, 1 decimal place), returns 0 if unable to calculate
    """
    from datetime import datetime

    start_str = str(start_str).strip()
    end_str = str(end_str).strip()
    if not start_str:
        return 0

    start_date = _parse_date_str(start_str)
    if not start_date:
        return 0

    if end_str in ("至今", "现在", "present", "Present", "now", "Now", ""):
        end_date = datetime.now()
    else:
        end_date = _parse_date_str(end_str)
        if not end_date:
            end_date = datetime.now()

    months = (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month)
    if months <= 0:
        return 0
    return round(months / 12.0, 1)


def _calculate_work_years(experiences: list[dict]) -> float:
    """
    Calculate total work years based on start/end dates of each work experience.

    Args:
        experiences: List of work experiences, each containing start_date, end_date fields
    Returns:
        Total work years (float), returns 0 if unable to calculate
    """
    total = 0.0
    for exp in experiences:
        total += _calc_single_exp_years(
            exp.get("start_date", ""), exp.get("end_date", "")
        )
    return round(total, 1)


def _parse_date_str(date_str: str) -> Optional[datetime.datetime]:
    """
    Parse date string, supporting multiple common formats.

    Supported formats:
    - 2024.1 / 2024.01
    - 2024-1 / 2024-01
    - 2024/1 / 2024/01
    - 2024年1月
    - 2024 (year only, defaults to January)

    Args:
        date_str: Date string
    Returns:
        datetime object, or None on parse failure
    """
    from datetime import datetime

    date_str = date_str.strip()
    # Try matching year.month / year-month / year/month / year(nian)month(yue) formats
    patterns = [
        (r"((?:19|20)\d{2})[.\-/年](\d{1,2})", "%Y-%m"),
        (r"^((?:19|20)\d{2})$", "%Y"),
    ]
    for pattern, _ in patterns:
        m = re.search(pattern, date_str)
        if m:
            try:
                year = int(m.group(1))
                month = int(m.group(2)) if len(m.groups()) > 1 else 1
                # Month range validation
                if month < 1 or month > 12:
                    month = 1
                return datetime(year, month, 1)
            except (ValueError, IndexError):
                continue
    return None




def _extract_description_from_range(
        index_range: list, lines: list[str],
        company: str = "", position: str = ""
) -> str:
    """
    Extract description from original text by index range (ref SmartResume's _extract_description_from_range).

    Key improvement:
    - Filter out lines containing both company name and position title (avoid mixing header lines into description)
    - Boundary safety checks

    Args:
        index_range: [start_line_number, end_line_number]
        lines: List of original line texts
        company: Company name (used to filter header lines)
        position: Position title (used to filter header lines)
    Returns:
        Extracted description text
    """
    if not index_range or len(index_range) != 2:
        return ""

    start_idx, end_idx = int(index_range[0]), int(index_range[1])

    # Boundary safety check
    if start_idx < 0 or end_idx >= len(lines) or start_idx > end_idx:
        return ""

    extracted_lines = lines[start_idx:end_idx + 1]

    # Filter out lines containing both company name and position title (ref SmartResume)
    if company or position:
        norm_company = _normalize_for_comparison(company)
        norm_position = _normalize_for_comparison(position)
        filtered = []
        for line in extracted_lines:
            norm_line = _normalize_for_comparison(line)
            # If a line contains both company name and position title, it's likely a header line, skip
            if norm_company and norm_position and norm_company in norm_line and norm_position in norm_line:
                continue
            # If a line exactly equals company name or position title, also skip
            if norm_line == norm_company or norm_line == norm_position:
                continue
            filtered.append(line)
        extracted_lines = filtered

    if not extracted_lines:
        return ""

    return "\n".join(line.strip() for line in extracted_lines if line.strip())


def _extract_basic_info(indexed_text: str, tenant_id , lang: str) -> Optional[dict]:
    """Extract basic info (subtask 1).

    Basic info is usually at the beginning of the resume, first 8000 chars suffice.
    """
    prompt = get_basic_info_prompt(lang).format(indexed_text=indexed_text[:8000])
    return _call_llm(prompt,tenant_id, lang)


def _extract_work_experience(indexed_text: str, tenant_id , lang: str) -> Optional[dict]:
    """Extract work experience (subtask 2, using index pointers).

    Work experience may span the middle-to-end of the resume, use full text to avoid truncation.
    """
    prompt = get_work_exp_prompt(lang).format(indexed_text=indexed_text)
    return _call_llm(prompt, tenant_id , lang)


def _extract_education(indexed_text: str, tenant_id , lang: str) -> Optional[dict]:
    """Extract education background (subtask 3).

    Education is usually at the end of the resume, must use full text to avoid truncation.
    Resume text is generally under 30K chars, within LLM context window.
    """
    prompt = get_education_prompt(lang).format(indexed_text=indexed_text)
    return _call_llm(prompt,tenant_id, lang)


def _extract_project_experience(indexed_text: str, tenant_id , lang: str) -> Optional[dict]:
    """Extract project experience (subtask 4, using index pointers).

    Project experience may span the middle-to-end of the resume, use full text to avoid truncation.
    """
    prompt = get_project_exp_prompt(lang).format(indexed_text=indexed_text)
    return _call_llm(prompt, tenant_id , lang)


def parse_with_llm(indexed_text: str, lines: list[str], tenant_id , lang: str) -> Optional[dict]:
    """
    Extract resume info using parallel task decomposition strategy (ref SmartResume Section 3.2).

    Decomposes extraction into four independent subtasks executed in parallel:
    1. Basic info (name, phone, skills, self-evaluation, etc.)
    2. Work experience (company, position, description line ranges)
    3. Education background (school, major, degree)
    4. Project experience (project name, role, description line ranges)

    Args:
        indexed_text: Line-indexed resume text
        lines: List of original line texts (for index-based extraction)
        lang: Language
    Returns:
        Merged structured resume dictionary, or None on failure
    """
    try:
        # Execute four subtasks in parallel
        with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
            future_basic = executor.submit(_extract_basic_info, indexed_text, tenant_id , lang)
            future_work = executor.submit(_extract_work_experience, indexed_text, tenant_id , lang)
            future_edu = executor.submit(_extract_education, indexed_text, tenant_id, lang)
            future_project = executor.submit(_extract_project_experience, indexed_text, tenant_id , lang)

            basic_info = future_basic.result(timeout=60)
            work_exp = future_work.result(timeout=60)
            education = future_edu.result(timeout=60)
            project_exp = future_project.result(timeout=60)

        # Merge results
        resume = {}

        # Merge basic info
        if basic_info:
            resume.update(basic_info)
            logger.info(f"Basic info extraction succeeded: {len(basic_info)} fields")

        # Process work experience (index pointer extraction)
        if work_exp and "workExperience" in work_exp:
            experiences = work_exp["workExperience"]
            companies = []
            positions = []
            work_descs = []
            # Save detailed info for each experience (dates, years) for chunk generation
            work_exp_details = []
            for exp in experiences:
                company = exp.get("company", "")
                position = exp.get("position", "")
                start_date = exp.get("start_date", "")
                end_date = exp.get("end_date", "")
                # Calculate years for this experience entry
                years = _calc_single_exp_years(start_date, end_date)
                if company:
                    companies.append(company)
                if position:
                    positions.append(position)
                # Save detailed info for each experience entry
                work_exp_details.append({
                    "company": company,
                    "position": position,
                    "start_date": start_date,
                    "end_date": end_date,
                    "years": years,
                })
                # Index pointer mechanism: extract description from original text by line range
                # Use _extract_description_from_range to filter header lines (ref SmartResume)
                desc_lines = exp.get("desc_lines", [])
                if isinstance(desc_lines, list) and len(desc_lines) == 2:
                    desc = _extract_description_from_range(
                        desc_lines, lines, company=company, position=position
                    )
                    if desc.strip():
                        work_descs.append(desc.strip())

            if companies:
                resume["corp_nm_tks"] = companies
                resume["corporation_name_tks"] = companies[0]
            if positions:
                resume["position_name_tks"] = positions
            if work_descs:
                resume["work_desc_tks"] = work_descs
            # Save experience details for _build_chunk_document
            if work_exp_details:
                resume["_work_exp_details"] = work_exp_details
            # Calculate total work years from each experience's dates (overrides LLM's guess in basic info)
            calculated_years = _calculate_work_years(experiences)
            if calculated_years > 0:
                resume["work_exp_flt"] = calculated_years
            logger.info(f"Work experience extraction succeeded: {len(experiences)} entries, calculated total years: {calculated_years}")

        # Process education background
        if education and "education" in education:
            edu_list = education["education"]
            schools = []
            majors = []
            degrees = []
            for edu in edu_list:
                if edu.get("school"):
                    schools.append(edu["school"])
                if edu.get("major"):
                    majors.append(edu["major"])
                if edu.get("degree"):
                    degrees.append(edu["degree"])
                # Extract graduation year
                end_date = edu.get("end_date", "")
                if end_date and not resume.get("edu_end_int"):
                    year_match = re.search(r"(19|20)\d{2}", str(end_date))
                    if year_match:
                        resume["edu_end_int"] = int(year_match.group(0))

            if schools:
                resume["school_name_tks"] = schools
                resume["first_school_name_tks"] = schools[-1]  # Earliest school is usually last
            if majors:
                resume["major_tks"] = majors
                resume["first_major_tks"] = majors[-1]
            if degrees:
                resume["degree_kwd"] = degrees
                # Infer highest degree (supports both Chinese and English degree names)
                degree_rank = {
                    "博士": 5, "PhD": 5, "Doctor": 5,
                    "硕士": 4, "Master": 4, "MBA": 4, "EMBA": 4, "MPA": 4,
                    "本科": 3, "Bachelor": 3,
                    "大专": 2, "专科": 2, "Associate": 2, "Diploma": 2,
                    "高中": 1, "High School": 1,
                }
                highest = max(degrees, key=lambda d: degree_rank.get(d, 0), default="")
                if highest:
                    resume["highest_degree_kwd"] = highest
                resume["first_degree_kwd"] = degrees[-1] if degrees else ""
            logger.info(f"Education extraction succeeded: {len(edu_list)} entries")

        # Process project experience (index pointer extraction, similar to work experience)
        if project_exp and "projectExperience" in project_exp:
            projects = project_exp["projectExperience"]
            project_names = []
            project_descs = []
            for proj in projects:
                name = proj.get("project_name", "")
                if name:
                    project_names.append(name)
                # Index pointer mechanism: extract project description from original text by line range
                desc_lines = proj.get("desc_lines", [])
                if isinstance(desc_lines, list) and len(desc_lines) == 2:
                    desc = _extract_description_from_range(
                        desc_lines, lines, company=name, position=proj.get("role", "")
                    )
                    if desc.strip():
                        project_descs.append(desc.strip())

            if project_names:
                resume["project_tks"] = project_names
            if project_descs:
                resume["project_desc_tks"] = project_descs
            logger.info(f"Project experience extraction succeeded: {len(projects)} entries")

        if not resume.get("name_kwd"):
            resume["name_kwd"] = "Unknown" if _is_english(lang) else "未知"

        return resume if len(resume) > 2 else None

    except concurrent.futures.TimeoutError:
        logger.warning("LLM parallel extraction timed out")
        return None
    except Exception as e:
        logger.warning(f"LLM parallel extraction failed: {e}")
        return None


# ==================== Phase 3: Regex Fallback Parsing ====================



def parse_with_regex(text: str, lang: str = "Chinese") -> dict:
    """
    Parse resume text using regex (fallback strategy)

    When LLM parsing fails, use regex to extract basic structured info from text.

    Args:
        text: Resume text content (without line number index)
        lang: Language parameter, default "Chinese"
    Returns:
        Structured resume info dictionary
    """
    resume: dict = {}
    lines = [line.strip() for line in text.split("\n") if line.strip()]

    # --- Extract Name ---
    if _is_english(lang):
        # English resume: extract from "Name: XXX" format
        for line in lines[:30]:
            name_match = re.search(r'(?:Name|Full\s*Name)\s*[:：]\s*([A-Za-z][A-Za-z\s\-\.]{1,40})', line, re.IGNORECASE)
            if name_match:
                resume["name_kwd"] = name_match.group(1).strip()
                break
        # English resume strategy 2: first line if short text without digits, may be a name
        if "name_kwd" not in resume and lines:
            first = lines[0].strip()
            if len(first) <= 40 and not re.search(r"\d", first) and re.match(r'^[A-Za-z][A-Za-z\s\-\.]+$', first):
                resume["name_kwd"] = first
    else:
        # Chinese resume: extract from "姓名：XXX" format
        for line in lines[:30]:
            name_match = re.search(r'姓\s*名\s*[:：]\s*([\u4e00-\u9fa5]{2,4})', line)
            if name_match:
                resume["name_kwd"] = name_match.group(1)
                break

        # Strategy 2: search first 20 lines for standalone Chinese names (2-4 chars), excluding common title words
        if "name_kwd" not in resume:
            title_words = {
                "个人", "简历", "求职", "应聘", "基本", "信息", "概述", "简介",
                "教育", "工作", "经历", "经验", "技能", "项目", "自我", "评价",
                "专业", "技术", "证书", "语言", "能力", "培训", "荣誉", "奖项",
            }
            for line in lines[:20]:
                if any(w in line for w in title_words):
                    continue
                if re.search(r'[:：]', line) and len(line) > 6:
                    continue
                cleaned = re.sub(r"^[A-Za-z_\-\d\s]+\s+", "", line)
                cleaned = re.sub(r"\s+[A-Za-z_\-\d\s]+$", "", cleaned).strip()
                if 2 <= len(cleaned) <= 4 and re.match(r"^[\u4e00-\u9fa5]{2,4}$", cleaned):
                    resume["name_kwd"] = cleaned
                    break

        # Strategy 3: first line if short without digits, may be a name
        if "name_kwd" not in resume and lines:
            first = lines[0].strip()
            if len(first) <= 10 and not re.search(r"\d", first):
                cn_part = re.findall(r'[\u4e00-\u9fa5]+', first)
                if cn_part and 2 <= len(cn_part[0]) <= 4:
                    resume["name_kwd"] = cn_part[0]

    # --- Extract Phone Number ---
    phones = re.findall(r"1[3-9]\d{9}", text)
    if phones:
        resume["phone_kwd"] = phones[0]

    # --- Extract Email ---
    emails = re.findall(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", text)
    if emails:
        resume["email_tks"] = emails[0]

    # --- Extract Gender ---
    if _is_english(lang):
        # English resume: extract from "Gender: Male/Female" format
        gender_label = re.search(r'(?:Gender|Sex)\s*[:：]\s*(Male|Female|M|F)', text, re.IGNORECASE)
        if gender_label:
            raw = gender_label.group(1).strip().upper()
            resume["gender_kwd"] = "Male" if raw in ("M", "MALE") else "Female"
        else:
            gender_match = re.search(r'\b(Male|Female)\b', text[:500], re.IGNORECASE)
            if gender_match:
                resume["gender_kwd"] = gender_match.group(1).capitalize()
    else:
        # Chinese resume: extract from "性别：男/女" format
        gender_label = re.search(r'性\s*别\s*[:：]\s*(男|女)', text)
        if gender_label:
            resume["gender_kwd"] = gender_label.group(1)
        else:
            gender_match = re.search(r"(男|女)", text[:500])
            if gender_match:
                resume["gender_kwd"] = gender_match.group(1)

    # --- Extract Age ---
    if _is_english(lang):
        # English resume: match "25 years old" or "Age: 25"
        age_match = re.search(r'(?:Age)\s*[:：]\s*(\d{1,2})', text, re.IGNORECASE)
        if not age_match:
            age_match = re.search(r'(\d{1,2})\s*years?\s*old', text, re.IGNORECASE)
        if age_match:
            resume["age_int"] = int(age_match.group(1))
    else:
        # Chinese resume: match "25岁"
        age_match = re.search(r"(\d{1,2})\s*岁", text)
        if age_match:
            resume["age_int"] = int(age_match.group(1))

    # --- Extract Date of Birth ---
    if _is_english(lang):
        # English resume: match "1990-01-15" or "Jan 15, 1990" etc.
        birth_match = re.search(r'(?:Birth|DOB|Date\s*of\s*Birth)\s*[:：]\s*(.{6,20})', text, re.IGNORECASE)
        if birth_match:
            resume["birth_dt"] = birth_match.group(1).strip()
        else:
            birth_match = re.search(r"(19|20)\d{2}[-/]\d{1,2}[-/]\d{1,2}", text)
            if birth_match:
                resume["birth_dt"] = birth_match.group(0)
    else:
        # Chinese resume: match "1990年1月15日" or "1990-01-15"
        birth_match = re.search(r"(19|20)\d{2}[年/-]\d{1,2}[月/-]\d{1,2}", text)
        if birth_match:
            resume["birth_dt"] = birth_match.group(0)

    # --- Extract Education Level ---
    degree_keywords_zh = ["博士", "硕士", "本科", "大专", "专科", "高中", "MBA", "EMBA", "MPA"]
    degree_keywords_en = ["PhD", "Master", "Bachelor", "Associate", "Diploma", "High School",
                          "MBA", "EMBA", "MPA", "Doctor"]
    degree_keywords = degree_keywords_en if _is_english(lang) else degree_keywords_zh
    found_degrees = [d for d in degree_keywords if d in text]
    if found_degrees:
        resume["degree_kwd"] = found_degrees

    # --- Extract School ---
    if _is_english(lang):
        # English resume: match "University/College/Institute/School" keywords
        schools = re.findall(
            r'([A-Z][A-Za-z\s\-&]{2,40}(?:University|College|Institute|School|Academy))',
            text
        )
        # Remove extra whitespace
        schools = [re.sub(r'\s+', ' ', s).strip() for s in schools]
    else:
        # Chinese resume: match "XX大学/学院/职业技术学院"
        schools = re.findall(r"[\u4e00-\u9fa5]{2,15}(?:大学|学院|职业技术学院)", text)
    if schools:
        resume["school_name_tks"] = list(set(schools))
        resume["first_school_name_tks"] = schools[0]

    # --- Extract Major ---
    if _is_english(lang):
        # English resume: match "Major: XXX" / "Field of Study: XXX" / "Specialization: XXX"
        majors = re.findall(
            r'(?:Major|Field\s*of\s*Study|Specialization|Concentration)\s*[:：]\s*([A-Za-z\s\-&,]{2,40})',
            text, re.IGNORECASE
        )
        majors = [m.strip() for m in majors if m.strip()]
    else:
        # Chinese resume: match "专业：XXX"
        majors = re.findall(r"专业[:：]\s*([\u4e00-\u9fa5]{2,20})", text)
    if majors:
        resume["major_tks"] = majors
        resume["first_major_tks"] = majors[0]

    # --- Extract Company Names ---
    if _is_english(lang):
        # English resume: match common company suffixes
        en_company_patterns = [
            r'([A-Z][A-Za-z\s\-&,\.]{2,40}(?:Inc\.|Corp\.|Ltd\.|LLC|Co\.|Company|Group|Technologies|Technology|Solutions|Consulting|Services|Bank))',
        ]
        companies = []
        for pattern in en_company_patterns:
            companies.extend(re.findall(pattern, text))
        companies = [re.sub(r'\s+', ' ', c).strip() for c in companies]
    else:
        # Chinese resume: match "XX有限公司" format
        company_patterns = [
            r"[\u4e00-\u9fa5]{2,20}[（(][\u4e00-\u9fa5]{2,10}[)）](?:科技|信息技术|网络科技)?(?:股份)?有限公司",
            r"[\u4e00-\u9fa5]{4,20}(?:科技|信息技术|网络科技|银行)?(?:股份)?有限公司",
        ]
        companies = []
        for pattern in company_patterns:
            companies.extend(re.findall(pattern, text))

    unique_companies = []
    seen = set()
    # Filter verb list (bilingual)
    filter_verbs = (
        ["completed", "conducted", "implemented", "responsible", "participated", "developed"]
        if _is_english(lang)
        else ["完成", "进行", "实施", "负责", "参与", "开发"]
    )
    min_len = 3 if _is_english(lang) else 6
    for c in companies:
        if len(c) < min_len or any(v in c.lower() for v in filter_verbs) or c in seen:
            continue
        is_sub = False
        for existing in list(unique_companies):
            if c in existing:
                is_sub = True
                break
            if existing in c:
                unique_companies.remove(existing)
                seen.discard(existing)
        if not is_sub:
            unique_companies.append(c)
            seen.add(c)

    if unique_companies:
        resume["corp_nm_tks"] = unique_companies
        resume["corporation_name_tks"] = unique_companies[0]

    # --- Extract Position (improved: context constraints to reduce noise) ---
    if _is_english(lang):
        # English resume: Strategy 1 - extract from "Title: XXX" / "Position: XXX" / "Role: XXX" format
        position_label_matches = re.findall(
            r'(?:Title|Position|Role|Job\s*Title)\s*[:：]\s*([A-Za-z\s\-/&]{2,30})',
            text, re.IGNORECASE
        )
        positions = [p.strip() for p in position_label_matches if p.strip()]

        # English resume: Strategy 2 - match common position suffix keywords
        en_position_suffixes = [
            "Engineer", "Manager", "Director", "Supervisor", "Specialist",
            "Designer", "Consultant", "Assistant", "Architect", "Analyst",
            "Developer", "Lead", "Officer", "Coordinator", "Administrator",
            "Intern", "VP", "President",
        ]
        for line in lines:
            if len(line) > 60:
                continue  # Skip overly long lines (usually description text)
            for suffix in en_position_suffixes:
                match = re.search(rf'([A-Za-z\s\-]{{1,25}}{suffix})\b', line, re.IGNORECASE)
                if match:
                    pos = match.group(1).strip()
                    # Filter out matches that are clearly not positions (contain verbs)
                    filter_pos_verbs = ["responsible", "participated", "completed", "developed", "designed"]
                    if not any(v in pos.lower() for v in filter_pos_verbs) and len(pos) > 3:
                        positions.append(pos)
    else:
        # Chinese resume: Strategy 1 - extract from "职位/岗位：XXX" format
        position_label_matches = re.findall(
            r'(?:职位|岗位|职务|职称|担任)\s*[:：]\s*([\u4e00-\u9fa5a-zA-Z]{2,15})',
            text
        )
        positions = list(position_label_matches)

        # Chinese resume: Strategy 2 - extract from work experience paragraphs (company name followed by position)
        for line in lines:
            pos_match = re.search(
                r'(?:有限公司|集团|银行)\s+([\u4e00-\u9fa5]{2,8}(?:工程师|经理|总监|主管|专员|设计师|顾问|助理|架构师|分析师|运营|产品))',
                line
            )
            if pos_match:
                positions.append(pos_match.group(1))

        # Chinese resume: Strategy 3 - position keywords in standalone lines (length-limited to avoid matching description text)
        position_suffixes = ["工程师", "经理", "总监", "主管", "专员", "设计师", "顾问",
                             "助理", "架构师", "分析师", "开发者", "负责人"]
        for line in lines:
            if len(line) > 20:
                continue  # Skip overly long lines
            for suffix in position_suffixes:
                match = re.search(rf'([\u4e00-\u9fa5]{{1,6}}{suffix})', line)
                if match:
                    pos = match.group(1)
                    if not any(v in pos for v in ["负责", "参与", "完成", "开发了", "设计了"]):
                        positions.append(pos)

    if positions:
        # Deduplicate while preserving order
        seen_pos = set()
        unique_positions = []
        for p in positions:
            if p not in seen_pos:
                seen_pos.add(p)
                unique_positions.append(p)
        resume["position_name_tks"] = unique_positions

    # --- Extract Years of Experience ---
    if _is_english(lang):
        # English resume: match "5 years experience" / "5+ years of experience"
        work_exp_match = re.search(r'(\d+)\+?\s*years?\s*(?:of\s*)?(?:experience|work)', text, re.IGNORECASE)
        if work_exp_match:
            resume["work_exp_flt"] = float(work_exp_match.group(1))
    else:
        # Chinese resume: match "5年...经验"
        work_exp_match = re.search(r"(\d+)\s*年.*?经验", text)
        if work_exp_match:
            resume["work_exp_flt"] = float(work_exp_match.group(1))

    # --- Extract Graduation Year ---
    if _is_english(lang):
        # English resume: match "Graduated 2020" / "Graduation: 2020" / "Class of 2020"
        grad_match = re.search(r'(?:Graduat(?:ed|ion)|Class\s*of)\s*[:：]?\s*((?:19|20)\d{2})', text, re.IGNORECASE)
        if grad_match:
            resume["edu_end_int"] = int(grad_match.group(1))
    else:
        # Chinese resume: match "2020年...毕业"
        grad_match = re.search(r"((?:19|20)\d{2})\s*年.*?毕业", text)
        if grad_match:
            resume["edu_end_int"] = int(grad_match.group(1))

    if "name_kwd" not in resume:
        resume["name_kwd"] = "Unknown" if _is_english(lang) else "未知"

    return resume



# ==================== Phase 4: Post-processing Pipeline ====================


def _postprocess_resume(resume: dict, lines: list[str], lang: str = "Chinese") -> dict:
    """
    Four-phase post-processing pipeline (ref: SmartResume Section 3.2.3)

    1. Source text validation: check if key fields can be found in the original text
    2. Domain normalization: standardize date formats, clean company name suffix noise
    3. Contextual deduplication: remove duplicate company/school entries
    4. Field completion: ensure all required fields exist

    Args:
        resume: Raw resume dictionary extracted by LLM
        lines: Original line text list (for source text validation)
        lang: Language parameter, default "Chinese"
    Returns:
        Post-processed resume dictionary
    """
    _en = _is_english(lang)
    full_text = "\n".join(lines) if lines else ""
    # Normalize full text for comparison (ref: SmartResume _validate_fields_in_text)
    norm_full_text = _normalize_for_comparison(full_text)

    # --- Phase 1: Source text validation (prune hallucinations, ref: SmartResume _validate_fields_in_text) ---
    # Name validation: clear if not found in source text (SmartResume strategy: discard hallucinated fields)
    _unknown_names = ("未知", "Unknown")
    if resume.get("name_kwd") and resume["name_kwd"] not in _unknown_names:
        norm_name = _normalize_for_comparison(resume["name_kwd"])
        if norm_full_text and norm_name and norm_name not in norm_full_text:
            logger.warning(f"Name '{resume['name_kwd']}' not found in source text, classified as LLM hallucination, cleared")
            resume["name_kwd"] = ""

    # Validate company names (strict matching: full name must appear in source text, no longer using loose 4-char prefix matching)
    if resume.get("corp_nm_tks") and norm_full_text:
        verified_companies = []
        for company in resume["corp_nm_tks"]:
            norm_company = _normalize_for_comparison(company)
            if norm_company and norm_company in norm_full_text:
                verified_companies.append(company)
            else:
                logger.debug(f"Company '{company}' not found in source text, filtered out")
        # Update even if all filtered out (SmartResume strategy: prefer missing over wrong)
        resume["corp_nm_tks"] = verified_companies
        if verified_companies:
            resume["corporation_name_tks"] = verified_companies[0]
        else:
            resume["corporation_name_tks"] = ""

    # Validate school names (ref: SmartResume _validate_fields_in_text)
    if resume.get("school_name_tks") and norm_full_text:
        verified_schools = []
        for school in resume["school_name_tks"]:
            norm_school = _normalize_for_comparison(school)
            if norm_school and norm_school in norm_full_text:
                verified_schools.append(school)
            else:
                logger.debug(f"School '{school}' not found in source text, filtered out")
        resume["school_name_tks"] = verified_schools
        if verified_schools:
            if resume.get("first_school_name_tks"):
                # Ensure first_school is also in the verified list
                if resume["first_school_name_tks"] not in verified_schools:
                    resume["first_school_name_tks"] = verified_schools[-1]
        else:
            resume["first_school_name_tks"] = ""

    # Validate position names
    if resume.get("position_name_tks") and norm_full_text:
        verified_positions = []
        for pos in resume["position_name_tks"]:
            norm_pos = _normalize_for_comparison(pos)
            if norm_pos and norm_pos in norm_full_text:
                verified_positions.append(pos)
        if verified_positions:
            resume["position_name_tks"] = verified_positions

    # --- Phase 2: Domain normalization ---
    # Standardize date format
    if resume.get("birth_dt"):
        resume["birth_dt"] = re.sub(r"[年月]", "-", str(resume["birth_dt"])).rstrip("-")

    # Clean non-digit characters from phone number (keep + sign)
    if resume.get("phone_kwd"):
        phone = re.sub(r"[^\d+]", "", str(resume["phone_kwd"]))
        if phone:
            resume["phone_kwd"] = phone

    # Standardize gender (output format determined by language parameter)
    if resume.get("gender_kwd"):
        gender = str(resume["gender_kwd"]).strip()
        if gender in ("male", "Male", "M", "m", "男"):
            resume["gender_kwd"] = "Male" if _en else "男"
        elif gender in ("female", "Female", "F", "f", "女"):
            resume["gender_kwd"] = "Female" if _en else "女"

    # --- Phase 3: Contextual deduplication ---
    for list_field in ["corp_nm_tks", "school_name_tks", "major_tks",
                       "position_name_tks", "skill_tks"]:
        if isinstance(resume.get(list_field), list):
            # Order-preserving deduplication
            seen = set()
            deduped = []
            for item in resume[list_field]:
                item_str = str(item).strip()
                if item_str and item_str not in seen:
                    seen.add(item_str)
                    deduped.append(item_str)
            resume[list_field] = deduped
    # --- Phase 3.4: work_desc_tks dedup by company name + time period ---
    # LLM often extracts the same company's content twice: once from the "Work Experience"
    # section and once from the "Project Experience" section, producing entries like
    # These have different descriptions (daily work vs project details), so content-based
    # Jaccard dedup cannot catch them. Instead, we detect duplicate companies by checking
    # if one company name is a substring of another AND their time periods overlap.
    # This also fixes the inflated work_exp_flt (e.g. 25.5 years instead of ~14).
    work_descs = resume.get("work_desc_tks", [])
    if len(work_descs) > 1:
        corp_names = resume.get("corp_nm_tks", [])
        work_details = resume.get("_work_exp_details", [])
        positions = resume.get("position_name_tks", [])
        kept_indices = []
        for i in range(len(work_descs)):
            is_dup = False
            corp_i = _normalize_for_comparison(corp_names[i]) if i < len(corp_names) else ""
            detail_i = work_details[i] if i < len(work_details) else {}
            start_i = detail_i.get("start_date", "")
            end_i = detail_i.get("end_date", "")
            # Parse dates for entry i once (reused across inner loop)
            dt_start_i = _parse_date_str(start_i) if start_i else None
            dt_end_i = _parse_date_str(end_i) if end_i else None
            for j in kept_indices:
                # Strategy A: company name substring + time period overlap
                corp_j = _normalize_for_comparison(corp_names[j]) if j < len(corp_names) else ""
                if corp_i and corp_j:
                    shorter_c, longer_c = (corp_i, corp_j) if len(corp_i) <= len(corp_j) else (corp_j, corp_i)
                    if shorter_c in longer_c:
                        # Check time period overlap using parsed dates
                        # Two intervals [s1,e1] and [s2,e2] overlap iff s1 <= e2 and s2 <= e1
                        # Use <= because resume dates are month-granularity (e.g. "2018.03" means "sometime in March 2018")
                        detail_j = work_details[j] if j < len(work_details) else {}
                        start_j = detail_j.get("start_date", "")
                        end_j = detail_j.get("end_date", "")
                        dt_start_j = _parse_date_str(start_j) if start_j else None
                        dt_end_j = _parse_date_str(end_j) if end_j else None
                        # Need at least one valid date on each side to compare
                        if dt_start_i and dt_start_j:
                            # Use far-future as default end if missing
                            eff_end_i = dt_end_i or datetime.datetime(2099, 12, 1)
                            eff_end_j = dt_end_j or datetime.datetime(2099, 12, 1)
                            if dt_start_i <= eff_end_j and dt_start_j <= eff_end_i:
                                is_dup = True
                                break
                        elif (start_i and start_j and start_i == start_j) or \
                                (end_i and end_j and end_i == end_j):
                            # Fallback: exact string match if date parsing fails
                            is_dup = True
                            break
                # Strategy B: content-based Jaccard similarity (fallback)
                norm_i = _normalize_for_comparison(work_descs[i])
                norm_j = _normalize_for_comparison(work_descs[j])
                shorter, longer = (norm_i, norm_j) if len(norm_i) <= len(norm_j) else (norm_j, norm_i)
                if shorter and longer and shorter in longer:
                    is_dup = True
                    break
                jac = _shingling_jaccard(work_descs[i], work_descs[j], n=5)
                if jac > 0.5:
                    is_dup = True
                    break
            if is_dup:
                dup_corp = corp_names[i] if i < len(corp_names) else f"#{i+1}"
                logger.debug(f"Work desc internal duplicate removed: {dup_corp}")
            else:
                kept_indices.append(i)
        # Only update when entries were actually removed
        if len(kept_indices) < len(work_descs):
            resume["work_desc_tks"] = [work_descs[i] for i in kept_indices]
            if corp_names:
                resume["corp_nm_tks"] = [corp_names[i] for i in kept_indices if i < len(corp_names)]
            if work_details:
                resume["_work_exp_details"] = [work_details[i] for i in kept_indices if i < len(work_details)]
            if positions:
                resume["position_name_tks"] = [positions[i] for i in kept_indices if i < len(positions)]
            # Recalculate work years based on deduplicated entries
            new_details = resume.get("_work_exp_details", [])
            if new_details:
                recalc_years = sum(d.get("years", 0) for d in new_details)
                recalc_years = round(recalc_years, 1)
                if recalc_years > 0:
                    resume["work_exp_flt"] = recalc_years
                    logger.info(f"Work years recalculated: {recalc_years} yrs (before dedup: {_calculate_work_years([{'start_date': d.get('start_date',''), 'end_date': d.get('end_date','')} for d in work_details])} yrs)")
            new_corps = resume.get("corp_nm_tks", [])
            if new_corps:
                resume["corporation_name_tks"] = new_corps[0]

    # --- Phase 3.5: Merge project_desc_tks into work_desc_tks ---
    # Instead of complex cross-dedup, we simply merge unique project descriptions into
    # work_desc_tks and clear project_desc_tks. This avoids the problem where LLM extracts
    # the same content into both fields with slightly different wording.
    # After merge, project_desc_tks is emptied so _build_chunk_document won't generate
    # duplicate chunks. Project names are preserved in project_tks for reference.
    work_descs = resume.get("work_desc_tks", [])
    project_descs = resume.get("project_desc_tks", [])
    # Save pre-merge project descriptions for debugging
    resume["_raw_project_descs"] = list(project_descs) if project_descs else []
    if project_descs:
        project_names = resume.get("project_tks", [])
        merged_count = 0
        skipped_count = 0
        for i, proj_desc in enumerate(project_descs):
            norm_proj = _normalize_for_comparison(proj_desc)
            if not norm_proj:
                continue
            # Check if this project desc already exists in work_descs (exact or near-duplicate)
            already_exists = False
            for wd in work_descs:
                norm_wd = _normalize_for_comparison(wd)
                if not norm_wd:
                    continue
                # Substring containment check
                shorter, longer = (norm_proj, norm_wd) if len(norm_proj) <= len(norm_wd) else (norm_wd, norm_proj)
                if shorter in longer:
                    already_exists = True
                    break
                # Jaccard similarity check
                if _shingling_jaccard(proj_desc, wd, n=5) > 0.5:
                    already_exists = True
                    break
            if already_exists:
                skipped_count += 1
                proj_name = project_names[i] if i < len(project_names) else f"#{i+1}"
                logger.debug(f"Project desc already in work_desc, skipped: {proj_name}")
            else:
                # Append to work_desc_tks with project name prefix for context
                proj_name = project_names[i] if i < len(project_names) else ""
                if proj_name:
                    proj_desc_with_prefix = f"[{proj_name}] {proj_desc}"
                else:
                    proj_desc_with_prefix = proj_desc
                work_descs.append(proj_desc_with_prefix)
                merged_count += 1
        resume["work_desc_tks"] = work_descs
        # Clear project_desc_tks — all content is now in work_desc_tks
        resume["project_desc_tks"] = []
        logger.info(f"Merged project descs into work_desc_tks: {merged_count} merged, {skipped_count} skipped (duplicate)")
    # --- Phase 4: Field completion ---
    required_fields = [
        "name_kwd", "gender_kwd", "phone_kwd", "email_tks",
        "position_name_tks", "school_name_tks", "major_tks",
    ]
    for field in required_fields:
        if field not in resume:
            if field.endswith("_tks"):
                resume[field] = []
            elif field.endswith("_int") or field.endswith("_flt"):
                resume[field] = 0
            else:
                resume[field] = ""

    # Clean internal marker fields (already handled in Phase 1, this is a safety fallback)
    resume.pop("_name_confidence", None)

    return resume


# ==================== Pipeline Orchestration & Chunk Construction ====================


def parse_resume(filename: str, binary: bytes, tenant_id , lang: str = "Chinese") -> tuple[dict, list[str], list[dict]]:
    """
    Resume parsing pipeline orchestration function

    Execution flow:
        1. Text extraction (dual-path fusion + layout reconstruction + line-number index)
        2. Parallel LLM structured extraction (three sub-tasks)
        3. Regex fallback parsing (when LLM fails)
        4. Four-phase post-processing

    Args:
        filename: File name
        binary: File binary content
        lang: Language, default "Chinese"
    Returns:
        (resume, lines, line_positions) tuple:
        - resume: Structured resume information dictionary
        - lines: Original line text list (for chunk text matching and positioning)
        - line_positions: Per-line coordinate info list (for writing chunk position_int fields)
    """
    # Phase 1: Text extraction
    indexed_text, lines, line_positions = extract_text(filename, binary)
    if not indexed_text or not lines:
        logger.warning(f"Text extraction returned empty: {filename}")
        default_name = "Unknown" if _is_english(lang) else "未知"
        return {"name_kwd": default_name}, [], []

    # Phase 2: Parallel LLM structured extraction
    resume = parse_with_llm(indexed_text, lines, tenant_id , lang)

    # Phase 3: Fallback to regex parsing when LLM fails
    if not resume:
        logger.info(f"LLM parsing failed, falling back to regex parsing: {filename}")
        plain_text = "\n".join(lines)
        resume = parse_with_regex(plain_text, lang)

    # Phase 4: Post-processing pipeline
    resume = _postprocess_resume(resume, lines, lang)

    return resume, lines, line_positions


def _build_chunk_document(filename: str, resume: dict,
                          lang: str = "Chinese") -> list[dict]:
    """
    Build a list of document chunks from structured resume information

    Each field generates an independent chunk containing tokenization results and metadata.
    Compatible with the build_chunks flow in task_executor.py.

    Key design: Each chunk redundantly includes key identity fields (name, phone, email, etc.),
    so that when any chunk is retrieved, the candidate's identity can be immediately identified.
    The full resume can be fetched via doc_id to get all chunks for complete information.

    Args:
        filename: File name
        resume: Structured resume information dictionary
        lang: Language parameter, default "Chinese"
    Returns:
        Document chunk list, each chunk contains content_with_weight, content_ltks,
        position_int, page_num_int, top_int and other fields
    """
    chunks = []
    # Get the corresponding field map version based on language parameter
    field_map = get_field_map(lang)
    doc = {
        "docnm_kwd": filename,
        "title_tks": rag_tokenizer.tokenize(re.sub(r"\.[a-zA-Z]+$", "", filename)),
    }
    doc["title_sm_tks"] = rag_tokenizer.fine_grained_tokenize(doc["title_tks"])

    # Extract key identity fields, redundantly written to each chunk
    # These fields are small in size but high in information density; once retrieved, the candidate can be immediately identified
    _IDENTITY_FIELDS = ("name_kwd", "phone_kwd", "email_tks", "gender_kwd",
                        "highest_degree_kwd", "work_exp_flt")
    identity_meta = {}
    for ik in _IDENTITY_FIELDS:
        iv = resume.get(ik)
        if not iv:
            continue
        if ik.endswith("_tks"):
            identity_meta[ik] = rag_tokenizer.tokenize(
                " ".join(iv) if isinstance(iv, list) else str(iv)
            )
        elif ik.endswith("_kwd"):
            identity_meta[ik] = iv if isinstance(iv, list) else str(iv)
        elif ik.endswith("_flt"):
            try:
                identity_meta[ik] = float(iv)
            except (ValueError, TypeError):
                pass
        else:
            identity_meta[ik] = str(iv)

    # Build resume summary text, appended to each chunk's content to improve semantic retrieval recall
    summary_parts = []
    _en = _is_english(lang)
    if resume.get("name_kwd"):
        summary_parts.append(f"{'Name' if _en else '姓名'}:{resume['name_kwd']}")
    if resume.get("phone_kwd"):
        summary_parts.append(f"{'Phone' if _en else '电话'}:{resume['phone_kwd']}")
    if resume.get("corporation_name_tks"):
        corp = resume["corporation_name_tks"]
        summary_parts.append(f"{'Company' if _en else '公司'}:{corp if isinstance(corp, str) else ' '.join(corp)}")
    if resume.get("highest_degree_kwd"):
        summary_parts.append(f"{'Degree' if _en else '学历'}:{resume['highest_degree_kwd']}")
    if resume.get("work_exp_flt"):
        if _en:
            summary_parts.append(f"Experience:{resume['work_exp_flt']}yrs")
        else:
            summary_parts.append(f"经验:{resume['work_exp_flt']}年")
    resume_summary = " | ".join(summary_parts) if summary_parts else ""

    # List fields that need per-element splitting (each experience/project generates a separate chunk to avoid oversized merged chunks)
    _SPLIT_LIST_FIELDS = {"work_desc_tks", "project_desc_tks"}

    # Basic info field set: these fields should be merged into one chunk to avoid splitting name, phone, email, etc.
    _BASIC_INFO_FIELDS = {
        "name_kwd", "name_pinyin_kwd", "gender_kwd", "age_int",
        "phone_kwd", "email_tks", "birth_dt", "work_exp_flt",
        "position_name_tks", "expect_city_names_tks",
        "expect_position_name_tks",
    }

    # Education field set: degree, school, major, tags, etc. should be merged into one chunk
    _EDUCATION_FIELDS = {
        "first_school_name_tks", "first_degree_kwd", "highest_degree_kwd",
        "first_major_tks", "edu_first_fea_kwd", "degree_kwd", "major_tks",
        "school_name_tks", "sch_rank_kwd", "edu_fea_kwd", "edu_end_int",
    }

    # Skills & certificates field set: skills, languages, certificates are small, merge into one chunk
    _SKILL_CERT_FIELDS = {
        "skill_tks", "language_tks", "certificate_tks",
    }

    # Work overview field set: company list, industry, most recent company merged into one chunk
    _WORK_OVERVIEW_FIELDS = {
        "corporation_name_tks", "corp_nm_tks", "industry_name_tks",
    }

    # All merge groups: (field_set, group_title) tuple list
    _MERGE_GROUPS = [
        (_BASIC_INFO_FIELDS, "Basic Info" if _en else "基本信息"),
        (_EDUCATION_FIELDS, "Education" if _en else "教育背景"),
        (_SKILL_CERT_FIELDS, "Skills & Certificates" if _en else "技能与证书"),
        (_WORK_OVERVIEW_FIELDS, "Work Overview" if _en else "工作概况"),
    ]

    # Collect all fields that need merge processing; skip them during individual iteration
    _ALL_MERGED_FIELDS = set()
    for fields_set, _ in _MERGE_GROUPS:
        _ALL_MERGED_FIELDS.update(fields_set)

    # Merge fields by group, generating one chunk per group
    for fields_set, group_title in _MERGE_GROUPS:
        group_parts = []
        group_field_values = {}  # Store structured values for each field, to be written into chunk
        for field_key in field_map:
            if field_key not in fields_set:
                continue
            value = resume.get(field_key)
            if not value:
                continue
            field_desc = field_map[field_key]
            if isinstance(value, list):
                text_value = " ".join(str(v) for v in value if v)
            else:
                text_value = str(value)
            if not text_value.strip():
                continue
            group_parts.append(f"{field_desc}: {text_value}")
            group_field_values[field_key] = value

        if not group_parts:
            continue

        content = f"{group_title}\n" + "\n".join(group_parts)
        if resume_summary:
            content += f"\n[{resume_summary}]"
        chunk = {
            "content_with_weight": content,
            "content_ltks": rag_tokenizer.tokenize(content),
            "content_sm_ltks": rag_tokenizer.fine_grained_tokenize(
                rag_tokenizer.tokenize(content)
            ),
        }
        chunk.update(doc)
        # Redundantly write identity fields
        for mk, mv in identity_meta.items():
            chunk[mk] = mv
        # Write each field's structured value into chunk (for structured retrieval)
        for fk, fv in group_field_values.items():
            if fk.endswith("_tks"):
                text_val = " ".join(str(v) for v in fv) if isinstance(fv, list) else str(fv)
                chunk[fk] = rag_tokenizer.tokenize(text_val)
            elif fk.endswith("_kwd"):
                chunk[fk] = fv if isinstance(fv, list) else str(fv)
            elif fk.endswith("_int"):
                try:
                    chunk[fk] = int(fv)
                except (ValueError, TypeError):
                    pass
            elif fk.endswith("_flt"):
                try:
                    chunk[fk] = float(fv)
                except (ValueError, TypeError):
                    pass
            else:
                chunk[fk] = str(fv)
        chunks.append(chunk)

    # Iterate over field map, generating a chunk for each non-merged field with a value
    for field_key, field_desc in field_map.items():
        # Skip fields already processed in merge groups
        if field_key in _ALL_MERGED_FIELDS:
            continue
        value = resume.get(field_key)
        if not value:
            continue

        # For work/project descriptions (long text lists), split into multiple chunks per element
        if field_key in _SPLIT_LIST_FIELDS and isinstance(value, list):
            # Get company name list to add context to each work description
            corp_list = resume.get("corp_nm_tks", []) if field_key == "work_desc_tks" else []
            project_list = resume.get("project_tks", []) if field_key == "project_desc_tks" else []
            # Get detailed info for each work experience entry (time period, years)
            work_details = resume.get("_work_exp_details", []) if field_key == "work_desc_tks" else []

            for idx, item in enumerate(value):
                item_text = str(item).strip()
                if not item_text:
                    continue

                # Add company/project name prefix to each description for context
                if field_key == "work_desc_tks" and idx < len(work_details):
                    # Use detailed info to build prefix, including company, time range, years
                    detail = work_details[idx]
                    company = detail.get("company", "")
                    start_d = detail.get("start_date", "")
                    end_d = detail.get("end_date", "")
                    years = detail.get("years", 0)
                    # Build time range text
                    time_parts = []
                    if start_d:
                        time_range = f"{start_d}-{end_d}" if end_d else str(start_d)
                        time_parts.append(time_range)
                    if years > 0:
                        time_parts.append(f"{years}{'yrs' if _en else '年'}")
                    time_text = " ".join(time_parts)
                    if company and time_text:
                        content_prefix = f"{field_desc}（{company} {time_text}）"
                    elif company:
                        content_prefix = f"{field_desc}（{company}）"
                    else:
                        content_prefix = f"{field_desc}（{'#' if _en else '第'}{idx + 1}{'' if _en else '段'}）"
                elif field_key == "work_desc_tks" and idx < len(corp_list):
                    content_prefix = f"{field_desc}（{corp_list[idx]}）"
                elif field_key == "project_desc_tks" and idx < len(project_list):
                    content_prefix = f"{field_desc}（{project_list[idx]}）"
                else:
                    content_prefix = f"{field_desc}（{'#' if _en else '第'}{idx + 1}{'' if _en else '段'}）"

                if resume_summary:
                    content = f"{content_prefix}: {item_text}\n[{resume_summary}]"
                else:
                    content = f"{content_prefix}: {item_text}"

                chunk = {
                    "content_with_weight": content,
                    "content_ltks": rag_tokenizer.tokenize(content),
                    "content_sm_ltks": rag_tokenizer.fine_grained_tokenize(
                        rag_tokenizer.tokenize(content)
                    ),
                }
                chunk.update(doc)

                # Redundantly write identity fields
                for mk, mv in identity_meta.items():
                    if mk != field_key:
                        chunk[mk] = mv

                # Tokenization result for current segment
                chunk[field_key] = rag_tokenizer.tokenize(item_text)
                chunks.append(chunk)
            continue

        # Merge list values into text
        if isinstance(value, list):
            text_value = " ".join(str(v) for v in value if v)
        else:
            text_value = str(value)

        if not text_value.strip():
            continue

        # Build chunk content: "field_desc: field_value", append summary for semantic association
        if resume_summary and field_key not in ("name_kwd", "phone_kwd"):
            content = f"{field_desc}: {text_value}\n[{resume_summary}]"
        else:
            content = f"{field_desc}: {text_value}"

        chunk = {
            "content_with_weight": content,
            "content_ltks": rag_tokenizer.tokenize(content),
            "content_sm_ltks": rag_tokenizer.fine_grained_tokenize(
                rag_tokenizer.tokenize(content)
            ),
        }
        chunk.update(doc)

        # Redundantly write identity fields (do not overwrite the current field's own value)
        for mk, mv in identity_meta.items():
            if mk != field_key:
                chunk[mk] = mv

        # Write resume field value into the chunk's corresponding field (for structured retrieval)
        if field_key.endswith("_tks"):
            chunk[field_key] = rag_tokenizer.tokenize(text_value)
        elif field_key.endswith("_kwd"):
            if isinstance(value, list):
                chunk[field_key] = value
            else:
                chunk[field_key] = text_value
        elif field_key.endswith("_int"):
            try:
                chunk[field_key] = int(value)
            except (ValueError, TypeError):
                pass
        elif field_key.endswith("_flt"):
            try:
                chunk[field_key] = float(value)
            except (ValueError, TypeError):
                pass
        else:
            chunk[field_key] = text_value

        chunks.append(chunk)

    # If no chunks were generated, create at least one chunk containing the name
    if not chunks:
        name = resume.get("name_kwd", "Unknown" if _en else "未知")
        content = f"{'Name' if _en else '姓名'}: {name}"
        chunk = {
            "content_with_weight": content,
            "content_ltks": rag_tokenizer.tokenize(content),
            "content_sm_ltks": rag_tokenizer.fine_grained_tokenize(
                rag_tokenizer.tokenize(content)
            ),
        }
        chunk.update(doc)
        chunks.append(chunk)

    # Write coordinate info to each chunk (position_int, page_num_int, top_int)
    #
    # Resume chunks are split by semantic fields (basic info, education, work description, etc.),
    # not by PDF physical regions. Field values may be scattered across multiple locations in the PDF,
    # and using text matching to reverse-lookup coordinates would cause disordered sorting.
    #
    # Therefore, assign incrementing coordinates based on chunk generation order (i.e., semantic logical order),
    # ensuring display order: basic info -> education -> skills/certs -> work overview -> work desc -> project desc...
    #
    # add_positions input format: [(page, left, right, top, bottom), ...]
    #   - page starts from 0, function internally stores +1
    #   - task_executor sorts by page_num_int and top_int (page first, then Y coordinate)
    from rag.nlp import add_positions

    for i, ck in enumerate(chunks):
        # All chunks placed on page=0, top increments by index to ensure logical ordering
        add_positions(ck, [[0, 0, 0, i, i]])

    return chunks

def _blackout_text_regions(image: "np.ndarray", meta_blocks: list[dict], page_idx: int,
                           pdf_to_img_scale: float) -> "np.ndarray":
    """
    Black out metadata-extracted text regions on the page image to prevent OCR duplication.

    Ref: SmartResume blackout strategy — extract metadata text first, black out those regions,
    then run OCR on the blacked-out image so it only recognizes content metadata missed.
    More reliable than IoU-based deduplication.

    Args:
        image: Page image (numpy array)
        meta_blocks: Text blocks from metadata extraction
        page_idx: Current page number
        pdf_to_img_scale: Scale factor from PDF coordinates to image coordinates
    Returns:
        Image with text regions blacked out
    """
    import cv2
    blacked = image.copy()
    page_blocks = [b for b in meta_blocks if b.get("page") == page_idx]
    # Draw filled black rectangles over each metadata text block
    padding = 2  # Extra pixels to ensure full coverage
    for b in page_blocks:
        x0 = int(b["x0"] * pdf_to_img_scale) - padding
        y0 = int(b["top"] * pdf_to_img_scale) - padding
        x1 = int(b["x1"] * pdf_to_img_scale) + padding
        y1 = int(b["bottom"] * pdf_to_img_scale) + padding
        # Clamp to image boundaries
        x0 = max(0, x0)
        y0 = max(0, y0)
        x1 = min(blacked.shape[1], x1)
        y1 = min(blacked.shape[0], y1)
        cv2.rectangle(blacked, (x0, y0), (x1, y1), (0, 0, 0), -1)
    return blacked



def chunk(filename, binary, tenant_id, from_page=0, to_page=100000,
          lang="Chinese", callback=None, **kwargs):
    """
    Resume parsing entry function (compatible with task_executor.py)

    This function is the entry point registered as FACTORY[ParserType.RESUME.value],
    with a signature consistent with other parsers (e.g., naive.chunk).

    Args:
        filename: File name
        binary: File binary content
        from_page: Start page number (not used in resume parsing)
        to_page: End page number (not used in resume parsing)
        lang: Language, default "Chinese"
        callback: Progress callback function, accepts (progress, message) parameters
        **kwargs: Other parameters (parser_config, kb_id, tenant_id, etc.)
    Returns:
        Document chunk list
    """
    if callback is None:
        def callback(prog, msg): return None

    try:
        callback(0.1, "Starting resume parsing...")

        # Parse resume
        resume, lines, line_positions = parse_resume(filename, binary, tenant_id , lang)
        callback(0.6, "Resume structured extraction complete")

        # Build document chunks (with coordinate info)
        chunks = _build_chunk_document(filename, resume, lang)
        callback(0.9, f"Document chunk construction complete, {len(chunks)} chunks total")

        callback(1.0, "Resume parsing complete")
        return chunks

    except Exception as e:
        logger.exception(f"Resume parsing exception: {filename}")
        callback(-1, f"Resume parsing failed: {str(e)}")
        return []


def _resort_page_with_layout(page_blocks: list[dict], layout_regions: list[dict]) -> list[dict]:
    if not page_blocks:
        return []

    if not layout_regions:
        return sorted(page_blocks, key=lambda b: (
            (b.get("top", 0) + b.get("bottom", 0)) / 2,
            (b.get("x0", 0) + b.get("x1", 0)) / 2,
        ))

    type_groups: dict[str, list] = {}
    for lt in layout_regions:
        tp = lt.get("type", "")
        type_groups.setdefault(tp, []).append(lt)
    entries = []
    for tp, group in type_groups.items():
        for idx, lt in enumerate(group):
            key = f"{tp}-{idx}"
            x0, x1 = lt.get("x0", 0), lt.get("x1", 0)
            top, bottom = lt.get("top", 0), lt.get("bottom", 0)
            entries.append({
                "key": key, "type": tp,
                "x0": x0, "top": top, "x1": x1, "bottom": bottom,
                "cy": (top + bottom) / 2, "cx": (x0 + x1) / 2,
            })

    for b in page_blocks:
        if b.get("layoutno"):
            continue
        b_cx = (b.get("x0", 0) + b.get("x1", 0)) / 2
        b_cy = (b.get("top", 0) + b.get("bottom", 0)) / 2
        for entry in entries:
            if (entry["x0"] <= b_cx <= entry["x1"]
                    and entry["top"] <= b_cy <= entry["bottom"]):
                b["layoutno"] = entry["key"]
                b["layout_type"] = entry["type"]
                break

    for entry in entries:
        layout_key = entry["key"]
        layout_area = (entry["x1"] - entry["x0"]) * (entry["bottom"] - entry["top"])
        if layout_area <= 0:
            continue
        layout_blocks = [b for b in page_blocks if b.get("layoutno") == layout_key]
        if not layout_blocks:
            continue
        text_total_area = sum(
            (b.get("x1", 0) - b.get("x0", 0)) * (b.get("bottom", 0) - b.get("top", 0))
            for b in layout_blocks
        )
        if text_total_area / layout_area < 0.075:
            for b in layout_blocks:
                b["layoutno"] = ""
                b["layout_type"] = ""

    entry_map = {e["key"]: e for e in entries}
    for b in page_blocks:
        b_cx = (b.get("x0", 0) + b.get("x1", 0)) / 2
        b_cy = (b.get("top", 0) + b.get("bottom", 0)) / 2
        b["_x_center"] = b_cx
        b["_y_center"] = b_cy
        layoutno = b.get("layoutno", "")
        if layoutno and layoutno in entry_map:
            b["_lx_center"] = entry_map[layoutno]["cx"]
            b["_ly_center"] = entry_map[layoutno]["cy"]
        else:
            b["_lx_center"] = b_cx
            b["_ly_center"] = b_cy

    active_keys = {b.get("layoutno") for b in page_blocks if b.get("layoutno")}
    active_entries = [e for e in entries if e["key"] in active_keys]

    for b in page_blocks:
        if b.get("layoutno"):
            continue
        if not active_entries:
            continue
        b_cx, b_cy = b["_x_center"], b["_y_center"]
        min_dist = float("inf")
        best_cx, best_cy = b_cx, b_cy
        for ae in active_entries:
            lx1, ly1, lx2, ly2 = ae["x0"], ae["top"], ae["x1"], ae["bottom"]
            if b_cy < ly1:
                dy = ly1 - b_cy
            elif b_cy > ly2:
                dy = b_cy - ly2
            else:
                dy = 0
            if b_cx < lx1:
                dx = lx1 - b_cx
            elif b_cx > lx2:
                dx = b_cx - lx2
            else:
                dx = 0
            dist = (dx ** 2 + dy ** 2) ** 0.5
            if dist < min_dist:
                min_dist = dist
                best_cx, best_cy = ae["cx"], ae["cy"]
        b["_lx_center"] = best_cx
        b["_ly_center"] = best_cy

    sorted_blocks = sorted(page_blocks, key=lambda b: (
        b.get("_ly_center", 0),
        b.get("_lx_center", 0),
        b.get("_y_center", 0),
        b.get("_x_center", 0),
    ))

    for b in sorted_blocks:
        b.pop("_ly_center", None)
        b.pop("_lx_center", None)
        b.pop("_y_center", None)
        b.pop("_x_center", None)

    return sorted_blocks


def _layout_detect_reorder(blocks: list[dict], binary: bytes) -> list[dict]:
    if not blocks:
        return blocks

    recognizer = _get_layout_recognizer()
    if recognizer is None:
        logger.info("Layout detector unavailable, falling back to heuristic sorting")
        return _layout_aware_reorder(blocks)

    try:
        import pdfplumber
        pages_blocks: dict[int, list[dict]] = {}
        for b in blocks:
            pg = b.get("page", 0)
            pages_blocks.setdefault(pg, []).append(b)

        page_indices = sorted(pages_blocks.keys())
        image_list = []
        ocr_res_per_page = []

        with pdfplumber.open(BytesIO(binary)) as pdf:
            for pg in page_indices:
                if pg >= len(pdf.pages):
                    continue
                page = pdf.pages[pg]
                pil_img = page.to_image(resolution=72 * 3).annotated
                image_list.append(pil_img)

                page_bxs = []
                for b in pages_blocks[pg]:
                    page_bxs.append({
                        "x0": float(b["x0"]),
                        "top": float(b["top"]),
                        "x1": float(b["x1"]),
                        "bottom": float(b["bottom"]),
                        "text": b["text"],
                        "page": pg,
                    })
                ocr_res_per_page.append(page_bxs)

        if not image_list:
            return _layout_aware_reorder(blocks)

        tagged_blocks, page_layouts = recognizer(
            image_list, ocr_res_per_page, scale_factor=3, thr=0.2, drop=False
        )

        if not tagged_blocks:
            logger.warning("Layout detector unavailable, falling back to heuristic sorting")
            return _layout_aware_reorder(blocks)

        tagged_per_page: dict[int, list[dict]] = {}
        for b in tagged_blocks:
            pg = b.get("page", 0)
            tagged_per_page.setdefault(pg, []).append(b)

        sorted_all = []
        total_layout_count = 0
        for pn, pg in enumerate(page_indices):
            page_bxs = tagged_per_page.get(pg, [])
            lts = page_layouts[pn] if pn < len(page_layouts) else []
            total_layout_count += len(lts)
            sorted_page = _resort_page_with_layout(page_bxs, lts)
            sorted_all.extend(sorted_page)

        for b in sorted_all:
            if "page" not in b:
                b["page"] = 0

        logger.info(f"YOLOv10 detector completed， {len(sorted_all)} total chunks，"
                    f"checked {total_layout_count} layout")
        return sorted_all

    except Exception as e:
        logger.warning(f"Layout detector unavailable, falling back to heuristic sorting: {e}")
        return _layout_aware_reorder(blocks)



def _text_shingles(text: str, n: int = 5) -> set[tuple[int, ...]]:
    """
    Generate text fingerprint set using tiktoken BPE tokenization + n-gram shingling.

    Compared to character-level splitting, BPE tokens have better granularity,
    and n-grams preserve word order, providing more accurate overlap measurement.

    Args:
        text: Original text
        n: Shingling window size, default 5
    Returns:
        Set of n-gram shingles (each shingle is a tuple of token ids)
    """
    if not text or _tiktoken_encoding is None:
        return set()
    tokens = _tiktoken_encoding.encode(text)
    if len(tokens) < n:
        # Text too short: return the entire token sequence as a single shingle
        return {tuple(tokens)} if tokens else set()
    return {tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)}


def _shingling_jaccard(text1: str, text2: str, n: int = 5) -> float:
    """
    Compute Jaccard similarity between two texts using tiktoken shingling.

    Args:
        text1: First text
        text2: Second text
        n: Shingling window size
    Returns:
        Jaccard similarity [0.0, 1.0]
    """
    s1 = _text_shingles(text1, n=n)
    s2 = _text_shingles(text2, n=n)
    union = s1 | s2
    if not union:
        return 1.0
    return len(s1 & s2) / len(union)
