Skip to content

Commit 70a57d0

Browse files
committed
Add gaia publication_figures_by_gene endpoint + wire gaia for tests
GET /gaia/publication_figures_by_gene/<identifier> resolves a gene to its alias set, matches OCR-detected figure words (word-boundary regex for aliases >=4 chars, exact match for <=3), and returns figures grouped by PMC with img_url/caption/bbox plus allImageWords. Includes the bare-name collision guard, null-url skip, and numeric-pubmed ordering. Implemented in pure SQLAlchemy Core (gaia bind inferred from models; no raw text()) similar fashion to our typical endpoints. - models: add AuthorList + FigureModels (reuse existing gaia models) - wire gaia for local/CI: SQLALCHEMY_BINDS entry, init.sh load line, and a curated config/databases/gaia.sql test fixture - tests: test_gaia.py with a skip-unless-MySQL guard so the MySQL-only endpoint skips (not errors) under the SQLite test harness
1 parent 71e5a83 commit 70a57d0

6 files changed

Lines changed: 506 additions & 3 deletions

File tree

‎api/models/gaia.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,22 @@ class Figures(db.Model):
6060
img_name: db.Mapped[str] = db.mapped_column(db.String(64), nullable=False)
6161
caption: db.Mapped[str] = db.mapped_column(db.Text, nullable=True)
6262
img_url: db.Mapped[str] = db.mapped_column(db.String(256), nullable=True)
63+
64+
65+
class AuthorList(db.Model):
66+
__bind_key__ = "gaia"
67+
__tablename__ = "author_list"
68+
69+
id: db.Mapped[int] = db.mapped_column(db.Integer, nullable=False, primary_key=True)
70+
publication_figures_id: db.Mapped[int] = db.mapped_column(
71+
ForeignKey("publication_figures.id", ondelete="CASCADE"), nullable=False
72+
)
73+
author: db.Mapped[str] = db.mapped_column(db.String(128), nullable=False)
74+
75+
76+
class FigureModels(db.Model):
77+
__bind_key__ = "gaia"
78+
__tablename__ = "figure_models"
79+
80+
id: db.Mapped[int] = db.mapped_column(db.Integer, nullable=False, primary_key=True)
81+
data: db.Mapped[dict] = db.mapped_column(db.JSON, nullable=True)

‎api/resources/gaia.py‎

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
from markupsafe import escape
44
from api import db
55
from api.utils.bar_utils import BARUtils
6-
from api.models.gaia import Genes, Aliases, PubIds, Figures
7-
from sqlalchemy import func, or_
6+
from api.models.gaia import Genes, Aliases, PublicationFigures, PubIds, Figures, AuthorList, FigureModels
7+
from sqlalchemy import func, or_, cast, literal
8+
from sqlalchemy.dialects import mysql
89
from marshmallow import Schema, ValidationError, fields as marshmallow_fields
910
import json
11+
import re
1012

1113
gaia = Namespace("Gaia", description="Gaia", path="/gaia")
1214

@@ -174,3 +176,167 @@ def post(self):
174176

