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

# --- 预设模板库 ---
REPORT_TEMPLATES = {
    "影像报告": {
        "prompt": textwrap.dedent("""\
            请从影像检查报告中提取以下关键信息:
            - 检查部位和器官
            - 病灶的位置、大小、密度/信号特征
            - 增强扫描表现
            - 阴性发现(未见异常的部位)
            
            提取规则:
            1. 必须使用报告中的原文,不要改写
            2. 保留数值的精确度
            3. 完整提取解剖学描述"""),
        "example": json.dumps([{
            "text": "胸部CT平扫显示:右肺上叶尖段见结节影,大小约1.2 x 0.9 cm,边缘毛糙,密度不均。纵隔淋巴结未见明显肿大。",
            "extractions": [
                {"extraction_class": "anatomy", "extraction_text": "右肺上叶尖段", "attributes": {"organ": "肺", "side": "右", "lobe": "上叶", "segment": "尖段"}},
                {"extraction_class": "finding", "extraction_text": "结节影", "attributes": {"type": "结节"}},
                {"extraction_class": "size", "extraction_text": "1.2 x 0.9 cm", "attributes": {"length": "1.2", "width": "0.9", "unit": "cm"}},
                {"extraction_class": "feature", "extraction_text": "边缘毛糙", "attributes": {"feature_type": "边缘", "description": "毛糙"}},
                {"extraction_class": "feature", "extraction_text": "密度不均", "attributes": {"feature_type": "密度", "description": "不均"}},
                {"extraction_class": "negative_finding", "extraction_text": "纵隔淋巴结未见明显肿大", "attributes": {"location": "纵隔", "structure": "淋巴结"}}
            ]
        }], ensure_ascii=False, indent=2)
    },
    "病理报告": {
        "prompt": textwrap.dedent("""\
            请从病理报告中提取以下关键信息:
            - 标本来源和类型
            - 组织学分型
            - 肿瘤分级
            - 免疫组化结果
            - 切缘情况
            
            提取规则:
            1. 完整保留病理诊断术语
            2. 提取所有阳性和阴性的免疫组化标记
            3. 保留分级和分期信息"""),
        "example": json.dumps([{
            "text": "(右乳腺)浸润性导管癌,组织学II级,肿瘤大小2.5cm。免疫组化:ER(+,90%),PR(+,70%),HER-2(-),Ki-67(30%)。切缘未见癌累及。",
            "extractions": [
                {"extraction_class": "specimen", "extraction_text": "右乳腺", "attributes": {"location": "右乳腺"}},
                {"extraction_class": "diagnosis", "extraction_text": "浸润性导管癌", "attributes": {"type": "癌", "subtype": "浸润性导管癌"}},
                {"extraction_class": "grade", "extraction_text": "组织学II级", "attributes": {"grading_system": "组织学", "grade": "II"}},
                {"extraction_class": "size", "extraction_text": "2.5cm", "attributes": {"value": "2.5", "unit": "cm"}},
                {"extraction_class": "ihc_marker", "extraction_text": "ER(+,90%)", "attributes": {"marker": "ER", "result": "阳性", "percentage": "90"}},
                {"extraction_class": "ihc_marker", "extraction_text": "PR(+,70%)", "attributes": {"marker": "PR", "result": "阳性", "percentage": "70"}},
                {"extraction_class": "ihc_marker", "extraction_text": "HER-2(-)", "attributes": {"marker": "HER-2", "result": "阴性"}},
                {"extraction_class": "ihc_marker", "extraction_text": "Ki-67(30%)", "attributes": {"marker": "Ki-67", "percentage": "30"}},
                {"extraction_class": "margin", "extraction_text": "切缘未见癌累及", "attributes": {"status": "阴性"}}
            ]
        }], ensure_ascii=False, indent=2)
    },
    "病历摘要": {
        "prompt": textwrap.dedent("""\
            请从病历中提取以下关键信息:
            - 主诉和现病史要点
            - 既往史(重要疾病和手术史)
            - 体格检查阳性体征
            - 辅助检查异常结果
            - 诊断和治疗方案
            
            提取规则:
            1. 提取关键时间节点
            2. 保留症状的完整描述
            3. 提取数值型指标"""),
        "example": json.dumps([{
            "text": "患者因反复胸痛3月加重1周入院。既往有高血压病史10年。查体:BP 150/95mmHg,心率92次/分。",
            "extractions": [
                {"extraction_class": "chief_complaint", "extraction_text": "反复胸痛3月加重1周", "attributes": {"symptom": "胸痛", "duration": "3月", "change": "加重1周"}},
                {"extraction_class": "past_history", "extraction_text": "高血压病史10年", "attributes": {"disease": "高血压", "duration": "10年"}},
                {"extraction_class": "vital_sign", "extraction_text": "BP 150/95mmHg", "attributes": {"type": "血压", "systolic": "150", "diastolic": "95", "unit": "mmHg"}},
                {"extraction_class": "vital_sign", "extraction_text": "心率92次/分", "attributes": {"type": "心率", "value": "92", "unit": "次/分"}}
            ]
        }], ensure_ascii=False, indent=2)
    }
}

