Skip to content

✨ feat(citations): citation resolution + node-merge redirects#58

Merged
marcelsamyn merged 10 commits into
mainfrom
feat/citation-resolve
Jun 8, 2026
Merged

✨ feat(citations): citation resolution + node-merge redirects#58
marcelsamyn merged 10 commits into
mainfrom
feat/citation-resolve

Conversation

@marcelsamyn

Copy link
Copy Markdown
Owner

What & why

Adds the memory-side support for Petals' new inline-citation feature:

  • resolveCitations(ids) — a batch endpoint (POST /citations/resolve) + SDK client method that resolves a mix of node_* / claim_* / src_* ids to citation-ready records (title, snippet, provenance source, available, canonicalId).
  • Node-merge redirectsmergeNodes previously hard-deleted consumed nodes, so any external reference (e.g. a stored citation) to a merged node id 404'd forever. It now writes a node_redirects(user_id, from_node_id → to_node_id) row per consumed node (re-pointing existing chains so they stay flat) before the delete, and resolveCitations follows them.

Bumps @marcelsamyn/memory to 1.18.0.

Changes

  • node_redirects table + migration; no FK on from_node_id (the consumed node is gone — the redirect must outlive it).
  • writeNodeRedirects / resolveNodeRedirects helpers; mergeNodes writes redirects inside its merge transaction.
  • resolveCitations lib + /citations/resolve route + SDK client method + schema exports.
  • Superseded/retracted claims and soft-deleted sources resolve as available: false (id durability preserved; staleness flagged).

How to test

  • pnpm test --run src/lib/node-redirects.test.ts src/lib/resolve-citations.test.ts (needs Postgres on :5431).
  • pnpm run build:check && pnpm run build-sdk && pnpm run lint.

Checklist

  • build:check
  • tests (375 pass)
  • lint / format
  • migration applied to deploy DB
  • publish 1.18.0 after merge

Design note: docs/citations-sdk.md. Consumed by the Petals inline-citations PR (Plan 3 / Source Peek).

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces inline-citation resolution capabilities, allowing the batch-resolution of node, claim, and source IDs into citation-ready records. It implements a new node_redirects table and helper functions to track and follow merged nodes, ensuring stale references can still be resolved. Additionally, it exposes a new POST endpoint /citations/resolve and updates the SDK client. The review feedback focuses on optimizing database queries by deduplicating input IDs in the resolution and redirect helpers, preventing potential PostgreSQL runtime errors by deduplicating IDs during conflict updates, and explicitly importing readBody in the route handler.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/routes/citations/resolve.post.ts Outdated
@@ -0,0 +1,16 @@
import { defineEventHandler } from "h3";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Import readBody explicitly from h3 to ensure it is defined and to avoid potential compilation or runtime errors if global auto-imports are not configured in the environment.

Suggested change
import { defineEventHandler } from "h3";
import { defineEventHandler, readBody } from "h3";

Comment thread src/lib/node-redirects.ts
Comment on lines +27 to +51
if (consumedIds.length === 0) return;

await db
.update(nodeRedirects)
.set({ toNodeId: survivorId })
.where(
and(
eq(nodeRedirects.userId, userId),
inArray(nodeRedirects.toNodeId, consumedIds),
),
);

await db
.insert(nodeRedirects)
.values(
consumedIds.map((fromNodeId) => ({
userId,
fromNodeId,
toNodeId: survivorId,
})),
)
.onConflictDoUpdate({
target: [nodeRedirects.userId, nodeRedirects.fromNodeId],
set: { toNodeId: survivorId },
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Deduplicate consumedIds before performing the update and insert operations. If consumedIds contains duplicate IDs, PostgreSQL will throw a runtime error during the INSERT ... ON CONFLICT DO UPDATE statement because a single command cannot affect the same row twice.

  if (consumedIds.length === 0) return;

  const uniqueConsumedIds = [...new Set(consumedIds)];

  await db
    .update(nodeRedirects)
    .set({ toNodeId: survivorId })
    .where(
      and(
        eq(nodeRedirects.userId, userId),
        inArray(nodeRedirects.toNodeId, uniqueConsumedIds),
      ),
    );

  await db
    .insert(nodeRedirects)
    .values(
      uniqueConsumedIds.map((fromNodeId) => ({
        userId,
        fromNodeId,
        toNodeId: survivorId,
      })),
    )
    .onConflictDoUpdate({
      target: [nodeRedirects.userId, nodeRedirects.fromNodeId],
      set: { toNodeId: survivorId },
    });

Comment thread src/lib/node-redirects.ts
Comment on lines +66 to +79
if (ids.length === 0) return out;

const rows = await db
.select({
fromNodeId: nodeRedirects.fromNodeId,
toNodeId: nodeRedirects.toNodeId,
})
.from(nodeRedirects)
.where(
and(
eq(nodeRedirects.userId, userId),
inArray(nodeRedirects.fromNodeId, ids),
),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Deduplicate ids before querying the database to optimize the query planner and reduce parameter overhead in the IN clause.

Suggested change
if (ids.length === 0) return out;
const rows = await db
.select({
fromNodeId: nodeRedirects.fromNodeId,
toNodeId: nodeRedirects.toNodeId,
})
.from(nodeRedirects)
.where(
and(
eq(nodeRedirects.userId, userId),
inArray(nodeRedirects.fromNodeId, ids),
),
);
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return out;
const rows = await db
.select({
fromNodeId: nodeRedirects.fromNodeId,
toNodeId: nodeRedirects.toNodeId,
})
.from(nodeRedirects)
.where(
and(
eq(nodeRedirects.userId, userId),
inArray(nodeRedirects.fromNodeId, uniqueIds),
),
);

Comment thread src/lib/resolve-citations.ts Outdated
Comment on lines +38 to +44
const nodeIds = ids.filter((i) => prefixOf(i) === "node") as TypeId<"node">[];
const claimIds = ids.filter(
(i) => prefixOf(i) === "claim",
) as TypeId<"claim">[];
const sourceIds = ids.filter(
(i) => prefixOf(i) === "src",
) as TypeId<"source">[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Deduplicate nodeIds, claimIds, and sourceIds before querying the database. This optimizes the query planner and avoids redundant database round-trips/parameters for duplicate requested IDs, while still preserving duplicates in the final output array via the byId map lookup.

  const nodeIds = [...new Set(ids.filter((i) => prefixOf(i) === "node"))] as TypeId<"node">[];
  const claimIds = [...new Set(ids.filter((i) => prefixOf(i) === "claim"))] as TypeId<"claim">[];
  const sourceIds = [...new Set(ids.filter((i) => prefixOf(i) === "src"))] as TypeId<"source">[];

@marcelsamyn marcelsamyn merged commit b6dd777 into main Jun 8, 2026
1 check passed
@marcelsamyn marcelsamyn deleted the feat/citation-resolve branch June 8, 2026 11:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant