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
2 changes: 1 addition & 1 deletion docs/GRAPH_SCHEMA_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,7 @@ ORDER BY b.name, a.name
### "Find unresolved contradictions"
```cypher
MATCH (a:Entity)-[c:CONTRADICTS]->(b:Entity)
WHERE c.resolved = false
WHERE coalesce(c.resolved, false) = false
RETURN labels(a) AS type_a, a.id AS id_a,
labels(b) AS type_b, b.id AS id_b,
c.description, c.detected_date
Expand Down
10 changes: 10 additions & 0 deletions prompts/dream-nightly.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,16 @@ If none fit exactly, use the closest match. Do NOT invent new values.

When a fact replaces an old one (e.g., "we switched from Vue to React"), `graph_relate` handles this automatically — it sets `invalid_at` on the old edge. Note the supersession in the changelog.

### 4g-1. Flag Contradictions

When two facts can't both be true and nothing says which one won, write a `CONTRADICTS` edge between them with these properties:

- `description`: one line on what conflicts
- `detected_date`: the run's timestamp
- `resolved`: `false`

The server fills `resolved: false` and `detected_date` if you leave them out, but always send `description`, since nothing else records why the pair conflicts. Log a `contradiction_found` audit event for each one.

### 4g-2. Validate Extractions

After writing entities and edges for a transcript, call `graph_validate` with the session's `source_session` to check for bad data:
Expand Down
16 changes: 16 additions & 0 deletions scripts/backfill-contradicts-resolved.cypher
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// One-time backfill: give every CONTRADICTS edge the review fields the
// contradiction check relies on. Safe to re-run: coalesce keeps any value
// already present, so resolved edges stay resolved.
//
// 1. Preview (read-only):
MATCH (a:Entity)-[r:CONTRADICTS]->(b:Entity)
WHERE r.resolved IS NULL OR r.detected_date IS NULL
RETURN a.tenant_id AS tenant, a.id AS from_id, b.id AS to_id,
r.resolved AS resolved, r.detected_date AS detected_date, r.ingested_at AS ingested_at;

// 2. Apply:
MATCH (:Entity)-[r:CONTRADICTS]->(:Entity)
WHERE r.resolved IS NULL OR r.detected_date IS NULL
SET r.resolved = coalesce(r.resolved, false),
r.detected_date = coalesce(r.detected_date, r.ingested_at, datetime())
RETURN count(r) AS backfilled;
7 changes: 4 additions & 3 deletions skills/graph-ask/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,10 @@ ORDER BY r.weight DESC LIMIT 30
### T7 — Contradictions
```cypher
MATCH (a:Entity)-[r:CONTRADICTS]->(b:Entity)
WHERE r.resolved = false
RETURN a.name AS entity_a, b.name AS entity_b, r.properties.description AS conflict, r.properties.detected_date AS detected
ORDER BY r.properties.detected_date DESC LIMIT 20
WHERE coalesce(r.resolved, false) = false
RETURN a.name AS entity_a, b.name AS entity_b, r.description AS conflict,
coalesce(r.detected_date, r.ingested_at) AS detected
ORDER BY toString(detected) DESC LIMIT 20
```
*Use for: "what contradictions exist?", "conflicting facts"*

Expand Down
50 changes: 50 additions & 0 deletions src/shared/neo4j-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,56 @@ describe("Contradictions edge cases", () => {
expect(result.count).toBe(0);
});

it("treats a CONTRADICTS edge with no resolved flag as unresolved", async () => {
await client.createRelationship(T, "fact-vue", "fact-react", "CONTRADICTS", 1.0, {
description: "Tech stack conflict",
});
// Recreate an edge written before the flag was always set.
await client.removeEdgeProperty(T, "fact-vue", "fact-react", "CONTRADICTS", "resolved");
await client.removeEdgeProperty(T, "fact-vue", "fact-react", "CONTRADICTS", "detected_date");

const result = await client.findContradictions(T);
expect(result.count).toBe(1);
expect(result.contradictions[0].resolved).toBe(false);
expect(result.contradictions[0].detected_date).not.toBe("");

const stats = await client.getStats(T);
expect(stats.health.unresolved_contradictions).toBe(1);
});

it("fills resolved=false and detected_date when a CONTRADICTS edge is written without them", async () => {
await client.createRelationship(T, "fact-vue", "fact-react", "CONTRADICTS", 0.5);

const edges = await client.getRelationships(T, "fact-vue", "out");
const edge = edges.find((e) => e.type === "CONTRADICTS");
expect(edge?.properties["resolved"]).toBe(false);
expect(edge?.properties["detected_date"]).toBeDefined();

const result = await client.findContradictions(T);
expect(result.count).toBe(1);
});

it("fills the CONTRADICTS defaults in batch mode too", async () => {
await client.batchRelate(T, {
entities: [],
relations: [{ from: "fact-vue", to: "fact-react", relation: "CONTRADICTS", weight: 0.5 }],
});
const result = await client.findContradictions(T);
expect(result.count).toBe(1);
expect(result.contradictions[0].resolved).toBe(false);
});

it("does not reopen a resolved contradiction when it is asserted again", async () => {
await client.createRelationship(T, "fact-vue", "fact-react", "CONTRADICTS", 1.0, {
description: "Tech stack conflict",
resolved: true,
});
await client.createRelationship(T, "fact-vue", "fact-react", "CONTRADICTS", 1.0);

expect((await client.findContradictions(T)).count).toBe(0);
expect((await client.findContradictions(T, true)).contradictions[0].resolved).toBe(true);
});

it("surfaces resolved contradictions when include_resolved=true", async () => {
await client.createRelationship(T, "fact-vue", "fact-react", "CONTRADICTS", 1.0, {
description: "Tech stack conflict",
Expand Down
44 changes: 39 additions & 5 deletions src/shared/neo4j-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ function recordToEdge(

// ─── Neo4jClient ───

/** Fills the review fields on a CONTRADICTS edge when the writer left them
* out, so the edge is always visible to findContradictions() and getStats().
* coalesce keeps any value already on the edge, so re-asserting a resolved
* contradiction does not reopen it. Expects `r` and `$now` in scope. */
const CONTRADICTS_DEFAULTS =
"SET r.resolved = coalesce(r.resolved, false), " +
"r.detected_date = coalesce(r.detected_date, datetime($now))";

export class Neo4jClient {
private driver: Driver;
private database: string;
Expand Down Expand Up @@ -357,6 +365,23 @@ export class Neo4jClient {
);
}

/** Remove one property from an edge. Used to recreate edges written
* before a property was always set (e.g. CONTRADICTS without `resolved`). */
async removeEdgeProperty(
tenantId: string,
fromId: string,
toId: string,
relation: RelationshipType,
property: "resolved" | "detected_date" | "description",
): Promise<void> {
const rel = existingRelation(relation);
await this.run(
`MATCH (a:Entity {tenant_id: $tenantId, id: $fromId})-[r:\`${rel}\`]->(b:Entity {tenant_id: $tenantId, id: $toId})
REMOVE r.\`${property}\``,
{ tenantId, fromId, toId },
);
}