# --- 示例文本库 ---
SAMPLE_TEXTS = {
    "影像报告示例": "头颅MRI平扫增强:左侧基底节区见片状异常信号影,T1WI呈低信号,T2WI及FLAIR呈高信号,大小约4.5 x 3.2 x 3.8 cm,周围见大片水肿带。增强扫描后病灶呈不均匀明显强化,周围水肿带未见强化。中线结构轻度右移约0.5cm。双侧侧脑室对称,未见明显扩大。",
    "病理报告示例": "(胃窦)腺癌,中分化,浸润至肌层。肿瘤大小3.0 x 2.5 cm。免疫组化:CK(+),CK7(-),CK20(+),CDX-2(+),Her-2(1+),Ki-67阳性指数约40%。送检淋巴结12枚,见癌转移3枚(3/12)。",
    "病历示例": "患者女性58岁,因发现左乳腺肿物2月入院。患者2月前无意中发现左乳腺外上象限肿物,约2cm大小,无疼痛,无乳头溢液。既往体健。查体:左乳外上象限可触及约2.5 x 2.0cm肿物,质硬,边界欠清,活动度差,无压痛。左侧腋窝可触及1枚肿大淋巴结,约1.5cm。辅助检查:乳腺超声示左乳外上象限低回声结节,BI-RADS 4C类。"
}

# --- 历史记录管理 ---
extraction_history = []

def save_to_history(input_text, result, template_name):
    """保存提取历史"""
    history_entry = {
        "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "template": template_name,
        "input_preview": input_text[:100] + "..." if len(input_text) > 100 else input_text,
        "extraction_count": len(result.get("extractions", [])),
        "full_result": result
    }
    extraction_history.insert(0, history_entry)
    if len(extraction_history) > 10:
        extraction_history.pop()
    return format_history_display()

def format_history_display():
    """格式化历史记录显示"""
    if not extraction_history:
        return "暂无提取历史"
    
    history_text = ""
    for i, entry in enumerate(extraction_history, 1):
        history_text += f"### 记录 {i} - {entry['timestamp']}\n"
        history_text += f"**模板**: {entry['template']} | **提取项**: {entry['extraction_count']}项\n"
        history_text += f"**文本预览**: {entry['input_preview']}\n\n"
    return history_text

# --- 统计信息生成 ---
def generate_statistics(result):
    """生成提取统计信息"""
    extractions = result.get("extractions", [])
    if not extractions:
        return "暂无统计信息"
    
    class_counts = {}
    for ext in extractions:
        cls = ext.get("extraction_class", "未分类")
        class_counts[cls] = class_counts.get(cls, 0) + 1
    
    stats_text = f"### 📊 提取统计\n"
    stats_text += f"**总提取项数**: {len(extractions)}\n\n"
    stats_text += "**分类统计**:\n"
    for cls, count in sorted(class_counts.items(), key=lambda x: x[1], reverse=True):
        stats_text += f"- {cls}: {count}项\n"
    
    return stats_text

