Parcourir la source

feat: 添加DOCX解析和要素提取功能

- 新增Python DOCX解析服务(docx_parser.py)
- 新增要素提取器(element_extractor.py)
- 新增extract路由,支持同步/异步解析
- 前端新建报告支持上传DOCX自动解析
- 解析进度在文档列表中实时显示
- 后端添加保存doc_content接口
- 添加NER相关设计文档
何文松 il y a 2 jours
Parent
commit
b876748b37

+ 12 - 0
backend/lingyue-project/src/main/java/com/lingyue/project/project/controller/ProjectController.java

@@ -100,4 +100,16 @@ public class ProjectController {
             return Result.ok(null);
         }
     }
+
+    @Operation(summary = "保存项目文档内容")
+    @PutMapping("/{id}/doc-content")
+    public Result<Void> saveDocContent(@PathVariable Long id, @RequestBody Object docContent) {
+        try {
+            String json = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(docContent);
+            propertyService.setNodePropertyJson(id, "doc_content", json);
+            return Result.ok();
+        } catch (Exception e) {
+            return Result.fail(500, "保存文档内容失败: " + e.getMessage());
+        }
+    }
 }

+ 818 - 0
docs/design/DOCX解析到要素提取完整流程.md

@@ -0,0 +1,818 @@
+# DOCX解析到要素提取完整流程
+
+## 一、整体数据流
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│                         用户上传 DOCX                                │
+└─────────────────────────────┬───────────────────────────────────────┘
+                              │
+                              ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  Java后端 (lingyue-project)                                          │
+│  ├─ AttachmentController.upload() - 上传附件                         │
+│  ├─ DocxParseService.parseToHtml() - 解析为HTML                      │
+│  ├─ 存储 doc_content (结构化JSON) 和 parsed_text (纯文本)             │
+│  └─ API: GET /api/v1/attachments/{id}/parsed-text                    │
+└─────────────────────────────┬───────────────────────────────────────┘
+                              │ 获取 parsed_text
+                              ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  Python NER服务 (ner-service)                                        │
+│  ├─ 输入: parsed_text (纯文本)                                       │
+│  ├─ POST /extract/from-text                                          │
+│  │                                                                   │
+│  │  ┌─────────────────┐     ┌─────────────────────────────────────┐ │
+│  │  │  NER规则提取     │     │  LLM智能提取 (可选)                  │ │
+│  │  │  (8个简单要素)   │     │  (16个总结型要素)                   │ │
+│  │  │  ├─ 日期         │     │  ├─ 目标、职责、安全投入等           │ │
+│  │  │  ├─ 得分、级别   │     │  └─ 需要语义理解的内容               │ │
+│  │  │  └─ 编号、机构   │     │                                     │ │
+│  │  └─────────────────┘     └─────────────────────────────────────┘ │
+│  │                                                                   │
+│  └─ 输出: elements + values                                          │
+└─────────────────────────────┬───────────────────────────────────────┘
+                              │
+                              ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  前端渲染                                                            │
+│  ├─ elements: 要素定义列表                                           │
+│  ├─ values: 要素值列表                                               │
+│  └─ doc_content: 文档渲染内容 (来自Java后端)                          │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+## 架构说明
+
+**职责分离**:
+- **Java后端**:负责DOCX解析(已有`DocxParseService`),存储和管理附件
+- **Python NER服务**:负责要素提取(NER规则 + LLM智能提取)
+- **前端**:调用两个服务,组装数据进行渲染
+
+## 二、前端所需的数据结构
+
+### 2.1 elements(要素定义)
+
+```javascript
+// 来自 elementApi.list(projectId)
+elements = [
+  {
+    id: 701,
+    elementKey: "project.reviewObject",
+    elementName: "评审对象",
+    elementType: "text",        // text | paragraph | table | static
+    namespace: "project",       // 分组命名空间
+    sortOrder: 1,
+    description: "被评审企业的完整名称"
+  },
+  {
+    id: 702,
+    elementKey: "project.workStartAt",
+    elementName: "评审开始日期",
+    elementType: "text",
+    namespace: "project",
+    sortOrder: 2
+  },
+  // ... 共47个要素
+]
+```
+
+### 2.2 values(要素值)
+
+```javascript
+// 来自 valueApi.list(projectId)
+values = [
+  {
+    valueId: 801,
+    elementKey: "project.reviewObject",
+    valueText: "中国电建集团成都勘测设计研究院有限公司",
+    isFilled: true,
+    fillSource: "ai",           // default | manual | rule | ai
+    confidence: 0.95,
+    sourceAttachmentId: 402,
+    extractPosition: { charStart: 100, charEnd: 130, line: 5 }
+  },
+  {
+    valueId: 802,
+    elementKey: "project.workStartAt",
+    valueText: "2024年7月13日",
+    isFilled: true,
+    fillSource: "rule",
+    confidence: 0.98
+  },
+  // ...
+]
+```
+
+### 2.3 doc_content(文档渲染内容)
+
+```javascript
+// 来自 attachmentApi.getDocContent(attachmentId)
+doc_content = {
+  page: {
+    widthMm: 210,
+    heightMm: 297,
+    marginTopMm: 25.4,
+    marginBottomMm: 25.4,
+    marginLeftMm: 31.8,
+    marginRightMm: 31.8
+  },
+  blocks: [
+    {
+      id: "b0",
+      type: "heading1",
+      runs: [{ text: "1 企业概述", bold: true, fontSize: 16 }],
+      style: { alignment: "left" }
+    },
+    {
+      id: "b1",
+      type: "paragraph",
+      runs: [
+        { text: "中国电建集团成都勘测设计研究院有限公司", bold: true },
+        { text: "(以下简称"成都院")是..." }
+      ]
+    },
+    {
+      id: "b2",
+      type: "table",
+      table: {
+        rows: 5,
+        cols: 4,
+        data: [
+          [{ text: "序号" }, { text: "项目名称" }, { text: "简称" }, { text: "类型" }],
+          [{ text: "1" }, { text: "成都院本部" }, { text: "本部" }, { text: "单位" }],
+          // ...
+        ]
+      }
+    },
+    // ...
+  ],
+  totalBlocks: 350
+}
+```
+
+## 三、混合提取策略实现
+
+### 3.1 要素分类与提取方法
+
+| 类别 | 要素数 | 提取方法 | 示例 |
+|------|--------|----------|------|
+| A. 简单结构化 | 8 | NER规则 | 日期、得分、级别、编号 |
+| B. 实体识别 | 2 | NER+后处理 | 评审对象、简称 |
+| C. 总结型 | 18 | LLM | 目标、职责、安全投入等 |
+| D. 列表拼接 | 2 | NER+LLM | 复审范围、工作过程 |
+| E. 表格数据 | 7 | 表格解析+LLM | 项目列表、人员列表 |
+
+### 3.2 NER规则提取(8个要素)
+
+```python
+# 提取规则定义
+NER_RULES = {
+    # 日期类
+    "project.workStartAt": {
+        "patterns": [
+            r'评审(?:开始)?日期[::]\s*(\d{4}年\d{1,2}月\d{1,2}日)',
+            r'(\d{4}年\d{1,2}月\d{1,2}日)至',
+        ],
+        "type": "DATE",
+        "post_process": "take_first"
+    },
+    "project.workEndAt": {
+        "patterns": [
+            r'至(\d{4}年\d{1,2}月\d{1,2}日)',
+            r'评审(?:结束)?日期[::]\s*(\d{4}年\d{1,2}月\d{1,2}日)',
+        ],
+        "type": "DATE",
+        "post_process": "take_last"
+    },
+    
+    # 得分类
+    "project.resultScore": {
+        "patterns": [
+            r'评审得分[::]\s*(\d+\.?\d*)\s*分',
+            r'得分[::]\s*(\d+\.?\d*)',
+        ],
+        "type": "SCORE",
+        "post_process": "to_float"
+    },
+    
+    # 级别类
+    "project.resultLevel": {
+        "patterns": [
+            r'级别[::]\s*(一级|二级|三级)',
+            r'评审(?:结论)?级别[::]\s*(一级|二级|三级)',
+        ],
+        "type": "LEVEL"
+    },
+    
+    # 编号类
+    "basicInfo.projectCode": {
+        "patterns": [
+            r'项目编号[::]\s*([A-Z]+-\d+-\d+)',
+            r'编号[::]\s*([A-Z0-9\-]+)',
+        ],
+        "type": "CODE"
+    },
+    "basicInfo.reviewObjectCertificateCode": {
+        "patterns": [
+            r'证书编号[::]\s*(ZGDIDBOY-\d+)',
+            r'证书编号[::]\s*([A-Z0-9\-]+)',
+        ],
+        "type": "CODE"
+    },
+    
+    # 机构类
+    "project.reviewObject": {
+        "patterns": [
+            r'评审对象[::]\s*([^\n]{10,50}(?:公司|集团|院|所))',
+        ],
+        "type": "ORG"
+    },
+    "project.reviewObjectAlias": {
+        "patterns": [
+            r'以下简称[「『""]([^」』""]{2,10})[」』""]',
+            r'简称[「『""]([^」』""]{2,10})[」』""]',
+        ],
+        "type": "ALIAS"
+    },
+}
+```
+
+### 3.3 LLM提取(27个要素)
+
+```python
+# LLM提取配置
+LLM_EXTRACT_CONFIG = {
+    # 总结型要素(18个)
+    "summary_elements": [
+        {
+            "element_key": "project.target",
+            "element_name": "目标",
+            "source_pattern": r"5\.1\.1\.\d",  # 评审代码匹配
+            "prompt_template": "请根据以下评审意见,总结企业的安全生产目标情况(100-200字):\n{text}"
+        },
+        {
+            "element_key": "project.duty",
+            "element_name": "职责",
+            "source_pattern": r"5\.1\.2\.1",
+            "prompt_template": "请根据以下评审意见,总结企业的安全生产职责落实情况(100-200字):\n{text}"
+        },
+        # ... 其他16个
+    ],
+    
+    # 表格提取要素(7个)
+    "table_elements": [
+        {
+            "element_key": "+SPSRRReviewProject",
+            "element_name": "现场复审项目",
+            "table_type": "review_project",
+            "columns": ["项目名称", "简称", "类型", "排序"],
+            "prompt_template": """
+请从以下文本中提取复审项目列表,以JSON数组格式返回:
+[{"name": "项目名称", "alias": "简称", "type": "单位/在建项目", "order": 1}]
+
+文本:{text}
+"""
+        },
+        # ... 其他6个
+    ]
+}
+```
+
+## 四、核心代码实现
+
+### 4.1 Python提取服务
+
+```python
+# python-services/ner-service/app/services/element_extractor.py
+
+import re
+import json
+from typing import Dict, List, Any, Optional
+from loguru import logger
+
+from .ner_service import ner_service
+from .deepseek_service import deepseek_service
+
+
+class ElementExtractor:
+    """要素提取器:混合NER+LLM策略"""
+    
+    def __init__(self):
+        self.ner_rules = NER_RULES  # 上面定义的规则
+        self.llm_config = LLM_EXTRACT_CONFIG
+    
+    async def extract_all(
+        self, 
+        doc_content: Dict,
+        attachment_id: int
+    ) -> Dict[str, Any]:
+        """
+        从文档内容中提取所有要素
+        
+        Args:
+            doc_content: parse_docx输出的文档结构
+            attachment_id: 附件ID
+            
+        Returns:
+            {
+                "elements": [...],  # 要素定义
+                "values": [...],    # 要素值
+                "statistics": {...} # 提取统计
+            }
+        """
+        # 1. 将blocks转为纯文本
+        full_text = self._blocks_to_text(doc_content['blocks'])
+        tables = self._extract_tables(doc_content['blocks'])
+        
+        logger.info(f"开始提取要素: attachment_id={attachment_id}, "
+                   f"text_length={len(full_text)}, tables={len(tables)}")
+        
+        # 2. NER规则提取(简单要素)
+        ner_values = await self._extract_by_ner(full_text, attachment_id)
+        logger.info(f"NER提取完成: {len(ner_values)} 个要素")
+        
+        # 3. LLM提取(总结型+表格)
+        llm_values = await self._extract_by_llm(full_text, tables, attachment_id)
+        logger.info(f"LLM提取完成: {len(llm_values)} 个要素")
+        
+        # 4. 合并结果
+        all_values = {**ner_values, **llm_values}
+        
+        # 5. 生成elements和values
+        elements, values = self._build_output(all_values, attachment_id)
+        
+        return {
+            "elements": elements,
+            "values": values,
+            "statistics": {
+                "total_elements": len(elements),
+                "filled_values": len([v for v in values if v.get("isFilled")]),
+                "ner_extracted": len(ner_values),
+                "llm_extracted": len(llm_values),
+            }
+        }
+    
+    def _blocks_to_text(self, blocks: List[Dict]) -> str:
+        """将blocks转为纯文本"""
+        lines = []
+        for block in blocks:
+            if block['type'] == 'table':
+                # 表格转为文本
+                table = block.get('table', {})
+                for row in table.get('data', []):
+                    cells = [cell.get('text', '') for cell in row]
+                    lines.append(' | '.join(cells))
+                lines.append('')  # 空行分隔
+            else:
+                # 段落
+                runs = block.get('runs', [])
+                text = ''.join(r.get('text', '') for r in runs)
+                lines.append(text)
+        return '\n'.join(lines)
+    
+    def _extract_tables(self, blocks: List[Dict]) -> List[Dict]:
+        """提取所有表格"""
+        tables = []
+        for i, block in enumerate(blocks):
+            if block['type'] == 'table':
+                tables.append({
+                    'block_id': block['id'],
+                    'block_index': i,
+                    'table': block.get('table', {})
+                })
+        return tables
+    
+    async def _extract_by_ner(
+        self, 
+        text: str, 
+        attachment_id: int
+    ) -> Dict[str, Dict]:
+        """NER规则提取"""
+        results = {}
+        
+        for element_key, rule in self.ner_rules.items():
+            for pattern in rule['patterns']:
+                match = re.search(pattern, text)
+                if match:
+                    value = match.group(1)
+                    
+                    # 后处理
+                    if rule.get('post_process') == 'to_float':
+                        try:
+                            value = str(float(value))
+                        except:
+                            pass
+                    
+                    results[element_key] = {
+                        'value': value,
+                        'confidence': 0.95,
+                        'source': 'ner',
+                        'position': {
+                            'charStart': match.start(1),
+                            'charEnd': match.end(1),
+                            'line': text[:match.start()].count('\n') + 1
+                        }
+                    }
+                    break  # 找到第一个匹配就停止
+        
+        return results
+    
+    async def _extract_by_llm(
+        self, 
+        text: str, 
+        tables: List[Dict],
+        attachment_id: int
+    ) -> Dict[str, Dict]:
+        """LLM智能提取"""
+        results = {}
+        
+        # 1. 提取总结型要素
+        for config in self.llm_config['summary_elements']:
+            element_key = config['element_key']
+            
+            # 查找相关文本(基于评审代码)
+            source_pattern = config.get('source_pattern')
+            if source_pattern:
+                relevant_text = self._find_relevant_text(text, source_pattern)
+            else:
+                relevant_text = text[:5000]  # 取前5000字
+            
+            if relevant_text:
+                prompt = config['prompt_template'].format(text=relevant_text)
+                try:
+                    response = await deepseek_service.chat(prompt)
+                    results[element_key] = {
+                        'value': response.strip(),
+                        'confidence': 0.85,
+                        'source': 'llm'
+                    }
+                except Exception as e:
+                    logger.error(f"LLM提取失败: {element_key}, error={e}")
+        
+        # 2. 提取表格型要素
+        for config in self.llm_config['table_elements']:
+            element_key = config['element_key']
+            
+            # 找到相关表格
+            relevant_table = self._find_relevant_table(tables, config['table_type'])
+            if relevant_table:
+                prompt = config['prompt_template'].format(
+                    text=json.dumps(relevant_table, ensure_ascii=False)
+                )
+                try:
+                    response = await deepseek_service.chat(prompt)
+                    # 解析JSON响应
+                    table_data = json.loads(response)
+                    results[element_key] = {
+                        'value': json.dumps(table_data, ensure_ascii=False),
+                        'confidence': 0.80,
+                        'source': 'llm',
+                        'is_table': True
+                    }
+                except Exception as e:
+                    logger.error(f"表格提取失败: {element_key}, error={e}")
+        
+        return results
+    
+    def _find_relevant_text(self, text: str, pattern: str) -> str:
+        """根据评审代码模式查找相关文本"""
+        # 简单实现:查找包含该代码的段落
+        lines = text.split('\n')
+        relevant_lines = []
+        capturing = False
+        
+        for line in lines:
+            if re.search(pattern, line):
+                capturing = True
+            if capturing:
+                relevant_lines.append(line)
+                if len(relevant_lines) > 20:  # 最多取20行
+                    break
+        
+        return '\n'.join(relevant_lines)
+    
+    def _find_relevant_table(self, tables: List[Dict], table_type: str) -> Optional[Dict]:
+        """根据表格类型查找相关表格"""
+        # 基于表头关键词匹配
+        keywords = {
+            'review_project': ['项目名称', '简称', '类型'],
+            'reviewer': ['姓名', '专业', '分工'],
+            'suggestion': ['问题', '建议', '整改'],
+        }
+        
+        target_keywords = keywords.get(table_type, [])
+        
+        for table_info in tables:
+            table = table_info['table']
+            if table.get('data') and len(table['data']) > 0:
+                header_row = table['data'][0]
+                header_texts = [cell.get('text', '') for cell in header_row]
+                
+                # 检查是否包含目标关键词
+                match_count = sum(1 for kw in target_keywords if any(kw in h for h in header_texts))
+                if match_count >= 2:
+                    return table
+        
+        return None
+    
+    def _build_output(
+        self, 
+        extracted_values: Dict[str, Dict],
+        attachment_id: int
+    ) -> tuple:
+        """构建输出的elements和values"""
+        
+        # 要素定义模板
+        ELEMENT_TEMPLATES = {
+            "project.reviewObject": {"name": "评审对象", "type": "text", "namespace": "project"},
+            "project.reviewObjectAlias": {"name": "评审对象简称", "type": "text", "namespace": "project"},
+            "project.workStartAt": {"name": "评审开始日期", "type": "text", "namespace": "project"},
+            "project.workEndAt": {"name": "评审结束日期", "type": "text", "namespace": "project"},
+            "project.resultScore": {"name": "评审得分", "type": "text", "namespace": "project"},
+            "project.resultLevel": {"name": "评审结论级别", "type": "text", "namespace": "project"},
+            "project.target": {"name": "目标", "type": "paragraph", "namespace": "project"},
+            "project.duty": {"name": "职责", "type": "paragraph", "namespace": "project"},
+            # ... 其他要素
+        }
+        
+        elements = []
+        values = []
+        
+        for i, (element_key, template) in enumerate(ELEMENT_TEMPLATES.items()):
+            element = {
+                "id": 700 + i,
+                "elementKey": element_key,
+                "elementName": template["name"],
+                "elementType": template["type"],
+                "namespace": template["namespace"],
+                "sortOrder": i
+            }
+            elements.append(element)
+            
+            # 查找提取的值
+            extracted = extracted_values.get(element_key)
+            if extracted:
+                value = {
+                    "valueId": 800 + i,
+                    "elementKey": element_key,
+                    "valueText": extracted['value'],
+                    "isFilled": True,
+                    "fillSource": "ai" if extracted['source'] == 'llm' else "rule",
+                    "confidence": extracted.get('confidence', 0.8),
+                    "sourceAttachmentId": attachment_id
+                }
+                if 'position' in extracted:
+                    value['extractPosition'] = extracted['position']
+            else:
+                value = {
+                    "valueId": 800 + i,
+                    "elementKey": element_key,
+                    "valueText": "",
+                    "isFilled": False,
+                    "fillSource": "default"
+                }
+            
+            values.append(value)
+        
+        return elements, values
+
+
+# 创建单例
+element_extractor = ElementExtractor()
+```
+
+### 4.2 API接口
+
+```python
+# python-services/ner-service/app/routers/extract.py
+
+from fastapi import APIRouter, HTTPException, UploadFile, File
+from pydantic import BaseModel
+from typing import Dict, Any, Optional
+import json
+
+from ..services.element_extractor import element_extractor
+from ..services.docx_parser import parse_docx_file
+
+router = APIRouter()
+
+
+class ExtractRequest(BaseModel):
+    """提取请求"""
+    doc_content: Dict[str, Any]  # parse_docx输出的文档结构
+    attachment_id: int
+
+
+class ExtractResponse(BaseModel):
+    """提取响应"""
+    success: bool
+    elements: list
+    values: list
+    statistics: Dict[str, Any]
+    error: Optional[str] = None
+
+
+@router.post("/extract/from-content", response_model=ExtractResponse)
+async def extract_from_content(request: ExtractRequest):
+    """
+    从已解析的文档内容中提取要素
+    
+    输入: doc_content (parse_docx输出)
+    输出: elements + values
+    """
+    try:
+        result = await element_extractor.extract_all(
+            doc_content=request.doc_content,
+            attachment_id=request.attachment_id
+        )
+        
+        return ExtractResponse(
+            success=True,
+            elements=result['elements'],
+            values=result['values'],
+            statistics=result['statistics']
+        )
+    except Exception as e:
+        return ExtractResponse(
+            success=False,
+            elements=[],
+            values=[],
+            statistics={},
+            error=str(e)
+        )
+
+
+@router.post("/extract/from-docx")
+async def extract_from_docx(
+    file: UploadFile = File(...),
+    attachment_id: int = 0
+):
+    """
+    完整流程:上传DOCX → 解析 → 提取要素
+    
+    输入: DOCX文件
+    输出: doc_content + elements + values
+    """
+    try:
+        # 1. 解析DOCX
+        content = await file.read()
+        doc_content = parse_docx_file(content)
+        
+        # 2. 提取要素
+        result = await element_extractor.extract_all(
+            doc_content=doc_content,
+            attachment_id=attachment_id
+        )
+        
+        return {
+            "success": True,
+            "doc_content": doc_content,
+            "elements": result['elements'],
+            "values": result['values'],
+            "statistics": result['statistics']
+        }
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+```
+
+### 4.3 前端调用示例
+
+```javascript
+// frontend/vue-demo/src/api/extract.js
+
+import api from './index'
+
+export const extractApi = {
+  /**
+   * 上传DOCX并提取要素(完整流程)
+   */
+  async extractFromDocx(file, attachmentId = 0) {
+    const formData = new FormData()
+    formData.append('file', file)
+    formData.append('attachment_id', attachmentId)
+    
+    return api.post('/extract/from-docx', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' },
+      timeout: 120000  // 2分钟超时
+    })
+  },
+  
+  /**
+   * 从已解析的内容中提取要素
+   */
+  async extractFromContent(docContent, attachmentId) {
+    return api.post('/extract/from-content', {
+      doc_content: docContent,
+      attachment_id: attachmentId
+    })
+  }
+}
+```
+
+```vue
+<!-- 使用示例 -->
+<script setup>
+import { ref } from 'vue'
+import { extractApi } from '@/api/extract'
+
+const loading = ref(false)
+const elements = ref([])
+const values = ref([])
+const docContent = ref(null)
+
+async function handleUpload(file) {
+  loading.value = true
+  try {
+    const result = await extractApi.extractFromDocx(file.raw)
+    
+    if (result.success) {
+      docContent.value = result.doc_content
+      elements.value = result.elements
+      values.value = result.values
+      
+      console.log('提取统计:', result.statistics)
+      // { total_elements: 47, filled_values: 35, ner_extracted: 8, llm_extracted: 27 }
+    }
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+```
+
+## 五、实施步骤
+
+### 第一步:DOCX解析服务(已有)
+
+现有的 `parse_docx.py` 已经可以:
+- 解析段落和格式
+- 解析表格
+- 提取图片
+
+**需要改造**:
+- 封装为HTTP服务
+- 支持文件上传接口
+
+### 第二步:NER规则提取(1-2天)
+
+1. 定义8个简单要素的提取规则
+2. 实现规则匹配逻辑
+3. 测试准确率
+
+### 第三步:LLM提取集成(2-3天)
+
+1. 配置DeepSeek API
+2. 设计提示词模板(18个总结型 + 7个表格型)
+3. 实现批量调用和结果解析
+4. 添加错误处理和重试
+
+### 第四步:结果合并与输出(1天)
+
+1. 合并NER和LLM结果
+2. 生成elements和values格式
+3. 添加置信度评分
+
+### 第五步:前端集成(1-2天)
+
+1. 添加上传接口调用
+2. 更新store数据
+3. 触发文档渲染
+
+## 六、预期输出示例
+
+```json
+{
+  "success": true,
+  "doc_content": {
+    "page": { "widthMm": 210, "heightMm": 297 },
+    "blocks": [
+      { "id": "b0", "type": "heading1", "runs": [{"text": "1 企业概述"}] },
+      // ... 350个blocks
+    ],
+    "totalBlocks": 350
+  },
+  "elements": [
+    { "id": 701, "elementKey": "project.reviewObject", "elementName": "评审对象", "elementType": "text" },
+    { "id": 702, "elementKey": "project.workStartAt", "elementName": "评审开始日期", "elementType": "text" },
+    // ... 47个要素
+  ],
+  "values": [
+    { "valueId": 801, "elementKey": "project.reviewObject", "valueText": "中国电建集团成都勘测设计研究院有限公司", "isFilled": true, "fillSource": "rule", "confidence": 0.95 },
+    { "valueId": 802, "elementKey": "project.workStartAt", "valueText": "2024年7月13日", "isFilled": true, "fillSource": "rule", "confidence": 0.98 },
+    { "valueId": 803, "elementKey": "project.target", "valueText": "成都院制定并发布《QHSE"十四五"规划》...", "isFilled": true, "fillSource": "ai", "confidence": 0.85 },
+    // ... 47个值
+  ],
+  "statistics": {
+    "total_elements": 47,
+    "filled_values": 35,
+    "ner_extracted": 8,
+    "llm_extracted": 27
+  }
+}
+```
+
+---
+
+**文档版本**:v1.0  
+**创建时间**:2024-03-04

