Skip to content
Merged
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
131 changes: 131 additions & 0 deletions e2e/citation-dedupe.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,134 @@ test("keeps two chips to the same title/pN as separate buttons", async ({
chatPanel.getByRole("button", { name: "Open source spacex-s1.pdf/p26" }),
).toHaveCount(2)
})

test("draws citation regions instead of a full-page highlight", async ({
context,
page,
}) => {
await context.addCookies([
{
name: "better-auth.session_token",
value: "playwright",
url: "http://localhost:3000",
},
])
await page.setViewportSize({ width: 1280, height: 832 })
await page.route("**/api/sources/source_spacex/chunks**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
chunks: [
{
chunkId: "page_26",
documentId: "doc_spacex",
sectionPath: "Page 26",
type: "page",
content: "Revenue evidence on page 26.",
readableContent: "Revenue evidence on page 26.",
pageNums: [26],
pageAssets: [
{
pageNumber: 26,
assetUrl: "/images/knowhere/logo-icon.png",
contentType: "image/png",
width: 1000,
height: 1400,
},
],
sourceTitle: "spacex-s1.pdf",
},
],
pagination: {
page: 1,
pageSize: 50,
total: 1,
totalPages: 1,
},
}),
})
})

await page.goto("/e2e/citation-same-page")
const chips = page
.getByTestId("desktop-chat-panel")
.getByTestId("citation-chip")
await expect(chips).toHaveCount(2)

await chips.first().click()
const firstRegions = page.getByTestId("citation-region-highlight")
await expect(firstRegions).toHaveCount(1)
await expect
.poll(() =>
firstRegions.first().evaluate((element) => ({
left: element.style.left,
top: element.style.top,
width: element.style.width,
height: element.style.height,
})),
)
.toEqual({ left: "12%", top: "18%", width: "46%", height: "8%" })
const pageImage = page.getByRole("img", { name: "Page 26" })
await expect(pageImage).toBeVisible()
const imageBox = await pageImage.boundingBox()
const firstRegionBox = await firstRegions.first().boundingBox()
expect(imageBox).not.toBeNull()
expect(firstRegionBox).not.toBeNull()
expect(firstRegionBox!.x).toBeCloseTo(
imageBox!.x + imageBox!.width * 0.12,
1,
)
expect(firstRegionBox!.y).toBeCloseTo(
imageBox!.y + imageBox!.height * 0.18,
1,
)
expect(firstRegionBox!.width).toBeCloseTo(imageBox!.width * 0.46, 1)
expect(firstRegionBox!.height).toBeCloseTo(imageBox!.height * 0.08, 1)

await chips.nth(1).click()
const secondRegions = page.getByTestId("citation-region-highlight")
await expect(secondRegions).toHaveCount(2)
const regionStyles = await secondRegions.evaluateAll((elements) =>
elements.map((element) => ({
left: (element as HTMLElement).style.left,
top: (element as HTMLElement).style.top,
width: (element as HTMLElement).style.width,
height: (element as HTMLElement).style.height,
})),
)
expect(regionStyles).toEqual([
{ left: "62%", top: "52%", width: "24%", height: "6%" },
{ left: "15%", top: "68%", width: "32%", height: "5%" },
])
expect(regionStyles).not.toContainEqual({
left: "0%",
top: "0%",
width: "100%",
height: "100%",
})
const expectedSecondRegions = [
{ x: 0.62, y: 0.52, w: 0.24, h: 0.06 },
{ x: 0.15, y: 0.68, w: 0.32, h: 0.05 },
]
for (const [index, expectedRegion] of expectedSecondRegions.entries()) {
const regionBox = await secondRegions.nth(index).boundingBox()
expect(regionBox).not.toBeNull()
expect(regionBox!.x).toBeCloseTo(
imageBox!.x + imageBox!.width * expectedRegion.x,
1,
)
expect(regionBox!.y).toBeCloseTo(
imageBox!.y + imageBox!.height * expectedRegion.y,
1,
)
expect(regionBox!.width).toBeCloseTo(
imageBox!.width * expectedRegion.w,
1,
)
expect(regionBox!.height).toBeCloseTo(
imageBox!.height * expectedRegion.h,
1,
)
}
})
46 changes: 46 additions & 0 deletions src/agent-harness/image-asset-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { EvidenceAsset, EvidenceChunk } from "./types"

export function getCanonicalImageAssetKey(
asset: EvidenceAsset,
chunksByRef: ReadonlyMap<string, EvidenceChunk>,
): string {
const chunk = chunksByRef.get(asset.chunkRef)
const documentId = getTrimmedValue(
asset.source.documentId ?? chunk?.source.documentId,
)
const revisionKey = getTrimmedValue(asset.revisionKey ?? chunk?.revisionKey)
const sourcePath = getTrimmedValue(asset.sourcePath)?.toLowerCase()

if (documentId && revisionKey && sourcePath) {
return `document:${documentId}\u0000${revisionKey}\u0000${sourcePath}`
}

const assetUrl = getNormalizedAssetUrl(asset.assetUrl)
if (assetUrl) return `url:${assetUrl}`

if (documentId && sourcePath) {
return `document:${documentId}\u0000${sourcePath}`
}

return asset.ref
}

