Skip to content
Draft
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
1 change: 1 addition & 0 deletions .codex/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
environments/
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated .codex gitignore accidentally included in PR

Low Severity

The .codex/.gitignore file ignoring environments/ appears unrelated to the PR's purpose of adding an /ingest route. The PR description explicitly lists a .gitignore for memory/ but doesn't mention this .codex change. It looks like a developer tooling artifact that was inadvertently staged alongside the feature work.

Fix in CursorΒ Fix in Web

2 changes: 2 additions & 0 deletions apps/hash-frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# AI-generated working documents (roadmaps, plans, research notes)
memory/
17 changes: 17 additions & 0 deletions apps/hash-frontend/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ const apiUrl = process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://localhost:5001";

const apiDomain = new URL(apiUrl).hostname;

// Mastra API origin for ingest pipeline proxy (local dev: port 4111)
const mastraApiOrigin =
process.env.MASTRA_API_ORIGIN ?? "http://localhost:4111";

/**
* @todo: import the page `entityTypeId` from `@local/hash-isomorphic-utils/ontology-types`
* when the `next.config.js` supports imports from modules
Expand All @@ -81,6 +85,19 @@ export default withSentryConfig(
{
async rewrites() {
return [
// Ingest pipeline proxy β†’ Mastra API
{
source: "/api/ingest",
destination: `${mastraApiOrigin}/discovery-runs`,
},
{
source: "/api/ingest/:path*",
destination: `${mastraApiOrigin}/discovery-runs/:path*`,
},
{
source: "/api/ingest-fixtures/:path*",
destination: `${mastraApiOrigin}/discovery-fixtures/:path*`,
},
{
source: "/pages",
destination: `/entities?entityTypeIdOrBaseUrl=${pageEntityTypeBaseUrl}`,
Expand Down
2 changes: 2 additions & 0 deletions apps/hash-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"codegen": "rimraf './src/**/*.gen.*'; graphql-codegen --config codegen.config.ts",
"dev": "next dev",
"fix:eslint": "eslint --fix .",
"fix:format": "biome format --write",
"lint:eslint": "eslint --report-unused-disable-directives .",
"lint:tsc": "tsc --noEmit",
"start": "next start",
Expand All @@ -20,6 +21,7 @@
},
"dependencies": {
"@apollo/client": "3.10.5",
"@ark-ui/react": "5.26.2",
"@blockprotocol/core": "0.1.4",
"@blockprotocol/graph": "workspace:*",
"@blockprotocol/hook": "0.1.8",
Expand Down
65 changes: 65 additions & 0 deletions apps/hash-frontend/src/pages/ingest.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { InfinityLightIcon } from "@hashintel/design-system";
import { Box, Container } from "@mui/material";
import { useRouter } from "next/router";
import { useEffect } from "react";

import type { NextPageWithLayout } from "../shared/layout";
import { getLayoutWithSidebar } from "../shared/layout";
import { WorkersHeader } from "../shared/workers-header";
import { getIngestResultsPath } from "./ingest.page/routing";
import { UploadPanel } from "./ingest.page/upload-panel";
import { shouldFetchResults, useIngestRun } from "./ingest.page/use-ingest-run";

const IngestPage: NextPageWithLayout = () => {
const router = useRouter();
const { state, upload, reset } = useIngestRun();

useEffect(() => {
if (!shouldFetchResults(state)) {
return;
}
void router.push(
getIngestResultsPath({
kind: "run",
runId: state.runStatus.runId,
}),
);
}, [router, state]);

return (
<>
<WorkersHeader
crumbs={[
{
title: "Ingest",
href: "/ingest",
id: "ingest",
},
]}
title={{
Icon: InfinityLightIcon,
text: "Ingest",
}}
subtitle="Upload a PDF to extract entities, claims, and evidence."
/>
<Container>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 400,
py: 4,
}}
>
<UploadPanel state={state} onUpload={upload} onReset={reset} />
</Box>
</Container>
</>
);
};

IngestPage.getLayout = (page) =>
getLayoutWithSidebar(page, { fullWidth: true });

export default IngestPage;
38 changes: 38 additions & 0 deletions apps/hash-frontend/src/pages/ingest.page/bbox-transform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Coordinate transform: PDF-point bbox β†’ CSS percentage positioning.
*
* Overlays are absolutely-positioned <div>s inside a container wrapping the
* page <img>. Percentage-based positioning keeps them responsive.
*/

export interface BboxInput {
x1: number;
y1: number;
x2: number;
y2: number;
}

export interface BboxPercentage {
left: number;
top: number;
width: number;
height: number;
}

