Spaces:
Sleeping
Sleeping
| import fitz | |
| import re | |
| import os | |
| import json | |
| from langchain_google_genai import ChatGoogleGenerativeAI | |
| from langchain_core.prompts import PromptTemplate | |
| from dotenv import load_dotenv | |
| from datetime import datetime | |
| from pathlib import Path | |
| # ββ LLM Segmentation ββββββββββββββββββββββββββββββ | |
| SEGMENT_PROMPT = """ | |
| You are an NDA document processor specialising in Indian employment contracts. | |
| Extract all clauses from the NDA and return structured JSON. | |
| For each clause: | |
| - clause_number : sequential integer | |
| - clause_title : heading or title of the clause | |
| - clause_text : FULL original text, word for word β never summarize | |
| - clause_type : one of: | |
| confidentiality | non-compete | non-solicitation | | |
| penalty | jurisdiction | termination | ip-ownership | | |
| data-protection | indemnity | definitions | general | other | |
| Rules: | |
| - Never paraphrase or summarize clause_text | |
| - Include all sub-clauses inside clause_text | |
| - If type unclear use "general" | |
| - Return ONLY valid JSON β no markdown, no explanation | |
| JSON structure: | |
| {{ | |
| "nda_title": "...", | |
| "total_clauses": <number>, | |
| "clauses": [ | |
| {{ | |
| "clause_number": 1, | |
| "clause_title": "...", | |
| "clause_text": "...", | |
| "clause_type": "..." | |
| }} | |
| ] | |
| }} | |
| NDA DOCUMENT: | |
| {nda_text} | |
| """ | |
| # ββ Regex Segmentation (fallback) βββββββββββββββββ | |
| CLAUSE_TYPES = { | |
| "confidential" : "confidentiality", | |
| "non-compete" : "non-compete", | |
| "non compete" : "non-compete", | |
| "non-solicit" : "non-solicitation", | |
| "penalty" : "penalty", | |
| "liquidated" : "penalty", | |
| "jurisdiction" : "jurisdiction", | |
| "governing law" : "jurisdiction", | |
| "terminat" : "termination", | |
| "term " : "duration", | |
| "duration" : "duration", | |
| "intellectual" : "ip-ownership", | |
| "proprietary" : "ip-ownership", | |
| "indemnif" : "indemnity", | |
| "definition" : "definitions", | |
| "data protect" : "data-protection", | |
| "privacy" : "data-protection", | |
| } | |
| # ββ Patterns ββββββββββββββββββββββββββββββββββββββ | |
| header_pattern = re.compile( | |
| r'(?m)^\s*' | |
| r'(?:' | |
| r'(?:Article|Section|Clause|Schedule)\s+\d+[\.\d]*' | |
| r'|\(\d+\)' | |
| r'|\d+[\.\d]*\)' | |
| r'|\d+[\.\d]*\.' | |
| r')' | |
| r'\s*' | |
| r'([A-Z][A-Za-z\s\-]{1,60})' | |
| r'\s*(?:β|-|:|\.|\n)', | |
| re.MULTILINE | |
| ) | |
| list_item_pattern = re.compile( | |
| r'\n\s*\((?:\d+|[a-zA-Z]|[ivxIVX]+)\)\s+(.*?)' | |
| r'(?=\n\s*\((?:\d+|[a-zA-Z]|[ivxIVX]+)\)|\Z)', | |
| re.DOTALL | |
| ) | |
| definition_pattern = re.compile( | |
| r'["\u201c\u2018](?P<term>.*?)["\u201d\u2019]' | |
| r'\s+(?:shall mean|shall include|shall have the meaning|means|includes)\s+' | |
| r'(?P<definition>.*?)' | |
| r'(?=\n[A-Z"\u201c]|\Z)', | |
| re.DOTALL | |
| ) | |
| noise_cleanup = [ | |
| (r'(?i)Page\s+\d+\s+of\s+\d+', ''), | |
| (r'(?i)Exhibit\s+\([a-z]\)\(\d+\)', ''), | |
| (r'(?i)CONFIDENTIAL\s*[-β]\s*DO NOT DISTRIBUTE', ''), | |
| (r'(?i)Draft\s+v\d+\.?\d*', ''), | |
| (r'(?i)EXECUTION COPY', ''), | |
| (r'\[SIGNATURE PAGE FOLLOWS\]', ''), | |
| (r'\[INTENTIONALLY LEFT BLANK\]', ''), | |
| (r'(?i)initial[s]?\s*[:\-]?\s*_{2,}', ''), | |
| (r'_{3,}', ''), | |
| (r'\s{2,}', ' '), | |
| (r'\n{3,}', '\n\n'), | |
| ] | |
| load_dotenv() | |
| model = ChatGoogleGenerativeAI(model="gemini-2.5-flash", temperature=0) | |
| BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| def extract_nda_text(pdf_path): | |
| text_parts = [] | |
| with fitz.open(pdf_path) as doc: | |
| for page in doc: | |
| text = str(page.get_text("text")).strip() | |
| if text: | |
| text_parts.append(text) | |
| # ββ Gap 3 β no page separator βββββββββββββββββ | |
| full_text = "\n".join(text_parts) | |
| # ββ Gap 4 β no unicode normalization ββββββββββ | |
| full_text = full_text.replace("\u2014", "β") # em dash | |
| full_text = full_text.replace("\u2013", "β") # en dash | |
| full_text = full_text.replace("\ufb01", "fi") # fi ligature | |
| full_text = full_text.replace("\ufb02", "fl") # fl ligature | |
| full_text = full_text.replace("\xa0", " ") # non-breaking space | |
| # ββ Gap 5 β no noise cleanup ββββββββββββββββββ | |
| full_text = clean_nda_text(full_text) | |
| print(f"β Extracted {len(full_text)} chars from NDA") | |
| return full_text.strip() | |
| def clean_nda_text(text): | |
| """ | |
| Remove common NDA PDF noise before sending to LLM. | |
| Safe patterns only β does not touch clause content. | |
| """ | |
| SAFE_PATTERNS = [ | |
| (r'(?i)Page\s+\d+\s+of\s+\d+', ''), # Page 1 of 10 | |
| (r'(?i)CONFIDENTIAL\s*[-β]\s*DO NOT DISTRIBUTE',''), # watermark | |
| (r'(?i)EXECUTION COPY', ''), # execution marker | |
| (r'\[SIGNATURE PAGE FOLLOWS\]', ''), # sig marker | |
| (r'\[INTENTIONALLY LEFT BLANK\]', ''), # blank page | |
| (r'(?i)initial[s]?\s*[:\-]?\s*_{3,}', ''), # Initials: ____ | |
| ] | |
| for pattern, replacement in SAFE_PATTERNS: | |
| text = re.sub(pattern, replacement, text) | |
| # Normalize whitespace last | |
| text = re.sub(r'\n{3,}', '\n\n', text) # max 2 newlines | |
| text = re.sub(r'[ \t]+', ' ', text) # collapse spaces | |
| return text.strip() | |
| def segment_nda_llm(nda_text): | |
| """ | |
| Primary segmentation β sends NDA text to Claude. | |
| Returns structured clause JSON or None if it fails. | |
| """ | |
| prompt = PromptTemplate.from_template(SEGMENT_PROMPT) | |
| chain = prompt | model | |
| try: | |
| response = chain.invoke({"nda_text": nda_text}) | |
| raw = str(response.content).strip() | |
| # Strip markdown fences if model added them | |
| raw = re.sub(r'^```json\s*', '', raw) | |
| raw = re.sub(r'\s*```$', '', raw) | |
| structured = json.loads(raw) | |
| # Basic structure check | |
| if "clauses" not in structured or not structured["clauses"]: | |
| print("β οΈ LLM returned empty clauses") | |
| return None | |
| print(f"β LLM segmented into {structured.get('total_clauses', len(structured['clauses']))} clauses") | |
| structured.setdefault("total_clauses", len(structured["clauses"])) | |
| return structured | |
| except json.JSONDecodeError as e: | |
| print(f"β LLM JSON parse failed: {e}") | |
| return None | |
| except Exception as e: | |
| print(f"β LLM call failed: {e}") | |
| return None | |
| def guess_clause_type(title): | |
| """ | |
| Guesses clause type from title keywords. | |
| Used by regex fallback since LLM isn't classifying. | |
| """ | |
| title_lower = title.lower() | |
| for keyword, clause_type in CLAUSE_TYPES.items(): | |
| if keyword in title_lower: | |
| return clause_type | |
| return "general" | |
| def segment_nda_regex(nda_text): | |
| """ | |
| Fallback segmentation using regex. | |
| Used only when LLM segmentation fails. | |
| Less accurate β does not classify types precisely. | |
| """ | |
| print("β οΈ Using regex fallback segmentation...") | |
| matches = list(header_pattern.finditer(nda_text)) | |
| if not matches: | |
| print("β Regex found no clause headers") | |
| return { | |
| "error" : "No headers found", | |
| "raw_text" : nda_text, | |
| "segmented_by" : "regex" | |
| } | |
| structured_clauses = [] | |
| clause_counter = 1 | |
| # ββ Capture preamble ββββββββββββββββββββββββββ | |
| preamble_text = nda_text[:matches[0].start()].strip() | |
| if preamble_text: | |
| structured_clauses.append({ | |
| "clause_number" : 0, # 0 = preamble | |
| "clause_title" : "Introduction / Parties", | |
| "clause_text" : preamble_text, | |
| "clause_type" : "preamble" | |
| }) | |
| # ββ Segment clauses βββββββββββββββββββββββββββ | |
| for i, match in enumerate(matches): | |
| next_start = matches[i + 1].start() \ | |
| if (i + 1) < len(matches) else len(nda_text) | |
| # Slice body by position β not string replace | |
| body = nda_text[match.end():next_start].strip() | |
| title = match.group(1).strip() | |
| if not body: | |
| continue | |
| structured_clauses.append({ | |
| "clause_number" : clause_counter, | |
| "clause_title" : title, | |
| "clause_text" : body, | |
| "clause_type" : guess_clause_type(title) | |
| }) | |
| clause_counter += 1 | |
| if clause_counter == 1: | |
| print("β Regex fallback found headers but no bodies") | |
| return { | |
| "error" : "No clause bodies extracted", | |
| "raw_text" : nda_text, | |
| "segmented_by" : "regex" | |
| } | |
| # Exclude preamble from clause count | |
| actual_clauses = [ | |
| c for c in structured_clauses | |
| if c["clause_type"] != "preamble" | |
| ] | |
| print(f"β οΈ Regex extracted {len(actual_clauses)} clauses " | |
| f"+ preamble (lower accuracy)") | |
| return { | |
| "nda_title" : "NDA Document", | |
| "total_clauses": len(actual_clauses), | |
| "clauses" : structured_clauses, # includes preamble at index 0 | |
| "segmented_by" : "regex" | |
| } | |
| def segment_nda(nda_text): | |
| """ | |
| Tries LLM first, falls back to regex if LLM fails. | |
| Always returns structured dict or None. | |
| """ | |
| # Try LLM first | |
| structured = segment_nda_llm(nda_text) | |
| if structured: | |
| structured["segmented_by"] = "llm" | |
| return structured | |
| # Fallback to regex | |
| print("β οΈ Falling back to regex segmentation") | |
| structured = segment_nda_regex(nda_text) | |
| if structured and "clauses" in structured: | |
| return structured | |
| # Both failed | |
| print("β Both LLM and regex segmentation failed") | |
| return None | |
| def run_segmentation_pipeline(pdf_path, output_filename=None): | |
| nda_text = extract_nda_text(pdf_path) | |
| structured = segment_nda(nda_text) | |
| if not structured: | |
| print("β Segmentation failed completely") | |
| return None | |
| if output_filename is None: | |
| stem = Path(pdf_path).stem | |
| ts = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| output_filename = f"{stem}_structured_{ts}.json" | |
| output_path = os.path.join(BASE_DIR, "data", output_filename) | |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) | |
| with open(output_path, "w", encoding="utf-8") as f: | |
| json.dump(structured, f, indent=2, ensure_ascii=False) | |
| return structured, output_path | |
| if __name__ == "__main__": | |
| pdf_path = os.path.join(BASE_DIR, "documents", "Nda_document1.pdf") | |
| nda_text = extract_nda_text(pdf_path) | |
| structured = segment_nda(nda_text) | |
| if not structured: | |
| print("β Segmentation failed completely") | |
| exit(1) | |
| # ββ Save to data/structured_nda.json ββββββββββ | |
| output_path = os.path.join(BASE_DIR, "data", "structured_nda.json") | |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) | |
| with open(output_path, "w", encoding="utf-8") as f: | |
| json.dump(structured, f, indent=2, ensure_ascii=False) | |
| print(f"β Saved to {output_path}") | |
| print(f"\nββ Result βββββββββββββββββββββββββββββββββββ") |