+ 428 - 0
docs/design/NER与LLM提取能力对比分析.md

@@ -0,0 +1,428 @@
+# NER vs LLM 提取能力对比分析
+
+## 一、智报要素分类与提取难度分析
+
+基于 `_mock_rules_v2.py` 中的47个要素,按提取难度和特征分为5类:
+
+### 类型A:简单结构化数据(6个要素)✅ NER完全覆盖
+
+| 要素 | 示例 | 特征 | 提取难度 |
+|------|------|------|----------|
+| workStartAt | 2024年7月13日 | 固定日期格式 | ⭐ 极低 |
+| workEndAt | 2024年10月19日 | 固定日期格式 | ⭐ 极低 |
+| resultScore | 93.33分 | 数值+单位 | ⭐ 极低 |
+| resultLevel | 一级 | 枚举值 | ⭐ 极低 |
+| basicInfo.projectCode | BZ-0092-2024 | 固定编号格式 | ⭐ 极低 |
+| basicInfo.reviewObjectCertificateCode | ZGDIDBOY-083 | 固定编号格式 | ⭐ 极低 |
+
+**NER能力**:✅ 100% 覆盖  
+**推荐方案**:使用规则NER,准确率可达95%+
+
+---
+
+### 类型B:简单实体识别(2个要素)✅ NER完全覆盖
+
+| 要素 | 示例 | 特征 | 提取难度 |
+|------|------|------|----------|
+| reviewObject | 中国电建集团成都勘测设计研究院有限公司 | 机构全称 | ⭐⭐ 低 |
+| reviewObjectAlias | 成都院 | 机构简称 | ⭐⭐ 低 |
+
+**NER能力**:✅ 90% 覆盖  
+**问题**:简称提取需要上下文理解("以下简称XX")  
+**推荐方案**:NER + 简单规则后处理
+
+---
+
+### 类型C:列表拼接型(2个要素)⚠️ NER部分覆盖
+
+| 要素 | 示例 | 特征 | 提取难度 |
+|------|------|------|----------|
+| reviewRange | 成都院本部、大邑地勘项目(简称:大邑项目) | 多项目拼接 | ⭐⭐⭐ 中 |
+| workProcess | 2024年7月13日至10月17日期间,评审组对成都院... | 模板化描述 | ⭐⭐⭐ 中 |
+
+**NER能力**:⚠️ 50% 覆盖  
+**问题**:
+- NER可以提取单个项目名,但无法自动拼接
+- workProcess需要按模板生成,不是简单提取
+
+**推荐方案**:NER提取实体 + LLM生成拼接/模板填充
+
+---
+
+### 类型D:总结型描述(18个要素)❌ NER无法覆盖
+
+| 要素 | actionType | 来源 | 特征 | 提取难度 |
+|------|-----------|------|------|----------|
+| target | summary | 评审代码5.1.1.1~5.1.1.3的remark | 需要总结多条意见 | ⭐⭐⭐⭐ 高 |
+| duty | summary | 评审代码5.1.2.1的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| fullParticipation | summary | 评审代码5.1.3的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| safetyInvestment | summary | 评审代码5.1.4的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| safetyCulture | summary | 评审代码5.1.5的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| systematicManagement | summary | 评审代码5.2.*的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| employeeTraining | summary | 评审代码5.3.2.*的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| assetManagement | summary | 评审代码5.4.1.*的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| jobSafety | summary | 评审代码5.4.2.1的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| positionQualified | summary | 评审代码5.4.2.3的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| partner | summary | 评审代码5.4.2.4的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| occupationalHealth | summary | 评审代码5.4.3.1的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| riskAssessment | summary | 评审代码5.5.1.1和5.5.1.2的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| majorHazardManagement | summary | 评审代码5.5.2.1的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| hazardInspection | summary | 评审代码5.5.3.1的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| changeManagement | summary | 评审代码5.5.1.4的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| earlyWarning | summary | 评审代码5.5.4的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+| emergencyResponse | summary | 评审代码5.6.1.1的remark | 需要总结 | ⭐⭐⭐⭐ 高 |
+
+**NER能力**:❌ 0% 覆盖  
+**原因**:这些要素不是"提取",而是"总结",需要:
+1. 定位特定评审代码的评审意见
+2. 理解多条意见的语义
+3. 生成连贯的总结性描述
+
+**推荐方案**:必须使用LLM
+
+---
+
+### 类型E:表格数据(7个要素)⚠️ NER部分覆盖
+
+| 要素 | actionType | 内容 | 提取难度 |
+|------|-----------|------|----------|
+| +SPSRRReviewProject | table_extract | 复审项目列表 | ⭐⭐⭐ 中 |
+| +SPSRRReviewer | table_extract | 复审人员列表 | ⭐⭐⭐ 中 |
+| +target_responsibility | table_extract | 目标职责详情表 | ⭐⭐⭐⭐ 高 |
+| +institutionalized_management | table_extract | 制度化管理详情表 | ⭐⭐⭐⭐ 高 |
+| +review_status | table_extract | 现场复审情况总览表 | ⭐⭐⭐⭐ 高 |
+| +review_result | table_extract | 复审结果表 | ⭐⭐⭐⭐ 高 |
+| +SPSRRSuggestion | table_extract | 整改建议表 | ⭐⭐⭐⭐ 高 |
+
+**NER能力**:⚠️ 30% 覆盖  
+**问题**:
+- 简单表格(如人员列表)可以用规则提取
+- 复杂表格需要理解表头、合并单元格、计算汇总等
+
+**推荐方案**:
+- 简单表格:文档解析器 + 规则提取
+- 复杂表格:LLM结构化提取
+
+---
+
+## 二、覆盖能力总结
+
+### NER覆盖情况
+
+| 类型 | 要素数 | NER覆盖率 | 适用场景 |
+|------|--------|-----------|----------|
+| A. 简单结构化 | 6 | ✅ 100% | 日期、数值、编号 |
+| B. 简单实体 | 2 | ✅ 90% | 机构名、人名 |
+| C. 列表拼接 | 2 | ⚠️ 50% | 需要后处理 |
+| D. 总结型 | 18 | ❌ 0% | 必须用LLM |
+| E. 表格数据 | 7 | ⚠️ 30% | 简单表格可用 |
+| **总计** | **35** | **~40%** | - |
+
+### LLM覆盖情况
+
+| 类型 | 要素数 | LLM覆盖率 | 适用场景 |
+|------|--------|-----------|----------|
+| A. 简单结构化 | 6 | ✅ 95% | 可用但成本高 |
+| B. 简单实体 | 2 | ✅ 95% | 可用但成本高 |
+| C. 列表拼接 | 2 | ✅ 90% | 适合 |
+| D. 总结型 | 18 | ✅ 85% | **必须用** |
+| E. 表格数据 | 7 | ✅ 80% | 适合 |
+| **总计** | **35** | **~88%** | - |
+
+**关键发现**:
+- NER只能覆盖约40%的要素(主要是简单提取)
+- LLM可以覆盖约88%的要素(但成本较高)
+- **18个总结型要素(51%)必须使用LLM**
+
+---
+
+## 三、混合提取策略设计
+
+### 策略1:NER优先 + LLM补充(推荐)
+
+```
+┌─────────────────────────────────────────┐
+│  文档解析(OCR/PDF提取)                  │
+└─────────────────┬───────────────────────┘
+                  │
+        ┌─────────┴─────────┐
+        │                   │
+        ▼                   ▼
+┌───────────────┐   ┌──────────────────┐
+│  NER提取      │   │  表格识别        │
+│  (规则模式)   │   │  (文档解析器)    │
+└───────┬───────┘   └────────┬─────────┘
+        │                    │
+        │  提取8个要素        │  提取简单表格
+        │  - 日期(2)         │  - 人员列表
+        │  - 编号(2)         │  - 项目列表
+        │  - 得分(1)         │
+        │  - 级别(1)         │
+        │  - 机构(2)         │
+        │                    │
+        └────────┬───────────┘
+                 │
+                 ▼
+        ┌────────────────────┐
+        │  LLM智能提取       │
+        │  (DeepSeek/Qwen)   │
+        └────────┬───────────┘
+                 │
+                 │  提取27个要素
+                 │  - 18个总结型(必须)
+                 │  - 7个复杂表格
+                 │  - 2个列表拼接
+                 │
+                 ▼
+        ┌────────────────────┐
+        │  结果合并与验证     │
+        └────────────────────┘
+```
+
+**优点**:
+- 成本优化:简单要素用NER(免费),复杂要素用LLM
+- 准确率高:各取所长
+- 可控性好:NER结果可预测
+
+**缺点**:
+- 实现复杂度较高
+- 需要维护两套提取逻辑
+
+---
+
+### 策略2:全LLM提取(简单但成本高)
+
+```
+┌─────────────────────────────────────────┐
+│  文档解析(OCR/PDF提取)                  │
+└─────────────────┬───────────────────────┘
+                  │
+                  ▼
+        ┌────────────────────┐
+        │  LLM结构化提取     │
+        │  (一次性提取所有)  │
+        └────────┬───────────┘
+                 │
+                 │  提取35个要素
+                 │  使用JSON Schema约束输出
+                 │
+                 ▼
+        ┌────────────────────┐
+        │  结果验证与修正     │
+        └────────────────────┘
+```
+
+**优点**:
+- 实现简单:一个提示词搞定
+- 覆盖率高:88%
+- 维护成本低
+
+**缺点**:
+- API成本高(每个附件约0.5-2元)
+- 速度较慢(10-30秒/附件)
+- 结果不稳定(需要多次验证)
+
+---
+
+### 策略3:分阶段混合(最优方案)⭐
+
+```
+阶段1: 快速NER提取(1-2秒)
+├─ 提取8个简单要素
+├─ 识别表格位置
+└─ 提取关键实体(机构、人名、日期)
+
+阶段2: 表格结构化(3-5秒)
+├─ 简单表格:规则提取
+└─ 复杂表格:LLM提取
+
+阶段3: LLM总结生成(10-20秒)
+├─ 定位评审代码对应的文本块(基于NER结果)
+├─ 批量调用LLM总结18个要素
+└─ 使用上下文窗口优化(一次调用处理多个要素)
+
+阶段4: 结果合并与验证
+├─ 合并NER和LLM结果
+├─ 交叉验证(如日期一致性)
+└─ 置信度评分
+```
+
+**优点**:
+- 成本最优:NER处理23%,LLM处理77%
+- 速度最快:并行处理 + 批量调用
+- 准确率最高:多阶段验证
+
+**缺点**:
+- 实现复杂度最高
+- 需要精细的流程控制
+
+---
+
+## 四、具体实施建议
+
+### 方案A:快速验证(1-2天)
+
+**目标**:验证LLM提取效果
+
+**步骤**:
+1. 选择1个真实附件(如工作方案PDF)
+2. 使用DeepSeek API提取5-10个要素
+3. 人工评估准确率
+4. 估算成本和时间
+
+**提示词示例**:
+```python
+prompt = """
+请从以下工作方案中提取信息,以JSON格式返回:
+
+{
+  "reviewObject": "评审对象全称",
+  "reviewObjectAlias": "评审对象简称",
+  "workStartAt": "评审开始日期(格式:yyyy年M月d日)",
+  "workEndAt": "评审结束日期",
+  "reviewRange": "复审范围(多个项目用顿号分隔)"
+}
+
+文本:
+{text}
+
+要求:
+1. 严格按照JSON格式输出
+2. 日期必须是yyyy年M月d日格式
+3. 如果找不到某个字段,填null
+"""
+```
+
+**验收标准**:
+- 准确率 > 80%
+- 单次调用成本 < 0.5元
+- 响应时间 < 10秒
+
+---
+
+### 方案B:混合实现(1周)
+
+**目标**:实现NER + LLM混合提取
+
+**阶段1:NER基础(2天)**
+- 扩展NER规则(8个简单要素)
+- 测试准确率 > 90%
+
+**阶段2:LLM集成(2天)**
+- 配置DeepSeek API
+- 实现18个总结型要素提取
+- 设计提示词模板
+
+**阶段3:表格提取(2天)**
+- 简单表格:规则提取
+- 复杂表格:LLM提取
+
+**阶段4:流程整合(1天)**
+- 实现分阶段提取流程
+- 结果合并与验证
+- 端到端测试
+
+---
+
+### 方案C:纯LLM实现(3天)
+
+**目标**:快速上线,后续优化
+
+**阶段1:提示词工程(1天)**
+- 设计结构化提取提示词
+- 测试不同模型(DeepSeek/Qwen)
+- 优化JSON Schema
+
+**阶段2:API集成(1天)**
+- 实现LLM调用服务
+- 添加重试和错误处理
+- 结果解析和验证
+
+**阶段3:测试优化(1天)**
+- 准确率测试
+- 成本优化(批量调用)
+- 缓存策略
+
+---
+
+## 五、成本与性能对比
+
+### 成本估算(基于DeepSeek API)
+
+| 方案 | NER成本 | LLM成本 | 总成本/附件 | 月成本(100附件) |
+|------|---------|---------|-------------|----------------|
+| 纯NER | ¥0 | ¥0 | ¥0 | ¥0 |
+| 纯LLM | ¥0 | ¥1.5 | ¥1.5 | ¥150 |
+| 混合方案 | ¥0 | ¥1.0 | ¥1.0 | ¥100 |
+
+**说明**:
+- DeepSeek价格:¥0.001/1K tokens
+- 平均每个附件:50K tokens输入 + 5K tokens输出
+- 成本 = (50 × 0.001) + (5 × 0.002) = ¥0.06/次
+- 18个总结型要素需要调用约15-20次
+
+### 性能对比
+
+| 方案 | 处理时间 | 准确率 | 覆盖率 | 维护成本 |
+|------|----------|--------|--------|----------|
+| 纯NER | 1-2秒 | 95% | 40% | 高(规则维护) |
+| 纯LLM | 15-30秒 | 85% | 88% | 低(提示词优化) |
+| 混合方案 | 10-20秒 | 90% | 95% | 中(两套逻辑) |
+
+---
+
+## 六、最终推荐
+
+### 🎯 推荐方案:**分阶段混合提取(策略3)**
+
+**理由**:
+1. **覆盖率最高**:95%(NER 40% + LLM 55%)
+2. **成本可控**:约¥1/附件,比纯LLM节省33%
+3. **准确率最优**:多阶段验证,综合准确率90%+
+4. **可扩展性强**:后续可以逐步优化NER规则,降低LLM依赖
+
+### 实施路径
+
+**第一步:快速验证(本周)**
+- 用纯LLM方案验证5个附件
+- 评估准确率和成本
+- 确定可行性
+
+**第二步:混合实现(下周)**
+- 实现NER基础提取(8个要素)
+- 集成LLM总结生成(18个要素)
+- 实现表格提取(7个要素)
+
+**第三步:优化迭代(后续)**
+- 收集错误案例
+- 优化NER规则
+- 优化LLM提示词
+- 降低成本
+
+---
+
+## 七、关键技术细节
+
+### LLM提示词设计原则
+
+1. **结构化输出**:使用JSON Schema约束
+2. **上下文窗口**:一次处理多个相关要素
+3. **Few-shot示例**:提供2-3个标准示例
+4. **明确约束**:日期格式、字数限制等
+
+### NER规则优化方向
+
+1. **上下文验证**:不仅匹配模式,还验证上下文
+2. **置信度计算**:基于匹配质量动态调整
+3. **去重合并**:同一实体的多次出现合并
+4. **边界检测**:避免过度匹配
+
+### 表格提取策略
+
+1. **简单表格**:基于分隔符的规则提取
+2. **复杂表格**:LLM + 表格理解模型
+3. **混合验证**:规则提取 + LLM验证
+
+---
+
+**结论**:智报的要素提取需求中,**51%必须使用LLM**(18个总结型要素),**23%可以用NER**(8个简单要素),**26%需要混合方案**(9个表格和列表要素)。因此,**纯NER无法满足需求**,建议采用**NER + LLM混合方案**。

