|
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,167 @@ 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 | + # 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}) |
0 commit comments