Skip to content
Open
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
28 changes: 28 additions & 0 deletions apps/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ const cliCommands = [
handler: withCommandContext(runGraphAuditAnchorsCommand),
showInTopLevelHelp: true,
},
{
key: "graphAuditDuplicates",
path: ["graph", "audit", "duplicates"],
usage: "graph audit duplicates",
handler: withCommandContext(runGraphAuditDuplicatesCommand),
showInTopLevelHelp: true,
},
{
key: "graphExport",
path: ["graph", "export"],
Expand Down Expand Up @@ -416,6 +423,27 @@ async function runGraphAuditAnchorsCommand(_args: string[], getContext: CommandC
if (anchorAuditIssueCount(result) > 0) process.exitCode = 1;
}

async function runGraphAuditDuplicatesCommand(_args: string[], getContext: CommandContextProvider): Promise<void> {
const { service } = getContext();
const result = await service.auditDuplicateClaims();
console.log("Duplicate claims audit:");
console.log(`Total active claims checked: ${result.total_claims}`);
if (result.groups.length === 0) {
console.log("- No duplicate claim groups found.");
return;
}
const totalDuplicates = result.groups.reduce((sum, group) => sum + group.duplicates.length, 0);
console.log(`Total duplicate pairs found: ${totalDuplicates}`);
for (const group of result.groups) {
const truncated = group.claim_text.length > 80 ? group.claim_text.slice(0, 80) + "..." : group.claim_text;
console.log(`- "${group.claim_id}" ("${truncated}")`);
for (const dup of group.duplicates) {
const dupTruncated = dup.claim_text.length > 80 ? dup.claim_text.slice(0, 80) + "..." : dup.claim_text;
console.log(` → "${dup.claim_id}" (${dup.similarity.toFixed(4)}) "${dupTruncated}"`);
}
}
}

async function runGraphExportCommand(args: string[], getContext: CommandContextProvider): Promise<void> {
const outputDir = requireFile(args[0], usage("graphExport"));
const { service } = getContext();
Expand Down
4 changes: 4 additions & 0 deletions libs/knowledge-graph/local-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export class LocalGraphMemoryProvider implements GraphMemoryProvider {
return this.service.auditCodeAnchors(this.repo);
}

async auditDuplicateClaims() {
return this.service.auditDuplicateClaims(this.repo);
}

async reviewProposal(proposal: unknown) {
return this.service.validateProposal(this.repo, proposal);
}
Expand Down
5 changes: 5 additions & 0 deletions libs/knowledge-graph/managed-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { normalizeProposal } from "./proposal.js";
import type { GraphMemoryProvider, ManagedProposalReviewResult } from "./provider.js";
import type { ApplyProposalResult, GraphReadResult, RepoRef } from "./service.js";
import type { GraphContextResult } from "./graph-context/types.js";
import type { ManagedDuplicateAudit } from "../managed/protocol.js";

export interface ManagedGraphClientOptions {
apiUrl: string;
Expand Down Expand Up @@ -101,6 +102,10 @@ export class ManagedGraphMemoryClient implements GraphMemoryProvider {
);
}

async auditDuplicateClaims(): Promise<ManagedDuplicateAudit> {
return this.request("/graph/audit-duplicates", { method: "GET" });
}

async reviewProposal(proposal: unknown): Promise<ManagedProposalReviewResult> {
const anchorAudit = await this.proposalAnchorAudit(proposal);
if (anchorAudit.result.missing_anchors.length > 0 ||
Expand Down
2 changes: 2 additions & 0 deletions libs/knowledge-graph/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { GraphContextResult } from "./graph-context/types.js";
import type { GraphViewData } from "./graph-view/build-graph-view.js";
import type {
ApplyProposalResult,
DuplicateAuditResult,
GraphReadResult,
ProposalReviewResult,
} from "./service.js";
Expand All @@ -23,6 +24,7 @@ export interface GraphMemoryProvider {
viewData(): Promise<GraphViewData>;
buildGraphView(): Promise<string>;
auditCodeAnchors(): Promise<ClaimAnchorAuditResult>;
auditDuplicateClaims(): Promise<DuplicateAuditResult>;
reviewProposal(proposal: unknown): Promise<ManagedProposalReviewResult>;
applyProposal(proposal: unknown): Promise<ApplyProposalResult>;
close(): void;
Expand Down
111 changes: 110 additions & 1 deletion libs/knowledge-graph/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { SqliteRepository as SqliteKnowledgeGraphRepository } from "../storage/s
import { bufferToFloat32Array } from "./graph-context/vector.js";
import { createEmbedder } from "./graph-context/embedder.js";
import { buildClaimDocuments } from "./graph-context/documents.js";
import { findSimilarClaims, type SimilarClaimMatch } from "./dedupe/find-similar-claims.js";
import { findSimilarClaims, type SimilarClaimMatch, type ClaimEmbeddingCandidate } from "./dedupe/find-similar-claims.js";

export type { GraphContextResult } from "./graph-context/types.js";
export type { ClaimAnchorAuditResult } from "./code-anchors/types.js";
Expand Down Expand Up @@ -62,6 +62,17 @@ export interface ProposalReviewResult extends ProposalValidationResult {
duplicate_warnings: Record<string, SimilarClaimMatch[]>;
}

export interface DuplicateAuditGroup {
claim_id: string;
claim_text: string;
duplicates: Array<{ claim_id: string; claim_text: string; similarity: number }>;
}

export interface DuplicateAuditResult {
total_claims: number;
groups: DuplicateAuditGroup[];
}

export class KnowledgeGraphService {
constructor(
private readonly repository: SqliteRepository,
Expand Down Expand Up @@ -152,6 +163,104 @@ export class KnowledgeGraphService {
return auditClaimCodeAnchors(input.repo_root, claims, new CodeAnchorResolver(), baselineFingerprints);
}

async auditDuplicateClaims(input: RepoRef): Promise<DuplicateAuditResult> {
const initialized = this.requireRepo(input);
const graph = this.repository.readGraphView(initialized.repo_id);
if (graph.claims.length === 0) {
return { total_claims: 0, groups: [] };
}

const supersededIds = new Set(
this.repository.readSupersededClaims(initialized.repo_id).map((claim) => claim.id),
);
const activeClaims = graph.claims.filter((claim) => !supersededIds.has(claim.id));
if (activeClaims.length === 0) {
return { total_claims: 0, groups: [] };
}

const storedVectors = new Map(
this.repository
.listGraphObjectEmbeddings({
repo_id: initialized.repo_id,
provider: this.contextConfig.embedding.provider,
model: this.contextConfig.embedding.model,
dimensions: this.contextConfig.embedding.dimensions,
})
.filter((record) => record.object_type === "claim")
.map((record) => {
const vector = bufferToFloat32Array(record.embedding);
if (vector.length !== this.contextConfig.embedding.dimensions) {
throw new Error(
`Stored embedding for claim ${record.object_id} has ${vector.length} dimensions; expected ${this.contextConfig.embedding.dimensions}.`,
);
}
return [record.object_id, vector] as const;
}),
);

const activeDocuments = buildClaimDocuments({ ...graph, claims: activeClaims });
const existingVectors: ClaimEmbeddingCandidate[] = [];
const missingDocs: typeof activeDocuments = [];
for (const doc of activeDocuments) {
const vector = storedVectors.get(doc.id);
if (vector === undefined) {
missingDocs.push(doc);
} else {
existingVectors.push({ claim_id: doc.id, vector });
}
}

if (missingDocs.length > 0) {
const embedder = createEmbedder(this.contextConfig.embedding);
const generated = await embedder.embedBatch(missingDocs.map((d) => d.text));
if (generated.length !== missingDocs.length) {
throw new Error(
`Embedding provider returned ${generated.length} vectors for ${missingDocs.length} missing claims.`,
);
}
for (const [index, doc] of missingDocs.entries()) {
const values = generated[index];
if (values === undefined || values.length !== this.contextConfig.embedding.dimensions) {
throw new Error(
`Embedding for claim ${doc.id} has ${values?.length ?? 0} dimensions; expected ${this.contextConfig.embedding.dimensions}.`,
);
}
const vector = new Float32Array(values);
existingVectors.push({ claim_id: doc.id, vector });
}
}

const seen = new Set<string>();
const claimText = new Map(activeClaims.map((c) => [c.id, c.text]));
const groups: DuplicateAuditGroup[] = [];

for (const claim of activeClaims) {
if (seen.has(claim.id)) continue;
const vector = existingVectors.find((v) => v.claim_id === claim.id)?.vector;
if (vector === undefined) continue;
const others = existingVectors.filter((v) => v.claim_id !== claim.id && !seen.has(v.claim_id));
const matches = findSimilarClaims(vector, others, this.contextConfig.dedupe.similarityThreshold)
.filter((m) => m.claim_id !== claim.id)
.map((m) => ({
claim_id: m.claim_id,
claim_text: claimText.get(m.claim_id) ?? "",
similarity: m.similarity,
}));
if (matches.length > 0) {
groups.push({
claim_id: claim.id,
claim_text: claim.text,
duplicates: matches,
});
seen.add(claim.id);
for (const m of matches) seen.add(m.claim_id);
}
}

groups.sort((a, b) => b.duplicates[0].similarity - a.duplicates[0].similarity);
return { total_claims: activeClaims.length, groups };
}

async validateProposal(input: RepoRef, proposal: unknown): Promise<ProposalReviewResult> {
const initialized = this.requireRepo(input);
const normalizedProposal = normalizeProposal(proposal, this.subjectLookup(initialized.repo_id));
Expand Down
17 changes: 17 additions & 0 deletions libs/managed/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,21 @@ export const ProposalAnchorAuditSchema = Type.Object({
fingerprints: Type.Record(Type.String(), Type.Record(Type.String(), Type.String())),
});

export const DuplicateAuditMatchSchema = Type.Object({
claim_id: Type.String(),
claim_text: Type.String(),
similarity: Type.Number(),
});
export const DuplicateAuditGroupSchema = Type.Object({
claim_id: Type.String(),
claim_text: Type.String(),
duplicates: Type.Array(DuplicateAuditMatchSchema),
});
export const DuplicateAuditSchema = Type.Object({
total_claims: Type.Integer({ minimum: 0 }),
groups: Type.Array(DuplicateAuditGroupSchema),
});

export const ProposalReviewSchema = Type.Object({
valid: Type.Boolean(),
errors: Type.Array(Type.String()),
Expand Down Expand Up @@ -481,6 +496,7 @@ export const routeSchemas = {
claims: Type.Array(ClaimSchema),
fingerprints: Type.Record(Type.String(), Type.Record(Type.String(), Type.String())),
})),
graphAuditDuplicates: route(Type.Object({}), DuplicateAuditSchema),
proposalReview: route(Type.Object({ proposal: MemoryProposalSchema, anchor_audit: ProposalAnchorAuditSchema }), ProposalReviewSchema),
proposalApply: route(Type.Object({
proposal: MemoryProposalSchema,
Expand All @@ -507,6 +523,7 @@ export type ManagedAccessRequest = Static<typeof AccessRequestSchema>;
export type ManagedGraphRead = Static<typeof GraphReadSchema>;
export type ManagedGraphContext = Static<typeof GraphContextSchema>;
export type ManagedGraphViewData = Static<typeof GraphViewDataSchema>;
export type ManagedDuplicateAudit = Static<typeof DuplicateAuditSchema>;
export type ManagedProposalReview = Static<typeof ProposalReviewSchema>;

function route<TRequest extends TSchema, TResponse extends TSchema>(request: TRequest, response: TResponse) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs",
"smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs",
"smoke:cursor": "npm run build && node scripts/smoke-cursor-install.mjs",
"test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js",
"test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-duplicate-audit.js",
"test:repo-installations": "npm run build && node scripts/check-repo-installations.js",
"test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js",
"test:repo-context": "npm run build && node scripts/check-repo-context.js",
Expand Down
Loading
Loading