Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions backend/src/monarch_py/utils/association_type_mappings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,11 @@
category: "biolink:GenotypeToDiseaseAssociation"
subject_category: "biolink:Genotype"
object_category: "biolink:Disease"
- subject_label: Medical Action
object_label: Medical Action
- subject_label: Indications
object_label: Treatments
category: "biolink:ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation"
# Note: subject_category/object_category omitted - this association uses disease_context_qualifier
# and requires special 1-hop handling that isn't yet implemented in the entity grid
subject_category: "biolink:ChemicalEntity"
object_category: "biolink:Disease"
- subject_label: Variant to Phenotype
object_label: Variant to Phenotype
category: "biolink:VariantToPhenotypicFeatureAssociation"
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/fixtures/association_counts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@

@pytest.fixture
def association_counts():
return {'items': [{'label': 'Disease Model', 'count': 246, 'category': 'biolink:GenotypeToDiseaseAssociation', 'count_direct': 14, 'count_with_orthologs': None}, {'label': 'Disease to Phenotype', 'count': 4247, 'category': 'biolink:DiseaseToPhenotypicFeatureAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Causal Gene', 'count': 133, 'category': 'biolink:CausalGeneToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Correlated Gene', 'count': 156, 'category': 'biolink:CorrelatedGeneToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Variant to Disease', 'count': 701, 'category': 'biolink:VariantToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Medical Action', 'count': 6, 'category': 'biolink:ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Cases', 'count': 136, 'category': 'biolink:CaseToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}]}
return {'items': [{'label': 'Disease Model', 'count': 246, 'category': 'biolink:GenotypeToDiseaseAssociation', 'count_direct': 14, 'count_with_orthologs': None}, {'label': 'Disease to Phenotype', 'count': 4247, 'category': 'biolink:DiseaseToPhenotypicFeatureAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Causal Gene', 'count': 133, 'category': 'biolink:CausalGeneToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Correlated Gene', 'count': 156, 'category': 'biolink:CorrelatedGeneToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Variant to Disease', 'count': 701, 'category': 'biolink:VariantToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Treatments', 'count': 6, 'category': 'biolink:ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation', 'count_direct': 0, 'count_with_orthologs': None}, {'label': 'Cases', 'count': 136, 'category': 'biolink:CaseToDiseaseAssociation', 'count_direct': 0, 'count_with_orthologs': None}]}
2 changes: 1 addition & 1 deletion backend/tests/fixtures/node.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion backend/tests/fixtures/object_formatted.py

Large diffs are not rendered by default.

4 changes: 1 addition & 3 deletions frontend/src/composables/use-association-categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { startCase } from "lodash";
import { TRAVERSE_ORTHOLOG_CATEGORIES } from "@/api/associations";
import type { Node } from "@/api/model";

const HIDDEN_CATEGORIES = new Set([
"biolink:ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation",
]);
const HIDDEN_CATEGORIES = new Set<string>([]);

export function useAssociationCategories(node: Node) {
const options = computed(() => {
Expand Down
157 changes: 157 additions & 0 deletions frontend/src/pages/node/SectionAssociationDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@
</span>
</AppDetail>

<AppDetail
v-if="association.knowledge_level"
title="Knowledge Level"
icon="scale-balanced"
>
<span>{{ startCase(association.knowledge_level) }}</span>
</AppDetail>

<AppDetail title="Primary Knowledge Source" icon="lightbulb">
<span>{{ association.primary_knowledge_source }}</span>
</AppDetail>
Expand Down Expand Up @@ -79,6 +87,42 @@
</span>
</AppDetail>

<AppDetail
v-if="retrievalSources.length"
title="Provenance"
icon="sitemap"
:full="true"
>
<AppFlex direction="col" gap="small" align-h="left">
<div
v-for="(source, index) in retrievalSources"
:key="index"
class="retrieval-source"
>
<span class="role">{{ formatRole(source.resource_role) }}:</span>
<code>{{ source.resource_id }}</code>
<span v-if="source.upstream_resource_ids?.length" class="upstream">
&larr; {{ source.upstream_resource_ids.join(", ") }}
</span>
</div>
</AppFlex>
</AppDetail>

<AppDetail
v-if="association.aggregator_knowledge_source?.length"
title="Aggregator Knowledge Source"
icon="database"
:full="true"
>
<AppFlex gap="small" align-h="left">
<code
v-for="(agg, index) in association.aggregator_knowledge_source"
:key="index"
>{{ agg }}</code
>
</AppFlex>
</AppDetail>

<AppDetail title="Provided By" icon="notes-medical">
<AppLink :to="association.provided_by_link?.url || ''" :replace="true">
{{ association.provided_by_link?.id || association.provided_by }}
Expand All @@ -97,6 +141,61 @@
</AppFlex>
</AppDetail>

<AppDetail
v-if="association.original_predicate"
title="Original Predicate"
icon="code"
>
<code>{{ association.original_predicate }}</code>
</AppDetail>

<AppDetail
v-if="qualifierRows.length"
title="Qualifiers"
icon="clipboard-list"
:full="true"
>
<AppFlex direction="col" gap="small" align-h="left">
<div v-for="(qualifier, index) in qualifierRows" :key="index">
<strong>{{ qualifier.label }}:</strong> {{ qualifier.value }}
</div>
</AppFlex>
</AppDetail>

<AppDetail
v-if="hasFrequencyData"
title="Frequency Data"
icon="equals"
:full="true"
>
<AppFlex direction="col" gap="small" align-h="left">
<div v-if="association.has_count != null">
<strong>Count:</strong> {{ association.has_count }}
</div>
<div v-if="association.has_total != null">
<strong>Total:</strong> {{ association.has_total }}
</div>
<div v-if="association.has_percentage != null">
<strong>Percentage:</strong> {{ association.has_percentage }}
</div>
<div v-if="association.has_quotient != null">
<strong>Quotient:</strong> {{ association.has_quotient }}
</div>
</AppFlex>
</AppDetail>

<AppDetail
v-if="association.FDA_adverse_event_level"
title="FDA Adverse Event Level"
icon="circle-exclamation"
>
<span>{{ association.FDA_adverse_event_level }}</span>
</AppDetail>

<AppDetail v-if="association.negated" title="Negated" icon="circle-xmark">
<span>Yes</span>
</AppDetail>

<AppDetail
v-if="association.supporting_text?.length"
icon="quote-left"
Expand All @@ -119,6 +218,7 @@

<script setup lang="ts">
import { computed, onMounted, watch } from "vue";
import { startCase } from "lodash";
import type { DirectionalAssociation, Node } from "@/api/model";
import AppDetail from "@/components/AppDetail.vue";
import AppDetails from "@/components/AppDetails.vue";
Expand All @@ -127,6 +227,7 @@ import AppPredicateBadge from "@/components/AppPredicateBadge.vue";
import { useSourceVersions } from "@/composables/use-source-versions";
import { scrollTo } from "@/router";
import { getAgentTypeMeta } from "@/util/agentType";
import { parseRetrievalSources } from "@/util/retrievalSources";

type Props = {
/** current node */
Expand All @@ -148,6 +249,47 @@ const sourceVersion = computed(() =>
props.association ? versionForEdge(props.association) : null,
);

/** parse the nested retrieval-source provenance chain (JSON-encoded) */
const retrievalSources = computed(() =>
parseRetrievalSources(props.association?.sources),
);

/** humanize a retrieval-source role, e.g. "primary_knowledge_source" */
const formatRole = (role?: string) => (role ? startCase(role) : "Source");

/** present qualifier values, preferring labels over raw ids */
const qualifierRows = computed(() => {
const a = props.association;
const rows: { label: string; value: string }[] = [];
if (!a) return rows;
const add = (label: string, value?: string | null) => {
if (value) rows.push({ label, value });
};
add("Frequency", a.frequency_qualifier_label || a.frequency_qualifier);
add("Onset", a.onset_qualifier_label || a.onset_qualifier);
add("Sex", a.sex_qualifier_label || a.sex_qualifier);
add("Stage", a.stage_qualifier_label || a.stage_qualifier);
add(
"Disease context",
a.disease_context_qualifier_label || a.disease_context_qualifier,
);
add("Object specialization", a.object_specialization_qualifier);
(a.qualifiers ?? []).forEach((qualifier) => add("Qualifier", qualifier));
return rows;
});

/** whether any frequency/count representation is present */
const hasFrequencyData = computed(() => {
const a = props.association;
return (
a != null &&
(a.has_count != null ||
a.has_total != null ||
a.has_percentage != null ||
a.has_quotient != null)
);
});

/** scroll details section into view */
async function scrollIntoView() {
scrollTo("#association-details");
Expand Down Expand Up @@ -186,4 +328,19 @@ onMounted(scrollIntoView);
color: $gray;
font-size: 0.9em;
}

.retrieval-source {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 0.5em;
}

.retrieval-source .role {
color: $gray;
}

.retrieval-source .upstream {
color: $gray;
}
</style>
26 changes: 26 additions & 0 deletions frontend/src/util/retrievalSources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export type RetrievalSource = {
resource_id?: string;
resource_role?: string;
upstream_resource_ids?: string[];
};

/**
* Parse the `sources` field of an association into a flat list of
* retrieval-source records. Each entry may be a JSON-encoded string holding an
* array of records (as the API serializes it); malformed entries are skipped.
*/
export const parseRetrievalSources = (
sources?: string[] | null,
): RetrievalSource[] => {
const out: RetrievalSource[] = [];
for (const entry of sources ?? []) {
try {
const parsed = typeof entry === "string" ? JSON.parse(entry) : entry;
if (Array.isArray(parsed)) out.push(...parsed);
else if (parsed) out.push(parsed);
} catch {
/* skip unparseable source entries */
}
}
return out;
};
41 changes: 41 additions & 0 deletions frontend/unit/retrievalSources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from "vitest";
import { parseRetrievalSources } from "@/util/retrievalSources";

test("parses a MEDIC-style nested retrieval-source chain", () => {
/** the API serializes `sources` as a list holding one JSON-encoded array */
const sources = [
JSON.stringify([
{
resource_id: "infores:medic",
resource_role: "primary_knowledge_source",
upstream_resource_ids: ["infores:dailymed"],
},
{
resource_id: "infores:dailymed",
resource_role: "supporting_data_source",
upstream_resource_ids: null,
},
]),
];
const parsed = parseRetrievalSources(sources);
expect(parsed).toHaveLength(2);
expect(parsed[0].resource_id).toBe("infores:medic");
expect(parsed[0].resource_role).toBe("primary_knowledge_source");
expect(parsed[0].upstream_resource_ids).toEqual(["infores:dailymed"]);
expect(parsed[1].resource_role).toBe("supporting_data_source");
});

test("returns empty for missing or empty sources", () => {
expect(parseRetrievalSources(undefined)).toEqual([]);
expect(parseRetrievalSources(null)).toEqual([]);
expect(parseRetrievalSources([])).toEqual([]);
});

test("skips unparseable entries without throwing", () => {
const parsed = parseRetrievalSources([
"not json",
JSON.stringify([{ resource_id: "infores:ctd" }]),
]);
expect(parsed).toHaveLength(1);
expect(parsed[0].resource_id).toBe("infores:ctd");
});
Loading