# --- 后端处理函数 ---
def extract_information(api_key, prompt, examples_json, input_text, template_name):
    """执行信息提取"""
    if not api_key:
        raise gr.Error("⚠️ 请输入您的 Google AI Studio API 密钥")
    if not input_text.strip():
        raise gr.Error("⚠️ 请输入待提取的文本内容")

        

    try:
        examples_data = json.loads(examples_json)
        examples = [
            lx.data.ExampleData(
                text=ex['text'],
                extractions=[lx.data.Extraction(**extr) for extr in ex['extractions']]
            ) for ex in examples_data
        ]
    except (json.JSONDecodeError, KeyError) as e:
        raise gr.Error(f"❌ 示例JSON格式错误: {e}")
        

    # ... 其他代码保持不变 ...
    try:
        os.environ['LANGEXTRACT_API_KEY'] = api_key
        
        results = lx.extract(
            text_or_documents=input_text,
            prompt_description=prompt,
            examples=examples,
            model_id="gemini-2.5-flash", # 建议使用最新的模型
        )
        
        if isinstance(results, list) and len(results) > 0:
            result = results[0]
        else:
            result = results
        
        extractions_list = []
        if hasattr(result, 'extractions'):
            for ext in result.extractions:
                ext_dict = {
                    "extraction_class": ext.extraction_class if hasattr(ext, 'extraction_class') else "",
                    "extraction_text": ext.extraction_text if hasattr(ext, 'extraction_text') else "",
                    "char_span": getattr(ext, 'char_span', None),
                    "attributes": ext.attributes if hasattr(ext, 'attributes') else {}
                }
                extractions_list.append(ext_dict)
        
        output_dict = {
            "source_text": result.text if hasattr(result, 'text') else input_text,
            "extractions": extractions_list
        }

        # --- START: 修改后的文件保存逻辑 ---
        
        # 1. 创建一个临时的、持久化的文件,用于最终的下载
        with tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.jsonl', encoding='utf-8') as final_output_file:
            download_path = final_output_file.name

        # 2. 创建一个临时目录,供 langextract 库使用
        with tempfile.TemporaryDirectory() as tmp_dir:
            # 准备好要保存的文档列表
            documents_to_save = results if isinstance(results, list) else [results]
            
            # 3. 让库将结果保存到这个临时目录中
            lx.io.save_annotated_documents(documents_to_save, tmp_dir)
            
            # 4. 库通常会生成一个名为 'output.jsonl' 的文件,找到它
            source_file_path = os.path.join(tmp_dir, 'output.jsonl')
            
            # 5. 将生成的文件内容,复制到我们为 Gradio 准备的最终文件中
            if os.path.exists(source_file_path):
                with open(source_file_path, 'r', encoding='utf-8') as src_file:
                    with open(download_path, 'w', encoding='utf-8') as dest_file:
                        dest_file.write(src_file.read())
            else:
                # 如果没有生成文件,就直接保存我们构造的JSON字典
                with open(download_path, 'w', encoding='utf-8') as dest_file:
                    json.dump(output_dict, dest_file, ensure_ascii=False, indent=2)

        # --- END: 修改后的文件保存逻辑 ---
        
        stats = generate_statistics(output_dict)
        history = save_to_history(input_text, output_dict, template_name)
        
        return output_dict, download_path, stats, history, gr.update(visible=True)

    except Exception as e:
        import traceback
        error_detail = traceback.format_exc()
        print(f"详细错误信息:\n{error_detail}")
        raise gr.Error(f"❌ 提取失败: {str(e)}")
        

def load_template(template_name):
    """加载预设模板"""
    if template_name in REPORT_TEMPLATES:
        template = REPORT_TEMPLATES[template_name]
        return template["prompt"], template["example"]
    return "", ""

def load_sample_text(sample_name):
    """加载示例文本"""
    return SAMPLE_TEXTS.get(sample_name, "")

# --- Gradio 界面 ---
custom_css = """
#header {
    text-align: center;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    padding: 2rem;
    border-radius: 10px;
    color: white;
    margin-bottom: 2rem;
}
#header h1 {
    margin: 0;
    font-size: 2.5rem;
    font-weight: 700;
}
#header p {
    margin: 0.5rem 0 0 0;
    font-size: 1.1rem;
    opacity: 0.95;
}
.template-btn {
    margin: 0.25rem !important;
}
#stats-box {
    background: #f8f9fa;
    padding: 1rem;
    border-radius: 8px;
    border-left: 4px solid #667eea;
}
"""