+ 809 - 0
docs/design/NER实现示例代码.md

@@ -0,0 +1,809 @@
+# NER实现示例代码
+
+## 一、后端实现
+
+### 1.1 扩展Constants常量
+
+```java
+// backend/lingyue-common/src/main/java/com/lingyue/common/core/Constants.java
+
+public final class Constants {
+    // ... 现有常量
+    
+    // NER相关节点类型
+    public static final String NODE_NER_ENTITY = "NER_ENTITY";
+    public static final String NODE_NER_RELATION = "NER_RELATION";
+    
+    // NER相关边类型
+    public static final String EDGE_HAS_NER_ENTITY = "HAS_NER_ENTITY";
+    public static final String EDGE_ENTITY_RELATION = "ENTITY_RELATION";
+    public static final String EDGE_ENTITY_TO_VALUE = "ENTITY_TO_VALUE";
+    
+    // NER提取方法
+    public static final String NER_METHOD_RULE = "rule";
+    public static final String NER_METHOD_LLM = "llm";
+    public static final String NER_METHOD_MANUAL = "manual";
+    
+    // NER状态
+    public static final String NER_PENDING = "pending";
+    public static final String NER_PROCESSING = "processing";
+    public static final String NER_COMPLETED = "completed";
+    public static final String NER_FAILED = "failed";
+}
+```
+
+### 1.2 NER实体DTO
+
+```java
+// backend/lingyue-ai/src/main/java/com/lingyue/ai/dto/NerEntityDTO.java
+
+package com.lingyue.ai.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+
+@Data
+public class NerEntityDTO {
+    private Long id;
+    private String entityType;      // ORG, DATE, PERSON, SCORE等
+    private String entityName;      // 实体名称
+    private String entityValue;     // 实体值
+    private BigDecimal confidence;  // 置信度
+    
+    // 位置信息
+    private Integer charStart;
+    private Integer charEnd;
+    private Integer line;
+    private String context;         // 上下文
+    
+    // 来源信息
+    private Long attachmentId;
+    private String attachmentName;
+    private String extractMethod;   // rule/llm/manual
+    private String extractTime;
+    
+    // 映射信息
+    private String mappedElementKey;  // 映射到的要素key
+    private Boolean isMapped;
+}
+```
+
+### 1.3 NER Service实现
+
+```java
+// backend/lingyue-ai/src/main/java/com/lingyue/ai/service/NerEntityService.java
+
+package com.lingyue.ai.service;
+
+import com.lingyue.ai.dto.NerEntityDTO;
+import com.lingyue.ai.dto.NerExtractRequest;
+import com.lingyue.ai.dto.NerExtractResponse;
+import com.lingyue.common.core.Constants;
+import com.lingyue.graph.service.NodeService;
+import com.lingyue.graph.service.EdgeService;
+import com.lingyue.graph.service.PropertyService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class NerEntityService {
+    
+    private final NerService nerService;
+    private final NodeService nodeService;
+    private final EdgeService edgeService;
+    private final PropertyService propertyService;
+    
+    /**
+     * 对附件执行NER提取并保存到图数据库
+     */
+    @Transactional
+    public List<NerEntityDTO> extractAndSaveEntities(Long attachmentId, String text) {
+        log.info("开始NER提取: attachmentId={}", attachmentId);
+        
+        // 1. 调用Python NER服务提取实体
+        NerExtractRequest request = new NerExtractRequest();
+        request.setText(text);
+        request.setDocumentId(String.valueOf(attachmentId));
+        
+        NerExtractResponse response = nerService.extract(request);
+        
+        // 2. 保存实体到图数据库
+        List<NerEntityDTO> savedEntities = new ArrayList<>();
+        
+        for (NerExtractResponse.EntityItem item : response.getEntities()) {
+            NerEntityDTO entity = saveEntity(attachmentId, item);
+            savedEntities.add(entity);
+        }
+        
+        log.info("NER提取完成: attachmentId={}, entityCount={}", 
+                 attachmentId, savedEntities.size());
+        
+        return savedEntities;
+    }
+    
+    /**
+     * 保存单个实体到图数据库
+     */
+    private NerEntityDTO saveEntity(Long attachmentId, NerExtractResponse.EntityItem item) {
+        // 创建实体节点
+        String entityKey = "entity_" + System.currentTimeMillis() + "_" + 
+                          Math.abs(item.getText().hashCode());
+        
+        Long entityId = nodeService.createNode(
+            Constants.NODE_NER_ENTITY,
+            entityKey,
+            item.getText(),
+            null  // createdBy
+        );
+        
+        // 设置实体属性
+        propertyService.setNodeProperty(entityId, "entity_type", item.getType());
+        propertyService.setNodeProperty(entityId, "entity_value", item.getText());
+        propertyService.setNodeProperty(entityId, "confidence", 
+                                       String.valueOf(item.getConfidence()));
+        propertyService.setNodeProperty(entityId, "char_start", 
+                                       String.valueOf(item.getStartPos()));
+        propertyService.setNodeProperty(entityId, "char_end", 
+                                       String.valueOf(item.getEndPos()));
+        propertyService.setNodeProperty(entityId, "extract_method", 
+                                       Constants.NER_METHOD_RULE);
+        
+        // 创建附件→实体的边
+        edgeService.createEdge(
+            Constants.EDGE_HAS_NER_ENTITY,
+            attachmentId,
+            entityId,
+            0  // sortOrder
+        );
+        
+        // 构建DTO返回
+        NerEntityDTO dto = new NerEntityDTO();
+        dto.setId(entityId);
+        dto.setEntityType(item.getType());
+        dto.setEntityName(item.getText());
+        dto.setEntityValue(item.getText());
+        dto.setConfidence(item.getConfidence());
+        dto.setCharStart(item.getStartPos());
+        dto.setCharEnd(item.getEndPos());
+        dto.setAttachmentId(attachmentId);
+        dto.setExtractMethod(Constants.NER_METHOD_RULE);
+        dto.setIsMapped(false);
+        
+        return dto;
+    }
+    
+    /**
+     * 查询附件的所有NER实体
+     */
+    public List<NerEntityDTO> getEntitiesByAttachment(Long attachmentId) {
+        // TODO: 实现查询逻辑
+        return new ArrayList<>();
+    }
+    
+    /**
+     * 将实体映射到要素
+     */
+    @Transactional
+    public void mapEntityToElement(Long entityId, String elementKey) {
+        log.info("映射实体到要素: entityId={}, elementKey={}", entityId, elementKey);
+        
+        // 1. 查找element节点
+        // 2. 创建ENTITY_TO_VALUE边
+        // 3. 更新entity的mapped属性
+        
+        propertyService.setNodeProperty(entityId, "mapped_element_key", elementKey);
+        propertyService.setNodeProperty(entityId, "is_mapped", "true");
+    }
+}
+```
+
+### 1.4 NER Controller
+
+```java
+// backend/lingyue-ai/src/main/java/com/lingyue/ai/controller/NerController.java
+
+package com.lingyue.ai.controller;
+
+import com.lingyue.ai.dto.NerEntityDTO;
+import com.lingyue.ai.service.NerEntityService;
+import com.lingyue.common.core.Result;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/ner")
+@RequiredArgsConstructor
+public class NerController {
+    
+    private final NerEntityService nerEntityService;
+    
+    /**
+     * 对附件执行NER提取
+     */
+    @PostMapping("/attachments/{attachmentId}/extract")
+    public Result<List<NerEntityDTO>> extractEntities(
+            @PathVariable Long attachmentId,
+            @RequestBody String text) {
+        
+        List<NerEntityDTO> entities = nerEntityService.extractAndSaveEntities(
+            attachmentId, text
+        );
+        
+        return Result.ok(entities);
+    }
+    
+    /**
+     * 查询附件的NER实体
+     */
+    @GetMapping("/attachments/{attachmentId}/entities")
+    public Result<List<NerEntityDTO>> getEntities(@PathVariable Long attachmentId) {
+        List<NerEntityDTO> entities = nerEntityService.getEntitiesByAttachment(
+            attachmentId
+        );
+        return Result.ok(entities);
+    }
+    
+    /**
+     * 将实体映射到要素
+     */
+    @PostMapping("/entities/{entityId}/map")
+    public Result<?> mapEntity(
+            @PathVariable Long entityId,
+            @RequestParam String elementKey) {
+        
+        nerEntityService.mapEntityToElement(entityId, elementKey);
+        return Result.ok();
+    }
+}
+```
+
+## 二、Python NER服务扩展
+
+### 2.1 扩展实体类型规则
+
+```python
+# python-services/ner-service/app/services/ner_service.py
+
+# 在 _extract_by_rules 方法中添加智报专用规则
+
+async def _extract_by_rules(self, text: str, entity_types: Optional[List[str]] = None):
+    """基于规则的NER提取(智报增强版)"""
+    
+    rules = {
+        # ... 现有规则
+        
+        # === 智报专用规则 ===
+        "SCORE": [
+            # 评审得分:93.33分
+            r'(\d+\.?\d*分)',
+            r'得分[::]\s*(\d+\.?\d*)',
+        ],
+        
+        "LEVEL": [
+            # 级别:一级、二级
+            r'(一级|二级|三级)',
+            r'级别[::]\s*(一级|二级|三级)',
+        ],
+        
+        "CERTIFICATE_CODE": [
+            # 证书编号:ZGDIDBOY-083
+            r'(ZGDIDBOY-\d+)',
+            r'([A-Z]+-\d+-\d+)',
+            r'证书编号[::]\s*([A-Z0-9\-]+)',
+        ],
+        
+        "REVIEW_CODE": [
+            # 评审代码:5.1.1.1
+            r'(5\.\d+(?:\.\d+)*)',
+        ],
+        
+        "COMPANY_ALIAS": [
+            # 公司简称(需要结合上下文)
+            r'简称[::「『]([^」』::]{2,10})[」』]',
+            r'以下简称[「『""]([^」』""]{2,10})[」』""]',
+        ],
+        
+        "PROJECT_CODE": [
+            # 项目编号:BZ-0092-2024
+            r'([A-Z]+-\d+-\d+)',
+            r'项目编号[::]\s*([A-Z0-9\-]+)',
+        ],
+        
+        "REVIEW_ITEM": [
+            # 评审项:目标职责、制度化管理等
+            r'(目标职责|制度化管理|教育培训|现场管理|安全风险管控|应急管理|事故管理|持续改进)',
+        ],
+    }
+    
+    # ... 其余提取逻辑保持不变
+```
+
+### 2.2 添加表格提取功能
+
+```python
+# python-services/ner-service/app/services/table_extractor.py
+
+from typing import List, Dict
+import re
+
+class TableExtractor:
+    """表格数据提取器"""
+    
+    def extract_tables(self, text: str) -> List[Dict]:
+        """
+        从文本中提取表格数据
+        
+        返回格式:
+        [
+            {
+                "table_type": "review_project",  # 表格类型
+                "headers": ["项目名称", "简称", "类型"],
+                "rows": [
+                    ["大邑地勘项目", "大邑项目", "在建项目"],
+                    ...
+                ]
+            }
+        ]
+        """
+        tables = []
+        
+        # 方法1:基于分隔符识别(简单表格)
+        tables.extend(self._extract_simple_tables(text))
+        
+        # 方法2:基于关键词识别(特定表格)
+        tables.extend(self._extract_known_tables(text))
+        
+        return tables
+    
+    def _extract_simple_tables(self, text: str) -> List[Dict]:
+        """提取简单表格(基于|或制表符分隔)"""
+        tables = []
+        
+        # 查找表格块
+        table_pattern = r'(\|[^\n]+\|(?:\n\|[^\n]+\|)+)'
+        matches = re.finditer(table_pattern, text)
+        
+        for match in matches:
+            table_text = match.group(1)
+            rows = table_text.strip().split('\n')
+            
+            # 解析表头和数据行
+            headers = [cell.strip() for cell in rows[0].split('|') if cell.strip()]
+            data_rows = []
+            
+            for row in rows[1:]:
+                cells = [cell.strip() for cell in row.split('|') if cell.strip()]
+                if cells:
+                    data_rows.append(cells)
+            
+            if headers and data_rows:
+                tables.append({
+                    "table_type": "unknown",
+                    "headers": headers,
+                    "rows": data_rows
+                })
+        
+        return tables
+    
+    def _extract_known_tables(self, text: str) -> List[Dict]:
+        """提取已知类型的表格"""
+        tables = []
+        
+        # 示例:提取复审项目表
+        if "复审项目" in text or "评审项目" in text:
+            table = self._extract_review_project_table(text)
+            if table:
+                tables.append(table)
+        
+        # 示例:提取复审人员表
+        if "评审组" in text or "评审人员" in text:
+            table = self._extract_reviewer_table(text)
+            if table:
+                tables.append(table)
+        
+        return tables
+    
+    def _extract_review_project_table(self, text: str) -> Dict:
+        """提取复审项目表"""
+        # TODO: 实现具体逻辑
+        return None
+    
+    def _extract_reviewer_table(self, text: str) -> Dict:
+        """提取评审人员表"""
+        # TODO: 实现具体逻辑
+        return None
+
+# 创建单例
+table_extractor = TableExtractor()
+```
+
+## 三、前端实现
+
+### 3.1 NER分析页面
+
+```vue
+<!-- frontend/vue-demo/src/views/NerAnalysis.vue -->
+
+<template>
+  <div class="ner-analysis-container">
+    <!-- 顶部统计卡片 -->
+    <el-row :gutter="20" class="stats-row">
+      <el-col :span="6">
+        <el-card>
+          <el-statistic title="实体总数" :value="statistics.totalEntities">
+            <template #suffix>个</template>
+          </el-statistic>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card>
+          <el-statistic title="已映射" :value="statistics.mappedEntities">
+            <template #suffix>个</template>
+          </el-statistic>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card>
+          <el-statistic title="关系数" :value="statistics.totalRelations">
+            <template #suffix>个</template>
+          </el-statistic>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card>
+          <el-statistic 
+            title="平均置信度" 
+            :value="statistics.avgConfidence"
+            :precision="2">
+            <template #suffix>%</template>
+          </el-statistic>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 主内容区 -->
+    <el-card class="main-content">
+      <el-tabs v-model="activeTab">
+        <!-- 实体列表 -->
+        <el-tab-pane label="实体列表" name="entities">
+          <entity-list-view 
+            :entities="entities"
+            @map="handleMapEntity"
+            @delete="handleDeleteEntity" />
+        </el-tab-pane>
+
+        <!-- 文本标注 -->
+        <el-tab-pane label="文本标注" name="annotation">
+          <text-annotation-view 
+            :text="sourceText"
+            :entities="entities"
+            @entity-click="handleEntityClick" />
+        </el-tab-pane>
+
+        <!-- 要素映射 -->
+        <el-tab-pane label="要素映射" name="mapping">
+          <entity-mapping-view 
+            :entities="entities"
+            :elements="elements"
+            @map="handleMapEntity" />
+        </el-tab-pane>
+
+        <!-- 关系图谱 -->
+        <el-tab-pane label="关系图谱" name="graph">
+          <relation-graph-view 
+            :entities="entities"
+            :relations="relations" />
+        </el-tab-pane>
+      </el-tabs>
+    </el-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { getNerEntities, mapEntityToElement } from '@/api/ner'
+import EntityListView from './components/EntityListView.vue'
+import TextAnnotationView from './components/TextAnnotationView.vue'
+import EntityMappingView from './components/EntityMappingView.vue'
+import RelationGraphView from './components/RelationGraphView.vue'
+
+const route = useRoute()
+const attachmentId = ref(route.params.attachmentId)
+const activeTab = ref('entities')
+
+const entities = ref([])
+const relations = ref([])
+const sourceText = ref('')
+const elements = ref([])
+
+// 统计数据
+const statistics = computed(() => ({
+  totalEntities: entities.value.length,
+  mappedEntities: entities.value.filter(e => e.isMapped).length,
+  totalRelations: relations.value.length,
+  avgConfidence: entities.value.length > 0
+    ? entities.value.reduce((sum, e) => sum + e.confidence, 0) / entities.value.length * 100
+    : 0
+}))
+
+// 加载数据
+const loadData = async () => {
+  const res = await getNerEntities(attachmentId.value)
+  entities.value = res.data
+}
+
+// 映射实体到要素
+const handleMapEntity = async (entityId, elementKey) => {
+  await mapEntityToElement(entityId, elementKey)
+  await loadData()
+}
+
+// 删除实体
+const handleDeleteEntity = async (entityId) => {
+  // TODO: 实现删除逻辑
+}
+
+// 点击实体
+const handleEntityClick = (entity) => {
+  console.log('点击实体:', entity)
+}
+
+onMounted(() => {
+  loadData()
+})
+</script>
+
+<style scoped>
+.ner-analysis-container {
+  padding: 20px;
+}
+
+.stats-row {
+  margin-bottom: 20px;
+}
+
+.main-content {
+  min-height: 600px;
+}
+</style>
+```
+
+### 3.2 实体列表组件
+
+```vue
+<!-- frontend/vue-demo/src/views/components/EntityListView.vue -->
+
+<template>
+  <div class="entity-list">
+    <!-- 筛选工具栏 -->
+    <el-row class="toolbar">
+      <el-col :span="12">
+        <el-input 
+          v-model="searchText"
+          placeholder="搜索实体..."
+          clearable>
+          <template #prefix>
+            <el-icon><Search /></el-icon>
+          </template>
+        </el-input>
+      </el-col>
+      <el-col :span="12" class="filter-group">
+        <el-select v-model="filterType" placeholder="实体类型" clearable>
+          <el-option label="全部" value="" />
+          <el-option label="机构" value="ORG" />
+          <el-option label="日期" value="DATE" />
+          <el-option label="人名" value="PERSON" />
+          <el-option label="得分" value="SCORE" />
+          <el-option label="级别" value="LEVEL" />
+        </el-select>
+        
+        <el-select v-model="filterMapped" placeholder="映射状态" clearable>
+          <el-option label="全部" value="" />
+          <el-option label="已映射" value="true" />
+          <el-option label="未映射" value="false" />
+        </el-select>
+      </el-col>
+    </el-row>
+
+    <!-- 实体表格 -->
+    <el-table 
+      :data="filteredEntities"
+      stripe
+      border
+      height="500">
+      
+      <el-table-column prop="entityType" label="类型" width="100">
+        <template #default="{ row }">
+          <el-tag :type="getTypeColor(row.entityType)">
+            {{ row.entityType }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      
+      <el-table-column prop="entityName" label="实体名称" width="200" />
+      
+      <el-table-column prop="confidence" label="置信度" width="100">
+        <template #default="{ row }">
+          <el-progress 
+            :percentage="row.confidence * 100"
+            :color="getConfidenceColor(row.confidence)" />
+        </template>
+      </el-table-column>
+      
+      <el-table-column prop="context" label="上下文" show-overflow-tooltip />
+      
+      <el-table-column prop="isMapped" label="映射状态" width="120">
+        <template #default="{ row }">
+          <el-tag v-if="row.isMapped" type="success">已映射</el-tag>
+          <el-tag v-else type="info">未映射</el-tag>
+        </template>
+      </el-table-column>
+      
+      <el-table-column label="操作" width="200" fixed="right">
+        <template #default="{ row }">
+          <el-button 
+            size="small"
+            @click="handleMap(row)">
+            映射
+          </el-button>
+          <el-button 
+            size="small"
+            type="danger"
+            @click="handleDelete(row)">
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 映射对话框 -->
+    <el-dialog v-model="mapDialogVisible" title="映射到要素">
+      <el-select v-model="selectedElementKey" placeholder="选择要素">
+        <el-option 
+          v-for="elem in elements"
+          :key="elem.key"
+          :label="elem.label"
+          :value="elem.key" />
+      </el-select>
+      
+      <template #footer>
+        <el-button @click="mapDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmMap">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { Search } from '@element-plus/icons-vue'
+
+const props = defineProps({
+  entities: Array,
+  elements: Array
+})
+
+const emit = defineEmits(['map', 'delete'])
+
+const searchText = ref('')
+const filterType = ref('')
+const filterMapped = ref('')
+const mapDialogVisible = ref(false)
+const currentEntity = ref(null)
+const selectedElementKey = ref('')
+
+// 过滤实体
+const filteredEntities = computed(() => {
+  let result = props.entities
+
+  if (searchText.value) {
+    result = result.filter(e => 
+      e.entityName.includes(searchText.value) ||
+      e.context.includes(searchText.value)
+    )
+  }
+
+  if (filterType.value) {
+    result = result.filter(e => e.entityType === filterType.value)
+  }
+
+  if (filterMapped.value) {
+    const isMapped = filterMapped.value === 'true'
+    result = result.filter(e => e.isMapped === isMapped)
+  }
+
+  return result
+})
+
+// 获取类型颜色
+const getTypeColor = (type) => {
+  const colors = {
+    'ORG': 'primary',
+    'DATE': 'success',
+    'PERSON': 'warning',
+    'SCORE': 'danger',
+    'LEVEL': 'info'
+  }
+  return colors[type] || ''
+}
+
+// 获取置信度颜色
+const getConfidenceColor = (confidence) => {
+  if (confidence >= 0.8) return '#67C23A'
+  if (confidence >= 0.6) return '#E6A23C'
+  return '#F56C6C'
+}
+
+// 映射操作
+const handleMap = (entity) => {
+  currentEntity.value = entity
+  mapDialogVisible.value = true
+}
+
+const confirmMap = () => {
+  if (selectedElementKey.value) {
+    emit('map', currentEntity.value.id, selectedElementKey.value)
+    mapDialogVisible.value = false
+  }
+}
+
+// 删除操作
+const handleDelete = (entity) => {
+  emit('delete', entity.id)
+}
+</script>
+
+<style scoped>
+.toolbar {
+  margin-bottom: 20px;
+}
+
+.filter-group {
+  display: flex;
+  gap: 10px;
+  justify-content: flex-end;
+}
+</style>
+```
+
+## 四、数据库迁移脚本
+
+```sql
+-- database/migrations/003_add_ner_support.sql
+
+-- 添加NER相关的节点类型和边类型支持
+-- 注意:实际执行时需要根据现有schema调整
+
+-- 1. 如果有node_types表,添加新类型
+INSERT INTO node_types (type_code, type_name, description) VALUES
+('NER_ENTITY', 'NER实体', 'NER提取的命名实体'),
+('NER_RELATION', 'NER关系', '实体间的关系')
+ON DUPLICATE KEY UPDATE type_name = VALUES(type_name);
+
+-- 2. 如果有edge_types表,添加新类型
+INSERT INTO edge_types (type_code, type_name, description) VALUES
+('HAS_NER_ENTITY', '包含NER实体', '附件包含的NER实体'),
+('ENTITY_RELATION', '实体关系', '实体之间的语义关系'),
+('ENTITY_TO_VALUE', '实体到值', '实体映射到要素值')
+ON DUPLICATE KEY UPDATE type_name = VALUES(type_name);
+
+-- 3. 添加NER相关索引(如果需要)
+-- CREATE INDEX idx_ner_entity_type ON graph_properties(node_id, property_key)
+--   WHERE property_key = 'entity_type';
+```
+
+---
+
+以上代码提供了NER阶段的核心实现框架,可以根据实际需求进行调整和扩展。

+ 375 - 0
docs/design/NER开发任务清单.md

@@ -0,0 +1,375 @@
+# NER阶段开发任务清单
+
+## 阶段1:基础设施搭建(预计2天)
+
+### 任务1.1:扩展常量定义
+- [ ] 编辑 `Constants.java`,添加NER相关常量
+  - 节点类型:`NODE_NER_ENTITY`, `NODE_NER_RELATION`
+  - 边类型:`EDGE_HAS_NER_ENTITY`, `EDGE_ENTITY_RELATION`, `EDGE_ENTITY_TO_VALUE`
+  - 提取方法:`NER_METHOD_RULE`, `NER_METHOD_LLM`, `NER_METHOD_MANUAL`
+  - NER状态:`NER_PENDING`, `NER_PROCESSING`, `NER_COMPLETED`, `NER_FAILED`
+
+### 任务1.2:创建DTO类
+- [ ] 创建 `NerEntityDTO.java`(实体数据传输对象)
+- [ ] 创建 `NerRelationDTO.java`(关系数据传输对象)
+- [ ] 创建 `NerStatisticsDTO.java`(统计数据对象)
+- [ ] 创建 `EntityMappingRequest.java`(映射请求对象)
+
+### 任务1.3:数据库迁移
+- [ ] 创建迁移脚本 `003_add_ner_support.sql`
+- [ ] 添加节点类型和边类型(如果有类型表)
+- [ ] 添加必要的索引优化查询性能
+- [ ] 测试迁移脚本
+
+### 任务1.4:配置DeepSeek API(可选)
+- [ ] 在 `application.properties` 添加API配置
+  ```properties
+  ai.deepseek.api-key=your-api-key
+  ai.deepseek.base-url=https://api.deepseek.com
+  ai.deepseek.model=deepseek-chat
+  ```
+- [ ] 测试API连接
+
+**验收标准**:
+- 所有常量定义完成且编译通过
+- DTO类创建完成并通过单元测试
+- 数据库迁移成功执行
+
+---
+
+## 阶段2:Python NER服务增强(预计3天)
+
+### 任务2.1:扩展实体类型规则
+- [ ] 编辑 `ner_service.py`,在 `_extract_by_rules` 方法中添加:
+  - `SCORE` 规则(评审得分)
+  - `LEVEL` 规则(级别)
+  - `CERTIFICATE_CODE` 规则(证书编号)
+  - `REVIEW_CODE` 规则(评审代码)
+  - `COMPANY_ALIAS` 规则(公司简称)
+  - `PROJECT_CODE` 规则(项目编号)
+  - `REVIEW_ITEM` 规则(评审项)
+
+### 任务2.2:优化规则准确率
+- [ ] 添加停用词过滤
+- [ ] 添加上下文验证逻辑
+- [ ] 调整置信度计算方法
+- [ ] 测试并优化规则
+
+### 任务2.3:实现表格提取功能
+- [ ] 创建 `table_extractor.py`
+- [ ] 实现 `extract_tables()` 方法
+- [ ] 实现 `_extract_simple_tables()` 方法(基于分隔符)
+- [ ] 实现 `_extract_known_tables()` 方法(基于关键词)
+- [ ] 实现特定表格提取器:
+  - `_extract_review_project_table()` - 复审项目表
+  - `_extract_reviewer_table()` - 复审人员表
+  - `_extract_suggestion_table()` - 整改建议表
+
+### 任务2.4:集成LLM提取(可选)
+- [ ] 实现 `_extract_by_deepseek()` 方法
+- [ ] 设计提示词模板
+- [ ] 实现结果解析和验证
+- [ ] 添加错误处理和重试机制
+
+### 任务2.5:单元测试
+- [ ] 编写规则提取测试用例
+- [ ] 编写表格提取测试用例
+- [ ] 编写LLM提取测试用例(如果实现)
+- [ ] 准备测试数据(从mock数据中提取)
+
+**验收标准**:
+- 所有新实体类型规则添加完成
+- 表格提取功能正常工作
+- 单元测试覆盖率 > 80%
+- 在测试数据上准确率 > 85%
+
+---
+
+## 阶段3:后端服务集成(预计3天)
+
+### 任务3.1:创建NER实体服务
+- [ ] 创建 `NerEntityService.java`
+- [ ] 实现 `extractAndSaveEntities()` - 提取并保存实体
+- [ ] 实现 `saveEntity()` - 保存单个实体到图数据库
+- [ ] 实现 `getEntitiesByAttachment()` - 查询附件的实体
+- [ ] 实现 `mapEntityToElement()` - 映射实体到要素
+- [ ] 实现 `deleteEntity()` - 删除实体
+- [ ] 实现 `updateEntity()` - 更新实体信息
+
+### 任务3.2:创建NER控制器
+- [ ] 创建 `NerController.java`
+- [ ] 实现 `POST /api/v1/ner/attachments/{id}/extract` - 触发NER提取
+- [ ] 实现 `GET /api/v1/ner/attachments/{id}/entities` - 获取实体列表
+- [ ] 实现 `POST /api/v1/ner/entities/{id}/map` - 映射实体
+- [ ] 实现 `DELETE /api/v1/ner/entities/{id}` - 删除实体
+- [ ] 实现 `PUT /api/v1/ner/entities/{id}` - 更新实体
+- [ ] 实现 `GET /api/v1/ner/attachments/{id}/statistics` - 获取统计信息
+
+### 任务3.3:集成到附件上传流程
+- [ ] 修改 `AttachmentService.uploadAttachment()`
+- [ ] 添加NER触发选项(自动/手动)
+- [ ] 实现异步NER任务创建
+- [ ] 添加NER状态更新逻辑
+
+### 任务3.4:实现自动映射逻辑
+- [ ] 创建 `EntityMappingService.java`
+- [ ] 实现基于规则的自动映射
+  - 日期类实体 → 日期类要素
+  - 机构类实体 → 评审对象相关要素
+  - 得分类实体 → 评审得分要素
+- [ ] 实现映射置信度计算
+- [ ] 添加映射建议功能
+
+### 任务3.5:API测试
+- [ ] 编写集成测试
+- [ ] 测试完整流程:上传附件 → NER提取 → 保存实体 → 查询展示
+- [ ] 测试异常情况处理
+- [ ] 性能测试(大文档处理)
+
+**验收标准**:
+- 所有API接口实现完成
+- 集成测试通过
+- API文档更新
+- 性能满足要求(单个附件NER < 30s)
+
+---
+
+## 阶段4:前端界面开发(预计4天)
+
+### 任务4.1:创建API接口层
+- [ ] 创建 `src/api/ner.js`
+- [ ] 实现 `getNerEntities(attachmentId)` - 获取实体列表
+- [ ] 实现 `triggerNerExtract(attachmentId)` - 触发NER提取
+- [ ] 实现 `mapEntityToElement(entityId, elementKey)` - 映射实体
+- [ ] 实现 `deleteEntity(entityId)` - 删除实体
+- [ ] 实现 `getNerStatistics(attachmentId)` - 获取统计信息
+
+### 任务4.2:创建NER分析主页面
+- [ ] 创建 `src/views/NerAnalysis.vue`
+- [ ] 实现顶部统计卡片
+- [ ] 实现Tab切换(实体列表/文本标注/要素映射/关系图谱)
+- [ ] 实现数据加载和刷新
+- [ ] 添加加载状态和错误处理
+
+### 任务4.3:实现实体列表视图
+- [ ] 创建 `src/views/components/EntityListView.vue`
+- [ ] 实现实体表格展示
+- [ ] 实现筛选功能(类型/映射状态/置信度)
+- [ ] 实现搜索功能
+- [ ] 实现映射操作
+- [ ] 实现删除操作
+- [ ] 添加批量操作功能
+
+### 任务4.4:实现文本标注视图
+- [ ] 创建 `src/views/components/TextAnnotationView.vue`
+- [ ] 实现原文展示
+- [ ] 实现实体高亮(不同颜色标识不同类型)
+- [ ] 实现鼠标悬停显示详情
+- [ ] 实现点击实体跳转到详情
+- [ ] 添加高亮开关和类型过滤
+
+### 任务4.5:实现要素映射视图
+- [ ] 创建 `src/views/components/EntityMappingView.vue`
+- [ ] 实现左右分栏布局(实体列表 | 要素列表)
+- [ ] 实现拖拽映射功能
+- [ ] 实现自动映射建议展示
+- [ ] 实现映射关系可视化
+- [ ] 添加批量映射功能
+
+### 任务4.6:实现关系图谱视图(可选)
+- [ ] 创建 `src/views/components/RelationGraphView.vue`
+- [ ] 集成图可视化库(如 ECharts/G6)
+- [ ] 实现节点和边的渲染
+- [ ] 实现交互功能(缩放/拖拽/点击)
+- [ ] 添加布局算法选择
+
+### 任务4.7:集成到附件管理页面
+- [ ] 修改 `src/views/AttachmentManagement.vue`
+- [ ] 添加"NER分析"按钮
+- [ ] 添加NER状态显示
+- [ ] 添加快捷操作入口
+
+### 任务4.8:样式优化
+- [ ] 设计实体类型颜色方案
+- [ ] 优化表格和卡片样式
+- [ ] 添加响应式布局
+- [ ] 优化移动端显示
+
+**验收标准**:
+- 所有视图组件实现完成
+- UI/UX符合设计规范
+- 响应式布局正常工作
+- 浏览器兼容性测试通过
+
+---
+
+## 阶段5:测试与优化(预计3天)
+
+### 任务5.1:端到端测试
+- [ ] 准备测试数据集(5-10个真实附件)
+- [ ] 测试完整流程:
+  1. 上传附件
+  2. 触发NER提取
+  3. 查看实体列表
+  4. 文本标注展示
+  5. 映射到要素
+  6. 验证要素值填充
+- [ ] 记录测试结果和问题
+
+### 任务5.2:准确率评估
+- [ ] 人工标注测试数据的正确实体
+- [ ] 计算NER准确率、召回率、F1值
+- [ ] 分析错误案例
+- [ ] 优化规则和阈值
+- [ ] 重新测试直到准确率 > 85%
+
+### 任务5.3:性能优化
+- [ ] 分析性能瓶颈(使用profiler)
+- [ ] 优化数据库查询(添加索引)
+- [ ] 优化NER提取速度(并行处理)
+- [ ] 优化前端渲染性能(虚拟滚动)
+- [ ] 添加缓存机制
+- [ ] 压力测试(并发请求)
+
+### 任务5.4:用户体验优化
+- [ ] 添加操作引导(新手教程)
+- [ ] 优化错误提示信息
+- [ ] 添加操作确认对话框
+- [ ] 优化加载状态展示
+- [ ] 添加快捷键支持
+- [ ] 收集用户反馈并改进
+
+### 任务5.5:文档编写
+- [ ] 编写用户操作手册
+- [ ] 编写API接口文档
+- [ ] 编写开发者文档
+- [ ] 录制操作演示视频
+- [ ] 编写FAQ文档
+
+**验收标准**:
+- 端到端测试全部通过
+- NER准确率 > 85%
+- 单个附件处理时间 < 30s
+- 用户满意度 > 4.0/5.0
+- 文档完整齐全
+
+---
+
+## 阶段6:部署与上线(预计1天)
+
+### 任务6.1:环境配置
+- [ ] 配置生产环境的NER服务地址
+- [ ] 配置DeepSeek API密钥(如使用)
+- [ ] 配置数据库连接
+- [ ] 配置日志级别和输出
+
+### 任务6.2:部署Python NER服务
+- [ ] 构建Docker镜像
+- [ ] 部署到服务器
+- [ ] 配置systemd服务(参考 `scripts/systemd/lingyue-ner.service`)
+- [ ] 测试服务可用性
+- [ ] 配置监控和告警
+
+### 任务6.3:部署后端服务
+- [ ] 编译打包后端代码
+- [ ] 执行数据库迁移
+- [ ] 部署到服务器
+- [ ] 重启相关服务
+- [ ] 验证API可用性
+
+### 任务6.4:部署前端
+- [ ] 构建前端代码
+- [ ] 部署到Web服务器
+- [ ] 配置Nginx反向代理
+- [ ] 测试页面访问
+
+### 任务6.5:上线验证
+- [ ] 执行冒烟测试
+- [ ] 验证核心功能
+- [ ] 检查日志输出
+- [ ] 监控系统性能
+- [ ] 准备回滚方案
+
+**验收标准**:
+- 所有服务正常运行
+- 核心功能验证通过
+- 监控指标正常
+- 无严重错误日志
+
+---
+
+## 附录:开发规范
+
+### 代码规范
+- Java代码遵循阿里巴巴Java开发手册
+- Python代码遵循PEP 8规范
+- Vue代码遵循Vue官方风格指南
+- 所有代码必须通过Lint检查
+
+### 提交规范
+- Commit message格式:`[类型] 简短描述`
+- 类型:feat/fix/docs/style/refactor/test/chore
+- 示例:`[feat] 添加NER实体列表视图`
+
+### 分支管理
+- 主分支:`main`
+- 开发分支:`dev/ner-implementation`
+- 功能分支:`feature/ner-entity-service`
+- 修复分支:`fix/ner-accuracy-issue`
+
+### Code Review
+- 所有代码必须经过至少1人Review
+- Review重点:功能正确性、代码质量、性能、安全性
+- 使用GitLab Merge Request进行Review
+
+### 测试要求
+- 单元测试覆盖率 > 80%
+- 关键功能必须有集成测试
+- 上线前必须通过端到端测试
+
+---
+
+## 风险管理
+
+### 高风险项
+1. **NER准确率不达标**
+   - 缓解措施:准备多种提取模式(规则/LLM),支持人工校正
+   - 应急方案:降低自动映射比例,增加人工审核
+
+2. **性能问题**
+   - 缓解措施:异步处理、分块提取、缓存优化
+   - 应急方案:限制并发数、增加服务器资源
+
+3. **LLM API成本过高**
+   - 缓解措施:优先使用规则模式,仅复杂场景用LLM
+   - 应急方案:切换到本地模型(Ollama)
+
+### 中风险项
+1. **数据一致性问题**
+   - 缓解措施:事务控制、审计日志
+   - 应急方案:数据回滚机制
+
+2. **用户体验不佳**
+   - 缓解措施:用户测试、快速迭代
+   - 应急方案:提供详细操作文档和培训
+
+---
+
+## 进度跟踪
+
+| 阶段 | 预计工期 | 开始日期 | 结束日期 | 负责人 | 状态 |
+|------|----------|----------|----------|--------|------|
+| 阶段1:基础设施 | 2天 | - | - | - | 待开始 |
+| 阶段2:NER服务 | 3天 | - | - | - | 待开始 |
+| 阶段3:后端集成 | 3天 | - | - | - | 待开始 |
+| 阶段4:前端开发 | 4天 | - | - | - | 待开始 |
+| 阶段5:测试优化 | 3天 | - | - | - | 待开始 |
+| 阶段6:部署上线 | 1天 | - | - | - | 待开始 |
+| **总计** | **16天** | - | - | - | - |
+
+---
+
+**文档版本**:v1.0  
+**创建时间**:2024-03-04  
+**最后更新**:2024-03-04  
+**维护者**:开发团队

