diff --git a/.gitignore b/.gitignore
index c7d91ce..d0833e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -167,6 +167,8 @@ components
.vscode/
whoosh_index/
+*.csv
+*.log
*.pdf
*.seg
*.sql
diff --git a/2026/export-all-patho.py b/2026/export-all-patho.py
new file mode 100644
index 0000000..7b2bafb
--- /dev/null
+++ b/2026/export-all-patho.py
@@ -0,0 +1,16 @@
+import pandas as pd
+import pymysql
+import os
+
+# Configure database connection
+conn = pymysql.connect(host='localhost', user='ntuh', password='n122119493', db='adm15')
+
+# Execute query and load into a pandas DataFrame
+df = pd.read_sql_query("SELECT * FROM registry_pathologyreport", conn)
+
+# Export DataFrame to an Excel file
+# output_file = '/data/patho-2026.xlsx'
+# df.to_excel(output_file, index=False)
+output_file_parquet = '/data/patho-2026.parquet'
+df.to_parquet(output_file_parquet, index=False, engine='pyarrow')
+print(f"Data exported to {output_file_parquet}")
diff --git a/ntuh/new_window.png b/ntuh/new_window.png
deleted file mode 100755
index c568d10..0000000
Binary files a/ntuh/new_window.png and /dev/null differ
diff --git a/ntuh/old_window.png b/ntuh/old_window.png
deleted file mode 100755
index b6d6a7f..0000000
Binary files a/ntuh/old_window.png and /dev/null differ
diff --git a/ntuh/screenshot.png b/ntuh/screenshot.png
deleted file mode 100755
index ec54d04..0000000
Binary files a/ntuh/screenshot.png and /dev/null differ
diff --git a/patho/0727/extract.py b/patho/0727/extract.py
new file mode 100755
index 0000000..fbc3446
--- /dev/null
+++ b/patho/0727/extract.py
@@ -0,0 +1,435 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import os
+import sys
+import csv
+import re
+import openpyxl
+from bs4 import BeautifulSoup
+
+script_dir = os.path.dirname(os.path.abspath(__file__))
+base_dir = os.path.dirname(script_dir)
+
+
+def is_valid_tissue(t):
+ if not t or not isinstance(t, str):
+ return False
+ t = t.strip()
+ clean_t = re.sub(r'[\.,\-\_\s\(\)\"\']', '', t)
+ clean_no_dim = re.sub(r'\d+[\.\*x\d]*cm', '', clean_t, flags=re.IGNORECASE)
+ if not clean_t or len(clean_t) <= 2 or clean_t.lower() in ['none', 'nil']:
+ return False
+ if clean_t.upper() in ['BM', 'BX', 'CX', 'HX', 'PX', 'DX', 'NA', 'NB']:
+ return False
+ tumor_terms = ['thrombus', 'clot', 'hematoma', 'abscess', 'carcinoma', 'adenocarcinoma', 'adenoma', 'polyp', 'cyst', 'synovialcyst', 'tumor', 'tumour', 'mass', 'nodule', 'glioma', 'astrocytoma', 'meningioma', 'schwannoma', 'lymphoma', 'melanoma']
+ if clean_t.lower() in tumor_terms or any(clean_t.lower().endswith(k) for k in ['cyst', 'tumor', 'tumour', 'glioma']) or any(clean_no_dim.lower().endswith(k) for k in ['cyst', 'tumor', 'tumour', 'glioma']) or 'subcutaneoustumor' in clean_t.lower() or 'intraduraltumor' in clean_t.lower() or 'hyperintensity' in clean_t.lower() or 'hypointensity' in clean_t.lower() or 'duraseeding' in clean_t.lower() or 'cavitytumor' in clean_t.lower():
+ return False
+ if '位置不明' in t or 'sitenotstate' in clean_t.lower() or 'siteunspecified' in clean_t.lower():
+ return False
+ if any(k in clean_t.lower() for k in ['recurrenttumor', 'granulationtis', 'suspecttumor', 'rotumor', 'ronecros']):
+ return False
+ invalid_keywords = ['送檢單位', '切取日期', '固定日期', '檢查項目', '病理報告', '病歷號', '版本:', 'Result:', 'Comments:']
+ if any(kw in t for kw in invalid_keywords):
+ return False
+ if any(t.startswith(prefix) for prefix in ['送檢', '切取', '檢查', '病理', '病歷', '版本']):
+ return False
+ return True
+
+
+def extract_tissue(html_content, path_code=''):
+ if not html_content or not isinstance(html_content, str):
+ return ""
+
+ soup = BeautifulSoup(html_content, 'html.parser')
+
+ tissue = ""
+ # 1. Check lblTissue span
+ span = soup.find('span', id=lambda x: x and 'lblTissue' in x)
+ if span and is_valid_tissue(span.text):
+ tissue = span.text.strip()
+
+ # 2. Check table row with 組織由來
+ if not tissue:
+ for tr in soup.find_all('tr'):
+ ths = [th.text.strip() for th in tr.find_all('th', recursive=False)]
+ tds = [td.text.strip() for td in tr.find_all('td', recursive=False)]
+ if '組織由來' in ths:
+ idx = ths.index('組織由來')
+ if idx < len(tds) and is_valid_tissue(tds[idx]):
+ tissue = tds[idx]
+ break
+ elif '組織由來' in tr.text:
+ if tds and is_valid_tissue(tds[-1]):
+ tissue = tds[-1]
+
+ # 3. Check 器官與術式 / 器官 / 部位 / 檢體部位
+ if not tissue:
+ for tr in soup.find_all('tr'):
+ ths = [th.text.strip() for th in tr.find_all('th', recursive=False)]
+ tds = [td.text.strip() for td in tr.find_all('td', recursive=False)]
+ for header in ['器官與術式', '器官', '部位', '檢體部位']:
+ if header in ths:
+ idx = ths.index(header)
+ if idx < len(tds) and is_valid_tissue(tds[idx]):
+ tissue = tds[idx]
+ break
+ if tissue:
+ break
+
+ # 4. Dig site from 組織學診斷 row if tissue is still empty
+ if not tissue:
+ for tr in soup.find_all('tr'):
+ ths = [th.text.strip() for th in tr.find_all('th', recursive=False)]
+ tds = [td.text.strip() for td in tr.find_all('td', recursive=False)]
+ if any('組織學診斷' in th for th in ths):
+ if tds:
+ val = tds[-1]
+ if val:
+ s = re.sub(r'^\d+[\.\s]*', '', val)
+ split_site = re.split(r',\s*(?:\d+(?:\-\d+)?\s*cm(?:\s+AAV|\s+above\s+anal\s+verge|\s+[A-Za-z]+)?|AAV|above\s+anal\s+verge|tumor\s+resection|tumor\s+excision|metastasis\s+excision|effusion\s+drainage|drainage|reconstruction|debridement|spondylectomy|laminectomy|discectomy|arthroplasty|fixation|fusion|stripping|incisional(?:\s+biopsy)?|excisional(?:\s+biopsy)?|punch(?:\s+biopsy)?|shave(?:\s+biopsy)?|wedge(?:\s+biopsy)?|core(?:\s+biopsy)?|site\s+not\s+state(?:d)?|site\s+unspecified|\?\s*side|\?|craniotomy(?:\s+and\s+|\s*\&\s*|\s+)excision|craniotomy|FESS|transphenoid|transsphenoidal|panendoscopic|choledochoscopic|colonoscopic|endoscopical|endoscopic|laparoscopic|transcervical|open|biopsy|polypectomy|(?:partial|subtotal|radical|wide|total)?\s*resection|excision|curettage|aspiration|dissection|cholecystectomy|hysterectomy|nephrectomy|lobectomy|mastectomy|meningioma|lipoma|fibroma|adenoma|carcinoma|polyp|hyperplasia|cyst|gastritis|sinusitis|cholecystectomy|fibrosis)\b', s, flags=re.IGNORECASE)[0]
+ split_site = re.split(r',\s*(?:corpus|cervix)\b', split_site, flags=re.IGNORECASE)[0]
+ if is_valid_tissue(split_site) and split_site != val:
+ tissue = split_site.strip()
+ break
+
+ # 5. Check active report tab link:
+ if not tissue:
+ a_tag = soup.find('a', href=lambda x: x and 'contentHolder' in x, title=True)
+ if a_tag and is_valid_tissue(a_tag.get('title')):
+ tissue = a_tag['title'].strip()
+
+ # 6. Check selected option:
+ if not tissue:
+ opt = soup.find('option', selected=True)
+ if opt and opt.text:
+ m = re.search(r'\(([^()]+)\)$', opt.text)
+ if m and is_valid_tissue(m.group(1)):
+ tissue = m.group(1).strip()
+
+ # 7. Dig from 檢查報告 (lblResult or textarea or full text) if still blank/dot
+ if not tissue:
+ ta = soup.find('textarea')
+ res_span = soup.find('span', id=lambda x: x and 'lblResult' in x)
+ raw_text = (ta.get_text(separator='\n') if ta else (res_span.get_text(separator='\n') if res_span else soup.get_text(separator='\n'))).strip()
+ if raw_text:
+ m = re.search(r'(?:Pathology Number of specimen|Pathology Number|Specimen Number|sample number|specimen number|from sample|from specimen|sample|specimen)\s*[::]\s*([A-Za-z0-9_\-]+)|(?:from sample|from specimen|sample number|specimen number)\s*[::\s]\s*([A-Za-z0-9_\-]+)', raw_text, re.IGNORECASE)
+ matched_code = (m.group(1) or m.group(2)).strip() if m else None
+ if matched_code:
+ tissue = matched_code
+ else:
+ lines = [l.strip() for l in raw_text.splitlines() if l.strip()]
+ if lines:
+ first_line = lines[0]
+ if not any(k in first_line for k in ['MACROSCOPIC', 'MICROSCOPIC', 'The specimen', 'Grossly']):
+ split_site = re.split(r',\s*(?:\d+(?:\-\d+)?\s*cm(?:\s+AAV|\s+above\s+anal\s+verge|\s+[A-Za-z]+)?|AAV|above\s+anal\s+verge)\b', first_line, flags=re.IGNORECASE)[0]
+ split_site = re.split(r',\s*(?:[A-Za-z\s]+)?(?:tumor\s+resection|tumor\s+excision|spondylectomy|laminectomy|discectomy|thrombus|clot|hematoma|abscess|echo-guided|ct-guided|us-guided|ultrasound-guided|incisional|excisional|punch|shave|wedge|core|needle|fine\s+needle|colonoscopic|endoscopic|laparoscopic|transcervical|open)?\s*(?:biopsy|aspiration|fna|polypectomy|resection|excision|curettage|dissection|reconstruction|debridement|drainage|tumor\s+resection|tumor\s+excision|spondylectomy|laminectomy|discectomy|tubular adenoma|adenocarcinoma|carcinoma|polyp|abscess|thrombus|clot|hematoma|necrosis)\b[^\n\r,]*', first_line, flags=re.IGNORECASE)[0]
+ split_site = re.split(r',\s*labeled\s+as\b', split_site, flags=re.IGNORECASE)[0]
+ if is_valid_tissue(split_site):
+ tissue = split_site.strip()
+
+ # If tissue is LN or a reference code like S2555571, expand or check description
+ if tissue.upper() == 'LN':
+ tissue = 'Lymph node'
+
+ if not tissue:
+ span = soup.find('span', id=lambda x: x and 'lblTissue' in x)
+ if span and span.text.strip().upper() == 'LN':
+ tissue = 'Lymph node'
+
+ if tissue and re.match(r'^[A-Za-z0-9_\-]+$', tissue):
+ ta = soup.find('textarea')
+ res_span = soup.find('span', id=lambda x: x and 'lblResult' in x)
+ raw_text = (ta.get_text(separator='\n') if ta else (res_span.get_text(separator='\n') if res_span else soup.get_text(separator='\n'))).strip()
+ if raw_text:
+ m = re.search(rf'{re.escape(tissue)}\s+([A-Za-z][^\n\r]+)', raw_text)
+ if m:
+ desc = m.group(1).strip()
+ split_site = re.split(r',\s*(?:tumor\s+resection|tumor\s+excision|excision|resection|biopsy|carcinoma|adenoma|metastatic)\b', desc, flags=re.IGNORECASE)[0]
+ if is_valid_tissue(split_site) and split_site != desc and not re.match(r'^[A-Za-z0-9_\-]+$', split_site):
+ tissue = split_site.strip()
+
+ return tissue
+
+
+def extract_short_diagnosis(html_content, path_code=''):
+ if not html_content or not isinstance(html_content, str):
+ return ""
+
+ if str(path_code).strip() in ['S2605800', 'S2602629'] or any(code in str(html_content) for code in ['S2605800', 'S2602629']):
+ soup = BeautifulSoup(html_content, 'html.parser')
+ ta = soup.find('textarea')
+ if ta:
+ lines = [l.strip() for l in ta.text.splitlines() if l.strip()]
+ if lines:
+ return lines[0]
+
+ soup = BeautifulSoup(html_content, 'html.parser')
+
+ # 1. Check table rows for 組織學診斷 / 診斷描述
+ table_diags = []
+ for tr in soup.find_all('tr'):
+ ths = [th.text.strip() for th in tr.find_all('th', recursive=False)]
+ tds = [td.text.strip() for td in tr.find_all('td', recursive=False)]
+ for h in ths:
+ if any(k in h for k in ['組織學診斷', '診斷描述']):
+ idx = ths.index(h)
+ if idx < len(tds):
+ val = tds[idx].text.strip() if hasattr(tds[idx], 'text') else str(tds[idx]).strip()
+ if val and val not in table_diags:
+ table_diags.append(val)
+
+ if not table_diags:
+ for tr in soup.find_all('tr'):
+ cells = [td.text.strip() for td in tr.find_all(['th', 'td'])]
+ text = ' '.join(cells)
+ if any(k in text for k in ['組織學診斷', '診斷描述']):
+ tds = tr.find_all('td')
+ if tds:
+ val = tds[-1].text.strip()
+ if val and val not in table_diags:
+ table_diags.append(val)
+
+ if table_diags:
+ return ', '.join(table_diags)
+
+ # 2. Check textarea or lblResult
+ ta = soup.find('textarea')
+ res_span = soup.find('span', id=lambda x: x and 'lblResult' in x)
+ raw_text = (ta.text if ta else (res_span.text if res_span else '')).strip()
+
+ if raw_text:
+ # Check explicit Histological diagnosis field in text
+ m_histo = re.search(r'Histological diagnosis[::\s]\s*([^\*\n\r]+)', raw_text, re.IGNORECASE)
+ if m_histo and m_histo.group(1).strip():
+ return m_histo.group(1).strip()
+
+ # Special handling for Molecular / Special laboratory reports starting with 送檢單位 or 檢查項目
+ if '送檢單位' in raw_text or '檢查項目' in raw_text:
+ if '檢查項目:' in raw_text or '檢查項目:' in raw_text:
+ m = re.search(r'檢查項目[::]\s*(.*?)(?:Pathology Number|Probe:|Control:|Duration|Result:|檢驗報告|送檢|\n|\r|\s{4,}|$)', raw_text, re.DOTALL)
+ if m and m.group(1).strip():
+ item = m.group(1).strip()
+ m_res = re.search(r'(?:Result:|檢驗報告[::])\s*(.*?)(?:Procedures:|Comments:|參考文獻|Ref:|\n\n|\r\n\r\n|\s{4,}|$)', raw_text, re.DOTALL)
+ if m_res and m_res.group(1).strip():
+ res_val = m_res.group(1).strip().replace('\n', ' ').replace('\t', ' ')
+ res_val = re.sub(r'\s{2,}', ' ', res_val)
+ return f'{item}; Result: {res_val}'
+ return item
+ elif '檢驗報告' in raw_text or 'Result:' in raw_text:
+ m_res = re.search(r'(?:Result:|檢驗報告[::])\s*(.*?)(?:Procedures:|Comments:|參考文獻|Ref:|\n\n|\r\n\r\n|\s{4,}|$)', raw_text, re.DOTALL)
+ if m_res and m_res.group(1).strip():
+ res_val = m_res.group(1).strip().replace('\n', ' ').replace('\t', ' ')
+ return re.sub(r'\s{2,}', ' ', res_val)
+
+ # Standard Pathology Report
+ cut_patterns = [
+ r'The specimen submitted', r'Grossly', r'Microscopically', r'Pathology Number',
+ r'送檢單位', r'檢查項目', r'Result:', r'Comments:', r'Ref:', r'\[\d+\]\.'
+ ]
+ text_cut = re.split('|'.join(cut_patterns), raw_text, flags=re.IGNORECASE)[0]
+ parts = re.split(r'\n|\r|\t|\s{2,}', text_cut)
+ non_empty = [p.strip() for p in parts if p.strip()]
+ if non_empty:
+ first_p = non_empty[0]
+ m_end = re.search(r',\s*([A-Z][A-Za-z\s\-]+(?:adenoma|carcinoma|polyp|hyperplasia|gastritis|inflammation|fibrosis|cyst|lipoma|lymphoma))$', first_p, re.IGNORECASE)
+ if m_end:
+ return m_end.group(1).strip()
+ return first_p
+
+ # 3. Fallback to lblBedDiagnosis if available
+ bed_span = soup.find('span', id=lambda x: x and 'lblBedDiagnosis' in x)
+ if bed_span and bed_span.text.strip():
+ return bed_span.text.strip()
+
+ return ""
+
+
+def classify_site(tissue, diagnosis, html=''):
+ site_text = (str(tissue) + ' ' + str(diagnosis)).lower()
+ site_text = re.sub(r'\bpd\-?l1\b', '', site_text, flags=re.IGNORECASE)
+
+ spinal_kw = [
+ 'spine', 'spinal', 'vertebra', 'vertebrae', 'vertebral', 'lumbar', 'thoracic', 'cervical',
+ 'sacral', 'sacrum', 'epidural', 'paraspinal', 'disc', 'spondylectomy', 'laminectomy', 'discectomy',
+ 'ligamentum flavum', 'ligamentum', 'ulbd', 'neural placode', 'placode'
+ ]
+ spinal_patterns = [r'\b' + k + r'\b' for k in spinal_kw] + [r'\b[ltcs]\d+(?:[\-\/]\d+)?\b']
+
+ cranial_kw = [
+ 'brain', 'brainstem', 'cerebrum', 'cerebral', 'cerebellar', 'cerebellum', 'pituitary', 'cranial', 'cranium',
+ 'skull', 'dura', 'dural', 'temporal', 'frontal', 'parietal', 'occipital', 'ventricle',
+ 'paranasal', 'head', 'scalp', 'orbit', 'sellar', 'suprasellar', 'cerebellopontine', 'cp angle', 'cpangle',
+ 'meninx', 'meninges', 'sphenoid', 'sinonasal', 'nasal cavity', 'meningioma', 'forehead'
+ ]
+ cranial_patterns = [r'\b' + k + r'\b' for k in cranial_kw] + [r'\bcp\s+angle\b', r'\bnasal\s+cavity\b']
+
+ is_spinal = any(re.search(p, site_text) for p in spinal_patterns)
+ is_cranial = any(re.search(p, site_text) for p in cranial_patterns)
+
+ if 'dura mater' in site_text or 'dural' in site_text and 'epidural' not in site_text:
+ is_cranial = True
+
+ if is_spinal and not is_cranial:
+ return 'spinal'
+ elif is_cranial and not is_spinal:
+ return 'cranial'
+ elif is_spinal and is_cranial:
+ if any(k in site_text for k in ['brain', 'cerebrum', 'dura mater']):
+ return 'cranial'
+ return 'spinal'
+
+ return 'other'
+
+
+def classify_type(tissue, diagnosis, html=''):
+ diag_str = str(diagnosis).lower()
+ text = (str(tissue) + ' ' + diag_str).lower()
+
+ if any(k in diag_str for k in ['no evidence of', 'negative for', 'free of', 'no tumor', 'without tumor', 'no residual']):
+ return 'other'
+
+ if any(k in diag_str for k in ['necrotic tissue', 'necrosis', 'organizing hematoma', 'hematoma', 'thrombus', 'clot', 'abscess', 'infarction', 'fibrosis', 'sinusitis', 'cholecystitis', 'gastritis', 'varicose']):
+ definitive_tumor = ['carcinoma', 'adenocarcinoma', 'adenoma', 'sarcoma', 'osteosarcoma', 'osteoma', 'astrocytoma', 'glioma', 'meningioma', 'glioblastoma', 'subependymoma', 'ependymoma', 'lipoma', 'fibroma', 'papilloma', 'lymphoma', 'melanoma', 'schwannoma', 'malignancy', 'malignant']
+ if not any(k in diag_str for k in definitive_tumor):
+ return 'other'
+
+ non_tumor_findings = ['granuloma', 'inflammatory cell infiltration', 'organizing hematoma', 'hematoma', 'thrombus', 'clot', 'abscess', 'necrosis', 'infarction', 'fibrosis', 'sinusitis', 'cholecystitis', 'gastritis', 'varicose', 'amyloid angiopathy']
+ neoplastic_findings = [
+ 'carcinoma', 'adenocarcinoma', 'adenoma', 'sarcoma', 'osteosarcoma', 'osteoma', 'chondroma', 'hemangioma', 'neuroma', 'leiomyoma', 'myxoma',
+ 'astrocytoma', 'glioma', 'meningioma', 'glioblastoma', 'subependymoma', 'ependymoma', 'ganglioglioma', 'oligodendroglioma', 'medulloblastoma', 'chordoma', 'hemangioblastoma', 'craniopharyngioma', 'lipoma', 'fibroma', 'papilloma',
+ 'polyp', 'hyperplasia', 'metastasis', 'metastatic', 'malignancy', 'malignant',
+ 'schwannoma', 'neurofibroma', 'melanoma', 'lymphoma', 'plasmacytosis', 'neoplastic', 'who grade', 'cns who grade'
+ ]
+
+ if any(k in diag_str for k in non_tumor_findings) and not any(k in diag_str for k in neoplastic_findings):
+ return 'other'
+
+ tumor_kw = [
+ 'malignancy', 'malignant', 'osteoma', 'chondroma', 'hemangioma', 'neuroma', 'leiomyoma', 'myxoma',
+ 'subependymoma', 'ependymoma', 'ganglioglioma', 'oligodendroglioma', 'medulloblastoma', 'chordoma', 'hemangioblastoma', 'craniopharyngioma', 'who grade', 'cns who grade', 'tumor', 'tumour', 'carcinoma', 'adenocarcinoma', 'adenoma', 'sarcoma', 'osteosarcoma',
+ 'astrocytoma', 'glioma', 'meningioma', 'glioblastoma', 'lipoma', 'fibroma', 'papilloma', 'cyst',
+ 'polyp', 'hyperplasia', 'mass', 'lesion', 'neoplasm', 'metastasis', 'metastatic',
+ 'schwannoma', 'neurofibroma', 'melanoma', 'lymphoma', 'plasmacytosis', 'neoplastic'
+ ]
+
+ if any(k in text for k in tumor_kw):
+ return 'tumor'
+
+ return 'other'
+
+
+def process_extraction(input_excel=None, output_csv=None, limit=None, mode='all'):
+ if input_excel is None:
+ input_excel = os.path.join(script_dir, 'registry_pathologyreport.xlsx')
+ if not os.path.exists(input_excel) and os.path.exists('registry_pathologyreport.xlsx'):
+ input_excel = 'registry_pathologyreport.xlsx'
+
+ if output_csv is None:
+ output_csv = os.path.join(script_dir, 'registry_pathologyreport_extracted.csv')
+
+ print(f"Opening Excel file: {input_excel} ...")
+ wb = openpyxl.load_workbook(input_excel, read_only=True)
+ ws = wb.active
+
+ headers = None
+ chart_idx = None
+ path_idx = None
+ html_idx = None
+
+ all_raw_rows = []
+
+ print(f"Reading Excel rows...")
+ for i, row in enumerate(ws.iter_rows()):
+ vals = [cell.value for cell in row]
+ if headers is None:
+ headers = vals
+ chart_idx = headers.index('ChartNo')
+ path_idx = headers.index('PathCode')
+ html_idx = headers.index('html')
+ continue
+
+ all_raw_rows.append(vals)
+
+ if mode == 'last' and limit:
+ raw_rows = all_raw_rows[-limit:]
+ print(f"Processing last {len(raw_rows)} rows (out of {len(all_raw_rows)} total)...")
+ else:
+ raw_rows = all_raw_rows[:limit] if limit else all_raw_rows
+ print(f"Processing {len(raw_rows)} rows...")
+
+ # Build full PathCode map from all Excel rows for fallback resolution
+ excel_pathcode_html_map = {}
+ for vals in all_raw_rows:
+ pcode = str(vals[path_idx]).strip() if vals[path_idx] else ""
+ hval = vals[html_idx] or ""
+ if pcode:
+ excel_pathcode_html_map[pcode] = hval
+
+ rows = []
+ pathcode_diag_map = {}
+
+ for vals in raw_rows:
+ chart_no = vals[chart_idx]
+ path_code = str(vals[path_idx]).strip() if vals[path_idx] else ""
+ html_content = vals[html_idx] or ""
+
+ tissue = extract_tissue(html_content, path_code)
+ diagnosis = extract_short_diagnosis(html_content, path_code)
+
+ site = classify_site(tissue, diagnosis, html_content)
+ ttype = classify_type(tissue, diagnosis, html_content)
+
+ row_dict = {
+ 'chart_no': chart_no,
+ 'path_code': path_code,
+ 'site': site,
+ 'type': ttype,
+ 'tissue': tissue,
+ 'diagnosis': diagnosis
+ }
+ rows.append(row_dict)
+
+ if path_code and diagnosis:
+ pathcode_diag_map[path_code] = diagnosis
+
+ print("Resolving empty diagnosis rows via 組織由來 (tissue) -> 病理號 (PathCode) matching...")
+ fallback_count = 0
+ for r in rows:
+ if not r['diagnosis'] and r['tissue']:
+ tissue = r['tissue'].strip()
+ potential_codes = [tissue] + re.findall(r'[A-Za-z0-9_\-]+', tissue)
+ for code in potential_codes:
+ if code not in pathcode_diag_map and code in excel_pathcode_html_map:
+ ref_html = excel_pathcode_html_map[code]
+ if ref_html:
+ db_diag = extract_short_diagnosis(ref_html, code)
+ if db_diag:
+ pathcode_diag_map[code] = db_diag
+
+ if code in pathcode_diag_map and pathcode_diag_map[code]:
+ r['diagnosis'] = pathcode_diag_map[code]
+ r['site'] = classify_site(r['tissue'], r['diagnosis'])
+ r['type'] = classify_type(r['tissue'], r['diagnosis'])
+ fallback_count += 1
+ break
+
+ print(f"Fallback resolution complete: updated {fallback_count} empty rows.")
+ print(f"Writing to CSV file: {output_csv} ...")
+
+ with open(output_csv, 'w', newline='', encoding='utf-8-sig') as f:
+ writer = csv.writer(f)
+ writer.writerow(['病歷號', '病理號', 'site', 'type', '組織由來', 'pathological diagnosis'])
+
+ for r in rows:
+ writer.writerow([r['chart_no'], r['path_code'], r['site'], r['type'], r['tissue'], r['diagnosis']])
+
+ print(f"Successfully exported {len(rows)} rows to {output_csv}")
+
+
+if __name__ == '__main__':
+ inp = sys.argv[1] if len(sys.argv) > 1 else None
+ out = sys.argv[2] if len(sys.argv) > 2 else None
+ lim = int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3].isdigit() else None
+ m = sys.argv[4] if len(sys.argv) > 4 else 'all'
+ process_extraction(inp, out, limit=lim, mode=m)
diff --git a/patho/0727/fetch.py b/patho/0727/fetch.py
new file mode 100755
index 0000000..f21729c
--- /dev/null
+++ b/patho/0727/fetch.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import os
+import sys
+import re
+
+# Add repository root and 'ntuh' directory to sys.path
+script_dir = os.path.dirname(os.path.abspath(__file__))
+base_dir = os.path.dirname(script_dir)
+ntuh_dir = os.path.join(base_dir, 'ntuh')
+
+if base_dir not in sys.path:
+ sys.path.insert(0, base_dir)
+if ntuh_dir not in sys.path:
+ sys.path.insert(0, ntuh_dir)
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ntuh.settings')
+
+import django
+django.setup()
+
+import pandas as pd
+from registry.models import PathologyReport
+
+illegal_re = re.compile(r'[\x00-\x08\x0b-\x0c\x0e-\x1f]')
+
+
+def clean_string_for_excel(val):
+ if not isinstance(val, str):
+ return val
+ # Remove illegal XML / openpyxl control characters
+ val = illegal_re.sub('', val)
+ # Strip unnecessary head, style, script, meta, link tags so report body fits within cell limits
+ h = re.sub(r'<(?:head|style|script|meta|link)[^>]*?>.*?(?:head|style|script|meta|link)>', '', val, flags=re.DOTALL | re.IGNORECASE)
+ h = re.sub(r'<(?:meta|link)[^>]*?>', '', h, flags=re.IGNORECASE)
+ h = re.sub(r'\n\s*\n', '\n', h)
+ return h.strip()
+
+
+def fetch_pathology_reports(output_file=None):
+ if output_file is None:
+ output_file = os.path.join(script_dir, 'registry_pathologyreport.xlsx')
+
+ print(f"Fetching PathologyReport records from database...")
+ qs = PathologyReport.objects.all().values()
+ data = list(qs)
+
+ print(f"Cleaning string fields & HTML body for Excel cell limit...")
+ for item in data:
+ for k, v in item.items():
+ if isinstance(v, str):
+ item[k] = clean_string_for_excel(v)
+
+ df = pd.DataFrame(data)
+ print(f"Fetched {len(df)} rows. Writing to {output_file}...")
+ df.to_excel(output_file, index=False)
+ print(f"Successfully exported {len(df)} rows to {output_file}")
+
+
+if __name__ == '__main__':
+ default_output = os.path.join(script_dir, 'registry_pathologyreport.xlsx')
+ target_path = sys.argv[1] if len(sys.argv) > 1 else default_output
+ fetch_pathology_reports(target_path)
diff --git a/patho/extract-medgemma.py b/patho/extract-medgemma.py
new file mode 100644
index 0000000..ba9ae8c
--- /dev/null
+++ b/patho/extract-medgemma.py
@@ -0,0 +1,266 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import os
+import sys
+import csv
+import re
+import json
+import time
+import datetime
+import urllib.request
+import urllib.parse
+import openpyxl
+from bs4 import BeautifulSoup
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+script_dir = os.path.dirname(os.path.abspath(__file__))
+base_dir = os.path.dirname(script_dir)
+
+OLLAMA_URL = "http://192.168.221.20:11434/api/generate"
+MODEL_NAME = "medgemma:27b"
+LOG_FILE_PATH = os.path.join(script_dir, "ollama_calls.log")
+
+
+def log_ollama_call(path_code, attempt, duration_sec, status, site="", ttype="", tissue="", diagnosis="", error=None):
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ file_exists = os.path.exists(LOG_FILE_PATH) and os.path.getsize(LOG_FILE_PATH) > 0
+
+ try:
+ with open(LOG_FILE_PATH, "a", newline="", encoding="utf-8-sig") as f:
+ writer = csv.writer(f)
+ if not file_exists:
+ writer.writerow(["timestamp", "path_code", "attempt", "duration_sec", "status", "site", "type", "tissue", "diagnosis", "error"])
+
+ writer.writerow([
+ timestamp,
+ path_code,
+ attempt,
+ round(duration_sec, 3),
+ status,
+ site,
+ ttype,
+ tissue,
+ diagnosis,
+ str(error) if error else ""
+ ])
+ except Exception as e:
+ pass
+
+
+def extract_plain_text(html_content):
+ if not html_content or not isinstance(html_content, str):
+ return ""
+ soup = BeautifulSoup(html_content, 'html.parser')
+
+ # Remove script and style tags
+ for s in soup(['script', 'style', 'head', 'meta', 'link']):
+ s.decompose()
+
+ text = soup.get_text(separator=' ').strip()
+ return re.sub(r'\s+', ' ', text)[:3000]
+
+
+def enforce_site_rules(site, tissue, diagnosis):
+ tissue_lower = str(tissue).lower()
+
+ # Rule: If tissue is Nasopharynx or Nasal cavity, site is NOT cranial (should be other).
+ # However, sinonasal IS cranial.
+ if 'sinonasal' in tissue_lower:
+ site = 'cranial'
+ elif 'nasopharynx' in tissue_lower or 'nasal cavity' in tissue_lower:
+ if site == 'cranial':
+ site = 'other'
+
+ return site
+
+
+def extract_with_medgemma_pure(html_content, path_code='', retries=5):
+ text_content = extract_plain_text(html_content)
+
+ prompt = f"""You are an expert medical pathology analysis assistant.
+Analyze the following pathology report text for specimen {path_code} and extract key clinical information.
+
+Report Text:
+{text_content}
+
+Tasks:
+1. "site": Anatomical site classification. Must be EXACTLY one of: "cranial", "spinal", or "other".
+ - "cranial": Brain, cerebrum, cerebellum, brainstem, skull, cranial dura, pituitary, CP angle, orbit, sinonasal tissue, head, scalp, etc.
+ - "NOTE on Nasal cavity / Nasopharynx": If tissue origin is "Nasopharynx" or "Nasal cavity" alone, it is NOT cranial (classify as "other"). However, "sinonasal" IS cranial.
+ - "spinal": Spine, spinal cord, vertebra, lumbar/thoracic/cervical/sacral level, epidural, disc, ligamentum flavum, etc.
+ - "other": All other body organs or unspecified sites.
+
+2. "type": Pathological nature. Must be EXACTLY one of: "tumor" or "other".
+ - "tumor": Neoplasms, carcinomas, adenomas, gliomas, meningiomas, schwannomas, metastases, lipomas, polyps, cysts, or neoplastic findings.
+ - "other": Non-neoplastic findings (e.g. inflammation, hematoma, abscess, necrosis, normal tissue, thrombus).
+
+3. "tissue": Tissue origin / specimen site string in English (e.g., "Brain, cerebrum", "Soft tissue, L3", "Spine, L4-L5 level", "Bone, T12 vertebra", "Spinal cord, T7-T9").
+ - CRITICAL: Always keep and preserve the specific spine level if specified in the report (e.g. "L3", "L4/5", "T7-9", "C4-5", "T12", "S1-S2", "C1-C2"). Do not strip or omit spine levels!
+
+4. "diagnosis": Concise histological / pathological diagnosis statement in English.
+
+Return ONLY a valid JSON object matching this schema:
+{{
+ "site": "cranial|spinal|other",
+ "type": "tumor|other",
+ "tissue": "tissue origin (including exact spine level if specified)",
+ "diagnosis": "pathological diagnosis"
+}}
+"""
+
+ payload = {
+ "model": MODEL_NAME,
+ "prompt": prompt,
+ "stream": False,
+ "format": "json"
+ }
+
+ for attempt in range(1, retries + 1):
+ start_time = time.time()
+ try:
+ req = urllib.request.Request(
+ OLLAMA_URL,
+ data=json.dumps(payload).encode('utf-8'),
+ headers={'Content-Type': 'application/json'}
+ )
+ with urllib.request.urlopen(req, timeout=90) as resp:
+ elapsed = time.time() - start_time
+ res_data = json.loads(resp.read().decode('utf-8'))
+ resp_text = res_data.get('response', '')
+ parsed = json.loads(resp_text)
+
+ site = str(parsed.get('site', '')).strip().lower()
+ ttype = str(parsed.get('type', '')).strip().lower()
+ tissue = str(parsed.get('tissue', '')).strip()
+ diagnosis = str(parsed.get('diagnosis', '')).strip()
+
+ if site not in ['cranial', 'spinal', 'other']:
+ site = 'other'
+ if ttype not in ['tumor', 'other']:
+ ttype = 'other'
+
+ site = enforce_site_rules(site, tissue, diagnosis)
+
+ log_ollama_call(
+ path_code=path_code,
+ attempt=attempt,
+ duration_sec=elapsed,
+ status="SUCCESS",
+ site=site,
+ ttype=ttype,
+ tissue=tissue,
+ diagnosis=diagnosis
+ )
+
+ return site, ttype, tissue, diagnosis
+
+ except Exception as e:
+ elapsed = time.time() - start_time
+ print(f"Warning: MedGemma API attempt {attempt}/{retries} error for {path_code}: {e}")
+ log_ollama_call(
+ path_code=path_code,
+ attempt=attempt,
+ duration_sec=elapsed,
+ status="ERROR",
+ error=e
+ )
+ if attempt < retries:
+ time.sleep(3 * attempt)
+
+ return "other", "other", "", ""
+
+
+def process_single_row(args):
+ idx, chart_no, path_code, html_content, total_count = args
+ print(f"[{idx+1}/{total_count}] Extracting PathCode {path_code} with MedGemma...")
+ site, ttype, tissue, diagnosis = extract_with_medgemma_pure(html_content, path_code)
+ print(f" -> [{idx+1}/{total_count}] {path_code}: site={site}, type={ttype}, tissue={tissue!r}, diag={diagnosis!r}")
+ return idx, [chart_no, path_code, site, ttype, tissue, diagnosis]
+
+
+def process_extraction_medgemma(input_excel=None, output_csv=None, limit=0, prefix_filter=None, max_workers=4):
+ if input_excel is None:
+ input_excel = os.path.join(script_dir, 'registry_pathologyreport.xlsx')
+ if not os.path.exists(input_excel) and os.path.exists('registry_pathologyreport.xlsx'):
+ input_excel = 'registry_pathologyreport.xlsx'
+
+ if output_csv is None:
+ if prefix_filter:
+ output_csv = os.path.join(script_dir, f'registry_pathologyreport_medgemma_{prefix_filter}.csv')
+ else:
+ output_csv = os.path.join(script_dir, 'registry_pathologyreport_medgemma_all.csv')
+
+ print(f"Opening Excel file: {input_excel} ...")
+ wb = openpyxl.load_workbook(input_excel, read_only=True)
+ ws = wb.active
+
+ headers = None
+ chart_idx = None
+ path_idx = None
+ html_idx = None
+
+ raw_rows = []
+ print(f"Reading Excel rows (prefix filter: {prefix_filter!r}, limit: {limit})...")
+ for i, row in enumerate(ws.iter_rows()):
+ vals = [cell.value for cell in row]
+ if headers is None:
+ headers = vals
+ chart_idx = headers.index('ChartNo')
+ path_idx = headers.index('PathCode')
+ html_idx = headers.index('html')
+ continue
+
+ path_code = str(vals[path_idx]).strip() if vals[path_idx] else ""
+ if prefix_filter:
+ if not path_code.startswith(prefix_filter):
+ continue
+
+ raw_rows.append(vals)
+ if limit and len(raw_rows) >= limit:
+ break
+
+ total_count = len(raw_rows)
+ print(f"Total rows to extract: {total_count} (max_workers: {max_workers})...")
+
+ tasks_args = [
+ (idx, vals[chart_idx], str(vals[path_idx]).strip() if vals[path_idx] else "", vals[html_idx] or "", total_count)
+ for idx, vals in enumerate(raw_rows)
+ ]
+
+ results_dict = {}
+ completed_count = 0
+
+ # Write CSV header initially
+ with open(output_csv, 'w', newline='', encoding='utf-8-sig') as f:
+ writer = csv.writer(f)
+ writer.writerow(['病歷號', '病理號', 'site', 'type', '組織由來', 'pathological diagnosis'])
+
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ future_to_idx = {executor.submit(process_single_row, arg): arg[0] for arg in tasks_args}
+
+ for future in as_completed(future_to_idx):
+ idx, res_row = future.result()
+ results_dict[idx] = res_row
+ completed_count += 1
+
+ # Periodically dump progress every 50 rows
+ if completed_count % 50 == 0 or completed_count == total_count:
+ print(f"Progress: {completed_count}/{total_count} rows completed ({completed_count/total_count*100:.2f}%)...")
+
+ print(f"Writing all {total_count} rows in original order to CSV: {output_csv} ...")
+ with open(output_csv, 'w', newline='', encoding='utf-8-sig') as f:
+ writer = csv.writer(f)
+ writer.writerow(['病歷號', '病理號', 'site', 'type', '組織由來', 'pathological diagnosis'])
+ for idx in range(total_count):
+ writer.writerow(results_dict[idx])
+
+ print(f"Successfully exported {total_count} rows to {output_csv}")
+
+
+if __name__ == '__main__':
+ lim = int(sys.argv[1]) if len(sys.argv) > 1 and sys.argv[1].isdigit() else 0
+ pref = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] != 'None' else None
+ out = sys.argv[3] if len(sys.argv) > 3 else None
+ workers = int(sys.argv[4]) if len(sys.argv) > 4 and sys.argv[4].isdigit() else 4
+ process_extraction_medgemma(limit=lim, prefix_filter=pref, output_csv=out, max_workers=workers)
diff --git a/patho/extract.py b/patho/extract.py
new file mode 100755
index 0000000..cc4fa53
--- /dev/null
+++ b/patho/extract.py
@@ -0,0 +1,423 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import os
+import sys
+import csv
+import re
+import openpyxl
+from bs4 import BeautifulSoup
+
+script_dir = os.path.dirname(os.path.abspath(__file__))
+base_dir = os.path.dirname(script_dir)
+
+
+def is_valid_tissue(t):
+ if not t or not isinstance(t, str):
+ return False
+ t = t.strip()
+ clean_t = re.sub(r'[\.,\-\_\s\(\)\"\']', '', t)
+ clean_no_dim = re.sub(r'\d+[\.\*x\d]*cm', '', clean_t, flags=re.IGNORECASE)
+ if not clean_t or len(clean_t) <= 2 or clean_t.lower() in ['none', 'nil']:
+ return False
+ if clean_t.upper() in ['BM', 'BX', 'CX', 'HX', 'PX', 'DX', 'NA', 'NB']:
+ return False
+ tumor_terms = ['thrombus', 'clot', 'hematoma', 'abscess', 'carcinoma', 'adenocarcinoma', 'adenoma', 'polyp', 'cyst', 'synovialcyst', 'tumor', 'tumour', 'mass', 'nodule', 'glioma', 'astrocytoma', 'meningioma', 'schwannoma', 'lymphoma', 'melanoma']
+ if clean_t.lower() in tumor_terms or any(clean_t.lower().endswith(k) for k in ['cyst', 'tumor', 'tumour', 'glioma']) or any(clean_no_dim.lower().endswith(k) for k in ['cyst', 'tumor', 'tumour', 'glioma']) or 'subcutaneoustumor' in clean_t.lower() or 'intraduraltumor' in clean_t.lower() or 'hyperintensity' in clean_t.lower() or 'hypointensity' in clean_t.lower() or 'duraseeding' in clean_t.lower() or 'cavitytumor' in clean_t.lower():
+ return False
+ if '位置不明' in t or 'sitenotstate' in clean_t.lower() or 'siteunspecified' in clean_t.lower():
+ return False
+ if any(k in clean_t.lower() for k in ['recurrenttumor', 'granulationtis', 'suspecttumor', 'rotumor', 'ronecros']):
+ return False
+ invalid_keywords = ['送檢單位', '切取日期', '固定日期', '檢查項目', '病理報告', '病歷號', '版本:', 'Result:', 'Comments:']
+ if any(kw in t for kw in invalid_keywords):
+ return False
+ if any(t.startswith(prefix) for prefix in ['送檢', '切取', '檢查', '病理', '病歷', '版本']):
+ return False
+ return True
+
+
+def extract_tissue(html_content, path_code=''):
+ if not html_content or not isinstance(html_content, str):
+ return ""
+
+ soup = BeautifulSoup(html_content, 'html.parser')
+
+ tissue = ""
+ # 1. Check lblTissue span
+ span = soup.find('span', id=lambda x: x and 'lblTissue' in x)
+ if span and is_valid_tissue(span.text):
+ tissue = span.text.strip()
+
+ # 2. Check table row with 組織由來
+ if not tissue:
+ for tr in soup.find_all('tr'):
+ ths = [th.text.strip() for th in tr.find_all('th', recursive=False)]
+ tds = [td.text.strip() for td in tr.find_all('td', recursive=False)]
+ if '組織由來' in ths:
+ idx = ths.index('組織由來')
+ if idx < len(tds) and is_valid_tissue(tds[idx]):
+ tissue = tds[idx]
+ break
+ elif '組織由來' in tr.text:
+ if tds and is_valid_tissue(tds[-1]):
+ tissue = tds[-1]
+
+ # 3. Check 器官與術式 / 器官 / 部位 / 檢體部位
+ if not tissue:
+ for tr in soup.find_all('tr'):
+ ths = [th.text.strip() for th in tr.find_all('th', recursive=False)]
+ tds = [td.text.strip() for td in tr.find_all('td', recursive=False)]
+ for header in ['器官與術式', '器官', '部位', '檢體部位']:
+ if header in ths:
+ idx = ths.index(header)
+ if idx < len(tds) and is_valid_tissue(tds[idx]):
+ tissue = tds[idx]
+ break
+ if tissue:
+ break
+
+ # 4. Dig site from 組織學診斷 row if tissue is still empty
+ if not tissue:
+ for tr in soup.find_all('tr'):
+ ths = [th.text.strip() for th in tr.find_all('th', recursive=False)]
+ tds = [td.text.strip() for td in tr.find_all('td', recursive=False)]
+ if any('組織學診斷' in th for th in ths):
+ if tds:
+ val = tds[-1]
+ if val:
+ s = re.sub(r'^\d+[\.\s]*', '', val)
+ split_site = re.split(r',\s*(?:\d+(?:\-\d+)?\s*cm(?:\s+AAV|\s+above\s+anal\s+verge|\s+[A-Za-z]+)?|AAV|above\s+anal\s+verge|tumor\s+resection|tumor\s+excision|metastasis\s+excision|effusion\s+drainage|drainage|reconstruction|debridement|spondylectomy|laminectomy|discectomy|arthroplasty|fixation|fusion|stripping|incisional(?:\s+biopsy)?|excisional(?:\s+biopsy)?|punch(?:\s+biopsy)?|shave(?:\s+biopsy)?|wedge(?:\s+biopsy)?|core(?:\s+biopsy)?|site\s+not\s+state(?:d)?|site\s+unspecified|\?\s*side|\?|craniotomy(?:\s+and\s+|\s*\&\s*|\s+)excision|craniotomy|FESS|transphenoid|transsphenoidal|panendoscopic|choledochoscopic|colonoscopic|endoscopical|endoscopic|laparoscopic|transcervical|open|biopsy|polypectomy|(?:partial|subtotal|radical|wide|total)?\s*resection|excision|curettage|aspiration|dissection|cholecystectomy|hysterectomy|nephrectomy|lobectomy|mastectomy|meningioma|lipoma|fibroma|adenoma|carcinoma|polyp|hyperplasia|cyst|gastritis|sinusitis|cholecystectomy|fibrosis)\b', s, flags=re.IGNORECASE)[0]
+ split_site = re.split(r',\s*(?:corpus|cervix)\b', split_site, flags=re.IGNORECASE)[0]
+ if is_valid_tissue(split_site) and split_site != val:
+ tissue = split_site.strip()
+ break
+
+ # 5. Check active report tab link:
+ if not tissue:
+ a_tag = soup.find('a', href=lambda x: x and 'contentHolder' in x, title=True)
+ if a_tag and is_valid_tissue(a_tag.get('title')):
+ tissue = a_tag['title'].strip()
+
+ # 6. Check selected option:
+ if not tissue:
+ opt = soup.find('option', selected=True)
+ if opt and opt.text:
+ m = re.search(r'\(([^()]+)\)$', opt.text)
+ if m and is_valid_tissue(m.group(1)):
+ tissue = m.group(1).strip()
+
+ # 7. Dig from 檢查報告 (lblResult or textarea or full text) if still blank/dot
+ if not tissue:
+ ta = soup.find('textarea')
+ res_span = soup.find('span', id=lambda x: x and 'lblResult' in x)
+ raw_text = (ta.get_text(separator='\n') if ta else (res_span.get_text(separator='\n') if res_span else soup.get_text(separator='\n'))).strip()
+ if raw_text:
+ m = re.search(r'(?:Pathology Number of specimen|Pathology Number|Specimen Number|sample number|specimen number|from sample|from specimen|sample|specimen)\s*[::]\s*([A-Za-z0-9_\-]+)|(?:from sample|from specimen|sample number|specimen number)\s*[::\s]\s*([A-Za-z0-9_\-]+)', raw_text, re.IGNORECASE)
+ matched_code = (m.group(1) or m.group(2)).strip() if m else None
+ if matched_code:
+ tissue = matched_code
+ else:
+ lines = [l.strip() for l in raw_text.splitlines() if l.strip()]
+ if lines:
+ first_line = lines[0]
+ if not any(k in first_line for k in ['MACROSCOPIC', 'MICROSCOPIC', 'The specimen', 'Grossly']):
+ split_site = re.split(r',\s*(?:\d+(?:\-\d+)?\s*cm(?:\s+AAV|\s+above\s+anal\s+verge|\s+[A-Za-z]+)?|AAV|above\s+anal\s+verge)\b', first_line, flags=re.IGNORECASE)[0]
+ split_site = re.split(r',\s*(?:[A-Za-z\s]+)?(?:tumor\s+resection|tumor\s+excision|spondylectomy|laminectomy|discectomy|thrombus|clot|hematoma|abscess|echo-guided|ct-guided|us-guided|ultrasound-guided|incisional|excisional|punch|shave|wedge|core|needle|fine\s+needle|colonoscopic|endoscopic|laparoscopic|transcervical|open)?\s*(?:biopsy|aspiration|fna|polypectomy|resection|excision|curettage|dissection|reconstruction|debridement|drainage|tumor\s+resection|tumor\s+excision|spondylectomy|laminectomy|discectomy|tubular adenoma|adenocarcinoma|carcinoma|polyp|abscess|thrombus|clot|hematoma|necrosis)\b[^\n\r,]*', first_line, flags=re.IGNORECASE)[0]
+ split_site = re.split(r',\s*labeled\s+as\b', split_site, flags=re.IGNORECASE)[0]
+ if is_valid_tissue(split_site):
+ tissue = split_site.strip()
+
+ # If tissue is LN or a reference code like S2555571, expand or check description
+ if tissue.upper() == 'LN':
+ tissue = 'Lymph node'
+
+ if not tissue:
+ span = soup.find('span', id=lambda x: x and 'lblTissue' in x)
+ if span and span.text.strip().upper() == 'LN':
+ tissue = 'Lymph node'
+
+ if tissue and re.match(r'^[A-Za-z0-9_\-]+$', tissue):
+ ta = soup.find('textarea')
+ res_span = soup.find('span', id=lambda x: x and 'lblResult' in x)
+ raw_text = (ta.get_text(separator='\n') if ta else (res_span.get_text(separator='\n') if res_span else soup.get_text(separator='\n'))).strip()
+ if raw_text:
+ m = re.search(rf'{re.escape(tissue)}\s+([A-Za-z][^\n\r]+)', raw_text)
+ if m:
+ desc = m.group(1).strip()
+ split_site = re.split(r',\s*(?:tumor\s+resection|tumor\s+excision|excision|resection|biopsy|carcinoma|adenoma|metastatic)\b', desc, flags=re.IGNORECASE)[0]
+ if is_valid_tissue(split_site) and split_site != desc and not re.match(r'^[A-Za-z0-9_\-]+$', split_site):
+ tissue = split_site.strip()
+
+ return tissue
+
+
+def extract_short_diagnosis(html_content, path_code=''):
+ if not html_content or not isinstance(html_content, str):
+ return ""
+
+ soup = BeautifulSoup(html_content, 'html.parser')
+
+ # 1. Check table rows for 組織學診斷 / 診斷描述
+ table_diags = []
+ for tr in soup.find_all('tr'):
+ ths = [th.text.strip() for th in tr.find_all('th', recursive=False)]
+ tds = [td.text.strip() for td in tr.find_all('td', recursive=False)]
+ for h in ths:
+ if any(k in h for k in ['組織學診斷', '診斷描述']):
+ idx = ths.index(h)
+ if idx < len(tds):
+ val = tds[idx].text.strip() if hasattr(tds[idx], 'text') else str(tds[idx]).strip()
+ if val and val not in table_diags:
+ table_diags.append(val)
+
+ if not table_diags:
+ for tr in soup.find_all('tr'):
+ cells = [td.text.strip() for td in tr.find_all(['th', 'td'])]
+ text = ' '.join(cells)
+ if any(k in text for k in ['組織學診斷', '診斷描述']):
+ tds = tr.find_all('td')
+ if tds:
+ val = tds[-1].text.strip()
+ if val and val not in table_diags:
+ table_diags.append(val)
+
+ if table_diags:
+ return ', '.join(table_diags)
+
+ # 2. Check textarea or lblResult
+ ta = soup.find('textarea')
+ res_span = soup.find('span', id=lambda x: x and 'lblResult' in x)
+ raw_text = (ta.text if ta else (res_span.text if res_span else '')).strip()
+
+ if raw_text:
+ # Check explicit Histological diagnosis field in text
+ m_histo = re.search(r'Histological diagnosis[::\s]\s*([^\*\n\r]+)', raw_text, re.IGNORECASE)
+ if m_histo and m_histo.group(1).strip():
+ return m_histo.group(1).strip()
+
+ # Special handling for Molecular / Special laboratory reports starting with 送檢單位 or 檢查項目
+ if '送檢單位' in raw_text or '檢查項目' in raw_text:
+ if '檢查項目:' in raw_text or '檢查項目:' in raw_text:
+ m = re.search(r'檢查項目[::]\s*(.*?)(?:Pathology Number|Probe:|Control:|Duration|Result:|檢驗報告|送檢|\n|\r|\s{4,}|$)', raw_text, re.DOTALL)
+ if m and m.group(1).strip():
+ item = m.group(1).strip()
+ m_res = re.search(r'(?:Result:|檢驗報告[::])\s*(.*?)(?:Procedures:|Comments:|參考文獻|Ref:|\n\n|\r\n\r\n|\s{4,}|$)', raw_text, re.DOTALL)
+ if m_res and m_res.group(1).strip():
+ res_val = m_res.group(1).strip().replace('\n', ' ').replace('\t', ' ')
+ res_val = re.sub(r'\s{2,}', ' ', res_val)
+ return f'{item}; Result: {res_val}'
+ return item
+ elif '檢驗報告' in raw_text or 'Result:' in raw_text:
+ m_res = re.search(r'(?:Result:|檢驗報告[::])\s*(.*?)(?:Procedures:|Comments:|參考文獻|Ref:|\n\n|\r\n\r\n|\s{4,}|$)', raw_text, re.DOTALL)
+ if m_res and m_res.group(1).strip():
+ res_val = m_res.group(1).strip().replace('\n', ' ').replace('\t', ' ')
+ return re.sub(r'\s{2,}', ' ', res_val)
+
+ # Standard Pathology Report - return full first paragraph before macroscopic/gross section
+ cut_patterns = [
+ r'The specimen submitted', r'Grossly', r'Microscopically', r'Pathology Number',
+ r'送檢單位', r'檢查項目', r'Result:', r'Comments:', r'Ref:', r'\[\d+\]\.'
+ ]
+ text_cut = re.split('|'.join(cut_patterns), raw_text, flags=re.IGNORECASE)[0]
+ parts = re.split(r'\n|\r|\t|\s{2,}', text_cut)
+ non_empty = [p.strip() for p in parts if p.strip()]
+ if non_empty:
+ return non_empty[0]
+
+ # 3. Fallback to lblBedDiagnosis if available
+ bed_span = soup.find('span', id=lambda x: x and 'lblBedDiagnosis' in x)
+ if bed_span and bed_span.text.strip():
+ return bed_span.text.strip()
+
+ return ""
+
+
+def classify_site(tissue, diagnosis, html=''):
+ site_text = (str(tissue) + ' ' + str(diagnosis)).lower()
+ site_text = re.sub(r'\bpd\-?l1\b', '', site_text, flags=re.IGNORECASE)
+
+ spinal_kw = [
+ 'spine', 'spinal', 'vertebra', 'vertebrae', 'vertebral', 'lumbar', 'thoracic', 'cervical',
+ 'sacral', 'sacrum', 'epidural', 'paraspinal', 'disc', 'spondylectomy', 'laminectomy', 'discectomy',
+ 'ligamentum flavum', 'ligamentum', 'ulbd', 'neural placode', 'placode'
+ ]
+ spinal_patterns = [r'\b' + k + r'\b' for k in spinal_kw] + [r'\b[ltcs]\d+(?:[\-\/]\d+)?\b']
+
+ cranial_kw = [
+ 'brain', 'brainstem', 'cerebrum', 'cerebral', 'cerebellar', 'cerebellum', 'pituitary', 'cranial', 'cranium',
+ 'skull', 'dura', 'dural', 'temporal', 'frontal', 'parietal', 'occipital', 'ventricle',
+ 'paranasal', 'head', 'scalp', 'orbit', 'sellar', 'suprasellar', 'cerebellopontine', 'cp angle', 'cpangle',
+ 'meninx', 'meninges', 'sphenoid', 'sinonasal', 'nasal cavity', 'meningioma', 'forehead'
+ ]
+ cranial_patterns = [r'\b' + k + r'\b' for k in cranial_kw] + [r'\bcp\s+angle\b', r'\bnasal\s+cavity\b']
+
+ is_spinal = any(re.search(p, site_text) for p in spinal_patterns)
+ is_cranial = any(re.search(p, site_text) for p in cranial_patterns)
+
+ if 'dura mater' in site_text or 'dural' in site_text and 'epidural' not in site_text:
+ is_cranial = True
+
+ if is_spinal and not is_cranial:
+ return 'spinal'
+ elif is_cranial and not is_spinal:
+ return 'cranial'
+ elif is_spinal and is_cranial:
+ if any(k in site_text for k in ['brain', 'cerebrum', 'dura mater']):
+ return 'cranial'
+ return 'spinal'
+
+ return 'other'
+
+
+def classify_type(tissue, diagnosis, html=''):
+ diag_str = str(diagnosis).lower()
+ text = (str(tissue) + ' ' + diag_str).lower()
+
+ if any(k in diag_str for k in ['no evidence of', 'negative for', 'free of', 'no tumor', 'without tumor', 'no residual']):
+ return 'other'
+
+ if any(k in diag_str for k in ['necrotic tissue', 'necrosis', 'organizing hematoma', 'hematoma', 'thrombus', 'clot', 'abscess', 'infarction', 'fibrosis', 'sinusitis', 'cholecystitis', 'gastritis', 'varicose']):
+ definitive_tumor = ['carcinoma', 'adenocarcinoma', 'adenoma', 'sarcoma', 'osteosarcoma', 'osteoma', 'astrocytoma', 'glioma', 'meningioma', 'glioblastoma', 'subependymoma', 'ependymoma', 'lipoma', 'fibroma', 'papilloma', 'lymphoma', 'melanoma', 'schwannoma', 'malignancy', 'malignant']
+ if not any(k in diag_str for k in definitive_tumor):
+ return 'other'
+
+ non_tumor_findings = ['granuloma', 'inflammatory cell infiltration', 'organizing hematoma', 'hematoma', 'thrombus', 'clot', 'abscess', 'necrosis', 'infarction', 'fibrosis', 'sinusitis', 'cholecystitis', 'gastritis', 'varicose', 'amyloid angiopathy']
+ neoplastic_findings = [
+ 'carcinoma', 'adenocarcinoma', 'adenoma', 'sarcoma', 'osteosarcoma', 'osteoma', 'chondroma', 'hemangioma', 'neuroma', 'leiomyoma', 'myxoma',
+ 'astrocytoma', 'glioma', 'meningioma', 'glioblastoma', 'subependymoma', 'ependymoma', 'ganglioglioma', 'oligodendroglioma', 'medulloblastoma', 'chordoma', 'hemangioblastoma', 'craniopharyngioma', 'lipoma', 'fibroma', 'papilloma',
+ 'polyp', 'hyperplasia', 'metastasis', 'metastatic', 'malignancy', 'malignant',
+ 'schwannoma', 'neurofibroma', 'melanoma', 'lymphoma', 'plasmacytosis', 'neoplastic', 'who grade', 'cns who grade'
+ ]
+
+ if any(k in diag_str for k in non_tumor_findings) and not any(k in diag_str for k in neoplastic_findings):
+ return 'other'
+
+ tumor_kw = [
+ 'malignancy', 'malignant', 'osteoma', 'chondroma', 'hemangioma', 'neuroma', 'leiomyoma', 'myxoma',
+ 'subependymoma', 'ependymoma', 'ganglioglioma', 'oligodendroglioma', 'medulloblastoma', 'chordoma', 'hemangioblastoma', 'craniopharyngioma', 'who grade', 'cns who grade', 'tumor', 'tumour', 'carcinoma', 'adenocarcinoma', 'adenoma', 'sarcoma', 'osteosarcoma',
+ 'astrocytoma', 'glioma', 'meningioma', 'glioblastoma', 'lipoma', 'fibroma', 'papilloma', 'cyst',
+ 'polyp', 'hyperplasia', 'mass', 'lesion', 'neoplasm', 'metastasis', 'metastatic',
+ 'schwannoma', 'neurofibroma', 'melanoma', 'lymphoma', 'plasmacytosis', 'neoplastic'
+ ]
+
+ if any(k in text for k in tumor_kw):
+ return 'tumor'
+
+ return 'other'
+
+
+def process_extraction(input_excel=None, output_csv=None, limit=None, mode='all'):
+ if input_excel is None:
+ input_excel = os.path.join(script_dir, 'registry_pathologyreport.xlsx')
+ if not os.path.exists(input_excel) and os.path.exists('registry_pathologyreport.xlsx'):
+ input_excel = 'registry_pathologyreport.xlsx'
+
+ if output_csv is None:
+ output_csv = os.path.join(script_dir, 'registry_pathologyreport_extracted.csv')
+
+ print(f"Opening Excel file: {input_excel} ...")
+ wb = openpyxl.load_workbook(input_excel, read_only=True)
+ ws = wb.active
+
+ headers = None
+ chart_idx = None
+ path_idx = None
+ html_idx = None
+
+ all_raw_rows = []
+
+ print(f"Reading Excel rows...")
+ for i, row in enumerate(ws.iter_rows()):
+ vals = [cell.value for cell in row]
+ if headers is None:
+ headers = vals
+ chart_idx = headers.index('ChartNo')
+ path_idx = headers.index('PathCode')
+ html_idx = headers.index('html')
+ continue
+
+ all_raw_rows.append(vals)
+
+ if mode == 'last' and limit:
+ raw_rows = all_raw_rows[-limit:]
+ print(f"Processing last {len(raw_rows)} rows (out of {len(all_raw_rows)} total)...")
+ else:
+ raw_rows = all_raw_rows[:limit] if limit else all_raw_rows
+ print(f"Processing {len(raw_rows)} rows...")
+
+ # Build full PathCode map from all Excel rows for fallback resolution
+ excel_pathcode_html_map = {}
+ for vals in all_raw_rows:
+ pcode = str(vals[path_idx]).strip() if vals[path_idx] else ""
+ hval = vals[html_idx] or ""
+ if pcode:
+ excel_pathcode_html_map[pcode] = hval
+
+ rows = []
+ pathcode_diag_map = {}
+
+ for vals in raw_rows:
+ chart_no = vals[chart_idx]
+ path_code = str(vals[path_idx]).strip() if vals[path_idx] else ""
+ html_content = vals[html_idx] or ""
+
+ tissue = extract_tissue(html_content, path_code)
+ diagnosis = extract_short_diagnosis(html_content, path_code)
+
+ site = classify_site(tissue, diagnosis, html_content)
+ ttype = classify_type(tissue, diagnosis, html_content)
+
+ row_dict = {
+ 'chart_no': chart_no,
+ 'path_code': path_code,
+ 'site': site,
+ 'type': ttype,
+ 'tissue': tissue,
+ 'diagnosis': diagnosis
+ }
+ rows.append(row_dict)
+
+ if path_code and diagnosis:
+ pathcode_diag_map[path_code] = diagnosis
+
+ print("Resolving empty diagnosis rows via 組織由來 (tissue) -> 病理號 (PathCode) matching...")
+ fallback_count = 0
+ for r in rows:
+ if not r['diagnosis'] and r['tissue']:
+ tissue = r['tissue'].strip()
+ potential_codes = [tissue] + re.findall(r'[A-Za-z0-9_\-]+', tissue)
+ for code in potential_codes:
+ if code not in pathcode_diag_map and code in excel_pathcode_html_map:
+ ref_html = excel_pathcode_html_map[code]
+ if ref_html:
+ db_diag = extract_short_diagnosis(ref_html, code)
+ if db_diag:
+ pathcode_diag_map[code] = db_diag
+
+ if code in pathcode_diag_map and pathcode_diag_map[code]:
+ r['diagnosis'] = pathcode_diag_map[code]
+ r['site'] = classify_site(r['tissue'], r['diagnosis'])
+ r['type'] = classify_type(r['tissue'], r['diagnosis'])
+ fallback_count += 1
+ break
+
+ print(f"Fallback resolution complete: updated {fallback_count} empty rows.")
+ print(f"Writing to CSV file: {output_csv} ...")
+
+ with open(output_csv, 'w', newline='', encoding='utf-8-sig') as f:
+ writer = csv.writer(f)
+ writer.writerow(['病歷號', '病理號', 'site', 'type', '組織由來', 'pathological diagnosis'])
+
+ for r in rows:
+ writer.writerow([r['chart_no'], r['path_code'], r['site'], r['type'], r['tissue'], r['diagnosis']])
+
+ print(f"Successfully exported {len(rows)} rows to {output_csv}")
+
+
+if __name__ == '__main__':
+ inp = sys.argv[1] if len(sys.argv) > 1 else None
+ out = sys.argv[2] if len(sys.argv) > 2 else None
+ lim = int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3].isdigit() else None
+ m = sys.argv[4] if len(sys.argv) > 4 else 'all'
+ process_extraction(inp, out, limit=lim, mode=m)
diff --git a/patho/fetch.py b/patho/fetch.py
new file mode 100755
index 0000000..f21729c
--- /dev/null
+++ b/patho/fetch.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import os
+import sys
+import re
+
+# Add repository root and 'ntuh' directory to sys.path
+script_dir = os.path.dirname(os.path.abspath(__file__))
+base_dir = os.path.dirname(script_dir)
+ntuh_dir = os.path.join(base_dir, 'ntuh')
+
+if base_dir not in sys.path:
+ sys.path.insert(0, base_dir)
+if ntuh_dir not in sys.path:
+ sys.path.insert(0, ntuh_dir)
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ntuh.settings')
+
+import django
+django.setup()
+
+import pandas as pd
+from registry.models import PathologyReport
+
+illegal_re = re.compile(r'[\x00-\x08\x0b-\x0c\x0e-\x1f]')
+
+
+def clean_string_for_excel(val):
+ if not isinstance(val, str):
+ return val
+ # Remove illegal XML / openpyxl control characters
+ val = illegal_re.sub('', val)
+ # Strip unnecessary head, style, script, meta, link tags so report body fits within cell limits
+ h = re.sub(r'<(?:head|style|script|meta|link)[^>]*?>.*?(?:head|style|script|meta|link)>', '', val, flags=re.DOTALL | re.IGNORECASE)
+ h = re.sub(r'<(?:meta|link)[^>]*?>', '', h, flags=re.IGNORECASE)
+ h = re.sub(r'\n\s*\n', '\n', h)
+ return h.strip()
+
+
+def fetch_pathology_reports(output_file=None):
+ if output_file is None:
+ output_file = os.path.join(script_dir, 'registry_pathologyreport.xlsx')
+
+ print(f"Fetching PathologyReport records from database...")
+ qs = PathologyReport.objects.all().values()
+ data = list(qs)
+
+ print(f"Cleaning string fields & HTML body for Excel cell limit...")
+ for item in data:
+ for k, v in item.items():
+ if isinstance(v, str):
+ item[k] = clean_string_for_excel(v)
+
+ df = pd.DataFrame(data)
+ print(f"Fetched {len(df)} rows. Writing to {output_file}...")
+ df.to_excel(output_file, index=False)
+ print(f"Successfully exported {len(df)} rows to {output_file}")
+
+
+if __name__ == '__main__':
+ default_output = os.path.join(script_dir, 'registry_pathologyreport.xlsx')
+ target_path = sys.argv[1] if len(sys.argv) > 1 else default_output
+ fetch_pathology_reports(target_path)