Skip to content

Commit e944958

Browse files
authored
Merge branch 'main' into feature/structured-extraction-b3
2 parents 48b9b8a + e853cd3 commit e944958

20 files changed

Lines changed: 761 additions & 196 deletions

File tree

.github/workflows/codeql-analysis.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ jobs:
3939

4040
steps:
4141
- name: Checkout repository
42-
uses: actions/checkout@v2
42+
uses: actions/checkout@v4
4343

4444
# Initializes the CodeQL tools for scanning.
4545
- name: Initialize CodeQL
46-
uses: github/codeql-action/init@v1
46+
uses: github/codeql-action/init@v4
4747
with:
4848
languages: ${{ matrix.language }}
4949
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -54,7 +54,7 @@ jobs:
5454
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
5555
# If this step fails, then you should remove it and run the build manually (see below)
5656
- name: Autobuild
57-
uses: github/codeql-action/autobuild@v1
57+
uses: github/codeql-action/autobuild@v4
5858

5959
# ℹ️ Command-line programs to run using the OS shell.
6060
# 📚 https://git.io/JvXDl
@@ -68,4 +68,4 @@ jobs:
6868
# make release
6969

7070
- name: Perform CodeQL Analysis
71-
uses: github/codeql-action/analyze@v1
71+
uses: github/codeql-action/analyze@v4

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
name: Test
22
on: [push, pull_request]
3+
permissions:
4+
contents: read
5+
36
jobs:
47
build:
58
name: Test

application/database/db.py

Lines changed: 97 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from pprint import pprint
1212