// ─── Schema Initialization ───

async initializeSchema(): Promise<void> {
Expand Down Expand Up @@ -615,6 +640,7 @@ export class Neo4jClient {
// applies to verbs like FAMILY_OF, where dating one child's birth must not
// retire every other child.
const supersede = validAtChecked !== null && isSupersedable(rel.value);
const contradictsDefaults = rel.value === "CONTRADICTS" ? CONTRADICTS_DEFAULTS : "";
const supersedeAt = validAtChecked ?? now;
const { clean } = sanitizeProperties(properties, "edge");
const allProps = {
Expand Down Expand Up @@ -666,6 +692,7 @@ export class Neo4jClient {
r.valid_at = CASE WHEN $validAt IS NOT NULL THEN datetime($validAt) ELSE r.valid_at END,
r += $allProps
WITH r, a, b
${contradictsDefaults}
SET r.proposed_relations = CASE
WHEN $proposed IS NULL THEN r.proposed_relations
WHEN $proposed IN coalesce(r.proposed_relations, []) THEN r.proposed_relations
Expand Down Expand Up @@ -1021,16 +1048,22 @@ export class Neo4jClient {
}>;
count: number;
}> {
const resolvedFilter = includeResolved ? "" : "AND r.resolved = false";
// A CONTRADICTS edge written without `resolved` is unresolved. Matching
// only `r.resolved = false` hid every such edge, so the check reported 0
// while real contradictions sat in the graph.
const resolvedFilter = includeResolved ? "" : "AND coalesce(r.resolved, false) = false";
const rows = await this.run(
`
MATCH (a:Entity {tenant_id: $tenantId})-[r:CONTRADICTS]->(b:Entity {tenant_id: $tenantId})
WHERE 1=1 ${resolvedFilter}
RETURN a.id AS aId, labels(a) AS aLabels, a.name AS aName,
b.id AS bId, labels(b) AS bLabels, b.name AS bName,
r.description AS description, r.detected_date AS detected_date,
r.resolved AS resolved
ORDER BY r.detected_date DESC
r.description AS description,
coalesce(r.detected_date, r.ingested_at) AS detected_date,
coalesce(r.resolved, false) AS resolved
// Older writers stored detected_date as an ISO string, newer edges get a
// datetime. toString() puts both on one ISO-ordered scale.
ORDER BY toString(coalesce(r.detected_date, r.ingested_at)) DESC
`,
{ tenantId },
);
Expand Down Expand Up @@ -1360,6 +1393,7 @@ export class Neo4jClient {
r.valid_at = CASE WHEN $validAt IS NOT NULL THEN datetime($validAt) ELSE r.valid_at END,
r += $props
WITH r
${rel.relation === "CONTRADICTS" ? CONTRADICTS_DEFAULTS : ""}
SET r.proposed_relations = CASE
WHEN $proposed IS NULL THEN r.proposed_relations
WHEN $proposed IN coalesce(r.proposed_relations, []) THEN r.proposed_relations
Expand Down Expand Up @@ -1462,7 +1496,7 @@ export class Neo4jClient {
WHERE NOT (orphan)-[]-()
WITH avgWeight, count(orphan) AS orphanCount
OPTIONAL MATCH (a2:Entity {tenant_id: $tenantId})-[c:CONTRADICTS]->(b2:Entity {tenant_id: $tenantId})
WHERE c.resolved = false
WHERE coalesce(c.resolved, false) = false
WITH avgWeight, orphanCount, count(c) AS contradictions
OPTIONAL MATCH (stale:Entity {tenant_id: $tenantId})
WHERE stale.confidence < 0.2 AND stale.last_seen < datetime() - duration('P90D')
Expand Down
Loading