debase 0.5.1__py3-none-any.whl → 0.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
debase/_version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.5.1"
3
+ __version__ = "0.6.1"
@@ -0,0 +1,146 @@
1
+ """Utilities for handling campaign information across extractors.
2
+
3
+ This module provides functions to load and use campaign information
4
+ to improve extraction accuracy by providing context about model substrates,
5
+ products, and data locations.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import List, Dict, Optional, Any
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def load_campaigns_from_file(campaign_file: Path) -> List[Dict[str, Any]]:
17
+ """Load campaign information from a JSON file.
18
+
19
+ Args:
20
+ campaign_file: Path to campaigns.json file
21
+
22
+ Returns:
23
+ List of campaign dictionaries
24
+ """
25
+ if not campaign_file.exists():
26
+ logger.warning(f"Campaign file not found: {campaign_file}")
27
+ return []
28
+
29
+ try:
30
+ with open(campaign_file, 'r') as f:
31
+ campaigns = json.load(f)
32
+ logger.info(f"Loaded {len(campaigns)} campaigns from {campaign_file}")
33
+ return campaigns
34
+ except Exception as e:
35
+ logger.error(f"Failed to load campaigns from {campaign_file}: {e}")
36
+ return []
37
+
38
+
39
+ def find_campaign_by_id(campaigns: List[Dict[str, Any]], campaign_id: str) -> Optional[Dict[str, Any]]:
40
+ """Find a specific campaign by ID.
41
+
42
+ Args:
43
+ campaigns: List of campaign dictionaries
44
+ campaign_id: Campaign ID to search for
45
+
46
+ Returns:
47
+ Campaign dictionary if found, None otherwise
48
+ """
49
+ for campaign in campaigns:
50
+ if campaign.get('campaign_id') == campaign_id:
51
+ return campaign
52
+ return None
53
+
54
+
55
+ def get_campaign_context(campaign: Dict[str, Any]) -> str:
56
+ """Generate context string for prompts from campaign information.
57
+
58
+ Args:
59
+ campaign: Campaign dictionary
60
+
61
+ Returns:
62
+ Formatted context string for inclusion in prompts
63
+ """
64
+ context_parts = []
65
+
66
+ # Basic campaign info
67
+ context_parts.append(f"Campaign: {campaign.get('campaign_name', 'Unknown')}")
68
+ context_parts.append(f"Description: {campaign.get('description', '')}")
69
+
70
+ # Model reaction info
71
+ if campaign.get('model_substrate'):
72
+ context_parts.append(f"Model Substrate: {campaign['model_substrate']} (ID: {campaign.get('substrate_id', 'unknown')})")
73
+ if campaign.get('model_product'):
74
+ context_parts.append(f"Model Product: {campaign['model_product']} (ID: {campaign.get('product_id', 'unknown')})")
75
+
76
+ # Data locations
77
+ if campaign.get('data_locations'):
78
+ locations = ', '.join(campaign['data_locations'])
79
+ context_parts.append(f"Key Data Locations: {locations}")
80
+
81
+ # Lineage hint if available
82
+ if campaign.get('lineage_hint'):
83
+ context_parts.append(f"Evolution Pathway: {campaign['lineage_hint']}")
84
+
85
+ # Additional notes
86
+ if campaign.get('notes'):
87
+ context_parts.append(f"Notes: {campaign['notes']}")
88
+
89
+ return '\n'.join(context_parts)
90
+
91
+
92
+ def get_location_hints_for_campaign(campaign: Dict[str, Any]) -> List[str]:
93
+ """Extract specific location hints from campaign data.
94
+
95
+ Args:
96
+ campaign: Campaign dictionary
97
+
98
+ Returns:
99
+ List of location strings (e.g., ["Figure 2a", "Table S4"])
100
+ """
101
+ return campaign.get('data_locations', [])
102
+
103
+
104
+ def enhance_prompt_with_campaign(prompt: str, campaign: Optional[Dict[str, Any]],
105
+ section_name: str = "CAMPAIGN CONTEXT") -> str:
106
+ """Enhance a prompt with campaign context information.
107
+
108
+ Args:
109
+ prompt: Original prompt
110
+ campaign: Campaign dictionary (optional)
111
+ section_name: Section header for the campaign context
112
+
113
+ Returns:
114
+ Enhanced prompt with campaign context
115
+ """
116
+ if not campaign:
117
+ return prompt
118
+
119
+ context = get_campaign_context(campaign)
120
+ locations = get_location_hints_for_campaign(campaign)
121
+
122
+ campaign_section = f"\n\n{section_name}:\n{'-' * 50}\n{context}"
123
+
124
+ if locations:
125
+ campaign_section += f"\n\nIMPORTANT: Focus particularly on these locations: {', '.join(locations)}"
126
+
127
+ campaign_section += f"\n{'-' * 50}\n"
128
+
129
+ # Insert campaign context early in the prompt
130
+ # Look for a good insertion point after initial instructions
131
+ lines = prompt.split('\n')
132
+ insert_idx = 0
133
+
134
+ # Find a good place to insert (after first paragraph or instruction block)
135
+ for i, line in enumerate(lines):
136
+ if i > 5 and (not line.strip() or line.startswith('Given') or line.startswith('You')):
137
+ insert_idx = i
138
+ break
139
+
140
+ if insert_idx == 0:
141
+ # Fallback: just prepend
142
+ return campaign_section + prompt
143
+ else:
144
+ # Insert at found position
145
+ lines.insert(insert_idx, campaign_section)
146
+ return '\n'.join(lines)
@@ -0,0 +1,39 @@
1
+ """Universal caption pattern for all DEBase extractors.
2
+
3
+ This module provides a consistent caption pattern that handles various
4
+ formats found in scientific papers, including:
5
+ - Standard formats: Figure 1, Fig. 1, Table 1
6
+ - Supplementary formats: Supplementary Figure 1, Supp. Table 1
7
+ - Extended data: Extended Data Figure 1, ED Fig. 1
8
+ - Other types: Scheme 1, Chart 1
9
+ - Page headers: S14 Table 5
10
+ - Various punctuation: Figure 1. Figure 1: Figure 1 |
11
+ """
12
+
13
+ import re
14
+
15
+ # Universal caption pattern that handles all common formats
16
+ UNIVERSAL_CAPTION_PATTERN = re.compile(
17
+ r"""
18
+ ^ # Start of line
19
+ [^\n]{0,20}? # Up to 20 chars of any content (page headers, etc.)
20
+ ( # Start capture group
21
+ (?:Extended\s+Data\s+)? # Optional "Extended Data" prefix
22
+ (?:ED\s+)? # Optional "ED" prefix
23
+ (?:Supplementary|Supp\.?|Suppl\.?)?\s* # Optional supplementary prefixes
24
+ (?:Table|Fig(?:ure)?|Scheme|Chart) # Main caption types
25
+ ) # End capture group
26
+ (?: # Non-capturing group for what follows
27
+ \s* # Optional whitespace
28
+ (?:S?\d+[A-Za-z]?|[IVX]+) # Number (with optional S prefix or roman)
29
+ (?:[.:|]|\s+\|)? # Optional punctuation (. : or |)
30
+ | # OR
31
+ \. # Just a period (for "Fig." without number)
32
+ )
33
+ """,
34
+ re.I | re.X | re.M
35
+ )
36
+
37
+ def get_universal_caption_pattern():
38
+ """Get the universal caption pattern for use in extractors."""
39
+ return UNIVERSAL_CAPTION_PATTERN
@@ -28,6 +28,13 @@ import fitz
28
28
  import re
29
29
  import json
30
30
  import time
31
+
32
+ # Import universal caption pattern
33
+ try:
34
+ from .caption_pattern import get_universal_caption_pattern
35
+ except ImportError:
36
+ # Fallback if running as standalone script
37
+ from caption_pattern import get_universal_caption_pattern
31
38
  import logging
32
39
  from pathlib import Path
33
40
  from dataclasses import dataclass, field
@@ -113,17 +120,8 @@ _DOI_REGEX = re.compile(r"10\.[0-9]{4,9}/[-._;()/:A-Z0-9]+", re.I)
113
120
  # PDB ID regex - matches 4-character PDB codes
114
121
  _PDB_REGEX = re.compile(r"\b[1-9][A-Z0-9]{3}\b")
115
122
 
116
- # Improved caption prefix regex - captures most journal variants
117
- _CAPTION_PREFIX_RE = re.compile(
118
- r"""
119
- ^\s*
120
- (?:Fig(?:ure)?|Extended\s+Data\s+Fig|ED\s+Fig|Scheme|Chart|
121
- Table|Supp(?:lementary|l|\.?)\s+(?:Fig(?:ure)?|Table)) # label part
122
- \s*(?:S?\d+[A-Za-z]?|[IVX]+) # figure number
123
- [.:]?\s* # trailing punctuation/space
124
- """,
125
- re.I | re.X,
126
- )
123
+ # Use universal caption pattern
124
+ _CAPTION_PREFIX_RE = get_universal_caption_pattern()
127
125
 
128
126
 
129
127
  def _open_doc(pdf_path: str | Path | bytes):
@@ -956,6 +954,9 @@ def identify_evolution_locations(
956
954
  campaign_context = f"\nYou are looking for lineage data for a SPECIFIC campaign:\n- Campaign: {camp.campaign_name}\n- Description: {camp.description}\n"
957
955
  if hasattr(camp, 'notes') and camp.notes:
958
956
  campaign_context += f"- Key identifiers: {camp.notes}\n"
957
+ if hasattr(camp, 'data_locations') and camp.data_locations:
958
+ campaign_context += f"- KNOWN DATA LOCATIONS: {', '.join(camp.data_locations)}\n"
959
+ campaign_context += " IMPORTANT: Prioritize these known locations highly!\n"
959
960
  campaign_specific = f" for the '{camp.campaign_name}' campaign"
960
961
  campaign_field = '\n- "campaign_id": "{}" (optional - include if this location is specific to one campaign)'.format(camp.campaign_id)
961
962
  campaign_example = f', "campaign_id": "{camp.campaign_id}"'
@@ -964,7 +965,10 @@ def identify_evolution_locations(
964
965
  campaign_context = "\nThis manuscript contains multiple directed evolution campaigns:\n"
965
966
  for camp in campaigns:
966
967
  campaign_context += f"- {camp.campaign_id}: {camp.campaign_name} - {camp.description}\n"
968
+ if hasattr(camp, 'data_locations') and camp.data_locations:
969
+ campaign_context += f" Known locations: {', '.join(camp.data_locations)}\n"
967
970
  campaign_context += "\nFind locations that contain lineage data for ANY of these campaigns.\n"
971
+ campaign_context += "IMPORTANT: Prioritize the known locations listed above!\n"
968
972
  campaign_specific = " for any of the identified campaigns"
969
973
  campaign_field = '\n- "campaign_id": "string" (optional - include if this location is specific to one campaign)'
970
974
  campaign_example = ', "campaign_id": "campaign_id_here"'
@@ -1041,6 +1045,7 @@ def extract_complete_lineage(
1041
1045
  campaign_id: Optional[str] = None,
1042
1046
  campaign_info: Optional[Campaign] = None,
1043
1047
  pdf_paths: Optional[List[Path]] = None,
1048
+ location_str: Optional[str] = None,
1044
1049
  ) -> List[Variant]:
1045
1050
  """Prompt Gemini for the full lineage and return a list[Variant]."""
1046
1051
  # Build campaign context
@@ -1060,6 +1065,21 @@ IMPORTANT:
1060
1065
  2. Include "campaign_id": "{campaign_info.campaign_id}" for each variant in your response.
1061
1066
  3. Use the lineage hint pattern above to identify which variants belong to this campaign.
1062
1067
  4. Include parent variants only if they are direct ancestors in this campaign's lineage.
1068
+ """
1069
+
1070
+ # Add location context if provided
1071
+ location_context = ""
1072
+ if location_str:
1073
+ location_context = f"""
1074
+
1075
+ LOCATION CONTEXT:
1076
+ You are extracting data SPECIFICALLY from: {location_str}
1077
+
1078
+ CRITICAL INSTRUCTIONS:
1079
+ - ONLY extract enzyme variants that appear in {location_str}
1080
+ - DO NOT include variants from other figures, tables, or sections
1081
+ - If {location_str} references variants from other locations, DO NOT include those unless they are explicitly shown in {location_str}
1082
+ - Focus strictly on the data presented within the boundaries of {location_str}
1063
1083
  """
