Spaces:
Sleeping
Sleeping
| """ | |
| BaoThang-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 | |
| # ββ File Loaders βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| # ββ Drug Info Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 [] | |
| # Split on common separators: +, comma, semicolon, slash, space | |
| tokens = re.split(r'[+,;/()\s]+', active_ingredient) | |
| # Filter: keep words β₯3 chars, alphabetic, not dosage/unit numbers | |
| 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 | |
| # Skip pure numbers or dosage patterns | |
| if re.match(r'^[\d.,]+$', t_clean): | |
| continue | |
| # Skip dosage with units like "1G", "200MG" | |
| 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)) # Deduplicate | |
| 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() | |
| # Primary check: Vietnamese Unicode folder name | |
| if "thΓ΄ng tin thuα»c nα»i bα»" in normed: | |
| return True | |
| # Fallback: ASCII-normalized (in case Docker normalizes differently) | |
| if "thong tin thuoc noi bo" in normed: | |
| return True | |
| # Fallback: check by path segment containing "thuoc" | |
| parts = normed.split("/") | |
| for part in parts: | |
| if "thuoc" in part and "noi" in part and "bo" in part: | |
| return True | |
| return False | |
| # ββ Chunking βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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: | |
| # Only prepend if not already there (avoid double-prefix) | |
| 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 | |
| # ββ Main Loader ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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() | |
| # ββ Skip individual drug .md files (thΓ΄ng tin thuα»c nα»i bα») βββββ | |
| # All drug info is consolidated in DANH_MUC_THUOC_NOI_BO_TOAN_BO.md | |
| if ext == ".md" and _is_drug_info_file(fp): | |
| drug_count += 1 | |
| continue | |
| # ββ Non-drug .md files ββββββββββββββββββββββββββββββββββββββββββββββ | |
| if ext == ".md": | |
| docs = load_text(fp) | |
| if docs: | |
| meta = {"doc_type": "reference_md"} | |
| all_docs.extend(split_documents(docs, extra_metadata=meta)) | |
| continue | |
| # ββ PDF, DOCX, TXT, XLSX ββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |