diff --git a/StartServer.mjs b/StartServer.mjs index 04949d31..f082bf03 100644 --- a/StartServer.mjs +++ b/StartServer.mjs @@ -4,6 +4,7 @@ import { bootstrapConfig } from './lib/config.mjs'; import { loadBiolink } from './lib/biolink-model.mjs'; import { loadChebi } from './lib/chebi.mjs'; +import { loadTrapi } from './lib/trapi.mjs'; import { TranslatorService } from './services/TranslatorService.mjs'; import { TranslatorServicexFEAdapter } from './adapters/TranslatorServicexFEAdapter.mjs'; import { ARSClient } from './lib/ARSClient.mjs'; @@ -33,10 +34,8 @@ const SERVER_CONFIG = await (async function() { return bootstrapConfig(basefile, overrides); })(); -await loadBiolink(SERVER_CONFIG.biolink.version, - SERVER_CONFIG.biolink.support_deprecated_predicates, - SERVER_CONFIG.biolink.infores_catalog, - SERVER_CONFIG.biolink.prefix_catalog); +await loadBiolink(SERVER_CONFIG.biolink); +await loadTrapi(SERVER_CONFIG.trapi); await loadChebi(); // Bootstrap the translator service. diff --git a/adapters/TranslatorServicexFEAdapter.mjs b/adapters/TranslatorServicexFEAdapter.mjs index ea81e2be..0c409e7d 100644 --- a/adapters/TranslatorServicexFEAdapter.mjs +++ b/adapters/TranslatorServicexFEAdapter.mjs @@ -1,7 +1,7 @@ 'use strict'; import * as arsmsg from '../lib/ARSMessages.mjs'; -import * as trapi from '../lib/trapi.mjs'; +import * as sm from '../lib/summarization.mjs'; /* Translate messages coming from the Translator Service into the formats that the Frontend (FE) app expects */ /* This module should not contain logic that goes beyond message transformations */ @@ -49,15 +49,15 @@ class TranslatorServicexFEAdapter { } }); - const summary = await trapi.creativeAnswersToSummary( + const smry = await sm.answersToSmry( msg.pk, data, maxHops); - summary.meta.timestamp = msg.meta.timestamp; + smry.meta.timestamp = msg.meta.timestamp; return { status: determineStatus(msg), - data: summary + data: smry }; } } diff --git a/configurations/ci.json b/configurations/ci.json index d4638ceb..45fd42aa 100644 --- a/configurations/ci.json +++ b/configurations/ci.json @@ -3,6 +3,10 @@ "port": 8386, "response_timeout": 300, "json_payload_limit": "20mb", + "trapi": { + "query_subject_key": "sn", + "query_object_key": "on" + }, "ars_endpoint": { "host": "ars.ci.transltr.io", "post_uri": "/ars/api/submit", diff --git a/configurations/production.json b/configurations/production.json index f3d12f4d..cdf81fdd 100644 --- a/configurations/production.json +++ b/configurations/production.json @@ -3,6 +3,10 @@ "port": 8386, "response_timeout": 300, "json_payload_limit": "1mb", + "trapi": { + "query_subject_key": "sn", + "query_object_key": "on" + }, "ars_endpoint": { "host": "ars-prod.transltr.io", "post_uri": "/ars/api/submit", diff --git a/configurations/test.json b/configurations/test.json index abdf26c0..d6a27495 100644 --- a/configurations/test.json +++ b/configurations/test.json @@ -3,6 +3,10 @@ "port": 8386, "response_timeout": 300, "json_payload_limit": "1mb", + "trapi": { + "query_subject_key": "sn", + "query_object_key": "on" + }, "ars_endpoint": { "host": "ars.test.transltr.io", "post_uri": "/ars/api/submit", diff --git a/lib/biolink-model.mjs b/lib/biolink-model.mjs index e4af4c1f..b38d547b 100644 --- a/lib/biolink-model.mjs +++ b/lib/biolink-model.mjs @@ -9,54 +9,58 @@ let DEPRECATED_TO_QUALIFIED_PREDICATE_MAP = null; let PREFIX_CATALOG = null; let PREFIX_EXCLUDE_LIST = null; -export async function loadBiolink(biolinkVersion, supportDeprecatedPredicates, inforesCatalog, prefixCatalog) { +export async function loadBiolink(biolinkConfig) { + const biolinkVersion = biolinkConfig.version; + const supportDeprecatedPreds = biolinkConfig.support_deprecated_predicates; + const inforesCatalog = biolinkConfig.infores_catalog; + const prefixCatalog = biolinkConfig.prefix_catalog; const biolinkModel = await cmn.readJson(`./assets/biolink-model/${biolinkVersion}/biolink-model.json`); const slots = cmn.jsonGet(biolinkModel, 'slots'); const classes = cmn.jsonGet(biolinkModel, 'classes'); - BIOLINK_PREDICATES = makeBlPredicates(slots); + BIOLINK_PREDICATES = makeBlPreds(slots); BIOLINK_CLASSES = makeBlClasses(classes); INFORES_CATALOG = await cmn.readJson(`./assets/biolink-model/common/${inforesCatalog}`); PREFIX_CATALOG = await cmn.readJson(`./assets/biolink-model/common/${prefixCatalog.path}`); PREFIX_EXCLUDE_LIST = prefixCatalog.exclude; - if (supportDeprecatedPredicates) { + if (supportDeprecatedPreds) { DEPRECATED_TO_QUALIFIED_PREDICATE_MAP = await cmn.readJson(`./assets/biolink-model/${biolinkVersion}/deprecated-predicate-mapping.json`); } } export function tagBiolink(str) { - return biolinkifyPredicate(str); + return biolinkifyPred(str); } -export function isDeprecatedPredicate(s) { +export function isDeprecatedPred(s) { return !!DEPRECATED_TO_QUALIFIED_PREDICATE_MAP && DEPRECATED_TO_QUALIFIED_PREDICATE_MAP[sanitizeBiolinkItem(s)] !== undefined; } -export function isBiolinkPredicate(s) { - const sanitizedPredicate = sanitizeBiolinkItem(s); - return BIOLINK_PREDICATES[sanitizedPredicate] !== undefined || - isDeprecatedPredicate(sanitizedPredicate); +export function isBiolinkPred(s) { + const sanitizedPred = sanitizeBiolinkItem(s); + return BIOLINK_PREDICATES[sanitizedPred] !== undefined || + isDeprecatedPred(sanitizedPred); } export function sanitizeBiolinkItem(pred) { return pred.replaceAll('_', ' ').replaceAll('biolink:', ''); } -export function invertBiolinkPredicate(pred, biolinkify = false) { +export function invertBiolinkPred(pred, biolinkify = false) { const p = sanitizeBiolinkItem(pred); - const biolinkPredicate = cmn.jsonGet(BIOLINK_PREDICATES, p, false); - if (biolinkPredicate) { + const biolinkPred= cmn.jsonGet(BIOLINK_PREDICATES, p, false); + if (biolinkPred) { if (biolinkify) { - return biolinkifyPredicate(biolinkPredicate.inverse); + return biolinkifyPred(biolinkPred.inverse); } - return biolinkPredicate.inverse; + return biolinkPred.inverse; } throw InvalidPredicateError(p); } -export function deprecatedPredicateToPredicateAndQualifiers(predicate) { - const qualifiedPredicate = DEPRECATED_TO_QUALIFIED_PREDICATE_MAP[predicate]; - return [qualifiedPredicate.predicate, qualifiedPredicate]; +export function deprecatedPredToPredAndQualifiers(pred) { + const qualifiedPred = DEPRECATED_TO_QUALIFIED_PREDICATE_MAP[pred]; + return [qualifiedPred.predicate, qualifiedPred]; } export function inforesToProvenance(infores) { @@ -117,13 +121,13 @@ export function curieToUrl(curie) { return `${url}${curieId}`; } -export function predicateToUrl(predicate) { - const p = predicate.replaceAll(' ', '_'); +export function predToUrl(pred) { + const p = pred.replaceAll(' ', '_'); return `https://biolink.github.io/biolink-model/${p}/` } -function InvalidPredicateError(predicate) { - const error = new Error(`Expected a valid biolink predicate. Got: ${predicate}`, 'biolink-model.mjs'); +function InvalidPredicateError(pred) { + const error = new Error(`Expected a valid biolink predicate. Got: ${pred}`, 'biolink-model.mjs'); } InvalidPredicateError.prototype = Object.create(Error.prototype); @@ -164,39 +168,39 @@ function getInverse(pred, record) { return cmn.jsonGet(record, 'inverse', false); } -function distanceFromRelatedTo(slots, predicate) { +function distanceFromRelatedTo(slots, pred) { for (let level = 0; ;level += 1) { - if (predicate === 'related to') { + if (pred === 'related to') { return level; } - let predicateObj = cmn.jsonGet(slots, predicate, false); - if (!predicateObj) { + let predObj = cmn.jsonGet(slots, pred, false); + if (!predObj) { return -1; } - predicate = cmn.jsonGet(predicateObj, 'is_a', false); + pred = cmn.jsonGet(predObj, 'is_a', false); } } -function makeBlPredicate(predicate, record, rank) { +function makeBlPred(pred, record, rank) { return { - 'parent': getParent(predicate, record), - 'isCanonical': isCanonical(predicate, record), - 'isSymmetric': isSymmetric(predicate, record), - 'isDeprecated': isDeprecated(predicate, record), - 'inverse': getInverse(predicate, record), + 'parent': getParent(pred, record), + 'isCanonical': isCanonical(pred, record), + 'isSymmetric': isSymmetric(pred, record), + 'isDeprecated': isDeprecated(pred, record), + 'inverse': getInverse(pred, record), 'rank': rank, }; } -function makeBlPredicates(slots) { +function makeBlPreds(slots) { let blPreds = {}; Object.keys(slots).forEach((pred) => { const rank = distanceFromRelatedTo(slots, pred); if (rank >= 0) { const record = slots[pred]; - blPreds[pred] = makeBlPredicate(pred, record, rank); + blPreds[pred] = makeBlPred(pred, record, rank); } }); @@ -213,7 +217,7 @@ function makeBlPredicates(slots) { return blPreds; } -function biolinkifyPredicate(pred) { +function biolinkifyPred(pred) { let s = pred.replaceAll(' ', '_'); if (s.startsWith('biolink:')) { return s; @@ -222,20 +226,20 @@ function biolinkifyPredicate(pred) { return `biolink:${s}`; } -function getBiolinkPredicateData(pred) { +function getBiolinkPredData(pred) { return cmn.jsonGet(BIOLINK_PREDICATES, sanitizeBiolinkItem(pred), false); } -function isBiolinkPredicateMoreSpecific(pred1, pred2) { +function isBiolinkPredMoreSpecific(pred1, pred2) { const p1 = sanitizeBiolinkItem(pred1); const p2 = sanitizeBiolinkItem(pred2); - const biolinkPredicate1 = getBiolinkPredicateData(p1); - const biolinkPredicate2 = getBiolinkPredicateData(p2); + const biolinkPred1 = getBiolinkPredData(p1); + const biolinkPred2 = getBiolinkPredData(p2); - if (!biolinkPredicate1) { + if (!biolinkPred1) { throw InvalidPredicateError(p1); } - else if (!biolinkPredicate2) { + else if (!biolinkPred2) { throw InvalidPredicateError(p2); } else { @@ -243,8 +247,8 @@ function isBiolinkPredicateMoreSpecific(pred1, pred2) { } } -function sortBiolinkPredicates(preds) { - return sort(pres, isBiolinkPredicateMoreSpecific); +function sortBiolinkPreds(preds) { + return sort(pres, isBiolinkPredMoreSpecific); } /* Biolink category-related functions diff --git a/lib/biothings-annotation.mjs b/lib/biothings-annotation.mjs index 2a06e850..84a86c32 100644 --- a/lib/biothings-annotation.mjs +++ b/lib/biothings-annotation.mjs @@ -6,9 +6,9 @@ import * as chebi from './chebi.mjs'; export function getFdaApproval(annotation) { return parseAnnotation( annotation, - noHandler, + genDefaultHandler(), getChemicalFdaApproval, - noHandler); + genDefaultHandler()); } export function getDescription(annotation) { @@ -22,37 +22,40 @@ export function getDescription(annotation) { export function getChebiRoles(annotation) { return parseAnnotation( annotation, - noHandler, + genDefaultHandler(), getChemicalChebiRoles, - noHandler); + genDefaultHandler()); } export function getDrugIndications(annotation) { + const defaultHandler = genDefaultHandler([]); return parseAnnotation( annotation, - noHandler, + defaultHandler, getChemicalDrugIndications, - noHandler); + defaultHandler, + defaultHandler); } export function getCuries(annotation) { + const defaultHandler = genDefaultHandler([]); return parseAnnotation( annotation, getDiseaseMeshCuries, - noHandler, - noHandler); + defaultHandler, + defaultHandler, + defaultHandler); } export function getNames(annotation) { return parseAnnotation( annotation, - noHandler, + genDefaultHandler(), getChemicalNames, - noHandler); + genDefaultHandler()); } -function parseAnnotation(annotation, diseaseHandler, chemicalHandler, geneHandler, fallback = null) { - annotation = annotation[0]; +function parseAnnotation(annotation, diseaseHandler, chemicalHandler, geneHandler, defaultHandler = genDefaultHandler()) { if (isDisease(annotation)) { return diseaseHandler(annotation); } @@ -65,7 +68,7 @@ function parseAnnotation(annotation, diseaseHandler, chemicalHandler, geneHandle return geneHandler(annotation); } - return fallback; + return defaultHandler(); } function getDiseaseDescription(annotation) { @@ -82,7 +85,6 @@ function getDiseaseMeshCuries(annotation) { ['mondo', 'xrefs', 'mesh'], ['disease_ontology', 'xrefs', 'mesh'] ]; - const curies = []; for (const path of paths) { const curie = cmn.jsonGetFromKpath(annotation, path, null); @@ -90,7 +92,6 @@ function getDiseaseMeshCuries(annotation) { curies.push(`MESH:${curie}`); } } - return curies; } @@ -181,6 +182,6 @@ function isGene(annotation) { return annotation.symbol !== undefined; } -function noHandler(annotation) { - return null; +function genDefaultHandler(fallback = null) { + return function() { return fallback }; } diff --git a/lib/common.mjs b/lib/common.mjs index 800bfb50..f7737ec7 100644 --- a/lib/common.mjs +++ b/lib/common.mjs @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as zlib from 'zlib'; import { validate as isUuid } from 'uuid'; +import cloneDeep from 'lodash/cloneDeep.js'; import { randomInt, randomBytes, createHash } from 'crypto'; import { join } from 'path'; @@ -16,8 +17,7 @@ export async function readJson(path) export function deepCopy(o) { - // TODO: Inefficient - return JSON.parse(JSON.stringify(o)); + return cloneDeep(o); } export function capitalize(s) @@ -173,7 +173,7 @@ export function jsonGet(obj, key, fallback = undefined) return fallback } - throw new ReferenceError(`Key: '${key}' not found and no default provided`, 'common.mjs'); + throw new ReferenceError(`Key: '${key}' not found in object '${JSON.stringify(obj)}' and no default provided`, 'common.mjs'); } export function jsonSet(obj, key, v) @@ -279,6 +279,10 @@ export function jsonUpdate(obj, key, update) return jsonSet(obj, key, update(jsonGet(obj, key))); } +export function coerceArray(v) { + return isArray(v) ? v : [v]; +} + export class ServerError extends Error { constructor(message, httpCode) { diff --git a/lib/summarization.mjs b/lib/summarization.mjs new file mode 100644 index 00000000..d8c2fc4d --- /dev/null +++ b/lib/summarization.mjs @@ -0,0 +1,2077 @@ +'use strict'; + +import { default as hash } from 'hash-sum'; +import * as cmn from './common.mjs'; +import * as ev from './evidence.mjs'; +import * as bl from './biolink-model.mjs'; +import * as bta from './biothings-annotation.mjs'; +import * as trapi from './trapi.mjs'; + +const CONSTANTS = { + PUBLICATIONS: 'publications', + SUPPORTING_TEXT: 'supporting_text', + TAGS: 'tags' +}; + +/** + * Responsible for converting a set of TRAPI answers into a summarized form that the FE application can use. + * + * @param {string} qid - The query ID for the given answer set. + * @param {object[]} answers - The set of TRAPI answers to summarize. + * @param {number} maxHops - The maximum number of hops to consider when summarizing the answers. + * + * @returns {object} - The summarized form of the answers. + */ +export function answersToSmry (qid, answers, maxHops) { + if (answers.length < 1) { + return {}; + } + + const nodeRules = makeExtractionRules( + [ + aggregateProperty('name', ['names']), + aggregateProperty('categories', ['types']), + aggregateAttributes([bl.tagBiolink('xref')], 'curies'), + aggregateAttributes([bl.tagBiolink('description')], 'descriptions'), + ]); + + const edgeRules = makeExtractionRules( + [ + transformPropertyRule( + 'predicate', + (obj, key) => bl.sanitizeBiolinkItem(cmn.jsonGet(obj, key))), + aggregateAndTransformProperty( + trapi.CONSTANTS.GRAPH.SOURCES.KEY, + ['provenance'], + (obj, key) => trapi.getPrimarySrc(obj)), + transformPropertyRule('qualifiers', (obj, key) => cmn.jsonGet(obj, key, false)), + getPropertyRule('subject'), + getPropertyRule('object'), + getPubs(), + getSupText() + ]); + + const queryType = answersToQueryTemplate(answers); + const [sfs, errors] = answersToSmryFgmts(answers, nodeRules, edgeRules, maxHops); + return smryFgmtsToSmry( + qid, + sfs, + answersToKgraph(answers), + queryType, + errors); +} + +class Summary { + constructor(meta, results, paths, nodes, edges, pubs, tags, errors) { + this.meta = meta || {}; + this.results = results || []; + this.paths = paths || {}; + this.nodes = nodes || {}; + this.edges = edges || {}; + this.publications = pubs || {}; + this.tags = tags || {}; + this.errors = errors || {}; + } +} + +class SummaryFragment { + constructor(agents, paths, nodes, edges, scores, errors) { + this.agents = agents || []; + this.paths = paths || []; + this.nodes = nodes || []; + this.edges = edges || new SummaryFragmentEdges(); + this.scores = scores || {}; + this.errors = errors || {}; + } + + isEmpty() { + return cmn.isArrayEmpty(this.paths) && + cmn.isArrayEmpty(this.nodes) && + this.edges.isEmpty() + } + + pushScore(rid, scoringComponents) { + const resultScores = cmn.jsonSetDefaultAndGet(this.scores, rid, []); + resultScores.push(scoringComponents); + } + + pushAgent(agent) { + this.agents.push(agent); + } + + pushError(agent, error) { + const currentError = cmn.jsonSetDefaultAndGet(this.errors, agent, []); + currentError.push(error); + } + + merge(smryFgmt) { + this.agents.push(...smryFgmt.agents); + this.paths.push(...smryFgmt.paths); + this.nodes.push(...smryFgmt.nodes); + this.edges.merge(smryFgmt.edges); + this._mergeFgmtObjects(this.scores, smryFgmt.scores); + this._mergeFgmtObjects(this.errors, smryFgmt.errors); + return this + } + + _mergeFgmtObjects(obj1Prop, obj2Prop) { + Object.keys(obj2Prop).forEach((k) => { + const current = cmn.jsonSetDefaultAndGet(obj1Prop, k, []); + current.push(...obj2Prop[k]); + }); + } +} + +function makeErrSmryFgmt(agent, error) { + const smryFgmt = new SummaryFragment(); + smryFgmt.pushAgent(agent); + smryFgmt.pushError(agent, error); + return smryFgmt; +} + +class SummaryFragmentEdges { + constructor(base, updates) { + this.base = base || {}; + this.updates = updates || []; + } + + isEmpty() { + return cmn.isObjectEmpty(this.base) && cmn.isArrayEmpty(this.updates); + } + + merge(smryFgmtEdges) { + Object.keys(smryFgmtEdges.base).forEach((eid) => { + const currentEdge = cmn.jsonSetDefaultAndGet(this.base, eid, new SummaryEdge()); + currentEdge.merge(smryFgmtEdges.base[eid]); + }); + + this.updates.push(...smryFgmtEdges.updates); + return this; + } +} + +class SummaryNode { + constructor(agents) { + this.aras = agents || []; + this.curies = []; + this.descriptions = []; + this.names = []; + this.other_names = []; + this.provenance = [] + this.tags = {} + this.types = []; + } + + name() { return (cmn.isArrayEmpty(this.names) ? this.curies[0] : this.names[0]); } + get otherNames () { return this.other_names; } + set otherNames (otherNames) { this.other_names = otherNames; } + + get type() { + // TODO: We should inject 'Named Thing' as a type if its empty + if (cmn.isArrayEmpty(this.types)) { + return 'Named Thing'; // TODO: Should be a biolink constant + } + + return bl.sanitizeBiolinkItem(this.types[0]); + } + + extendAgents(agents) { + this.aras.concat(agents) + } +} + +class SummaryEdge { + constructor(agents, supPaths, isRootPath) { + this.aras = agents || []; + this.support = supPaths || []; + this.is_root = isRootPath || false; + this.knowledge_level = null; + this.subject = null; + this.object = null; + this.predicate = null; + this.predicate_url = null; + this.provenance = []; + this.publications = {}; + } + + // TODO: Should this be a property of the path instead of the edge? + get isRootPath() { return this.is_root; } + set isRootPath(isRootPath) { this.is_root = isRootPath; } + get supPaths() { return this.support; } + set supPaths(supPaths) { this.support = supPaths; } + get knowledgeLevel() { return this.knowledge_level; } + set knowledgeLevel(kl) { this.knowledge_level = kl; } + get predUrl() { return this.predicate_url; } + set predUrl(predUrl) { this.predicate_url = predUrl; } + + hasSupport() { return !cmn.isArrayEmpty(this.supPaths); } + extendAgents(agents) { + this.aras.concat(agents) + } + + extendSupPaths(paths) { + this.supPaths.push(...paths); + } + + merge(smryEdge) { + this.extendAgents(smryEdge.aras); + this.extendSupPaths(smryEdge.supPaths); + this.isRootPath = this.isRootPath || smryEdge.isRootPath; + this.knowledgeLevel = this.knowledgeLevel || smryEdge.knowledgeLevel; + this.subject = this.subject || smryEdge.subject; + this.predicate = this.predicate || smryEdge.predicate; + this.object = this.object || smryEdge.object; + this.predUrl = this.predUrl || smryEdge.predUrl; + this.provenance.push(...smryEdge.provenance); + Object.keys(smryEdge.publications).forEach((kl) => { + if (!this.publications[kl]) { + this.publications[kl] = []; + } + + this.publications[kl].push(...smryEdge.publications[kl]); + }); + + return this + } +} + +class SummaryPath { + constructor(subgraph, agents) { + this.subgraph = subgraph; + this.aras = agents; + this.tags = {}; + } + + extendAgents(agents) { + this.aras.concat(agents); + } + + get agents() { return this.aras; } + get length() { return this.subgraph.length; } + get nodeCount() { + if (this.subgraph.length === 0) return 0; + return Math.floor(this.length/2)+1; + } + + get edgeCount() { return (this.length-1)/2; } + get graph() { return this.subgraph; } + get start() { return this.subgraph[0]; } + get end() { return this.subgraph[this.length-1]; } + + nid(i) { return this.subgraph[i*2]; } + forNids(func) { this._forIds(func, 0, this.length); } + forInternalNids(func) { this._forIds(func, 2, this.length-1); } + eid(i) { return this.subgraph[(i*2)+1]; } + forEids(func) { this._forIds(func, 1, this.length); } + + _forIds(func, start, end) { + for (let i = start; i < end; i+=2) { + func(this.subgraph[i]); + } + } +} + +function smryPathCompare(smryPath1, smryPath2) { + const len1 = smryPath1.length; + const len2 = smryPath2.length; + if (len1 === len2) { + for (let i = 0; i < smryPath1.nodeCount; i++) { + if (smryPath1.nid(i) < smryPath2.nid(i)) return -1; + else if (smryPath1.nid(i) > smryPath2.nid(i)) return 1; + } + + return 0; + } + + if (len1 < len2) return -1; + return 1; +} + +class SummaryMetadata { + constructor(qid, agents) { + if (qid === undefined || !cmn.isString(qid)) { + throw new TypeError(`Expected argument qid to be of type string, got: ${qid}`); + } + + if (agents === undefined || !cmn.isArray(agents)) { + throw new TypeError(`Expected argument agents to be type array, got: ${agents}`); + } + + this.qid = qid; + this.aras = agents; + } +} + +class SummaryPublication { + constructor(type, url, src) { + this.type = type; + this.url = url; + this.source = src; + } +} + +/* + * Determine the query template type based on a set of answers. + * + * @param {object} answers - The answers to determine the query type from. + * + * @returns {number} - The query type. + */ +function answersToQueryTemplate(answers) { + // TODO: A more robust solution might be possible but all answers should have the same query type. + return trapi.messageToQueryTemplate(answers[0]); // This assumes a fully merged TRAPI message +} + +function answersToKgraph(answers) { + return trapi.getKgraph(answers[0]); // This assumes a fully merged TRAPI message +} + +function inforesToName(infores) { + return bl.inforesToProvenance(infores).name; +} + +/* Constructs a rule on how to extract a property from a source object. There are 3 different stages to an extraction rule: + * 1. Definition: This is what this function does. + * 2. Application: The rule can by applied to source object with some context which will produce a transformer function. + * 3. Transformation: Once the target object is known, the actual transformation can be applied to modify the target object. + * + * @param {string} key - The key to extract from an object. + * @param {function} transform - The transformation to apply to the extracted value. + * @param {function} update - How to update the accumulator with the extracted value. + * @param {object} defaultValue - The default value to use if the extraction fails. + * + * @returns {function} - The extraction rule. + */ +function makeExtractionRule(key, transform, update, defaultValue) { + return (src, cxt) => { + return (target) => { + try { + const v = transform(src, key, cxt); + return update(v, target); + } catch (e) { + const agentErrors = cmn.jsonSetDefaultAndGet(cxt.errors, cxt.agent, []); + agentErrors.push(e.message); + return update(defaultValue, target); + } + } + } +} + +/* Constructs a rule for extracting a property from a Graph Element, transforming it, and placing it in the accumuator using the same key. + * + * @param {string} key - The key to extract from a Graph Element and place into the accumulator. + * @param {function} transform - The transformation to apply to the extracted value. + * + * @returns {function} - The extraction rule. + */ +function transformPropertyRule(key, transform) { + return makeExtractionRule( + key, + transform, + (property, acc) => { return cmn.jsonSetFromKpath(acc, [key], property); }, + null); +} + +/* Constructs a rule for extracting a property from a Graph Element and placing it in the accumulator using the same key. + * + * @param {string} key - The key to extract from a Graph Element and place into the accumulator. + * + * @returns {function} - The extraction rule. + */ +function getPropertyRule(key) { + return transformPropertyRule(key, (obj, key) => cmn.jsonGet(obj, key)); +} + +/* Constructs a rule for extracting a property from a Graph Element, transforming it, and aggregating it in the accumulator using the same key. + * + * @param {string} key - The key to extract from a Graph Element and aggregate in the accumulator. + * @param {string[]} kpath - The path to the property in the accumulator. + * @param {function} transform - The transformation to apply to the extracted value. + * + * @returns {function} - The extraction rule. + */ +function aggregateAndTransformProperty(key, kpath, transform) { + return makeExtractionRule( + key, + transform, + (property, acc) => { + const currentValue = cmn.jsonSetDefaultAndGet(acc, kpath, []); + if (property !== null) { + currentValue.push(...cmn.coerceArray(property)); + } + + return acc; + }, + []); +} + +/* Constructs a rule for extracting a property from a Graph Element and aggregating it in the accumulator. + * + * @param {string} key - The key to extract from a Graph Element and aggregate in the accumulator. + * @param {string[]} kpath - The path to the property in the accumulator. + * + * @returns {function} - The extraction rule. + */ +function aggregateProperty(key, kpath) { + return aggregateAndTransformProperty(key, kpath, (obj) => { return cmn.jsonGet(obj, key); }); +} + +/* Constructs a rule for renaming and transforming an attribute from a Graph Element and placing it into an accumulator. + * + * @param {string} attributeId - The Type ID of the attribute to rename and transform. + * @param {string[]} kpath - The path to the property in the accumulator. + * @param {function} transform - The transformation to apply to the extracted value. + * + * @returns {function} - The extraction rule. + */ +function renameAndTransformAttribute(attrId, kpath, transform) { + return makeExtractionRule( + trapi.CONSTANTS.GRAPH.ATTRIBUTES.KEY, + (obj, key) => { + const attrIter = new trapi.AttributeIterator(obj[key]); + const attrVal = attrIter.findOneVal([attrId]); + if (attrVal === null) return null; + return transform(attrVal); + }, + (attr, acc) => { + const currentValue = cmn.jsonGetFromKpath(acc, kpath, false); + if (currentValue && attr === null) { + return acc; + } + + return cmn.jsonSetFromKpath(acc, kpath, attr); + }, + null); +} + +/* Constructs a rule for aggregating and transforming attributes from a Graph Element and placing them into an accumulator. + * + * @param {string[]} attributeIds - The Type IDs of the attributes to aggregate and transform. + * @param {string[]} kpath - The path to the property in the accumulator. + * @param {function} transform - The transformation to apply to the extracted value. + * + * @returns {function} - The extraction rule. + */ +function aggregateAndTransformAttributes(attrIds, accKey, transform) { + return makeExtractionRule( + trapi.CONSTANTS.GRAPH.ATTRIBUTES.KEY, + (obj, key, cxt) => { + const attrIter = new trapi.AttributeIterator(obj[key]); + const result = attrIter.findAllVal(attrIds); + return result.map((v) => { return transform(v, cxt); }).flat(); + }, + (attrs, acc) => { + const currentValue = cmn.jsonSetDefaultAndGet(acc, accKey, []); + currentValue.push(...attrs); + return acc; + }, + []); +} + +/* Constructs a rule for aggregating an attribute from a Graph Element and placing it into an accumulator. + * + * @param {string[]} attributeIds - The Type IDs of the attributes to aggregate. + * @param {string} accKey - The key to aggregate the attributes under in the accumulator. + * + * @returns {function} - The extraction rule. + */ +function aggregateAttributes(attrIds, accKey) { + return aggregateAndTransformAttributes( + attrIds, + accKey, + cmn.identity) +} + +/* A special rule used to generate tags used for faceting from attributes. + * + * @param {string} attributeId - The Type ID of the attribute to generate tags from. + * @param {function} transform - The transformation to apply to the extracted value. + * + * @returns {function} - The extraction rule. + */ +function tagAttribute(attrId, transform) { + return makeExtractionRule( + trapi.CONSTANTS.GRAPH.ATTRIBUTES.KEY, + (obj, key, cxt) => { + const attrIter = new trapi.AttributeIterator(obj[key]); + const result = attrIter.findOneVal([attrId]); + return transform(result, cxt); + }, + (tags, acc) => { + if (!tags) return acc; + const currentTags = getTags(acc); + tags.forEach((tag) => { + if (tag && currentTags[tag.label] === undefined) { + currentTags[tag.label] = tag.description; + } + }); + + return acc; + }, + null); +} + +/* A special rule for extracting supporting text for a publication. + * + * @returns {function} - The extraction rule. + */ +function getSupText() { + const supStudyId = bl.tagBiolink('has_supporting_study_result'); + const pubsId = bl.tagBiolink('publications'); + const textId = bl.tagBiolink('supporting_text'); + const subTokenId = bl.tagBiolink('subject_location_in_text'); + const objTokenId = bl.tagBiolink('object_location_in_text'); + + function parseTokenIndex(token) { + const range = token.split('|').map(t => parseInt(t.trim())); + range[0] = range[0] + 1; + return range; + } + + return makeExtractionRule( + trapi.CONSTANTS.GRAPH.ATTRIBUTES.KEY, + (obj, key) => { + const attrIter = new trapi.AttributeIterator(obj[key]); + const supTextEntries = attrIter.findAll([supStudyId]); + const supText = {}; + supTextEntries.forEach(supTextEntry => { + const entryAttrs = trapi.getAttrs(supTextEntry); + const supTextData = {}; + entryAttrs.forEach(attr => { + const aid = trapi.getAttrId(attr); + const av = trapi.getAttrVal(attr); + switch (aid) { + case pubsId: + supText[ev.sanitize(av)] = supTextData; break; + case textId: + supTextData.text = av; break; + case subTokenId: + supTextData.subject = parseTokenIndex(av); break; + case objTokenId: + supTextData.object = parseTokenIndex(av); break; + } + }); + }); + + return supText; + }, + (supText, acc) => + { + const currentSupText = cmn.jsonSetDefaultAndGet(acc, CONSTANTS.SUPPORTING_TEXT, {}); + Object.keys(supText).forEach((pid) => { + currentSupText[pid] = supText[pid]; + }); + }, + {}); +} + +/* A special rule for extracting publications from attributes. + * + * @returns {function} - The extraction rule. + */ +function getPubs() { + const pubIds = [ + bl.tagBiolink('supporting_document'), + bl.tagBiolink('Publication'), + bl.tagBiolink('publications'), + bl.tagBiolink('publication') // Remove me when this is fixed in the ARA/KPs + ]; + + return makeExtractionRule( + trapi.CONSTANTS.GRAPH.ATTRIBUTES.KEY, + (obj, key, cxt) => { + const pubs = {}; + const attrIter = new trapi.AttributeIterator(obj[key]); + const pids = attrIter.findAllVal(pubIds); + if (cmn.isArrayEmpty(pids)) return pubs; + const provenance = bl.inforesToProvenance(cxt.primarySrc); + const kl = getKlevel(obj, provenance); + if (!pubs[kl]) { + pubs[kl] = []; + } + + pids.forEach((pid) => { + pubs[kl].push({ + id: ev.sanitize(pid), + src: provenance + }); + }); + + return pubs; + }, + (pubs, acc) => { + const currentPubs = cmn.jsonSetDefaultAndGet(acc, CONSTANTS.PUBLICATIONS, {}); + Object.keys(pubs).forEach((kl) => { + if (!currentPubs[kl]) { + currentPubs[kl] = []; + } + + currentPubs[kl].push(...(pubs[kl])); + }); + + return acc; + }, + {} + ); +} + +/** + * Generates a function to extract attributes or properties from a TRAPI object given a set of rules. + * + * @param {object[]} rules - The set of rules to use for extracting attributes. + * + * @returns {function} - A rule that will apply a list of rules to a source object and produce a list of transformers. + */ +function makeExtractionRules(rules) { + return (src, cxt) => { + return rules.map(rule => { return rule(src, cxt); }); + }; +} + +/** + * Get the endpoints for a TRAPI result graph. + * + * @param {object} result - The TRAPI result. + * @param {string} startKey - The key to use for the start node. There should only be a single start node. + * @param {string} endKey - The key to use for the end nodes. There can be multiple end nodes. + * + * @returns {string[]} - The start and end nodes for the result graph. + * @throws {NodeBindingNotFoundError} - If either of the start or end nodes are not found. + */ +function getResultStartAndEnd(result, startKey, endKey) { + // Flatten the node bindings for a specific key of a result into a list of IDs + function flattenBinding(result, key) { + const nodeBinding = trapi.getNodeBinding(result, key); + if (cmn.isArrayEmpty(nodeBinding)) { + throw new NodeBindingNotFoundError(nodeBinding); + } + + // TODO: move node binding getters to trapi.mjs + return nodeBinding.map((entry) => { + let endpoint = cmn.jsonGet(entry, 'query_id', false); + if (!endpoint) { + endpoint = cmn.jsonGet(entry, 'id'); + } + + return endpoint; + }); + } + + const rnodeStart = flattenBinding(result, startKey)[0]; + const rnodeEnds = flattenBinding(result, endKey); // There can be multiple endpoints + return [rnodeStart, rnodeEnds]; +} + +/** + * Flatten all bindings into a list of binding IDs. + * + * @param {object} bindings - The bindings to flatten. + * + * @returns {string[]} - The list of binding IDs. + */ +function flattenBindings(bindings) { + return Object.values(bindings).reduce((ids, binding) => { + return ids.concat(binding.map(obj => { return cmn.jsonGet(obj, 'id'); })); + }, + []); +} + +/** + * Gets a summarized edge tag based on the Knowledge Level and Agent Type of the edge. Defaults to using the Knowledge Level provided by the infores catalog if the tag cannot be determined by using the edge attributes. + * + * @param {object} kedge - The knowledge edge to summarize. + * @param {object} provenance - The provenance of the edge. + * + * @returns {string} - The summarized edge tag. + */ +// TODO: Add constants for KL/AT and the summarized KL +function getKlevel(kedge, provenance) { + const agentType = trapi.getAgentType(kedge); + if (agentType === 'text_mining_agent') { + return 'ml'; + } + + const kl = trapi.getKlevel(kedge); + if (kl === 'knowledge_assertion') { + return 'trusted'; + } else if (kl === 'not_provided') { + return 'unknown'; + } else if (kl !== null) { + return 'inferred'; + } + + return provenance.knowledge_level; +} + +/** + * Convert the array of qualifiers of a knowledge edge into an object. + * + * @param {object} kedge - The knowledge edge to extract qualifiers from. + * + * @returns {object} - The qualifiers of the knowledge edge or null if there is an error. + */ +function getQualifiers(kedge) { + try { + const trapiQualifiers = trapi.getQualifiers(kedge); + const qualifiers = {}; + trapiQualifiers.forEach((qualifier) => { + const qualifierId = bl.sanitizeBiolinkItem(trapi.getQualifierId(qualifier)); + const qualifierVal = bl.sanitizeBiolinkItem(trapi.getQualifierVal(qualifier)); + qualifiers[qualifierId] = qualifierVal; + }); + + return qualifiers; + } catch (err) { + //console.error(err); + return null; + } +} + +/** + * Get the most specific predicate available from a kedge. + * + * @param {object} kedge - The knowledge edge to extract the predicate from. + * + * @returns {string} - The most specific predicate available. + */ +// TODO: Add biolink constants for qualifier keys +function getMostSpecificPred(kedge) { + const qualifiers = getQualifiers(kedge); + if (!qualifiers) { + return trapi.getPred(kedge); + } + + return qualifiers['qualified predicate'] || trapi.getPred(kedge); +} + +/** + * Generates the qualified predicate for a kedge. + * + * @param {object} kedge - The knowledge edge to generate the qualified predicate for. + * @param {boolean} invert - Whether or not to invert the predicate. + * + * @returns {string} - The qualified predicate. + */ +function genQualifiedPred(kedge, invert = false) { + function genQualification(type, qualifiers, prefixes) { + // TODO: How do part and derivative qualifiers interact? Correct ordering? + // TODO: How to handle the context qualifier? + // TODO: Make more robust to biolink qualifier changes. + // TODO: Add biolink constants for qualifiers + // The ordering of qualifierKeys impacts the final qualified predicate + const qualifierKeys = ['form or variant', 'direction', 'aspect', 'part', 'derivative']; + const qualifierValues = qualifierKeys + .map(key => cmn.jsonGet(qualifiers, `${type} ${key} qualifier`, false)) + + let qualification = ''; + qualifierValues.forEach((qv, i) => { + if (qv) { + if (qualification) { + qualification += ' ' + } + + if (prefixes[i]) { + qualification += `${prefixes[i]} `; + } + + qualification += qv; + } + }); + + return qualification; + } + + function genSubQualification(qualifiers, directionPrefix = false) { + return genQualification('subject', qualifiers, directionPrefix); + } + + function genObjQualification(qualifiers, directionPrefix = false) { + return genQualification('object', qualifiers, directionPrefix); + } + + function combinePredParts(prefix, pred, suffix) { + if (prefix) { + prefix += ' '; + } + + if (suffix) { + suffix = ` ${suffix} of`; + } + + const qualifiedPred = `${prefix}${pred}${suffix}`; + return qualifiedPred; + } + + function genSpecialPred(pred, qualifiers, invert) { + const objDirectionQualifier = qualifiers['object direction qualifier']; + if (pred === 'regulates' && + (objDirectionQualifier === 'upregulated' || + objDirectionQualifier === 'downregulated')) { + if (invert) { + return `is ${objDirectionQualifier} by`; + } + + return objDirectionQualifier.replace('ed', 'es'); + } + + return false; + } + + let pred = bl.sanitizeBiolinkItem(trapi.getPred(kedge)); + let qualifiers = getQualifiers(kedge); + if (!qualifiers && bl.isDeprecatedPred(pred)) { + [pred, qualifiers] = bl.deprecatedPredToPredAndQualifiers(pred); + } + + // If we don't have any qualifiers, treat it like biolink v2 + if (!qualifiers) { + if (invert) { + pred = bl.invertBiolinkPred(pred); + } + + return pred; + } + + pred = bl.sanitizeBiolinkItem(getMostSpecificPred(kedge)); + const specialCase = genSpecialPred(pred, qualifiers, invert); + if (specialCase) { + return specialCase; + } + + const subPrefixes = ['of a', 'has', false, 'of the', false]; + const objPrefixes = ['a', false, false, 'of the', false]; + if (invert) { + const subQualification = genSubQualification(qualifiers, objPrefixes); + const objQualification = genObjQualification(qualifiers, subPrefixes); + return combinePredParts(objQualification, bl.invertBiolinkPred(pred), subQualification); + } + + const subQualification = genSubQualification(qualifiers, subPrefixes); + const objQualification = genObjQualification(qualifiers, objPrefixes); + return combinePredParts(subQualification, pred, objQualification); +} + +/** + * Generate a tag for a any graph summarization object + * + * @param {string} label - Label for the tag used for faceting. + * @param {string} name - User facing name for the tag. + * @param {string} description - User facing description for the tag. + * + * @returns {object} - The tag object. + */ +function makeTag(label, name, description = '') { + return { + 'label': label, + 'description': makeTagDescription(name, description) + }; +} + +/** + * Generate a user facing description for a tag. + * + * @param {string} name - User facing name of the tag. + * @param {string} description - User facing description of the tag. + * + * @returns {object} - The tag description object. + */ +function makeTagDescription(name, description = '') { + return { + 'name': name, + 'value': description + }; +} + +function getTags(graphElem) { + // TODO: Throw an error if the tags are not found + return graphElem.tags; +} + +/** + * Add a tag to a summary element. + * + * @param {object} summaryElement - Summary element to add the tag to. + * @param {object} tag - Tag to add to the summary element. + * + * @returns {object} - The summary element with the added tag. + */ +function addTag(smryElem, tag) { + smryElem.tags[tag.label] = tag.description; + return smryElem; +} + +/** + * Merge the tags of a source summary element into a target summary element with an optional filter for the tags of the source summary element. + * + * @param {object} smryTgt - Summary element to add the tag to. + * @param {object} smrySrc - Summary element to add the tag to. + * @param {function} filterFunc - Optional filter for the source summary element. + * + * @returns {object} - The summary target. + */ +function mergeTags(smryTgt, smrySrc, filterFunc = null) { + let srcTags = Object.keys(smrySrc.tags); + if (filterFunc !== null) { + srcTags = srcTags.filter(filterFunc); + } + + srcTags.forEach((k) => smryTgt.tags[k] = smrySrc.tags[k]); + return smryTgt; +} + +function getTagFamily(tag) { + return tag.split('/')[1]; +} + +function isResultTag(tag) { + return tag.startsWith('r/'); +} + +function isPathTag(tag) { + return tag.startsWith('p/'); +} + +function isExternalTag(tag) { + const validFamilies = ['cc', 'di', 'pc', 'pt', 'role', 'ara']; + const family = getTagFamily(tag); + return validFamilies.includes(family); +} + +function isFdaTag(tag) { + return tag.startsWith('r/fda'); +} + +function genMaxPhaseTag(node, queryType) { + function isDrug(node, fdaLevel) { + return fdaLevel === 4 || node.type === 'Drug'; + } + + function isClinicalPhase(fdaLevel) { + return fdaLevel > 0 && fdaLevel < 4; + } + + // Only generate this tag for non-gene/chemical queries + if (!trapi.isValidQuery(queryType) || trapi.isGeneChemicalQuery(queryType)) { + return false; + } + + const fdaTags = Object.keys(getTags(node)).filter(isFdaTag); + let highestFdaApproval = 0; + if (!cmn.isArrayEmpty(fdaTags)) { + highestFdaApproval = Math.max(...fdaTags.map((tag) => { return parseInt(tag.split('/')[2]); })); + } + + if (highestFdaApproval === 0) return makeTag('r/cc/other', 'Other'); + if (isDrug(node, highestFdaApproval)) return makeTag('r/cc/drug', 'Drug'); + if (isClinicalPhase(highestFdaApproval)) return makeTag(`r/cc/phase${highestFdaApproval}`, `Phase ${highestFdaApproval} Drug`); + return makeTag(`r/cc/other`, `Other`); +} + +function genRgraph(rnodes, redges, edgeMappings, kgraph) { + if (!redges) { + return false; + } + + for (const rid of rnodes) { + if (!trapi.hasKnode(rid, kgraph)) { + return false; + } + } + + const rgraph = {}; + rgraph.nodes = rnodes; + rgraph.edges = redges.filter(redge => { + const kedge = trapi.getKedge(redge, kgraph); + return bl.isBiolinkPred(trapi.getPred(kedge)); + }); + rgraph.edgeMappings = edgeMappings; + + return rgraph; +} + +function isRedgeInverted(redge, sub, kgraph) { + const kedge = trapi.getKedge(redge, kgraph); + return sub === trapi.getObj(kedge); +} + +function analysisToRgraph(analysis, kgraph, auxGraphs) { + const edgeBindingData = new Map(); + let unprocessedEdgeBindings = flattenBindings(trapi.getEdgeBindings(analysis)).map((eb) => { + edgeBindingData[eb] = { partOf: ['root'] }; + return eb; + }); + + let unprocessedSupGraphs = []; + const nodeBindings = new Set(); + const supGraphs = new Set(); + // Invariant: edges and subgraphs will only ever be processed once. This is very important + // for how the following code works. + while (!cmn.isArrayEmpty(unprocessedEdgeBindings) || !cmn.isArrayEmpty(unprocessedSupGraphs)) { + while (!cmn.isArrayEmpty(unprocessedEdgeBindings)) { + const eb = unprocessedEdgeBindings.pop(); + if (edgeBindingData[eb].supPaths !== undefined) { + continue; + } + + const kedge = trapi.getKedge(eb, kgraph); + if (!kedge) { + throw new EdgeBindingNotFoundError(eb); + } + + nodeBindings.add(trapi.getSub(kedge)); + nodeBindings.add(trapi.getObj(kedge)); + const edgeSupGraphs = trapi.getSupGraphs(kedge); + edgeBindingData[eb].supPaths = edgeSupGraphs; + edgeSupGraphs.forEach((gid) => { + if (!supGraphs.has(gid)) { + unprocessedSupGraphs.push(gid); + } + }); + }; + + while (!cmn.isArrayEmpty(unprocessedSupGraphs)) { + const gid = unprocessedSupGraphs.pop(); + if (supGraphs.has(gid)) { + continue; + } + + const auxGraph = trapi.getAuxGraph(gid, auxGraphs); + if (!auxGraph) { + throw new AuxGraphNotFoundError(gid); + } + + const auxEdgeBindings = trapi.getAuxGraphEdges(auxGraph); + auxEdgeBindings.forEach((eb) => { + if (!edgeBindingData[eb]) { + edgeBindingData[eb] = { partOf: [gid] }; + unprocessedEdgeBindings.push(eb); + } else { + // We do not want to process the same edge twice, but we need to include this + // graph as a graph where this edge occurs. + edgeBindingData[eb].partOf.push(gid); + } + }); + + supGraphs.add(gid); + } + } + + return genRgraph([...nodeBindings], [...Object.keys(edgeBindingData)], edgeBindingData, kgraph); +} + +function genNid(rnode, kgraph) { + return rnode; +} + +function genEid(redge, kgraph, doInvert = false) { + const kedge = trapi.getKedge(redge, kgraph); + const sub = trapi.getSub(kedge); + const pred = genQualifiedPred(kedge, doInvert); + const obj = trapi.getObj(kedge); + const provenance = bl.inforesToProvenance(trapi.getPrimarySrc(kedge)); + const kl = getKlevel(kedge, provenance); + if (doInvert) { + return genPid([obj, pred, sub, kl]); + } + + return genPid([sub, pred, obj, kl]); +} + +function summarizeRnode(rnode, kgraph, nodeRules, cxt) { + const nid = genNid(rnode, kgraph); + return cmn.makePair(nid, + nodeRules(trapi.getKnode(rnode, kgraph), cxt), + 'id', + 'transforms'); +} + +function summarizeRedge(redge, kgraph, edgeRules, cxt, edgeBaseIds) { + let eid = genEid(redge, kgraph); + if (!edgeBaseIds.has(eid)) { + eid = genEid(redge, kgraph, true); + } + + return cmn.makePair(eid, + edgeRules(trapi.getKedge(redge, kgraph), cxt), + 'id', + 'transforms'); +} + +function makeRedgeToEdgeId(rgraph, kgraph) { + function makeEdgeId(sub, obj) + { + return cmn.makePair(sub, obj, 'sub', 'obj'); + } + + let redgeToEdgeId = {}; + rgraph.edges.forEach(redge => { + const kedge = trapi.getKedge(redge, kgraph); + cmn.jsonSet(redgeToEdgeId, redge, makeEdgeId(trapi.getSub(kedge), trapi.getObj(kedge))); + }); + + return (redge) => { return cmn.jsonGet(redgeToEdgeId, redge); }; +} + +function makeRnodeToOutEdges(rgraph, kgraph) { + + function makeOutEdge(redge, node) { + return cmn.makePair(redge, node, 'redge', 'target'); + } + + const redgeToEdgeId = makeRedgeToEdgeId(rgraph, kgraph); + const rnodeToOutEdges = {}; + rnodeToOutEdges.update = (rnode, val) => { + const outEdges = cmn.jsonGet(rnodeToOutEdges, rnode, []); + outEdges.push(val); + cmn.jsonSet(rnodeToOutEdges, rnode, outEdges); + }; + + rgraph.edges.forEach(redge => { + const edgeId = redgeToEdgeId(redge); + const sub = edgeId.sub; + const obj = edgeId.obj; + + rnodeToOutEdges.update(sub, makeOutEdge(redge, obj)); + rnodeToOutEdges.update(obj, makeOutEdge(redge, sub)); + }); + + return (rnode) => { return cmn.jsonGet(rnodeToOutEdges, rnode, []); }; +} + +function rgraphFold(proc, init, acc) { + let objLeft = init; + let res = acc; + while (!cmn.isArrayEmpty(objLeft)) { + const paths = proc(objLeft.pop()); + objLeft.push(...paths.first); + res.push(...paths.second); + } + + return res; +} + +function edgeToString(edge) { + return `${edge.subject}-${edge.predicate}-${edge.object}`; +} + +function updateErrorsFromEdge(edge, errors, edgeErrorReasons) { + const edgeAras = edge.aras; + let edgeErrors = null; + if (edgeAras.length !== 1) { + edgeErrors = cmn.jsonSetDefaultAndGet(errors, 'unknown', []); + } else { + edgeErrors = cmn.jsonSetDefaultAndGet(errors, edgeAras[0], []); + } + + edgeErrors.push(...edgeErrorReasons); +} + +function reasonsForEdgeErrors(edge) { + const reasons = []; + if (!edge.subject || !edge.object || !edge.predicate) { + reasons.push(`Invalid edge found: ${edgeToString(edge)}`); + } + + if (!edge.provenance || edge.provenance.length === 0) { + reasons.push(`No provenance for edge: ${edgeToString(edge)}`); + } + + return reasons; +} + +function genPid(path) { + return hash(path); +} + +function answersToSmryFgmts(answers, nodeRules, edgeRules, maxHops) { + function resultToSmryFgmt(result, kgraph, auxGraphs, startKey, endKey, errors) { + function analysisToSmryFgmt(analysis, kgraph, auxGraphs, start, ends) { + function finalizePaths(rgraphPaths, edgeMappings, kgraph) { + function N(n) { return genNid(n, kgraph); } + function E(e, o) { return genEid(e, kgraph, isRedgeInverted(e, o, kgraph)); } + const normalizedMappings = {}; + const normalizedPaths = rgraphPaths.map(path => { + let normalizedPath = []; + const pathLength = path.length - 1; + if (pathLength < 0) { + return normalizedPath; + } + + for (let i = 0; i < pathLength; i+=2) { + const node = path[i]; + const edge = path[i+1]; + const normalizedEdge = E(edge, node); + if (!normalizedMappings[normalizedEdge]) { + normalizedMappings[normalizedEdge] = { partOf: [], supPaths: [] }; + } + normalizedMappings[normalizedEdge].partOf.push(...edgeMappings[edge].partOf); + normalizedMappings[normalizedEdge].supPaths.push(...edgeMappings[edge].supPaths); + normalizedPath.push(N(node), normalizedEdge); + } + + normalizedPath.push(N(path[pathLength])); + return normalizedPath; + }); + + Object.keys(normalizedMappings).forEach(atrId => cmn.objRemoveDuplicates(normalizedMappings[atrId])); + const pathToSupGraph = {}; + // For every path find which graphs the path appears in. A path appears in a graph iff all + // edges in the path appear in the graph. + for (const path of normalizedPaths) { + let gids = [...normalizedMappings[path[1]].partOf]; + for (let i = 3; i < path.length; i+=2) { + gids = gids.filter((gid) => normalizedMappings[path[i]].partOf.includes(gid)); + } + + pathToSupGraph[genPid(path)] = gids; + } + + const edgeBases = {} + // Determine which paths support which edges + for (const edge of Object.keys(normalizedMappings)) { + const edgeSupGraphs = normalizedMappings[edge].supPaths; + const edgePaths = []; + for (const path of Object.keys(pathToSupGraph)) { + for (const pgid of pathToSupGraph[path]) { + if (edgeSupGraphs.includes(pgid)) { + edgePaths.push(path); + break; + } + } + } + + if (!edgeBases[edge]) { + edgeBases[edge] = new SummaryEdge(); + } + + edgeBases[edge].extendSupPaths(edgePaths); + edgeBases[edge].isRootPath = normalizedMappings[edge].partOf.includes('root'); + } + + return [normalizedPaths, edgeBases]; + } + + const agent = cmn.jsonGet(analysis, 'resource_id', false); + if (!agent) { + return makeErrSmryFgmt('unknown', 'Expected analysis to have resource_id'); + } + + try { + const rgraph = analysisToRgraph(analysis, kgraph, auxGraphs); + const rnodeToOutEdges = makeRnodeToOutEdges(rgraph, kgraph); + const maxPathLength = (2 * maxHops) + 1; + // This is an exhaustive search based on the max path length. We may have to come up + // with a better algorithm if the max path length increases significantly. + const rgraphPaths = rgraphFold((path) => { + const currentRnode = path[path.length-1]; + if (maxPathLength < path.length) { + return cmn.makePair([], []); + } + + let validPaths = []; + rnodeToOutEdges(currentRnode).forEach((edge) => { + const target = edge.target + // Do not allow cycles + if (!path.includes(target)) { + let newPath = [...path, edge.redge, edge.target]; + validPaths.push(newPath); + } + }); + + const finalPaths = []; + if (ends.includes(currentRnode)) { + finalPaths.push(path); + } + + return cmn.makePair(validPaths, finalPaths); + }, + [[start]], + []); + + const [normalizedPaths, edgeBases] = finalizePaths(rgraphPaths, rgraph.edgeMappings, kgraph); + const analysisCxt = { + agent: agent, + errors: errors + }; + + return new SummaryFragment( + [agent], + normalizedPaths, + rgraph.nodes.map(rnode => { return summarizeRnode(rnode, kgraph, nodeRules, analysisCxt); }), + new SummaryFragmentEdges( + edgeBases, + rgraph.edges.map(redge => { + const kedge = trapi.getKedge(redge, kgraph); + const edgeCxt = cmn.deepCopy(analysisCxt); + edgeCxt.primarySrc = trapi.getPrimarySrc(kedge); + return summarizeRedge(redge, kgraph, + edgeRules, edgeCxt, new Set(Object.keys(edgeBases))); + }) + )); + } catch (err) { + console.error(err); + if (err instanceof EdgeBindingNotFoundError) { + return makeErrSmryFgmt(agent, e.message); + } + + return makeErrSmryFgmt(agent, 'Unknown error while building RGraph'); + } + } + + try { + const [rnodeStart, rnodeEnds] = getResultStartAndEnd(result, startKey, endKey); + // TODO: There SHOULD only be a single start point. We should probably throw an error when this is not the case. + const analyses = getResultAnalyses(result); + const resultSmryFgmt = analyses.reduce( + (rsf, analysis) => { + return rsf.merge(analysisToSmryFgmt(analysis, kgraph, auxGraphs, rnodeStart, rnodeEnds)); + }, + new SummaryFragment()); + + if (!resultSmryFgmt.isEmpty()) { + // Ordering components are a property of the result, so we have to add them after the result analyses are summarized. + const rid = genNid(rnodeStart, kgraph); // The first node uniquely identifies a result. + const scoringComponents = getResultScoringComponents(result); + resultSmryFgmt.pushScore(rid, scoringComponents); + } + + return resultSmryFgmt; + } catch (err) { + console.error(err); + if (err instanceof NodeBindingNotFoundError) { + return makeErrSmryFgmt('unknown', err.message); + } + + return makeErrSmryFgmt('unknown', 'Unknown error while building result summary fragment'); + } + } + + const smryFgmts = []; + const errors = {}; + answers.forEach((answer) => { + const results = trapi.getResults(answer); + if (!results) { + // TODO: Add warning + return; + } + + // TODO: What to do if these fail + const kgraph = trapi.getKgraph(answer); + const auxGraphs = trapi.getAuxGraphs(answer); + const [startKey, endKey] = trapi.messageToEndpoints(answer); + + // TODO: Where is the error handling? + results.forEach((result) => { + const sf = resultToSmryFgmt(result, kgraph, auxGraphs, startKey, endKey, errors); + // TODO: Empty summary fragments should throw an error (at least in some cases) + if (!sf.isEmpty()) { + smryFgmts.push(sf); + } + }); + }); + + return [smryFgmts, errors]; +} + +function smryPathFromPid(pid, paths) { + return cmn.jsonGet(paths, pid); +} + +function pidSort(pids, paths) { + function pidCompare(pid1, pid2) { + const smryPath1 = smryPathFromPid(pid1, paths); + const smryPath2 = smryPathFromPid(pid2, paths); + const comparison = smryPathCompare(smryPath1, smryPath2); + if (comparison === 0) { + if (pid1 < pid2) return -1; + if (pid2 < pid1) return 1; + return 0; + } + + return comparison; + } + + if (pids.length < 2) return pids; + return pids.sort(pidCompare); +} + +function isRootPath(pid, paths, edges) { + const smryPath = smryPathFromPid(pid, paths); + let isRoot = true; + smryPath.forEids((eid) => { + isRoot = isRoot && edges[eid].isRootPath; + }); + + return isRoot; +} + +function getRootPids(pids, paths, edges) { + const rootPids = pids.filter(pid => isRootPath(pid, paths, edges)); + return rootPids; +} + +function genSupChain(pids, paths, edges) { + const seenPids = []; + const remaining = getRootPids(pids, paths, edges); + while (remaining.length !== 0) { + const next = remaining.pop(); + if (seenPids.includes(next)) continue; + seenPids.push(next); + const smryPath = smryPathFromPid(next, paths); + smryPath.forEids((eid) => { + const edgeSup = edges[eid].supPaths; + remaining.push(...edgeSup.filter((spid) => !seenPids.includes(spid))); + }); + } + + return seenPids; +} + +function cleanup(results, paths, edges, nodes) { + function clean(section, seenIds) { + for (let id of Object.keys(section)) { + if (!seenIds.has(id)) { + delete section[id]; + } + } + } + + const seenPaths = new Set(); + const seenEdges = new Set(); + const seenNodes = new Set(); + for (let res of results) { + const resPaths = []; + for (let pid of res.paths) { + const path = smryPathFromPid(pid, paths); + if (path.length !== 0) { + resPaths.push(pid); + seenPaths.add(pid); + } + + path.forEids(eid => seenEdges.add(eid)); + path.forNids(nid => seenNodes.add(nid)); + } + + res.paths = resPaths; + } + + clean(paths, seenPaths); + clean(edges, seenEdges); + clean(nodes, seenNodes); +} + +function genMetaPath(smryPath, nodes) { + const metaPath = []; + smryPath.forNids((nid) => { + const node = nodes[nid]; + metaPath.push(node.type); + }); + + return metaPath; +} + +function smryFgmtsToSmry(qid, smryFgmts, kgraph, queryType, errors) { + function fgmtPathsToResultsAndPaths(fgmtPaths, nodes, queryType) { + const results = []; + const paths = []; + fgmtPaths.forEach((path) => { + const pid = genPid(path); + let rid = path[0]; + if (trapi.isPathfinderQuery(queryType)) { + rid = genPid(genMetaPath(path, nodes)); + } + results.push(cmn.makePair(rid, pid, 'start', 'pid')); + paths.push(cmn.makePair(pid, path, 'pid', 'path')); + }); + + return [results, paths]; + } + + function extendSmryResults(results, newResults) { + newResults.forEach((result) => { + let existingResult = cmn.jsonSetDefaultAndGet(results, result.start, {}); + let paths = cmn.jsonSetDefaultAndGet(existingResult, 'paths', []) + paths.push(result.pid); + }); + } + + function extendSmryPaths(paths, newPaths, agents) { + newPaths.forEach((path) => { + const smryPath = cmn.jsonGet(paths, path.pid, false); + if (smryPath) { + smryPath.extendAgents(agents); + return; + } + + cmn.jsonSet(paths, path.pid, new SummaryPath(path.path, agents)); + }); + } + + function extendSmryGraphElem(objs, updates, agents, defaultValue) { + updates.forEach((update) => { + let obj = cmn.jsonSetDefaultAndGet(objs, update.id, defaultValue()); + update.transforms.forEach((transform) => { + transform(obj); + obj.aras.push(...agents); + }); + }); + } + + function extendSmryNodes(nodes, nodeUpdates, agents) { + extendSmryGraphElem(nodes, nodeUpdates, agents, () => new SummaryNode()); + } + + function extendSmryEdges(edges, edgeFgmts, agents) { + Object.keys(edgeFgmts.base).forEach((eid) => { + const edge = cmn.jsonSetDefaultAndGet(edges, eid, new SummaryEdge()); + edge.merge(edgeFgmts.base[eid]); + }); + + extendSmryGraphElem(edges, edgeFgmts.updates, agents, () => new SummaryEdge()); + } + + function extendSmryScores(scores, newScores) { + Object.keys(newScores).forEach((rid) => { + const currentScores = cmn.jsonSetDefaultAndGet(scores, rid, []); + currentScores.push(...newScores[rid]); + }); + } + + function extendSmryErrors(errors, newErrors) { + Object.keys(newErrors).forEach((agtId) => { + const currentErrors = cmn.jsonSetDefaultAndGet(errors, agtId, []); + currentErrors.push(...newErrors[agtId]); + }); + } + + function extendSmryPubs(smryPubs, edge) { + const pubs = cmn.jsonGet(edge, 'publications', {}); + Object.keys(pubs).forEach((ks) => { + const pubData = cmn.jsonGet(pubs, ks, []); + pubData.forEach((pub) => { + const pid = pub.id; + const [type, url] = ev.idToTypeAndUrl(pid); + cmn.jsonSet(smryPubs, pid, new SummaryPublication(type, url, pub.src)); + }); + }); + } + + function edgesToEdgesAndPubs(edges) { + function addInverseEdge(edges, edge) { + const invertedPred = genQualifiedPred(edge, true); + const sub = cmn.jsonGet(edge, 'subject'); + const obj = cmn.jsonGet(edge, 'object'); + const kl = cmn.jsonGet(edge, 'knowledge_level'); + + const invertedEid = genPid([obj, invertedPred, sub, kl]); + let invertedEdge = cmn.deepCopy(edge); + cmn.jsonMultiSet(invertedEdge, + [['subject', obj], + ['object', sub], + ['predicate', invertedPred]]); + + const unqualifiedInvertedPred = bl.invertBiolinkPred(getMostSpecificPred(edge)); + invertedEdge.predUrl = bl.predToUrl(unqualifiedInvertedPred); + delete invertedEdge['qualifiers']; + edges[invertedEid] = invertedEdge; + } + + const pubs = {}; + Object.values(edges).forEach((edge) => { + extendSmryPubs(pubs, edge); + const edgePubs = cmn.jsonGet(edge, 'publications', {}) + const supText = cmn.jsonGet(edge, 'supporting_text', {}); + Object.keys(edgePubs).forEach((kl) => { + edgePubs[kl] = edgePubs[kl].map((pub) => { + return { id: pub.id, support: supText[pub.id] || null }; + }); + }); + delete edge['supporting_text']; + addInverseEdge(edges, edge); + cmn.jsonSet(edge, 'predicate_url', bl.predToUrl(getMostSpecificPred(edge))); + cmn.jsonSet(edge, 'predicate', genQualifiedPred(edge)); + delete edge['qualifiers']; + }); + + return [edges, pubs]; + } + + function resultsToResultsAndTags(results, paths, nodes, edges, scores, errors, queryType) { + function markPathAsIndirect(pid, edges, paths) { + function helper(pid, edges, paths, seen) { + seen.add(pid); + const tag = 'p/pt/inf'; + const smryPath = smryPathFromPid(pid, paths); + smryPath.forEids((eid) => { + const edge = edges[eid]; + if (edge.hasSupport()) { + for (let spid of edge.supPaths) { + if (!seen.has(spid)) { + helper(spid, edges, paths, seen); + } + } + } + }); + smryPath.tags[tag] = makeTagDescription('Indirect'); + } + helper(pid, edges, paths, new Set()); + } + + function genName(nodes, smryPath, queryType) { + if (trapi.isPathfinderQuery(queryType)) { + const metaPath = genMetaPath(smryPath, nodes) + metaPath[0] = nodes[smryPath.start].name(); + metaPath[metaPath.length-1] = nodes[smryPath.end].name(); + return metaPath.join('/'); + } + return nodes[smryPath.start].name(); + } + + function genId(nodes, smryPath, queryType) { + if (trapi.isPathfinderQuery(queryType)) { + const metaPath = genMetaPath(smryPath, nodes); + return genPid(metaPath); + } + return genPid([smryPath.start, smryPath.end]); + } + + const usedTags = {}; + const expandedResults = []; + for (const result of results) { + const pids = result.paths; + const rootPids = getRootPids(pids, paths, edges); + // Bail if there are no root paths + if (rootPids.length === 0) { + let aras = new Set(); + for (const pid of pids) { + for (const ara of paths[pid].aras) { + aras.add(ara); + } + } + + aras = [...aras]; + const errorString = "No root paths found"; + console.error(`${aras.join(', ')}: ${errorString}`) + for (const ara of aras) { + const araErrors = cmn.jsonSetDefaultAndGet(errors, ara, []); + araErrors.push(errorString); + } + + continue; + } + + const smryPath = smryPathFromPid(rootPids[0], paths); + const name = genName(nodes, smryPath, queryType); + const start = smryPath.start; + const end = smryPath.end; + const tags = {}; + pids.forEach((pid) => { + const path = smryPathFromPid(pid, paths); + for (const tag of Object.keys(path.tags)) { + if (isResultTag(tag) && path.start !== start) continue; + usedTags[tag] = path.tags[tag]; + tags[tag] = null; + }; + }); + + // Generate direct/indirect tags for results + rootPids.forEach((pid) => { + const smryPath = smryPathFromPid(pid, paths); + let isPathIndirect = false; + smryPath.forEids((eid) => { + const edge = edges[eid]; + isPathIndirect = isPathIndirect || edge.hasSupport(); + }); + + if (isPathIndirect) { + const tag = 'p/pt/inf'; + markPathAsIndirect(pid, edges, paths); + usedTags[tag] = makeTagDescription('Indirect'); + tags[tag] = null; + } else { + const tag = 'p/pt/lkup'; + const directTag = makeTagDescription('Direct'); + usedTags[tag] = directTag; + smryPath.tags[tag] = directTag; + tags[tag] = null; + } + }); + + expandedResults.push({ + 'id': genId(nodes, smryPath, queryType), + 'subject': start, + 'drug_name': name, + 'paths': pidSort(rootPids, paths), + 'object': end, + 'scores': scores[start], + 'tags': tags + }); + } + + return [expandedResults, usedTags]; + } + + let results = {}; + let paths = {}; + let nodes = {}; + let edges = {}; + let pubs = {}; + let scores = {}; + let tags = []; + smryFgmts.forEach((sf) => { + const agents = sf.agents; + extendSmryNodes(nodes, sf.nodes, agents); + extendSmryEdges(edges, sf.edges, agents); + extendSmryScores(scores, sf.scores); + extendSmryErrors(errors, sf.errors); + }); + + Object.values(nodes).forEach(node => { + node.types.sort(bl.biolinkClassCmpFn); + }); + + smryFgmts.forEach((sf) => { + const agents = sf.agents; + const [newResults, newPaths] = fgmtPathsToResultsAndPaths(sf.paths); + extendSmryResults(results, newResults); + extendSmryPaths(paths, newPaths, agents); + }); + + results = Object.values(results).map(cmn.objRemoveDuplicates) + function pushIfEmpty(arr, val) { + if (cmn.isArrayEmpty(arr)) { + arr.push(val); + } + }; + + // Edge post-processing + Object.keys(edges).forEach((eid) => { + const edge = edges[eid]; + // Remove any empty edges. TODO: Why are these even here? + if (Object.keys(edge).length === 2 && edge.aras !== undefined && edge.supPaths !== undefined) { + delete edges[eid]; + return; + } + + // Remove any edges that have a missing subject, object, predicate, or provenance + const edgeErrorReasons = reasonsForEdgeErrors(edge); + if (edgeErrorReasons.length !== 0) { + console.error(`Found invalid edge ${eid}. Reasons: ${JSON.stringify(edgeErrorReasons)}`); + updateErrorsFromEdge(edge, errors, edgeErrorReasons); + delete edges[eid]; + return; + } + + // Remove any duplicates on all edge attributes + cmn.objRemoveDuplicates(edge); + + // Remove duplicates from publications + const pubs = cmn.jsonGet(edge, 'publications', {}); + Object.keys(pubs).forEach((kl) => { + const klPubs = cmn.jsonGet(pubs, kl, []); + const seenIds = new Set(); + cmn.jsonSet(pubs, kl, klPubs.filter((pub) => { + const shouldInclude = !seenIds.has(pub.id); + seenIds.add(pub.id); + return shouldInclude; + })); + }); + + // Convert all infores to provenance + cmn.jsonUpdate(edge, 'provenance', (provenance) => { + return provenance.map((p) => { + const provenanceMapping = bl.inforesToProvenance(p); + if (!provenanceMapping) { + edgeErrorReasons.push(`Found invalid provenance ${p} on edge ${edgeToString(edge)}`); + } + + return provenanceMapping; + }).filter(cmn.identity); + }); + + if (edgeErrorReasons.length !== 0) { + updateErrorsFromEdge(edge, errors, edgeErrorReasons); + delete edges[eid]; + return + } + + // Populate knowledge level + edge.knowledgeLevel = edge.provenance[0].knowledge_level; + }); + + [edges, pubs] = edgesToEdgesAndPubs(edges); + + const meta = new SummaryMetadata(qid, cmn.distinctArray(smryFgmts.map((sf) => { + return sf.agents; + }).flat())); + + try { + // Node annotation + const nodeRules = makeExtractionRules( + [ + renameAndTransformAttribute( + 'biothings_annotations', + ['descriptions'], + (annotations) => { + const description = bta.getDescription(annotations[0]); + if (description === null) { + return []; + } + + return [description]; + } + ), + renameAndTransformAttribute( + 'biothings_annotations', + ['other_names'], + (annotations) => { + const otherNames = bta.getNames(annotations[0]); + if (otherNames === null + || (cmn.isArrayEmpty(otherNames.commercial) && cmn.isArrayEmpty(otherNames.generic))) { + return null; + } + + return otherNames; + } + ), + aggregateAndTransformAttributes( + ['biothings_annotations'], + 'curies', + (annotation) => bta.getCuries(annotation) + ) + ] + ); + + const resultNodeRules = makeExtractionRules( + [ + tagAttribute( + 'biothings_annotations', + (annotations) => { + const fdaApproval = bta.getFdaApproval(annotations[0]); + if (fdaApproval === null) { + return false; + } else if (fdaApproval < 4) { + const tags = []; + if (fdaApproval > 0) { + tags.push(makeTag(`r/fda/${fdaApproval}`, `Clinical Trial Phase ${fdaApproval}`)); + } else { + tags.push(makeTag('r/fda/0', 'Not FDA Approved')); + } + + return tags; + } else { + return [makeTag(`r/fda/${fdaApproval}`, `FDA Approved`)]; + } + } + ), + tagAttribute( + 'biothings_annotations', + (annotations, cxt) => { + if (trapi.isGeneChemicalQuery(cxt.queryType)) return []; + + const chebiRoles = bta.getChebiRoles(annotations[0]); + if (chebiRoles === null) { + return []; + } + + return chebiRoles.map((role) => { return makeTag(`r/role/${role.id}`, cmn.titleize(role.name))}); + } + ), + renameAndTransformAttribute( + 'biothings_annotations', + ['indications'], + (annotations) => bta.getDrugIndications(annotations[0]) + ) + ] + ); + + const resultNodes = new Set(); + results.forEach((result) => { + const pids = cmn.jsonGet(result, 'paths'); + pids.forEach((pid) => { + const smryPath = smryPathFromPid(pid, paths); + resultNodes.add(smryPath.start); + }); + }); + + const annotationCxt = { + agent: 'biothings-annotator', + queryType: queryType, + errors: {} + }; + + const nodeUpdates = Object.keys(nodes).map((nid) => { + return summarizeRnode(nid, kgraph, nodeRules, annotationCxt); + }); + + const resultNodeUpdates = [...resultNodes].map((nid) => { + return summarizeRnode(nid, kgraph, resultNodeRules, annotationCxt); + }); + + extendSmryNodes(nodes, nodeUpdates.concat(resultNodeUpdates), ['biothings-annotator']); + extendSmryErrors(errors, annotationCxt.errors); + } + catch (err) { + console.error(err); + } + finally { + // Node post-processing + Object.keys(nodes).forEach((nid) => { + const node = nodes[nid]; + node.curies.push(nid); + // Remove any duplicates on all node attributes + cmn.objRemoveDuplicates(node); + node.types.sort(bl.biolinkClassCmpFn); + + // Provide a CURIE as a default value if the node has no name + const nodeNames = cmn.jsonGet(node, 'names'); + pushIfEmpty(nodeNames, nid); + + cmn.jsonSet(node, 'provenance', [bl.curieToNormalizedUrl(nid, node.curies)]); + }); + + // Path post-processing + Object.keys(paths).forEach((pid) => { + const smryPath = smryPathFromPid(pid, paths); + // Remove paths where there is an undefined node reference in the path + + smryPath.forNids((nid) => { + if (nodes[nid] === undefined) { + delete paths[pid]; + return; + } + }); + + // Remove paths where there is an undefined edge reference in the path + smryPath.forEids((eid) => { + if (edges[eid] === undefined) { + delete paths[pid]; + return; + } + }); + + // Remove duplicates from every attribute on a path + cmn.objRemoveDuplicates(smryPath); + + if (trapi.isChemicalDiseaseQuery(queryType)) { + // Consider the chemical indicated for the disease iff + // 1. The chemical is marked as indicated for the disease + // 2. The chemical has reached phase 4 approval from the FDA + const start = nodes[smryPath.start]; + if (start.indications !== undefined) { + const startIndications = new Set(start.indications); + const end = nodes[smryPath.end]; + const endMeshIds = end.curies.filter((curie) => { return curie.startsWith('MESH:'); }); + let indicatedFor = false; + for (let i = 0; i < endMeshIds.length; i++) { + if (startIndications.has(endMeshIds[i])) { + indicatedFor = start.tags['r/fda/4'] !== undefined; + break; + } + } + + let indicationTag = null; + if (indicatedFor) { + indicationTag = makeTag('r/di/ind', 'In a clinical trial for indicated disease'); + } else { + indicationTag = makeTag('r/di/not', 'Not in a clinical trial for indicated disease'); + } + + addTag(start, indicationTag); + } + + cmn.jsonDelete(start, 'indications'); + } + + // Add tags for paths by processing nodes + mergeTags(smryPath, nodes[smryPath.start], (tag) => { return isResultTag(tag) && isExternalTag(tag) }); + smryPath.forInternalNids((nid) => { + const node = nodes[nid]; + mergeTags(smryPath, node, (tag) => { return !isResultTag(tag) && isExternalTag(tag) }); + const nodeType = node.type; + const typeTag = makeTag(`p/pc/${nodeType}`, nodeType); + addTag(smryPath, typeTag); + }); + + // Generate a special tag for the answer node + const maxPhaseTag = genMaxPhaseTag(nodes[smryPath.start], queryType); + if (maxPhaseTag) { + addTag(smryPath, maxPhaseTag); + } + + // Generate tags based on the aras for this path + const agentInfores = smryPath.agents; + agentInfores.forEach((infores) => { + const araTag = makeTag(`r/ara/${infores}`, inforesToName(infores)); + addTag(smryPath, araTag); + }); + + // Generate tags for number of connections + const edgeCount = smryPath.edgeCount + let tagDescription = 'Connections'; + if (edgeCount === 1) { + tagDescription = 'Connection'; + } + + const lengthTag = makeTag(`p/pt/${edgeCount}`, `${edgeCount} ${tagDescription}`); + addTag(smryPath, lengthTag); + }); + + Object.keys(edges).forEach((eid) => { + const edge = cmn.jsonGet(edges, eid); + edge.supPaths = edge.supPaths.filter(pid => paths[pid] !== undefined); + }); + + results.forEach((res) => { + res.paths = res.paths.filter(pid => paths[pid] !== undefined); + res.paths = genSupChain(res.paths, paths, edges); + }); + + cleanup(results, paths, edges, nodes); + [results, tags] = resultsToResultsAndTags(results, paths, nodes, edges, scores, errors, queryType); + Object.keys(edges).forEach((eid) => { + const edge = cmn.jsonGet(edges, eid); + edge.supPaths = pidSort(edge.supPaths, paths); + }); + + return new Summary(meta, results, paths, nodes, edges, pubs, tags, errors); + } +} + +function getResultAnalyses(result) { + return cmn.jsonGet(result, 'analyses'); +} + +function getResultScoringComponents(result) { + const scoringComponents = cmn.jsonGet( + result, + 'ordering_components', + {confidence: 0, novelty: 0, clinical_evidence: 0} + ); + + const normalizedScore = cmn.jsonGet(result, 'normalized_score', 0); + cmn.jsonSet(scoringComponents, 'normalized_score', normalizedScore); + return scoringComponents; +} + +function getResultNormalizedScore(result) { + return cmn.jsonGet(result, 'normalized_score', 0); +} + +class NodeBindingNotFoundError extends Error { + constructor(edgeBinding) { + super(`Node binding not found for ${JSON.stringify(edgeBinding)}`); + } +} + +class EdgeBindingNotFoundError extends Error { + constructor(edgeBinding) { + super(`Edge binding not found for ${JSON.stringify(edgeBinding)}`); + } +} + +class AuxGraphNotFoundError extends Error { + constructor(auxGraph) { + super(`Auxiliary graph not found for ${auxGraph}`); + } +} diff --git a/lib/trapi.mjs b/lib/trapi.mjs index e96bbf2c..bd229782 100644 --- a/lib/trapi.mjs +++ b/lib/trapi.mjs @@ -1,2211 +1,720 @@ 'use strict'; - import { default as hash } from 'hash-sum'; import * as cmn from './common.mjs'; -import * as ev from './evidence.mjs'; import * as bl from './biolink-model.mjs'; -import * as bta from './biothings-annotation.mjs'; - -const subjectKey = 'sn'; -const objectKey = 'on'; - -export function makeMetadataObject(qid, agents) { - if (qid === undefined || !cmn.isString(qid)) { - throw new TypeError(`Expected argument qid to be of type string, got: ${qid}`); - } - if (agents === undefined || !cmn.isArray(agents)) { - throw new TypeError(`Expected argument agents to be type array, got: ${agents}`); +// Set by configuration. They allow us to assign endpoints to a graph for path generation. +// Both the subject and object keys could be the start or end of a path, but will be consistent +// for a single query. +let SUBJECT_KEY = null; +let OBJECT_KEY = null; + +export const CONSTANTS = { + ROOT: 'message', + QUALIFIERS: { + CONSTRAINTS: 'qualifier_constraints', + SET: 'qualifier_set', + ID: 'qualifier_type_id', + VALUE: 'qualifier_value' + }, + QGRAPH: { + KEY: 'query_graph', + INFERRED: 'inferred', + TEMPLATE: { + CHEMICAL_GENE: 0, + CHEMICAL_DISEASE: 1, + GENE_CHEMICAL: 2, + PATHFINDER: 3 + }, + NODES: 'nodes', + EDGES: 'edges', + }, + GRAPH: { + KEY: 'knowledge_graph', + NODES: 'nodes', + EDGES: 'edges', + EDGE: { + SUBJECT: 'subject', + OBJECT: 'object', + PREDICATE: 'predicate', + QUALIFIER: { + KEY: 'qualifiers', + ID: 'qualifier_type_id', + VALUE: 'qualifier_value' + } + }, + ATTRIBUTES: { + KEY: 'attributes', + ID: 'attribute_type_id', + VALUE: 'value' + }, + SOURCES: { + KEY: 'sources', + ID: 'resource_id', + ROLE: 'resource_role', + PRIMARY: 'primary_knowledge_source' + } + }, + AGRAPH: { + KEY: 'auxiliary_graphs' + }, + RESULTS: { + KEY: 'results', } - - return { - 'qid': qid, - 'aras': agents - }; } -export function queryToCreativeQuery(query) { - function buildCreativeQgraph(subject, object, predicate, direction) { - function nodeToQgNode(node) { - const qgNode = {}; - qgNode['categories'] = [bl.tagBiolink(node.category)]; - if (node.id) { - qgNode['ids'] = [node.id]; - } - - return qgNode; +class QNode { + constructor(binding, category, curies) { + if (!category) throw new TypeError(`Expected category to be a string, got: ${category}`); + this.categories = [bl.tagBiolink(category)]; + if (curies && !cmn.isArrayEmpty(curies)) { + this.ids = curies; } - const qgNodes = {}; - qgNodes[subjectKey] = nodeToQgNode(subject); - qgNodes[objectKey] = nodeToQgNode(object); - - const qgEdge = { - 'subject': subjectKey, - 'object': objectKey, - 'predicates': [bl.tagBiolink(predicate)], - 'knowledge_type': 'inferred', - }; - - if (direction) { - qgEdge['qualifier_constraints'] = [ - { - 'qualifier_set': [ - { - 'qualifier_type_id': 'biolink:qualified_predicate', - 'qualifier_value': bl.tagBiolink('causes') - }, - { - 'qualifier_type_id': 'biolink:object_aspect_qualifier', - 'qualifier_value': 'activity_or_abundance' - }, - { - 'qualifier_type_id': 'biolink:object_direction_qualifier', - 'qualifier_value': direction - } - ] - } - ] - } + this.binding = binding; + } + toTrapi() { return { - 'nodes': qgNodes, - 'edges': {'t_edge': qgEdge} + 'ids': this.ids, + 'categories': this.categories, } } - function diseaseToTrapiQgraph(disease) { - return buildCreativeQgraph( - {'category': 'ChemicalEntity'}, - {'category': 'Disease', 'id': disease}, - 'treats', - null); + static fromTrapi(binding, trapiQNode) { + const category = cmn.jsonGet(trapiQNode, 'categories')[0]; + const curies = cmn.jsonGet(trapiQNode, 'ids', []); + return new QNode(binding, category, curies); } +} - function geneToTrapiQgraph(gene, direction) { - return buildCreativeQgraph( - {'category': 'ChemicalEntity'}, - {'category': 'Gene', 'id': gene}, - 'affects', - direction); - } - - function chemicalToTrapiQgraph(chemical, direction) { - return buildCreativeQgraph( - {'category': 'ChemicalEntity', 'id': chemical}, - {'category': 'Gene'}, - 'affects', - direction); - } - - function genPathfinderQgraph(subject, object, constraint) { - function makeQNode(curie, category) { - const node = {}; - if (curie) { - node['ids'] = [curie]; - } - if (category) { - node['categories'] = [category]; - } - return node; - } - - function makeQEdge(subjectId, objectId) { - const edge = {}; - edge['subject'] = subjectId; - edge['object'] = objectId; - edge['predicates'] = [bl.tagBiolink('related_to')]; - edge['knowledge_type'] = 'inferred'; - return edge; - } - - const interKey = 'un'; - const nodes = {}; - nodes[subjectKey] = makeQNode(subject.id, subject.category); - nodes[objectKey] = makeQNode(object.id, object.category); - if (!constraint) { - constraint = bl.tagBiolink('NamedThing'); +class QEdge { + constructor(sub, obj, pred, /* TODO: edgeMode, */ constraints) { + this.subject = sub; + this.object = obj; + this.predicates = [bl.tagBiolink(pred)]; + // We only support creative queries right now. In the future we may want to support lookup queries. + this.queryMode = CONSTANTS.QGRAPH.INFERRED; + if (constraints && !cmn.isArrayEmpty(constraints)) { + this.constraints = constraints; } - nodes[interKey] = makeQNode(false, constraint); - const edges = {}; - edges['e0'] = makeQEdge(subjectKey, interKey); - edges['e1'] = makeQEdge(interKey, objectKey); - edges['e2'] = makeQEdge(subjectKey, objectKey); - return { - 'nodes': nodes, - 'edges': edges - }; } - if (!cmn.isObject(query)) { - throw new TypeError(`Expected query to be type object, got: ${query}`); - } - - let qg = null; - const queryType = cmn.jsonGet(query, 'type'); - switch (queryType) { - case 'drug': - qg = diseaseToTrapiQgraph(cmn.jsonGet(query, 'curie')); - break; - case 'gene': - qg = chemicalToTrapiQgraph(cmn.jsonGet(query, 'curie'), cmn.jsonGet(query, 'direction')); - break; - case 'chemical': - qg = geneToTrapiQgraph(cmn.jsonGet(query, 'curie'), cmn.jsonGet(query, 'direction')); - break; - case 'pathfinder': - const subject = cmn.jsonGet(query, 'subject'); - const object = cmn.jsonGet(query, 'object'); - const constraint = cmn.jsonGet(query, 'constraint', false); - qg = genPathfinderQgraph(subject, object, constraint); - break; - default: - throw new RangeError(`Expected query type to be one of [drug, gene, chemical], got: ${queryType}`); - } - - return { - 'message': { - 'query_graph': qg + toTrapi() { + return { + 'subject': this.subject.binding, + 'object': this.object.binding, + 'predicates': this.predicates, + 'constraints': this.constraints, + 'knowledge_type': this.queryMode } - }; -} - -export function creativeAnswersToSummary (qid, answers, maxHops) { - const nodeRules = makeSummarizeRules( - [ - aggregateProperty('name', ['names']), - aggregateProperty('categories', ['types']), - aggregateAttributes([bl.tagBiolink('xref')], 'curies'), - aggregateAttributes([bl.tagBiolink('description')], 'descriptions'), - ]); - - const edgeRules = makeSummarizeRules( - [ - transformProperty('predicate', (obj, key) => bl.sanitizeBiolinkItem(cmn.jsonGet(obj, key))), - aggregateAndTransformProperty('sources', ['provenance'], (obj, key) => getPrimarySource(cmn.jsonGet(obj, key))), - transformProperty('qualifiers', (obj, key) => cmn.jsonGet(obj, key, false)), - getProperty('subject'), - getProperty('object'), - getPublications(), - getSupportingText() - ]); - - const queryType = answerToQueryType(answers[0]); - function agentToName(agent) { - return bl.inforesToProvenance(agent).name; } - const [sfs, errors] = creativeAnswersToSummaryFragments(answers, nodeRules, edgeRules, maxHops); - - return summaryFragmentsToSummary( - qid, - sfs, - answerToKGraph(answers[0]), - queryType, - agentToName, - errors); -} - -// Create a minimal TRAPI message for the annotator -function createKGFromNodeIds(nodeIds) { - const nodes = {}; - nodeIds.forEach(id => { - if (bl.isValidCurie(id)) { - nodes[id] = {}; - } - }); - - const retval = { - message: { - knowledge_graph: { - edges: {}, - nodes: nodes + static fromTrapi(trapiQEdge, qNodes) { + const sub = getSub(trapiQEdge); + const obj = getObj(trapiQEdge); + const pred = cmn.jsonGet(trapiQEdge, 'predicates')[0]; + const constraints = cmn.jsonGet(trapiQEdge, 'constraints', []).map(constraint => { + if (CONSTANTS.QUALIFIERS.SET in constraint) { + return QEdgeQualifierSet.fromTrapi(constraint[CONSTANTS.QUALIFIERS.SET]); + } else { + throw new TypeError(`Unsupported constraint type found while building QEdge: ${constraint}`); } - } - }; - - return retval; -} - -const QUERY_TYPE = { - CHEMICAL_GENE: 0, - CHEMICAL_DISEASE: 1, - GENE_CHEMICAL: 2, - PATHFINDER: 3 -} - -function isChemicalGeneQuery(queryType) { - return queryType === QUERY_TYPE.CHEMICAL_GENE; -} + }); -function isChemicalDiseaseQuery(queryType) { - return queryType === QUERY_TYPE.CHEMICAL_DISEASE; -} + return new QEdge(qNodes[sub], qNodes[obj], pred, constraints); + } -function isGeneChemicalQuery(queryType) { - return queryType === QUERY_TYPE.GENE_CHEMICAL; -} + // Knowledge type is used everywhere in Translator and can mean different things depending on context. In the context of + // a query edge it can have two values: + // 1. 'lookup' - The edge will be directly queried from a knowledge graph. + // 2. 'inferred' - The reasoners are allowed to infer an edge based on combinations of other edges. + // Right now this essentially chooses between two different "query modes": lookup and creative. In the future a query could + // have a mix of types. + get queryMode() { return this.knowledge_type; } + set queryMode(qm) { this.knowledge_type = qm; } -function isPathfinderQuery(queryType) { - return queryType === QUERY_TYPE.PATHFINDER; -} + get constraints() { return this[CONSTANTS.QUALIFIERS.CONSTRAINTS]; } + set constraints(constraints) { this[CONSTANTS.QUALIFIERS.CONSTRAINTS] = constraints; } -function isValidQuery(queryType) { - return Object.values(QUERY_TYPE).includes(queryType); -} + // TODO: The TRAPI spec seems to allow for more than one qualifier set. What does that mean? + get qualifiers() { + return this.constraints.map(constraint => { + if (CONSTANTS.QUALIFIERS.SET in constraint) { + return constraint; + } + }); + } -function answerToKGraph(answer) { - const kg = cmn.jsonGetFromKpath(answer, ['message', 'knowledge_graph'], false); - if (!kg) return {}; - return kg; + genBinding() { + return hash([this.subject, this.object, this.predicates[0]]); + } } -function answerToQueryType(answer) { - const qg = cmn.jsonGetFromKpath(answer, ['message', 'query_graph'], false); - if (!qg) - { - return false; +class QEdgeQualifierSet { + constructor(qualifiers = []) { + this[CONSTANTS.QUALIFIERS.SET] = qualifiers; } - const [subjectKey, objectKey] = getPathDirection(qg); - if (isPathfinderQGraph(qg)) { - return QUERY_TYPE.PATHFINDER; + static fromTrapi(trapiQEdgeQualifiers) { + // Only support qualifier constraints right now. Not even sure there are other constraint types. + const qualifiers = trapiQEdgeQualifiers.map(qualifier => { + return QEdgeQualifier.fromTrapi(qualifier); + }); + return new QEdgeQualifierSet(qualifiers); } - const subCategory = cmn.jsonGetFromKpath(qg, ['nodes', subjectKey, 'categories'], false)[0]; - const objCategory = cmn.jsonGetFromKpath(qg, ['nodes', objectKey, 'categories'], false)[0]; - if (subCategory === bl.tagBiolink('ChemicalEntity') && - objCategory === bl.tagBiolink('Gene')) { - return QUERY_TYPE.CHEMICAL_GENE; - } - else if (subCategory === bl.tagBiolink('ChemicalEntity') && - objCategory === bl.tagBiolink('Disease')) { - return QUERY_TYPE.CHEMICAL_DISEASE; - } - else if (subCategory === bl.tagBiolink('Gene') && - objCategory === bl.tagBiolink('ChemicalEntity')) { - return QUERY_TYPE.GENE_CHEMICAL; + // Lets just say its a multiset + add(qualifier) { + this[CONSTANTS.QUALIFIERS.SET].push(qualifier); } - - return false; } -function makeMapping(key, transform, update, fallback) { - return (obj, context) => { - return (acc) => { - try { - const v = transform(obj, key, context); - return update(v, acc); - } catch (e) { - const agentErrors = cmn.jsonSetDefaultAndGet(context.errors, context.agent, []); - agentErrors.push(e.message); - return update(fallback, acc); - } - } +class QEdgeQualifier { + constructor(type, val) { + this[CONSTANTS.QUALIFIERS.ID] = type; + this[CONSTANTS.QUALIFIERS.VALUE] = val; } -} -function aggregatePropertyUpdateWhen(v, obj, kpath, doUpdate) { - const cv = cmn.jsonGetFromKpath(obj, kpath, false); - if (doUpdate(v)) { - const uv = cmn.isArray(v) ? v : [v]; - return cmn.jsonSetFromKpath(obj, kpath, cv ? cv.concat(uv) : uv); + static fromTrapi(trapiQEdgeQualifier) { + const type = cmn.jsonGet(trapiQEdgeQualifier, CONSTANTS.QUALIFIERS.ID); + const val = cmn.jsonGet(trapiQEdgeQualifier, CONSTANTS.QUALIFIERS.VALUE); + return new QEdgeQualifier(type, val); } - else if (cv) { - return obj - } - else { - return cmn.jsonSetFromKpath(obj, kpath, []); - } -} - -function aggregatePropertyUpdate(v, obj, kpath) { - return aggregatePropertyUpdateWhen(v, obj, kpath, v => v !== null); -} - -function renameAndTransformProperty(key, kpath, transform) { - return makeMapping( - key, - transform, - (v, obj) => { return cmn.jsonSetFromKpath(obj, kpath, v); }, - null); -} - -function transformProperty(key, transform) { - return renameAndTransformProperty(key, [key], transform); -} - -function renameProperty(key, kpath) { - return renameAndTransformProperty(key, kpath, (obj, key) => cmn.jsonGet(obj, key), null); -} - -function getProperty(key) { - return renameProperty(key, [key]); -} - -function aggregatePropertyWhen(key, kpath, doUpdate) { - return makeMapping( - key, - (obj, key) => cmn.jsonGet(obj, key), - (v, obj) => { return aggregatePropertyUpdateWhen(v, obj, kpath, doUpdate); }, - []); -} - -function aggregateAndTransformProperty(key, kpath, transform) { - return makeMapping( - key, - transform, - (v, obj) => { return aggregatePropertyUpdate(v, obj, kpath); }, - []); -} - -function aggregateProperty(key, kpath) { - return aggregatePropertyWhen(key, kpath, v => v !== null); -} - -function attrId(attribute) { - return cmn.jsonGet(attribute, 'attribute_type_id'); -} - -function attrValue(attribute) { - return cmn.jsonGet(attribute, 'value'); -} - -function attrAttributes(attribute) { - return cmn.jsonGet(attribute, 'attributes'); -} - -function areNoAttributes(attributes) { - return attributes === undefined || attributes === null || cmn.isArrayEmpty(attributes); } -function renameAndTransformAttribute(attributeId, kpath, transform) { - return makeMapping( - 'attributes', - (obj, key) => { - const attributes = cmn.jsonGet(obj, key, null); - if (areNoAttributes(attributes)) { - return null; - } - - for (const attribute of attributes) { - if (attributeId === attrId(attribute)) { - return transform(attrValue(attribute)); - } - } - - return null; - }, - (v, obj) => { - const currentValue = cmn.jsonGetFromKpath(obj, kpath, false); - if (currentValue && v === null) { - return obj; - } - - return cmn.jsonSetFromKpath(obj, kpath, v); - }, - null); -} - -function aggregateAndTransformAttributes(attributeIds, tgtKey, transform) { - return makeMapping( - 'attributes', - (obj, key, context) => { - const attributes = cmn.jsonGet(obj, key, null); - if (areNoAttributes(attributes)) { - return []; - } - - const result = []; - attributes.forEach(attribute => { - if (attributeIds.includes(attrId(attribute))) { - result.push(...transform(attrValue(attribute), context)); - } - }); - - return result; - }, - (v, obj) => { - const cv = cmn.jsonGet(obj, tgtKey, false); - cmn.jsonSet(obj, tgtKey, cv ? cv.concat(v) : v); - }, - []); +function makeQEdgeAspectQualifier(aspect) { + return new QEdgeQualifier(bl.tagBiolink('object_aspect_qualifier'), aspect); } -function aggregateAttributes(attributeIds, tgtKey) { - return aggregateAndTransformAttributes( - attributeIds, - tgtKey, - (v) => { return cmn.isArray(v) ? v : [v] }); +function makeQEdgeDirectionQualifier(direction) { + return new QEdgeQualifier(bl.tagBiolink('object_direction_qualifier'), direction); } -function tagAttribute(attributeId, transform) { - return makeMapping( - 'attributes', - (obj, key, context) => { - const attributes = obj[key]; - if (areNoAttributes(attributes)) { - return []; - } - - for (const attribute of attributes) { - if (attributeId === attrId(attribute)) { - return transform(attrValue(attribute), context); - } - } - - return []; - }, - (vs, obj) => { - const currentTags = cmn.jsonSetDefaultAndGet(obj, 'tags', {}); - if (!vs) { - return obj; - } - - if (!cmn.isArray(vs)) { - vs = [vs]; - } - - vs.forEach((v) => { - if (v && currentTags[v.tag] === undefined) { - currentTags[v.tag] = v.description; - } - }); - - return obj - }, - null); +function makeQEdgePredQualifier(pred) { + return new QEdgeQualifier(bl.tagBiolink('qualified_predicate'), pred); } -function getSupportingText() { - const searchAttrId = bl.tagBiolink('has_supporting_study_result'); - const publicationsId = bl.tagBiolink('publications'); - const textId = bl.tagBiolink('supporting_text'); - const subjectTokenId = bl.tagBiolink('subject_location_in_text'); - const objectTokenId = bl.tagBiolink('object_location_in_text'); - - function parseTokenIndex(token) { - const range = token.split('|').map(t => parseInt(t.trim())); - range[0] = range[0] + 1; - return range; +class QGraph { + constructor(qNodes = {}, qEdges = {}) { + this.qNodes = qNodes; + this.qEdges = qEdges; } - return makeMapping( - 'attributes', - (obj, key) => { - const attributes = obj[key]; - if (areNoAttributes(attributes)) { - return {}; - } - - const supportingText = {}; - attributes.forEach(attribute => { - const innerAttrs = attrId(attribute) === searchAttrId ? attrAttributes(attribute) : []; - const supportingTextData = {}; - innerAttrs.forEach(attribute => { - const aid = attrId(attribute); - const av = attrValue(attribute); - if (aid === publicationsId) { - supportingText[ev.sanitize(av)] = supportingTextData; - } else if (aid === textId) { - supportingTextData.text = av; - } else if (aid === subjectTokenId) { - supportingTextData.subject = av; - } else if (aid === objectTokenId) { - supportingTextData.object = av; - } - }); - - if (!cmn.isObjectEmpty(supportingTextData)) { - supportingTextData.subject = parseTokenIndex(supportingTextData.subject); - supportingTextData.object = parseTokenIndex(supportingTextData.object); - } - }); - - return supportingText; - }, - (vs, obj) => - { - const currentSupportingText = cmn.jsonSetDefaultAndGet(obj, 'supporting_text', {}); - Object.keys(vs).forEach((pid) => { - currentSupportingText[pid] = vs[pid]; - }); - }, - {}); -} - -function getPublications() { - const publicationIds = [ - bl.tagBiolink('supporting_document'), - bl.tagBiolink('Publication'), - bl.tagBiolink('publications'), - bl.tagBiolink('publication') // Remove me when this is fixed in the ARA/KPs - ]; - - return makeMapping( - 'attributes', - (obj, key, context) => { - const attributes = obj[key]; - if (areNoAttributes(attributes)) { - return {}; - } - - const result = {}; - const provenance = bl.inforesToProvenance(context.primarySource); - const knowledgeLevel = getKnowledgeLevel(obj, provenance); - attributes.forEach(attribute => { - let v = (publicationIds.includes(attrId(attribute))) ? attrValue(attribute) : null; - if (v !== null) { - v = cmn.isArray(v) ? v : [v]; - if (!result[knowledgeLevel]) { - result[knowledgeLevel] = []; - } - - result[knowledgeLevel].push(...(v.map((pubId => { - return { - id: ev.sanitize(pubId), - source: provenance - }; - })))); - }; - }); - - return result; - }, - (vs, obj) => - { - const currentPublications = cmn.jsonSetDefaultAndGet(obj, 'publications', {}); - Object.keys(vs).forEach((knowledgeLevel) => { - if (!currentPublications[knowledgeLevel]) { - currentPublications[knowledgeLevel] = []; - } - - currentPublications[knowledgeLevel].push(...(vs[knowledgeLevel])); - }); - - return obj; - }, - {}); -} - -function getPrimarySource(sources) { -for (let source of sources) { - const id = cmn.jsonGet(source, 'resource_id', false); - const role = cmn.jsonGet(source, 'resource_role', false); - if (!role || !id) { - continue; - } - else if (role === 'primary_knowledge_source') { - return [id]; - } - } - - throw new Error('No primary knowledge source found'); -} + toTrapi() { + const nodes = {}; + Object.keys(this.qNodes).forEach((binding) => { + nodes[binding] = this.qNodes[binding].toTrapi(); + }); + const edges = {}; + Object.keys(this.qEdges).forEach((binding) => { + edges[binding] = this.qEdges[binding].toTrapi(); + }); -function getKnowledgeLevel(kedge, provenance) { - const attributes = cmn.jsonGet(kedge, 'attributes', []); - if (areNoAttributes(attributes)) { - return provenance.knowledge_level; + return { + 'nodes': nodes, + 'edges': edges + }; } - let rawKnowledgeLevel = null; - let rawAgentType = null; - for (const attribute of attributes) { - if (attrId(attribute) === bl.tagBiolink('knowledge_level')) { - rawKnowledgeLevel = attrValue(attribute); + static fromTrapi(trapiQGraph) { + const qg = new QGraph(); + const nodes = cmn.jsonGet(trapiQGraph, 'nodes'); + for (const [binding, node] of Object.entries(nodes)) { + qg.qNodes[binding] = QNode.fromTrapi(binding, node); } - if (attrId(attribute) === bl.tagBiolink('agent_type')) { - rawAgentType = attrValue(attribute); + const edges = cmn.jsonGet(trapiQGraph, 'edges'); + for (const [binding, edge] of Object.entries(edges)) { + qg.qEdges[binding] = QEdge.fromTrapi(edge, qg.qNodes); } - } - - if (rawAgentType === 'text_mining_agent') { - return 'ml'; - } else if (rawKnowledgeLevel === 'knowledge_assertion') { - return 'trusted'; - } else if (rawKnowledgeLevel === 'not_provided') { - return 'unknown'; - } else if (rawKnowledgeLevel !== null) { - return 'inferred'; - } - - return provenance.knowledge_level; -} - -function makeSummarizeRules(rules) { - return (obj, context) => { - return rules.map(rule => { return rule(obj, context); }); - }; -} - -function trapiBindingToKobj(binding, type, kgraph) { - return cmn.jsonGet(cmn.jsonGet(kgraph, type, {}), binding, false); -} - -function redgeToTrapiKedge(edgeBinding, kgraph) { - return trapiBindingToKobj(edgeBinding, 'edges', kgraph); -} - -function rnodeToTrapiKnode(nodeBinding, kgraph) { - return trapiBindingToKobj(nodeBinding, 'nodes', kgraph); -} -function getNodeBindingEndpoints(bindings, key) { - const nodeBinding = cmn.jsonGet(bindings, key, false); - if (!nodeBinding) { - throw new NodeBindingNotFoundError(nodeBinding); + return qg; } - return nodeBinding.map((entry) => { - let endpoint = cmn.jsonGet(entry, 'query_id', false); - if (!endpoint) { - endpoint = cmn.jsonGet(entry, 'id') + get qNodes() { return this[CONSTANTS.QGRAPH.NODES]; } + qNodeCount() { return Object.keys(this.qNodes).length; } + set qNodes(nodes) { this[CONSTANTS.QGRAPH.NODES] = nodes; } + get qEdges() { return this[CONSTANTS.QGRAPH.EDGES]; } + qEdgeCount() { return Object.keys(this.qEdges).length; } + set qEdges(edges) { this[CONSTANTS.QGRAPH.EDGES] = edges; } + get template() { + let [start, end] = [SUBJECT_KEY, OBJECT_KEY]; + const startIsObj = this.qNodes[SUBJECT_KEY].ids + if (startIsObj) { + [start, end] = [OBJECT_KEY, SUBJECT_KEY]; } - return endpoint; - }); -} - -function flattenBindings(bindings) { - return Object.values(bindings).reduce((ids, binding) => { - return ids.concat(binding.map(obj => { return cmn.jsonGet(obj, 'id'); })); - }, - []); -} - -function kedgeSubject(kedge) { - return cmn.jsonGet(kedge, 'subject'); -} - -function kedgeObject(kedge) { - return cmn.jsonGet(kedge, 'object'); -} - -function kedgePredicate(kedge) { - return cmn.jsonGet(kedge, 'predicate'); -} - -function isNodeIndex(index) { - return index % 2 === 0; -} + const startCategory = this.qNodes[start].categories[0]; + const endCategory = this.qNodes[end].categories[0]; + if (startCategory === bl.tagBiolink('ChemicalEntity') && + endCategory === bl.tagBiolink('Gene')) { + return CONSTANTS.QGRAPH.TEMPLATE.CHEMICAL_GENE; + } else if (startCategory === bl.tagBiolink('ChemicalEntity') && + endCategory === bl.tagBiolink('Disease')) { + return CONSTANTS.QGRAPH.TEMPLATE.CHEMICAL_DISEASE; + } else if (startCategory === bl.tagBiolink('Gene') && + endCategory === bl.tagBiolink('ChemicalEntity')) { + return CONSTANTS.QGRAPH.TEMPLATE.GENE_CHEMICAL; + } else if (this.nodeCount === 3 && this.edgeCount === 3) { + return CONSTANTS.QGRAPH.TEMPLATE.PATHFINDER; + } -function kedgeAttributes(kedge) { - const attributes = cmn.jsonGet(kedge, 'attributes', null); - if (areNoAttributes(attributes)) { - return []; + throw new RangeError(`Unsupported query graph template: ${startCategory} -> ${endCategory}`); } - - return attributes; } -function kedgeSupportGraphs(kedge) { - const attributes = kedgeAttributes(kedge); - for (const attr of attributes) { - if (attrId(attr) === bl.tagBiolink('support_graphs')) { - return attrValue(attr); +class Query { + constructor(clientReq) { + if (!cmn.isObject(clientReq)) { + throw new TypeError(`Expected clientReq to be type object, got: ${clientReq}`); } - }; - return []; -} + this.template = queryTypeToTemplate(cmn.jsonGet(clientReq, 'type', null)); + this.curie = cmn.jsonGet(clientReq, 'curie', null); + this.direction = cmn.jsonGet(clientReq, 'direction', null); -function kedgeToQualifiers(kedge) { - const kedgeQualifiers = cmn.jsonGet(kedge, 'qualifiers', false); - if (!kedgeQualifiers || !cmn.isArray(kedgeQualifiers) || cmn.isArrayEmpty(kedgeQualifiers)) { - return false; + // TODO: This is only for pathfinder, we need to fix this + this.subject = cmn.jsonGet(clientReq, 'subject', null); + this.object = cmn.jsonGet(clientReq, 'object', null); + this.constraint = cmn.jsonGet(clientReq, 'constraint', null); } - - const qualifiers = {}; - kedgeQualifiers.forEach((q) => { - const qualifierKey = q['qualifier_type_id']; - const qualifierValue = q['qualifier_value']; - if (qualifierKey === undefined || qualifierValue === undefined) - { - return false; - } - - qualifiers[bl.sanitizeBiolinkItem(qualifierKey)] = bl.sanitizeBiolinkItem(qualifierValue); - }); - - return qualifiers; } -// Get the most specific predicate available from a kedge -function getSpecificPredicate(kedge) { - const qualifiers = kedgeToQualifiers(kedge); - if (!qualifiers) { - return kedge.predicate; - } - - return cmn.jsonGet(qualifiers, 'qualified predicate', kedge.predicate); +/* + * Load the TRAPI configuration. Must be called before using the module. + */ +export function loadTrapi(trapiConfig) { + SUBJECT_KEY = trapiConfig.query_subject_key + OBJECT_KEY = trapiConfig.query_object_key } -function edgeToQualifiedPredicate(kedge, invert = false) { - function qualifiersToString(type, qualifiers, prefixes) { - // TODO: How do part and derivative qualifiers interact? Correct ordering? - // TODO: How to handle the context qualifier? - // TODO: Make more robust to biolink qualifier changes. - // This ordering is important for building the correct statement - const qualifierKeys = ['form or variant', 'direction', 'aspect', 'part', 'derivative']; - const qualifierValues = qualifierKeys.map(key => cmn.jsonGet(qualifiers, `${type} ${key} qualifier`, false)); - - let qualifierStr = ''; - qualifierValues.forEach((qv, i) => { - if (qv) { - if (qualifierStr) { - qualifierStr += ' ' - } - - if (prefixes[i]) { - qualifierStr += `${prefixes[i]} `; - } - - qualifierStr += qv; - } - }); +/* + * Convert a request from the client to a TRAPI query for the ARS. + * + * @param {object} query - The query to convert. + * @returns {object} - A TRAPI compliant message wth a query graph. + */ +export function clientReqToTrapiQuery(clientReq) { + function makeQueryTemplateGraph(subNode, objNode, qEdge) { + const qNodes = {}; + qNodes[subNode.binding] = subNode; + qNodes[objNode.binding] = objNode; + const qEdges = {}; + qEdges[qEdge.genBinding()] = qEdge; + const x= (new QGraph(qNodes, qEdges)).toTrapi(); + return (new QGraph(qNodes, qEdges)).toTrapi(); - return qualifierStr; } - function subjectQualifiersToString(qualifiers, prefixes) { - return qualifiersToString('subject', qualifiers, prefixes); + function makeGeneChemicalConstraints(direction) { + const qualifierSet = new QEdgeQualifierSet(); + qualifierSet.add(makeQEdgePredQualifier(bl.tagBiolink('causes'))); + qualifierSet.add(makeQEdgeAspectQualifier('activity_or_abundance')); + qualifierSet.add(makeQEdgeDirectionQualifier(direction)); + return [qualifierSet]; } - function objectQualifiersToString(qualifiers, prefixes) { - return qualifiersToString('object', qualifiers, prefixes); + function diseaseToTrapiQgraph(diseaseCurie) { + const subNode = new QNode(SUBJECT_KEY, 'ChemicalEntity'); + const objNode = new QNode(OBJECT_KEY, 'Disease', [diseaseCurie]); + return makeQueryTemplateGraph( + subNode, + objNode, + new QEdge(subNode, objNode, bl.tagBiolink('treats'))); } - function finalizeQualifiedPredicate(prefix, predicate, suffix) { - if (prefix) { - prefix += ' '; - } - - if (suffix) { - suffix = ` ${suffix} of`; - } - - const finalPredicate = `${prefix}${predicate}${suffix}`; - return finalPredicate; + function geneToTrapiQgraph(geneCurie, direction) { + const subNode = new QNode(SUBJECT_KEY, 'ChemicalEntity'); + const objNode = new QNode(OBJECT_KEY, 'Gene', [geneCurie]); + return makeQueryTemplateGraph( + subNode, + objNode, + new QEdge(subNode, objNode, bl.tagBiolink('affects'), makeGeneChemicalConstraints(direction))); + } + + function chemicalToTrapiQgraph(chemicalCurie, direction) { + const subNode = new QNode(SUBJECT_KEY, 'ChemicalEntity', [chemicalCurie]); + const objNode = new QNode(OBJECT_KEY, 'Gene'); + return makeQueryTemplateGraph( + subNode, + objNode, + new QEdge(subNode, objNode, bl.tagBiolink('affects'), makeGeneChemicalConstraints(direction))); } - function getSpecialCase(predicate, qualifiers, invert) { - const objDirectionQualifier = qualifiers['object direction qualifier']; - if (predicate === 'regulates' && - (objDirectionQualifier === 'upregulated' || - objDirectionQualifier === 'downregulated')) { - if (invert) { - return `is ${objDirectionQualifier} by`; - } - - return objDirectionQualifier.replace('ed', 'es'); + function makePathfinderQgraph(subject, object, constraint) { + const subNode = new QNode(SUBJECT_KEY, subject.category, [subject.id]); + const objNode = new QNode(OBJECT_KEY, object.category, [object.id]); + if (!constraint) { + constraint = bl.tagBiolink('NamedThing'); } - - return false; + const interNode = new QNode('un', constraint); + const qNodes = {}; + qNodes[subNode.binding] = subNode + qNodes[objNode.binding] = objNode + qNodes[interNode.binding] = interNode + const qEdges = {}; + const predicate = 'related_to'; + qEdges['e0'] = new QEdge(subNode, interNode, predicate) + qEdges['e1'] = new QEdge(interNode, objNode, predicate) + qEdges['e2'] = new QEdge(subNode, objNode, predicate) + return (new QGraph(qNodes, qEdges)).toTrapi(); } - let predicate = bl.sanitizeBiolinkItem(kedgePredicate(kedge)); - let qualifiers = kedgeToQualifiers(kedge); - if (!qualifiers && bl.isDeprecatedPredicate(predicate)) { - [predicate, qualifiers] = bl.deprecatedPredicateToPredicateAndQualifiers(predicate); + const query = new Query(clientReq); + if (isChemicalDiseaseQuery(query.template)) { + return makeTrapiMessage(diseaseToTrapiQgraph(query.curie)); + } else if (isGeneChemicalQuery(query.template)) { + return makeTrapiMessage(chemicalToTrapiQgraph(query.curie, query.direction)); + } else if (isChemicalGeneQuery(query.template)) { + return makeTrapiMessage(geneToTrapiQgraph(query.curie, query.direction)); + } else if (isPathfinderQuery(query.template)) { + return makeTrapiMessage(makePathfinderQgraph(query.subject, query.object, query.constraint)); } - // If we don't have any qualifiers, treat it like biolink v2 - if (!qualifiers) { - if (invert) { - predicate = bl.invertBiolinkPredicate(predicate); - } - - return predicate; - } + throw new RangeError(`Expected query type to be one of [drug, gene, chemical], got: ${queryType}`); +} - predicate = bl.sanitizeBiolinkItem(getSpecificPredicate(kedge)); - const specialCase = getSpecialCase(predicate, qualifiers, invert); - if (specialCase) { - return specialCase; - } +export function nodeIdsToTrapiMessage(nodeIds) { + return makeTrapiMessage(false, makeKgraphFromNodeIds(nodeIds)); +} - const subjectPrefixes = ['of a', 'has', false, 'of the', false]; - const objectPrefixes = ['a', false, false, 'of the', false]; - if (invert) { - const subjectQualifierStr = subjectQualifiersToString(qualifiers, objectPrefixes); - const objectQualifierStr = objectQualifiersToString(qualifiers, subjectPrefixes); - return finalizeQualifiedPredicate(objectQualifierStr, - bl.invertBiolinkPredicate(predicate), - subjectQualifierStr); +export function getQueryGraph(trapiMessage) { + const qg = cmn.jsonGetFromKpath(trapiMessage, [CONSTANTS.ROOT, CONSTANTS.QGRAPH.KEY], false); + if (!qg) { + throw new MissingQueryGraphError(trapiMessage); } - const subjectQualifierStr = subjectQualifiersToString(qualifiers, subjectPrefixes); - const objectQualifierStr = objectQualifiersToString(qualifiers, objectPrefixes); - return finalizeQualifiedPredicate(subjectQualifierStr, - predicate, - objectQualifierStr); + return qg; } -function makeTag(tag, name, description = '') { - return { - 'tag': tag, - 'description': makeTagDescription(name, description) - }; +export function getResults(trapiMessage) { + const results = cmn.jsonGetFromKpath(trapiMessage, [CONSTANTS.ROOT, CONSTANTS.RESULTS.KEY], false); + return results; } -function makeTagDescription(name, description = '') { - return { - 'name': name, - 'value': description - }; +export function getAuxGraphs(trapiMessage) { + const results = cmn.jsonGetFromKpath(trapiMessage, [CONSTANTS.ROOT, CONSTANTS.AGRAPH.KEY], false); + return results; } -function getTagFamily(tag) { - return tag.split('/')[1]; +export function getAuxGraph(gid, auxGraphs) { + return cmn.jsonGet(auxGraphs, gid, false); } -function isResultTag(tag) { - return tag.startsWith('r/'); +export function getAuxGraphEdges(auxGraph) { + return cmn.jsonGet(auxGraph, 'edges', []); } -function isPathTag(tag) { - return tag.startsWith('p/'); +export function getEdgeBindings(analysis) { + return cmn.jsonGet(analysis, 'edge_bindings', []) } -function isExternalTag(tag) { - const family = getTagFamily(tag); - const validFamilies = ['cc', 'di', 'pc', 'pt', 'role', 'ara']; - return validFamilies.includes(family); +export function getNodeBinding(result, key) { + return cmn.jsonGetFromKpath(result, ['node_bindings', key], []); } -function determineAnswerTag(type, answerTags, queryType) { - function isDrug(type, fdaLevel) { - return fdaLevel === 4 || type === 'Drug'; - } - - function isClinicalPhase(fdaLevel) { - return fdaLevel > 0 && fdaLevel < 4; - } - - if (!isValidQuery(queryType) || isGeneChemicalQuery(queryType)) { - return [false, false]; - } - - const fdaTags = Object.keys(answerTags).filter((tag) => { return tag.startsWith('r/fda'); }); - let highestFdaApproval = 0; - if (!cmn.isArrayEmpty(fdaTags)) { - highestFdaApproval = Math.max(...fdaTags.map((tag) => { return parseInt(tag.split('/')[2]); })); - } - - if (highestFdaApproval === 0) return ['r/cc/other', 'Other']; - - if (isDrug(type, highestFdaApproval)) return ['r/cc/drug', 'Drug']; - - if (isClinicalPhase(highestFdaApproval)) return [`r/cc/phase${highestFdaApproval}`, `Phase ${highestFdaApproval} Drug`]; - - return [`r/cc/other`, `Other`]; +export function getKgraph(trapiMessage) { + return cmn.jsonGetFromKpath(trapiMessage, [CONSTANTS.ROOT, CONSTANTS.GRAPH.KEY]); } -function makeRgraph(rnodes, redges, edgeMappings, kgraph) { - if (!redges) { - return false; - } - - const knodes = cmn.jsonGet(kgraph, 'nodes'); - for (const rnode of rnodes) { - if (!cmn.jsonHasKey(knodes, rnode)) { - return false; - } - } - - const rgraph = {}; - rgraph.nodes = rnodes; - rgraph.edges = redges.filter(redge => { - const kedge = redgeToTrapiKedge(redge, kgraph); - return bl.isBiolinkPredicate(kedgePredicate(kedge)); - }); - rgraph.edgeMappings = edgeMappings; - - return rgraph; +export function getKedge(edgeBinding, kgraph) { + return getKgraphElem(edgeBinding, CONSTANTS.GRAPH.EDGES, kgraph); } -function isRedgeInverted(redge, subject, kgraph) { - const kedge = redgeToTrapiKedge(redge, kgraph); - return subject === kedgeObject(kedge); +export function getKnode(nodeBinding, kgraph) { + return getKgraphElem(nodeBinding, CONSTANTS.GRAPH.NODES, kgraph); } -function analysisToRgraph(analysis, kgraph, auxGraphs) { - const edgeBindingData = new Map(); - let unprocessedEdgeBindings = flattenBindings(cmn.jsonGet(analysis, 'edge_bindings', [])).map((eb) => { - edgeBindingData[eb] = { partOf: ['root'] }; - return eb; - }); - - let unprocessedSupportGraphs = []; - const nodeBindings = new Set(); - const supportGraphs = new Set(); - // Invariant: edges and subgraphs will only ever be processed once. This is very important - // for how the following code works. - while (!cmn.isArrayEmpty(unprocessedEdgeBindings) || !cmn.isArrayEmpty(unprocessedSupportGraphs)) { - while (!cmn.isArrayEmpty(unprocessedEdgeBindings)) { - const eb = unprocessedEdgeBindings.pop(); - if (edgeBindingData[eb].support !== undefined) { - continue; - } - - const kedge = redgeToTrapiKedge(eb, kgraph); - if (!kedge) { - throw new EdgeBindingNotFoundError(eb); - } - - nodeBindings.add(kedgeSubject(kedge)); - nodeBindings.add(kedgeObject(kedge)); - const edgeSupportGraphs = kedgeSupportGraphs(kedge); - edgeBindingData[eb].support = edgeSupportGraphs; - edgeSupportGraphs.forEach((sg) => { - if (!supportGraphs.has(sg)) { - unprocessedSupportGraphs.push(sg); - } - }); - }; - - while (!cmn.isArrayEmpty(unprocessedSupportGraphs)) { - const gid = unprocessedSupportGraphs.pop(); - if (supportGraphs.has(gid)) { - continue; - } - - const auxGraph = cmn.jsonGet(auxGraphs, gid, false); - if (!auxGraph) { - throw new AuxGraphNotFoundError(gid); - } - - const sgEdgeBindings = cmn.jsonGet(auxGraph, 'edges', []); - sgEdgeBindings.forEach((eb) => { - if (!edgeBindingData[eb]) { - edgeBindingData[eb] = { partOf: [gid] }; - unprocessedEdgeBindings.push(eb); - } else { - // We do not want to process the same edge twice, but we need to include this - // graph as a graph where this edge occurs. - edgeBindingData[eb].partOf.push(gid); - } - }); - - supportGraphs.add(gid); - } - } - - return makeRgraph([...nodeBindings], [...Object.keys(edgeBindingData)], edgeBindingData, kgraph); +export function hasKnode(nodeBinding, kgraph) { + return getKnode(nodeBinding, kgraph) !== null; } -function rnodeToKey(rnode, kgraph) { - return rnode; -} +export class AttributeIterator { + constructor(attrs) { + if (noAttrs(attrs)) { + this.attrs = []; + } else { + this.attrs = attrs; + } -function redgeToKey(redge, kgraph, doInvert = false) { - const kedge = redgeToTrapiKedge(redge, kgraph); - const ksubject = kedgeSubject(kedge); - const predicate = edgeToQualifiedPredicate(kedge, doInvert); - const kobject = kedgeObject(kedge); - const provenance = bl.inforesToProvenance(getPrimarySource(cmn.jsonGet(kedge, 'sources'))[0]); - const knowledgeLevel = getKnowledgeLevel(kedge, provenance); - if (doInvert) { - return pathToKey([kobject, predicate, ksubject, knowledgeLevel]); + this.index = 0; } - return pathToKey([ksubject, predicate, kobject, knowledgeLevel]); -} - -function summarizeRnode(rnode, kgraph, nodeRules, context) { - const rnodeKey = rnodeToKey(rnode, kgraph); - return cmn.makePair(rnodeToKey(rnode, kgraph), - nodeRules(rnodeToTrapiKnode(rnode, kgraph), context), - 'key', - 'transforms'); -} - -function summarizeRedge(redge, kgraph, edgeRules, context, edgeBaseKeys) { - let edgeKey = redgeToKey(redge, kgraph); - if (!edgeBaseKeys.has(edgeKey)) { - edgeKey = redgeToKey(redge, kgraph, true); + hasNext() { + return this.index < this.attrs.length; } - return cmn.makePair(edgeKey, - edgeRules(redgeToTrapiKedge(redge, kgraph), context), - 'key', - 'transforms'); -} + findOne(searchIds, sentinel = null) { + while (this.hasNext()) { + const attr = this.attrs[this.index++]; + if (searchIds.includes(getAttrId(attr))) { + return attr; + } + } -function makeRedgeToEdgeId(rgraph, kgraph) { - function makeEdgeId(subject, object) - { - return cmn.makePair(subject, object, 'subject', 'object'); + return sentinel } - let redgeToEdgeId = {}; - rgraph.edges.forEach(redge => { - const kedge = redgeToTrapiKedge(redge, kgraph); - cmn.jsonSet(redgeToEdgeId, redge, makeEdgeId(kedgeSubject(kedge), kedgeObject(kedge))); - }); - - return (redge) => { return cmn.jsonGet(redgeToEdgeId, redge); }; -} - -function makeRnodeToOutEdges(rgraph, kgraph) { + findOneVal(searchIds, sentinel = null) { + const attr = this.findOne(searchIds, sentinel); + if (attr !== sentinel) { + return getAttrVal(attr); + } - function makeOutEdge(redge, node) { - return cmn.makePair(redge, node, 'redge', 'target'); + return sentinel; } - const redgeToEdgeId = makeRedgeToEdgeId(rgraph, kgraph); - const rnodeToOutEdges = {}; - rnodeToOutEdges.update = (rnode, val) => { - const outEdges = cmn.jsonGet(rnodeToOutEdges, rnode, []); - outEdges.push(val); - cmn.jsonSet(rnodeToOutEdges, rnode, outEdges); - }; - - rgraph.edges.forEach(redge => { - const edgeId = redgeToEdgeId(redge); - const subject = edgeId.subject; - const object = edgeId.object; - - rnodeToOutEdges.update(subject, makeOutEdge(redge, object)); - rnodeToOutEdges.update(object, makeOutEdge(redge, subject)); - }); - - return (rnode) => { return cmn.jsonGet(rnodeToOutEdges, rnode, []); }; -} + findAll(searchIds, sentinel = null) { + const result = []; + while (this.hasNext()) { + const attr = this.findOne(searchIds, sentinel); + if (attr === sentinel) break; + result.push(attr); + } -function rgraphFold(proc, init, acc) { - let objLeft = init; - let res = acc; - while (!cmn.isArrayEmpty(objLeft)) { - const paths = proc(objLeft.pop()); - objLeft.push(...paths.first); - res.push(...paths.second); + return result; } - return res; -} - -function makeSummaryFragment(agents, paths, nodes, edges, scores, errors) { - const summaryFragment = {}; - summaryFragment.agents = agents; - summaryFragment.paths = paths; - summaryFragment.nodes = nodes; - summaryFragment.edges = edges; - summaryFragment.scores = scores; - summaryFragment.errors = errors; - return summaryFragment; -} - -function emptySummaryFragment() { - return makeSummaryFragment( - [], // agents - [], // paths - [], // nodes - {base: {}, updates: []}, //edges - {}, // scores - {} // errors - ); -} + findAllVal(searchIds, sentinel = null) { + const result = []; + while (this.hasNext()) { + const attr = this.findOne(searchIds, sentinel); + if (attr === sentinel) break; + const attrVal = getAttrVal(attr); + if (attrVal !== null) { + result.push(...cmn.coerceArray(attrVal)); + } + } -function errorSummaryFragment(agent, error) { - const summaryFragment = emptySummaryFragment(); - summaryFragment.agents = [agent]; - summaryFragment.errors[agent] = [error]; - return summaryFragment; + return result; + } } -function isEmptySummaryFragment(summaryFragment) { - return cmn.isArrayEmpty(summaryFragment.paths) && - cmn.isArrayEmpty(summaryFragment.nodes) && - cmn.isObjectEmpty(summaryFragment.edges.base) && - cmn.isArrayEmpty(summaryFragment.edges.updates); +export function getAttrs(graphElem) { + return cmn.jsonGet(graphElem, CONSTANTS.GRAPH.ATTRIBUTES.KEY, []); } -function condensedSummaryAgents(condensedSummary) { - return condensedSummary.agents; +export function getAttrId(attr) { + return cmn.jsonGet(attr, CONSTANTS.GRAPH.ATTRIBUTES.ID); } -function condensedSummaryPaths(condensedSummary) { - return condensedSummary.paths; +export function getAttrVal(attr) { + return cmn.jsonGet(attr, CONSTANTS.GRAPH.ATTRIBUTES.VALUE); } -function condensedSummaryNodes(condensedSummary) { - return condensedSummary.nodes; +export function noAttrs(attrs) { + return attrs === undefined || attrs === null || cmn.isArrayEmpty(attrs); } -function condensedSummaryEdges(condensedSummary) { - return condensedSummary.edges; -} +/* Gets the primary knowledge source from a TRAPI Graph Element. + * + * @param {string} sources - The key to extract from a Graph Element. + * + * @returns {function} - The extraction rule. + */ +export function getPrimarySrc(graphElem) { + const srcs = cmn.jsonGet(graphElem, CONSTANTS.GRAPH.SOURCES.KEY, []); + for (let src of srcs) { + const id = cmn.jsonGet(src, CONSTANTS.GRAPH.SOURCES.ID, false); + const role = cmn.jsonGet(src, CONSTANTS.GRAPH.SOURCES.ROLE, false); + if (!role || !id) continue; + if (role === CONSTANTS.GRAPH.SOURCES.PRIMARY) return id; + } -function condensedSummaryScores(condensedSummary) { - return condensedSummary.scores; + throw new Error(`No primary knowledge source found: ${JSON.stringify(graphElem)}`); } -function condensedSummaryErrors(condensedSummary) { - return condensedSummary.errors; +export function getSub(kedge) { + return cmn.jsonGet(kedge, CONSTANTS.GRAPH.EDGE.SUBJECT); } -function makeEdgeBase() { - return { - aras: [], - support: [], - is_root: false - }; +export function getObj(kedge) { + return cmn.jsonGet(kedge, CONSTANTS.GRAPH.EDGE.OBJECT); } -function mergeEdgeBase(eb1, eb2) { - eb1.aras.push(...eb2.aras); - eb1.support.push(...eb2.support); - eb1.is_root = eb1.is_root || eb2.is_root; - return eb1 +export function getPred(kedge) { + return cmn.jsonGet(kedge, CONSTANTS.GRAPH.EDGE.PREDICATE); } -function edgeToString(edge) { - return `${edge.subject}-${edge.predicate}-${edge.object}`; +export function getSupGraphs(kedge) { + return _getGraphElemAttrVal(kedge, bl.tagBiolink('support_graphs'), []); } -function updateErrorsFromEdge(edge, errors, edgeErrorReasons) { - const edgeAras = edge.aras; - let edgeErrors = null; - if (edgeAras.length !== 1) { - edgeErrors = cmn.jsonSetDefaultAndGet(errors, 'unknown', []); - } else { - edgeErrors = cmn.jsonSetDefaultAndGet(errors, edgeAras[0], []); +export function getQualifiers(kedge) { + const qualifiers = cmn.jsonGet(kedge, CONSTANTS.GRAPH.EDGE.QUALIFIER.KEY, []); + if (!cmn.isArray(qualifiers)) { + throw new InvalidQualifiersError(kedge); } - edgeErrors.push(...edgeErrorReasons); + return qualifiers; } -function reasonsForEdgeErrors(edge) { - const reasons = []; - if (!edge.subject || !edge.object || !edge.predicate) { - reasons.push(`Invalid edge found: ${edgeToString(edge)}`); - } - - if (!edge.provenance || edge.provenance.length === 0) { - reasons.push(`No provenance for edge: ${edgeToString(edge)}`); - } - - return reasons; +export function getQualifierId(qualifier) { + return cmn.jsonGet(qualifier, CONSTANTS.GRAPH.EDGE.QUALIFIER.ID, false); } -function pathToKey(path) { - return hash(path); +export function getQualifierVal(qualifier) { + return cmn.jsonGet(qualifier, CONSTANTS.GRAPH.EDGE.QUALIFIER.VALUE); } -function mergeFragmentObjects(obj1, obj2) { - Object.keys(obj2).forEach((k) => { - const current = cmn.jsonSetDefaultAndGet(obj1, k, []); - current.push(...obj2[k]); - }); +/* Gets the knowledge level from a TRAPI Knowledge Edge. + * + * @param {object} kedge - An edge from the Knowledge Graph to extract the knowledge level from. + * + * @returns {string} - The knowledge level. + */ +export function getKlevel(kedge) { + return _getGraphElemAttrVal(kedge, bl.tagBiolink('knowledge_level')); } -function mergeSummaryFragments(f1, f2) { - f1.agents.push(...f2.agents); - f1.paths.push(...f2.paths); - f1.nodes.push(...f2.nodes); - f1.edges.updates.push(...f2.edges.updates); - Object.keys(f2.edges.base).forEach((ek) => { - const currentEdge = cmn.jsonSetDefaultAndGet(f1.edges.base, ek, makeEdgeBase()); - mergeEdgeBase(currentEdge, f2.edges.base[ek]); - }); - mergeFragmentObjects(f1.scores, f2.scores); - mergeFragmentObjects(f1.errors, f2.errors); - return f1; +/* Gets the agent type from a TRAPI Knowledge Edge. + * + * @param {object} kedge - An edge from the Knowledge Graph to extract the agent type from. + * + * @returns {string} - The agent type. + */ +export function getAgentType(kedge) { + return _getGraphElemAttrVal(kedge, bl.tagBiolink('agent_type')); } -function isPathfinderQGraph(qg) { - const nodeBindings = cmn.jsonGet(qg, 'nodes', false); - const edgeBindings = cmn.jsonGet(qg, 'edges', false); - return !!nodeBindings && - !!edgeBindings && - Object.keys(nodeBindings).length === 3 && - Object.keys(edgeBindings).length === 3; +/* + * Determine the query template type based on the TRAPI message. + * + * @param {object} message - The message to determine the query template from. + * + * @returns {number} - The query template. See CONSTANTS.QGRAPH.TEMPLATE. + */ +export function messageToQueryTemplate(message) { + const qg = QGraph.fromTrapi(getQueryGraph(message)); + return qg.template; } -function getPathDirection(qgraph) { - if (isPathfinderQGraph(qgraph)) { - return [subjectKey, objectKey]; +/* + * Determine which keys correspond to the start and end points of all graphs. + * + * @param {object} message - The message to determine the start and end keys from. + * + * @returns {string[]} - The keys corresponding to the start and end points. + */ +export function messageToEndpoints(message) { + const qg = QGraph.fromTrapi(getQueryGraph(message)); + if (qg.qNodeCount === 3 && qg.qEdgeCount() === 3) { + return [SUBJECT_KEY, OBJECT_KEY]; } - const startIsObject = cmn.jsonGetFromKpath(qgraph, ['nodes', subjectKey, 'ids'], false); - if (startIsObject) { - return [objectKey, subjectKey]; + const startIsObj = qg.qNodes[SUBJECT_KEY].ids !== undefined; + if (startIsObj) { + return [OBJECT_KEY, SUBJECT_KEY]; } - return [subjectKey, objectKey]; + return [SUBJECT_KEY, OBJECT_KEY]; } -function creativeAnswersToSummaryFragments(answers, nodeRules, edgeRules, maxHops) { - function trapiResultToSummaryFragment(trapiResult, kgraph, auxGraphs, startKey, endKey, errors) { - function analysisToSummaryFragment(analysis, kgraph, auxGraphs, start, ends) { - function finalizePaths(rgraphPaths, edgeMappings, kgraph) { - function N(n) { return rnodeToKey(n, kgraph); } - function E(e, o) { return redgeToKey(e, kgraph, isRedgeInverted(e, o, kgraph)); } - const normalizedMappings = {}; - const normalizedPaths = rgraphPaths.map(path => { - let normalizedPath = []; - const pathLength = path.length - 1; - if (pathLength < 0) { - return normalizedPath; - } - - for (let i = 0; i < pathLength; i+=2) { - const node = path[i]; - const edge = path[i+1]; - const normalizedEdge = E(edge, node); - if (!normalizedMappings[normalizedEdge]) { - normalizedMappings[normalizedEdge] = { partOf: [], support: [] }; - } - normalizedMappings[normalizedEdge].partOf.push(...edgeMappings[edge].partOf); - normalizedMappings[normalizedEdge].support.push(...edgeMappings[edge].support); - normalizedPath.push(N(node), normalizedEdge); - } - - normalizedPath.push(N(path[pathLength])); - return normalizedPath; - }); - - Object.keys(normalizedMappings).forEach(key => cmn.objRemoveDuplicates(normalizedMappings[key])); - const pathToSupportGraph = {}; - // For every path find which graphs the path appears in. A path appears in a graph iff all - // edges in the path appear in the graph. - for (const path of normalizedPaths) { - let gids = [...normalizedMappings[path[1]].partOf]; - for (let i = 3; i < path.length; i+=2) { - gids = gids.filter((gid) => normalizedMappings[path[i]].partOf.includes(gid)); - } - - pathToSupportGraph[pathToKey(path)] = gids; - } - - const edgeBases = {} - // Determine which paths support which edges - for (const edge of Object.keys(normalizedMappings)) { - const edgeSupportGraphs = normalizedMappings[edge].support; - const edgePaths = []; - for (const path of Object.keys(pathToSupportGraph)) { - for (const pgid of pathToSupportGraph[path]) { - if (edgeSupportGraphs.includes(pgid)) { - edgePaths.push(path); - break; - } - } - } - - if (edgeBases[edge] === undefined) { - edgeBases[edge] = makeEdgeBase(); - } - - edgeBases[edge].support.push(...edgePaths); - edgeBases[edge].is_root = normalizedMappings[edge].partOf.includes('root'); - } - - return [normalizedPaths, edgeBases]; - } - - const agent = cmn.jsonGet(analysis, 'resource_id', false); - if (!agent) { - return errorSummaryFragment('unknown', 'Expected analysis to have resource_id'); - } - - try { - const rgraph = analysisToRgraph(analysis, kgraph, auxGraphs); - const rnodeToOutEdges = makeRnodeToOutEdges(rgraph, kgraph); - const maxPathLength = (2 * maxHops) + 1; - // This is an exhaustive search based on the max path length. We may have to come up - // with a better algorithm if the max path length increases significantly. - const rgraphPaths = rgraphFold((path) => { - const currentRnode = path[path.length-1]; - if (maxPathLength < path.length) { - return cmn.makePair([], []); - } - - let validPaths = []; - rnodeToOutEdges(currentRnode).forEach((edge) => { - const target = edge.target - // Do not allow cycles - if (!path.includes(target)) { - let newPath = [...path, edge.redge, edge.target]; - validPaths.push(newPath); - } - }); - - const finalPaths = []; - if (ends.includes(currentRnode)) { - finalPaths.push(path); - } - - return cmn.makePair(validPaths, finalPaths); - }, - [[start]], - []); - - const [normalizedPaths, edgeBases] = finalizePaths(rgraphPaths, rgraph.edgeMappings, kgraph); - const analysisContext = { - agent: agent, - errors: errors - }; - - return makeSummaryFragment( - [agent], - normalizedPaths, - rgraph.nodes.map(rnode => { return summarizeRnode(rnode, kgraph, nodeRules, analysisContext); }), - { - base: edgeBases, - updates: rgraph.edges.map(redge => { - const kedge = redgeToTrapiKedge(redge, kgraph); - const edgeContext = cmn.deepCopy(analysisContext); - edgeContext.primarySource = getPrimarySource(cmn.jsonGet(kedge, 'sources'))[0]; - return summarizeRedge(redge, kgraph, - edgeRules, edgeContext, new Set(Object.keys(edgeBases))); - }) - }, - {}, - {}); - } catch (err) { - console.error(err); - if (err instanceof EdgeBindingNotFoundError) { - return errorSummaryFragment(agent, e.message); - } - - return errorSummaryFragment(agent, 'Unknown error with building RGraph'); - } - } - - try { - const resultNodeBindings = cmn.jsonGet(trapiResult, 'node_bindings'); - const start = getNodeBindingEndpoints(resultNodeBindings, startKey)[0]; - const ends = getNodeBindingEndpoints(resultNodeBindings, endKey); - const analyses = cmn.jsonGet(trapiResult, 'analyses'); - const resultSummaryFragment = analyses.reduce( - (rsf, analysis) => { - return mergeSummaryFragments( - rsf, - analysisToSummaryFragment(analysis, kgraph, auxGraphs, start, ends)); - }, - emptySummaryFragment()); - - if (!isEmptySummaryFragment(resultSummaryFragment)) { - // Insert the ordering components after the analyses have been merged - const resultStartKey = rnodeToKey(start, kgraph); - const scoringComponents = cmn.jsonGet(trapiResult, 'ordering_components', {confidence: 0, novelty: 0, clinical_evidence: 0}); - const normalizedScore = cmn.jsonGet(trapiResult, 'normalized_score', 0); - scoringComponents['normalized_score'] = normalizedScore; - resultSummaryFragment.scores[resultStartKey] = [scoringComponents]; - } - - return resultSummaryFragment; - } catch (e) { - if (e instanceof NodeBindingNotFoundError) { - return errorSummaryFragment('unknown', e.message); - } - - return errorSummaryFragment('unknown', 'Unknown error while building result summary fragment'); - } - } - - const summaryFragments = []; - const errors = {}; - answers.forEach((answer) => { - const trapiMessage = answer.message; - const trapiResults = cmn.jsonGet(trapiMessage, 'results', false); - if (!trapiResults) { - return; - } - - const kgraph = cmn.jsonGet(trapiMessage, 'knowledge_graph'); - const auxGraphs = cmn.jsonGet(trapiMessage, 'auxiliary_graphs', {}); - const [startKey, endKey] = getPathDirection(cmn.jsonGet(trapiMessage, 'query_graph')); - - trapiResults.forEach((result) => { - const sf = trapiResultToSummaryFragment(result, kgraph, auxGraphs, startKey, endKey, errors); - if (!isEmptySummaryFragment(sf)) { - summaryFragments.push(sf); - } - }); - }); - - return [summaryFragments, errors]; +export function isChemicalGeneQuery(queryType) { + return queryType === CONSTANTS.QGRAPH.TEMPLATE.CHEMICAL_GENE; } -function getPathFromPid(paths, pid) { - return cmn.jsonGetFromKpath(paths, [pid, 'subgraph']); +export function isChemicalDiseaseQuery(queryType) { + return queryType === CONSTANTS.QGRAPH.TEMPLATE.CHEMICAL_DISEASE; } -function sortPaths(pids, paths) { - function isPidLessThan(pid1, pid2) { - if (pid1 === undefined || pid2 === undefined) return 0; - const path1 = getPathFromPid(paths, pid1); - const path2 = getPathFromPid(paths, pid2); - const p1Len = path1.length; - const p2Len = path2.length; - if (p1Len === p2Len) { - for (let i = 0; i < path1.length; i+=2) { - if (path1[i] < path2[i]) { - return -1; - } - else if (path1[i] > path2[i]) { - return 1; - } - } - - if (pid1 < pid2) { - return -1; - } else if (pid2 < pid1) { - return 1; - } - - return 0; - } - - if (p1Len < p2Len) { - return -1; - } - - return 1; - } - - if (pids.length < 2) return pids; - return pids.sort(isPidLessThan); +export function isGeneChemicalQuery(queryType) { + return queryType === CONSTANTS.QGRAPH.TEMPLATE.GENE_CHEMICAL; } -function isRootPath(pid, paths, edges) { - const path = getPathFromPid(paths, pid); - let isRoot = true; - for (let i = 1; i < path.length; i+=2) { - isRoot = isRoot && edges[path[i]].is_root; - } - return isRoot; +export function isPathfinderQuery(queryType) { + return queryType === CONSTANTS.QGRAPH.TEMPLATE.PATHFINDER; } -function getRootPids(pids, paths, edges) { - const rootPids = pids.filter(pid => isRootPath(pid, paths, edges)); - return rootPids; +export function isValidQuery(queryType) { + return Object.values(CONSTANTS.QGRAPH.TEMPLATE).includes(queryType); } -function generateSupportChain(pids, paths, edges) { - const seenPids = []; - const remaining = getRootPids(pids, paths, edges); - while (remaining.length !== 0) { - const next = remaining.pop(); - if (seenPids.includes(next)) continue; - seenPids.push(next); - const summaryPath = getPathFromPid(paths, next); - for (let i = 1; i < summaryPath.length; i+=2) { - const eid = summaryPath[i]; - const edgeSupport = edges[eid].support; - remaining.push(...edgeSupport.filter((spid) => !seenPids.includes(spid))); - } - } - - return seenPids; +function getKgraphElem(binding, type, kgraph) { + return cmn.jsonGet(cmn.jsonGet(kgraph, type, {}), binding, null); } -function cleanup(results, paths, edges, nodes) { - function clean(section, seenIds) { - for (let id of Object.keys(section)) { - if (!seenIds.has(id)) { - delete section[id]; - } - } - } - - const seenPaths = new Set(); - const seenEdges = new Set(); - const seenNodes = new Set(); - for (let res of results) { - const resPaths = []; - for (let pid of res.paths) { - const path = getPathFromPid(paths, pid); - if (path.length !== 0) { - resPaths.push(pid); - seenPaths.add(pid); - } - - for (let i = 0; i < path.length; i++) { - if (isNodeIndex(i)) { - seenNodes.add(path[i]); - } else { - seenEdges.add(path[i]); - } - } - } - - res.paths = resPaths; - } - - clean(paths, seenPaths); - clean(edges, seenEdges); - clean(nodes, seenNodes); +function _getGraphElemAttrVal(graphElem, id, defaultVal = null) { + const attrs = getAttrs(graphElem); + const attrIter = new AttributeIterator(attrs); + const attrVal = attrIter.findOneVal([id]); + if (attrVal === null) return defaultVal; + return attrVal; } -function genMetaPath(path, nodes) { - const metaPath = [] - for (let i = 0; i < path.length; i+=2) { - const node = nodes[path[i]]; - metaPath.push(bl.sanitizeBiolinkItem(node.types[0])); - } - - return metaPath; -} - -async function summaryFragmentsToSummary(qid, condensedSummaries, kgraph, queryType, agentToName, errors) { - function fragmentPathsToResultsAndPaths(fragmentPaths, nodes, queryType) { - // TODO: use objects instead of arrays? - let results = []; - let paths = []; - fragmentPaths.forEach((path) => { - const pathKey = pathToKey(path); - let resultKey = path[0]; - if (isPathfinderQuery(queryType)) { - resultKey = pathToKey(genMetaPath(path, nodes)); - } - results.push(cmn.makePair(resultKey, pathKey, 'start', 'pathKey')); - paths.push(cmn.makePair(pathKey, path, 'key', 'path')); - }); - - return [results, paths]; - } - - function extendSummaryResults(results, newResults) { - newResults.forEach((result) => { - let existingResult = cmn.jsonSetDefaultAndGet(results, result.start, {}); - let paths = cmn.jsonSetDefaultAndGet(existingResult, 'paths', []) - paths.push(result.pathKey); - }); - } - - function extendSummaryPaths(paths, newPaths, agents) { - newPaths.forEach((path) => { - let existingPath = cmn.jsonGet(paths, path.key, false); - if (existingPath) { - cmn.jsonGet(existingPath, 'aras').concat(agents); - return; - } - - cmn.jsonSet(paths, path.key, {'subgraph': path.path, 'aras': agents}); - }); - } - - function extendSummaryObj(objs, updates, agents, fallback) { - updates.forEach((update) => { - let obj = cmn.jsonSetDefaultAndGet(objs, update.key, fallback()); - update.transforms.forEach((transform) => { - transform(obj); - obj.aras.push(...agents); - }); - }); - } - - function extendSummaryNodes(nodes, nodeUpdates, agents) { - extendSummaryObj(nodes, nodeUpdates, agents, () => new Object({aras: []})); - } - - function extendSummaryEdges(edges, edgeUpdates, agents) { - extendSummaryObj(edges, edgeUpdates, agents, makeEdgeBase); - } - - function extendSummaryScores(scores, newScores) { - Object.keys(newScores).forEach((resultNode) => { - const currentScores = cmn.jsonSetDefaultAndGet(scores, resultNode, []); - currentScores.push(...newScores[resultNode]); - }); - } - - function extendSummaryErrors(errors, newErrors) { - Object.keys(newErrors).forEach((agent) => { - const currentErrors = cmn.jsonSetDefaultAndGet(errors, agent, []); - currentErrors.push(...newErrors[agent]); - }); - } - - function extendSummaryPublications(publications, edge) - { - function makePublicationObject(type, url, source) - { - return { - 'type': type, - 'url': url, - 'source': source - }; - } - - const pubs = cmn.jsonGet(edge, 'publications', {}); - Object.keys(pubs).forEach((ks) => { - const publicationData = cmn.jsonGet(pubs, ks, []); - publicationData.forEach((pub) => { - const id = pub.id; - const [type, url] = ev.idToTypeAndUrl(id); - cmn.jsonSet(publications, id, makePublicationObject(type, url, pub.source)); - }); - }); - } - - function edgesToEdgesAndPublications(edges) { - function addInverseEdge(edges, edge) { - const invertedPredicate = edgeToQualifiedPredicate(edge, true); - const subject = cmn.jsonGet(edge, 'subject'); - const object = cmn.jsonGet(edge, 'object'); - const knowledgeLevel = cmn.jsonGet(edge, 'knowledge_level'); - const invertedEdgeKey = pathToKey([object, invertedPredicate, subject, knowledgeLevel]); - let invertedEdge = cmn.deepCopy(edge); - cmn.jsonMultiSet(invertedEdge, - [['subject', object], - ['object', subject], - ['predicate', invertedPredicate]]); - - const unqualifiedInvertedPredicate = bl.invertBiolinkPredicate(getSpecificPredicate(edge)); - cmn.jsonSet(invertedEdge, 'predicate_url', bl.predicateToUrl(unqualifiedInvertedPredicate)); - delete invertedEdge['qualifiers']; - edges[invertedEdgeKey] = invertedEdge; - } - - const publications = {}; - Object.values(edges).forEach((edge) => { - extendSummaryPublications(publications, edge); - const edgePublications = cmn.jsonGet(edge, 'publications', {}); - const supportingText = cmn.jsonGet(edge, 'supporting_text', {}); - Object.keys(edgePublications).forEach((knowledgeLevel) => { - edgePublications[knowledgeLevel] = edgePublications[knowledgeLevel].map((pub) => { - return { id: pub.id, support: supportingText[pub.id] || null }; - }); - }); - delete edge['supporting_text']; - addInverseEdge(edges, edge); - cmn.jsonSet(edge, 'predicate_url', bl.predicateToUrl(getSpecificPredicate(edge))); - cmn.jsonSet(edge, 'predicate', edgeToQualifiedPredicate(edge)); - delete edge['qualifiers']; - }); - - return [edges, publications]; - } - - function resultsToResultsAndTags(results, paths, nodes, edges, scores, errors, queryType) { - function supportChainIncludesPid(rootPids, paths, edges, pid) { - if (isRootPath(pid, paths, edges)) return true; - - const seenPids = new Set(); - const pids = [...rootPids]; - while (pids.length > 0) { - const validPid = pids.pop(); - seenPids.add(validPid); - const path = getPathFromPid(paths, validPid); - for (let i = 1; i < path.length; i+=2) { - const edgeSupport = edges[path[i]].support; - if (edgeSupport.includes(pid)) return true; - pids.push(...edgeSupport.filter((spid) => !seenPids.has(spid))); - } - } - - return false; - } - - function markPathAsIndirect(paths, edges, pid) { - function helper(paths, edges, pid, seen) { - seen.add(pid); - const tag = 'p/pt/inf'; - const path = paths[pid]; - const subgraph = path.subgraph; - for (let i = 1; i < subgraph.length; i+=2) { - const edge = edges[subgraph[i]]; - if (!cmn.isArrayEmpty(edge.support)) { - for (let spid of edge.support) { - if (!seen.has(spid)) { - markPathAsIndirect(paths, edges, spid); - } - } - } - } - path.tags[tag] = makeTagDescription('Indirect'); - } - - helper(paths, edges, pid, new Set()); - } - - function genName(nodes, subgraph, queryType) { - function getNodeName(nodes, nid) { - const names = cmn.jsonGetFromKpath(nodes, [nid, 'names']); - return (cmn.isArrayEmpty(names)) ? nid : names[0]; - } - if (isPathfinderQuery(queryType)) { - const metaPath = genMetaPath(subgraph, nodes); - metaPath[0] = getNodeName(nodes, subgraph[0]); - metaPath[metaPath.length-1] = getNodeName(nodes, subgraph[subgraph.length-1]); - return metaPath.join('/'); - } - return getNodeName(nodes, subgraph[0]); - } - - function genId(nodes, subgraph, queryType) { - if (isPathfinderQuery(queryType)) { - const metaPath = genMetaPath(subgraph, nodes); - return pathToKey(metaPath); - } - const start = subgraph[0]; - const end = subgraph[subgraph.length-1]; - return pathToKey([start, end]); - } - - const usedTags = {}; - const expandedResults = []; - for (const result of results) { - const pids = cmn.jsonGet(result, 'paths'); - const rootPids = getRootPids(pids, paths, edges); - // Bail if there are no root paths - if (rootPids.length === 0) { - let aras = new Set(); - for (const pid of pids) { - for (const ara of paths[pid].aras) { - aras.add(ara); - } - } - - aras = [...aras]; - const errorString = "No root paths found"; - console.error(`${aras.join(', ')}: ${errorString}`) - for (const ara of aras) { - const araErrors = cmn.jsonSetDefaultAndGet(errors, ara, []); - araErrors.push(errorString); - } - - continue; - } - - const subgraph = getPathFromPid(paths, rootPids[0]); - const name = genName(nodes, subgraph, queryType); - const start = subgraph[0]; - const end = subgraph[subgraph.length-1]; - const tags = {}; - pids.forEach((pid) => { - // Include the tags if the path is a root path or appears in the chain of support for a root path - if (supportChainIncludesPid(rootPids, paths, edges, pid)) { - const path = paths[pid]; - for (const tag of Object.keys(path.tags)) { - if (isResultTag(tag) && path.subgraph[0] !== start) { - continue; - } - - usedTags[tag] = path.tags[tag]; - tags[tag] = null; - } - } - }); - - // Generate inferred/lookup tags for results and paths - rootPids.forEach((pid) => { - const subgraph = getPathFromPid(paths, pid); - const edge = edges[subgraph[1]]; - const pathIsIndirect = edge.support.length > 0; - if (pathIsIndirect) { - const tag = 'p/pt/inf'; - markPathAsIndirect(paths, edges, pid); - usedTags[tag] = makeTagDescription('Indirect'); - tags[tag] = null; - } else { - const tag = 'p/pt/lkup'; - const directTag = makeTagDescription('Direct'); - usedTags[tag] = directTag; - paths[pid].tags[tag] = directTag; - tags[tag] = null; - } - }); - - expandedResults.push({ - 'id': genId(nodes, subgraph, queryType), - 'subject': start, - 'drug_name': name, - 'paths': sortPaths(rootPids, paths), - 'object': end, - 'scores': scores[start], - 'tags': tags - }); +/* + * Create a minimal TRAPI message from a collection of node CURIEs + * + * @param {Array} nodeIds - Array of CURIEs + * @returns {Object} - TRAPI message + */ +function makeKgraphFromNodeIds(nodeIds) { + const nodes = {}; + nodeIds.forEach(id => { + if (bl.isValidCurie(id)) { + nodes[id] = {}; } - - return [expandedResults, usedTags]; - } - - let results = {}; - let paths = {}; - let nodes = {}; - let edges = {}; - let publications = {}; - let scores = {}; - let tags = []; - condensedSummaries.forEach((cs) => { - const agents = condensedSummaryAgents(cs); - extendSummaryNodes(nodes, condensedSummaryNodes(cs), agents); - const summaryEdges = condensedSummaryEdges(cs); - Object.keys(summaryEdges.base).forEach((k) => { - const edge = cmn.jsonSetDefaultAndGet(edges, k, makeEdgeBase()); - mergeEdgeBase(edge, summaryEdges.base[k]); - }); - extendSummaryEdges(edges, summaryEdges.updates, agents); - extendSummaryScores(scores, condensedSummaryScores(cs)); - extendSummaryErrors(errors, condensedSummaryErrors(cs)); - }); - - Object.values(nodes).forEach(node => { - cmn.objRemoveDuplicates(node); - node.types.sort(bl.biolinkClassCmpFn); }); - condensedSummaries.forEach((cs) => { const agents = condensedSummaryAgents(cs); - const [newResults, newPaths] = fragmentPathsToResultsAndPaths(condensedSummaryPaths(cs), nodes, queryType); - extendSummaryResults(results, newResults); - extendSummaryPaths(paths, newPaths, agents); - }); - - results = Object.values(results).map(cmn.objRemoveDuplicates) - function pushIfEmpty(arr, val) { - if (cmn.isArrayEmpty(arr)) { - arr.push(val); - } + return { + edges: {}, + nodes: nodes }; +} - // Edge post-processing - Object.keys(edges).forEach((ek) => { - const edge = edges[ek]; - // Remove any empty edges. TODO: Why are these even here? - if (Object.keys(edge).length === 2 && edge.aras !== undefined && edge.support !== undefined) { - delete edges[ek]; - return; - } - - // Remove any edges that have a missing subject, object, predicate, or provenance - const edgeErrorReasons = reasonsForEdgeErrors(edge); - if (edgeErrorReasons.length !== 0) { - console.error(`Found invalid edge ${ek}. Reasons: ${JSON.stringify(edgeErrorReasons)}`); - updateErrorsFromEdge(edge, errors, edgeErrorReasons); - delete edges[ek]; - return; - } - - // Remove any duplicates on all edge attributes - cmn.objRemoveDuplicates(edge); - - // Remove duplicates from publications - const publications = cmn.jsonGet(edge, 'publications', {}); - Object.keys(publications).forEach((kl) => { - const klPublications = cmn.jsonGet(publications, kl, []); - const seenIds = new Set(); - cmn.jsonSet(publications, kl, klPublications.filter((pub) => { - const shouldInclude = !seenIds.has(pub.id); - seenIds.add(pub.id); - return shouldInclude; - })); - }); - - // Convert all infores to provenance - cmn.jsonUpdate(edge, 'provenance', (provenance) => { - return provenance.map((p) => { - const provenanceMapping = bl.inforesToProvenance(p); - if (!provenanceMapping) { - edgeErrorReasons.push(`Found invalid provenance ${p} on edge ${edgeToString(edge)}`); - } - - return provenanceMapping; - }).filter(cmn.identity); - }); - - if (edgeErrorReasons.length !== 0) { - updateErrorsFromEdge(edge, errors, edgeErrorReasons); - delete edges[ek]; - return - } - - // Populate knowledge level - edge.knowledge_level = edge.provenance[0].knowledge_level; - }); - - [edges, publications] = edgesToEdgesAndPublications(edges); - const metadataObject = makeMetadataObject(qid, cmn.distinctArray(condensedSummaries.map((cs) => { return cs.agents; }).flat())); - try { - // Node annotation - const nodeRules = makeSummarizeRules( - [ - renameAndTransformAttribute( - 'biothings_annotations', - ['descriptions'], - (annotations) => { - const description = bta.getDescription(annotations); - if (description === null) { - return []; - } - - return [description]; - } - ), - renameAndTransformAttribute( - 'biothings_annotations', - ['other_names'], - (annotations) => { - const otherNames = bta.getNames(annotations); - if (otherNames === null - || (cmn.isArrayEmpty(otherNames.commercial) && cmn.isArrayEmpty(otherNames.generic))) { - return []; - } - - return otherNames; - } - ), - aggregateAndTransformAttributes( - ['biothings_annotations'], - 'curies', - (annotations) => { - const curies = bta.getCuries(annotations); - return curies; - } - ) - ] - ); - - const resultNodeRules = makeSummarizeRules( - [ - tagAttribute( - 'biothings_annotations', - (annotations) => { - const fdaApproval = bta.getFdaApproval(annotations); - if (fdaApproval === null) { - return false; - } else if (fdaApproval < 4) { - const tags = []; - if (fdaApproval > 0) { - tags.push(makeTag(`r/fda/${fdaApproval}`, `Clinical Trial Phase ${fdaApproval}`)); - } else { - tags.push(makeTag('r/fda/0', 'Not FDA Approved')); - } - - return tags; - } else { - return makeTag(`r/fda/${fdaApproval}`, `FDA Approved`); - } - } - ), - tagAttribute( - 'biothings_annotations', - (annotations, context) => { - if (isGeneChemicalQuery(context.queryType)) return []; - - const chebiRoles = bta.getChebiRoles(annotations); - if (chebiRoles === null) { - return []; - } - - return chebiRoles.map((role) => { return makeTag(`r/role/${role.id}`, cmn.titleize(role.name))}); - } - ), - renameAndTransformAttribute( - 'biothings_annotations', - ['indications'], - (annotations) => { - const indications = bta.getDrugIndications(annotations); - if (indications === null) { - return []; - } - - return indications; - } - ) - ] - ); - - - const resultNodes = new Set(); - results.forEach((result) => { - const ps = cmn.jsonGet(result, 'paths'); - ps.forEach((p) => { - const subgraph = getPathFromPid(paths, p); - resultNodes.add(subgraph[0]); - }); - }); - - const annotationContext = { - agent: 'biothings-annotator', - queryType: queryType, - errors: {} - }; - - const nodeUpdates = Object.keys(nodes).map((rnode) => { - return summarizeRnode(rnode, kgraph, nodeRules, annotationContext); - }); - - const resultNodeUpdates = [...resultNodes].map((rnode) => { - return summarizeRnode(rnode, kgraph, resultNodeRules, annotationContext); - }); - - extendSummaryNodes(nodes, nodeUpdates.concat(resultNodeUpdates), ['biothings-annotator']); - extendSummaryErrors(errors, annotationContext.errors); - } - catch (err) { - console.error(err); +/* + * Create a TRAPI message from a query graph and knowledge graph. + * + * @param {object} queryGraph? - Optional. The query graph to include in the message. + * @param {object} knowledgeGraph? - Optional. The knowledge graph to include in the message. + * + * @returns {object} - A TRAPI compliant message. + */ +function makeTrapiMessage(queryGraph, kgraph) { + const message = {}; + if (queryGraph) { + message['query_graph'] = queryGraph; } - finally { - // Node post-processing - Object.keys(nodes).forEach((k) => { - const node = nodes[k]; - node.curies.push(k); - // Remove any duplicates on all node attributes - cmn.objRemoveDuplicates(node); - node.types.sort(bl.biolinkClassCmpFn); - - // Provide a CURIE as a fallback if the node has no name - const nodeNames = cmn.jsonGet(node, 'names'); - pushIfEmpty(nodeNames, k); - - cmn.jsonSet(node, 'provenance', [bl.curieToNormalizedUrl(k, node.curies)]) - - // Add tag attribute to nodes that don't have one - cmn.jsonSetDefaultAndGet(node, 'tags', {}); - - if (cmn.jsonGet(node, 'other_names') === null) { - cmn.jsonSet(node, 'other_names', []); - } - }); - - // Path post-processing - Object.keys(paths).forEach((pk) => { - const path = paths[pk]; - // Remove paths where there is an undefined node reference in the path - for (let i = 0; i < path.subgraph.length; i += 2) { - if (nodes[path.subgraph[i]] === undefined) { - delete paths[pk]; - return; - } - } - - // Remove paths where there is an undefined edge reference in the path - for (let i = 1; i < path.subgraph.length; i += 2) { - if (edges[path.subgraph[i]] === undefined) { - delete paths[pk]; - return; - } - } - - // Remove duplicates from every attribute on a path - cmn.objRemoveDuplicates(path); - - if (isChemicalDiseaseQuery(queryType)) { - // Consider the chemical indicated for the disease iff - // 1. The chemical is marked as indicated for the disease - // 2. The chemical has reached phase 4 approval from the FDA - const start = nodes[path.subgraph[0]]; - if (start.indications !== undefined) { - const startIndications = new Set(start.indications); - const end = nodes[path.subgraph[path.subgraph.length-1]]; - const endMeshIds = end.curies.filter((curie) => { return curie.startsWith('MESH:'); }); - let indicatedFor = false; - for (let i = 0; i < endMeshIds.length; i++) { - if (startIndications.has(endMeshIds[i])) { - indicatedFor = start.tags['r/fda/4'] !== undefined; - break; - } - } - - if (indicatedFor) { - start.tags['r/di/ind'] = makeTagDescription('In a clinical trial for indicated disease'); - } else { - start.tags['r/di/not'] = makeTagDescription('Not in a clinical trial for indicated disease'); - } - } - - cmn.jsonDelete(start, 'indications'); - } - - // Add tags for paths by processing nodes (skip the last node) - const tags = {}; - for (let i = 0; i < path.subgraph.length-1; ++i) { - if (isNodeIndex(i)) { - const node = nodes[path.subgraph[i]]; - if (node !== undefined) { // Remove me when result graphs are fixed - const type = cmn.isArrayEmpty(node.types) ? - 'Named Thing' : - bl.sanitizeBiolinkItem(node.types[0]); - if (i === 0) { - // Merge result level node tags - Object.keys(node.tags) - .filter((tag) => { return isResultTag(tag) && isExternalTag(tag); }) - .forEach((tagID) => { tags[tagID] = node.tags[tagID]; }); - - const [answerTag, answerDescription] = determineAnswerTag(type, node.tags, queryType); - if (answerTag) { - tags[answerTag] = makeTagDescription(answerDescription); - } - } else { - // Merge all node tags into the path for intermediate nodes - Object.keys(node.tags) - .filter((tag) => { return !isResultTag(tag) && isExternalTag(tag); }) - .forEach((tagID) => { tags[ID] = node.tags[ID]; }); - - // Generate tags based on the node category - tags[`p/pc/${type}`] = makeTagDescription(type); - } - } - } - } - - // Generate tags based on the aras for this path - const aras = cmn.jsonGet(path, 'aras'); - aras.forEach((ara) => { - tags[`r/ara/${ara}`] = makeTagDescription(agentToName(ara)); - }); - - // Generate tags for path length - const pathLength = (path.subgraph.length-1)/2; - let tagDescription = 'Connections'; - if (pathLength == 1) { - tagDescription = 'Connection'; - } - - tags[`p/pt/${pathLength}`] = makeTagDescription(`${pathLength} ${tagDescription}`); - path.tags = tags; - }); - - // Remove PIDs that are no longer valid from results and support for edges and sort - // support paths - Object.keys(edges).forEach((k) => { - edges[k].support = edges[k].support.filter(p => paths[p] !== undefined); - edges[k].support = sortPaths(edges[k].support, paths); - }); - results.forEach((r) => { - r.paths = r.paths.filter(p => paths[p] !== undefined); - r.paths = generateSupportChain(r.paths, paths, edges); - }); - - // Remove all unneeded items from results, paths, edges and nodes - cleanup(results, paths, edges, nodes); - - [results, tags] = resultsToResultsAndTags(results, paths, nodes, edges, scores, errors, queryType); - return { - 'meta': metadataObject, - 'results': results, - 'paths': paths, - 'nodes': nodes, - 'edges': edges, - 'publications': publications, - 'tags': tags, - 'errors': errors - }; + if (kgraph) { + message['knowledge_graph'] = kgraph; } + + const trapiMessage = {}; + trapiMessage[CONSTANTS.ROOT] = message; + return trapiMessage; } -class NodeBindingNotFoundError extends Error { - constructor(edgeBinding) { - super(`Node binding not found for ${JSON.stringify(edgeBinding)}`); +/* Convert a query type sent from the client to a query template. + * + * @param {string} queryType - The query type from the client. + * + * @returns {number} - The query template. See CONSTANTS.QGRAPH.TEMPLATE. + */ +function queryTypeToTemplate(queryType) { + switch (queryType) { + case 'drug': + return CONSTANTS.QGRAPH.TEMPLATE.CHEMICAL_DISEASE; + case 'gene': + return CONSTANTS.QGRAPH.TEMPLATE.GENE_CHEMICAL; + case 'chemical': + return CONSTANTS.QGRAPH.TEMPLATE.CHEMICAL_GENE; + case 'pathfinder': + return CONSTANTS.QGRAPH.TEMPLATE.PATHFINDER; + default: + throw new RangeError(`Expected query type to be one of [drug, gene, chemical], got: ${queryType}`); } } -class EdgeBindingNotFoundError extends Error { - constructor(edgeBinding) { - super(`Edge binding not found for ${JSON.stringify(edgeBinding)}`); +class InvalidQualifiersError extends Error { + constructor(kedge) { + super(`Invalid qualifiers in knowledge edge: ${JSON.stringify(kedge)}}`); } } -class AuxGraphNotFoundError extends Error { - constructor(auxGraph) { - super(`Auxiliary graph not found for ${auxGraph}`); +class MissingQueryGraphError extends Error { + constructor(message) { + super(`No query graph in ${JSON.stringify(message)}`); } } diff --git a/package-lock.json b/package-lock.json index f9e1fdda..238b737a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "hash-sum": "^2.0.0", "jsonwebtoken": "^9.0.0", "knex": "^2.4.2", + "lodash": "^4.17.21", "meow": "^12.1.1", "pg": "^8.11.0", "pino-http": "^8.2.1", @@ -2395,8 +2396,7 @@ "pg-pool": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.0.tgz", - "integrity": "sha512-clFRf2ksqd+F497kWFyM21tMjeikn60oGDmqMT8UBrynEwVEX/5R5xd2sdvdo1cZCFlguORNpVuqxIj+aK4cfQ==", - "requires": {} + "integrity": "sha512-clFRf2ksqd+F497kWFyM21tMjeikn60oGDmqMT8UBrynEwVEX/5R5xd2sdvdo1cZCFlguORNpVuqxIj+aK4cfQ==" }, "pg-protocol": { "version": "1.6.0", diff --git a/package.json b/package.json index 71188e72..ee2dab99 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "hash-sum": "^2.0.0", "jsonwebtoken": "^9.0.0", "knex": "^2.4.2", + "lodash": "^4.17.21", "meow": "^12.1.1", "pg": "^8.11.0", "pino-http": "^8.2.1", diff --git a/services/TranslatorService.mjs b/services/TranslatorService.mjs index ae03e5dc..c5a7d09a 100644 --- a/services/TranslatorService.mjs +++ b/services/TranslatorService.mjs @@ -32,7 +32,7 @@ class TranslatorService inputToQuery(input) { - return trapi.queryToCreativeQuery(input); + return trapi.clientReqToTrapiQuery(input); } async submitQuery(query) @@ -59,7 +59,7 @@ class TranslatorService const resp = await this.queryClient.retainQuery(queryId); return resp; } catch (err) { - console.log(err); + console.error(err); throw new QueryClientError(`Error retaining query for ${queryId}`, queryId, 'retain', err); } } diff --git a/test/regression.mjs b/test/regression.mjs index 5ac5c7fa..102fd94a 100644 --- a/test/regression.mjs +++ b/test/regression.mjs @@ -6,28 +6,26 @@ import * as tsmy from './lib/summarization.mjs'; import { loadBiolink } from '../lib/biolink-model.mjs'; import { loadChebi } from '../lib/chebi.mjs'; import { TranslatorServicexFEAdapter } from '../adapters/TranslatorServicexFEAdapter.mjs'; +import { loadTrapi } from '../lib/trapi.mjs'; // We have to do this because the 'before' hook does not seem to work async function loadConfig() { - const config = await cfg.bootstrapConfig('./configurations/production.json') - await loadBiolink(config.biolink.version, - config.biolink.support_deprecated_predicates, - config.biolink.infores_catalog, - config.biolink.prefix_catalog); + const config = await cfg.bootstrapConfig('./configurations/production.json'); + await loadBiolink(config.biolink); await loadChebi(); + loadTrapi(config.trapi); } async function regressionTest(testFile) { await loadConfig(); - const input = cmn.readJson(`test/data/regression/in/${testFile}`); - const expected = cmn.readJson(`test/data/regression/out/${testFile}`); + const input = await cmn.readJson(`test/data/regression/in/${testFile}`); + const expected = await cmn.readJson(`test/data/regression/out/${testFile}`); const maxHops = 3; const translatorAdapter = new TranslatorServicexFEAdapter(); - const actual = await translatorAdapter.queryResultsToFE(await input, maxHops); - tsmy.testSummary(actual.data, await expected); + const actual = await translatorAdapter.queryResultsToFE(input, maxHops); + tsmy.testSummary(actual.data, expected); } - await regressionTest('00881bc8-5bcd-472b-aafa-dbc4e8992dcd.json'); await regressionTest('020d41bd-1709-416f-befc-392b7ca56e2a.json'); await regressionTest('050daf46-2233-4603-bec5-e71812290494.json'); @@ -63,6 +61,5 @@ await regressionTest('eafb6dcf-ef65-46b6-bd5b-d237e23907da.json'); await regressionTest('f18a3b53-b309-4978-93f2-0b38d8f3c701.json'); await regressionTest('f6b094da-ad8a-40a5-839f-d2a3c26ae99c.json'); await regressionTest('fa9c31cf-6d13-4284-b657-96acee6c387d.json'); -await regressionTest('fbdf1b14-179b-44c0-a41f-ee2bc84047a6.json'); await regressionTest('fe681a8f-b240-4d07-a2dd-67e789907778.json'); console.log('Regression tests passed'); diff --git a/utilities/genRegressionTests.sh b/utilities/genRegressionTests.sh index d75070a5..00dd6ad3 100755 --- a/utilities/genRegressionTests.sh +++ b/utilities/genRegressionTests.sh @@ -20,17 +20,38 @@ import * as tsmy from './lib/summarization.mjs'; import { loadBiolink } from '../lib/biolink-model.mjs'; import { loadChebi } from '../lib/chebi.mjs'; import { TranslatorServicexFEAdapter } from '../adapters/TranslatorServicexFEAdapter.mjs'; +import { loadTrapi } from '../lib/trapi.mjs'; // We have to do this because the 'before' hook does not seem to work async function loadConfig() { - const config = await cfg.bootstrapConfig('test/data/regression/config.json') - await loadBiolink(config.biolink.version, - config.biolink.support_deprecated_predicates, - config.biolink.infores_catalog, - config.biolink.prefix_catalog); + const config = await cfg.bootstrapConfig('test/data/regression/config.json'); + await loadBiolink(config.biolink); await loadChebi(); + loadTrapi(config.trapi); } +<<<<<<< HEAD +function reduceSummaryNoise(summary) { + function toObject(c, p) { + Object.keys(c[p]).forEach(id => { + c[p][id] = {...c[p][id]}; + }); + + return c; + } + + summary = {...summary}; + summary.nodes = toObject(summary, 'nodes'); + summary.edges = toObject(summary, 'edges'); + summary.paths = toObject(summary, 'paths'); + summary.publications = toObject(summary, 'publications'); + summary.meta = null; + summary.errors = null; + return summary; +} + +======= +>>>>>>> test/regression async function regressionTest(testFile) { await loadConfig(); const input = cmn.readJson("'`test/data/regression/in/${testFile}`'"); diff --git a/utilities/node/summarizeQuery.mjs b/utilities/node/summarizeQuery.mjs index 7c3b80e9..d46e9183 100644 --- a/utilities/node/summarizeQuery.mjs +++ b/utilities/node/summarizeQuery.mjs @@ -10,6 +10,7 @@ import { loadBiolink } from '../../lib/biolink-model.mjs'; import { loadChebi } from '../../lib/chebi.mjs'; import { TranslatorServicexFEAdapter } from '../../adapters/TranslatorServicexFEAdapter.mjs' import { readJson } from '../../lib/common.mjs'; +import { loadTrapi } from '../../lib/trapi.mjs'; // TODO: config shit const configPath = process.argv[2]; @@ -18,11 +19,9 @@ const maxHops = 3; const translatorAdapter = new TranslatorServicexFEAdapter(); readJson(dataPath).then(async (data) => { const config = await cfg.bootstrapConfig(configPath); - await loadBiolink(config.biolink.version, - config.biolink.support_deprecated_predicates, - config.biolink.infores_catalog, - config.biolink.prefix_catalog); + await loadBiolink(config.biolink); await loadChebi(); + loadTrapi(config.trapi); const summaryMsg = await translatorAdapter.queryResultsToFE(data, maxHops); console.log(JSON.stringify(summaryMsg.data)); });