1064
1084
 
1065
1085
  # Extract table of contents from PDFs if available
@@ -1096,8 +1116,11 @@ IMPORTANT:
1096
1116
  # Include TOC in the prompt text
1097
1117
  combined_text = toc_text + text if toc_text else text
1098
1118
 
1119
+ # Combine campaign and location context
1120
+ full_context = campaign_context + location_context
1121
+
1099
1122
  prompt = _LINEAGE_EXTRACT_PROMPT.format(
1100
- campaign_context=campaign_context,
1123
+ campaign_context=full_context,
1101
1124
  schema=_LINEAGE_SCHEMA_HINT,
1102
1125
  text=combined_text[:MAX_CHARS],
1103
1126
  )
@@ -1705,7 +1728,8 @@ def get_lineage(
1705
1728
  debug_dir=debug_dir,
1706
1729
  campaign_id=campaign.campaign_id,
1707
1730
  campaign_info=campaign,
1708
- pdf_paths=pdf_paths
1731
+ pdf_paths=pdf_paths,
1732
+ location_str=location_str
1709
1733
  )
1710
1734
  if variants:
1711
1735
  log.info(f"Extracted {len(variants)} variants from {location_type}")
@@ -3364,6 +3388,9 @@ Only match variants that represent the SAME enzyme, accounting for different nam
3364
3388
  Return ONLY a JSON object mapping lineage IDs to sequence IDs.
