diff --git a/docs/GRAPH_SCHEMA_REFERENCE.md b/docs/GRAPH_SCHEMA_REFERENCE.md index e972274..35ded96 100644 --- a/docs/GRAPH_SCHEMA_REFERENCE.md +++ b/docs/GRAPH_SCHEMA_REFERENCE.md @@ -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 diff --git a/prompts/dream-nightly.md b/prompts/dream-nightly.md index 118895c..e440efe 100644 --- a/prompts/dream-nightly.md +++ b/prompts/dream-nightly.md @@ -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: diff --git a/scripts/backfill-contradicts-resolved.cypher b/scripts/backfill-contradicts-resolved.cypher new file mode 100644 index 0000000..ef35f6c --- /dev/null +++ b/scripts/backfill-contradicts-resolved.cypher @@ -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; diff --git a/skills/graph-ask/SKILL.md b/skills/graph-ask/SKILL.md index f71a040..b72917a 100644 --- a/skills/graph-ask/SKILL.md +++ b/skills/graph-ask/SKILL.md @@ -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"* diff --git a/src/shared/neo4j-client.test.ts b/src/shared/neo4j-client.test.ts index b37849d..c063569 100644 --- a/src/shared/neo4j-client.test.ts +++ b/src/shared/neo4j-client.test.ts @@ -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", diff --git a/src/shared/neo4j-client.ts b/src/shared/neo4j-client.ts index 85ff26a..7aa38b0 100644 --- a/src/shared/neo4j-client.ts +++ b/src/shared/neo4j-client.ts @@ -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; @@ -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 { + 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 { @@ -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 = { @@ -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 @@ -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 }, ); @@ -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 @@ -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')