structflo-ner 0.5.0__tar.gz → 0.6.0__tar.gz

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.
Files changed (44) hide show
  1. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/PKG-INFO +15 -1
  2. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/README.md +14 -0
  3. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/__init__.py +1 -1
  4. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/_examples.py +126 -5
  5. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/_prompts.py +47 -15
  6. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/README.md +1 -1
  7. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/_loader.py +7 -2
  8. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/_matcher.py +3 -2
  9. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/tests/test_extractor.py +15 -1
  10. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/tests/test_fast.py +14 -0
  11. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/uv.lock +3 -3
  12. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/.github/workflows/ci.yml +0 -0
  13. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/.github/workflows/publish.yml +0 -0
  14. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/.gitignore +0 -0
  15. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/Makefile +0 -0
  16. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/coverage.xml +0 -0
  17. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/images/fast-viz.png +0 -0
  18. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/images/local-gen-pandas.png +0 -0
  19. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/images/local-gen-viz.png +0 -0
  20. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/images/local-tb-viz.png +0 -0
  21. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/images/ner_visualization.gif +0 -0
  22. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/images/struct-flo-ner.png +0 -0
  23. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/notebooks/01_quickstart.ipynb +0 -0
  24. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/notebooks/02_fast_ner.ipynb +0 -0
  25. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/pyproject.toml +0 -0
  26. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/_display.py +0 -0
  27. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/_entities.py +0 -0
  28. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/_mapping.py +0 -0
  29. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/extractor.py +0 -0
  30. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/__init__.py +0 -0
  31. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/_normalize.py +0 -0
  32. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/extractor.py +0 -0
  33. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/gazetteers/accession_number.yml +0 -0
  34. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/gazetteers/compound_name.yml +0 -0
  35. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/gazetteers/disease.yml +0 -0
  36. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/gazetteers/functional_category.yml +0 -0
  37. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/gazetteers/gene_name.yml +0 -0
  38. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/gazetteers/product.yml +0 -0
  39. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/gazetteers/screening_method.yml +0 -0
  40. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/gazetteers/strain.yml +0 -0
  41. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/fast/gazetteers/target.yml +0 -0
  42. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/structflo/ner/profiles.py +0 -0
  43. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/tests/__init__.py +0 -0
  44. {structflo_ner-0.5.0 → structflo_ner-0.6.0}/tests/test_entities.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: structflo-ner
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Drug discovery NER wrapper around LangExtract — zero-config entity extraction for chemistry and biology.
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -347,6 +347,20 @@ df = result.to_dataframe()
347
347
  result.to_dict()
348
348
  ```
349
349
 
350
+ Each bioactivity carries its measurement in `attributes`:
351
+
352
+ | Attribute | Example | Meaning |
353
+ | --------------- | --------------------------- | ---------------------------------------------------------- |
354
+ | `value` | `>20.0` | number as reported, qualifier kept |
355
+ | `unit` | `µM` | unit as written |
356
+ | `assay_type` | `CC50` | the endpoint (IC50, MIC90, GI50, ED90, ...) |
357
+ | `assay` | `HepG2 MTT` | assay, cell line or read-out the value was measured in |
358
+ | `compound_name` | `8t` | compound the value belongs to |
359
+ | `strain` | `H37Rv` | organism or strain (`TB` profile only) |
360
+
361
+ An attribute the text does not state comes back as the string `"None"` with
362
+ providers that enforce a strict schema (OpenAI), or is absent otherwise.
363
+
350
364
 
351
365
  ## Notebooks
352
366
 
@@ -334,6 +334,20 @@ df = result.to_dataframe()
334
334
  result.to_dict()
335
335
  ```
336
336
 
