|
3 | 3 | from markupsafe import escape |
4 | 4 | from api import db |
5 | 5 | 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 |
8 | 9 | from marshmallow import Schema, ValidationError, fields as marshmallow_fields |
9 | 10 | import json |
| 11 | +import re |
10 | 12 |
|
11 | 13 | gaia = Namespace("Gaia", description="Gaia", path="/gaia") |
12 | 14 |
|
@@ -174,3 +176,168 @@ def post(self): |
174 | 176 |
|
175 | 177 | # Return final data |
176 | 178 | 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 | + |
| 186 | + # Escape input |
| 187 | + identifier = escape(identifier) |
| 188 | + |
| 189 | + # Is it valid |
| 190 | + if not BARUtils.is_gaia_alias(identifier): |
| 191 | + return BARUtils.error_exit("Invalid identifier"), 400 |
| 192 | + |
| 193 | + # Resolve to gene ids: try alias first, then locus / ncbi id |
| 194 | + rows = db.session.execute(db.select(Aliases.genes_id).filter(Aliases.alias == identifier)).fetchall() |
| 195 | + gene_ids = [r.genes_id for r in rows] |
| 196 | + |
| 197 | + if not gene_ids: |
| 198 | + rows = db.session.execute( |
| 199 | + db.select(Genes.id).filter(or_(Genes.locus == identifier, Genes.geneid == identifier)) |
| 200 | + ).fetchall() |
| 201 | + gene_ids = [r.id for r in rows] |
| 202 | + |
| 203 | + if not gene_ids: |
| 204 | + return BARUtils.error_exit("Nothing found"), 404 |
| 205 | + |
| 206 | + # Get the gene's full alias set |
| 207 | + aliases = [ |
| 208 | + r.alias.lower() |
| 209 | + for r in db.session.execute(db.select(Aliases.alias).filter(Aliases.genes_id.in_(gene_ids))).fetchall() |
| 210 | + ] |
| 211 | + |
| 212 | + # Match OCR words: word-boundary regex for long aliases, exact match for short ones |
| 213 | + long_aliases = sorted({re.escape(a) for a in aliases if len(a) >= 4}) |
| 214 | + short_aliases = sorted({a for a in aliases if len(a) < 4}) |
| 215 | + |
| 216 | + # No usable aliases, nothing to match on |
| 217 | + if not long_aliases and not short_aliases: |
| 218 | + return BARUtils.success_exit({"figures": {}, "allImageWords": {}}) |
| 219 | + |
| 220 | + word_expr = func.lower(func.json_unquote(func.json_extract(FigureModels.data, "$.word"))) |
| 221 | + match_conds = [] |
| 222 | + if long_aliases: |
| 223 | + alias_re = "(^|[^a-z0-9])(" + "|".join(long_aliases) + ")([^a-z0-9]|$)" |
| 224 | + match_conds.append(word_expr.regexp_match(alias_re)) |
| 225 | + if short_aliases: |
| 226 | + match_conds.append(word_expr.in_(short_aliases)) |
| 227 | + |
| 228 | + matched_rows = db.session.execute(db.select(FigureModels.data).where(or_(*match_conds))).fetchall() |
| 229 | + if not matched_rows: |
| 230 | + return BARUtils.success_exit({"figures": {}, "allImageWords": {}}) |
| 231 | + |
| 232 | + # Collect each matched image and its boxes (keep the image even if a box is missing) |
| 233 | + bbox_by_name = {} |
| 234 | + for row in matched_rows: |
| 235 | + d = row.data if isinstance(row.data, dict) else json.loads(row.data) |
| 236 | + for img in d.get("image", []): |
| 237 | + name = (img.get("imageName") or "").lstrip("/") |
| 238 | + if not name: |
| 239 | + continue |
| 240 | + bbox_list = bbox_by_name.setdefault(name, []) |
| 241 | + bbox = img.get("bbox") |
| 242 | + if bbox is not None: |
| 243 | + bbox_list.append(bbox) |
| 244 | + |
| 245 | + stripped_names = list(bbox_by_name.keys()) |
| 246 | + if not stripped_names: |
| 247 | + return BARUtils.success_exit({"figures": {}, "allImageWords": {}}) |
| 248 | + |
| 249 | + # Drop image names used by more than one publication, we can't attribute those |
| 250 | + collision = ( |
| 251 | + db.select(Figures.img_name) |
| 252 | + .group_by(Figures.img_name) |
| 253 | + .having(func.count(func.distinct(Figures.publication_figures_id)) > 1) |
| 254 | + ) |
| 255 | + |
| 256 | + # Pull the figures and their publication info, skip null urls, newest pubmed first |
| 257 | + core_stmt = ( |
| 258 | + db.select( |
| 259 | + PubIds.pmc, |
| 260 | + PubIds.pubmed, |
| 261 | + PublicationFigures.id.label("pf_id"), |
| 262 | + PublicationFigures.title, |
| 263 | + PublicationFigures.abstract, |
| 264 | + Figures.img_name, |
| 265 | + Figures.img_url, |
| 266 | + Figures.caption, |
| 267 | + ) |
| 268 | + .select_from(Figures) |
| 269 | + .join(PublicationFigures, PublicationFigures.id == Figures.publication_figures_id) |
| 270 | + .join(PubIds, PubIds.publication_figures_id == PublicationFigures.id) |
| 271 | + .where(Figures.img_name.in_(stripped_names)) |
| 272 | + .where(Figures.img_url.isnot(None)) |
| 273 | + .where(Figures.img_name.not_in(collision)) |
| 274 | + .order_by(cast(PubIds.pubmed, mysql.INTEGER(unsigned=True)).desc()) |
| 275 | + ) |
| 276 | + fig_rows = db.session.execute(core_stmt).fetchall() |
| 277 | + |
| 278 | + if not fig_rows: |
| 279 | + return BARUtils.success_exit({"figures": {}, "allImageWords": {}}) |
| 280 | + |
| 281 | + # Group figures by PMC, one entry per image name |
| 282 | + figures_by_pmc, pmc_to_pf, pf_ids, seen_names = {}, {}, set(), set() |
| 283 | + for r in fig_rows: |
| 284 | + pf_ids.add(r.pf_id) |
| 285 | + pmc_to_pf[r.pmc] = r.pf_id |
| 286 | + if r.pmc not in figures_by_pmc: |
| 287 | + figures_by_pmc[r.pmc] = { |
| 288 | + "title": r.title, |
| 289 | + "abstract": r.abstract, |
| 290 | + "authors": [], |
| 291 | + "pubmed": r.pubmed, |
| 292 | + "figures": [], |
| 293 | + } |
| 294 | + if r.img_name in seen_names: |
| 295 | + continue |
| 296 | + seen_names.add(r.img_name) |
| 297 | + figures_by_pmc[r.pmc]["figures"].append( |
| 298 | + { |
| 299 | + "img_name": r.img_name, |
| 300 | + "img_url": r.img_url, |
| 301 | + "caption": r.caption, |
| 302 | + "bbox": bbox_by_name.get(r.img_name, []), |
| 303 | + } |
| 304 | + ) |
| 305 | + |
| 306 | + # Attach authors to each publication |
| 307 | + authors_by_pf = {} |
| 308 | + for r in db.session.execute( |
| 309 | + db.select(AuthorList.publication_figures_id, AuthorList.author).filter( |
| 310 | + AuthorList.publication_figures_id.in_(pf_ids) |
| 311 | + ) |
| 312 | + ).fetchall(): |
| 313 | + authors_by_pf.setdefault(r.publication_figures_id, []).append(r.author) |
| 314 | + for pmc, pf_id in pmc_to_pf.items(): |
| 315 | + figures_by_pmc[pmc]["authors"] = authors_by_pf.get(pf_id, []) |
| 316 | + |
| 317 | + # allImageWords: gene words detected on the shown figures, for the gene-name filter |
| 318 | + displayed_names = list({r.img_name for r in fig_rows}) |
| 319 | + all_image_words = {} |
| 320 | + if displayed_names: |
| 321 | + displayed_slashed = json.dumps(["/" + n for n in displayed_names]) # stored names keep a leading / |
| 322 | + words_rows = db.session.execute( |
| 323 | + db.select(FigureModels.data) |
| 324 | + .where(func.json_unquote(func.json_extract(FigureModels.data, "$.gene")) == "true") |
| 325 | + .where( |
| 326 | + func.json_overlaps( |
| 327 | + func.json_extract(FigureModels.data, "$.image[*].imageName"), |
| 328 | + cast(literal(displayed_slashed), mysql.JSON), |
| 329 | + ) |
| 330 | + ) |
| 331 | + ).fetchall() |
| 332 | + displayed_set = set(displayed_names) |
| 333 | + for row in words_rows: |
| 334 | + d = row.data if isinstance(row.data, dict) else json.loads(row.data) |
| 335 | + word = (d.get("word") or "").lower() |
| 336 | + for img in d.get("image", []): |
| 337 | + name = (img.get("imageName") or "").lstrip("/") |
| 338 | + if name in displayed_set: |
| 339 | + bbox = img.get("bbox") |
| 340 | + all_image_words.setdefault(word, {})[name] = bbox if bbox is not None else [] |
| 341 | + |
| 342 | + # Return final data |
| 343 | + return BARUtils.success_exit({"figures": figures_by_pmc, "allImageWords": all_image_words}) |
0 commit comments