| """ |
| DeepMed-AI — tools/document_loader.py |
| Multi-format document loading with drug-aware chunking. |
| Every chunk from a drug .md file: |
| 1. Is prefixed with [Thuốc: NAME | Hoạt chất: ...] for embedding search |
| 2. Has metadata drug_name="NAME" + doc_type="drug_info" for FILTERED search |
| This ensures 100% retrieval accuracy for any drug query. |
| """ |
|
|
| import os |
| import re |
| import glob |
| from typing import List, Optional, Set, Tuple |
| from langchain_core.documents import Document |
| from app.core.logging_config import logger |
|
|
|
|
| |
|
|
| def load_pdf(file_path: str) -> List[Document]: |
| from langchain_community.document_loaders import PyPDFLoader |
| return PyPDFLoader(file_path).load() |
|
|
|
|
| def load_docx(file_path: str) -> List[Document]: |
| import docx |
| try: |
| doc = docx.Document(file_path) |
| content = "\n".join(p.text for p in doc.paragraphs if p.text.strip()) |
| if content: |
| return [Document(page_content=content, metadata={"source": file_path})] |
| except Exception as e: |
| logger.error("Failed to load DOCX %s: %s", file_path, e) |
| return [] |
|
|
|
|
| def load_text(file_path: str) -> List[Document]: |
| try: |
| with open(file_path, "r", encoding="utf-8") as f: |
| content = f.read() |
| if content.strip(): |
| return [Document(page_content=content, metadata={"source": file_path})] |
| except Exception as e: |
| logger.error("Failed to load Text/MD %s: %s", file_path, e) |
| return [] |
|
|
|
|
| def load_smart_excel(file_path: str) -> List[Document]: |
| """Each Excel row becomes a separate Document.""" |
| import pandas as pd |
| docs = [] |
| try: |
| if file_path.lower().endswith(".csv"): |
| df = pd.read_csv(file_path) |
| else: |
| df = pd.read_excel(file_path) |
| for index, row in df.iterrows(): |
| items = [f"[{col}: {val}]" for col, val in row.items() |
| if pd.notna(val) and str(val).strip()] |
| if items: |
| docs.append(Document( |
| page_content=" | ".join(items), |
| metadata={"source": file_path, "row": index + 1}, |
| )) |
| except Exception as e: |
| logger.error("Failed to load Excel %s: %s", file_path, e) |
| return docs |
|
|
|
|
| |
|
|
| def _extract_drug_header(content: str) -> Tuple[str, str, str]: |
| """Extract drug name + active ingredient from the first lines of a .md file. |
| |
| Expected format: |
| # MIDANTIN |
| (blank line) |
| Hoạt chất: Amoxicilin+acid clavulanic 1g+0,2g |
| |
| Returns: |
| (header_prefix, drug_name, active_ingredient) |
| header_prefix: "[Thuốc: MIDANTIN | Hoạt chất: Amoxicilin+acid clavulanic 1g+0,2g]\n" |
| drug_name: "MIDANTIN" |
| active_ingredient: "Amoxicilin+acid clavulanic 1g+0,2g" |
| """ |
| lines = content.split("\n", 12)[:12] |
| drug_name = "" |
| active = "" |
|
|
| for line in lines: |
| s = line.strip() |
| if s.startswith("# ") and not drug_name: |
| drug_name = s[2:].strip() |
| if not active and "hoạt chất" in s.lower(): |
| m = re.search(r'[Hh]oạt chất[:\s]+(.+)', s) |
| if m: |
| active = m.group(1).strip() |
|
|
| if drug_name: |
| parts = [f"Thuốc: {drug_name}"] |
| if active: |
| parts.append(f"Hoạt chất: {active}") |
| prefix = "[" + " | ".join(parts) + "]\n" |
| return prefix, drug_name, active |
| return "", "", "" |
|
|
|
|
| def _parse_ingredient_keywords(active_ingredient: str) -> List[str]: |
| """Parse active ingredient string into searchable uppercase keywords. |
| |
| Examples: |
| "Amoxicilin+acid clavulanic 1g+0,2g" |
| → ["AMOXICILIN", "CLAVULANIC"] |
| |
| "Ceftriaxon dưới dạng Ceftriaxon natri 2000mg" |
| → ["CEFTRIAXON", "CEFTRIAXON", "NATRI"] |
| |
| "Paracetamol 500mg" |
| → ["PARACETAMOL"] |
| """ |
| if not active_ingredient: |
| return [] |
|
|
| |
| tokens = re.split(r'[+,;/()\s]+', active_ingredient) |
|
|
| |
| SKIP_WORDS = { |
| "MG", "ML", "MCG", "IU", "DẠ", "DẠNG", "DƯỚI", "MỖI", |
| "ACID", "VIÊN", "NÉN", "GÓI", "LỌ", "ỐNG", "LIỀU", |
| "THUỐC", "TIÊM", "UỐNG", "HOẠT", "CHẤT", |
| } |
| keywords = [] |
| for t in tokens: |
| t_clean = t.strip().upper() |
| if len(t_clean) < 3: |
| continue |
| |
| if re.match(r'^[\d.,]+$', t_clean): |
| continue |
| |
| if re.match(r'^\d+[A-Z]{1,3}$', t_clean): |
| continue |
| if t_clean in SKIP_WORDS: |
| continue |
| keywords.append(t_clean) |
|
|
| return list(set(keywords)) |
|
|
|
|
| def _is_drug_info_file(file_path: str) -> bool: |
| """Detect drug info .md files by directory name. |
| |
| Works on both Windows (backslash) and Linux/Docker (forward slash). |
| Handles both Unicode and ASCII-normalized folder names. |
| """ |
| normed = file_path.replace("\\", "/").lower() |
| |
| if "thông tin thuốc nội bộ" in normed: |
| return True |
| |
| if "thong tin thuoc noi bo" in normed: |
| return True |
| |
| parts = normed.split("/") |
| for part in parts: |
| if "thuoc" in part and "noi" in part and "bo" in part: |
| return True |
| return False |
|
|
|
|
| |
|
|
| def split_documents( |
| docs: List[Document], |
| drug_prefix: str = "", |
| extra_metadata: Optional[dict] = None, |
| ) -> List[Document]: |
| """Split docs into overlapping chunks. |
| |
| If drug_prefix is set, prepend it to every chunk. |
| If extra_metadata is set, merge it into every chunk's metadata. |
| """ |
| from langchain_text_splitters import RecursiveCharacterTextSplitter |
| splitter = RecursiveCharacterTextSplitter( |
| chunk_size=1024, |
| chunk_overlap=128, |
| separators=["\n\n", ". ", "\n", " "], |
| ) |
| chunks = splitter.split_documents(docs) |
|
|
| if drug_prefix: |
| for chunk in chunks: |
| |
| if not chunk.page_content.startswith(drug_prefix): |
| chunk.page_content = drug_prefix + chunk.page_content |
|
|
| if extra_metadata: |
| for chunk in chunks: |
| chunk.metadata.update(extra_metadata) |
|
|
| return chunks |
|
|
|
|
| |
|
|
| def load_all_documents(directory: str) -> List[Document]: |
| """Recursively load all supported files from the data directory. |
| |
| Drug .md files get special metadata: drug_name, active_ingredient, doc_type. |
| This enables filtered search in Qdrant for 100% retrieval accuracy. |
| """ |
| all_docs: List[Document] = [] |
| drug_count = 0 |
|
|
| loaders = { |
| ".pdf": load_pdf, |
| ".docx": load_docx, |
| ".txt": load_text, |
| ".csv": load_smart_excel, |
| ".xlsx": load_smart_excel, |
| } |
|
|
| pattern = os.path.join(directory, "**", "*.*") |
| files = glob.glob(pattern, recursive=True) |
| logger.info("Scanning %s: found %d files", directory, len(files)) |
|
|
| for fp in files: |
| ext = os.path.splitext(fp)[1].lower() |
|
|
| |
| |
| if ext == ".md" and _is_drug_info_file(fp): |
| drug_count += 1 |
| continue |
|
|
| |
| if ext == ".md": |
| docs = load_text(fp) |
| if docs: |
| meta = {"doc_type": "reference_md"} |
| all_docs.extend(split_documents(docs, extra_metadata=meta)) |
| continue |
|
|
| |
| if ext in loaders: |
| docs = loaders[ext](fp) |
| if docs: |
| meta = {"doc_type": f"reference_{ext.lstrip('.')}"} |
| if ext != ".xlsx": |
| docs = split_documents(docs, extra_metadata=meta) |
| else: |
| for d in docs: |
| d.metadata.update(meta) |
| all_docs.extend(docs) |
|
|
| logger.info( |
| "Loaded %d chunks total (%d drug info files, %d other files)", |
| len(all_docs), drug_count, len(files) - drug_count, |
| ) |
| return all_docs |
|
|