File size: 28,648 Bytes
b4f2238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
"""
外部模板分析服务

负责模板上传校验、文件保存、版本管理。
结构提取和 LLM 分析方法将在后续任务 (6.2, 6.3) 中实现。

Requirements: 1.1, 1.2, 1.3, 1.6, 1.7, 1.8, 9.1, 9.2, 9.3, 9.4
"""

from __future__ import annotations

import json
import logging
import re
import uuid
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
from xml.etree import ElementTree as ET

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.translation import gettext_lazy as _

from .fingerprint_service import FingerprintService

if TYPE_CHECKING:
    from django.core.files.uploadedfile import UploadedFile

    from apps.documents.models.external_template import ExternalTemplate
    from apps.documents.services.placeholders.registry import PlaceholderRegistry

logger: logging.Logger = logging.getLogger(__name__)


class AnalysisService:
    """外部模板分析服务:上传校验 + 结构提取 + LLM 字段映射"""

    MAX_FILE_SIZE: ClassVar[int] = 20 * 1024 * 1024  # 20MB

    def __init__(
        self,
        fingerprint_service: FingerprintService,
        llm_service: Any,
        placeholder_registry: PlaceholderRegistry,
    ) -> None:
        self._fingerprint_service = fingerprint_service
        self._llm_service = llm_service
        self._placeholder_registry = placeholder_registry

    # ------------------------------------------------------------------
    # 上传与校验
    # ------------------------------------------------------------------

    def upload_template(
        self,
        file: UploadedFile,
        name: str,
        source_name: str,
        uploaded_by: Any,
    ) -> ExternalTemplate:
        """
        上传外部模板:
        1. 校验 .docx 格式、文件大小 ≤ 20MB
        2. 保存文件(UUID 重命名)
        3. 验证 python-docx 可解析
        4. 处理版本管理(同机构 + 类别组合自增版本号)
        5. 创建 ExternalTemplate 记录
        """
        from apps.documents.models.external_template import ExternalTemplate

        self._validate_file(file)

        law_firm_id: int = uploaded_by.law_firm_id
        abs_path, rel_path = self._save_file(file, law_firm_id)

        try:
            self._validate_parseable(abs_path)
        except ValidationError:
            # 解析失败时删除已保存的文件
            if abs_path.exists():
                abs_path.unlink()
                logger.info("已删除无法解析的文件: %s", abs_path)
            raise

        file_size: int = file.size if file.size is not None else 0
        original_filename: str = file.name if file.name else ""

        with transaction.atomic():
            version, deactivated = self._handle_versioning(
                law_firm_id=law_firm_id,
                source_name=source_name,
            )

            template: ExternalTemplate = ExternalTemplate.objects.create(
                name=name,
                source_name=source_name,
                file_path=rel_path,
                original_filename=original_filename,
                file_size=file_size,
                version=version,
                is_active=True,
                uploaded_by=uploaded_by,
                law_firm_id=law_firm_id,
            )

        logger.info(
            "模板上传成功: id=%d, name=%s, version=%d, deactivated=%d",
            template.id,
            template.name,
            version,
            deactivated,
        )
        return template

    def _validate_file(self, file: UploadedFile) -> None:
        """校验 .docx 格式和文件大小"""
        filename: str = file.name if file.name else ""
        if not filename.lower().endswith(".docx"):
            logger.info("文件格式校验失败: %s", filename)
            raise ValidationError(_("仅支持 .docx 格式"))

        file_size: int = file.size if file.size is not None else 0
        if file_size > self.MAX_FILE_SIZE:
            logger.info(
                "文件大小超出限制: %s, size=%d",
                filename,
                file_size,
            )
            raise ValidationError(_("文件大小超出限制"))

    def _validate_parseable(self, file_path: Path) -> None:
        """尝试 python-docx 打开文件,验证可解析性"""
        try:
            from docx import Document as DocxDocument

            DocxDocument(str(file_path))
        except Exception as exc:
            logger.info(
                "文件无法解析: %s, error=%s",
                file_path.name,
                str(exc),
            )
            raise ValidationError(_("文件无法解析,请检查文件是否损坏或加密")) from exc

    def _save_file(self, file: UploadedFile, law_firm_id: int) -> tuple[Path, str]:
        """
        UUID 重命名保存文件

        Returns:
            (绝对路径, 相对于 MEDIA_ROOT 的路径)
        """
        media_root = Path(settings.MEDIA_ROOT)
        rel_dir = Path("documents") / "external_templates" / str(law_firm_id)
        abs_dir = media_root / rel_dir
        abs_dir.mkdir(parents=True, exist_ok=True)

        new_filename = f"{uuid.uuid4()}.docx"
        rel_path = rel_dir / new_filename
        abs_path = media_root / rel_path

        with abs_path.open("wb") as dest:
            for chunk in file.chunks():
                dest.write(chunk)

        logger.info("文件已保存: %s", abs_path)
        return abs_path, str(rel_path)

    # ------------------------------------------------------------------
    # 版本管理
    # ------------------------------------------------------------------

    def _handle_versioning(
        self,
        *,
        law_firm_id: int,
        source_name: str,
    ) -> tuple[int, int]:
        """
        处理版本管理:同一来源自增版本号,旧版本 is_active=False

        Returns:
            (新版本号, 被停用的旧版本数量)
        """
        from apps.documents.models.external_template import ExternalTemplate

        existing = ExternalTemplate.objects.filter(
            law_firm_id=law_firm_id,
            source_name=source_name,
        )

        max_version: int | None = existing.order_by("-version").values_list("version", flat=True).first()

        new_version: int = (max_version or 0) + 1

        deactivated: int = existing.filter(is_active=True).update(is_active=False)

        return new_version, deactivated

    # ------------------------------------------------------------------
    # 结构提取 (Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8)
    # ------------------------------------------------------------------

    def extract_structure(self, template_id: int) -> dict[str, Any]:
        """
        从 .docx 提取文档结构:段落、表格(含嵌套)、复选框、删除不适用项。

        1. 从数据库加载模板记录,获取 file_path
        2. 使用 python-docx 打开文件
        3. 调用 _extract_paragraphs / _extract_tables / _extract_checkboxes
        4. 将结构 JSON 保存到模板记录的 structure_json 字段
        5. 返回结构 dict
        """
        from apps.documents.models.external_template import ExternalTemplate

        template: ExternalTemplate = ExternalTemplate.objects.get(pk=template_id)
        abs_path = Path(settings.MEDIA_ROOT) / template.file_path

        from docx import Document as DocxDocument

        doc: Any = DocxDocument(str(abs_path))

        paragraphs = self._extract_paragraphs(doc)
        tables = self._extract_tables(doc)
        checkboxes = self._extract_checkboxes(doc)

        structure: dict[str, Any] = {
            "paragraphs": paragraphs,
            "tables": tables,
            "checkboxes": checkboxes,
        }

        template.structure_json = structure
        template.save(update_fields=["structure_json", "updated_at"])

        logger.info(
            "结构提取完成: template_id=%d, paragraphs=%d, tables=%d, checkboxes=%d",
            template_id,
            len(paragraphs),
            len(tables),
            len(checkboxes),
        )
        return structure

    def _extract_paragraphs(self, doc: Any) -> list[dict[str, Any]]:
        """
        提取段落文本和位置索引。

        对每个段落记录:paragraph_index、text、position_locator。
        同时检测"删除不适用项"模式。
        """
        paragraphs: list[dict[str, Any]] = []

        for idx, para in enumerate(doc.paragraphs):
            text: str = para.text.strip()
            if not text:
                continue

            entry: dict[str, Any] = {
                "paragraph_index": idx,
                "text": text,
                "position_locator": {
                    "type": "paragraph",
                    "paragraph_index": idx,
                },
            }

            options = self._detect_delete_inapplicable(text)
            if options is not None:
                entry["delete_inapplicable"] = options
                entry["position_locator"]["type"] = "delete_inapplicable"

            paragraphs.append(entry)

        return paragraphs

    def _extract_tables(self, doc: Any) -> list[dict[str, Any]]:
        """
        提取表格结构(递归嵌套、合并单元格)。

        对每个表格遍历行和单元格,处理合并单元格(通过 _tc 检测),
        递归处理嵌套表格。
        """
        tables: list[dict[str, Any]] = []

        for table_idx, table in enumerate(doc.tables):
            table_data = self._extract_single_table(table, table_idx, [])
            tables.append(table_data)

        return tables

    def _extract_single_table(
        self,
        table: Any,
        table_index: int,
        nested_path: list[int],
    ) -> dict[str, Any]:
        """递归提取单个表格的结构,通过 XML 正确处理合并单元格。"""
        ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
        rows_data: list[dict[str, Any]] = []
        tbl = table._tbl
        tr_elements = tbl.findall("w:tr", ns)

        for row_idx, tr in enumerate(tr_elements):
            cells_data: list[dict[str, Any]] = []
            col_cursor = 0

            for tc in tr.findall("w:tc", ns):
                tc_pr = tc.find("w:tcPr", ns)
                grid_span = 1
                is_v_merge_continue = False

                if tc_pr is not None:
                    gs = tc_pr.find("w:gridSpan", ns)
                    if gs is not None:
                        grid_span = int(gs.get(f"{{{ns['w']}}}val", "1"))
                    vm = tc_pr.find("w:vMerge", ns)
                    if vm is not None:
                        val = vm.get(f"{{{ns['w']}}}val", "continue")
                        is_v_merge_continue = val != "restart"

                if is_v_merge_continue:
                    col_cursor += grid_span
                    continue

                texts = [t.text or "" for t in tc.findall(".//w:t", ns)]
                text = "".join(texts).strip()

                cell_entry: dict[str, Any] = {
                    "row": row_idx,
                    "col": col_cursor,
                    "text": text,
                    "position_locator": {
                        "type": "table_cell",
                        "table_index": table_index,
                        "row": row_idx,
                        "col": col_cursor,
                        "nested_table_path": nested_path,
                    },
                }
                if grid_span > 1:
                    cell_entry["col_span"] = grid_span

                options = self._detect_delete_inapplicable(text)
                if options is not None:
                    cell_entry["delete_inapplicable"] = options
                    cell_entry["position_locator"]["type"] = "delete_inapplicable"

                # 递归处理嵌套表格(通过 python-docx cell 对象)
                # 找到对应的 python-docx cell
                if col_cursor < len(table.rows[row_idx].cells):
                    py_cell = table.rows[row_idx].cells[col_cursor]
                    nested_tables: list[dict[str, Any]] = []
                    for nested_idx, nested_table in enumerate(py_cell.tables):
                        nested_data = self._extract_single_table(
                            nested_table,
                            nested_idx,
                            [*nested_path, table_index],
                        )
                        nested_tables.append(nested_data)
                    if nested_tables:
                        cell_entry["nested_tables"] = nested_tables

                cells_data.append(cell_entry)
                col_cursor += grid_span

            rows_data.append({"row_index": row_idx, "cells": cells_data})

        return {
            "table_index": table_index,
            "nested_table_path": nested_path,
            "rows": rows_data,
        }

    def _extract_checkboxes(self, doc: Any) -> list[dict[str, Any]]:
        """
        识别复选框控件。

        解析文档 XML,查找 w14:checkbox 或 w:sdt 中包含复选框的结构化文档标签。
        """
        checkboxes: list[dict[str, Any]] = []
        ns: dict[str, str] = {
            "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
            "w14": "http://schemas.microsoft.com/office/word/2010/wordml",
        }

        body_xml: str = doc.element.xml
        try:
            root: ET.Element = ET.fromstring(body_xml)
        except ET.ParseError:
            logger.info("复选框提取: XML 解析失败,跳过")
            return checkboxes

        # 查找所有 sdt(结构化文档标签)
        sdt_elements = root.iter(f"{{{ns['w']}}}sdt")
        checkbox_index = 0

        for sdt in sdt_elements:
            sdt_pr = sdt.find(f"{{{ns['w']}}}sdtPr", ns)
            if sdt_pr is None:
                continue

            # 检查是否为复选框:w14:checkbox 或 w:checkbox
            is_checkbox = (
                sdt_pr.find(f"{{{ns['w14']}}}checkbox", ns) is not None
                or sdt_pr.find(f"{{{ns['w']}}}checkbox", ns) is not None
            )
            if not is_checkbox:
                continue

            # 提取关联文本标签
            sdt_content = sdt.find(f"{{{ns['w']}}}sdtContent", ns)
            label_text = ""
            if sdt_content is not None:
                runs = sdt_content.iter(f"{{{ns['w']}}}t")
                label_parts: list[str] = [r.text for r in runs if r.text]
                label_text = "".join(label_parts)

            # 尝试获取 checked 状态
            checked = False
            checkbox_el = sdt_pr.find(f"{{{ns['w14']}}}checkbox", ns)
            if checkbox_el is None:
                checkbox_el = sdt_pr.find(f"{{{ns['w']}}}checkbox", ns)
            if checkbox_el is not None:
                checked_el = checkbox_el.find(f"{{{ns['w14']}}}checked", ns)
                if checked_el is None:
                    checked_el = checkbox_el.find(f"{{{ns['w']}}}checked", ns)
                if checked_el is not None:
                    val = checked_el.get(
                        f"{{{ns['w14']}}}val",
                        checked_el.get(f"{{{ns['w']}}}val", "0"),
                    )
                    checked = val in ("1", "true")

            checkboxes.append(
                {
                    "checkbox_index": checkbox_index,
                    "label": label_text,
                    "checked": checked,
                    "position_locator": {
                        "type": "checkbox",
                        "checkbox_index": checkbox_index,
                    },
                }
            )
            checkbox_index += 1

        logger.info("复选框提取完成: 共 %d 个", len(checkboxes))
        return checkboxes

    def _detect_delete_inapplicable(self, text: str) -> list[str] | None:
        """
        检测"删除不适用项"格式(如"自然人/法人/非法人组织")。

        使用正则匹配以 / 或 / 分隔的选项模式。
        返回选项列表,若未检测到则返回 None。
        """
        # 匹配 "A/B/C" 或 "A/B/C" 模式,至少 2 个选项
        pattern = r"([\u4e00-\u9fff\w]+(?:[//][\u4e00-\u9fff\w]+){1,})"
        match = re.search(pattern, text)
        if match is None:
            return None

        matched_text: str = match.group(1)
        options: list[str] = re.split(r"[//]", matched_text)

        # 过滤:每个选项至少 1 个中文字符,才认为是"删除不适用项"
        has_chinese = all(re.search(r"[\u4e00-\u9fff]", opt) for opt in options)
        if not has_chinese:
            return None

        return options

    # ------------------------------------------------------------------
    # LLM 分析 (Requirements: 3.3, 3.4, 3.5, 4.1–4.8, 5.6, 5.7, 11.2, 11.6)
    # ------------------------------------------------------------------

    def analyze_template(self, template_id: int) -> list[Any]:
        """
        分析模板并生成字段映射:
        1. 提取结构 → 计算指纹
        2. 指纹匹配 → 命中则复用映射(标注 mapping_source)
        3. 未命中 → 构建 LLM 提示词 → 调用 LLM → 解析结果
        4. 创建 FieldMapping 记录
        5. 更新模板状态为 ready
        """
        from apps.documents.models.choices import TemplateStatus
        from apps.documents.models.external_template import ExternalTemplate, ExternalTemplateFieldMapping

        template: ExternalTemplate = ExternalTemplate.objects.get(pk=template_id)

        # 更新状态为 analyzing
        template.status = TemplateStatus.ANALYZING
        template.save(update_fields=["status", "status_changed_at", "updated_at"])

        try:
            # 1. 提取结构
            structure_json: dict[str, Any] = self.extract_structure(template_id)

            # 2. 计算指纹
            abs_path = Path(settings.MEDIA_ROOT) / template.file_path
            fingerprint: str = self._fingerprint_service.compute_fingerprint(abs_path)
            template.structure_fingerprint = fingerprint
            template.save(update_fields=["structure_fingerprint", "updated_at"])

            # 3. 查找匹配模板
            matched: ExternalTemplate | None = self._fingerprint_service.find_matching_template(
                fingerprint, template.law_firm_id
            )

            # 排除自身
            if matched is not None and matched.pk == template.pk:
                matched = None

            created_mappings: list[ExternalTemplateFieldMapping]

            if matched is not None:
                # 复用已有映射
                created_mappings = self._copy_mappings_from(
                    source_template=matched,
                    target_template=template,
                )
                template.mapping_source = matched
                logger.info(
                    "复用映射: template_id=%d, source_id=%d, count=%d",
                    template_id,
                    matched.pk,
                    len(created_mappings),
                )
            else:
                # LLM 分析
                prompt: str = self._build_llm_prompt(structure_json)
                response = self._llm_service.complete(
                    prompt=prompt,
                    system_prompt=("你是一个法律文书模板分析专家。请分析模板结构并返回字段映射的 JSON 数组。"),
                    temperature=0.1,
                    max_tokens=4096,
                )
                raw_mappings: list[dict[str, Any]] = self._parse_llm_response(response.content)
                created_mappings = self._create_field_mappings(template, raw_mappings)
                logger.info(
                    "LLM 分析完成: template_id=%d, mappings=%d",
                    template_id,
                    len(created_mappings),
                )

            # 更新状态为 ready
            template.status = TemplateStatus.READY
            template.save(
                update_fields=[
                    "status",
                    "mapping_source",
                    "status_changed_at",
                    "updated_at",
                ]
            )

            return created_mappings

        except Exception:
            # 分析失败:设置状态并重新抛出
            logger.exception("模板分析失败: template_id=%d", template_id)
            template.refresh_from_db()
            template.status = TemplateStatus.ANALYSIS_FAILED
            template.save(update_fields=["status", "status_changed_at", "updated_at"])
            raise

    def _copy_mappings_from(
        self,
        source_template: Any,
        target_template: Any,
    ) -> list[Any]:
        """从源模板复制映射到目标模板"""
        from apps.documents.models.external_template import ExternalTemplateFieldMapping

        source_mappings = ExternalTemplateFieldMapping.objects.filter(template=source_template)
        created: list[ExternalTemplateFieldMapping] = []
        for m in source_mappings:
            new_mapping = ExternalTemplateFieldMapping.objects.create(
                template=target_template,
                position_locator=m.position_locator,
                position_description=m.position_description,
                semantic_label=m.semantic_label,
                fill_type=m.fill_type,
                sort_order=m.sort_order,
            )
            created.append(new_mapping)
        return created

    def _build_llm_prompt(self, structure_json: dict[str, Any]) -> str:
        """
        构建 LLM 提示词:结构 JSON + fill_type 说明
        """
        structure_text: str = json.dumps(structure_json, ensure_ascii=False, indent=2)

        prompt: str = (
            f"请分析以下法律文书模板的结构,识别所有可填充位置。\n\n"
            f"## 文档结构 JSON\n```json\n{structure_text}\n```\n\n"
            f"## 填充类型说明\n"
            f"- text: 文本替换(替换段落或单元格中的文本)\n"
            f"- checkbox: 勾选复选框\n"
            f"- delete_inapplicable: 删除不适用项(保留匹配项,删除其余选项)\n\n"
            f"## 输出要求\n"
            f"请返回一个 JSON 数组,每个元素包含以下字段:\n"
            f"- position_locator: 位置定位器(直接使用结构 JSON 中的 position_locator)\n"
            f"- semantic_label: 语义标签(中文描述该位置应填写的内容,如'被申请人姓名'、'住所地')\n"
            f"- fill_type: 填充类型(text / checkbox / delete_inapplicable)\n\n"
            f"仅返回 JSON 数组,不要包含其他文字说明。"
        )
        return prompt

    def _parse_llm_response(self, response: str) -> list[dict[str, Any]]:
        """
        解析 LLM 返回的 JSON 映射结果。
        处理 markdown 代码块包裹的情况。
        """
        text: str = response.strip()

        # 去除 markdown 代码块
        if text.startswith("```"):
            # 去除首行 ```json 或 ```
            lines: list[str] = text.split("\n")
            # 找到第一个 ``` 之后的内容
            start_idx = 1
            end_idx = len(lines)
            for i in range(len(lines) - 1, 0, -1):
                if lines[i].strip().startswith("```"):
                    end_idx = i
                    break
            text = "\n".join(lines[start_idx:end_idx])

        try:
            parsed: Any = json.loads(text)
        except json.JSONDecodeError:
            logger.error("LLM 返回的 JSON 解析失败: %s", text[:500])
            raise

        if not isinstance(parsed, list):
            logger.error("LLM 返回的不是 JSON 数组: type=%s", type(parsed).__name__)
            raise ValueError(_("LLM 返回格式异常,期望 JSON 数组"))

        result: list[dict[str, Any]] = []
        for item in parsed:
            if not isinstance(item, dict):
                continue
            result.append(
                {
                    "position_locator": item.get("position_locator", {}),
                    "semantic_label": str(item.get("semantic_label", "")),
                    "fill_type": str(item.get("fill_type", "text")),
                }
            )

        return result

    def _create_field_mappings(
        self,
        template: Any,
        mappings: list[dict[str, Any]],
    ) -> list[Any]:
        """根据解析后的映射数据创建 FieldMapping 记录"""
        from apps.documents.models.choices import FillType
        from apps.documents.models.external_template import ExternalTemplateFieldMapping

        valid_fill_types: set[str] = {ft.value for ft in FillType}
        created: list[ExternalTemplateFieldMapping] = []

        for idx, m in enumerate(mappings):
            fill_type: str = m.get("fill_type", FillType.TEXT)
            if fill_type not in valid_fill_types:
                fill_type = FillType.TEXT

            position_locator: dict[str, Any] = m.get("position_locator", {})
            pos_type: str = str(position_locator.get("type", ""))
            position_description: str = ""
            if pos_type == "paragraph":
                p_idx = position_locator.get("paragraph_index", "")
                position_description = f"段落 {p_idx}"
            elif pos_type == "table_cell":
                t_idx = position_locator.get("table_index", "")
                row = position_locator.get("row", "")
                col = position_locator.get("col", "")
                position_description = f"表格{t_idx} 行{row} 列{col}"
            elif pos_type == "checkbox":
                cb_idx = position_locator.get("checkbox_index", "")
                position_description = f"复选框 {cb_idx}"
            elif pos_type == "delete_inapplicable":
                position_description = "删除不适用项"

            new_mapping = ExternalTemplateFieldMapping.objects.create(
                template=template,
                position_locator=position_locator,
                position_description=position_description,
                semantic_label=m.get("semantic_label", ""),
                fill_type=fill_type,
                sort_order=idx,
            )
            created.append(new_mapping)

        return created

    # ------------------------------------------------------------------
    # 重新分析
    # ------------------------------------------------------------------

    def retry_analysis(self, template_id: int) -> list[Any]:
        """重新触发 LLM 分析:删除旧映射后重新分析。"""
        from apps.documents.models.external_template import ExternalTemplateFieldMapping

        deleted_count, _ = ExternalTemplateFieldMapping.objects.filter(template_id=template_id).delete()
        logger.info(
            "重新分析: template_id=%d, 已删除旧映射 %d 条",
            template_id,
            deleted_count,
        )
        return self.analyze_template(template_id)

    def create_manual_mapping(
        self,
        *,
        template_id: int,
        position_locator: dict[str, Any],
        position_description: str,
        semantic_label: str,
        fill_type: str = "text",
    ) -> Any:
        """手动新增单条映射。"""
        from apps.documents.models.external_template import ExternalTemplate, ExternalTemplateFieldMapping

        template = ExternalTemplate.objects.get(pk=template_id)
        max_order: int = (
            ExternalTemplateFieldMapping.objects.filter(template=template)
            .order_by("-sort_order")
            .values_list("sort_order", flat=True)
            .first()
            or 0
        )
        return ExternalTemplateFieldMapping.objects.create(
            template=template,
            position_locator=position_locator,
            position_description=position_description,
            semantic_label=semantic_label,
            fill_type=fill_type,
            sort_order=max_order + 1,
        )

    def delete_mapping(self, mapping_id: int) -> None:
        """删除单条映射。"""
        from apps.documents.models.external_template import ExternalTemplateFieldMapping

        ExternalTemplateFieldMapping.objects.filter(pk=mapping_id).delete()