13-
from collections import Counter
13+
from collections import Counter, defaultdict
1414
from itertools import permutations
1515
from typing import Any, Dict, List, Optional, Tuple, cast
1616
from neomodel.exceptions import (
@@ -1449,7 +1449,6 @@ def get_CREs(
14491449
include_only: Optional[List[str]] = None,
14501450
internal_id: Optional[str] = None,
14511451
) -> List[cre_defs.CRE]:
1452-
cres: List[cre_defs.CRE] = []
14531452
query = CRE.query
14541453
if not external_id and not name and not description and not internal_id:
14551454
logger.error(
@@ -1484,65 +1483,128 @@ def get_CREs(
14841483
)
14851484
return []
14861485

1487-
for matching_cre in dbcres:
1488-
cre = CREfromDB(matching_cre)
1489-
cre.links = self.__make_cre_links(
1490-
cre=matching_cre, include_only_nodes=include_only
1491-
)
1492-
cre.links.extend(self.__make_cre_internal_links(cre=matching_cre))
1493-
cres.append(cre)
1494-
return cres
1486+
return self._hydrate_cres_batch(dbcres, include_only_nodes=include_only)
14951487

1496-
def __make_cre_internal_links(self, cre: CRE) -> List[cre_defs.Link]:
1497-
links = []
1498-
internal_links = (
1488+
def _hydrate_cres_batch(
1489+
self,
1490+
dbcres: List[CRE],
1491+
include_only_nodes: Optional[List[str]] = None,
1492+
) -> List[cre_defs.CRE]:
1493+
if not dbcres:
1494+
return []
1495+
1496+
cre_ids = [cre.id for cre in dbcres]
1497+
node_links_by_cre: Dict[str, List[Links]] = defaultdict(list)
1498+
node_ids: set = set()
1499+
for link in self.session.query(Links).filter(Links.cre.in_(cre_ids)).all():
1500+
node_links_by_cre[link.cre].append(link)
1501+
node_ids.add(link.node)
1502+
1503+
nodes_by_id: Dict[str, Node] = {}
1504+
if node_ids:
1505+
nodes_by_id = {
1506+
node.id: node
1507+
for node in self.session.query(Node).filter(Node.id.in_(node_ids)).all()
1508+
}
1509+
1510+
internal_links_by_cre: Dict[str, List[InternalLinks]] = defaultdict(list)
1511+
linked_cre_ids: set = set()
1512+
for internal_link in (
14991513
self.session.query(InternalLinks)
15001514
.filter(
1501-
sqla.or_(InternalLinks.cre == cre.id, InternalLinks.group == cre.id)
1515+
sqla.or_(
1516+
InternalLinks.cre.in_(cre_ids),
1517+
InternalLinks.group.in_(cre_ids),
1518+
)
15021519
)
15031520
.all()
1504-
)
1521+
):
1522+
linked_cre_ids.add(internal_link.cre)
1523+
linked_cre_ids.add(internal_link.group)
1524+
if internal_link.cre in cre_ids:
1525+
internal_links_by_cre[internal_link.cre].append(internal_link)
1526+
if internal_link.group in cre_ids:
1527+
internal_links_by_cre[internal_link.group].append(internal_link)
1528+
1529+
cres_by_id: Dict[str, CRE] = {cre.id: cre for cre in dbcres}
1530+
extra_cre_ids = linked_cre_ids - set(cre_ids)
1531+
if extra_cre_ids:
1532+
for linked in (
1533+
self.session.query(CRE).filter(CRE.id.in_(extra_cre_ids)).all()
1534+
):
1535+
cres_by_id[linked.id] = linked
15051536

1506-
if len(internal_links) == 0:
1537+
result: List[cre_defs.CRE] = []
1538+
for matching_cre in dbcres:
1539+
cre = CREfromDB(matching_cre)
1540+
cre.links = self._assemble_cre_node_links(
1541+
node_links_by_cre.get(matching_cre.id, []),
1542+
nodes_by_id,
1543+
include_only_nodes,
1544+
)
1545+
seen_internal: set = set()
1546+
internal_rows = []
1547+
for row in internal_links_by_cre.get(matching_cre.id, []):
1548+
key = (row.cre, row.group, row.type)
1549+
if key not in seen_internal:
1550+
seen_internal.add(key)
1551+
internal_rows.append(row)
1552+
cre.links.extend(
1553+
self._assemble_cre_internal_links(
1554+
matching_cre, internal_rows, cres_by_id
1555+
)
1556+
)
1557+
result.append(cre)
1558+
return result
1559+
1560+
def _assemble_cre_internal_links(
1561+
self,
1562+
cre: CRE,
1563+
internal_links: List[InternalLinks],
1564+
cres_by_id: Dict[str, CRE],
1565+
) -> List[cre_defs.Link]:
1566+
links: List[cre_defs.Link] = []
1567+
if not internal_links:
15071568
logger.debug(
15081569
f"CRE {cre.name}:{cre.external_id}:{cre.id} has no internal links"
15091570
)
15101571

15111572
for internal_link in internal_links:
1512-
1513-
linked_cre_query = self.session.query(CRE)
15141573
link_type = cre_defs.LinkTypes.from_str(internal_link.type)
15151574

15161575
if internal_link.cre == cre.id:
1517-
# if we are the lower cre in this relationship, we need to flip the "Contains" linktypes
1518-
linked_cre = linked_cre_query.filter(
1519-
CRE.id == internal_link.group
1520-
).first() # get the higher cre so we can add the link
1576+
linked_cre = cres_by_id.get(internal_link.group)
1577+
if not linked_cre:
1578+
continue
15211579
if link_type == cre_defs.LinkTypes.Contains:
15221580
links.append(
15231581
cre_defs.Link(
15241582
ltype=cre_defs.LinkTypes.PartOf,
15251583
document=CREfromDB(linked_cre),
15261584
)
15271585
)
1528-
elif (
1529-
link_type == cre_defs.LinkTypes.Related
1530-
): # if it's not a "Contains" link, it's a "Related" link
1586+
elif link_type == cre_defs.LinkTypes.Related:
15311587
links.append(
15321588
cre_defs.Link(ltype=link_type, document=CREfromDB(linked_cre))
15331589
)
15341590
continue
1535-
# if we are are the higher cre then we don't need to do anything, relationship types are always "higher"->"lower"
1536-
linked_cre = linked_cre_query.filter(CRE.id == internal_link.cre).first()
1537-
links.append(cre_defs.Link(ltype=link_type, document=CREfromDB(linked_cre)))
1591+
1592+
linked_cre = cres_by_id.get(internal_link.cre)
1593+
if linked_cre:
1594+
links.append(
1595+
cre_defs.Link(ltype=link_type, document=CREfromDB(linked_cre))
1596+
)
15381597
return links
15391598

1540-
def __make_cre_links(
1541-
self, cre: CRE, include_only_nodes: List[str]
1599+
def _assemble_cre_node_links(
1600+
self,
1601+
node_links: List[Links],
1602+
nodes_by_id: Dict[str, Node],
1603+
include_only_nodes: Optional[List[str]],
15421604
) -> List[cre_defs.Link]:
1543-
links = []
1544-
for link in self.session.query(Links).filter(Links.cre == cre.id).all():
1545-
node = self.session.query(Node).filter(Node.id == link.node).first()
1605+
links: List[cre_defs.Link] = []
1606+
for link in node_links:
1607+
node = nodes_by_id.get(link.node)
15461608
if node and (not include_only_nodes or node.name in include_only_nodes):
15471609
links.append(
15481610
cre_defs.Link(
@@ -1647,13 +1709,11 @@ def export(self, dir: str = None, dry_run: bool = False) -> List[cre_defs.Docume
16471709
def all_cres_with_pagination(
16481710
self, page: int = 1, per_page: int = 10
16491711
) -> List[cre_defs.CRE]:
1650-
result: List[cre_defs.CRE] = []
16511712
cres = self.session.query(CRE).paginate(
16521713
page=int(page), per_page=per_page, error_out=False
16531714
)
16541715
total_pages = cres.pages
1655-
for cre in cres.items:
1656-
result.extend(self.get_CREs(external_id=cre.external_id))
1716+
result = self._hydrate_cres_batch(list(cres.items))
16571717
return result, page, total_pages
16581718

16591719
def get_cre_path(self, fromID: str, toID: str) -> List[cre_defs.Document]:
@@ -2202,10 +2262,7 @@ def get_root_cres(self):
22022262
)
22032263
.all()
22042264
)
2205-
result = []
2206-
for c in cres:
2207-
result.extend(self.get_CREs(external_id=c.external_id))
2208-
return result
2265+
return self._hydrate_cres_batch(list(cres))
22092266

22102267
def get_embeddings_by_doc_type(self, doc_type: str) -> Dict[str, List[float]]:
22112268
res = {}

application/frontend/src/App.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { QueryClient, QueryClientProvider } from 'react-query';
66
import { BrowserRouter } from 'react-router-dom';
77

88
import { GlobalFilterState, filterContext } from './hooks/applyFilters';
9-
import { DataProvider } from './providers/DataProvider';
109
import { MainContentArea } from './scaffolding';
1110

1211
const queryClient = new QueryClient();
@@ -15,12 +14,10 @@ const App = () => (
1514
<div className="app">
1615
<filterContext.Provider value={GlobalFilterState}>
1716
<QueryClientProvider client={queryClient}>
18-
<DataProvider>
19-
<BrowserRouter>
20-
<Toaster />
21-
<MainContentArea />
22-
</BrowserRouter>
23-
</DataProvider>
17+
<BrowserRouter>
18+
<Toaster />
19+
<MainContentArea />
20+
</BrowserRouter>
2421
</QueryClientProvider>
2522
</filterContext.Provider>
2623
</div>

application/frontend/src/const.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const TWO_DAYS_MILLISECONDS = 1.728e8;
2+
export const EXPLORER_CRE_PAGE_SIZE = 20;
23

34
export const TYPE_IS_PART_OF = 'Is Part Of';
45
export const TYPE_LINKED_TO = 'Linked To';

application/frontend/src/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="utf-8" />
55
<meta
66
name="viewport"
7-
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no"
7+
content="width=device-width, initial-scale=1"
88
/>
99
<meta
1010
name="description"
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
3+
import { DataProvider } from '../../providers/DataProvider';
4+
5+
type ExplorerLayoutProps = {
6+
children: React.ReactNode;
7+
};
8+
9+
export const ExplorerLayout = ({ children }: ExplorerLayoutProps) => <DataProvider>{children}</DataProvider>;
10+
11+
export function withExplorerLayout<P extends object>(Component: React.ComponentType<P>): React.FC<P> {
12+
const Wrapped = (props: P) => (
13+
<ExplorerLayout>
14+
<Component {...props} />
15+
</ExplorerLayout>
16+
);
17+
Wrapped.displayName = `ExplorerLayout(${Component.displayName || Component.name || 'Component'})`;
18+
return Wrapped;
19+
}

application/frontend/src/pages/Explorer/explorer.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import { GraphDebugPanel } from './GraphDebugPanel';
1313
import { LinkedStandards } from './LinkedStandards';
1414

1515
export const Explorer = () => {
16-
const { dataLoading, dataTree, dataStore } = useDataStore();
16+
const { dataLoading, dataTree, dataStore, hasMore, isLoadingMore, loadNextPage, dataLoadError } =
17+
useDataStore();
1718
const [loading, setLoading] = useState<boolean>(false);
1819
const [filter, setFilter] = useState('');
1920
const [filteredTree, setFilteredTree] = useState<TreeDocument[]>();
2021
const [debugMode, setDebugMode] = useState<boolean>(false);
22+
const loadMoreRef = React.useRef<HTMLDivElement | null>(null);
2123

2224
const applyHighlight = (text, term) => {
2325
if (!term) return text;
@@ -84,6 +86,25 @@ export const Explorer = () => {
8486
setLoading(dataLoading);
8587
}, [dataLoading]);
8688

89+
useEffect(() => {
90+
const sentinel = loadMoreRef.current;
91+
if (!sentinel || !hasMore) {
92+
return;
93+
}
94+
95+
const observer = new IntersectionObserver(
96+
(entries) => {
97+
if (entries.some((entry) => entry.isIntersecting)) {
98+
loadNextPage();
99+
}
100+
},
101+
{ rootMargin: '200px' }
102+
);
103+
104+
observer.observe(sentinel);
105+
return () => observer.disconnect();
106+
}, [hasMore, loadNextPage, filteredTree]);
107+
87108
function processNode(item) {
88109
if (!item) {
89110
return <></>;
@@ -192,12 +213,14 @@ export const Explorer = () => {
192213

193214
{debugMode && <GraphDebugPanel dataStore={dataStore} />}
194215

195-
<LoadingAndErrorIndicator loading={loading} error={null} />
216+
<LoadingAndErrorIndicator loading={loading || dataLoading} error={dataLoadError} />
196217
<List>
197218
{filteredTree?.map((item) => {
198219
return processNode(item);
199220
})}
200221
</List>
222+
<div ref={loadMoreRef} style={{ height: 1 }} />
223+
{isLoadingMore && hasMore && <p className="explorer-load-more">Loading more requirements…</p>}
201224
</main>
202225
</>
203226
);

application/frontend/src/pages/Explorer/visuals/circles/circles.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Button, Icon } from 'semantic-ui-react';
1010
export const ExplorerCircles = () => {
1111
const { height, width } = useWindowDimensions();
1212
const [useFullScreen, setUseFullScreen] = useState(false);
13-
const { dataLoading, dataTree } = useDataStore();
13+
const { dataLoading, dataTree, ensureFullExplorerData, fullLoadProgress, dataLoadError } = useDataStore();
1414
const [breadcrumb, setBreadcrumb] = useState<string[]>([]);
1515
const svgRef = React.useRef(null);
1616

@@ -21,6 +21,18 @@ export const ExplorerCircles = () => {
2121
const zoomToRef = React.useRef<any>(null);
2222
const margin = 20;
2323

24+
useEffect(() => {
25+
let active = true;
26+
void ensureFullExplorerData().catch((err) => {
27+
if (active) {
28+
console.error('Failed to load explorer graph data', err);
29+
}
30+
});
31+
return () => {
32+
active = false;
33+
};
34+
}, [ensureFullExplorerData]);
35+
2436
const defaultSize = width > height ? height - 100 : width;
2537
const size = useFullScreen ? width : defaultSize;
2638

@@ -447,7 +459,10 @@ export const ExplorerCircles = () => {
447459
<g transform={`translate(${size / 2},${size / 2})`}></g>
448460
</svg>
449461
</div>
450-
<LoadingAndErrorIndicator loading={dataLoading} error={null} />
462+
<LoadingAndErrorIndicator loading={dataLoading || !!fullLoadProgress} error={dataLoadError} />
463+
{fullLoadProgress && (
464+
<p className="explorer-full-load-progress">Loading graph data ({fullLoadProgress})…</p>
465+
)}
451466
</div>
452467
);
453468
};

0 commit comments

Comments
 (0)