337
+ Each bioactivity carries its measurement in `attributes`:
338
+
339
+ | Attribute | Example | Meaning |
340
+ | --------------- | --------------------------- | ---------------------------------------------------------- |
341
+ | `value` | `>20.0` | number as reported, qualifier kept |
342
+ | `unit` | `µM` | unit as written |
343
+ | `assay_type` | `CC50` | the endpoint (IC50, MIC90, GI50, ED90, ...) |
344
+ | `assay` | `HepG2 MTT` | assay, cell line or read-out the value was measured in |
345
+ | `compound_name` | `8t` | compound the value belongs to |
346
+ | `strain` | `H37Rv` | organism or strain (`TB` profile only) |
347
+
348
+ An attribute the text does not state comes back as the string `"None"` with
349
+ providers that enforce a strict schema (OpenAI), or is absent otherwise.
350
+
337
351
 
338
352
  ## Notebooks
339
353
 
@@ -63,7 +63,7 @@ from structflo.ner.profiles import (
63
63
  EntityProfile,
64
64
  )
65
65
 
66
- __version__ = "0.5.0"
66
+ __version__ = "0.6.0"
67
67
 
68
68
  __all__ = [
69
69
  # Main classes
@@ -158,8 +158,8 @@ BIOLOGY_EXAMPLES: list[lx.data.ExampleData] = [
158
158
 
159
159
  _BIOACTIVITY_EXAMPLE_1 = lx.data.ExampleData(
160
160
  text=(
161
- "Compound 7 inhibited EGFR with an IC50 of 2.3 nM in a cell-free enzymatic assay "
162
- "and showed an EC50 of 45 nM in A549 (human lung adenocarcinoma) cell proliferation assay. "
161
+ "Compound 7 (CHEMBL5171042) inhibited EGFR with an IC50 of 2.3 nM in a cell-free "
162
+ "enzymatic assay and showed an EC50 of 45 nM in A549 (human lung adenocarcinoma) cell proliferation assay. "
163
163
  "Selectivity over ERBB2 was >100-fold (Ki = 0.8 nM vs 95 nM)."
164
164
  ),
165
165
  extractions=[
@@ -170,7 +170,8 @@ _BIOACTIVITY_EXAMPLE_1 = lx.data.ExampleData(
170
170
  "value": "2.3",
171
171
  "unit": "nM",
172
172
  "assay_type": "IC50",
173
- "compound_name": "Compound 7",
173
+ "compound_name": "CHEMBL5171042",
174
+ "assay": "cell-free enzymatic assay",
174
175
  },
175
176
  ),
176
177
  lx.data.Extraction(
@@ -185,7 +186,8 @@ _BIOACTIVITY_EXAMPLE_1 = lx.data.ExampleData(
185
186
  "value": "45",
186
187
  "unit": "nM",
187
188
  "assay_type": "EC50",
188
- "compound_name": "Compound 7",
189
+ "compound_name": "CHEMBL5171042",
190
+ "assay": "A549 cell proliferation assay",
189
191
  },
190
192
  ),
191
193
  lx.data.Extraction(
@@ -204,7 +206,7 @@ _BIOACTIVITY_EXAMPLE_1 = lx.data.ExampleData(
204
206
  "value": "0.8",
205
207
  "unit": "nM",
206
208
  "assay_type": "Ki",
207
- "compound_name": "Compound 7",
209
+ "compound_name": "CHEMBL5171042",
208
210
  },
209
211
  ),
210
212
  ],
@@ -337,6 +339,7 @@ _FULL_EXAMPLE_1 = lx.data.ExampleData(
337
339
  "unit": "µM",
338
340
  "assay_type": "IC50",
339
341
  "compound_name": "Gefitinib",
342
+ "assay": "cell-free biochemical assay",
340
343
  },
341
344
  ),
342
345
  lx.data.Extraction(
@@ -357,6 +360,7 @@ _FULL_EXAMPLE_1 = lx.data.ExampleData(
357
360
  "unit": "µM",
358
361
  "assay_type": "IC50",
359
362
  "compound_name": "Gefitinib",
363
+ "assay": "A431 cells",
360
364
  },
361
365
  ),
362
366
  lx.data.Extraction(
@@ -449,6 +453,7 @@ _TB_EXAMPLE_1 = lx.data.ExampleData(
449
453
  "assay_type": "MIC",
450
454
  "strain": "H37Rv",
451
455
  "compound_name": "Bedaquiline",
456
+ "assay": "MABA",
452
457
  },
453
458
  ),
454
459
  lx.data.Extraction(
@@ -619,6 +624,7 @@ _TB_EXAMPLE_3 = lx.data.ExampleData(
619
624
  "unit": "nM",
620
625
  "assay_type": "IC50",
621
626
  "compound_name": "Compound 14a",
627
+ "assay": "biochemical assay",
622
628
  },
623
629
  ),
624
630
  lx.data.Extraction(
@@ -643,6 +649,7 @@ _TB_EXAMPLE_3 = lx.data.ExampleData(
643
649
  "assay_type": "MIC90",
644
650
  "strain": "H37Rv",
645
651
  "compound_name": "Compound 14a",
652
+ "assay": "REMA",
646
653
  },
647
654
  ),
648
655
  lx.data.Extraction(
@@ -658,6 +665,7 @@ _TB_EXAMPLE_3 = lx.data.ExampleData(
658
665
  "unit": "uM",
659
666
  "assay_type": "EC50",
660
667
  "compound_name": "Compound 14a",
668
+ "assay": "THP-1 macrophage infection assay",
661
669
  },
662
670
  ),
663
671
  lx.data.Extraction(
@@ -673,6 +681,7 @@ _TB_EXAMPLE_3 = lx.data.ExampleData(
673
681
  "unit": "uM",
674
682
  "assay_type": "MIC",
675
683
  "compound_name": "Compound 14a",
684
+ "assay": "LORA",
676
685
  },
677
686
  ),
678
687
  lx.data.Extraction(
@@ -688,6 +697,7 @@ _TB_EXAMPLE_3 = lx.data.ExampleData(
688
697
  "unit": "uM",
689
698
  "assay_type": "CC50",
690
699
  "compound_name": "Compound 14a",
700
+ "assay": "HepG2 cells",
691
701
  },
692
702
  ),
693
703
  ],