export function bboxToPercentage(
bbox: BboxInput,
pdfPageWidth: number,
pdfPageHeight: number,
origin: "BOTTOMLEFT" | "TOPLEFT",
): BboxPercentage {
const left = (bbox.x1 / pdfPageWidth) * 100;
const width = ((bbox.x2 - bbox.x1) / pdfPageWidth) * 100;
const height = ((bbox.y2 - bbox.y1) / pdfPageHeight) * 100;

const top =
origin === "BOTTOMLEFT"
? ((pdfPageHeight - bbox.y2) / pdfPageHeight) * 100
: (bbox.y1 / pdfPageHeight) * 100;

return { left, top, width, height };
}
51 changes: 51 additions & 0 deletions apps/hash-frontend/src/pages/ingest.page/evidence-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Evidence resolver: selection β†’ highlighted block IDs + target page.
*
* Pure function. No I/O, no React.
*/
import type { Block, ExtractedClaim, RosterEntry } from "./types";

export type Selection =
| { kind: "roster"; entry: RosterEntry }
| { kind: "claim"; claim: ExtractedClaim }
| null;

export interface EvidenceResult {
blockIds: string[];
targetPage: number | null;
}

export function resolveEvidence(
selection: Selection,
blocks: Block[],
): EvidenceResult {
if (!selection) {
return { blockIds: [], targetPage: null };
}

const blockIds =
selection.kind === "roster"
? [...new Set(selection.entry.mentions.map((mention) => mention.blockId))]
: [
...new Set(
selection.claim.evidenceRefs.flatMap((ref) => ref.blockIds),
),
];

let targetPage: number | null = null;
for (const block of blocks) {
if (!blockIds.includes(block.blockId)) {
continue;
}
for (const anchor of block.anchors) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Anchor union may expand
if (anchor.kind === "file_page_bbox") {
if (targetPage === null || anchor.page < targetPage) {
targetPage = anchor.page;
}
}
}
}

return { blockIds, targetPage };
}
129 changes: 129 additions & 0 deletions apps/hash-frontend/src/pages/ingest.page/page-viewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Page viewer: PDF page image with bbox overlay highlights.
*/
import { Box, Stack, Typography } from "@mui/material";
import type { FunctionComponent } from "react";

import { Button } from "../../shared/ui/button";
import { bboxToPercentage } from "./bbox-transform";
import type { Block, PageImageManifest } from "./types";

interface PageViewerProps {
pageImages: PageImageManifest[];
blocks: Block[];
highlightedBlockIds: string[];
currentPage: number;
onPageChange: (page: number) => void;
}

export const PageViewer: FunctionComponent<PageViewerProps> = ({
pageImages,
blocks,
highlightedBlockIds,
currentPage,
onPageChange,
}) => {
const totalPages = pageImages.length;
const pageImage = pageImages.find((img) => img.pageNumber === currentPage);
if (!pageImage) {
return null;
}

const visibleBlocks =
highlightedBlockIds.length > 0
? blocks.filter(
(block) =>
highlightedBlockIds.includes(block.blockId) &&
block.anchors.some((anchor) => anchor.page === currentPage),
)
: [];

return (
<Box>
{/* Page navigation */}
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ mb: 1, fontSize: "0.875rem" }}
>
<Button
size="small"
variant="secondary"
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
>
← Prev
</Button>
<Typography variant="smallTextLabels">
Page {currentPage} / {totalPages}
</Typography>
<Button
size="small"
variant="secondary"
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages}
>
Next β†’
</Button>
{visibleBlocks.length > 0 && (
<Typography
variant="smallTextLabels"
sx={{ color: "blue.70", ml: 1 }}
>
{visibleBlocks.length} highlighted
</Typography>
)}
</Stack>

{/* Page image with bbox overlays */}
<Box
sx={{ position: "relative", display: "inline-block", lineHeight: 0 }}
>
<img
src={pageImage.imageUrl}
alt={`Page ${currentPage}`}
style={{
maxWidth: "100%",
height: "auto",
border: "1px solid",
borderColor: "rgba(0, 0, 0, 0.12)",
}}
/>

{visibleBlocks.map((block) => {
const anchor = block.anchors.find((anc) => anc.page === currentPage);
if (!anchor) {
return null;
}

const pct = bboxToPercentage(
anchor.bbox,
pageImage.pdfPageWidth,
pageImage.pdfPageHeight,
pageImage.bboxOrigin,
);

return (
<Box
key={block.blockId}
title={`[${block.kind}] ${block.text.substring(0, 80)}`}
sx={{
position: "absolute",
left: `${pct.left}%`,
top: `${pct.top}%`,
width: `${pct.width}%`,
height: `${pct.height}%`,
border: "2px solid rgba(59, 130, 246, 0.7)",
backgroundColor: "rgba(59, 130, 246, 0.12)",
pointerEvents: "none",
boxSizing: "border-box",
borderRadius: "2px",
}}
/>
);
})}
</Box>
</Box>
);
};
Loading
Loading