with gr.Blocks(theme=gr.themes.Soft(), css=custom_css, title="医学信息智能提取系统") as demo:
    
    with gr.Row(elem_id="header"):
        gr.Markdown("""
        # 🏥 医学信息智能提取系统
        ### 基于 AI 的结构化医学文本分析工具
        """)
    
    with gr.Row():
        with gr.Column(scale=1):
            gr.Markdown("### 🔐 API 配置")
            api_key_input = gr.Textbox(
                label="Google AI Studio API Key",
                type="password",
                placeholder="请输入您的 API 密钥...",
                info="获取密钥: https://aistudio.google.com/app/apikey"
            )
            
            gr.Markdown("### 📋 选择报告类型")
            template_selector = gr.Radio(
                choices=list(REPORT_TEMPLATES.keys()),
                value="影像报告",
                label="预设模板",
                info="选择适合您文本类型的模板"
            )
            
            load_template_btn = gr.Button("📥 加载模板", variant="secondary", size="sm")
            
            gr.Markdown("### ✏️ 自定义提取规则")
            prompt_input = gr.Textbox(
                label="提取指令",
                value=REPORT_TEMPLATES["影像报告"]["prompt"],
                lines=8,
                placeholder="描述您想提取的信息类型和规则..."
            )
            
            with gr.Accordion("🎯 提取示例 (JSON格式)", open=False):
                examples_input = gr.Code(
                    value=REPORT_TEMPLATES["影像报告"]["example"],
                    language="json",
                    lines=15,
                    label="示例数据"
                )
        
        with gr.Column(scale=1):
            gr.Markdown("### 📄 输入医学文本")
            
            sample_selector = gr.Dropdown(
                choices=list(SAMPLE_TEXTS.keys()),
                label="快速加载示例",
                value=None
            )
            
            text_input = gr.Textbox(
                label="待提取文本",
                lines=18,
                placeholder="请粘贴或输入医学报告、病历等文本...\n\n支持:\n• 影像报告 (CT/MRI/X线等)\n• 病理报告\n• 病历记录\n• 检验报告",
                max_lines=25
            )
            
            with gr.Row():
                clear_btn = gr.Button("🗑️ 清空", size="sm")
                submit_btn = gr.Button("🚀 开始提取", variant="primary", size="lg", scale=2)
        
        with gr.Column(scale=1):
            gr.Markdown("### ✨ 提取结果")
            
            result_tabs = gr.Tabs()
            with result_tabs:
                with gr.Tab("📊 结构化数据"):
                    json_output = gr.JSON(label="提取结果", show_label=False)
                
                with gr.Tab("📈 统计分析"):
                    stats_output = gr.Markdown("点击'开始提取'后显示统计信息", elem_id="stats-box")
                
                with gr.Tab("📜 历史记录"):
                    history_output = gr.Markdown("暂无提取历史")
            
            file_output = gr.File(label="💾 下载结果文件 (.jsonl)", visible=False)
    
    with gr.Accordion("ℹ️ 使用说明", open=False):
        gr.Markdown("""
        ### 使用步骤
        1. **输入 API 密钥**: 从 Google AI Studio 获取免费 API 密钥
        2. **选择模板**: 根据文本类型选择预设模板,或自定义提取规则
        3. **输入文本**: 粘贴您的医学报告或病历文本
        4. **开始提取**: 点击提取按钮,AI 将自动识别并结构化关键信息
        5. **查看结果**: 在右侧查看结构化数据、统计分析和历史记录
        
        ### 支持的报告类型
        - **影像报告**: CT、MRI、X线、超声等各类影像学检查
        - **病理报告**: 组织病理、细胞病理、免疫组化等
        - **病历记录**: 入院记录、病程记录、出院小结等
        
        ### 提示
        - 提供高质量的示例可显著提升提取准确度
        - 可同时处理多份报告(用空行分隔)
        - 结果可导出为 JSONL 格式供后续分析使用
        """)
    
    load_template_btn.click(
        fn=load_template,
        inputs=[template_selector],
        outputs=[prompt_input, examples_input]
    )
    
    sample_selector.change(
        fn=load_sample_text,
        inputs=[sample_selector],
        outputs=[text_input]
    )
    
    clear_btn.click(
        fn=lambda: "",
        outputs=[text_input]
    )
    
    submit_btn.click(
        fn=extract_information,
        inputs=[api_key_input, prompt_input, examples_input, text_input, template_selector],
        outputs=[json_output, file_output, stats_output, history_output, file_output]
    )

if __name__ == "__main__":
    demo.launch()