+ 412 - 0
docs/design/NER阶段实现方案.md

@@ -0,0 +1,412 @@
+# NER阶段完整实现方案
+
+## 一、当前架构分析
+
+### 1.1 数据流概览
+
+```
+附件上传 → 文档解析 → 文本存储 → NER提取 → 图数据库存储 → 前端展示
+```
+
+### 1.2 现有组件
+
+**后端服务**:
+- `lingyue-ai`: NER服务调用层
+- `lingyue-project`: 项目、附件、规则管理
+- `lingyue-file`: 文件存储
+- `lingyue-task`: 异步任务管理
+
+**Python服务**:
+- `ner-service`: NER实体提取服务(支持rule/ollama/deepseek模式)
+
+**数据存储**:
+- 图数据库:`graph_nodes`(节点)、`graph_edges`(边)、`graph_properties`(属性)
+- 节点类型:PROJECT, ELEMENT, VALUE, ATTACHMENT, ENTITY, RULE
+
+### 1.3 Mock数据要素分类
+
+**A. 用户输入类**(6个):
+- 项目编号、申请级别、申请日期、获证日期、证书编号、通过评审日期
+
+**B. 附件提取类-基础信息**(7个):
+- 评审对象、简称、开始日期、结束日期、复审范围、工作过程、自评过程等
+
+**C. 附件提取类-评审内容**(18个):
+- 目标、职责、全员参与、安全投入、安全文化、体系化管理等
+
+**D. 表格数据类**(7个):
+- 复审项目列表、复审人员列表、整改建议、评审详情等
+
+**E. 计算类**(2个):
+- 评审得分、评审级别(基于表格数据计算)
+
+## 二、NER要素提取策略
+
+### 2.1 提取模式设计
+
+采用**三层提取策略**:
+
+#### 层1:规则提取(Rule-based)
+适用于格式固定的字段:
+- 日期:`2024年7月13日`
+- 数值:`93.33分`
+- 级别:`一级`、`二级`
+- 证书编号:`ZGDIDBOY-083`
+- 评审代码:`5.1.1.1`
+
+#### 层2:LLM智能提取(DeepSeek/Qwen)
+适用于需要语义理解的字段:
+- 评审对象简称提取
+- 复审范围总结
+- 各类管理描述(目标、职责等)
+- 工作过程描述
+
+#### 层3:表格结构化提取
+适用于表格数据:
+- 使用文档解析器识别表格结构
+- 按行列提取数据
+- 映射到实体节点
+
+### 2.2 实体类型扩展
+
+**新增智报专用实体类型**:
+
+```python
+ENTITY_TYPES = {
+    # 基础类型(已有)
+    "DATE": "日期",
+    "NUMBER": "数值", 
+    "ORG": "机构",
+    "LOC": "地点",
+    "PERSON": "人名",
+    
+    # 智报专用类型(新增)
+    "SCORE": "评审得分",
+    "LEVEL": "级别",
+    "CERTIFICATE_CODE": "证书编号",
+    "REVIEW_CODE": "评审代码",
+    "COMPANY_ALIAS": "公司简称",
+    "PROJECT_NAME": "项目名称",
+    "REVIEW_ITEM": "评审项",
+    "SUGGESTION": "整改建议",
+}
+```
+
+### 2.3 分阶段提取流程
+
+```
+阶段1: 工作方案(001008)
+  ↓ 提取:评审对象、简称、日期、范围、人员
+  
+阶段2: 核心要素评审记录表(001001)
+  ↓ 提取:得分、各项评审内容、问题描述
+  
+阶段3: 复审问题建议表(001010)
+  ↓ 提取:整改建议
+  
+阶段4: 其他附件(001003等)
+  ↓ 提取:自评过程、工作依据等
+```
+
+## 三、存储结构设计
+
+### 3.1 图数据库节点设计
+
+#### 节点类型扩展
+
+```sql
+-- 新增节点类型
+NODE_NER_ENTITY = "NER_ENTITY"  -- NER提取的实体
+NODE_NER_RELATION = "NER_RELATION"  -- 实体间关系
+```
+
+#### 实体节点属性
+
+```json
+{
+  "id": 1001,
+  "node_type": "NER_ENTITY",
+  "node_key": "entity_20240101_001",
+  "node_name": "中国电建集团成都勘测设计研究院有限公司",
+  "properties": {
+    "entity_type": "ORG",           // 实体类型
+    "entity_value": "成都院",        // 实体值(简称)
+    "confidence": 0.95,             // 置信度
+    "char_start": 100,              // 文本起始位置
+    "char_end": 125,                // 文本结束位置
+    "line": 5,                      // 所在行号
+    "context": "...评审对象:中国电建...",  // 上下文
+    "source_attachment_id": 408,    // 来源附件
+    "extract_method": "llm",        // 提取方法
+    "extract_time": "2024-03-04 14:00:00"
+  }
+}
+```
+
+### 3.2 边关系设计
+
+```sql
+-- 新增边类型
+EDGE_HAS_NER_ENTITY = "HAS_NER_ENTITY"  -- 附件→实体
+EDGE_ENTITY_RELATION = "ENTITY_RELATION"  -- 实体→实体关系
+EDGE_ENTITY_TO_VALUE = "ENTITY_TO_VALUE"  -- 实体→要素值(映射)
+```
+
+#### 关系示例
+
+```
+附件[408:工作方案] --[HAS_NER_ENTITY]--> 实体[成都院]
+实体[成都院] --[ENTITY_RELATION:简称]--> 实体[中国电建集团成都院]
+实体[成都院] --[ENTITY_TO_VALUE]--> 要素值[project.reviewObjectAlias]
+```
+
+### 3.3 与现有要素系统的映射
+
+```
+NER实体 → 规则处理 → 要素值填充
+
+示例:
+NER提取: "2024年7月13日" (DATE)
+  ↓
+规则匹配: project.workStartAt
+  ↓
+创建VALUE节点: "2024年7月13日"
+  ↓
+关联到ELEMENT: workStartAt
+```
+
+## 四、前端展示方案
+
+### 4.1 NER结果展示页面
+
+**位置**:项目详情 → 附件管理 → NER分析
+
+**功能模块**:
+
+1. **实体列表视图**
+   - 按类型分组展示(ORG/DATE/PERSON等)
+   - 显示置信度、来源位置
+   - 支持筛选、搜索
+
+2. **文本标注视图**
+   - 在原文中高亮显示实体
+   - 不同颜色标识不同类型
+   - 鼠标悬停显示详情
+
+3. **关系图谱视图**
+   - 可视化实体间关系
+   - 支持交互式探索
+
+4. **要素映射视图**
+   - 显示NER实体→要素值的映射关系
+   - 支持手动调整映射
+
+### 4.2 UI组件设计
+
+```vue
+<template>
+  <div class="ner-analysis">
+    <!-- 顶部统计 -->
+    <div class="stats-bar">
+      <el-statistic title="实体总数" :value="entityCount" />
+      <el-statistic title="关系数" :value="relationCount" />
+      <el-statistic title="已映射" :value="mappedCount" />
+    </div>
+    
+    <!-- 实体列表 -->
+    <el-tabs v-model="activeTab">
+      <el-tab-pane label="实体列表" name="entities">
+        <entity-list :entities="entities" />
+      </el-tab-pane>
+      
+      <el-tab-pane label="文本标注" name="annotation">
+        <text-annotation :text="sourceText" :entities="entities" />
+      </el-tab-pane>
+      
+      <el-tab-pane label="关系图谱" name="graph">
+        <relation-graph :entities="entities" :relations="relations" />
+      </el-tab-pane>
+      
+      <el-tab-pane label="要素映射" name="mapping">
+        <entity-mapping :entities="entities" :elements="elements" />
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+```
+
+## 五、实施路线图
+
+### 阶段1:基础设施搭建(1-2天)
+
+**任务**:
+- [ ] 扩展Constants常量(新增节点/边类型)
+- [ ] 创建NER实体相关的DTO/VO类
+- [ ] 扩展图数据库Service(支持NER节点操作)
+- [ ] 配置DeepSeek API(如使用LLM模式)
+
+**产出**:
+- NER基础数据结构就绪
+- API配置完成
+
+### 阶段2:NER服务增强(2-3天)
+
+**任务**:
+- [ ] 扩展Python NER服务的实体类型规则
+- [ ] 实现智报专用实体提取逻辑
+- [ ] 添加表格数据提取功能
+- [ ] 实现分阶段提取策略
+
+**产出**:
+- 增强版NER服务
+- 单元测试通过
+
+### 阶段3:后端集成(2-3天)
+
+**任务**:
+- [ ] 在AttachmentService中添加NER触发逻辑
+- [ ] 实现NER结果存储到图数据库
+- [ ] 实现实体→要素值的自动映射
+- [ ] 添加NER结果查询API
+
+**产出**:
+- 完整的NER数据流
+- API接口文档
+
+### 阶段4:前端展示(3-4天)
+
+**任务**:
+- [ ] 创建NER分析页面组件
+- [ ] 实现实体列表视图
+- [ ] 实现文本标注视图
+- [ ] 实现要素映射视图
+- [ ] (可选)实现关系图谱视图
+
+**产出**:
+- NER可视化界面
+- 用户操作手册
+
+### 阶段5:测试与优化(2-3天)
+
+**任务**:
+- [ ] 端到端测试(上传附件→NER→展示)
+- [ ] 准确率评估与规则优化
+- [ ] 性能测试与优化
+- [ ] 用户体验优化
+
+**产出**:
+- 测试报告
+- 优化方案
+
+## 六、技术实现细节
+
+### 6.1 NER触发时机
+
+**方案A:附件上传后自动触发**
+```java
+// AttachmentService.uploadAttachment()
+@Transactional
+public AttachmentUploadVO uploadAttachment(...) {
+    // ... 创建附件节点
+    
+    // 触发NER提取(异步)
+    taskService.createNerTask(attachmentId);
+    
+    return vo;
+}
+```
+
+**方案B:用户手动触发**
+```java
+// 新增API
+@PostMapping("/{attachmentId}/ner")
+public Result<?> triggerNer(@PathVariable Long attachmentId) {
+    nerService.extractEntities(attachmentId);
+    return Result.ok();
+}
+```
+
+### 6.2 实体映射策略
+
+**自动映射规则**:
+```java
+// 基于实体类型和上下文自动映射
+if (entity.getType().equals("ORG") && 
+    entity.getContext().contains("评审对象")) {
+    mapToElement(entity, "project.reviewObject");
+}
+
+if (entity.getType().equals("DATE") && 
+    entity.getContext().contains("评审开始")) {
+    mapToElement(entity, "project.workStartAt");
+}
+```
+
+**手动映射接口**:
+```java
+@PostMapping("/entities/{entityId}/map")
+public Result<?> mapEntityToElement(
+    @PathVariable Long entityId,
+    @RequestBody MapRequest request) {
+    
+    nerService.mapEntity(entityId, request.getElementKey());
+    return Result.ok();
+}
+```
+
+### 6.3 性能优化
+
+**批量处理**:
+- 多个附件并行NER提取
+- 批量插入图数据库
+
+**缓存策略**:
+- 缓存NER结果(避免重复提取)
+- 缓存实体映射规则
+
+**增量更新**:
+- 仅对新上传/修改的附件执行NER
+- 支持部分重新提取
+
+## 七、风险与应对
+
+### 7.1 准确率问题
+
+**风险**:规则模式准确率不足,LLM成本高
+
+**应对**:
+- 混合模式:规则+LLM
+- 人工校验机制
+- 持续优化规则库
+
+### 7.2 性能问题
+
+**风险**:大文档NER耗时长
+
+**应对**:
+- 异步处理
+- 分块提取
+- 进度反馈
+
+### 7.3 数据一致性
+
+**风险**:NER结果与要素值不一致
+
+**应对**:
+- 版本控制
+- 审计日志
+- 回滚机制
+
+## 八、后续扩展
+
+- [ ] 支持自定义实体类型
+- [ ] 实体去重与合并
+- [ ] 跨附件实体关联
+- [ ] 知识图谱构建
+- [ ] 智能问答(基于NER结果)
+
+---
+
+**文档版本**:v1.0  
+**创建时间**:2024-03-04  
+**维护者**:开发团队

+ 133 - 0
frontend/vue-demo/src/api/index.js

@@ -110,6 +110,10 @@ export const projectApi = {
 
   getDocContent(id) {
     return api.get(`/projects/${id}/doc-content`)
+  },
+
+  saveDocContent(id, docContent) {
+    return api.put(`/projects/${id}/doc-content`, docContent)
   }
 }
 
@@ -325,6 +329,135 @@ export const aiApi = {
   }
 }
 
+// ==================== 要素提取 API (Python NER服务) ====================
+
+const extractService = axios.create({
+  baseURL: 'http://127.0.0.1:8002',  // NER服务地址(Python服务端口8002)
+  timeout: 180000  // 3分钟超时
+})
+
+export const extractApi = {
+  /**
+   * 同步处理:上传DOCX → 解析 → 提取要素
+   * 适用于小文档(<5MB)
+   * 
+   * @param {File} file - DOCX文件
+   * @param {number} attachmentId - 附件ID
+   * @param {boolean} useLlm - 是否使用LLM提取
+   * @returns {Promise} { success, doc_content, elements, values, statistics }
+   */
+  extractFromDocx(file, attachmentId = 0, useLlm = true) {
+    const formData = new FormData()
+    formData.append('file', file)
+    formData.append('attachment_id', attachmentId)
+    formData.append('use_llm', useLlm)
+    return extractService.post('/extract/from-docx', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    }).then(r => r.data)
+  },
+
+  /**
+   * 异步处理:上传DOCX → 返回任务ID → 轮询获取结果
+   * 适用于大文档(>5MB)
+   * 
+   * @param {File} file - DOCX文件
+   * @param {number} attachmentId - 附件ID
+   * @param {boolean} useLlm - 是否使用LLM提取
+   * @returns {Promise} { task_id, status, progress, message }
+   */
+  extractFromDocxAsync(file, attachmentId = 0, useLlm = true) {
+    const formData = new FormData()
+    formData.append('file', file)
+    formData.append('attachment_id', attachmentId)
+    formData.append('use_llm', useLlm)
+    return extractService.post('/extract/async/from-docx', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    }).then(r => r.data)
+  },
+
+  /**
+   * 查询异步任务状态
+   * 
+   * @param {string} taskId - 任务ID
+   * @returns {Promise} { task_id, status, progress, message, result }
+   */
+  getTaskStatus(taskId) {
+    return extractService.get(`/extract/task/${taskId}`).then(r => r.data)
+  },
+
+  /**
+   * 轮询等待异步任务完成
+   * 
+   * @param {string} taskId - 任务ID
+   * @param {Function} onProgress - 进度回调 (progress, message) => void
+   * @param {number} interval - 轮询间隔(ms),默认2000
+   * @param {number} timeout - 超时时间(ms),默认300000(5分钟)
+   * @returns {Promise} 任务结果
+   */
+  async waitForTask(taskId, onProgress = null, interval = 2000, timeout = 300000) {
+    const startTime = Date.now()
+    
+    while (true) {
+      const task = await this.getTaskStatus(taskId)
+      
+      if (onProgress) {
+        onProgress(task.progress, task.message)
+      }
+      
+      if (task.status === 'completed') {
+        return task.result
+      }
+      
+      if (task.status === 'failed') {
+        throw new Error(task.message || '任务处理失败')
+      }
+      
+      if (Date.now() - startTime > timeout) {
+        throw new Error('任务处理超时')
+      }
+      
+      await new Promise(resolve => setTimeout(resolve, interval))
+    }
+  },
+
+  /**
+   * 智能上传:根据文件大小自动选择同步或异步
+   * 
+   * @param {File} file - DOCX文件
+   * @param {number} attachmentId - 附件ID
+   * @param {boolean} useLlm - 是否使用LLM提取
+   * @param {Function} onProgress - 进度回调(仅异步时有效)
+   * @returns {Promise} { success, doc_content, elements, values, statistics }
+   */
+  async smartExtract(file, attachmentId = 0, useLlm = true, onProgress = null) {
+    const MAX_SYNC_SIZE = 5 * 1024 * 1024  // 5MB
+    
+    if (file.size <= MAX_SYNC_SIZE) {
+      // 小文件:同步处理
+      return this.extractFromDocx(file, attachmentId, useLlm)
+    } else {
+      // 大文件:异步处理
+      const { task_id } = await this.extractFromDocxAsync(file, attachmentId, useLlm)
+      const result = await this.waitForTask(task_id, onProgress)
+      return {
+        success: true,
+        ...result
+      }
+    }
+  },
+
+  /**
+   * 从纯文本中提取要素(兼容接口)
+   */
+  extractFromText(text, attachmentId = 0, useLlm = true) {
+    return extractService.post('/extract/from-text', {
+      text,
+      attachment_id: attachmentId,
+      use_llm: useLlm
+    }).then(r => r.data)
+  }
+}
+
 // ==================== PDF 解析 API (外部服务) ====================
 
 const parseService = axios.create({

+ 271 - 15
frontend/vue-demo/src/views/Editor.vue

@@ -57,7 +57,17 @@
               </div>
               <div class="doc-item-body">
                 <div class="doc-item-title">{{ project.title }}</div>
-                <div class="doc-item-meta">
+                <!-- 解析进度条 -->
+                <div v-if="parsingProjectId === project.id" class="doc-item-progress">
+                  <el-progress 
+                    :percentage="docxParseProgress" 
+                    :status="docxParseStatus || undefined"
+                    :stroke-width="4"
+                    :show-text="false"
+                  />
+                  <span class="progress-text">{{ docxParseMessage }}</span>
+                </div>
+                <div v-else class="doc-item-meta">
                   <el-tag
                     size="small"
                     :type="project.status === 'archived' ? 'success' : project.status === 'editing' ? '' : 'warning'"
@@ -813,18 +823,58 @@
     </el-dialog>
 
     <!-- 新建项目对话框 -->
-    <el-dialog v-model="showNewProjectDialog" title="新建项目" width="460" :close-on-click-modal="false">
-      <el-form :model="newProjectForm" label-width="80px">
+    <el-dialog v-model="showNewProjectDialog" title="新建项目" width="520" :close-on-click-modal="false">
+      <el-form :model="newProjectForm" label-width="100px">
         <el-form-item label="项目名称" required>
           <el-input v-model="newProjectForm.title" placeholder="请输入项目名称" maxlength="100" show-word-limit />
         </el-form-item>
         <el-form-item label="项目描述">
-          <el-input v-model="newProjectForm.description" type="textarea" :rows="3" placeholder="项目描述(可选)" />
+          <el-input v-model="newProjectForm.description" type="textarea" :rows="2" placeholder="项目描述(可选)" />
+        </el-form-item>
+        
+        <!-- 可选:上传DOCX文档自动提取要素 -->
+        <el-form-item label="导入文档">
+          <div class="upload-docx-area">
+            <el-upload
+              ref="docxUploadRef"
+              :auto-upload="false"
+              :show-file-list="true"
+              :limit="1"
+              accept=".docx"
+              :on-change="handleDocxFileChange"
+              :on-remove="handleDocxFileRemove"
+            >
+              <template #trigger>
+                <el-button type="primary" plain size="small">
+                  <el-icon><Upload /></el-icon>
+                  选择DOCX文件
+                </el-button>
+              </template>
+              <template #tip>
+                <div class="el-upload__tip">
+                  可选:上传DOCX文件自动解析并提取要素
+                </div>
+              </template>
+            </el-upload>
+          </div>
+        </el-form-item>
+        
+        <!-- 解析进度 -->
+        <el-form-item v-if="docxParseProgress > 0" label="解析进度">
+          <el-progress :percentage="docxParseProgress" :status="docxParseStatus" />
+          <div class="parse-message">{{ docxParseMessage }}</div>
         </el-form-item>
       </el-form>
       <template #footer>
-        <el-button @click="showNewProjectDialog = false">取消</el-button>
-        <el-button type="primary" @click="handleCreateProject" :disabled="!newProjectForm.title.trim()" :loading="creatingProject">创建</el-button>
+        <el-button @click="handleCancelNewProject">取消</el-button>
+        <el-button 
+          type="primary" 
+          @click="handleCreateProject" 
+          :disabled="!newProjectForm.title.trim()" 
+          :loading="creatingProject"
+        >
+          {{ newProjectForm.docxFile ? '创建并解析' : '创建' }}
+        </el-button>
       </template>
     </el-dialog>
 
@@ -1009,9 +1059,9 @@
 <script setup>
 import { ref, reactive, computed, watch, onMounted } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
-import { Plus, Delete, Search, Loading, Check, CopyDocument, MoreFilled, List, Folder, Document, Grid, Setting, Paperclip } from '@element-plus/icons-vue'
+import { Plus, Delete, Search, Loading, Check, CopyDocument, MoreFilled, List, Folder, Document, Grid, Setting, Paperclip, Upload } from '@element-plus/icons-vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { projectApi, elementApi, valueApi, attachmentApi, ruleApi, parseApi } from '@/api'
+import { projectApi, elementApi, valueApi, attachmentApi, ruleApi, parseApi, extractApi } from '@/api'
 import { marked } from 'marked'
 import JSZip from 'jszip'
 import { useTaskCenterStore } from '@/stores/taskCenter'
@@ -1840,7 +1890,12 @@ const zipParentAtt = ref(null) // 当前打开的 ZIP 附件对象
 // 缓存上传的原始文件对象,用于后续解析
 const attachmentFileCache = new Map()
 const creatingProject = ref(false)
-const newProjectForm = reactive({ title: '', description: '' })
+const newProjectForm = reactive({ title: '', description: '', docxFile: null })
+const docxUploadRef = ref(null)
+const docxParseProgress = ref(0)
+const docxParseStatus = ref('')
+const docxParseMessage = ref('')
+const parsingProjectId = ref(null)  // 正在解析的项目ID
 const newElementForm = reactive({ elementName: '', elementKey: '', dataType: 'text', description: '' })
 const newRuleForm = reactive({ 
   ruleName: '', 
@@ -1935,16 +1990,144 @@ async function loadProjectData(projectId) {
   finally { loading.value = false }
 }
 
+// DOCX文件选择处理
+function handleDocxFileChange(file) {
+  if (file && file.raw) {
+    newProjectForm.docxFile = file.raw
+  }
+}
+
+function handleDocxFileRemove() {
+  newProjectForm.docxFile = null
+  docxParseProgress.value = 0
+  docxParseStatus.value = ''
+  docxParseMessage.value = ''
+}
+
+function handleCancelNewProject() {
+  showNewProjectDialog.value = false
+  newProjectForm.title = ''
+  newProjectForm.description = ''
+  newProjectForm.docxFile = null
+  docxParseProgress.value = 0
+  docxParseStatus.value = ''
+  docxParseMessage.value = ''
+  if (docxUploadRef.value) {
+    docxUploadRef.value.clearFiles()
+  }
+}
+
 async function handleCreateProject() {
   if (!newProjectForm.title.trim()) return
   creatingProject.value = true
+  
+  const hasDocxFile = !!newProjectForm.docxFile
+  const docxFile = newProjectForm.docxFile  // 保存文件引用
+  
   try {
+    // 1. 创建项目
     const project = await projectApi.create({ title: newProjectForm.title.trim(), description: newProjectForm.description })
-    showNewProjectDialog.value = false; newProjectForm.title = ''; newProjectForm.description = ''
+    
+    // 2. 立即关闭弹窗并刷新项目列表
+    handleCancelNewProject()
     await loadProjects()
-    if (project) await switchProject(project)
-    ElMessage.success('项目创建成功')
-  } catch (error) { ElMessage.error('创建失败: ' + error.message) }
+    
+    // 3. 如果有DOCX文件,在文档列表中显示解析进度
+    if (hasDocxFile && project) {
+      parsingProjectId.value = project.id
+      docxParseProgress.value = 5
+      docxParseMessage.value = '正在上传文档...'
+      
+      try {
+        // 使用智能提取(自动选择同步/异步)
+        const result = await extractApi.smartExtract(
+          docxFile,
+          0,  // attachmentId暂时为0
+          false,  // 先不使用LLM,快速测试
+          (progress, message) => {
+            docxParseProgress.value = 5 + Math.floor(progress * 0.85)
+            docxParseMessage.value = message
+          }
+        )
+        
+        if (result.success) {
+          docxParseProgress.value = 92
+          docxParseMessage.value = '正在保存要素...'
+          
+          // 保存doc_content到项目
+          if (result.doc_content) {
+            await projectApi.saveDocContent(project.id, result.doc_content)
+          }
+          
+          // 先创建要素定义,再保存要素值
+          if (result.elements && result.elements.length > 0) {
+            for (const elem of result.elements) {
+              try {
+                await elementApi.add(project.id, {
+                  elementKey: elem.elementKey,
+                  elementName: elem.elementName,
+                  elementType: elem.elementType || 'text',
+                  namespace: elem.namespace || 'project',
+                  sortOrder: elem.sortOrder || 0
+                })
+              } catch (e) {
+                // 要素可能已存在,忽略错误
+              }
+            }
+          }
+          
+          // 保存提取的要素值
+          if (result.values && result.values.length > 0) {
+            let savedCount = 0
+            for (const val of result.values) {
+              if (val.isFilled && val.valueText) {
+                try {
+                  await valueApi.update(project.id, val.elementKey, { valueText: val.valueText })
+                  savedCount++
+                } catch (e) {
+                  console.warn('保存要素值失败:', val.elementKey)
+                }
+              }
+            }
+          }
+          
+          docxParseProgress.value = 100
+          docxParseStatus.value = 'success'
+          docxParseMessage.value = `完成!提取 ${result.statistics?.filled_values || 0} 个要素`
+          
+          ElMessage.success(`解析完成,已提取 ${result.statistics?.filled_values || 0} 个要素`)
+          
+          // 切换到该项目并刷新数据
+          await switchProject(project)
+        } else {
+          docxParseStatus.value = 'warning'
+          docxParseMessage.value = '解析失败'
+          ElMessage.warning('文档解析失败')
+        }
+      } catch (parseError) {
+        console.error('DOCX解析失败:', parseError)
+        docxParseStatus.value = 'exception'
+        docxParseMessage.value = '解析出错'
+        ElMessage.warning('文档解析失败: ' + parseError.message)
+      }
+      
+      // 3秒后清除进度显示
+      setTimeout(() => {
+        if (parsingProjectId.value === project.id) {
+          parsingProjectId.value = null
+          docxParseProgress.value = 0
+          docxParseStatus.value = ''
+          docxParseMessage.value = ''
+        }
+      }, 3000)
+    } else {
+      ElMessage.success('项目创建成功')
+      if (project) await switchProject(project)
+    }
+    
+  } catch (error) { 
+    ElMessage.error('创建失败: ' + error.message) 
+  }
   finally { creatingProject.value = false }
 }
 
@@ -3314,7 +3497,16 @@ async function parseZipEntry(zf) {
       while (pollCount++ < maxPolls) {
         await new Promise(r => setTimeout(r, 2000))
         const statusResult = await parseApi.getStatus(taskId)
-        if (statusResult.status === 'completed') {
+        const taskStatus = statusResult.status
+        
+        // 更新进度提示
+        if (taskStatus === 'pending') {
+          zf.parseProgress = `任务排队中... (${pollCount}/${maxPolls})`
+        } else if (taskStatus === 'processing') {
+          zf.parseProgress = statusResult.progress || `正在解析中... (${pollCount}/${maxPolls})`
+        }
+        
+        if (taskStatus === 'completed') {
           const result = await parseApi.getResult(taskId)
           let markdown = result.markdown || ''
           // 尝试从 zip 提取图片(如果 markdown 引用了 images/)
@@ -3698,7 +3890,21 @@ async function handleParseAttachment(att) {
           ElMessage.error(`解析失败: ${errMsg}`)
         } else {
           // pending / processing
-          state.progress = statusResult.progress || `解析中... (${pollCount})`
+          let progressMsg = ''
+          if (taskStatus === 'pending') {
+            progressMsg = `任务排队中,请稍候... (${pollCount}/${maxPolls})`
+          } else if (taskStatus === 'processing') {
+            progressMsg = statusResult.progress || `正在解析中... (${pollCount}/${maxPolls})`
+          } else {
+            progressMsg = `解析中... (${pollCount}/${maxPolls})`
+          }
+          state.progress = progressMsg
+          
+          // 每30秒提示一次用户任务仍在处理
+          if (pollCount % 15 === 0) {
+            ElMessage.info(`解析任务仍在处理中,已等待 ${Math.floor(pollCount * 2 / 60)} 分钟,请耐心等待...`)
+          }
+          
           setTimeout(poll, pollInterval)
         }
       } catch (e) {
@@ -4254,6 +4460,24 @@ onMounted(async () => {
           white-space: nowrap;
         }
       }
+      
+      .doc-item-progress {
+        display: flex;
+        flex-direction: column;
+        gap: 4px;
+        
+        .el-progress {
+          width: 100%;
+        }
+        
+        .progress-text {
+          font-size: 11px;
+          color: var(--primary);
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+      }
     }
 
     .doc-more-btn {
@@ -6446,6 +6670,38 @@ onMounted(async () => {
   }
 }
 
+// ==========================================
+// DOCX上传区域样式
+// ==========================================
+.upload-docx-area {
+  .el-upload__tip {
+    font-size: 12px;
+    color: var(--text-3);
+    margin-top: 6px;
+  }
+  
+  :deep(.el-upload-list) {
+    max-width: 100%;
+  }
+  
+  :deep(.el-upload-list__item) {
+    max-width: 100%;
+  }
+  
+  :deep(.el-upload-list__item-file-name) {
+    max-width: 280px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
+
+.parse-message {
+  font-size: 12px;
+  color: var(--text-2);
+  margin-top: 6px;
+}
+
 // ==========================================
 // 新建报告对话框样式
 // ==========================================

+ 1 - 1
frontend/vue-demo/vite.config.js

@@ -6,7 +6,7 @@ import { fileURLToPath, URL } from 'node:url'
 // 后端统一入口: http://47.108.80.98:8001
 // 所有 API 路径: /api/v1/*
 
-const API_SERVER = process.env.API_SERVER || 'http://47.108.80.98:8001'
+const API_SERVER = process.env.API_SERVER || 'http://127.0.0.1:8001'
 
 export default defineConfig({
   plugins: [vue()],

+ 2 - 1
python-services/ner-service/app/main.py

@@ -7,7 +7,7 @@ from loguru import logger
 import sys
 
 from .config import settings
-from .routers import ner, relation
+from .routers import ner, relation, extract
 from .models import HealthResponse
 
 # 配置日志
@@ -39,6 +39,7 @@ app.add_middleware(
 # 注册路由
 app.include_router(ner.router, prefix="/ner", tags=["NER"])
 app.include_router(relation.router, prefix="/ner", tags=["Relation"])
+app.include_router(extract.router, prefix="/extract", tags=["Extract"])
 
 
 @app.get("/health", response_model=HealthResponse, tags=["Health"])

+ 286 - 0
python-services/ner-service/app/routers/extract.py

@@ -0,0 +1,286 @@
+"""
+要素提取路由
+
+统一使用Python进行DOCX解析和要素提取,支持异步处理大文档。
+"""
+
+from fastapi import APIRouter, HTTPException, UploadFile, File, Form, BackgroundTasks
+from pydantic import BaseModel
+from typing import Dict, Any, Optional, List
+import time
+import uuid
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+
+from loguru import logger
+
+from ..services.docx_parser import parse_docx_file, blocks_to_text
+from ..services.element_extractor import element_extractor
+
+router = APIRouter()
+
+# 任务存储(生产环境应使用Redis)
+_tasks: Dict[str, Dict] = {}
+
+# 线程池用于CPU密集型的DOCX解析
+_executor = ThreadPoolExecutor(max_workers=4)
+
+
+class ExtractFromTextRequest(BaseModel):
+    """从纯文本提取要素的请求"""
+    text: str
+    attachment_id: int = 0
+    use_llm: bool = True
+
+
+class ExtractResponse(BaseModel):
+    """提取响应"""
+    success: bool
+    doc_content: Optional[Dict[str, Any]] = None
+    elements: List[Dict] = []
+    values: List[Dict] = []
+    statistics: Dict[str, Any] = {}
+    processing_time_ms: int = 0
+    error: Optional[str] = None
+
+
+class TaskResponse(BaseModel):
+    """异步任务响应"""
+    task_id: str
+    status: str  # pending, processing, completed, failed
+    progress: int = 0
+    message: str = ""
+    result: Optional[Dict[str, Any]] = None
+
+
+# ============================================================
+# 同步接口(小文档,<50页)
+# ============================================================
+
+@router.post("/from-docx", response_model=ExtractResponse)
+async def extract_from_docx(
+    file: UploadFile = File(...),
+    attachment_id: int = Form(default=0),
+    use_llm: bool = Form(default=True)
+):
+    """
+    同步处理:上传DOCX → 解析 → 提取要素
+    
+    适用于小文档(<50页,<5MB)。大文档请使用异步接口。
+    """
+    start_time = time.time()
+    
+    if not file.filename.lower().endswith('.docx'):
+        raise HTTPException(status_code=400, detail="仅支持.docx文件")
+    
+    # 检查文件大小(限制5MB)
+    content = await file.read()
+    if len(content) > 5 * 1024 * 1024:
+        raise HTTPException(
+            status_code=400, 
+            detail="文件过大,请使用异步接口 /extract/async/from-docx"
+        )
+    
+    try:
+        logger.info(f"同步处理文件: {file.filename}, size={len(content)}")
+        
+        # 1. 解析DOCX(在线程池中执行,避免阻塞)
+        loop = asyncio.get_event_loop()
+        doc_content = await loop.run_in_executor(_executor, parse_docx_file, content)
+        logger.info(f"DOCX解析完成: {doc_content['totalBlocks']} 个块")
+        
+        # 2. 转为纯文本
+        text = blocks_to_text(doc_content['blocks'])
+        
+        # 3. 提取要素
+        result = await element_extractor.extract_from_text(
+            text=text,
+            attachment_id=attachment_id,
+            use_llm=use_llm
+        )
+        
+        processing_time = int((time.time() - start_time) * 1000)
+        logger.info(f"处理完成: {result['statistics']}, 耗时: {processing_time}ms")
+        
+        return ExtractResponse(
+            success=True,
+            doc_content=doc_content,
+            elements=result['elements'],
+            values=result['values'],
+            statistics=result['statistics'],
+            processing_time_ms=processing_time
+        )
+    except Exception as e:
+        logger.error(f"处理失败: {e}", exc_info=True)
+        return ExtractResponse(
+            success=False,
+            error=str(e),
+            processing_time_ms=int((time.time() - start_time) * 1000)
+        )
+
+
+# ============================================================
+# 异步接口(大文档)
+# ============================================================
+
+@router.post("/async/from-docx", response_model=TaskResponse)
+async def extract_from_docx_async(
+    background_tasks: BackgroundTasks,
+    file: UploadFile = File(...),
+    attachment_id: int = Form(default=0),
+    use_llm: bool = Form(default=True)
+):
+    """
+    异步处理:上传DOCX → 返回任务ID → 后台解析和提取
+    
+    适用于大文档(>50页)。通过 /extract/task/{task_id} 查询进度和结果。
+    """
+    if not file.filename.lower().endswith('.docx'):
+        raise HTTPException(status_code=400, detail="仅支持.docx文件")
+    
+    # 读取文件内容
+    content = await file.read()
+    
+    # 创建任务
+    task_id = str(uuid.uuid4())
+    _tasks[task_id] = {
+        "status": "pending",
+        "progress": 0,
+        "message": "任务已创建,等待处理",
+        "result": None,
+        "created_at": time.time()
+    }
+    
+    logger.info(f"创建异步任务: task_id={task_id}, file={file.filename}, size={len(content)}")
+    
+    # 添加后台任务
+    background_tasks.add_task(
+        _process_docx_task,
+        task_id,
+        content,
+        attachment_id,
+        use_llm
+    )
+    
+    return TaskResponse(
+        task_id=task_id,
+        status="pending",
+        progress=0,
+        message="任务已创建,正在后台处理"
+    )
+
+
+async def _process_docx_task(
+    task_id: str,
+    content: bytes,
+    attachment_id: int,
+    use_llm: bool
+):
+    """后台处理DOCX任务"""
+    try:
+        _tasks[task_id]["status"] = "processing"
+        _tasks[task_id]["progress"] = 10
+        _tasks[task_id]["message"] = "正在解析DOCX文档..."
+        
+        start_time = time.time()
+        
+        # 1. 解析DOCX
+        loop = asyncio.get_event_loop()
+        doc_content = await loop.run_in_executor(_executor, parse_docx_file, content)
+        
+        _tasks[task_id]["progress"] = 40
+        _tasks[task_id]["message"] = f"解析完成,共{doc_content['totalBlocks']}个块,正在提取要素..."
+        
+        # 2. 转为纯文本
+        text = blocks_to_text(doc_content['blocks'])
+        
+        _tasks[task_id]["progress"] = 50
+        
+        # 3. 提取要素
+        result = await element_extractor.extract_from_text(
+            text=text,
+            attachment_id=attachment_id,
+            use_llm=use_llm
+        )
+        
+        processing_time = int((time.time() - start_time) * 1000)
+        
+        # 4. 保存结果
+        _tasks[task_id]["status"] = "completed"
+        _tasks[task_id]["progress"] = 100
+        _tasks[task_id]["message"] = "处理完成"
+        _tasks[task_id]["result"] = {
+            "doc_content": doc_content,
+            "elements": result['elements'],
+            "values": result['values'],
+            "statistics": result['statistics'],
+            "processing_time_ms": processing_time
+        }
+        
+        logger.info(f"异步任务完成: task_id={task_id}, 耗时={processing_time}ms")
+        
+    except Exception as e:
+        logger.error(f"异步任务失败: task_id={task_id}, error={e}", exc_info=True)
+        _tasks[task_id]["status"] = "failed"
+        _tasks[task_id]["message"] = str(e)
+
+
+@router.get("/task/{task_id}", response_model=TaskResponse)
+async def get_task_status(task_id: str):
+    """查询异步任务状态和结果"""
+    if task_id not in _tasks:
+        raise HTTPException(status_code=404, detail="任务不存在")
+    
+    task = _tasks[task_id]
+    return TaskResponse(
+        task_id=task_id,
+        status=task["status"],
+        progress=task["progress"],
+        message=task["message"],
+        result=task.get("result")
+    )
+
+
+# ============================================================
+# 纯文本接口(兼容Java后端解析的场景)
+# ============================================================
+
+@router.post("/from-text", response_model=ExtractResponse)
+async def extract_from_text(request: ExtractFromTextRequest):
+    """
+    从纯文本中提取要素
+    
+    适用于已有解析文本的场景(如Java后端已解析)。
+    """
+    start_time = time.time()
+    
+    if not request.text or len(request.text.strip()) < 10:
+        return ExtractResponse(
+            success=False,
+            error="文本内容为空或过短",
+            processing_time_ms=0
+        )
+    
+    try:
+        result = await element_extractor.extract_from_text(
+            text=request.text,
+            attachment_id=request.attachment_id,
+            use_llm=request.use_llm
+        )
+        
+        processing_time = int((time.time() - start_time) * 1000)
+        
+        return ExtractResponse(
+            success=True,
+            elements=result['elements'],
+            values=result['values'],
+            statistics=result['statistics'],
+            processing_time_ms=processing_time
+        )
+    except Exception as e:
+        logger.error(f"要素提取失败: {e}", exc_info=True)
+        return ExtractResponse(
+            success=False,
+            error=str(e),
+            processing_time_ms=int((time.time() - start_time) * 1000)
+        )

+ 335 - 0
python-services/ner-service/app/services/docx_parser.py

@@ -0,0 +1,335 @@
+"""
+DOCX解析服务
+
+将docx文件解析为结构化JSON,供前端渲染和要素提取使用。
+"""
+
+import base64
+import re
+from io import BytesIO
+from typing import Dict, List, Any, Optional
+
+import docx
+from docx import Document
+from docx.enum.text import WD_ALIGN_PARAGRAPH
+from docx.oxml.ns import qn
+from loguru import logger
+
+
+def parse_docx_file(file_content: bytes) -> Dict[str, Any]:
+    """
+    解析DOCX文件内容
+    
+    Args:
+        file_content: DOCX文件的二进制内容
+        
+    Returns:
+        {
+            "page": {...},
+            "blocks": [...],
+            "totalBlocks": int
+        }
+    """
+    doc = Document(BytesIO(file_content))
+    
+    # 提取图片
+    images_map = _extract_images(doc)
+    logger.info(f"提取图片: {len(images_map)} 张")
+    
+    # 页面设置
+    section = doc.sections[0]
+    page_info = {
+        'widthMm': round(section.page_width.mm, 1) if section.page_width else 210,
+        'heightMm': round(section.page_height.mm, 1) if section.page_height else 297,
+        'marginTopMm': round(section.top_margin.mm, 1) if section.top_margin else 25.4,
+        'marginBottomMm': round(section.bottom_margin.mm, 1) if section.bottom_margin else 25.4,
+        'marginLeftMm': round(section.left_margin.mm, 1) if section.left_margin else 31.8,
+        'marginRightMm': round(section.right_margin.mm, 1) if section.right_margin else 31.8,
+    }
+    
+    # 解析文档块
+    blocks = []
+    block_id = 0
+    
+    for block_type, item in _iter_block_items(doc):
+        if block_type == 'paragraph':
+            block = _parse_paragraph(item, block_id, images_map)
+            blocks.append(block)
+            block_id += 1
+        elif block_type == 'table':
+            block = _parse_table(item, block_id)
+            blocks.append(block)
+            block_id += 1
+    
+    logger.info(f"解析完成: {len(blocks)} 个块")
+    
+    return {
+        'page': page_info,
+        'blocks': blocks,
+        'totalBlocks': len(blocks),
+    }
+
+
+def _extract_images(doc) -> Dict[str, str]:
+    """提取文档中所有图片"""
+    images = {}
+    for rel_id, rel in doc.part.rels.items():
+        if "image" in rel.reltype:
+            blob = rel.target_part.blob
+            target = rel.target_ref.lower()
+            if target.endswith('.png'):
+                mime = 'image/png'
+            elif target.endswith('.jpg') or target.endswith('.jpeg'):
+                mime = 'image/jpeg'
+            elif target.endswith('.gif'):
+                mime = 'image/gif'
+            elif target.endswith('.bmp'):
+                mime = 'image/bmp'
+            else:
+                mime = 'image/png'
+            b64 = base64.b64encode(blob).decode('ascii')
+            images[rel_id] = f"data:{mime};base64,{b64}"
+    return images
+
+
+def _iter_block_items(doc):
+    """按文档body中的顺序迭代段落和表格"""
+    body = doc.element.body
+    for child in body:
+        if child.tag == qn('w:p'):
+            yield ('paragraph', docx.text.paragraph.Paragraph(child, doc))
+        elif child.tag == qn('w:tbl'):
+            yield ('table', docx.table.Table(child, doc))
+
+
+def _get_alignment(paragraph) -> Optional[str]:
+    """获取段落对齐方式"""
+    align = paragraph.alignment
+    if align is None:
+        pPr = paragraph._element.find(qn('w:pPr'))
+        if pPr is not None:
+            jc = pPr.find(qn('w:jc'))
+            if jc is not None:
+                return jc.get(qn('w:val'))
+        return None
+    align_map = {
+        WD_ALIGN_PARAGRAPH.LEFT: 'left',
+        WD_ALIGN_PARAGRAPH.CENTER: 'center',
+        WD_ALIGN_PARAGRAPH.RIGHT: 'right',
+        WD_ALIGN_PARAGRAPH.JUSTIFY: 'justify',
+    }
+    return align_map.get(align, None)
+
+
+def _get_run_format(run) -> Dict:
+    """提取Run级别格式"""
+    fmt = {}
+    font = run.font
+    
+    if font.name:
+        fmt['fontFamily'] = font.name
+    if font.size:
+        fmt['fontSize'] = font.size.pt
+    if font.bold:
+        fmt['bold'] = True
+    if font.italic:
+        fmt['italic'] = True
+    if font.underline and font.underline is not True:
+        fmt['underline'] = str(font.underline)
+    elif font.underline is True:
+        fmt['underline'] = 'single'
+    if font.strike:
+        fmt['strikeThrough'] = True
+    if font.color and font.color.rgb:
+        fmt['color'] = str(font.color.rgb)
+    
+    if font.superscript:
+        fmt['verticalAlign'] = 'superscript'
+    elif font.subscript:
+        fmt['verticalAlign'] = 'subscript'
+    
+    return fmt
+
+
+def _detect_paragraph_type(paragraph) -> str:
+    """检测段落类型"""
+    style_name = paragraph.style.name if paragraph.style else ''
+    
+    if style_name.startswith('Heading') or style_name.startswith('heading'):
+        level = re.search(r'\d+', style_name)
+        if level:
+            return f'heading{level.group()}'
+        return 'heading1'
+    
+    if style_name.startswith('toc ') or style_name.startswith('TOC'):
+        level = re.search(r'\d+', style_name)
+        lvl = level.group() if level else '1'
+        return f'toc{lvl}'
+    
+    if style_name.startswith('List'):
+        return 'list_item'
+    
+    # 通过格式推断标题
+    text = paragraph.text.strip()
+    if text and paragraph.runs:
+        first_run = paragraph.runs[0]
+        if first_run.font.bold and first_run.font.size:
+            size_pt = first_run.font.size.pt
+            if size_pt >= 18:
+                return 'heading1'
+            elif size_pt >= 16:
+                return 'heading2'
+            elif size_pt >= 14:
+                if re.match(r'^\d+(\.\d+)*\s', text):
+                    dots = text.split()[0].count('.')
+                    if dots == 0:
+                        return 'heading1'
+                    elif dots == 1:
+                        return 'heading2'
+                    else:
+                        return 'heading3'
+    
+    return 'paragraph'
+
+
+def _get_paragraph_images(paragraph, images_map) -> List[Dict]:
+    """检查段落中的内联图片"""
+    inline_images = []
+    for run in paragraph.runs:
+        run_xml = run._element
+        drawings = run_xml.findall(qn('w:drawing'))
+        for drawing in drawings:
+            blips = drawing.findall('.//' + qn('a:blip'))
+            for blip in blips:
+                embed = blip.get(qn('r:embed'))
+                if embed and embed in images_map:
+                    extent = drawing.find('.//' + qn('wp:extent'))
+                    width = height = None
+                    if extent is not None:
+                        cx = extent.get('cx')
+                        cy = extent.get('cy')
+                        if cx:
+                            width = int(cx) / 914400
+                        if cy:
+                            height = int(cy) / 914400
+                    
+                    inline_images.append({
+                        'rId': embed,
+                        'src': images_map[embed],
+                        'widthInch': round(width, 2) if width else None,
+                        'heightInch': round(height, 2) if height else None,
+                    })
+    return inline_images
+
+
+def _parse_paragraph(paragraph, block_id: int, images_map: Dict) -> Dict:
+    """解析段落"""
+    para_type = _detect_paragraph_type(paragraph)
+    
+    # 段落样式
+    style_info = {}
+    pf = paragraph.paragraph_format
+    alignment = _get_alignment(paragraph)
+    if alignment:
+        style_info['alignment'] = alignment
+    if pf.left_indent:
+        style_info['indentLeft'] = int(pf.left_indent)
+    if pf.first_line_indent:
+        val = int(pf.first_line_indent)
+        if val > 0:
+            style_info['indentFirstLine'] = val
+    
+    # 内联图片
+    inline_imgs = _get_paragraph_images(paragraph, images_map)
+    
+    # 提取runs
+    runs = []
+    for r in paragraph.runs:
+        run_text = r.text
+        if not run_text:
+            continue
+        run_data = {'text': run_text}
+        fmt = _get_run_format(r)
+        if fmt:
+            run_data.update(fmt)
+        runs.append(run_data)
+    
+    block = {
+        'id': f'b{block_id}',
+        'type': para_type,
+    }
+    
+    if runs:
+        block['runs'] = runs
+    if style_info:
+        block['style'] = style_info
+    if inline_imgs:
+        block['images'] = inline_imgs
+    
+    if not runs and not inline_imgs:
+        block['runs'] = [{'text': ''}]
+    
+    return block
+
+
+def _parse_table(table, block_id: int) -> Dict:
+    """解析表格"""
+    rows_data = []
+    for row in table.rows:
+        cells_data = []
+        for cell in row.cells:
+            cell_text = cell.text.strip()
+            tc = cell._tc
+            grid_span = tc.find(qn('w:tcPr'))
+            colspan = 1
+            if grid_span is not None:
+                gs = grid_span.find(qn('w:gridSpan'))
+                if gs is not None:
+                    colspan = int(gs.get(qn('w:val'), 1))
+            
+            cell_data = {
+                'text': cell_text,
+                'colspan': colspan,
+            }
+            cells_data.append(cell_data)
+        rows_data.append(cells_data)
+    
+    return {
+        'id': f'b{block_id}',
+        'type': 'table',
+        'table': {
+            'rows': len(table.rows),
+            'cols': len(table.columns),
+            'data': rows_data,
+        }
+    }
+
+
+def blocks_to_text(blocks: List[Dict]) -> str:
+    """将blocks转为纯文本"""
+    lines = []
+    for block in blocks:
+        if block['type'] == 'table':
+            table = block.get('table', {})
+            for row in table.get('data', []):
+                cells = [cell.get('text', '') for cell in row]
+                lines.append(' | '.join(cells))
+            lines.append('')
+        else:
+            runs = block.get('runs', [])
+            text = ''.join(r.get('text', '') for r in runs)
+            lines.append(text)
+    return '\n'.join(lines)
+
+
+def extract_tables_from_blocks(blocks: List[Dict]) -> List[Dict]:
+    """从blocks中提取所有表格"""
+    tables = []
+    for i, block in enumerate(blocks):
+        if block['type'] == 'table':
+            tables.append({
+                'block_id': block['id'],
+                'block_index': i,
+                'table': block.get('table', {})
+            })
+    return tables

+ 557 - 0
python-services/ner-service/app/services/element_extractor.py

@@ -0,0 +1,557 @@
+"""
+要素提取器:混合NER+LLM策略
+
+从解析后的文档内容中提取要素值,输出前端渲染所需的elements和values。
+"""
+
+import re
+import json
+from typing import Dict, List, Any, Optional, Tuple
+from loguru import logger
+
+# DOCX解析由Java后端完成,这里只处理纯文本
+
+
+# ============================================================
+# NER规则定义
+# ============================================================
+
+NER_RULES = {
+    # 日期类
+    "project.workStartAt": {
+        "patterns": [
+            r'评审日期[::]\s*(\d{4}年\d{1,2}月\d{1,2}日)至',
+            r'(\d{4}年\d{1,2}月\d{1,2}日)至\d{4}年',
+            r'评审(?:开始)?日期[::]\s*(\d{4}年\d{1,2}月\d{1,2}日)',
+        ],
+        "type": "DATE",
+        "element_name": "评审开始日期",
+        "element_type": "text",
+        "namespace": "project"
+    },
+    "project.workEndAt": {
+        "patterns": [
+            r'至(\d{4}年\d{1,2}月\d{1,2}日)',
+            r'评审(?:结束)?日期[::]\s*(\d{4}年\d{1,2}月\d{1,2}日)',
+        ],
+        "type": "DATE",
+        "element_name": "评审结束日期",
+        "element_type": "text",
+        "namespace": "project"
+    },
+    
+    # 得分类
+    "project.resultScore": {
+        "patterns": [
+            r'评审得分[::]\s*(\d+\.?\d*)\s*分',
+            r'得分[::]\s*(\d+\.?\d*)\s*分',
+            r'(\d+\.?\d*)分\s*级别',
+        ],
+        "type": "SCORE",
+        "element_name": "评审得分",
+        "element_type": "text",
+        "namespace": "project",
+        "post_process": "append_unit"  # 添加"分"单位
+    },
+    
+    # 级别类
+    "project.resultLevel": {
+        "patterns": [
+            r'级别[::]\s*(一级|二级|三级)',
+            r'评审(?:结论)?级别[::]\s*(一级|二级|三级)',
+            r'(一级|二级|三级)\s*(?:企业)?(?:证书)?',
+        ],
+        "type": "LEVEL",
+        "element_name": "评审结论级别",
+        "element_type": "text",
+        "namespace": "project"
+    },
+    
+    # 编号类
+    "basicInfo.projectCode": {
+        "patterns": [
+            r'项目编号[::]\s*([A-Z]+-\d+-\d+)',
+            r'编号[::]\s*([A-Z0-9\-]+)',
+        ],
+        "type": "CODE",
+        "element_name": "项目编号",
+        "element_type": "text",
+        "namespace": "basicInfo"
+    },
+    "basicInfo.reviewObjectCertificateCode": {
+        "patterns": [
+            r'证书编号[::]\s*(ZGDIDBOY-\d+)',
+            r'证书编号[((]([^))]+)[))]',
+            r'证书编号[::]\s*([A-Z0-9\-]+)',
+        ],
+        "type": "CODE",
+        "element_name": "证书编号",
+        "element_type": "text",
+        "namespace": "basicInfo"
+    },
+    
+    # 机构类
+    "project.reviewObject": {
+        "patterns": [
+            r'评审对象[::]\s*([^\n]{10,60}(?:公司|集团|院|所))',
+            r'对([^\n]{10,60}(?:公司|集团|院|所))进行.*?(?:评审|复审)',
+        ],
+        "type": "ORG",
+        "element_name": "评审对象",
+        "element_type": "text",
+        "namespace": "project"
+    },
+    "project.reviewObjectAlias": {
+        "patterns": [
+            r'以下简称[「『"""]([^」』""]{2,10})[」』"""]',
+            r'简称[「『"""]([^」』""]{2,10})[」』"""]',
+            r'(以下简称"([^"]{2,10})")',
+        ],
+        "type": "ALIAS",
+        "element_name": "评审对象简称",
+        "element_type": "text",
+        "namespace": "project"
+    },
+}
+
+# ============================================================
+# LLM提取配置
+# ============================================================
+
+LLM_SUMMARY_ELEMENTS = [
+    {
+        "element_key": "project.target",
+        "element_name": "目标",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["目标", "5.1.1"],
+        "prompt": "请根据以下评审意见,总结企业的安全生产目标情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.duty",
+        "element_name": "职责",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["职责", "5.1.2"],
+        "prompt": "请根据以下评审意见,总结企业的安全生产职责落实情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.fullParticipation",
+        "element_name": "全员参与",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["全员参与", "5.1.3"],
+        "prompt": "请根据以下评审意见,总结企业的全员参与情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.safetyInvestment",
+        "element_name": "安全投入",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["安全投入", "安全生产费用", "5.1.4"],
+        "prompt": "请根据以下评审意见,总结企业的安全投入情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.safetyCulture",
+        "element_name": "安全文化",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["安全文化", "5.1.5"],
+        "prompt": "请根据以下评审意见,总结企业的安全文化建设情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.systematicManagement",
+        "element_name": "体系化管理",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["制度化管理", "体系化", "5.2"],
+        "prompt": "请根据以下评审意见,总结企业的体系化管理情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.employeeTraining",
+        "element_name": "人员教育培训",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["教育培训", "5.3"],
+        "prompt": "请根据以下评审意见,总结企业的人员教育培训情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.assetManagement",
+        "element_name": "设备设施管理",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["设备设施", "5.4.1"],
+        "prompt": "请根据以下评审意见,总结企业的设备设施管理情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.jobSafety",
+        "element_name": "作业安全",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["作业安全", "5.4.2.1"],
+        "prompt": "请根据以下评审意见,总结企业的作业安全情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.riskAssessment",
+        "element_name": "风险辨识与评价",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["风险辨识", "风险评价", "5.5.1"],
+        "prompt": "请根据以下评审意见,总结企业的风险辨识与评价情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.hazardInspection",
+        "element_name": "隐患排查",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["隐患排查", "5.5.3"],
+        "prompt": "请根据以下评审意见,总结企业的隐患排查情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.emergencyResponse",
+        "element_name": "应急救援",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["应急救援", "应急管理", "5.6"],
+        "prompt": "请根据以下评审意见,总结企业的应急救援情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.incidentManagement",
+        "element_name": "事故管理",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["事故管理", "5.7"],
+        "prompt": "请根据以下评审意见,总结企业的事故管理情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.continuousImprovement",
+        "element_name": "持续改进",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["持续改进", "5.8"],
+        "prompt": "请根据以下评审意见,总结企业的持续改进情况(100-200字):\n{text}"
+    },
+    {
+        "element_key": "project.reviewObjectSelfAssessmentProcess",
+        "element_name": "自评过程",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["自评", "自查"],
+        "prompt": "请根据以下内容,总结企业的自评过程(150-250字):\n{text}"
+    },
+    {
+        "element_key": "project.safetyHighlight",
+        "element_name": "安全生产管理亮点",
+        "element_type": "paragraph",
+        "namespace": "project",
+        "source_keywords": ["亮点", "特色", "优秀"],
+        "prompt": "请根据以下内容,提炼企业的安全生产管理亮点(100-200字):\n{text}"
+    },
+]
+
+LLM_TABLE_ELEMENTS = [
+    {
+        "element_key": "+SPSRRReviewProject",
+        "element_name": "现场复审项目",
+        "element_type": "table",
+        "namespace": "spsrr",
+        "table_keywords": ["项目名称", "简称", "类型"],
+        "prompt": """请从以下表格中提取复审项目列表,以JSON数组格式返回:
+[{{"name": "项目名称", "alias": "简称", "type": "单位/在建项目", "order": 1}}]
+
+表格内容:
+{text}
+
+只返回JSON数组,不要其他内容。"""
+    },
+    {
+        "element_key": "+SPSRRReviewer",
+        "element_name": "现场复审人员",
+        "element_type": "table",
+        "namespace": "spsrr",
+        "table_keywords": ["姓名", "专业", "分工"],
+        "prompt": """请从以下表格中提取评审人员列表,以JSON数组格式返回:
+[{{"name": "姓名", "specialty": "专业分工"}}]
+
+表格内容:
+{text}
+
+只返回JSON数组,不要其他内容。"""
+    },
+]
+
+
+class ElementExtractor:
+    """要素提取器"""
+    
+    def __init__(self):
+        self.ner_rules = NER_RULES
+        self.llm_summary_config = LLM_SUMMARY_ELEMENTS
+        self.llm_table_config = LLM_TABLE_ELEMENTS
+        self._deepseek_service = None
+    
+    @property
+    def deepseek_service(self):
+        """延迟加载deepseek服务"""
+        if self._deepseek_service is None:
+            try:
+                from .deepseek_service import deepseek_service
+                self._deepseek_service = deepseek_service
+            except ImportError:
+                logger.warning("DeepSeek服务未配置,LLM提取将跳过")
+                self._deepseek_service = None
+        return self._deepseek_service
+    
+    async def extract_from_text(
+        self, 
+        text: str,
+        attachment_id: int = 0,
+        use_llm: bool = True
+    ) -> Dict[str, Any]:
+        """
+        从纯文本中提取所有要素(主接口)
+        
+        Args:
+            text: Java后端解析的纯文本
+            attachment_id: 附件ID
+            use_llm: 是否使用LLM提取(总结型要素)
+            
+        Returns:
+            {
+                "elements": [...],
+                "values": [...],
+                "statistics": {...}
+            }
+        """
+        logger.info(f"开始提取要素: attachment_id={attachment_id}, "
+                   f"text_length={len(text)}, use_llm={use_llm}")
+        
+        # 1. NER规则提取
+        ner_values = self._extract_by_ner(text, attachment_id)
+        logger.info(f"NER提取完成: {len(ner_values)} 个要素")
+        
+        # 2. LLM提取(可选)
+        llm_values = {}
+        if use_llm and self.deepseek_service:
+            llm_values = await self._extract_by_llm(text, attachment_id)
+            logger.info(f"LLM提取完成: {len(llm_values)} 个要素")
+        
+        # 4. 合并结果
+        all_values = {**ner_values, **llm_values}
+        
+        # 5. 生成输出
+        elements, values = self._build_output(all_values, attachment_id)
+        
+        return {
+            "elements": elements,
+            "values": values,
+            "statistics": {
+                "total_elements": len(elements),
+                "filled_values": len([v for v in values if v.get("isFilled")]),
+                "ner_extracted": len(ner_values),
+                "llm_extracted": len(llm_values),
+            }
+        }
+    
+    def _extract_by_ner(
+        self, 
+        text: str, 
+        attachment_id: int
+    ) -> Dict[str, Dict]:
+        """NER规则提取"""
+        results = {}
+        
+        for element_key, rule in self.ner_rules.items():
+            for pattern in rule['patterns']:
+                try:
+                    match = re.search(pattern, text)
+                    if match:
+                        value = match.group(1).strip()
+                        
+                        # 后处理
+                        if rule.get('post_process') == 'append_unit':
+                            if not value.endswith('分'):
+                                value = value + '分'
+                        
+                        results[element_key] = {
+                            'value': value,
+                            'confidence': 0.95,
+                            'source': 'ner',
+                            'position': {
+                                'charStart': match.start(1),
+                                'charEnd': match.end(1),
+                                'line': text[:match.start()].count('\n') + 1
+                            },
+                            'element_name': rule['element_name'],
+                            'element_type': rule['element_type'],
+                            'namespace': rule['namespace']
+                        }
+                        break
+                except Exception as e:
+                    logger.warning(f"NER规则匹配失败: {element_key}, pattern={pattern}, error={e}")
+        
+        return results
+    
+    async def _extract_by_llm(
+        self, 
+        text: str, 
+        attachment_id: int
+    ) -> Dict[str, Dict]:
+        """LLM智能提取(总结型要素)"""
+        results = {}
+        
+        if not self.deepseek_service:
+            return results
+        
+        # 提取总结型要素
+        for config in self.llm_summary_config:
+            element_key = config['element_key']
+            
+            # 查找相关文本
+            relevant_text = self._find_relevant_text(text, config['source_keywords'])
+            
+            if relevant_text and len(relevant_text) > 50:
+                prompt = config['prompt'].format(text=relevant_text[:3000])
+                try:
+                    response = await self.deepseek_service.chat(prompt)
+                    if response and len(response.strip()) > 20:
+                        results[element_key] = {
+                            'value': response.strip(),
+                            'confidence': 0.85,
+                            'source': 'llm',
+                            'element_name': config['element_name'],
+                            'element_type': config['element_type'],
+                            'namespace': config['namespace']
+                        }
+                except Exception as e:
+                    logger.error(f"LLM提取失败: {element_key}, error={e}")
+        
+        # 表格型要素暂时跳过(需要Java后端提供表格结构)
+        # TODO: 后续可以通过Java后端传递表格数据
+        
+        return results
+    
+    def _find_relevant_text(self, text: str, keywords: List[str]) -> str:
+        """根据关键词查找相关文本段落"""
+        lines = text.split('\n')
+        relevant_lines = []
+        capturing = False
+        capture_count = 0
+        
+        for line in lines:
+            # 检查是否包含关键词
+            if any(kw in line for kw in keywords):
+                capturing = True
+                capture_count = 0
+            
+            if capturing:
+                relevant_lines.append(line)
+                capture_count += 1
+                # 最多取30行
+                if capture_count > 30:
+                    capturing = False
+        
+        return '\n'.join(relevant_lines)
+    
+    def _find_relevant_table(self, tables: List[Dict], keywords: List[str]) -> Optional[Dict]:
+        """根据关键词查找相关表格"""
+        for table_info in tables:
+            table = table_info['table']
+            if table.get('data') and len(table['data']) > 0:
+                # 检查表头
+                header_row = table['data'][0]
+                header_texts = [cell.get('text', '') for cell in header_row]
+                header_str = ' '.join(header_texts)
+                
+                # 检查是否包含关键词
+                match_count = sum(1 for kw in keywords if kw in header_str)
+                if match_count >= 2:
+                    return table
+        
+        return None
+    
+    def _table_to_text(self, table: Dict) -> str:
+        """将表格转为文本"""
+        lines = []
+        for row in table.get('data', []):
+            cells = [cell.get('text', '') for cell in row]
+            lines.append(' | '.join(cells))
+        return '\n'.join(lines)
+    
+    def _build_output(
+        self, 
+        extracted_values: Dict[str, Dict],
+        attachment_id: int
+    ) -> Tuple[List[Dict], List[Dict]]:
+        """构建输出的elements和values"""
+        
+        # 合并所有要素定义
+        all_element_defs = {}
+        
+        # 从NER规则获取
+        for key, rule in self.ner_rules.items():
+            all_element_defs[key] = {
+                'element_name': rule['element_name'],
+                'element_type': rule['element_type'],
+                'namespace': rule['namespace']
+            }
+        
+        # 从LLM配置获取
+        for config in self.llm_summary_config:
+            all_element_defs[config['element_key']] = {
+                'element_name': config['element_name'],
+                'element_type': config['element_type'],
+                'namespace': config['namespace']
+            }
+        
+        for config in self.llm_table_config:
+            all_element_defs[config['element_key']] = {
+                'element_name': config['element_name'],
+                'element_type': config['element_type'],
+                'namespace': config['namespace']
+            }
+        
+        elements = []
+        values = []
+        
+        for i, (element_key, elem_def) in enumerate(all_element_defs.items()):
+            element = {
+                "id": 700 + i,
+                "elementKey": element_key,
+                "elementName": elem_def['element_name'],
+                "elementType": elem_def['element_type'],
+                "namespace": elem_def['namespace'],
+                "sortOrder": i
+            }
+            elements.append(element)
+            
+            # 查找提取的值
+            extracted = extracted_values.get(element_key)
+            if extracted:
+                value = {
+                    "valueId": 800 + i,
+                    "elementKey": element_key,
+                    "valueText": extracted['value'],
+                    "isFilled": True,
+                    "fillSource": "ai" if extracted['source'] == 'llm' else "rule",
+                    "confidence": extracted.get('confidence', 0.8),
+                    "sourceAttachmentId": attachment_id
+                }
+                if 'position' in extracted:
+                    value['extractPosition'] = extracted['position']
+            else:
+                value = {
+                    "valueId": 800 + i,
+                    "elementKey": element_key,
+                    "valueText": "",
+                    "isFilled": False,
+                    "fillSource": "default"
+                }
+            
+            values.append(value)
+        
+        return elements, values
+
+
+# 创建单例
+element_extractor = ElementExtractor()

+ 4 - 0
python-services/ner-service/requirements.txt

@@ -19,6 +19,10 @@ aiohttp==3.9.1
 # For DeepSeek/Qwen API fallback
 openai==1.6.1
 
+# DOCX parsing
+python-docx==1.1.0
+lxml==5.1.0
+
 # Utilities
 python-dotenv==1.0.0
 loguru==0.7.2