3365
3389
  Format: {{"lineage_id": "sequence_id", ...}}
3366
3390
  Only include matches you are confident represent the same variant.
3391
+
3392
+ DO NOT include any explanation, reasoning, or text other than the JSON object.
3393
+ Response must be valid JSON that starts with {{ and ends with }}
3367
3394
  """
3368
3395
 
3369
3396
  try:
@@ -3406,17 +3433,28 @@ Only include matches you are confident represent the same variant.
3406
3433
  log.error(f"Full cleaned text: {text}")
3407
3434
  # Try to extract JSON from within the response
3408
3435
  import re
3409
- json_match = re.search(r'\{.*\}', text, re.DOTALL)
3410
- if json_match:
3436
+ # First try to find JSON in code blocks
3437
+ code_block_match = re.search(r'```json\s*(\{[^`]*\})\s*```', text, re.DOTALL)
3438
+ if code_block_match:
3411
3439
  try:
3412
- matches = json.loads(json_match.group(0))
3413
- log.info(f"Successfully extracted JSON from response: {len(matches)} matches")
3440
+ matches = json.loads(code_block_match.group(1))
3441
+ log.info(f"Successfully extracted JSON from code block: {len(matches)} matches")
3414
3442
  except json.JSONDecodeError:
3415
- log.error("Failed to extract JSON from response")
3443
+ log.error("Failed to parse JSON from code block")
3416
3444
  matches = {}
3417
3445
  else:
3418
- log.error("No JSON object found in response")
3419
- matches = {}
3446
+ # Try to find standalone JSON object (non-greedy, looking for balanced braces)
3447
+ json_match = re.search(r'(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})', text)
3448
+ if json_match:
3449
+ try:
3450
+ matches = json.loads(json_match.group(1))
3451
+ log.info(f"Successfully extracted JSON from response: {len(matches)} matches")
3452
+ except json.JSONDecodeError:
3453
+ log.error("Failed to extract JSON from response")
3454
+ matches = {}
3455
+ else:
3456
+ log.error("No JSON object found in response")
3457
+ matches = {}
3420
3458
 
3421
3459
  # Create a mapping of sequence IDs to their data for efficient lookup
3422
3460
  seq_data_map = {row['variant_id']: row for idx, row in unmatched_seqs.iterrows()}
debase/lineage_format.py CHANGED
@@ -35,7 +35,6 @@ import logging
35
35
  import os
36
36
  import pickle
37
37
  import re
38
- import sqlite3
39
38
  import sys
40
39
  import time
41
40
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -137,8 +136,7 @@ SUBSTRATE_CACHE_FILE: Path = CACHE_DIR / "substrate_smiles_cache.pkl"
137
136
  CANONICAL_CACHE_FILE: Path = CACHE_DIR / "canonical_smiles_cache.pkl"
138
137
  CACHE_DIR.mkdir(parents=True, exist_ok=True)
139
138
 
140
- # Local PubChem DB (optional) --------------------------------------------------------
141
- PUBCHEM_DB_PATH: Path = Path(__file__).parent.parent.parent / "data" / "iupac2smiles.db"
139
+ # API endpoints for IUPAC to SMILES conversion --------------------------------------
142
140
 
143
141
  # Gemini API configuration -----------------------------------------------------------
144
142
  GEMINI_API_KEY: str = os.environ.get("GEMINI_API_KEY", "")
@@ -323,37 +321,7 @@ SUBSTRATE_CACHE: Dict[str, str] = _load_pickle(SUBSTRATE_CACHE_FILE)
323
321
  CANONICAL_CACHE: Dict[str, str] = _load_pickle(CANONICAL_CACHE_FILE)
324
322
 
325
323
 
326
- # --- Database lookup ---------------------------------------------------------------
327
- class PubChemDB:
328
- """Very thin wrapper around a local SQLite mapping IUPAC -> SMILES."""
329
-
330
- def __init__(self, path: Path | str) -> None:
331
- self.path = Path(path)
332
- self._conn: Optional[sqlite3.Connection] = None
333
- if not self.path.exists():
334
- log.warning("Local PubChem DB not found at %s", self.path)
335
-
336
- def _connect(self) -> sqlite3.Connection:
337
- if self._conn is None:
338
- self._conn = sqlite3.connect(str(self.path))
339
- return self._conn
340
-
341
- def lookup(self, name: str) -> Optional[str]:
342
- if not self.path.exists():
343
- return None
344
- sql = "SELECT smiles FROM x WHERE name = ? LIMIT 1"
345
- try:
346
- # Create a new connection for thread safety
347
- conn = sqlite3.connect(str(self.path))
348
- cur = conn.execute(sql, (name.lower(),))
349
- row = cur.fetchone()
350
- conn.close()
351
- return row[0] if row else None
352
- except Exception: # pragma: no cover
353
- return None
354
-
355
-
356
- PC_DB = PubChemDB(PUBCHEM_DB_PATH)
324
+ # --- Removed local database - using only online APIs -------------------------------
357
325
 
358
326
 
359
327
  # === 5. SEQUENCE / MUTATION HELPERS ================================================
@@ -481,12 +449,7 @@ def _name_to_smiles(name: str, is_substrate: bool) -> str:
481
449
  if not name or name.lower() in ['nan', 'none', 'null', 'n/a', 'na', '']:
482
450
  return ""
483
451
 
484
- # 1. Local DB (fast, offline)
485
- db_smiles = PC_DB.lookup(name)
486
- if db_smiles:
487
- return db_smiles
488
-
489
- # 2. OPSIN (if installed) ---------------------------------------------------
452
+ # 1. OPSIN (if installed) - fast and reliable for IUPAC names
490
453
  try:
491
454
  import subprocess
492
455
 
@@ -503,12 +466,7 @@ def _name_to_smiles(name: str, is_substrate: bool) -> str:
503
466
  except FileNotFoundError:
504
467
  pass # OPSIN not installed
505
468
 
506
- # 3. Gemini search (for complex compounds) ---------------------------------
507
- gemini_smiles = search_smiles_with_gemini(name)
508
- if gemini_smiles:
509
- return gemini_smiles
510
-
511
- # 4. PubChem PUG REST (online) ---------------------------------------------
469
+ # 2. PubChem PUG REST API (online) - comprehensive database
512
470
  try:
513
471
  import requests
514
472
 
@@ -521,6 +479,11 @@ def _name_to_smiles(name: str, is_substrate: bool) -> str:
521
479
  return pug_smiles
522
480
  except Exception: # pragma: no cover
523
481
  pass
482
+
483
+ # 3. Gemini search (for complex compounds) - AI fallback
484
+ gemini_smiles = search_smiles_with_gemini(name)
485
+ if gemini_smiles:
486
+ return gemini_smiles
524
487
 
525
488
  # Return empty string if all methods fail
526
489
  return ""