@@ -860,12 +870,123 @@ _TB_EXAMPLE_5 = lx.data.ExampleData(
860
870
  ],
861
871
  )
862
872
 
873
+ # Table as Docling emits it: the column header names the assay, the endpoint
874
+ # and unit come from the footnote, and every cell is its own bioactivity. The
875
+ # ID column, not the deck-local row label, names the compound.
876
+ _TB_EXAMPLE_6 = lx.data.ExampleData(
877
+ text=(
878
+ "Table 2. Whole-cell activity\n\n"
879
+ "| Cpd | ID | MABA | THP-1 | Vero |\n"
880
+ "| --- | --- | --- | --- | --- |\n"
881
+ "| 21a | SACC-4412 | 0.12 | 0.9 | >50 |\n"
882
+ "| 21b | CHEMBL5290347 | <0.03 | 2.4* | 18.5 |\n\n"
883
+ "MABA: MIC90 (µM) against M. tuberculosis H37Rv. THP-1: intracellular EC90 (µM). "
884
+ "Vero: CC50 (µM). *Single determination."
885
+ ),
886
+ extractions=[
887
+ lx.data.Extraction(
888
+ extraction_class="assay",
889
+ extraction_text="MABA",
890
+ attributes={"assay_format": "whole-cell", "strain": "H37Rv"},
891
+ ),
892
+ lx.data.Extraction(
893
+ extraction_class="assay",
894
+ extraction_text="THP-1",
895
+ attributes={"cell_line": "THP-1", "assay_format": "intracellular"},
896
+ ),
897
+ lx.data.Extraction(
898
+ extraction_class="assay",
899
+ extraction_text="Vero",
900
+ attributes={"cell_line": "Vero", "assay_format": "cytotoxicity"},
901
+ ),
902
+ lx.data.Extraction(
903
+ extraction_class="compound_name",
904
+ extraction_text="SACC-4412",
905
+ attributes={"synonyms": "21a"},
906
+ ),
907
+ lx.data.Extraction(
908
+ extraction_class="bioactivity",
909
+ extraction_text="0.12",
910
+ attributes={
911
+ "value": "0.12",
912
+ "unit": "µM",
913
+ "assay_type": "MIC90",
914
+ "strain": "H37Rv",
915
+ "compound_name": "SACC-4412",
916
+ "assay": "MABA",
917
+ },
918
+ ),
919
+ lx.data.Extraction(
920
+ extraction_class="bioactivity",
921
+ extraction_text="0.9",
922
+ attributes={
923
+ "value": "0.9",
924
+ "unit": "µM",
925
+ "assay_type": "EC90",
926
+ "compound_name": "SACC-4412",
927
+ "assay": "THP-1",
928
+ },
929
+ ),
930
+ lx.data.Extraction(
931
+ extraction_class="bioactivity",
932
+ extraction_text=">50",
933
+ attributes={
934
+ "value": ">50",
935
+ "unit": "µM",
936
+ "assay_type": "CC50",
937
+ "compound_name": "SACC-4412",
938
+ "assay": "Vero",
939
+ },
940
+ ),
941
+ lx.data.Extraction(
942
+ extraction_class="compound_name",
943
+ extraction_text="CHEMBL5290347",
944
+ attributes={"synonyms": "21b"},
945
+ ),
946
+ lx.data.Extraction(
947
+ extraction_class="bioactivity",
948
+ extraction_text="<0.03",
949
+ attributes={
950
+ "value": "<0.03",
951
+ "unit": "µM",
952
+ "assay_type": "MIC90",
953
+ "strain": "H37Rv",
954
+ "compound_name": "CHEMBL5290347",
955
+ "assay": "MABA",
956
+ },
957
+ ),
958
+ lx.data.Extraction(
959
+ extraction_class="bioactivity",
960
+ extraction_text="2.4",
961
+ attributes={
962
+ "value": "2.4",
963
+ "unit": "µM",
964
+ "assay_type": "EC90",
965
+ "compound_name": "CHEMBL5290347",
966
+ "assay": "THP-1",
967
+ },
968
+ ),
969
+ lx.data.Extraction(
970
+ extraction_class="bioactivity",
971
+ extraction_text="18.5",
972
+ attributes={
973
+ "value": "18.5",
974
+ "unit": "µM",
975
+ "assay_type": "CC50",
976
+ "compound_name": "CHEMBL5290347",
977
+ "assay": "Vero",
978
+ },
979
+ ),
980
+ ],
981
+ )
982
+
863
983
  TB_EXAMPLES: list[lx.data.ExampleData] = [
864
984
  _TB_EXAMPLE_1,
865
985
  _TB_EXAMPLE_2,
866
986
  _TB_EXAMPLE_3,
867
987
  _TB_EXAMPLE_4,
868
988
  _TB_EXAMPLE_5,
989
+ _TB_EXAMPLE_6,
869
990
  ]