function getNormalizedAssetUrl(value: string | undefined): string | null {
const trimmed = getTrimmedValue(value)
if (!trimmed) return null

try {
const url = new URL(trimmed)
url.hash = ""
return url.toString()
} catch {
return trimmed
}
}

function getTrimmedValue(
value: string | null | undefined,
): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
1 change: 1 addition & 0 deletions src/agent-harness/knowhere-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe("knowhereToolText", () => {
expect(text).toContain('ref="r1:result:1"')
expect(text).toContain('ref="asset:r1:result:1"')
expect(text).toContain("Call inspectImage")
expect(text).toContain("before finalize")
expect(text).not.toContain("https://assets.example/page-1.png")
})

Expand Down
2 changes: 1 addition & 1 deletion src/agent-harness/knowhere-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ type KnowhereOperation =
| "grep_chunks"

const assetInstruction =
"Notebook returned image/page asset refs. Call inspectImage with the asset refs when OCR, visual details, or verification are needed. Do not expose raw asset URLs."
"Notebook returned image/page asset refs. Call inspectImage with the asset refs you will cite before finalize so OCR/visual context and provenance boxes exist. Do not expose raw asset URLs."

export const knowhereToolText = {
formatSearch(input: SearchTextInput): string {
Expand Down
49 changes: 49 additions & 0 deletions src/agent-harness/ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,55 @@ describe("createEvidenceLedger", () => {
)
})

it("creates page image assets from snake-case retrieval metadata", () => {
const ledger = createEvidenceLedger()

const snapshot = ledger.addRetrievalResponse({
namespace: "notebook",
query: "revenue",
routerUsed: "mapnav",
answerText: null,
evidenceText: "Revenue evidence",
stopReason: "completed",
failureReason: null,
results: [
{
chunkId: "chunk_page_4",
content: "Revenue was $24.9B.",
chunkType: "page",
score: 0.9,
metadata: {
page_nums: [4],
pageAssets: [],
page_assets: [
{
page_num: 4,
artifactRef: "",
artifact_ref: "page_citation_assets/page-4.png",
contentType: "",
content_type: "image/png",
},
],
},
source: {
documentId: "doc_tsla",
sourceFileName: "TSLA-Q4-2025-Update.pdf",
sectionPath: "FINANCIAL SUMMARY",
},
},
],
referencedChunks: [],
})

expect(snapshot.assets).toContainEqual(
expect.objectContaining({
ref: "asset:r1:result:1",
sourcePath: "page_citation_assets/page-4.png",
type: "image",
}),
)
})

it("adds read chunk refs and page image assets", () => {
const ledger = createEvidenceLedger()

Expand Down
38 changes: 25 additions & 13 deletions src/agent-harness/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,8 +381,10 @@ function getPageCitationAssetCandidate(
): EvidenceAssetCandidate | null {
if (chunk.chunkType.toLowerCase() !== "page") return null

const candidates = parsePageCitationAssetCandidates(chunk.metadata?.pageAssets)
.filter(isSupportedPageCitationAsset)
const candidates = [
...parsePageCitationAssetCandidates(chunk.metadata?.pageAssets),
...parsePageCitationAssetCandidates(chunk.metadata?.page_assets),
].filter(isSupportedPageCitationAsset)
if (candidates.length === 0) return null

const pageNumbers = getPageNumbers(chunk.metadata)
Expand Down Expand Up @@ -463,21 +465,26 @@ function parsePageCitationAssetCandidates(

return value.flatMap((item): PageCitationAssetCandidate[] => {
if (!isRecord(item)) return []
const pageNum = getPositiveInteger(item.pageNum)
const pageNum =
getPositiveInteger(item.pageNum) ??
getPositiveInteger(item.page_num) ??
getPositiveInteger(item.pageNumber)
if (!pageNum) return []
const artifactRef =
getTrimmedString(item.artifactRef) ??
getTrimmedString(item.artifact_ref)
const assetUrl =
getTrimmedString(item.assetUrl) ?? getTrimmedString(item.asset_url)
const contentType =
getTrimmedString(item.contentType) ??
getTrimmedString(item.content_type)

return [
{
pageNum,
...(getTrimmedString(item.artifactRef)
? { artifactRef: getTrimmedString(item.artifactRef) ?? undefined }
: {}),
...(getTrimmedString(item.assetUrl)
? { assetUrl: getTrimmedString(item.assetUrl) ?? undefined }
: {}),
...(getTrimmedString(item.contentType)
? { contentType: getTrimmedString(item.contentType) ?? undefined }
: {}),
...(artifactRef ? { artifactRef } : {}),
...(assetUrl ? { assetUrl } : {}),
...(contentType ? { contentType } : {}),
},
]
})
Expand Down Expand Up @@ -507,7 +514,12 @@ function getPageNumbers(
): readonly number[] {
if (!metadata) return []

const values = [metadata.pageNums, metadata.page_nums, metadata.pageNum]
const values = [
metadata.pageNums,
metadata.page_nums,
metadata.pageNum,
metadata.page_num,
]
const pageNumbers = new Set<number>()

for (const value of values) {
Expand Down
Loading
Loading