175177
# Return final data
176178
return BARUtils.success_exit(data)
179+
180+
181+
@gaia.route("/publication_figures_by_gene/<string:identifier>")
182+
class GaiaPublicationFiguresByGene(Resource):
183+
@gaia.param("identifier", _in="path", default="ABI3")
184+
def get(self, identifier=""):
185+
# Escape input and validate
186+
identifier = escape(identifier)
187+
if not BARUtils.is_gaia_alias(identifier):
188+
return BARUtils.error_exit("Invalid identifier"), 400
189+
190+
# --- Resolve identifier -> gene id(s) -> the gene's full alias set ---
191+
rows = db.session.execute(db.select(Aliases.genes_id).filter(Aliases.alias == identifier)).fetchall()
192+
gene_ids = [r.genes_id for r in rows]
193+
194+
if not gene_ids: # not an alias - try locus / ncbi geneid
195+
rows = db.session.execute(
196+
db.select(Genes.id).filter(or_(Genes.locus == identifier, Genes.geneid == identifier))
197+
).fetchall()
198+
gene_ids = [r.id for r in rows]
199+
200+
if not gene_ids:
201+
return BARUtils.error_exit("Nothing found"), 404
202+
203+
aliases = [
204+
r.alias.lower()
205+
for r in db.session.execute(db.select(Aliases.alias).filter(Aliases.genes_id.in_(gene_ids))).fetchall()
206+
]
207+
208+
# --- Build the OCR word-boundary match (V1-resolved policy, no gene:true filter) ---
209+
# Aliases >= 4 chars: whole-token regex match (catches "abi3/vp1", rejects "gabi390_r").
210+
# Aliases <= 3 chars: exact IN(...) - a boundary match on a short token over-matches.
211+
long_aliases = sorted({re.escape(a) for a in aliases if len(a) >= 4})
212+
short_aliases = sorted({a for a in aliases if len(a) < 4})
213+
214+
# Gene resolved but no usable aliases -> empty payload (frontend falls back to PubMed feed).
215+
if not long_aliases and not short_aliases:
216+
return BARUtils.success_exit({"figures": {}, "allImageWords": {}})
217+
218+
# --- Step 1: match on the scalar OCR word (pure Core; no array explosion) ---
219+
word_expr = func.lower(func.json_unquote(func.json_extract(FigureModels.data, "$.word")))
220+
match_conds = []
221+
if long_aliases:
222+
alias_re = "(^|[^a-z0-9])(" + "|".join(long_aliases) + ")([^a-z0-9]|$)"
223+
match_conds.append(word_expr.regexp_match(alias_re))
224+
if short_aliases:
225+
match_conds.append(word_expr.in_(short_aliases))
226+
227+
matched_rows = db.session.execute(db.select(FigureModels.data).where(or_(*match_conds))).fetchall()
228+
if not matched_rows:
229+
return BARUtils.success_exit({"figures": {}, "allImageWords": {}})
230+
231+
# --- Step 2: unpack matched rows' image[] in Python -> name -> [bbox] (data: dict or str) ---
232+
bbox_by_name = {}
233+
for row in matched_rows:
234+
d = row.data if isinstance(row.data, dict) else json.loads(row.data)
235+
for img in d.get("image", []):
236+
name = (img.get("imageName") or "").lstrip("/")
237+
if name:
238+
bbox_by_name.setdefault(name, []).append(img.get("bbox"))
239+
240+
stripped_names = list(bbox_by_name.keys())
241+
if not stripped_names:
242+
return BARUtils.success_exit({"figures": {}, "allImageWords": {}})
243+
244+
# --- Step 3: figures + publication metadata (pure Core; bbox now comes from Python) ---
245+
# Bare-name guard: exclude any img_name mapping to >1 publication (un-attributable detections).
246+
collision = (
247+
db.select(Figures.img_name)
248+
.group_by(Figures.img_name)
249+
.having(func.count(func.distinct(Figures.publication_figures_id)) > 1)
250+
)
251+
core_stmt = (
252+
db.select(
253+
PubIds.pmc,
254+
PubIds.pubmed,
255+
PublicationFigures.id.label("pf_id"),
256+
PublicationFigures.title,
257+
PublicationFigures.abstract,
258+
Figures.img_name,
259+
Figures.img_url,
260+
Figures.caption,
261+
)
262+
.select_from(Figures)
263+
.join(PublicationFigures, PublicationFigures.id == Figures.publication_figures_id)
264+
.join(PubIds, PubIds.publication_figures_id == PublicationFigures.id)
265+
.where(Figures.img_name.in_(stripped_names))
266+
.where(Figures.img_url.isnot(None))
267+
.where(Figures.img_name.not_in(collision))
268+
.order_by(cast(PubIds.pubmed, mysql.INTEGER(unsigned=True)).desc())
269+
)
270+
fig_rows = db.session.execute(core_stmt).fetchall()
271+
272+
# Valid gene, but no OCR-matched figures -> empty payload (NOT 404).
273+
if not fig_rows:
274+
return BARUtils.success_exit({"figures": {}, "allImageWords": {}})
275+
276+
# --- Step 4: group by PMC; dedupe figures by img_name; attach the full accumulated bbox list ---
277+
# Dedup guard: the collision guard only excludes names spanning >1 publication, so two figures
278+
# rows with the same name under one pf_id could still duplicate. Surviving img_name -> pmc is
279+
# 1:1, so a seen-set on img_name is sufficient; bbox_by_name[name] already holds the full list.
280+
figures_by_pmc, pmc_to_pf, pf_ids, seen_names = {}, {}, set(), set()
281+
for r in fig_rows:
282+
pmc = r.pmc
283+
pf_ids.add(r.pf_id)
284+
pmc_to_pf[pmc] = r.pf_id
285+
if pmc not in figures_by_pmc:
286+
figures_by_pmc[pmc] = {
287+
"title": r.title,
288+
"abstract": r.abstract,
289+
"authors": [],
290+
"pubmed": r.pubmed,
291+
"figures": [],
292+
}
293+
if r.img_name in seen_names:
294+
continue
295+
seen_names.add(r.img_name)
296+
figures_by_pmc[pmc]["figures"].append(
297+
{
298+
"img_name": r.img_name,
299+
"img_url": r.img_url,
300+
"caption": r.caption,
301+
"bbox": bbox_by_name.get(r.img_name, []),
302+
}
303+
)
304+
305+
# --- Step 5: authors per publication (one bulk query; db.select infers the gaia bind) ---
306+
authors_by_pf = {}
307+
for r in db.session.execute(
308+
db.select(AuthorList.publication_figures_id, AuthorList.author).filter(
309+
AuthorList.publication_figures_id.in_(pf_ids)
310+
)
311+
).fetchall():
312+
authors_by_pf.setdefault(r.publication_figures_id, []).append(r.author)
313+
for pmc, pf_id in pmc_to_pf.items():
314+
figures_by_pmc[pmc]["authors"] = authors_by_pf.get(pf_id, [])
315+
316+
# --- Step 6: allImageWords (pure Core JSON_OVERLAPS pre-filter + Python unpack) ---
317+
# gene:true words detected on the displayed figures. JSON_OVERLAPS pre-filters rows that share
318+
# any displayed image; the Python pass keeps only the displayed names. All gaia-bound -> bind inferred.
319+
displayed_names = list({r.img_name for r in fig_rows})
320+
all_image_words = {}
321+
if displayed_names:
322+
displayed_slashed = json.dumps(["/" + n for n in displayed_names]) # re-add slash to match stored
323+
words_rows = db.session.execute(
324+
db.select(FigureModels.data)
325+
.where(func.json_unquote(func.json_extract(FigureModels.data, "$.gene")) == "true")
326+
.where(
327+
func.json_overlaps(
328+
func.json_extract(FigureModels.data, "$.image[*].imageName"),
329+
cast(literal(displayed_slashed), mysql.JSON),
330+
)
331+
)
332+
).fetchall()
333+
displayed_set = set(displayed_names)
334+
for row in words_rows:
335+
d = row.data if isinstance(row.data, dict) else json.loads(row.data)
336+
word = (d.get("word") or "").lower()
337+
for img in d.get("image", []):
338+
name = (img.get("imageName") or "").lstrip("/")
339+
if name in displayed_set:
340+
all_image_words.setdefault(word, {})[name] = img.get("bbox")
341+
342+
return BARUtils.success_exit({"figures": figures_by_pmc, "allImageWords": all_image_words})

‎config/BAR_API.cfg‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,6 @@ SQLALCHEMY_BINDS = {
4242
'striga' : 'mysql://root:root@localhost/striga',
4343
'tomato_nssnp' : 'mysql://root:root@localhost/tomato_nssnp',
4444
'tomato_sequence' : 'mysql://root:root@localhost/tomato_sequence',
45-
'triphysaria' : 'mysql://root:root@localhost/triphysaria'
45+
'triphysaria' : 'mysql://root:root@localhost/triphysaria',
46+
'gaia' : 'mysql://root:root@localhost/gaia'
4647
}

0 commit comments

Comments
 (0)