870
991
 
871
992
  TB_CHEMISTRY_EXAMPLES: list[lx.data.ExampleData] = [
@@ -1,5 +1,34 @@
1
1
  """Prompt strings for each built-in EntityProfile."""
2
2
 
3
+ # Shared by every prompt that extracts bioactivity values. The attribute names
4
+ # must match the keys used in the few-shot examples: langextract derives the
5
+ # output schema from those keys, not from this text.
6
+ _BIOACTIVITY_ATTRIBUTES = (
7
+ "For each bioactivity value capture:\n"
8
+ "- value: the number as reported, keeping qualifiers ('>20.0', '<1.0') and dropping "
9
+ "footnote markers ('0.3*' → '0.3').\n"
10
+ "- unit: as written (nM, µM, µg/mL, mg/kg, %, h).\n"
11
+ "- assay_type: the endpoint only, in plain characters (CC₅₀ → CC50). Common endpoints: "
12
+ "IC50, IC90, EC50, EC90, pIC50, Ki, Kd, '% inhibition at <concentration>'; "
13
+ "MIC, MIC50, MIC90, MIC99, MBC, log10 CFU reduction; CC50, TC50, SI (selectivity index); "
14
+ "GI50, TGI, LC50; DC50, Dmax (degraders); ED50, ED90, %TGI, T/C (in vivo); "
15
+ "PRR (parasite reduction ratio), parasite clearance half-life.\n"
16
+ "- assay: the assay, cell line or read-out the value was measured in, e.g. 'MABA', "
17
+ "'LORA', 'HepG2 MTT', 'THP-1 macrophage infection assay', 'asexual blood-stage', "
18
+ "'liver-stage', 'gametocyte', 'NCI-60', 'hERG', 'P. berghei 4-day suppressive test'. "
19
+ "null when no assay is stated.\n"
20
+ "- compound_name: the compound the value belongs to, by its name or identifier "
21
+ "(e.g. 'bedaquiline', 'BTZ043', 'CHEMBL1234', 'SACC-3000'); a document-local label "
22
+ "('7a', 'Compound 9b') only when nothing else names the compound.\n"
23
+ )
24
+ _BIOACTIVITY_TABLES = (
25
+ "In a data table, extract every cell value as its own bioactivity. An ID or registry "
26
+ "column, when present, supplies the compound_name and the row label is only its synonym; "
27
+ "otherwise the row label is the compound_name. A column header naming an assay or cell "
28
+ "line is the assay, and the endpoint and unit often come from the caption or a footnote "
29
+ "('CC50 in µM')."
30
+ )
31
+
3
32
  CHEMISTRY_PROMPT = (
4
33
  "Extract chemical entities from the text. "
5
34
  "Include: compound names (generic names, IUPAC names, code names like 'Compound 5b', "
@@ -17,14 +46,11 @@ BIOLOGY_PROMPT = (
17
46
  )
18
47
 
19
48
  BIOACTIVITY_PROMPT = (
20
- "Extract bioactivity measurements and assay data from the text. "
21
- "Include: potency values (IC50, MIC, EC50, Ki, Kd, GI50, CC50), selectivity ratios, "
22
- "percent inhibition values, and Hill coefficients. "
23
- "For each value, capture the numeric value, unit (nM, µM, mM, µg/mL, ng/mL), and measurement type. "
24
- "Also capture the compound — its name, code name, or identifier (e.g. 'Compound 7', 'BTZ043', "
25
- "'CHEMBL1234', 'SACC-3000') — that the measurement applies to, as a 'compound_name' attribute, "
26
- "if it can be identified from the surrounding context. "
27
- "Also extract assay descriptions: cell lines used (e.g. 'HeLa', 'A549'), assay formats "
49
+ "Extract bioactivity measurements and assay data from the text: potency, selectivity, "
50
+ "cytotoxicity and efficacy values.\n"
51
+ + _BIOACTIVITY_ATTRIBUTES
52
+ + _BIOACTIVITY_TABLES
53
+ + "\nAlso extract assay descriptions: cell lines used (e.g. 'HeLa', 'A549'), assay formats "
28
54
  "(e.g. 'cell viability', 'binding assay', 'enzymatic assay'), and organisms."
29
55
  )
30
56
 
@@ -43,22 +69,23 @@ FULL_PROMPT = (
43
69
  "- Chemical entities: compound names (generic, IUPAC, code names, brand names), "
44
70
  "SMILES strings (only if explicitly written), CAS numbers, molecular formulas.\n"
45
71
  "- Biological targets: protein names, gene names, receptor names, enzyme names, pathways.\n"
46
- "- Bioactivity data: IC50, MIC, EC50, Ki, Kd, and other potency/selectivity measurements "
47
- "with their numeric values, units, and the compound name or identifier they apply to "
48
- "(captured as a 'compound_name' attribute).\n"
72
+ "- Bioactivity data: potency, selectivity, cytotoxicity and efficacy values "
73
+ "(attributes below).\n"
49
74
  "- Assay information: cell lines, assay formats, experimental organisms.\n"
50
75
  "- Diseases and indications: cancer types, disease names, therapeutic areas.\n"
51
76
  "- Mechanisms of action: binding modes, inhibition types, selectivity descriptions.\n"
52
- "Extract only what is explicitly stated; do not infer or generate values."
77
+ + _BIOACTIVITY_ATTRIBUTES
78
+ + _BIOACTIVITY_TABLES
79
+ + "\nExtract only what is explicitly stated; do not infer or generate values."
53
80
  )
54
81
 
55
82
  # ── Tuberculosis early drug discovery prompts ──────────────────────────
56
83
 
57
84
  TB_PROMPT = (
58
85
  "Extract drug discovery entities from this tuberculosis research text.\n\n"
59
- "For each bioactivity measurement (IC50, MIC, MIC90, EC50, CC50, etc.), capture the "
60
- "compound name, code, or identifier it belongs to as a 'compound_name' attribute "
61
- "(e.g. 'BTZ043 showed a MIC of 1 ng/mL' → compound_name: 'BTZ043').\n\n"
86
+ + _BIOACTIVITY_ATTRIBUTES
87
+ + "- strain: the organism or strain tested (H37Rv, Erdman, Pf3D7, Dd2, K1), "
88
+ "null when not stated.\n" + _BIOACTIVITY_TABLES + "\n\n"
62
89
  "DISAMBIGUATION RULES:\n"
63
90
  "- Mycobacterial proteins (e.g. ClpC1, DprE1, InhA, AtpE, MmpL3, QcrB) "
64
91
  "are biological targets, NOT compounds.\n"
@@ -67,6 +94,11 @@ TB_PROMPT = (
67
94
  "- Compound registry and programme identifiers (CHEMBL4521987, ZINC000012345678, "
68
95
  "DB00945, SACC-3060, TBDA-01187, GSK3036656) are compound_name; only Rv locus "
69
96
  "tags, UniProt IDs, PDB codes and RefSeq IDs are accession_number.\n"
97
+ "- A compound with both a document-local label (a number with an optional letter: 7a, "
98
+ "12, 'Compound 9b') and a longer identifier (registry ID; programme, partner or vendor "
99
+ "code: CHEMBL…, SACC-…, a ChemBridge number) is extracted once, as the identifier, with "
100
+ "the label in synonyms; its bioactivities use the identifier as compound_name. A label "
101
+ "is the compound_name only when the document gives nothing else.\n"
70
102
  "- Enzyme descriptions like 'enoyl-ACP reductase' are product, not target.\n"
71
103
  "- 'cell wall', 'lipid metabolism' are functional_category, not mechanism_of_action.\n"
72
104
  "- 'fragment screening', 'biochemical assay' are screening_method, not assay.\n"
@@ -66,7 +66,7 @@ so a deployment's own gazetteer decides which ones apply:
66
66
  | Seed | Auto-derived Pattern | Matches |
67
67
  |---|---|---|
68
68
  | `Rv0005` | `Rv\d{4}[c]?` | All Rv locus tags |
69
- | `MT0005` | `MT\w+` | Mycobrowser IDs |
69
+ | `MT0005` | `MT[A-Z]{0,2}\d{2,}\w*` | Mycobrowser IDs (not MTT, MTX) |
70
70
  | `P9WGR1` | `[OPQ][0-9][A-Z0-9]{3}[0-9]` | UniProt accessions |
71
71
  | `4TZK` | `(?=[A-Z0-9]{0,3}[A-Z])[1-9][A-Z0-9]{3}` | PDB codes |
72
72
  | `WP_003407354` | `WP_\d+` | NCBI RefSeq proteins |
@@ -89,8 +89,13 @@ _COMPOUND_ID_PATTERNS: list[IdPattern] = [
89
89
  _ACCESSION_PATTERNS: list[tuple[re.Pattern[str], re.Pattern[str], str]] = [
90
90
  # Rv locus tags: Rv0005, Rv3854c
91
91
  (re.compile(r"^Rv\d{4}[c]?$"), re.compile(r"\bRv\d{4}[c]?\b"), "Rv locus tag"),
92
- # Mycobrowser MT IDs: MT0005, MTCI00.01
93
- (re.compile(r"^MT\w+$"), re.compile(r"\bMT\w+\b"), "Mycobrowser ID"),
92
+ # Mycobrowser MT IDs: MT0005, MT18B_0001, MTB000001. The digit run keeps
93
+ # MTT (viability assay), MTX and MTD from matching.
94
+ (
95
+ re.compile(r"^MT[A-Z]{0,2}\d{2,}\w*$"),
96
+ re.compile(r"\bMT[A-Z]{0,2}\d{2,}\w*\b"),
97
+ "Mycobrowser ID",
98
+ ),
94
99
  # UniProt accessions: P9WGR1, O53617
95
100
  (
96
101
  re.compile(r"^[OPQ][0-9][A-Z0-9]{3}[0-9]$"),
@@ -275,8 +275,9 @@ def _build_position_map(original: str, normalized: str) -> list[int]:
275
275
 
276
276
  # Advance original pointer to find the matching character
277
277
  while orig_idx < len(original):
278
- orig_lower = original[orig_idx].lower()
279
- if orig_lower == norm_char:
278
+ # normalize() the char too: a newline became " " and an en dash "-",
279
+ # and comparing raw chars would desync every position after them.
280
+ if (normalize(original[orig_idx]) or " ") == norm_char:
280
281
  position_map.append(orig_idx)
281
282
  orig_idx += 1
282
283
  break
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock
7
7
  import langextract as lx
8
8
 
9
9
  from structflo.ner import (
10
+ BIOACTIVITY,
10
11
  BIOLOGY,
11
12
  CHEMISTRY,
12
13
  FULL,
@@ -284,8 +285,21 @@ class TestTBProfile:
284
285
  for cls in TB_BIOLOGY.entity_classes:
285
286
  assert cls in TB.entity_classes
286
287
 
288
+ def test_bioactivity_schema_has_assay(self):
289
+ """langextract builds the output schema from example attribute keys, so an
290
+ `assay` slot exists only if some bioactivity example carries one."""
291
+ for profile in (TB, BIOACTIVITY, FULL):
292
+ keys = {
293
+ k
294
+ for ex in profile.examples
295
+ for e in ex.extractions
296
+ if e.extraction_class == "bioactivity"
297
+ for k in (e.attributes or {})
298
+ }
299
+ assert {"value", "unit", "assay_type", "compound_name", "assay"} <= keys, profile.name
300
+
287
301
  def test_tb_examples_count(self):
288
- assert len(TB.examples) == 5
302
+ assert len(TB.examples) == 6
289
303
  assert len(TB_CHEMISTRY.examples) == 2
290
304
  assert len(TB_BIOLOGY.examples) == 2
291
305
 
@@ -180,6 +180,13 @@ class TestDeriveIdPatterns:
180
180
  assert not pdb.regex.search("in 2019 the trial")
181
181
  assert not pdb.regex.search("n = 1234")
182
182
 
183
+ def test_mycobrowser_pattern_needs_locus_digits(self):
184
+ """MTT (viability assay), MTX and MTD are not Mycobrowser loci."""
185
+ mt = next(p for p in _accessions(["MT18B_0001"]) if p.description == "Mycobrowser ID")
186
+ for locus in ["MT0005", "MT18B_0001", "MTB000001"]:
187
+ assert mt.regex.fullmatch(locus), locus
188
+ assert not mt.regex.search("HepG2 MTT, MTX and MTD read-outs")
189
+
183
190
  def test_cas_check_digit_rejects_dates(self):
184
191
  cas = next(p for p in derive_id_patterns({}) if p.description == "CAS number")
185
192
  assert cas.validate("50-78-2") # aspirin
@@ -246,6 +253,13 @@ class TestGazetteerMatcher:
246
253
  m = inha_matches[0]
247
254
  assert text[m.char_start : m.char_end] == "InhA"
248
255
 
256
+ def test_char_offsets_survive_newlines(self):
257
+ """normalize() folds a newline into a space; offsets after it must not drift."""
258
+ matcher = self._simple_matcher(fuzzy_threshold=0)
259
+ text = "Targets:\nINHA and\n\nDPRE1."
260
+ found = {m.canonical: text[m.char_start : m.char_end] for m in matcher.match(text)}
261
+ assert found == {"InhA": "INHA", "DprE1": "DPRE1"}
262
+
249
263
  def test_regex_accession_matching(self):
250
264
  matcher = GazetteerMatcher(
251
265
  gazetteers={"target": ["InhA"]},
@@ -917,7 +917,7 @@ wheels = [
917
917
 
918
918
  [[package]]
919
919
  name = "langextract"
920
- version = "1.6.0"
920
+ version = "1.7.0"
921
921
  source = { registry = "https://pypi.org/simple" }
922
922
  dependencies = [
923
923
  { name = "absl-py" },
@@ -940,9 +940,9 @@ dependencies = [
940
940
  { name = "tqdm" },
941
941
  { name = "typing-extensions" },
942
942
  ]
943
- sdist = { url = "https://files.pythonhosted.org/packages/49/c2/30216c49a53419bf3c124cb3ea6daba95a617a62e5ea4964b9fb11784f1e/langextract-1.6.0.tar.gz", hash = "sha256:ac6412152b173bfb0d6b4e08bf6ace7319b1e734c4f9c6f33d39f0c5915e640d", size = 142637, upload-time = "2026-07-02T06:24:00.632Z" }
943
+ sdist = { url = "https://files.pythonhosted.org/packages/e3/e7/6cf877cdd3d5d3a479c0688ff2b63c665c4319ae35ba00734c9fef36a1d2/langextract-1.7.0.tar.gz", hash = "sha256:cb6dc515583b0fdc0f9eff9dce61b2a3c7c84b0668add4dd4e7be364f4c188ba", size = 147514, upload-time = "2026-09-13T18:58:06.376Z" }
944
944
  wheels = [
945
- { url = "https://files.pythonhosted.org/packages/d7/e0/3475ffd1c7e2a4a49f3d8c186c27869a53c710335742964707dbc499590e/langextract-1.6.0-py3-none-any.whl", hash = "sha256:ffa2d584c29ea25c73a2723cec756f21a76799e8ab76a1d9d92841e27504b057", size = 150147, upload-time = "2026-07-02T06:23:59.25Z" },
945
+ { url = "https://files.pythonhosted.org/packages/ae/ec/2883a93872701045971ea77e30434d72556a557f188df575b154d179854c/langextract-1.7.0-py3-none-any.whl", hash = "sha256:908f8ec696ab13578cdc3862efcf1a4fda929ae426410d99e86977d50e52136d", size = 154297, upload-time = "2026-09-13T18:58:04.896Z" },
946
946
  ]
947
947
 
948
948
  [[package]]
File without changes
File without changes