Joshua Sundance Bailey commited on
Commit Β·
a973dff
1
Parent(s): 1c5cd80
feat: open-world trust-loop UX (chat-first review, edge review, layout reshape)
Browse filesChat-first magic build; radial + chat-driven Accept/Reject review; edge-claim fix (edges now reviewable + amber); inspector panel dropped + full-width layout; fcose auto-arrange on build; confidence-tier voice; stable full-source context. 44 files, gate-verified upstream (mypy + full pytest green).
- cytoscapecanvas/frontend/Index.svelte +326 -12
- cytoscapecanvas/frontend/renderer.test.ts +411 -8
- cytoscapecanvas/frontend/renderer.ts +400 -35
- cytoscapecanvas/frontend/types.ts +3 -1
- fixtures/seed_local_ai.json +729 -0
- scripts/playwright_cxtmenu.py +97 -4
- src/loosecanvas/agent_harness.py +133 -32
- src/loosecanvas/agent_tools.py +171 -9
- src/loosecanvas/contracts.py +2 -1
- src/loosecanvas/exporter.py +78 -4
- src/loosecanvas/extractors/fixture_adapter.py +11 -2
- src/loosecanvas/extractors/text_graph_adapter.py +27 -8
- src/loosecanvas/graph_repository.py +15 -1
- src/loosecanvas/main.py +553 -169
- src/loosecanvas/reducer.py +62 -5
- src/loosecanvas/scene_curator.py +83 -5
- src/loosecanvas/turn_logic.py +604 -60
- src/loosecanvas/validator.py +18 -3
- tests/test_agent_harness.py +24 -1
- tests/test_agent_tools.py +29 -0
- tests/test_b2_cxtmenu_review.py +144 -0
- tests/test_contracts.py +3 -2
- tests/test_edge_claim_review.py +238 -0
- tests/test_exporter.py +322 -3
- tests/test_find_connection.py +189 -0
- tests/test_fixture_adapter.py +31 -0
- tests/test_graph_repository.py +50 -0
- tests/test_magic_build_opener.py +172 -0
- tests/test_p1_03_claim_review_consumer.py +35 -0
- tests/test_renderer_contract_parity.py +7 -4
- tests/test_review_agent_tool.py +271 -0
- tests/test_review_amber_clear.py +214 -0
- tests/test_scene_curator.py +82 -0
- tests/test_search_no_match_reset.py +57 -0
- tests/test_seed_resilience.py +132 -0
- tests/test_t1_03_polish.py +19 -4
- tests/test_t2_01_removal.py +1 -1
- tests/test_t2_02_claim_review.py +10 -2
- tests/test_t2_03_edit_edges.py +192 -6
- tests/test_t2_04_node_edits.py +367 -0
- tests/test_text_graph_adapter.py +74 -0
- tests/test_time_travel_output_arity.py +95 -0
- tests/test_tool_narration_chat.py +183 -0
- tests/test_tooltip_stale_review.py +135 -0
cytoscapecanvas/frontend/Index.svelte
CHANGED
|
@@ -1,10 +1,14 @@
|
|
| 1 |
<script module lang="ts">
|
| 2 |
import cytoscapeLib from "cytoscape";
|
| 3 |
import cxtmenu from "cytoscape-cxtmenu";
|
| 4 |
-
//
|
| 5 |
-
|
| 6 |
-
//
|
|
|
|
|
|
|
|
|
|
| 7 |
cytoscapeLib.use(cxtmenu);
|
|
|
|
| 8 |
</script>
|
| 9 |
|
| 10 |
<script lang="ts">
|
|
@@ -16,7 +20,12 @@
|
|
| 16 |
import {
|
| 17 |
applyCommands,
|
| 18 |
resetCanvas,
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
wireSelectionEvents,
|
| 21 |
wireRenameEvents,
|
| 22 |
wireTrustTooltip,
|
|
@@ -55,6 +64,47 @@
|
|
| 55 |
let focusedNodeId = $state<string | null>(null);
|
| 56 |
// FE-01: non-fatal patch-apply warning surfaced as a dismissible toast.
|
| 57 |
let patchError = $state<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
function fitGraph(): void {
|
| 60 |
if (!cy || cy.elements().length === 0) return;
|
|
@@ -65,6 +115,115 @@
|
|
| 65 |
cy.fit(cy.elements(), 28);
|
| 66 |
}
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
onMount(() => {
|
| 69 |
cy = cytoscape({
|
| 70 |
container,
|
|
@@ -115,6 +274,18 @@
|
|
| 115 |
|
| 116 |
wireTrustTooltip(cy);
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
// T3-03: Keyboard navigation handler (thin wiring; logic lives in keyboardNav.ts).
|
| 119 |
// Arrow keys: move focus ring to the nearest visible non-fogged node.
|
| 120 |
// Enter: dispatch a select event for the focused node.
|
|
@@ -122,6 +293,44 @@
|
|
| 122 |
function handleKeydown(e: KeyboardEvent): void {
|
| 123 |
if (!cy) return;
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
if (e.key === "Escape") {
|
| 126 |
cy.$(":selected").unselect();
|
| 127 |
cy.elements().removeClass("dimmed");
|
|
@@ -191,6 +400,11 @@
|
|
| 191 |
return () => {
|
| 192 |
container.removeEventListener("keydown", handleKeydown);
|
| 193 |
ro.disconnect();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
cy?.destroy();
|
| 195 |
cy = null;
|
| 196 |
};
|
|
@@ -226,13 +440,33 @@
|
|
| 226 |
resetCanvas(cy);
|
| 227 |
currentSceneId = value.scene_id;
|
| 228 |
pendingFit = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
}
|
| 230 |
const result = applyCommands(cy, value.operations);
|
| 231 |
lastAppliedPatchId = value.patch_id;
|
| 232 |
-
//
|
| 233 |
-
//
|
|
|
|
|
|
|
| 234 |
if (!value.is_snapshot) {
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
| 236 |
}
|
| 237 |
// FE-01: per-op failures are non-fatal β the rest of the patch still
|
| 238 |
// applied. Surface a dismissible toast and notify the host.
|
|
@@ -285,10 +519,50 @@
|
|
| 285 |
{/if}
|
| 286 |
|
| 287 |
<div class="lc-canvas-wrapper">
|
| 288 |
-
<!--
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
<div bind:this={container} class="lc-cytoscape-canvas"></div>
|
| 293 |
</div>
|
| 294 |
|
|
@@ -313,11 +587,15 @@
|
|
| 313 |
.lc-canvas-wrapper {
|
| 314 |
position: relative;
|
| 315 |
}
|
| 316 |
-
.lc-
|
| 317 |
position: absolute;
|
| 318 |
top: 8px;
|
| 319 |
right: 8px;
|
| 320 |
z-index: 10;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
padding: 4px 8px;
|
| 322 |
background: rgba(17, 19, 26, 0.85);
|
| 323 |
color: #cbd5e0;
|
|
@@ -329,6 +607,42 @@
|
|
| 329 |
.lc-fit-btn:hover {
|
| 330 |
background: #2d3748;
|
| 331 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
.lc-cytoscape-canvas {
|
| 333 |
width: 100%;
|
| 334 |
/* CRITICAL: fixed px height, NEVER vh. The app is embedded in an HF Spaces
|
|
|
|
| 1 |
<script module lang="ts">
|
| 2 |
import cytoscapeLib from "cytoscape";
|
| 3 |
import cxtmenu from "cytoscape-cxtmenu";
|
| 4 |
+
// @ts-expect-error cytoscape-fcose ships no types (same as cxtmenu)
|
| 5 |
+
import fcose from "cytoscape-fcose";
|
| 6 |
+
// Register extensions exactly once. Module scope runs a single time for the
|
| 7 |
+
// whole component (not per instance / remount), so this avoids Cytoscape's
|
| 8 |
+
// "extension already registered" warning. cxtmenu = radial menu; fcose =
|
| 9 |
+
// force-directed auto-layout (the "Arrange" button).
|
| 10 |
cytoscapeLib.use(cxtmenu);
|
| 11 |
+
cytoscapeLib.use(fcose);
|
| 12 |
</script>
|
| 13 |
|
| 14 |
<script lang="ts">
|
|
|
|
| 20 |
import {
|
| 21 |
applyCommands,
|
| 22 |
resetCanvas,
|
| 23 |
+
dimBurst,
|
| 24 |
+
collectTurnNodeIds,
|
| 25 |
+
startReviewPulse,
|
| 26 |
+
stopReviewPulse,
|
| 27 |
+
wireHoverFade,
|
| 28 |
+
wireEdgeHover,
|
| 29 |
wireSelectionEvents,
|
| 30 |
wireRenameEvents,
|
| 31 |
wireTrustTooltip,
|
|
|
|
| 64 |
let focusedNodeId = $state<string | null>(null);
|
| 65 |
// FE-01: non-fatal patch-apply warning surfaced as a dismissible toast.
|
| 66 |
let patchError = $state<string | null>(null);
|
| 67 |
+
// Review-queue badge: count of model-inferred proposals still awaiting the
|
| 68 |
+
// user's decision. Derived from the canvas after every patch so it counts down
|
| 69 |
+
// as proposals are accepted/rejected β framing review as the act that makes
|
| 70 |
+
// knowledge real. Pure frontend (no backend arity), so it never touches turn_outputs.
|
| 71 |
+
let pendingCount = $state(0);
|
| 72 |
+
// Task 4: ids of nodes added/touched by the most-recent non-snapshot turn.
|
| 73 |
+
// Used by settleNewNodes() to pin existing nodes and only lay out the new ones.
|
| 74 |
+
// Reset to empty on a snapshot (full rebuild) so "Settle new" falls back to
|
| 75 |
+
// a full layout on the first turn after a reload.
|
| 76 |
+
let lastTurnNodeIds = $state<Set<string>>(new Set());
|
| 77 |
+
|
| 78 |
+
// Auto-arrange debounce: after a BUILD/LOAD snapshot lands, wait 450 ms for
|
| 79 |
+
// streaming to settle, then run fcose once over all visible nodes. Reset on
|
| 80 |
+
// each new snapshot patch so bursts coalesce into a single layout run.
|
| 81 |
+
// Cleared on teardown so the timer never fires after the component is gone.
|
| 82 |
+
let autoArrangeTimer: ReturnType<typeof setTimeout> | null = null;
|
| 83 |
+
|
| 84 |
+
function updatePendingCount(): void {
|
| 85 |
+
if (cy) pendingCount = cy.$(".review-pending.origin-model-inferred").length;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/** j/k review-queue cycling: focus the next/prev pending proposal and select it
|
| 89 |
+
* (which surfaces its Accept/Reject in the inspector). Returns false if none. */
|
| 90 |
+
function cyclePending(dir: 1 | -1): boolean {
|
| 91 |
+
if (!cy) return false;
|
| 92 |
+
const pending = cy
|
| 93 |
+
.$(".review-pending.origin-model-inferred")
|
| 94 |
+
.filter((el) => el.isNode() && el.style("display") !== "none");
|
| 95 |
+
const ids = pending.map((n) => n.id());
|
| 96 |
+
if (ids.length === 0) return false;
|
| 97 |
+
const cur = focusedNodeId ? ids.indexOf(focusedNodeId) : -1;
|
| 98 |
+
const next = ids[(cur + (dir === 1 ? 1 : ids.length - 1)) % ids.length];
|
| 99 |
+
if (focusedNodeId) cy.getElementById(focusedNodeId).removeClass("focus-ring");
|
| 100 |
+
focusedNodeId = next;
|
| 101 |
+
const node = cy.getElementById(next);
|
| 102 |
+
node.addClass("focus-ring");
|
| 103 |
+
cy.animate({ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) }, { duration: 300 });
|
| 104 |
+
cy.$(":selected").unselect();
|
| 105 |
+
node.select();
|
| 106 |
+
return true;
|
| 107 |
+
}
|
| 108 |
|
| 109 |
function fitGraph(): void {
|
| 110 |
if (!cy || cy.elements().length === 0) return;
|
|
|
|
| 115 |
cy.fit(cy.elements(), 28);
|
| 116 |
}
|
| 117 |
|
| 118 |
+
// Layout algorithms offered in the toolbar. fcose is the force-directed default
|
| 119 |
+
// (registered in the module script); concentric/grid/breadthfirst are native
|
| 120 |
+
// Cytoscape layouts (no extra dep). The frontend owns positions (plan/05), so a
|
| 121 |
+
// user-triggered re-layout is allowed.
|
| 122 |
+
type LayoutName = "fcose" | "concentric" | "grid" | "breadthfirst";
|
| 123 |
+
|
| 124 |
+
// Auto-arrange β the "β¦ Arrange" button (fcose) + the layout <select>. Animates
|
| 125 |
+
// to a tidy arrangement and re-fits. Lays out only visible (non-fogged) elements
|
| 126 |
+
// so fogged nodes keep their place. No-op on an empty canvas.
|
| 127 |
+
function runLayout(name: LayoutName): void {
|
| 128 |
+
if (!cy || cy.elements().length === 0) return;
|
| 129 |
+
cy.elements().removeClass("dimmed");
|
| 130 |
+
const eles = cy.elements().filter((el) => !el.hasClass("fogged"));
|
| 131 |
+
// Per-algorithm options. fcose keeps the existing tuning; the native layouts
|
| 132 |
+
// share the same animate/fit/padding feel so switching is seamless.
|
| 133 |
+
const optsByName: Record<LayoutName, Record<string, unknown>> = {
|
| 134 |
+
fcose: {
|
| 135 |
+
name: "fcose",
|
| 136 |
+
animate: true,
|
| 137 |
+
animationDuration: 650,
|
| 138 |
+
fit: true,
|
| 139 |
+
padding: 36,
|
| 140 |
+
nodeSeparation: 95,
|
| 141 |
+
randomize: false,
|
| 142 |
+
quality: "default",
|
| 143 |
+
},
|
| 144 |
+
concentric: {
|
| 145 |
+
name: "concentric",
|
| 146 |
+
animate: true,
|
| 147 |
+
animationDuration: 650,
|
| 148 |
+
fit: true,
|
| 149 |
+
padding: 36,
|
| 150 |
+
minNodeSpacing: 40,
|
| 151 |
+
},
|
| 152 |
+
grid: {
|
| 153 |
+
name: "grid",
|
| 154 |
+
animate: true,
|
| 155 |
+
animationDuration: 650,
|
| 156 |
+
fit: true,
|
| 157 |
+
padding: 36,
|
| 158 |
+
avoidOverlap: true,
|
| 159 |
+
},
|
| 160 |
+
breadthfirst: {
|
| 161 |
+
name: "breadthfirst",
|
| 162 |
+
animate: true,
|
| 163 |
+
animationDuration: 650,
|
| 164 |
+
fit: true,
|
| 165 |
+
padding: 36,
|
| 166 |
+
spacingFactor: 1.2,
|
| 167 |
+
directed: false,
|
| 168 |
+
},
|
| 169 |
+
};
|
| 170 |
+
const opts = optsByName[name] as unknown as import("cytoscape").LayoutOptions;
|
| 171 |
+
eles.layout(opts).run();
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/**
|
| 175 |
+
* Task 4: Partial fcose layout β settle ONLY newly-added nodes while keeping
|
| 176 |
+
* existing nodes pinned at their current positions via fcose `fixedNodeConstraint`.
|
| 177 |
+
* If the last-turn set is empty (no turn applied yet) or covers all canvas nodes
|
| 178 |
+
* (a full rebuild), falls back to a full runLayout("fcose") instead.
|
| 179 |
+
*/
|
| 180 |
+
function settleNewNodes(): void {
|
| 181 |
+
if (!cy || cy.elements().length === 0) return;
|
| 182 |
+
const allNodeIds = cy.nodes().map((n) => n.id());
|
| 183 |
+
// Fall back to full layout when there are no last-turn nodes (fresh snapshot
|
| 184 |
+
// with no turn yet applied) or when every node is a new node.
|
| 185 |
+
if (lastTurnNodeIds.size === 0 || lastTurnNodeIds.size >= allNodeIds.length) {
|
| 186 |
+
runLayout("fcose");
|
| 187 |
+
return;
|
| 188 |
+
}
|
| 189 |
+
// Build fixedNodeConstraint for every node NOT in lastTurnNodeIds so they
|
| 190 |
+
// keep their positions. Only visible (non-fogged) nodes join the layout.
|
| 191 |
+
interface FixedConstraint { nodeId: string; position: { x: number; y: number } }
|
| 192 |
+
const fixedNodeConstraint: FixedConstraint[] = [];
|
| 193 |
+
cy.nodes().forEach((n) => {
|
| 194 |
+
if (!lastTurnNodeIds.has(n.id()) && !n.hasClass("fogged")) {
|
| 195 |
+
const pos = n.position();
|
| 196 |
+
fixedNodeConstraint.push({ nodeId: n.id(), position: { x: pos.x, y: pos.y } });
|
| 197 |
+
}
|
| 198 |
+
});
|
| 199 |
+
const eles = cy.elements().filter((el) => !el.hasClass("fogged"));
|
| 200 |
+
const opts = {
|
| 201 |
+
name: "fcose",
|
| 202 |
+
animate: true,
|
| 203 |
+
animationDuration: 650,
|
| 204 |
+
fit: true,
|
| 205 |
+
padding: 36,
|
| 206 |
+
nodeSeparation: 95,
|
| 207 |
+
randomize: false,
|
| 208 |
+
quality: "default",
|
| 209 |
+
fixedNodeConstraint,
|
| 210 |
+
} as unknown as import("cytoscape").LayoutOptions;
|
| 211 |
+
eles.layout(opts).run();
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// Pure-view zoom around the viewport center (the οΌ / οΌ toolbar buttons). No
|
| 215 |
+
// backend call; animates a smooth zoom so the gesture reads as deliberate.
|
| 216 |
+
function zoomBy(factor: number): void {
|
| 217 |
+
if (!cy) return;
|
| 218 |
+
cy.animate(
|
| 219 |
+
{
|
| 220 |
+
zoom: cy.zoom() * factor,
|
| 221 |
+
renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 },
|
| 222 |
+
},
|
| 223 |
+
{ duration: 180 },
|
| 224 |
+
);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
onMount(() => {
|
| 228 |
cy = cytoscape({
|
| 229 |
container,
|
|
|
|
| 274 |
|
| 275 |
wireTrustTooltip(cy);
|
| 276 |
|
| 277 |
+
// Hover-fade: fade everything outside the hovered node's neighbourhood so
|
| 278 |
+
// local structure pops (uses its own class; clears on mouseout).
|
| 279 |
+
wireHoverFade(cy);
|
| 280 |
+
|
| 281 |
+
// Edge hover-emphasis: brighten + thicken the hovered edge (class toggle only).
|
| 282 |
+
wireEdgeHover(cy);
|
| 283 |
+
|
| 284 |
+
// Pulse the amber "awaiting review" border on model-inferred pending nodes
|
| 285 |
+
// so a proposal that needs the user's decision draws the eye. Stopped in the
|
| 286 |
+
// teardown below before cy.destroy() so the interval never hits a dead core.
|
| 287 |
+
startReviewPulse(cy);
|
| 288 |
+
|
| 289 |
// T3-03: Keyboard navigation handler (thin wiring; logic lives in keyboardNav.ts).
|
| 290 |
// Arrow keys: move focus ring to the nearest visible non-fogged node.
|
| 291 |
// Enter: dispatch a select event for the focused node.
|
|
|
|
| 293 |
function handleKeydown(e: KeyboardEvent): void {
|
| 294 |
if (!cy) return;
|
| 295 |
|
| 296 |
+
// Single-key review shortcuts (the high-frequency path; far cheaper than a
|
| 297 |
+
// command palette). Guarded against firing while typing in a field; the
|
| 298 |
+
// handler is bound to the canvas container, so it only fires when the
|
| 299 |
+
// canvas has focus anyway. A = accept, R = reject the selected (or focused)
|
| 300 |
+
// node's proposal; F = zoom-to-fit. A/R reuse the same "review" select
|
| 301 |
+
// channel the radial menu dispatches, so the backend routing is unchanged.
|
| 302 |
+
const target = e.target as HTMLElement | null;
|
| 303 |
+
const typing =
|
| 304 |
+
target?.tagName === "INPUT" ||
|
| 305 |
+
target?.tagName === "TEXTAREA" ||
|
| 306 |
+
target?.isContentEditable === true;
|
| 307 |
+
if (!typing) {
|
| 308 |
+
const k = e.key.toLowerCase();
|
| 309 |
+
if (k === "f") {
|
| 310 |
+
e.preventDefault();
|
| 311 |
+
fitGraph();
|
| 312 |
+
return;
|
| 313 |
+
}
|
| 314 |
+
if (k === "a" || k === "r") {
|
| 315 |
+
const sel = cy.$("node:selected").first();
|
| 316 |
+
const id = sel.nonempty() ? sel.id() : focusedNodeId;
|
| 317 |
+
if (id) {
|
| 318 |
+
e.preventDefault();
|
| 319 |
+
gradio.dispatch("select", {
|
| 320 |
+
index: null,
|
| 321 |
+
value: { kind: "review", id, action: k === "a" ? "accepted" : "rejected" },
|
| 322 |
+
selected: false,
|
| 323 |
+
});
|
| 324 |
+
}
|
| 325 |
+
return;
|
| 326 |
+
}
|
| 327 |
+
if (k === "j" || k === "k") {
|
| 328 |
+
// Cycle the review queue: focus next/prev pending proposal + select it.
|
| 329 |
+
if (cyclePending(k === "j" ? 1 : -1)) e.preventDefault();
|
| 330 |
+
return;
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
if (e.key === "Escape") {
|
| 335 |
cy.$(":selected").unselect();
|
| 336 |
cy.elements().removeClass("dimmed");
|
|
|
|
| 400 |
return () => {
|
| 401 |
container.removeEventListener("keydown", handleKeydown);
|
| 402 |
ro.disconnect();
|
| 403 |
+
if (autoArrangeTimer !== null) {
|
| 404 |
+
clearTimeout(autoArrangeTimer);
|
| 405 |
+
autoArrangeTimer = null;
|
| 406 |
+
}
|
| 407 |
+
if (cy) stopReviewPulse(cy);
|
| 408 |
cy?.destroy();
|
| 409 |
cy = null;
|
| 410 |
};
|
|
|
|
| 440 |
resetCanvas(cy);
|
| 441 |
currentSceneId = value.scene_id;
|
| 442 |
pendingFit = true;
|
| 443 |
+
// A snapshot replaces the whole scene β clear the last-turn set so
|
| 444 |
+
// "Settle new" falls back to a full layout on the next turn.
|
| 445 |
+
lastTurnNodeIds = new Set();
|
| 446 |
+
// Auto-arrange: schedule fcose 450 ms after the most-recent snapshot
|
| 447 |
+
// patch so streaming bursts coalesce into a single layout run. The
|
| 448 |
+
// timer is reset on each new snapshot patch and cleared on teardown.
|
| 449 |
+
// Guard: only run when at least one visible (non-fogged) node exists.
|
| 450 |
+
if (autoArrangeTimer !== null) clearTimeout(autoArrangeTimer);
|
| 451 |
+
autoArrangeTimer = setTimeout(() => {
|
| 452 |
+
autoArrangeTimer = null;
|
| 453 |
+
if (!cy) return;
|
| 454 |
+
const visibleNodes = cy.nodes().filter((n) => !n.hasClass("fogged"));
|
| 455 |
+
if (visibleNodes.length === 0) return;
|
| 456 |
+
runLayout("fcose");
|
| 457 |
+
}, 450);
|
| 458 |
}
|
| 459 |
const result = applyCommands(cy, value.operations);
|
| 460 |
lastAppliedPatchId = value.patch_id;
|
| 461 |
+
updatePendingCount(); // refresh the review-queue badge after every patch
|
| 462 |
+
// C1+C3: post-turn dim-burst β glow what the agent just touched and
|
| 463 |
+
// briefly fade the rest, so "what changed" is unmistakable. Not on a
|
| 464 |
+
// load snapshot, where everything is "new" and a burst is meaningless.
|
| 465 |
if (!value.is_snapshot) {
|
| 466 |
+
const turnIds = collectTurnNodeIds(value.operations);
|
| 467 |
+
dimBurst(cy, turnIds);
|
| 468 |
+
// Track the set for Task 4 partial layout ("Settle new" button).
|
| 469 |
+
lastTurnNodeIds = new Set(turnIds);
|
| 470 |
}
|
| 471 |
// FE-01: per-op failures are non-fatal β the rest of the patch still
|
| 472 |
// applied. Surface a dismissible toast and notify the host.
|
|
|
|
| 519 |
{/if}
|
| 520 |
|
| 521 |
<div class="lc-canvas-wrapper">
|
| 522 |
+
<!-- Pure view operations; no backend call. Arrange = fcose auto-layout;
|
| 523 |
+
the select switches force/concentric/grid/tree; the zoom buttons zoom
|
| 524 |
+
around the viewport center; Fit fits all nodes. -->
|
| 525 |
+
<div class="lc-canvas-toolbar">
|
| 526 |
+
<button class="lc-fit-btn" type="button" aria-label="Auto-arrange the graph layout" onclick={() => runLayout("fcose")}>
|
| 527 |
+
β¦ Arrange
|
| 528 |
+
</button>
|
| 529 |
+
<button class="lc-fit-btn" type="button" aria-label="Settle newly-added nodes while pinning existing ones" onclick={() => settleNewNodes()}>
|
| 530 |
+
β Settle new
|
| 531 |
+
</button>
|
| 532 |
+
<select
|
| 533 |
+
class="lc-fit-btn lc-layout-select"
|
| 534 |
+
aria-label="Choose graph layout algorithm"
|
| 535 |
+
onchange={(e) => runLayout(e.currentTarget.value as LayoutName)}
|
| 536 |
+
>
|
| 537 |
+
<option value="fcose">Force</option>
|
| 538 |
+
<option value="concentric">Concentric</option>
|
| 539 |
+
<option value="grid">Grid</option>
|
| 540 |
+
<option value="breadthfirst">Tree</option>
|
| 541 |
+
</select>
|
| 542 |
+
<button class="lc-fit-btn lc-zoom-btn" type="button" aria-label="Zoom in" onclick={() => zoomBy(1.2)}>
|
| 543 |
+
οΌ
|
| 544 |
+
</button>
|
| 545 |
+
<button class="lc-fit-btn lc-zoom-btn" type="button" aria-label="Zoom out" onclick={() => zoomBy(0.8)}>
|
| 546 |
+
οΌ
|
| 547 |
+
</button>
|
| 548 |
+
<button class="lc-fit-btn" type="button" aria-label="Zoom to fit all nodes" onclick={() => fitGraph()}>
|
| 549 |
+
β‘ Fit
|
| 550 |
+
</button>
|
| 551 |
+
</div>
|
| 552 |
+
<!-- Review-queue badge: frames review as the act that makes knowledge real.
|
| 553 |
+
Counts down as proposals are accepted/rejected; press j/k to cycle them. -->
|
| 554 |
+
<div
|
| 555 |
+
class="lc-review-badge"
|
| 556 |
+
class:lc-review-clear={pendingCount === 0}
|
| 557 |
+
aria-live="polite"
|
| 558 |
+
title="Model proposals awaiting your review β press j/k to cycle them"
|
| 559 |
+
>
|
| 560 |
+
{#if pendingCount > 0}
|
| 561 |
+
⬑ {pendingCount} awaiting review
|
| 562 |
+
{:else}
|
| 563 |
+
β all reviewed
|
| 564 |
+
{/if}
|
| 565 |
+
</div>
|
| 566 |
<div bind:this={container} class="lc-cytoscape-canvas"></div>
|
| 567 |
</div>
|
| 568 |
|
|
|
|
| 587 |
.lc-canvas-wrapper {
|
| 588 |
position: relative;
|
| 589 |
}
|
| 590 |
+
.lc-canvas-toolbar {
|
| 591 |
position: absolute;
|
| 592 |
top: 8px;
|
| 593 |
right: 8px;
|
| 594 |
z-index: 10;
|
| 595 |
+
display: flex;
|
| 596 |
+
gap: 6px;
|
| 597 |
+
}
|
| 598 |
+
.lc-fit-btn {
|
| 599 |
padding: 4px 8px;
|
| 600 |
background: rgba(17, 19, 26, 0.85);
|
| 601 |
color: #cbd5e0;
|
|
|
|
| 607 |
.lc-fit-btn:hover {
|
| 608 |
background: #2d3748;
|
| 609 |
}
|
| 610 |
+
/* Dark pill <select> matching the buttons. Fixed sizing only (never vh). */
|
| 611 |
+
.lc-layout-select {
|
| 612 |
+
appearance: none;
|
| 613 |
+
-webkit-appearance: none;
|
| 614 |
+
height: 26px;
|
| 615 |
+
padding-right: 18px;
|
| 616 |
+
}
|
| 617 |
+
.lc-layout-select option {
|
| 618 |
+
background: #11131a;
|
| 619 |
+
color: #cbd5e0;
|
| 620 |
+
}
|
| 621 |
+
/* Square zoom pills with a slightly larger, centered glyph. */
|
| 622 |
+
.lc-zoom-btn {
|
| 623 |
+
width: 28px;
|
| 624 |
+
text-align: center;
|
| 625 |
+
font-size: 14px;
|
| 626 |
+
line-height: 1;
|
| 627 |
+
}
|
| 628 |
+
.lc-review-badge {
|
| 629 |
+
position: absolute;
|
| 630 |
+
top: 8px;
|
| 631 |
+
left: 8px;
|
| 632 |
+
z-index: 10;
|
| 633 |
+
padding: 4px 10px;
|
| 634 |
+
background: rgba(17, 19, 26, 0.85);
|
| 635 |
+
color: #f59e0b;
|
| 636 |
+
border: 1px solid #f59e0b;
|
| 637 |
+
border-radius: 999px;
|
| 638 |
+
font-size: 12px;
|
| 639 |
+
font-weight: 600;
|
| 640 |
+
pointer-events: none;
|
| 641 |
+
}
|
| 642 |
+
.lc-review-clear {
|
| 643 |
+
color: #34d399;
|
| 644 |
+
border-color: #34d399;
|
| 645 |
+
}
|
| 646 |
.lc-cytoscape-canvas {
|
| 647 |
width: 100%;
|
| 648 |
/* CRITICAL: fixed px height, NEVER vh. The app is embedded in an HF Spaces
|
cytoscapecanvas/frontend/renderer.test.ts
CHANGED
|
@@ -9,16 +9,20 @@
|
|
| 9 |
// which still throws and rejects the whole patch.
|
| 10 |
|
| 11 |
import { describe, it, expect, vi } from "vitest";
|
| 12 |
-
import type { Core, NodeSingular } from "cytoscape";
|
| 13 |
|
| 14 |
import {
|
| 15 |
applyCommands,
|
| 16 |
collectTurnNodeIds,
|
| 17 |
MAX_PATCH_OPS,
|
| 18 |
cytoscapeStyle,
|
|
|
|
|
|
|
| 19 |
wireCxtMenu,
|
|
|
|
| 20 |
wireSelectionEvents,
|
| 21 |
promptRename,
|
|
|
|
| 22 |
} from "./renderer";
|
| 23 |
import type { CxtMenuCommand, CxtMenuOptions, CxtMenuDispatch } from "./renderer";
|
| 24 |
import type { RendererCommand } from "./types";
|
|
@@ -324,6 +328,107 @@ describe("cytoscapeStyle β node shape encoding (node-shape-encoding)", () => {
|
|
| 324 |
});
|
| 325 |
});
|
| 326 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
describe("cytoscapeStyle β edge arrow mapping (edge-arrow-style-mapping)", () => {
|
| 328 |
it("maps each edge kind to a VALID Cytoscape arrow shape", () => {
|
| 329 |
for (const kind of ["implies", "is_a", "part_of", "contains", "related_to", "relates_to", "contradicts"]) {
|
|
@@ -361,6 +466,7 @@ interface OnCall {
|
|
| 361 |
interface CxtScratch {
|
| 362 |
suppressSelect: boolean;
|
| 363 |
pendingDeleteEdgeId: string | null;
|
|
|
|
| 364 |
}
|
| 365 |
|
| 366 |
interface CxtCyStub {
|
|
@@ -372,6 +478,8 @@ interface CxtCyStub {
|
|
| 372 |
getScratch: () => CxtScratch | undefined;
|
| 373 |
getFitCalls: () => number;
|
| 374 |
getDimCleared: () => number;
|
|
|
|
|
|
|
| 375 |
}
|
| 376 |
|
| 377 |
function makeCxtCy(): CxtCyStub {
|
|
@@ -389,9 +497,16 @@ function makeCxtCy(): CxtCyStub {
|
|
| 389 |
},
|
| 390 |
length: 0,
|
| 391 |
};
|
|
|
|
| 392 |
const elHandle = {
|
| 393 |
-
addClass: () =>
|
| 394 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
length: 1,
|
| 396 |
};
|
| 397 |
const cyObj = {
|
|
@@ -431,6 +546,7 @@ function makeCxtCy(): CxtCyStub {
|
|
| 431 |
getScratch: () => scratchStore,
|
| 432 |
getFitCalls: () => fitCalls,
|
| 433 |
getDimCleared: () => dimCleared,
|
|
|
|
| 434 |
};
|
| 435 |
}
|
| 436 |
|
|
@@ -584,6 +700,88 @@ describe("wireCxtMenu β Focus + Rename commands (C2)", () => {
|
|
| 584 |
});
|
| 585 |
});
|
| 586 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
describe("wireCxtMenu β background (core) menu (C5)", () => {
|
| 588 |
it("registers a second cxtmenu on the 'core' selector with Fit + Clusters", () => {
|
| 589 |
const { cy, getCoreOptions } = makeCxtCy();
|
|
@@ -596,6 +794,27 @@ describe("wireCxtMenu β background (core) menu (C5)", () => {
|
|
| 596 |
expect(contents.some((t) => t.includes("cluster"))).toBe(true);
|
| 597 |
});
|
| 598 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
it("Fit resizes, clears the Focus dim, and fits", () => {
|
| 600 |
const { cy, getCoreOptions, getFitCalls, getDimCleared } = makeCxtCy();
|
| 601 |
wireCxtMenu(cy, () => {});
|
|
@@ -636,9 +855,11 @@ describe("wireCxtMenu β edge menu, two-step delete confirm (C5b)", () => {
|
|
| 636 |
expect(edgeOpts, "no edge menu registered").not.toBeNull();
|
| 637 |
expect(edgeOpts!.selector).toBe("edge");
|
| 638 |
const cmds = edgeOpts!.commands(edgeStub("e1"));
|
| 639 |
-
|
| 640 |
-
expect(cmds
|
| 641 |
-
cmds
|
|
|
|
|
|
|
| 642 |
expect(getScratch()!.pendingDeleteEdgeId).toBe("e1"); // armed
|
| 643 |
expect(dispatched).toHaveLength(0); // but NOT deleted
|
| 644 |
});
|
|
@@ -648,7 +869,9 @@ describe("wireCxtMenu β edge menu, two-step delete confirm (C5b)", () => {
|
|
| 648 |
const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
|
| 649 |
wireCxtMenu(cy, (p) => dispatched.push(p));
|
| 650 |
const edgeOpts = getEdgeOptions()!;
|
| 651 |
-
|
|
|
|
|
|
|
| 652 |
const armed = edgeOpts.commands(edgeStub("e1")); // second right-click on the same edge
|
| 653 |
const contents = armed.map((c) => c.content.toLowerCase());
|
| 654 |
expect(contents.some((t) => t.includes("confirm"))).toBe(true);
|
|
@@ -665,7 +888,9 @@ describe("wireCxtMenu β edge menu, two-step delete confirm (C5b)", () => {
|
|
| 665 |
const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
|
| 666 |
wireCxtMenu(cy, (p) => dispatched.push(p));
|
| 667 |
const edgeOpts = getEdgeOptions()!;
|
| 668 |
-
|
|
|
|
|
|
|
| 669 |
const cancel = edgeOpts.commands(edgeStub("e1")).find((c) => c.content.toLowerCase().includes("cancel"))!;
|
| 670 |
cancel.select(edgeStub("e1"));
|
| 671 |
expect(getScratch()!.pendingDeleteEdgeId).toBeNull();
|
|
@@ -774,3 +999,181 @@ describe("collectTurnNodeIds", () => {
|
|
| 774 |
expect(collectTurnNodeIds(ops)).toEqual([]);
|
| 775 |
});
|
| 776 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
// which still throws and rejects the whole patch.
|
| 10 |
|
| 11 |
import { describe, it, expect, vi } from "vitest";
|
| 12 |
+
import type { Core, NodeSingular, EdgeSingular } from "cytoscape";
|
| 13 |
|
| 14 |
import {
|
| 15 |
applyCommands,
|
| 16 |
collectTurnNodeIds,
|
| 17 |
MAX_PATCH_OPS,
|
| 18 |
cytoscapeStyle,
|
| 19 |
+
startReviewPulse,
|
| 20 |
+
stopReviewPulse,
|
| 21 |
wireCxtMenu,
|
| 22 |
+
wireEdgeHover,
|
| 23 |
wireSelectionEvents,
|
| 24 |
promptRename,
|
| 25 |
+
promptEdgeLabel,
|
| 26 |
} from "./renderer";
|
| 27 |
import type { CxtMenuCommand, CxtMenuOptions, CxtMenuDispatch } from "./renderer";
|
| 28 |
import type { RendererCommand } from "./types";
|
|
|
|
| 328 |
});
|
| 329 |
});
|
| 330 |
|
| 331 |
+
describe("cytoscapeStyle β awaiting-review affordance (review-pending lynchpin)", () => {
|
| 332 |
+
// Regression: the awaiting-review signal was an 18% Cytoscape pie rendered
|
| 333 |
+
// dead-centre UNDER the centred node label β invisible. The fix is an amber
|
| 334 |
+
// dashed border (+ a pulse-peak step driven by startReviewPulse).
|
| 335 |
+
it("uses an amber dashed border, not a centred pie", () => {
|
| 336 |
+
const rule = ruleFor(".review-pending.origin-model-inferred");
|
| 337 |
+
expect(rule, "missing .review-pending.origin-model-inferred rule").toBeDefined();
|
| 338 |
+
expect(rule!.style["border-color"]).toBe("#f59e0b");
|
| 339 |
+
expect(rule!.style["border-style"]).toBe("dashed");
|
| 340 |
+
expect(Number(rule!.style["border-width"])).toBeGreaterThanOrEqual(4);
|
| 341 |
+
// The invisible pie must be gone.
|
| 342 |
+
expect(rule!.style["pie-size"]).toBeUndefined();
|
| 343 |
+
// The pulse is a class toggle smoothed by a border transition.
|
| 344 |
+
expect(String(rule!.style["transition-property"])).toContain("border");
|
| 345 |
+
});
|
| 346 |
+
|
| 347 |
+
it("defines a brighter, thicker pulse-peak step", () => {
|
| 348 |
+
const base = ruleFor(".review-pending.origin-model-inferred");
|
| 349 |
+
const peak = ruleFor(".review-pending.origin-model-inferred.pulse-peak");
|
| 350 |
+
expect(peak, "missing pulse-peak rule").toBeDefined();
|
| 351 |
+
expect(Number(peak!.style["border-width"])).toBeGreaterThan(Number(base!.style["border-width"]));
|
| 352 |
+
});
|
| 353 |
+
});
|
| 354 |
+
|
| 355 |
+
describe("startReviewPulse / stopReviewPulse (awaiting-review breathing)", () => {
|
| 356 |
+
function makePulseCy(pendingLength = 1) {
|
| 357 |
+
const classesOnPending = new Set<string>();
|
| 358 |
+
let len = pendingLength;
|
| 359 |
+
const scratchStore = new Map<string, unknown>();
|
| 360 |
+
const collection = {
|
| 361 |
+
get length() {
|
| 362 |
+
return len;
|
| 363 |
+
},
|
| 364 |
+
addClass: (c: string) => {
|
| 365 |
+
classesOnPending.add(c);
|
| 366 |
+
},
|
| 367 |
+
removeClass: (c: string) => {
|
| 368 |
+
classesOnPending.delete(c);
|
| 369 |
+
},
|
| 370 |
+
};
|
| 371 |
+
const cy = {
|
| 372 |
+
// Mirrors Cytoscape's overloaded scratch(ns) read / scratch(ns, val) write.
|
| 373 |
+
scratch: (ns: string, val?: unknown) => {
|
| 374 |
+
if (val !== undefined) {
|
| 375 |
+
scratchStore.set(ns, val);
|
| 376 |
+
return undefined;
|
| 377 |
+
}
|
| 378 |
+
return scratchStore.get(ns);
|
| 379 |
+
},
|
| 380 |
+
$: () => collection,
|
| 381 |
+
} as unknown as Core;
|
| 382 |
+
return { cy, classesOnPending, setPendingLength: (n: number) => (len = n) };
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
it("toggles .pulse-peak on pending nodes over time and is idempotent", () => {
|
| 386 |
+
vi.useFakeTimers();
|
| 387 |
+
try {
|
| 388 |
+
const s = makePulseCy(2);
|
| 389 |
+
startReviewPulse(s.cy, 800);
|
| 390 |
+
startReviewPulse(s.cy, 800); // 2nd call must NOT start a second timer
|
| 391 |
+
expect(s.classesOnPending.has("pulse-peak")).toBe(false);
|
| 392 |
+
vi.advanceTimersByTime(800);
|
| 393 |
+
expect(s.classesOnPending.has("pulse-peak")).toBe(true); // peak
|
| 394 |
+
vi.advanceTimersByTime(800);
|
| 395 |
+
expect(s.classesOnPending.has("pulse-peak")).toBe(false); // trough
|
| 396 |
+
stopReviewPulse(s.cy);
|
| 397 |
+
vi.advanceTimersByTime(2400);
|
| 398 |
+
expect(s.classesOnPending.has("pulse-peak")).toBe(false); // no toggles after stop
|
| 399 |
+
} finally {
|
| 400 |
+
vi.useRealTimers();
|
| 401 |
+
}
|
| 402 |
+
});
|
| 403 |
+
|
| 404 |
+
it("is a quiet no-op tick when nothing is awaiting review", () => {
|
| 405 |
+
vi.useFakeTimers();
|
| 406 |
+
try {
|
| 407 |
+
const s = makePulseCy(0);
|
| 408 |
+
startReviewPulse(s.cy, 800);
|
| 409 |
+
vi.advanceTimersByTime(1600);
|
| 410 |
+
expect(s.classesOnPending.has("pulse-peak")).toBe(false);
|
| 411 |
+
stopReviewPulse(s.cy);
|
| 412 |
+
} finally {
|
| 413 |
+
vi.useRealTimers();
|
| 414 |
+
}
|
| 415 |
+
});
|
| 416 |
+
});
|
| 417 |
+
|
| 418 |
+
describe("cytoscapeStyle β delight affordances (hover-fade + dim-burst)", () => {
|
| 419 |
+
it("defines hover-fade and burst-dim as strong fade opacities", () => {
|
| 420 |
+
expect(Number(ruleFor(".hover-faded")!.style.opacity)).toBeLessThan(0.2);
|
| 421 |
+
expect(Number(ruleFor(".burst-dim")!.style.opacity)).toBeLessThan(0.2);
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
it("burst-glow uses a visible overlay halo (the 'what just changed' glow)", () => {
|
| 425 |
+
const glow = ruleFor(".burst-glow");
|
| 426 |
+
expect(glow, "missing .burst-glow rule").toBeDefined();
|
| 427 |
+
expect(glow!.style["overlay-color"]).toBeTruthy();
|
| 428 |
+
expect(Number(glow!.style["overlay-opacity"])).toBeGreaterThan(0);
|
| 429 |
+
});
|
| 430 |
+
});
|
| 431 |
+
|
| 432 |
describe("cytoscapeStyle β edge arrow mapping (edge-arrow-style-mapping)", () => {
|
| 433 |
it("maps each edge kind to a VALID Cytoscape arrow shape", () => {
|
| 434 |
for (const kind of ["implies", "is_a", "part_of", "contains", "related_to", "relates_to", "contradicts"]) {
|
|
|
|
| 466 |
interface CxtScratch {
|
| 467 |
suppressSelect: boolean;
|
| 468 |
pendingDeleteEdgeId: string | null;
|
| 469 |
+
pendingDeleteNodeId: string | null;
|
| 470 |
}
|
| 471 |
|
| 472 |
interface CxtCyStub {
|
|
|
|
| 478 |
getScratch: () => CxtScratch | undefined;
|
| 479 |
getFitCalls: () => number;
|
| 480 |
getDimCleared: () => number;
|
| 481 |
+
/** Classes added/removed via getElementById(...).addClass/removeClass (pending-delete arming). */
|
| 482 |
+
getElClasses: () => Set<string>;
|
| 483 |
}
|
| 484 |
|
| 485 |
function makeCxtCy(): CxtCyStub {
|
|
|
|
| 497 |
},
|
| 498 |
length: 0,
|
| 499 |
};
|
| 500 |
+
const elClasses = new Set<string>();
|
| 501 |
const elHandle = {
|
| 502 |
+
addClass: (c: string) => {
|
| 503 |
+
elClasses.add(c);
|
| 504 |
+
return elHandle;
|
| 505 |
+
},
|
| 506 |
+
removeClass: (c: string) => {
|
| 507 |
+
elClasses.delete(c);
|
| 508 |
+
return elHandle;
|
| 509 |
+
},
|
| 510 |
length: 1,
|
| 511 |
};
|
| 512 |
const cyObj = {
|
|
|
|
| 546 |
getScratch: () => scratchStore,
|
| 547 |
getFitCalls: () => fitCalls,
|
| 548 |
getDimCleared: () => dimCleared,
|
| 549 |
+
getElClasses: () => elClasses,
|
| 550 |
};
|
| 551 |
}
|
| 552 |
|
|
|
|
| 700 |
});
|
| 701 |
});
|
| 702 |
|
| 703 |
+
describe("wireCxtMenu β node editing commands (Summary + Delete arm/confirm)", () => {
|
| 704 |
+
it("a normal (non-armed) node menu includes Summary and Delete commands", () => {
|
| 705 |
+
const { cy, getCxtOptions } = makeCxtCy();
|
| 706 |
+
wireCxtMenu(cy, () => {});
|
| 707 |
+
const cmds = getCxtOptions()!.commands(nodeStub({ id: "n1" }));
|
| 708 |
+
const contents = cmds.map((c) => c.content.toLowerCase());
|
| 709 |
+
expect(
|
| 710 |
+
contents.some((t) => /summary/i.test(t)),
|
| 711 |
+
"no Summary command"
|
| 712 |
+
).toBe(true);
|
| 713 |
+
expect(
|
| 714 |
+
contents.some((t) => /delete/i.test(t)),
|
| 715 |
+
"no Delete command"
|
| 716 |
+
).toBe(true);
|
| 717 |
+
});
|
| 718 |
+
|
| 719 |
+
it("selecting Delete ARMS the node (sets pendingDeleteNodeId + adds pending-delete class), no dispatch", () => {
|
| 720 |
+
const { cy, getCxtOptions, getScratch, getElClasses } = makeCxtCy();
|
| 721 |
+
const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
|
| 722 |
+
wireCxtMenu(cy, (p) => dispatched.push(p));
|
| 723 |
+
const del = getCxtOptions()!
|
| 724 |
+
.commands(nodeStub({ id: "n3" }))
|
| 725 |
+
.find((c) => /delete/i.test(c.content))!;
|
| 726 |
+
del.select(nodeStub({ id: "n3" }));
|
| 727 |
+
expect(getScratch()!.pendingDeleteNodeId).toBe("n3");
|
| 728 |
+
expect(getElClasses().has("pending-delete")).toBe(true);
|
| 729 |
+
expect(dispatched).toHaveLength(0); // armed only β not deleted
|
| 730 |
+
});
|
| 731 |
+
|
| 732 |
+
it("when armed (pendingDeleteNodeId === id) returns exactly Confirm + Cancel; Confirm dispatches delete_node", () => {
|
| 733 |
+
const { cy, getCxtOptions, getScratch } = makeCxtCy();
|
| 734 |
+
const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
|
| 735 |
+
wireCxtMenu(cy, (p) => dispatched.push(p));
|
| 736 |
+
const opts = getCxtOptions()!;
|
| 737 |
+
opts
|
| 738 |
+
.commands(nodeStub({ id: "n3" }))
|
| 739 |
+
.find((c) => /delete/i.test(c.content))!
|
| 740 |
+
.select(nodeStub({ id: "n3" })); // arm
|
| 741 |
+
const armed = opts.commands(nodeStub({ id: "n3" })); // second right-click on the same node
|
| 742 |
+
expect(armed).toHaveLength(2);
|
| 743 |
+
const contents = armed.map((c) => c.content.toLowerCase());
|
| 744 |
+
expect(contents.some((t) => t.includes("confirm"))).toBe(true);
|
| 745 |
+
expect(contents.some((t) => t.includes("cancel"))).toBe(true);
|
| 746 |
+
const confirm = armed.find((c) => c.content.toLowerCase().includes("confirm"))!;
|
| 747 |
+
confirm.select(nodeStub({ id: "n3" }));
|
| 748 |
+
expect(dispatched[0].value).toMatchObject({ kind: "delete_node", id: "n3" });
|
| 749 |
+
expect(getScratch()!.pendingDeleteNodeId).toBeNull(); // cleared after confirm
|
| 750 |
+
});
|
| 751 |
+
|
| 752 |
+
it("when armed, Cancel clears the armed state without dispatching", () => {
|
| 753 |
+
const { cy, getCxtOptions, getScratch } = makeCxtCy();
|
| 754 |
+
const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
|
| 755 |
+
wireCxtMenu(cy, (p) => dispatched.push(p));
|
| 756 |
+
const opts = getCxtOptions()!;
|
| 757 |
+
opts
|
| 758 |
+
.commands(nodeStub({ id: "n3" }))
|
| 759 |
+
.find((c) => /delete/i.test(c.content))!
|
| 760 |
+
.select(nodeStub({ id: "n3" })); // arm
|
| 761 |
+
const cancel = opts.commands(nodeStub({ id: "n3" })).find((c) => c.content.toLowerCase().includes("cancel"))!;
|
| 762 |
+
cancel.select(nodeStub({ id: "n3" }));
|
| 763 |
+
expect(getScratch()!.pendingDeleteNodeId).toBeNull();
|
| 764 |
+
expect(dispatched).toHaveLength(0);
|
| 765 |
+
});
|
| 766 |
+
|
| 767 |
+
it("the Summary command opens an inline input and dispatches kind:'edit_summary' on Enter", () => {
|
| 768 |
+
const { cy, getCxtOptions } = makeCxtCy();
|
| 769 |
+
const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
|
| 770 |
+
wireCxtMenu(cy, (p) => dispatched.push(p));
|
| 771 |
+
const summary = getCxtOptions()!
|
| 772 |
+
.commands(nodeStub({ id: "n5" }))
|
| 773 |
+
.find((c) => /summary/i.test(c.content))!;
|
| 774 |
+
summary.select(nodeStub({ id: "n5" }));
|
| 775 |
+
const input = document.querySelector("input");
|
| 776 |
+
expect(input, "Summary did not open an inline input").not.toBeNull();
|
| 777 |
+
input!.value = "A new summary";
|
| 778 |
+
input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
|
| 779 |
+
const evt = dispatched.find((d) => d.value.kind === "edit_summary");
|
| 780 |
+
expect(evt, "no edit_summary dispatch").toBeDefined();
|
| 781 |
+
expect(evt!.value).toMatchObject({ kind: "edit_summary", id: "n5", text: "A new summary" });
|
| 782 |
+
});
|
| 783 |
+
});
|
| 784 |
+
|
| 785 |
describe("wireCxtMenu β background (core) menu (C5)", () => {
|
| 786 |
it("registers a second cxtmenu on the 'core' selector with Fit + Clusters", () => {
|
| 787 |
const { cy, getCoreOptions } = makeCxtCy();
|
|
|
|
| 794 |
expect(contents.some((t) => t.includes("cluster"))).toBe(true);
|
| 795 |
});
|
| 796 |
|
| 797 |
+
it("includes an 'Add node' command that opens a centered input and dispatches kind:'add_node'", () => {
|
| 798 |
+
const { cy, getCoreOptions } = makeCxtCy();
|
| 799 |
+
const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
|
| 800 |
+
wireCxtMenu(cy, (p) => dispatched.push(p));
|
| 801 |
+
const core = getCoreOptions()!;
|
| 802 |
+
const contents = core.commands(nodeStub()).map((c) => c.content.toLowerCase());
|
| 803 |
+
expect(
|
| 804 |
+
contents.some((t) => /add node/i.test(t)),
|
| 805 |
+
"no Add node command"
|
| 806 |
+
).toBe(true);
|
| 807 |
+
const addNode = core.commands(nodeStub()).find((c) => /add node/i.test(c.content))!;
|
| 808 |
+
addNode.select(nodeStub());
|
| 809 |
+
const input = document.querySelector("input");
|
| 810 |
+
expect(input, "Add node did not open an inline input").not.toBeNull();
|
| 811 |
+
input!.value = "Fresh Node";
|
| 812 |
+
input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
|
| 813 |
+
const evt = dispatched.find((d) => d.value.kind === "add_node");
|
| 814 |
+
expect(evt, "no add_node dispatch").toBeDefined();
|
| 815 |
+
expect(evt!.value).toMatchObject({ kind: "add_node", label: "Fresh Node" });
|
| 816 |
+
});
|
| 817 |
+
|
| 818 |
it("Fit resizes, clears the Focus dim, and fits", () => {
|
| 819 |
const { cy, getCoreOptions, getFitCalls, getDimCleared } = makeCxtCy();
|
| 820 |
wireCxtMenu(cy, () => {});
|
|
|
|
| 855 |
expect(edgeOpts, "no edge menu registered").not.toBeNull();
|
| 856 |
expect(edgeOpts!.selector).toBe("edge");
|
| 857 |
const cmds = edgeOpts!.commands(edgeStub("e1"));
|
| 858 |
+
// expanded to Accept + Reject + Edit label + Delete (4 commands now)
|
| 859 |
+
expect(cmds.length).toBeGreaterThanOrEqual(4);
|
| 860 |
+
const delCmd = cmds.find((c) => /delete/i.test(c.content));
|
| 861 |
+
expect(delCmd, "no Delete command in edge menu").toBeDefined();
|
| 862 |
+
delCmd!.select(edgeStub("e1"));
|
| 863 |
expect(getScratch()!.pendingDeleteEdgeId).toBe("e1"); // armed
|
| 864 |
expect(dispatched).toHaveLength(0); // but NOT deleted
|
| 865 |
});
|
|
|
|
| 869 |
const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
|
| 870 |
wireCxtMenu(cy, (p) => dispatched.push(p));
|
| 871 |
const edgeOpts = getEdgeOptions()!;
|
| 872 |
+
// Arm via the Delete command (find it by content, not by index)
|
| 873 |
+
const armCmd = edgeOpts.commands(edgeStub("e1")).find((c) => /delete/i.test(c.content))!;
|
| 874 |
+
armCmd.select(edgeStub("e1")); // arm
|
| 875 |
const armed = edgeOpts.commands(edgeStub("e1")); // second right-click on the same edge
|
| 876 |
const contents = armed.map((c) => c.content.toLowerCase());
|
| 877 |
expect(contents.some((t) => t.includes("confirm"))).toBe(true);
|
|
|
|
| 888 |
const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
|
| 889 |
wireCxtMenu(cy, (p) => dispatched.push(p));
|
| 890 |
const edgeOpts = getEdgeOptions()!;
|
| 891 |
+
// Arm via the Delete command (find it by content, not by index)
|
| 892 |
+
const armCmd = edgeOpts.commands(edgeStub("e1")).find((c) => /delete/i.test(c.content))!;
|
| 893 |
+
armCmd.select(edgeStub("e1")); // arm
|
| 894 |
const cancel = edgeOpts.commands(edgeStub("e1")).find((c) => c.content.toLowerCase().includes("cancel"))!;
|
| 895 |
cancel.select(edgeStub("e1"));
|
| 896 |
expect(getScratch()!.pendingDeleteEdgeId).toBeNull();
|
|
|
|
| 999 |
expect(collectTurnNodeIds(ops)).toEqual([]);
|
| 1000 |
});
|
| 1001 |
});
|
| 1002 |
+
|
| 1003 |
+
// ββ node.pending-delete style (armed-node "right-click again to confirm") βββββ
|
| 1004 |
+
|
| 1005 |
+
describe("cytoscapeStyle β node.pending-delete (armed delete affordance)", () => {
|
| 1006 |
+
it("mirrors the edge armed-delete style: red dashed border", () => {
|
| 1007 |
+
const rule = ruleFor("node.pending-delete");
|
| 1008 |
+
expect(rule, "missing node.pending-delete rule").toBeDefined();
|
| 1009 |
+
// Red (LC_EDGE_CONTRADICTS) so it reads as the same "armed for deletion" language.
|
| 1010 |
+
expect(rule!.style["border-color"]).toBe("#ef4444");
|
| 1011 |
+
expect(rule!.style["border-style"]).toBe("dashed");
|
| 1012 |
+
expect(Number(rule!.style["border-width"])).toBeGreaterThanOrEqual(3);
|
| 1013 |
+
});
|
| 1014 |
+
});
|
| 1015 |
+
|
| 1016 |
+
// ββ wireEdgeHover (edge hover-emphasis) βββββββββββββββββββββββββββββββββββββββ
|
| 1017 |
+
|
| 1018 |
+
describe("wireEdgeHover (edge hover emphasis)", () => {
|
| 1019 |
+
it("binds mouseover/mouseout on edges that toggle the edge-hover class", () => {
|
| 1020 |
+
const onCalls: OnCall[] = [];
|
| 1021 |
+
const added: string[] = [];
|
| 1022 |
+
const removed: string[] = [];
|
| 1023 |
+
const edgeTarget = {
|
| 1024 |
+
addClass: (c: string) => added.push(c),
|
| 1025 |
+
removeClass: (c: string) => removed.push(c),
|
| 1026 |
+
};
|
| 1027 |
+
const cy = {
|
| 1028 |
+
on(events: string, a?: unknown, b?: unknown) {
|
| 1029 |
+
if (typeof a === "function") onCalls.push({ events, handler: a as (...args: unknown[]) => void });
|
| 1030 |
+
else onCalls.push({ events, selector: a as string, handler: b as (...args: unknown[]) => void });
|
| 1031 |
+
},
|
| 1032 |
+
} as unknown as Core;
|
| 1033 |
+
wireEdgeHover(cy);
|
| 1034 |
+
const over = onCalls.find((c) => c.events.includes("mouseover") && c.selector === "edge");
|
| 1035 |
+
const out = onCalls.find((c) => c.events.includes("mouseout") && c.selector === "edge");
|
| 1036 |
+
expect(over, "no mouseover edge handler").toBeDefined();
|
| 1037 |
+
expect(out, "no mouseout edge handler").toBeDefined();
|
| 1038 |
+
over!.handler({ target: edgeTarget });
|
| 1039 |
+
expect(added).toContain("edge-hover");
|
| 1040 |
+
out!.handler({ target: edgeTarget });
|
| 1041 |
+
expect(removed).toContain("edge-hover");
|
| 1042 |
+
});
|
| 1043 |
+
|
| 1044 |
+
it("defines a brighter/thicker .edge-hover style rule", () => {
|
| 1045 |
+
// Accept either ".edge-hover" or "edge.edge-hover" selector form.
|
| 1046 |
+
const rule = ruleFor("edge.edge-hover") ?? ruleFor(".edge-hover");
|
| 1047 |
+
expect(rule, "missing edge-hover style rule").toBeDefined();
|
| 1048 |
+
expect(Number(rule!.style.width)).toBeGreaterThanOrEqual(4);
|
| 1049 |
+
expect(rule!.style["line-color"], "edge-hover should brighten the line").toBeTruthy();
|
| 1050 |
+
});
|
| 1051 |
+
});
|
| 1052 |
+
|
| 1053 |
+
// ββ Task 5: edge-menu expanded commands + promptEdgeLabel (Task 2/3/5) βββββββββ
|
| 1054 |
+
//
|
| 1055 |
+
// These tests cover the NEW edge-menu Accept/Reject/Edit-label commands and the
|
| 1056 |
+
// promptEdgeLabel helper. They share the makeCxtCy + edgeStub infrastructure above.
|
| 1057 |
+
|
| 1058 |
+
describe("wireCxtMenu β edge menu expanded: Accept / Reject / Edit-label (Task 2/5)", () => {
|
| 1059 |
+
function edgeStubFull(id = "e1", fogged = false): NodeSingular {
|
| 1060 |
+
return {
|
| 1061 |
+
id: () => id,
|
| 1062 |
+
isNode: () => false,
|
| 1063 |
+
hasClass: (c: string) => (c === "fogged" ? fogged : false),
|
| 1064 |
+
data: (k: string) => (k === "label" ? "existing label" : undefined),
|
| 1065 |
+
renderedMidpoint: () => ({ x: 200, y: 150 }),
|
| 1066 |
+
} as unknown as NodeSingular;
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
it("(1) Accept dispatches {kind:'review_edge', id, action:'accepted'}", () => {
|
| 1070 |
+
const { cy, getEdgeOptions } = makeCxtCy();
|
| 1071 |
+
const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
|
| 1072 |
+
wireCxtMenu(cy, (p) => dispatched.push(p));
|
| 1073 |
+
const cmds = getEdgeOptions()!.commands(edgeStubFull("e2"));
|
| 1074 |
+
const accept = cmds.find((c) => /accept/i.test(c.content));
|
| 1075 |
+
expect(accept, "no Accept command on edge menu").toBeDefined();
|
| 1076 |
+
accept!.select(edgeStubFull("e2"));
|
| 1077 |
+
expect(dispatched).toHaveLength(1);
|
| 1078 |
+
expect(dispatched[0].value).toMatchObject({ kind: "review_edge", id: "e2", action: "accepted" });
|
| 1079 |
+
});
|
| 1080 |
+
|
| 1081 |
+
it("(2) Reject dispatches {kind:'review_edge', id, action:'rejected'}", () => {
|
| 1082 |
+
const { cy, getEdgeOptions } = makeCxtCy();
|
| 1083 |
+
const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
|
| 1084 |
+
wireCxtMenu(cy, (p) => dispatched.push(p));
|
| 1085 |
+
const cmds = getEdgeOptions()!.commands(edgeStubFull("e3"));
|
| 1086 |
+
const reject = cmds.find((c) => /reject/i.test(c.content));
|
| 1087 |
+
expect(reject, "no Reject command on edge menu").toBeDefined();
|
| 1088 |
+
reject!.select(edgeStubFull("e3"));
|
| 1089 |
+
expect(dispatched[0].value).toMatchObject({ kind: "review_edge", id: "e3", action: "rejected" });
|
| 1090 |
+
});
|
| 1091 |
+
|
| 1092 |
+
it("(3) a fogged edge yields no actionable menu (empty [])", () => {
|
| 1093 |
+
const { cy, getEdgeOptions } = makeCxtCy();
|
| 1094 |
+
wireCxtMenu(cy, () => {});
|
| 1095 |
+
const cmds = getEdgeOptions()!.commands(edgeStubFull("e4", true));
|
| 1096 |
+
expect(cmds).toEqual([]);
|
| 1097 |
+
});
|
| 1098 |
+
|
| 1099 |
+
it("(4) Edit-label opens an inline input and dispatches {kind:'relabel_edge', id, label} on Enter", () => {
|
| 1100 |
+
const { cy, getEdgeOptions } = makeCxtCy();
|
| 1101 |
+
const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
|
| 1102 |
+
wireCxtMenu(cy, (p) => dispatched.push(p));
|
| 1103 |
+
const cmds = getEdgeOptions()!.commands(edgeStubFull("e5"));
|
| 1104 |
+
const editLabel = cmds.find(
|
| 1105 |
+
(c) => /edit.?label/i.test(c.content) || /relabel/i.test(c.content) || /label/i.test(c.content)
|
| 1106 |
+
);
|
| 1107 |
+
expect(editLabel, "no Edit-label command on edge menu").toBeDefined();
|
| 1108 |
+
editLabel!.select(edgeStubFull("e5"));
|
| 1109 |
+
const input = document.querySelector("input");
|
| 1110 |
+
expect(input, "Edit-label did not open an inline input").not.toBeNull();
|
| 1111 |
+
input!.value = "New Edge Label";
|
| 1112 |
+
input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
|
| 1113 |
+
const evt = dispatched.find((d) => d.value.kind === "relabel_edge");
|
| 1114 |
+
expect(evt, "no relabel_edge dispatch").toBeDefined();
|
| 1115 |
+
expect(evt!.value).toMatchObject({ kind: "relabel_edge", id: "e5", label: "New Edge Label" });
|
| 1116 |
+
});
|
| 1117 |
+
|
| 1118 |
+
it("(5) a non-fogged edge menu exposes >= 4 commands (Accept, Reject, Edit label, Delete)", () => {
|
| 1119 |
+
const { cy, getEdgeOptions } = makeCxtCy();
|
| 1120 |
+
wireCxtMenu(cy, () => {});
|
| 1121 |
+
const cmds = getEdgeOptions()!.commands(edgeStubFull("e6"));
|
| 1122 |
+
expect(cmds.length).toBeGreaterThanOrEqual(4);
|
| 1123 |
+
const contents = cmds.map((c) => c.content.toLowerCase());
|
| 1124 |
+
expect(
|
| 1125 |
+
contents.some((t) => /accept/i.test(t)),
|
| 1126 |
+
"missing Accept"
|
| 1127 |
+
).toBe(true);
|
| 1128 |
+
expect(
|
| 1129 |
+
contents.some((t) => /reject/i.test(t)),
|
| 1130 |
+
"missing Reject"
|
| 1131 |
+
).toBe(true);
|
| 1132 |
+
expect(
|
| 1133 |
+
contents.some((t) => /delete/i.test(t)),
|
| 1134 |
+
"missing Delete"
|
| 1135 |
+
).toBe(true);
|
| 1136 |
+
});
|
| 1137 |
+
});
|
| 1138 |
+
|
| 1139 |
+
describe("promptEdgeLabel (inline label input for edges, Task 3)", () => {
|
| 1140 |
+
function edgeStubForPrompt(id = "e1", label = "current label"): EdgeSingular {
|
| 1141 |
+
return {
|
| 1142 |
+
id: () => id,
|
| 1143 |
+
data: (k: string) => (k === "label" ? label : undefined),
|
| 1144 |
+
renderedMidpoint: () => ({ x: 200, y: 150 }),
|
| 1145 |
+
} as unknown as EdgeSingular;
|
| 1146 |
+
}
|
| 1147 |
+
|
| 1148 |
+
it("opens an inline input pre-filled with the edge's current label and commits on Enter", () => {
|
| 1149 |
+
const container = document.createElement("div");
|
| 1150 |
+
document.body.appendChild(container);
|
| 1151 |
+
const cy = { container: () => container } as unknown as Core;
|
| 1152 |
+
let committed: string | null = null;
|
| 1153 |
+
promptEdgeLabel(cy, edgeStubForPrompt("e1", "old label"), (label) => {
|
| 1154 |
+
committed = label;
|
| 1155 |
+
});
|
| 1156 |
+
const input = document.querySelector("input");
|
| 1157 |
+
expect(input, "no input opened").not.toBeNull();
|
| 1158 |
+
expect(input!.value).toBe("old label");
|
| 1159 |
+
input!.value = "new label";
|
| 1160 |
+
input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
|
| 1161 |
+
expect(committed).toBe("new label");
|
| 1162 |
+
expect(document.querySelector("input"), "input not removed after commit").toBeNull();
|
| 1163 |
+
});
|
| 1164 |
+
|
| 1165 |
+
it("cancels on Escape without committing", () => {
|
| 1166 |
+
const container = document.createElement("div");
|
| 1167 |
+
document.body.appendChild(container);
|
| 1168 |
+
const cy = { container: () => container } as unknown as Core;
|
| 1169 |
+
let committed: string | null = null;
|
| 1170 |
+
promptEdgeLabel(cy, edgeStubForPrompt("e1", "old"), (label) => {
|
| 1171 |
+
committed = label;
|
| 1172 |
+
});
|
| 1173 |
+
const input = document.querySelector("input")!;
|
| 1174 |
+
input.value = "whatever";
|
| 1175 |
+
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
| 1176 |
+
expect(committed).toBeNull();
|
| 1177 |
+
expect(document.querySelector("input")).toBeNull();
|
| 1178 |
+
});
|
| 1179 |
+
});
|
cytoscapecanvas/frontend/renderer.ts
CHANGED
|
@@ -25,6 +25,8 @@ const LC_HIGHLIGHTED = "#ff9800"; // amber orange
|
|
| 25 |
const LC_INFERRED_EDGE = "#9f7aea"; // purple (legible on dark, less garish than #a0a)
|
| 26 |
const LC_EDGE_CONTRADICTS = "#ef4444"; // red β "contradicts" relations (high-signal trust edge)
|
| 27 |
const LC_EDGE_SUPPORTS = "#34d399"; // green β "supports" relations (matches user-asserted accent)
|
|
|
|
|
|
|
| 28 |
|
| 29 |
// Fixed cluster-colour hue pool (cluster-louvain-colors). One hue per `cluster-N`
|
| 30 |
// class emitted by the backend (renderer_adapter cluster deltas). Deliberately
|
|
@@ -66,6 +68,12 @@ interface RendererScratch {
|
|
| 66 |
connectSource: string | null;
|
| 67 |
/** Edge id armed for deletion (radial menu first step); null = none pending. */
|
| 68 |
pendingDeleteEdgeId: string | null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
const SCRATCH_NS = "_lc";
|
|
@@ -81,6 +89,9 @@ function getScratch(cy: Core): RendererScratch {
|
|
| 81 |
connectMode: false,
|
| 82 |
connectSource: null,
|
| 83 |
pendingDeleteEdgeId: null,
|
|
|
|
|
|
|
|
|
|
| 84 |
};
|
| 85 |
cy.scratch(SCRATCH_NS, state);
|
| 86 |
}
|
|
@@ -248,6 +259,30 @@ export const cytoscapeStyle: StylesheetJson = [
|
|
| 248 |
opacity: 0.4,
|
| 249 |
},
|
| 250 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
// T3-03: keyboard focus ring β distinct outset border, composes with .selected/.highlighted.
|
| 252 |
{
|
| 253 |
selector: ".focus-ring",
|
|
@@ -276,6 +311,29 @@ export const cytoscapeStyle: StylesheetJson = [
|
|
| 276 |
"line-style": "dashed",
|
| 277 |
},
|
| 278 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
// ββ Trust origin β shape + border style (node-shape-encoding) βββββββββββββ
|
| 280 |
// Shape reinforces the border-style origin signal (redundant encoding aids
|
| 281 |
// colour-blind / low-contrast reading). Shapes are convex with a large interior
|
|
@@ -304,7 +362,10 @@ export const cytoscapeStyle: StylesheetJson = [
|
|
| 304 |
shape: "hexagon",
|
| 305 |
"border-style": "dashed",
|
| 306 |
"border-color": LC_NODE_BORDER,
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
| 308 |
},
|
| 309 |
},
|
| 310 |
// ββ Cluster colours (cluster-louvain-colors) β placed BEFORE trust support so
|
|
@@ -318,13 +379,54 @@ export const cytoscapeStyle: StylesheetJson = [
|
|
| 318 |
"background-color": "#7c3aed",
|
| 319 |
},
|
| 320 |
},
|
| 321 |
-
// ββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
{
|
| 323 |
selector: ".review-pending.origin-model-inferred",
|
| 324 |
style: {
|
| 325 |
-
"
|
| 326 |
-
"
|
| 327 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
},
|
| 329 |
},
|
| 330 |
];
|
|
@@ -423,6 +525,44 @@ export function spotlightTurnNodes(cy: Core, commands: RendererCommand[], durati
|
|
| 423 |
}
|
| 424 |
}
|
| 425 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
/**
|
| 427 |
* Full reset: remove all elements, tear down callout overlays, and clear
|
| 428 |
* transient state. Called only on `is_snapshot: true` (load / reconnect).
|
|
@@ -631,6 +771,63 @@ function dimExcept(cy: Core, keepIds: string[]): void {
|
|
| 631 |
});
|
| 632 |
}
|
| 633 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
function selectElement(cy: Core, id: string | null): void {
|
| 635 |
const state = getScratch(cy);
|
| 636 |
// Suppress re-dispatch: this selection is backend-driven, not user-driven.
|
|
@@ -790,25 +987,37 @@ export function wireTrustTooltip(cy: Core): void {
|
|
| 790 |
|
| 791 |
// ββ Rename gesture + connect mode ββββββββββββββββββββββββββββββββββββββββββββ
|
| 792 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
/**
|
| 794 |
-
*
|
| 795 |
-
* the trimmed value via `onCommit`
|
| 796 |
-
*
|
| 797 |
-
*
|
| 798 |
*
|
| 799 |
-
* XSS guard: `input.value` is
|
|
|
|
| 800 |
*/
|
| 801 |
-
|
| 802 |
-
const id = node.id();
|
| 803 |
-
const currentLabel = (node.data("label") as string | undefined) ?? id;
|
| 804 |
-
|
| 805 |
const container = cy.container();
|
| 806 |
-
if (!container) return;
|
| 807 |
const parent = container.parentElement ?? container;
|
| 808 |
|
| 809 |
const input = document.createElement("input");
|
| 810 |
input.type = "text";
|
| 811 |
-
input.value =
|
|
|
|
| 812 |
input.style.position = "absolute";
|
| 813 |
input.style.zIndex = "2000";
|
| 814 |
input.style.background = "#1a1a2e";
|
|
@@ -823,9 +1032,8 @@ export function promptRename(cy: Core, node: NodeSingular, onCommit: (label: str
|
|
| 823 |
(parent as HTMLElement).style.position = "relative";
|
| 824 |
}
|
| 825 |
|
| 826 |
-
|
| 827 |
-
input.style.
|
| 828 |
-
input.style.top = `${pos.y - 12}px`;
|
| 829 |
parent.appendChild(input);
|
| 830 |
input.focus();
|
| 831 |
input.select();
|
|
@@ -835,11 +1043,9 @@ export function promptRename(cy: Core, node: NodeSingular, onCommit: (label: str
|
|
| 835 |
function commit(): void {
|
| 836 |
if (committed) return;
|
| 837 |
committed = true;
|
| 838 |
-
const
|
| 839 |
input.remove();
|
| 840 |
-
|
| 841 |
-
onCommit(newLabel);
|
| 842 |
-
}
|
| 843 |
}
|
| 844 |
|
| 845 |
input.addEventListener("keydown", (e) => {
|
|
@@ -852,6 +1058,94 @@ export function promptRename(cy: Core, node: NodeSingular, onCommit: (label: str
|
|
| 852 |
}
|
| 853 |
});
|
| 854 |
input.addEventListener("blur", commit);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
}
|
| 856 |
|
| 857 |
/**
|
|
@@ -968,7 +1262,9 @@ interface CxtMenuCore extends Core {
|
|
| 968 |
*/
|
| 969 |
export type CxtMenuDispatch = (payload: {
|
| 970 |
index: null;
|
| 971 |
-
|
|
|
|
|
|
|
| 972 |
selected: boolean;
|
| 973 |
}) => void;
|
| 974 |
|
|
@@ -991,6 +1287,27 @@ export function wireCxtMenu(cy: Core, dispatch: CxtMenuDispatch): void {
|
|
| 991 |
// openMenu() early-return.
|
| 992 |
if (ele.hasClass("fogged")) return [];
|
| 993 |
const id = ele.id();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 994 |
const reviewCommand = (action: "accepted" | "rejected"): CxtMenuCommand => ({
|
| 995 |
content: action === "accepted" ? "β Accept" : "β Reject",
|
| 996 |
select: () => dispatch({ index: null, value: { kind: "review", id, action }, selected: false }),
|
|
@@ -1014,12 +1331,41 @@ export function wireCxtMenu(cy: Core, dispatch: CxtMenuDispatch): void {
|
|
| 1014 |
dispatch({ index: null, value: { kind: "rename", id, label }, selected: false })
|
| 1015 |
),
|
| 1016 |
};
|
| 1017 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1018 |
};
|
| 1019 |
|
| 1020 |
// Edge menu with an in-canvas, two-step delete confirm (deletion is irreversible
|
| 1021 |
// except via undo). First right-click arms the edge (red dashed); the follow-up
|
| 1022 |
// menu on the same edge offers Confirm / Cancel.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1023 |
const buildEdgeCommands = (ele: CxtMenuEle): CxtMenuCommand[] => {
|
| 1024 |
if (ele.hasClass("fogged")) return [];
|
| 1025 |
const id = ele.id();
|
|
@@ -1041,22 +1387,35 @@ export function wireCxtMenu(cy: Core, dispatch: CxtMenuDispatch): void {
|
|
| 1041 |
{ content: "Cancel", select: () => clearArmed() },
|
| 1042 |
];
|
| 1043 |
}
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1052 |
},
|
| 1053 |
-
|
|
|
|
| 1054 |
};
|
| 1055 |
|
| 1056 |
(cy as CxtMenuCore).cxtmenu({
|
| 1057 |
selector: "node",
|
| 1058 |
commands: buildNodeCommands,
|
| 1059 |
-
|
|
|
|
|
|
|
| 1060 |
fillColor: "rgba(17, 19, 26, 0.92)", // matches LC_LABEL_BG pill tone
|
| 1061 |
activeFillColor: "rgba(59, 91, 219, 0.85)", // LC_NODE_BG indigo
|
| 1062 |
itemColor: LC_NODE_TEXT,
|
|
@@ -1095,6 +1454,12 @@ export function wireCxtMenu(cy: Core, dispatch: CxtMenuDispatch): void {
|
|
| 1095 |
content: "π¨ Clusters",
|
| 1096 |
select: () => dispatch({ index: null, value: { kind: "cluster", id: "" }, selected: false }),
|
| 1097 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1098 |
],
|
| 1099 |
menuRadius: () => 70,
|
| 1100 |
fillColor: "rgba(17, 19, 26, 0.92)",
|
|
|
|
| 25 |
const LC_INFERRED_EDGE = "#9f7aea"; // purple (legible on dark, less garish than #a0a)
|
| 26 |
const LC_EDGE_CONTRADICTS = "#ef4444"; // red β "contradicts" relations (high-signal trust edge)
|
| 27 |
const LC_EDGE_SUPPORTS = "#34d399"; // green β "supports" relations (matches user-asserted accent)
|
| 28 |
+
const LC_REVIEW_AMBER = "#f59e0b"; // awaiting-review border (matches the legend swatch in main.py)
|
| 29 |
+
const LC_REVIEW_AMBER_BRIGHT = "#fbbf24"; // pulse peak β brighter amber so the border "breathes"
|
| 30 |
|
| 31 |
// Fixed cluster-colour hue pool (cluster-louvain-colors). One hue per `cluster-N`
|
| 32 |
// class emitted by the backend (renderer_adapter cluster deltas). Deliberately
|
|
|
|
| 68 |
connectSource: string | null;
|
| 69 |
/** Edge id armed for deletion (radial menu first step); null = none pending. */
|
| 70 |
pendingDeleteEdgeId: string | null;
|
| 71 |
+
/** Node id armed for deletion (radial menu first step); null = none pending. */
|
| 72 |
+
pendingDeleteNodeId: string | null;
|
| 73 |
+
/** Interval id driving the awaiting-review border pulse; null when stopped. */
|
| 74 |
+
reviewPulseTimer: ReturnType<typeof setInterval> | null;
|
| 75 |
+
/** Current phase of the review pulse (true = peak/bright, false = trough). */
|
| 76 |
+
reviewPulsePeak: boolean;
|
| 77 |
}
|
| 78 |
|
| 79 |
const SCRATCH_NS = "_lc";
|
|
|
|
| 89 |
connectMode: false,
|
| 90 |
connectSource: null,
|
| 91 |
pendingDeleteEdgeId: null,
|
| 92 |
+
pendingDeleteNodeId: null,
|
| 93 |
+
reviewPulseTimer: null,
|
| 94 |
+
reviewPulsePeak: false,
|
| 95 |
};
|
| 96 |
cy.scratch(SCRATCH_NS, state);
|
| 97 |
}
|
|
|
|
| 259 |
opacity: 0.4,
|
| 260 |
},
|
| 261 |
},
|
| 262 |
+
// Hover-fade: while hovering a node, everything outside its closed neighbourhood
|
| 263 |
+
// fades so the local structure pops (the drop-in users expect from Obsidian/Kumu).
|
| 264 |
+
{
|
| 265 |
+
selector: ".hover-faded",
|
| 266 |
+
style: {
|
| 267 |
+
opacity: 0.12,
|
| 268 |
+
},
|
| 269 |
+
},
|
| 270 |
+
// Post-turn dim-burst: briefly fade the rest of the graph (burst-dim) and glow
|
| 271 |
+
// what the agent just added (burst-glow) so "what just changed" is unmistakable.
|
| 272 |
+
{
|
| 273 |
+
selector: ".burst-dim",
|
| 274 |
+
style: {
|
| 275 |
+
opacity: 0.12,
|
| 276 |
+
},
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
selector: ".burst-glow",
|
| 280 |
+
style: {
|
| 281 |
+
"overlay-color": LC_SELECTED,
|
| 282 |
+
"overlay-opacity": 0.3,
|
| 283 |
+
"overlay-padding": 8,
|
| 284 |
+
},
|
| 285 |
+
},
|
| 286 |
// T3-03: keyboard focus ring β distinct outset border, composes with .selected/.highlighted.
|
| 287 |
{
|
| 288 |
selector: ".focus-ring",
|
|
|
|
| 311 |
"line-style": "dashed",
|
| 312 |
},
|
| 313 |
},
|
| 314 |
+
// Node armed for deletion (radial "Delete node" first step) β mirrors the edge
|
| 315 |
+
// armed treatment: a red (LC_EDGE_CONTRADICTS) dashed border so the node reads as
|
| 316 |
+
// "right-click again to confirm". Cleared by the Confirm/Cancel follow-up menu.
|
| 317 |
+
{
|
| 318 |
+
selector: "node.pending-delete",
|
| 319 |
+
style: {
|
| 320 |
+
"border-color": LC_EDGE_CONTRADICTS,
|
| 321 |
+
"border-style": "dashed",
|
| 322 |
+
"border-width": 4,
|
| 323 |
+
},
|
| 324 |
+
},
|
| 325 |
+
// Edge hover-emphasis (wireEdgeHover): brighten + thicken the hovered edge so it
|
| 326 |
+
// pops from the slate background. Class toggle only β cheap and reversible.
|
| 327 |
+
{
|
| 328 |
+
selector: "edge.edge-hover",
|
| 329 |
+
style: {
|
| 330 |
+
width: 4,
|
| 331 |
+
"line-color": LC_HIGHLIGHTED,
|
| 332 |
+
"target-arrow-color": LC_HIGHLIGHTED,
|
| 333 |
+
opacity: 1,
|
| 334 |
+
"z-index": 10,
|
| 335 |
+
},
|
| 336 |
+
},
|
| 337 |
// ββ Trust origin β shape + border style (node-shape-encoding) βββββββββββββ
|
| 338 |
// Shape reinforces the border-style origin signal (redundant encoding aids
|
| 339 |
// colour-blind / low-contrast reading). Shapes are convex with a large interior
|
|
|
|
| 362 |
shape: "hexagon",
|
| 363 |
"border-style": "dashed",
|
| 364 |
"border-color": LC_NODE_BORDER,
|
| 365 |
+
// NB: no base opacity here. A static opacity:0.92 here is a LATER stylesheet
|
| 366 |
+
// rule than .dimmed/.hover-faded/.burst-dim (opacity 0.12/0.4) and would WIN,
|
| 367 |
+
// making hover-fade + dim-burst invisible on model_inferred nodes (the whole
|
| 368 |
+
// seed). The hexagon + dashed amber border already distinguish inferred nodes.
|
| 369 |
},
|
| 370 |
},
|
| 371 |
// ββ Cluster colours (cluster-louvain-colors) β placed BEFORE trust support so
|
|
|
|
| 379 |
"background-color": "#7c3aed",
|
| 380 |
},
|
| 381 |
},
|
| 382 |
+
// ββ Awaiting review β amber dashed border + gentle pulse (model_inferred) ββ
|
| 383 |
+
// Replaces a prior 18% Cytoscape pie that rendered DEAD-CENTRE under the
|
| 384 |
+
// centred node label β invisible. An amber border overrides the indigo dashed
|
| 385 |
+
// origin border (above) so an awaiting-review proposal is unmistakable; the
|
| 386 |
+
// `transition-*` props smooth the `.pulse-peak` toggle (driven by
|
| 387 |
+
// startReviewPulse) into a breathing pulse. Pies can't be corner badges in
|
| 388 |
+
// Cytoscape (always centred), so a border treatment is the correct affordance.
|
| 389 |
{
|
| 390 |
selector: ".review-pending.origin-model-inferred",
|
| 391 |
style: {
|
| 392 |
+
"border-color": LC_REVIEW_AMBER,
|
| 393 |
+
"border-width": 4,
|
| 394 |
+
"border-style": "dashed",
|
| 395 |
+
"transition-property": "border-width, border-color",
|
| 396 |
+
// Numeric time = milliseconds (Cytoscape `time` type implicitUnits:'ms') β 650ms.
|
| 397 |
+
"transition-duration": 650,
|
| 398 |
+
"transition-timing-function": "ease-in-out",
|
| 399 |
+
},
|
| 400 |
+
},
|
| 401 |
+
// Pulse peak β startReviewPulse toggles this class ~every 800ms; the transition
|
| 402 |
+
// on the rule above smooths the border-width/colour step so it "breathes".
|
| 403 |
+
{
|
| 404 |
+
selector: ".review-pending.origin-model-inferred.pulse-peak",
|
| 405 |
+
style: {
|
| 406 |
+
"border-width": 7,
|
| 407 |
+
"border-color": LC_REVIEW_AMBER_BRIGHT,
|
| 408 |
+
},
|
| 409 |
+
},
|
| 410 |
+
// Awaiting-review on EDGES (e.g. a freshly-proposed "hidden connection"). Border
|
| 411 |
+
// props are no-ops on edges, so the amber awaiting-review signal becomes an amber
|
| 412 |
+
// dashed LINE β the edge equivalent of the node's amber border. Same trust language.
|
| 413 |
+
{
|
| 414 |
+
selector: "edge.review-pending.origin-model-inferred",
|
| 415 |
+
style: {
|
| 416 |
+
"line-color": LC_REVIEW_AMBER,
|
| 417 |
+
"line-style": "dashed",
|
| 418 |
+
"target-arrow-color": LC_REVIEW_AMBER,
|
| 419 |
+
width: 3,
|
| 420 |
+
"transition-property": "width, line-color",
|
| 421 |
+
"transition-duration": 650,
|
| 422 |
+
},
|
| 423 |
+
},
|
| 424 |
+
{
|
| 425 |
+
selector: "edge.review-pending.origin-model-inferred.pulse-peak",
|
| 426 |
+
style: {
|
| 427 |
+
width: 5,
|
| 428 |
+
"line-color": LC_REVIEW_AMBER_BRIGHT,
|
| 429 |
+
"target-arrow-color": LC_REVIEW_AMBER_BRIGHT,
|
| 430 |
},
|
| 431 |
},
|
| 432 |
];
|
|
|
|
| 525 |
}
|
| 526 |
}
|
| 527 |
|
| 528 |
+
/**
|
| 529 |
+
* Start the "awaiting review" border pulse. Every `periodMs`, toggle `.pulse-peak`
|
| 530 |
+
* on all model-inferred review-pending nodes so the amber border breathes (the
|
| 531 |
+
* stylesheet `transition-*` on `.review-pending.origin-model-inferred` smooths the
|
| 532 |
+
* step into a gentle pulse). This is the spatial cue that a proposal needs the
|
| 533 |
+
* user's decision β the prior centred pie was invisible under the node label.
|
| 534 |
+
*
|
| 535 |
+
* Idempotent: a second call is a no-op while a timer is live. The timer lives for
|
| 536 |
+
* the `cy` lifetime; the host MUST call {@link stopReviewPulse} before
|
| 537 |
+
* `cy.destroy()` so the interval never touches a destroyed core.
|
| 538 |
+
*/
|
| 539 |
+
export function startReviewPulse(cy: Core, periodMs = 800): void {
|
| 540 |
+
const state = getScratch(cy);
|
| 541 |
+
if (state.reviewPulseTimer != null) return;
|
| 542 |
+
state.reviewPulseTimer = setInterval(() => {
|
| 543 |
+
const pending = cy.$(".review-pending.origin-model-inferred");
|
| 544 |
+
if (pending.length === 0) {
|
| 545 |
+
// Nothing awaiting review β keep the phase at trough so a freshly-added
|
| 546 |
+
// pending node starts calm rather than mid-flash.
|
| 547 |
+
state.reviewPulsePeak = false;
|
| 548 |
+
return;
|
| 549 |
+
}
|
| 550 |
+
state.reviewPulsePeak = !state.reviewPulsePeak;
|
| 551 |
+
if (state.reviewPulsePeak) pending.addClass("pulse-peak");
|
| 552 |
+
else pending.removeClass("pulse-peak");
|
| 553 |
+
}, periodMs);
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
/** Stop the review pulse and clear its interval. Safe to call when not running. */
|
| 557 |
+
export function stopReviewPulse(cy: Core): void {
|
| 558 |
+
const state = getScratch(cy);
|
| 559 |
+
if (state.reviewPulseTimer != null) {
|
| 560 |
+
clearInterval(state.reviewPulseTimer);
|
| 561 |
+
state.reviewPulseTimer = null;
|
| 562 |
+
state.reviewPulsePeak = false;
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
/**
|
| 567 |
* Full reset: remove all elements, tear down callout overlays, and clear
|
| 568 |
* transient state. Called only on `is_snapshot: true` (load / reconnect).
|
|
|
|
| 771 |
});
|
| 772 |
}
|
| 773 |
|
| 774 |
+
/**
|
| 775 |
+
* Post-turn "dim-burst": fade the rest of the graph and glow what the agent just
|
| 776 |
+
* added/touched for `duration` ms, so the eye lands on the change. The glow + dim
|
| 777 |
+
* auto-clear, leaving no persistent chrome (calm-canvas safe). Fogged elements are
|
| 778 |
+
* left untouched (they carry their own opacity β re-dimming/restoring them would
|
| 779 |
+
* invert it). A no-op when `ids` resolves to nothing.
|
| 780 |
+
*/
|
| 781 |
+
export function dimBurst(cy: Core, ids: string[], duration = 2200): void {
|
| 782 |
+
const fresh = cy.collection();
|
| 783 |
+
for (const id of ids) fresh.merge(cy.getElementById(id));
|
| 784 |
+
if (fresh.length === 0) return;
|
| 785 |
+
const keep = fresh.closedNeighborhood();
|
| 786 |
+
const others = cy.elements().filter((el) => !keep.has(el) && !el.hasClass("fogged"));
|
| 787 |
+
others.addClass("burst-dim");
|
| 788 |
+
fresh.addClass("burst-glow");
|
| 789 |
+
setTimeout(() => {
|
| 790 |
+
others.removeClass("burst-dim");
|
| 791 |
+
fresh.removeClass("burst-glow");
|
| 792 |
+
}, duration);
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
/**
|
| 796 |
+
* Hover-fade: while the pointer is over a node, fade everything outside that node's
|
| 797 |
+
* closed neighbourhood (the node + its edges + adjacent nodes) so local structure
|
| 798 |
+
* pops. Uses its own `.hover-faded` class so it never clobbers search/focus `.dimmed`
|
| 799 |
+
* state, and clears on mouseout. Fogged nodes don't trigger it (they're not "real"
|
| 800 |
+
* targets yet). Idempotent-safe: re-wiring would double-bind, so call once per cy.
|
| 801 |
+
*/
|
| 802 |
+
export function wireHoverFade(cy: Core): void {
|
| 803 |
+
cy.on("mouseover", "node", (evt) => {
|
| 804 |
+
const node = evt.target as NodeSingular;
|
| 805 |
+
if (node.hasClass("fogged")) return;
|
| 806 |
+
const keep = node.closedNeighborhood();
|
| 807 |
+
cy.elements()
|
| 808 |
+
.filter((el) => !keep.has(el))
|
| 809 |
+
.addClass("hover-faded");
|
| 810 |
+
});
|
| 811 |
+
cy.on("mouseout", "node", () => {
|
| 812 |
+
cy.elements().removeClass("hover-faded");
|
| 813 |
+
});
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
/**
|
| 817 |
+
* Edge hover-emphasis: while the pointer is over an edge, add `.edge-hover` so the
|
| 818 |
+
* stylesheet brightens + thickens it (and bumps its z-index above its neighbours);
|
| 819 |
+
* remove it on mouseout. Class toggle only β cheap and fully reversible, so it never
|
| 820 |
+
* mutates graph state. Call once per `cy` (re-wiring would double-bind).
|
| 821 |
+
*/
|
| 822 |
+
export function wireEdgeHover(cy: Core): void {
|
| 823 |
+
cy.on("mouseover", "edge", (evt) => {
|
| 824 |
+
(evt.target as EdgeSingular).addClass("edge-hover");
|
| 825 |
+
});
|
| 826 |
+
cy.on("mouseout", "edge", (evt) => {
|
| 827 |
+
(evt.target as EdgeSingular).removeClass("edge-hover");
|
| 828 |
+
});
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
function selectElement(cy: Core, id: string | null): void {
|
| 832 |
const state = getScratch(cy);
|
| 833 |
// Suppress re-dispatch: this selection is backend-driven, not user-driven.
|
|
|
|
| 987 |
|
| 988 |
// ββ Rename gesture + connect mode ββββββββββββββββββββββββββββββββββββββββββββ
|
| 989 |
|
| 990 |
+
/** Options for {@link buildInlineInput}: the shared inline-input affordance. */
|
| 991 |
+
interface InlineInputOptions {
|
| 992 |
+
/** Initial value (plain text; NEVER assigned to innerHTML). */
|
| 993 |
+
value?: string;
|
| 994 |
+
/** Placeholder shown when the input is empty. */
|
| 995 |
+
placeholder?: string;
|
| 996 |
+
/** Absolute left/top in the parent's coordinate space (px). */
|
| 997 |
+
left: number;
|
| 998 |
+
top: number;
|
| 999 |
+
/** Called with the trimmed value on commit; the caller decides whether to act. */
|
| 1000 |
+
onCommit: (value: string) => void;
|
| 1001 |
+
}
|
| 1002 |
+
|
| 1003 |
/**
|
| 1004 |
+
* Create an absolutely-positioned inline `<input>` over the cy container's parent.
|
| 1005 |
+
* Enter / blur commit the trimmed value via `onCommit`; Escape cancels. The input
|
| 1006 |
+
* removes itself from the DOM on commit OR cancel. Shared by {@link promptRename},
|
| 1007 |
+
* {@link promptSummary}, and {@link promptNewNode} so all three behave identically.
|
| 1008 |
*
|
| 1009 |
+
* XSS guard: `input.value` is read as plain text β NEVER assigned to innerHTML.
|
| 1010 |
+
* Returns false (and does nothing) when the cy container is absent.
|
| 1011 |
*/
|
| 1012 |
+
function buildInlineInput(cy: Core, opts: InlineInputOptions): boolean {
|
|
|
|
|
|
|
|
|
|
| 1013 |
const container = cy.container();
|
| 1014 |
+
if (!container) return false;
|
| 1015 |
const parent = container.parentElement ?? container;
|
| 1016 |
|
| 1017 |
const input = document.createElement("input");
|
| 1018 |
input.type = "text";
|
| 1019 |
+
input.value = opts.value ?? "";
|
| 1020 |
+
if (opts.placeholder) input.placeholder = opts.placeholder;
|
| 1021 |
input.style.position = "absolute";
|
| 1022 |
input.style.zIndex = "2000";
|
| 1023 |
input.style.background = "#1a1a2e";
|
|
|
|
| 1032 |
(parent as HTMLElement).style.position = "relative";
|
| 1033 |
}
|
| 1034 |
|
| 1035 |
+
input.style.left = `${opts.left}px`;
|
| 1036 |
+
input.style.top = `${opts.top}px`;
|
|
|
|
| 1037 |
parent.appendChild(input);
|
| 1038 |
input.focus();
|
| 1039 |
input.select();
|
|
|
|
| 1043 |
function commit(): void {
|
| 1044 |
if (committed) return;
|
| 1045 |
committed = true;
|
| 1046 |
+
const value = input.value.trim();
|
| 1047 |
input.remove();
|
| 1048 |
+
opts.onCommit(value);
|
|
|
|
|
|
|
| 1049 |
}
|
| 1050 |
|
| 1051 |
input.addEventListener("keydown", (e) => {
|
|
|
|
| 1058 |
}
|
| 1059 |
});
|
| 1060 |
input.addEventListener("blur", commit);
|
| 1061 |
+
return true;
|
| 1062 |
+
}
|
| 1063 |
+
|
| 1064 |
+
/**
|
| 1065 |
+
* Show an inline text input over a node to rename it. On Enter or blur it commits
|
| 1066 |
+
* the trimmed value via `onCommit` (only when non-empty AND changed); Escape
|
| 1067 |
+
* cancels. Extracted from the double-tap gesture so the radial menu's "Rename"
|
| 1068 |
+
* command reuses the exact same affordance.
|
| 1069 |
+
*
|
| 1070 |
+
* XSS guard: `input.value` is used as plain text β NEVER assigned to innerHTML.
|
| 1071 |
+
*/
|
| 1072 |
+
export function promptRename(cy: Core, node: NodeSingular, onCommit: (label: string) => void): void {
|
| 1073 |
+
const id = node.id();
|
| 1074 |
+
const currentLabel = (node.data("label") as string | undefined) ?? id;
|
| 1075 |
+
const pos = node.renderedPosition();
|
| 1076 |
+
buildInlineInput(cy, {
|
| 1077 |
+
value: currentLabel,
|
| 1078 |
+
left: pos.x - 40,
|
| 1079 |
+
top: pos.y - 12,
|
| 1080 |
+
onCommit: (newLabel) => {
|
| 1081 |
+
// Commit only a non-empty, changed label (preserves the prior behaviour).
|
| 1082 |
+
if (newLabel && newLabel !== currentLabel) onCommit(newLabel);
|
| 1083 |
+
},
|
| 1084 |
+
});
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
/**
|
| 1088 |
+
* Show an inline text input anchored at the edge's rendered midpoint to relabel
|
| 1089 |
+
* it. Prefilled with the edge's current `label` data field; on Enter or blur
|
| 1090 |
+
* commits the trimmed value via `onCommit` (only when non-empty AND changed);
|
| 1091 |
+
* Escape cancels. Mirrors {@link promptRename} but uses `edge.renderedMidpoint()`
|
| 1092 |
+
* for placement (edges have no `renderedPosition`).
|
| 1093 |
+
*
|
| 1094 |
+
* XSS guard: `input.value` is used as plain text β NEVER assigned to innerHTML.
|
| 1095 |
+
*/
|
| 1096 |
+
export function promptEdgeLabel(cy: Core, edge: EdgeSingular, onCommit: (label: string) => void): void {
|
| 1097 |
+
const currentLabel = (edge.data("label") as string | undefined) ?? "";
|
| 1098 |
+
const pos = edge.renderedMidpoint();
|
| 1099 |
+
buildInlineInput(cy, {
|
| 1100 |
+
value: currentLabel,
|
| 1101 |
+
placeholder: "Edge labelβ¦",
|
| 1102 |
+
left: pos.x - 40,
|
| 1103 |
+
top: pos.y - 12,
|
| 1104 |
+
onCommit: (newLabel) => {
|
| 1105 |
+
if (newLabel && newLabel !== currentLabel) onCommit(newLabel);
|
| 1106 |
+
},
|
| 1107 |
+
});
|
| 1108 |
+
}
|
| 1109 |
+
|
| 1110 |
+
/**
|
| 1111 |
+
* Show an inline text input over a node to edit its summary. Empty value +
|
| 1112 |
+
* "Edit summaryβ¦" placeholder (the frontend doesn't hold the current summary).
|
| 1113 |
+
* Commits the trimmed text via `onCommit` only when non-empty; Escape cancels.
|
| 1114 |
+
*
|
| 1115 |
+
* XSS guard: `input.value` is used as plain text β NEVER assigned to innerHTML.
|
| 1116 |
+
*/
|
| 1117 |
+
export function promptSummary(cy: Core, node: NodeSingular, onCommit: (text: string) => void): void {
|
| 1118 |
+
const pos = node.renderedPosition();
|
| 1119 |
+
buildInlineInput(cy, {
|
| 1120 |
+
placeholder: "Edit summaryβ¦",
|
| 1121 |
+
left: pos.x - 40,
|
| 1122 |
+
top: pos.y - 12,
|
| 1123 |
+
onCommit: (text) => {
|
| 1124 |
+
if (text) onCommit(text);
|
| 1125 |
+
},
|
| 1126 |
+
});
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
/**
|
| 1130 |
+
* Show an inline text input centered over the canvas to create a new node. Empty
|
| 1131 |
+
* value + "New node labelβ¦" placeholder. Commits the trimmed label via `onCommit`
|
| 1132 |
+
* only when non-empty; Escape cancels.
|
| 1133 |
+
*
|
| 1134 |
+
* XSS guard: `input.value` is used as plain text β NEVER assigned to innerHTML.
|
| 1135 |
+
*/
|
| 1136 |
+
export function promptNewNode(cy: Core, onCommit: (label: string) => void): void {
|
| 1137 |
+
const container = cy.container();
|
| 1138 |
+
// Center over the canvas using the container's pixel box (renderer-owned space).
|
| 1139 |
+
const cx = container ? container.clientWidth / 2 - 60 : 0;
|
| 1140 |
+
const cy0 = container ? container.clientHeight / 2 - 10 : 0;
|
| 1141 |
+
buildInlineInput(cy, {
|
| 1142 |
+
placeholder: "New node labelβ¦",
|
| 1143 |
+
left: cx,
|
| 1144 |
+
top: cy0,
|
| 1145 |
+
onCommit: (label) => {
|
| 1146 |
+
if (label) onCommit(label);
|
| 1147 |
+
},
|
| 1148 |
+
});
|
| 1149 |
}
|
| 1150 |
|
| 1151 |
/**
|
|
|
|
| 1262 |
*/
|
| 1263 |
export type CxtMenuDispatch = (payload: {
|
| 1264 |
index: null;
|
| 1265 |
+
// `id` is optional because the add_node command has no node id yet (the backend
|
| 1266 |
+
// mints one). Every other command supplies it.
|
| 1267 |
+
value: { kind: string; id?: string; action?: string; label?: string; text?: string };
|
| 1268 |
selected: boolean;
|
| 1269 |
}) => void;
|
| 1270 |
|
|
|
|
| 1287 |
// openMenu() early-return.
|
| 1288 |
if (ele.hasClass("fogged")) return [];
|
| 1289 |
const id = ele.id();
|
| 1290 |
+
const scratch = getScratch(cy);
|
| 1291 |
+
const clearArmed = (): void => {
|
| 1292 |
+
scratch.pendingDeleteNodeId = null;
|
| 1293 |
+
cy.getElementById(id).removeClass("pending-delete");
|
| 1294 |
+
};
|
| 1295 |
+
// Armed for deletion (second right-click on the same node): offer only a
|
| 1296 |
+
// Confirm / Cancel pair. Mirrors the edge two-step delete confirm β a
|
| 1297 |
+
// node deletion is destructive, so it must be a deliberate second action.
|
| 1298 |
+
if (scratch.pendingDeleteNodeId === id) {
|
| 1299 |
+
return [
|
| 1300 |
+
{
|
| 1301 |
+
content: "β Confirm",
|
| 1302 |
+
fillColor: "rgba(180, 40, 40, 0.9)",
|
| 1303 |
+
select: () => {
|
| 1304 |
+
clearArmed();
|
| 1305 |
+
dispatch({ index: null, value: { kind: "delete_node", id }, selected: false });
|
| 1306 |
+
},
|
| 1307 |
+
},
|
| 1308 |
+
{ content: "Cancel", select: () => clearArmed() },
|
| 1309 |
+
];
|
| 1310 |
+
}
|
| 1311 |
const reviewCommand = (action: "accepted" | "rejected"): CxtMenuCommand => ({
|
| 1312 |
content: action === "accepted" ? "β Accept" : "β Reject",
|
| 1313 |
select: () => dispatch({ index: null, value: { kind: "review", id, action }, selected: false }),
|
|
|
|
| 1331 |
dispatch({ index: null, value: { kind: "rename", id, label }, selected: false })
|
| 1332 |
),
|
| 1333 |
};
|
| 1334 |
+
const summaryCommand: CxtMenuCommand = {
|
| 1335 |
+
content: "β Summary",
|
| 1336 |
+
// Inline input (empty + placeholder); commits a new summary via edit_summary.
|
| 1337 |
+
select: () =>
|
| 1338 |
+
promptSummary(cy, ele as NodeSingular, (text) =>
|
| 1339 |
+
dispatch({ index: null, value: { kind: "edit_summary", id, text }, selected: false })
|
| 1340 |
+
),
|
| 1341 |
+
};
|
| 1342 |
+
const deleteCommand: CxtMenuCommand = {
|
| 1343 |
+
content: "π Delete",
|
| 1344 |
+
// Arm only β does NOT delete. The red dashed border signals "right-click
|
| 1345 |
+
// again to confirm" (the Confirm/Cancel pair appears on the armed branch).
|
| 1346 |
+
select: () => {
|
| 1347 |
+
scratch.pendingDeleteNodeId = id;
|
| 1348 |
+
cy.getElementById(id).addClass("pending-delete");
|
| 1349 |
+
},
|
| 1350 |
+
};
|
| 1351 |
+
return [
|
| 1352 |
+
reviewCommand("accepted"),
|
| 1353 |
+
reviewCommand("rejected"),
|
| 1354 |
+
focusCommand,
|
| 1355 |
+
expandCommand,
|
| 1356 |
+
renameCommand,
|
| 1357 |
+
summaryCommand,
|
| 1358 |
+
deleteCommand,
|
| 1359 |
+
];
|
| 1360 |
};
|
| 1361 |
|
| 1362 |
// Edge menu with an in-canvas, two-step delete confirm (deletion is irreversible
|
| 1363 |
// except via undo). First right-click arms the edge (red dashed); the follow-up
|
| 1364 |
// menu on the same edge offers Confirm / Cancel.
|
| 1365 |
+
// Full command set (wedge order): Accept | Reject | Edit label | Delete
|
| 1366 |
+
// (non-armed state). Accept/Reject mirror the node reviewCommand; Edit-label
|
| 1367 |
+
// opens a promptEdgeLabel inline input (non-destructive, no arm step);
|
| 1368 |
+
// Delete arms on first click and requires a second confirm (destructive).
|
| 1369 |
const buildEdgeCommands = (ele: CxtMenuEle): CxtMenuCommand[] => {
|
| 1370 |
if (ele.hasClass("fogged")) return [];
|
| 1371 |
const id = ele.id();
|
|
|
|
| 1387 |
{ content: "Cancel", select: () => clearArmed() },
|
| 1388 |
];
|
| 1389 |
}
|
| 1390 |
+
const edgeReviewCommand = (action: "accepted" | "rejected"): CxtMenuCommand => ({
|
| 1391 |
+
content: action === "accepted" ? "β Accept" : "β Reject",
|
| 1392 |
+
select: () => dispatch({ index: null, value: { kind: "review_edge", id, action }, selected: false }),
|
| 1393 |
+
});
|
| 1394 |
+
const editLabelCommand: CxtMenuCommand = {
|
| 1395 |
+
content: "β Edit label",
|
| 1396 |
+
// Non-destructive β no arm step. Opens inline input; commits relabel_edge.
|
| 1397 |
+
select: () =>
|
| 1398 |
+
promptEdgeLabel(cy, ele as EdgeSingular, (label) =>
|
| 1399 |
+
dispatch({ index: null, value: { kind: "relabel_edge", id, label }, selected: false })
|
| 1400 |
+
),
|
| 1401 |
+
};
|
| 1402 |
+
const deleteCommand: CxtMenuCommand = {
|
| 1403 |
+
content: "π Delete",
|
| 1404 |
+
// Arm only β does NOT delete. The red highlight signals "right-click again".
|
| 1405 |
+
select: () => {
|
| 1406 |
+
scratch.pendingDeleteEdgeId = id;
|
| 1407 |
+
cy.getElementById(id).addClass("pending-delete");
|
| 1408 |
},
|
| 1409 |
+
};
|
| 1410 |
+
return [edgeReviewCommand("accepted"), edgeReviewCommand("rejected"), editLabelCommand, deleteCommand];
|
| 1411 |
};
|
| 1412 |
|
| 1413 |
(cy as CxtMenuCore).cxtmenu({
|
| 1414 |
selector: "node",
|
| 1415 |
commands: buildNodeCommands,
|
| 1416 |
+
// Larger wheel to fit the expanded command set (Accept/Reject/Focus/Expand/
|
| 1417 |
+
// Rename/Summary/Delete) without crowding the wedges.
|
| 1418 |
+
menuRadius: () => 100,
|
| 1419 |
fillColor: "rgba(17, 19, 26, 0.92)", // matches LC_LABEL_BG pill tone
|
| 1420 |
activeFillColor: "rgba(59, 91, 219, 0.85)", // LC_NODE_BG indigo
|
| 1421 |
itemColor: LC_NODE_TEXT,
|
|
|
|
| 1454 |
content: "π¨ Clusters",
|
| 1455 |
select: () => dispatch({ index: null, value: { kind: "cluster", id: "" }, selected: false }),
|
| 1456 |
},
|
| 1457 |
+
{
|
| 1458 |
+
content: "οΌ Add node",
|
| 1459 |
+
// Centered inline input; commits a new node label via add_node.
|
| 1460 |
+
select: () =>
|
| 1461 |
+
promptNewNode(cy, (label) => dispatch({ index: null, value: { kind: "add_node", label }, selected: false })),
|
| 1462 |
+
},
|
| 1463 |
],
|
| 1464 |
menuRadius: () => 70,
|
| 1465 |
fillColor: "rgba(17, 19, 26, 0.92)",
|
cytoscapecanvas/frontend/types.ts
CHANGED
|
@@ -107,7 +107,9 @@ export interface ConnectEvent {
|
|
| 107 |
export type SelectPayload =
|
| 108 |
| { kind: "node" | "edge" | "none"; id: string }
|
| 109 |
| { kind: "rename"; id: string; label: string }
|
| 110 |
-
| { kind: "connect"; source: string; target: string; label: string }
|
|
|
|
|
|
|
| 111 |
|
| 112 |
export interface CytoscapeCanvasEvents {
|
| 113 |
change: never;
|
|
|
|
| 107 |
export type SelectPayload =
|
| 108 |
| { kind: "node" | "edge" | "none"; id: string }
|
| 109 |
| { kind: "rename"; id: string; label: string }
|
| 110 |
+
| { kind: "connect"; source: string; target: string; label: string }
|
| 111 |
+
| { kind: "review_edge"; id: string; action: string }
|
| 112 |
+
| { kind: "relabel_edge"; id: string; label: string };
|
| 113 |
|
| 114 |
export interface CytoscapeCanvasEvents {
|
| 115 |
change: never;
|
fixtures/seed_local_ai.json
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"graph": {
|
| 3 |
+
"nodes": [
|
| 4 |
+
{
|
| 5 |
+
"id": "concept::local_language_model",
|
| 6 |
+
"kind": "concept",
|
| 7 |
+
"label": "Local Language Model",
|
| 8 |
+
"summary": "A model running on a user's own machine that ensures data privacy.",
|
| 9 |
+
"properties": {},
|
| 10 |
+
"metadata": {
|
| 11 |
+
"origin": "model_inferred",
|
| 12 |
+
"support_state": "unverified"
|
| 13 |
+
},
|
| 14 |
+
"evidence_refs": [
|
| 15 |
+
{
|
| 16 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 17 |
+
"node_id": "concept::local_language_model",
|
| 18 |
+
"start_line": 1,
|
| 19 |
+
"end_line": 1,
|
| 20 |
+
"snippet_hash": "527e4efd4bcea8eb4e7857615797c4c9c7d4e1e3700f7224968865312cc26d35"
|
| 21 |
+
}
|
| 22 |
+
]
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"id": "concept::rag",
|
| 26 |
+
"kind": "concept",
|
| 27 |
+
"label": "Retrieval-Augmented Generation (RAG)",
|
| 28 |
+
"summary": "A technique that grounds model answers in specific documents to reduce hallucinations.",
|
| 29 |
+
"properties": {},
|
| 30 |
+
"metadata": {
|
| 31 |
+
"origin": "model_inferred",
|
| 32 |
+
"support_state": "unverified"
|
| 33 |
+
},
|
| 34 |
+
"evidence_refs": [
|
| 35 |
+
{
|
| 36 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 37 |
+
"node_id": "concept::rag",
|
| 38 |
+
"start_line": 1,
|
| 39 |
+
"end_line": 3,
|
| 40 |
+
"snippet_hash": "e36d3a792740cfda99064bdbd5a9efecf4fb40473945d4bcf35ecf9eed0c88ea"
|
| 41 |
+
}
|
| 42 |
+
]
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"id": "concept::embeddings",
|
| 46 |
+
"kind": "concept",
|
| 47 |
+
"label": "Embeddings",
|
| 48 |
+
"summary": "Numeric vectors that capture the semantic meaning of text.",
|
| 49 |
+
"properties": {},
|
| 50 |
+
"metadata": {
|
| 51 |
+
"origin": "model_inferred",
|
| 52 |
+
"support_state": "unverified"
|
| 53 |
+
},
|
| 54 |
+
"evidence_refs": [
|
| 55 |
+
{
|
| 56 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 57 |
+
"node_id": "concept::embeddings",
|
| 58 |
+
"start_line": 3,
|
| 59 |
+
"end_line": 3,
|
| 60 |
+
"snippet_hash": "a1b134578828dda6a026d13c149b5a883c0d7703a65b85baba3b71c36b9556fe"
|
| 61 |
+
}
|
| 62 |
+
]
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"id": "concept::vector_database",
|
| 66 |
+
"kind": "concept",
|
| 67 |
+
"label": "Vector Database",
|
| 68 |
+
"summary": "A storage system that allows for searching by meaning rather than keywords.",
|
| 69 |
+
"properties": {},
|
| 70 |
+
"metadata": {
|
| 71 |
+
"origin": "model_inferred",
|
| 72 |
+
"support_state": "unverified"
|
| 73 |
+
},
|
| 74 |
+
"evidence_refs": [
|
| 75 |
+
{
|
| 76 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 77 |
+
"node_id": "concept::vector_database",
|
| 78 |
+
"start_line": 3,
|
| 79 |
+
"end_line": 3,
|
| 80 |
+
"snippet_hash": "3434f2f64ea638101826dbe1aada727f6ffdb23e659e5512b1b6fe7b204574c2"
|
| 81 |
+
}
|
| 82 |
+
]
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"id": "concept::agent",
|
| 86 |
+
"kind": "concept",
|
| 87 |
+
"label": "Agent",
|
| 88 |
+
"summary": "A model that can perform actions by invoking tools to complete tasks.",
|
| 89 |
+
"properties": {},
|
| 90 |
+
"metadata": {
|
| 91 |
+
"origin": "model_inferred",
|
| 92 |
+
"support_state": "unverified"
|
| 93 |
+
},
|
| 94 |
+
"evidence_refs": [
|
| 95 |
+
{
|
| 96 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 97 |
+
"node_id": "concept::agent",
|
| 98 |
+
"start_line": 3,
|
| 99 |
+
"end_line": 3,
|
| 100 |
+
"snippet_hash": "669c0a4763e07218022e5ace4af8b0d143cefec33fc9fc0d12f8b4a63800ed42"
|
| 101 |
+
}
|
| 102 |
+
]
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"id": "concept::tool_calling",
|
| 106 |
+
"kind": "concept",
|
| 107 |
+
"label": "Tool Calling",
|
| 108 |
+
"summary": "The process where a model decides to invoke a function or API.",
|
| 109 |
+
"properties": {},
|
| 110 |
+
"metadata": {
|
| 111 |
+
"origin": "model_inferred",
|
| 112 |
+
"support_state": "unverified"
|
| 113 |
+
},
|
| 114 |
+
"evidence_refs": [
|
| 115 |
+
{
|
| 116 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 117 |
+
"node_id": "concept::tool_calling",
|
| 118 |
+
"start_line": 3,
|
| 119 |
+
"end_line": 3,
|
| 120 |
+
"snippet_hash": "2ff6b971eeb8ebcc4ebf47cf517b7a7065f9fb3e8b577712d2a65d394b04f681"
|
| 121 |
+
}
|
| 122 |
+
]
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"id": "concept::structured_output",
|
| 126 |
+
"kind": "concept",
|
| 127 |
+
"label": "Structured Output",
|
| 128 |
+
"summary": "Machine-readable output, such as JSON, required for tool calling and precise RAG citations.",
|
| 129 |
+
"properties": {},
|
| 130 |
+
"metadata": {
|
| 131 |
+
"origin": "model_inferred",
|
| 132 |
+
"support_state": "unverified"
|
| 133 |
+
},
|
| 134 |
+
"evidence_refs": [
|
| 135 |
+
{
|
| 136 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 137 |
+
"node_id": "concept::structured_output",
|
| 138 |
+
"start_line": 3,
|
| 139 |
+
"end_line": 3,
|
| 140 |
+
"snippet_hash": "d153caffaef0d004d5ea80a9d2d79de76444b0f1af4a22f69436a5b5c4c7a26a"
|
| 141 |
+
}
|
| 142 |
+
]
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
"id": "concept::fine_tuning",
|
| 146 |
+
"kind": "concept",
|
| 147 |
+
"label": "Fine-tuning",
|
| 148 |
+
"summary": "Adjusting a model's weights on specific examples to teach lasting behavior.",
|
| 149 |
+
"properties": {},
|
| 150 |
+
"metadata": {
|
| 151 |
+
"origin": "model_inferred",
|
| 152 |
+
"support_state": "unverified"
|
| 153 |
+
},
|
| 154 |
+
"evidence_refs": [
|
| 155 |
+
{
|
| 156 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 157 |
+
"node_id": "concept::fine_tuning",
|
| 158 |
+
"start_line": 5,
|
| 159 |
+
"end_line": 5,
|
| 160 |
+
"snippet_hash": "8bd39e784347707c5d4b96bc28d11ba3b99a139e2798a6e29a67ff5377212fae"
|
| 161 |
+
}
|
| 162 |
+
]
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"id": "concept::lora",
|
| 166 |
+
"kind": "concept",
|
| 167 |
+
"label": "LoRA",
|
| 168 |
+
"summary": "A parameter-efficient fine-tuning method that trains only small adapter layers.",
|
| 169 |
+
"properties": {},
|
| 170 |
+
"metadata": {
|
| 171 |
+
"origin": "model_inferred",
|
| 172 |
+
"support_state": "unverified"
|
| 173 |
+
},
|
| 174 |
+
"evidence_refs": [
|
| 175 |
+
{
|
| 176 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 177 |
+
"node_id": "concept::lora",
|
| 178 |
+
"start_line": 5,
|
| 179 |
+
"end_line": 5,
|
| 180 |
+
"snippet_hash": "44d1331560c7ce9bd6e813333f32e3ab6b816117dfbbf6d1faf83f5dd9e9ce6b"
|
| 181 |
+
}
|
| 182 |
+
]
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
"id": "concept::open_source_models",
|
| 186 |
+
"kind": "concept",
|
| 187 |
+
"label": "Open-source Models",
|
| 188 |
+
"summary": "Models with open weights that enable privacy, inspection, and customization.",
|
| 189 |
+
"properties": {},
|
| 190 |
+
"metadata": {
|
| 191 |
+
"origin": "model_inferred",
|
| 192 |
+
"support_state": "unverified"
|
| 193 |
+
},
|
| 194 |
+
"evidence_refs": [
|
| 195 |
+
{
|
| 196 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 197 |
+
"node_id": "concept::open_source_models",
|
| 198 |
+
"start_line": 5,
|
| 199 |
+
"end_line": 5,
|
| 200 |
+
"snippet_hash": "87998699bfff48c57e15c35e7fbfb2a952e5b52bb9fcbc3260ca6bac16c5d30c"
|
| 201 |
+
}
|
| 202 |
+
]
|
| 203 |
+
}
|
| 204 |
+
],
|
| 205 |
+
"edges": [
|
| 206 |
+
{
|
| 207 |
+
"id": "concept::rag::prerequisite_of::concept::embeddings",
|
| 208 |
+
"source": "concept::rag",
|
| 209 |
+
"target": "concept::embeddings",
|
| 210 |
+
"type": "prerequisite_of",
|
| 211 |
+
"label": "depends on",
|
| 212 |
+
"properties": {
|
| 213 |
+
"origin": "model_inferred",
|
| 214 |
+
"support_state": "unverified"
|
| 215 |
+
}
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"id": "concept::embeddings::related_to::concept::vector_database",
|
| 219 |
+
"source": "concept::embeddings",
|
| 220 |
+
"target": "concept::vector_database",
|
| 221 |
+
"type": "related_to",
|
| 222 |
+
"label": "stored in",
|
| 223 |
+
"properties": {
|
| 224 |
+
"origin": "model_inferred",
|
| 225 |
+
"support_state": "unverified"
|
| 226 |
+
}
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
"id": "concept::agent::explains::concept::tool_calling",
|
| 230 |
+
"source": "concept::agent",
|
| 231 |
+
"target": "concept::tool_calling",
|
| 232 |
+
"type": "explains",
|
| 233 |
+
"label": "works by",
|
| 234 |
+
"properties": {
|
| 235 |
+
"origin": "model_inferred",
|
| 236 |
+
"support_state": "unverified"
|
| 237 |
+
}
|
| 238 |
+
},
|
| 239 |
+
{
|
| 240 |
+
"id": "concept::tool_calling::prerequisite_of::concept::structured_output",
|
| 241 |
+
"source": "concept::tool_calling",
|
| 242 |
+
"target": "concept::structured_output",
|
| 243 |
+
"type": "prerequisite_of",
|
| 244 |
+
"label": "depends on",
|
| 245 |
+
"properties": {
|
| 246 |
+
"origin": "model_inferred",
|
| 247 |
+
"support_state": "unverified"
|
| 248 |
+
}
|
| 249 |
+
},
|
| 250 |
+
{
|
| 251 |
+
"id": "concept::structured_output::related_to::concept::rag",
|
| 252 |
+
"source": "concept::structured_output",
|
| 253 |
+
"target": "concept::rag",
|
| 254 |
+
"type": "related_to",
|
| 255 |
+
"label": "disciplines",
|
| 256 |
+
"properties": {
|
| 257 |
+
"origin": "model_inferred",
|
| 258 |
+
"support_state": "unverified"
|
| 259 |
+
}
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
"id": "concept::fine_tuning::explains::concept::lora",
|
| 263 |
+
"source": "concept::fine_tuning",
|
| 264 |
+
"target": "concept::lora",
|
| 265 |
+
"type": "explains",
|
| 266 |
+
"label": "efficient version of",
|
| 267 |
+
"properties": {
|
| 268 |
+
"origin": "model_inferred",
|
| 269 |
+
"support_state": "unverified"
|
| 270 |
+
}
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"id": "concept::rag::related_to::concept::fine_tuning",
|
| 274 |
+
"source": "concept::rag",
|
| 275 |
+
"target": "concept::fine_tuning",
|
| 276 |
+
"type": "related_to",
|
| 277 |
+
"label": "complementary to",
|
| 278 |
+
"properties": {
|
| 279 |
+
"origin": "model_inferred",
|
| 280 |
+
"support_state": "unverified"
|
| 281 |
+
}
|
| 282 |
+
},
|
| 283 |
+
{
|
| 284 |
+
"id": "concept::open_source_models::prerequisite_of::concept::fine_tuning",
|
| 285 |
+
"source": "concept::open_source_models",
|
| 286 |
+
"target": "concept::fine_tuning",
|
| 287 |
+
"type": "prerequisite_of",
|
| 288 |
+
"label": "enables",
|
| 289 |
+
"properties": {
|
| 290 |
+
"origin": "model_inferred",
|
| 291 |
+
"support_state": "unverified"
|
| 292 |
+
}
|
| 293 |
+
},
|
| 294 |
+
{
|
| 295 |
+
"id": "concept::open_source_models::related_to::concept::local_language_model",
|
| 296 |
+
"source": "concept::open_source_models",
|
| 297 |
+
"target": "concept::local_language_model",
|
| 298 |
+
"type": "related_to",
|
| 299 |
+
"label": "underpins",
|
| 300 |
+
"properties": {
|
| 301 |
+
"origin": "model_inferred",
|
| 302 |
+
"support_state": "unverified"
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
],
|
| 306 |
+
"claims": [
|
| 307 |
+
{
|
| 308 |
+
"id": "claim::concept::local_language_model",
|
| 309 |
+
"claim_type": "concept_node",
|
| 310 |
+
"target_id": "concept::local_language_model",
|
| 311 |
+
"source_id": "",
|
| 312 |
+
"text": "A model running on a user's own machine that ensures data privacy.",
|
| 313 |
+
"origin": "model_inferred",
|
| 314 |
+
"support_state": "unverified",
|
| 315 |
+
"review_state": "pending",
|
| 316 |
+
"evidence_refs": [
|
| 317 |
+
{
|
| 318 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 319 |
+
"node_id": "concept::local_language_model",
|
| 320 |
+
"start_line": 1,
|
| 321 |
+
"end_line": 1,
|
| 322 |
+
"snippet_hash": "527e4efd4bcea8eb4e7857615797c4c9c7d4e1e3700f7224968865312cc26d35"
|
| 323 |
+
}
|
| 324 |
+
]
|
| 325 |
+
},
|
| 326 |
+
{
|
| 327 |
+
"id": "claim::concept::rag",
|
| 328 |
+
"claim_type": "concept_node",
|
| 329 |
+
"target_id": "concept::rag",
|
| 330 |
+
"source_id": "",
|
| 331 |
+
"text": "A technique that grounds model answers in specific documents to reduce hallucinations.",
|
| 332 |
+
"origin": "model_inferred",
|
| 333 |
+
"support_state": "unverified",
|
| 334 |
+
"review_state": "pending",
|
| 335 |
+
"evidence_refs": [
|
| 336 |
+
{
|
| 337 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 338 |
+
"node_id": "concept::rag",
|
| 339 |
+
"start_line": 1,
|
| 340 |
+
"end_line": 3,
|
| 341 |
+
"snippet_hash": "e36d3a792740cfda99064bdbd5a9efecf4fb40473945d4bcf35ecf9eed0c88ea"
|
| 342 |
+
}
|
| 343 |
+
]
|
| 344 |
+
},
|
| 345 |
+
{
|
| 346 |
+
"id": "claim::concept::embeddings",
|
| 347 |
+
"claim_type": "concept_node",
|
| 348 |
+
"target_id": "concept::embeddings",
|
| 349 |
+
"source_id": "",
|
| 350 |
+
"text": "Numeric vectors that capture the semantic meaning of text.",
|
| 351 |
+
"origin": "model_inferred",
|
| 352 |
+
"support_state": "unverified",
|
| 353 |
+
"review_state": "pending",
|
| 354 |
+
"evidence_refs": [
|
| 355 |
+
{
|
| 356 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 357 |
+
"node_id": "concept::embeddings",
|
| 358 |
+
"start_line": 3,
|
| 359 |
+
"end_line": 3,
|
| 360 |
+
"snippet_hash": "a1b134578828dda6a026d13c149b5a883c0d7703a65b85baba3b71c36b9556fe"
|
| 361 |
+
}
|
| 362 |
+
]
|
| 363 |
+
},
|
| 364 |
+
{
|
| 365 |
+
"id": "claim::concept::vector_database",
|
| 366 |
+
"claim_type": "concept_node",
|
| 367 |
+
"target_id": "concept::vector_database",
|
| 368 |
+
"source_id": "",
|
| 369 |
+
"text": "A storage system that allows for searching by meaning rather than keywords.",
|
| 370 |
+
"origin": "model_inferred",
|
| 371 |
+
"support_state": "unverified",
|
| 372 |
+
"review_state": "pending",
|
| 373 |
+
"evidence_refs": [
|
| 374 |
+
{
|
| 375 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 376 |
+
"node_id": "concept::vector_database",
|
| 377 |
+
"start_line": 3,
|
| 378 |
+
"end_line": 3,
|
| 379 |
+
"snippet_hash": "3434f2f64ea638101826dbe1aada727f6ffdb23e659e5512b1b6fe7b204574c2"
|
| 380 |
+
}
|
| 381 |
+
]
|
| 382 |
+
},
|
| 383 |
+
{
|
| 384 |
+
"id": "claim::concept::agent",
|
| 385 |
+
"claim_type": "concept_node",
|
| 386 |
+
"target_id": "concept::agent",
|
| 387 |
+
"source_id": "",
|
| 388 |
+
"text": "A model that can perform actions by invoking tools to complete tasks.",
|
| 389 |
+
"origin": "model_inferred",
|
| 390 |
+
"support_state": "unverified",
|
| 391 |
+
"review_state": "pending",
|
| 392 |
+
"evidence_refs": [
|
| 393 |
+
{
|
| 394 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 395 |
+
"node_id": "concept::agent",
|
| 396 |
+
"start_line": 3,
|
| 397 |
+
"end_line": 3,
|
| 398 |
+
"snippet_hash": "669c0a4763e07218022e5ace4af8b0d143cefec33fc9fc0d12f8b4a63800ed42"
|
| 399 |
+
}
|
| 400 |
+
]
|
| 401 |
+
},
|
| 402 |
+
{
|
| 403 |
+
"id": "claim::concept::tool_calling",
|
| 404 |
+
"claim_type": "concept_node",
|
| 405 |
+
"target_id": "concept::tool_calling",
|
| 406 |
+
"source_id": "",
|
| 407 |
+
"text": "The process where a model decides to invoke a function or API.",
|
| 408 |
+
"origin": "model_inferred",
|
| 409 |
+
"support_state": "unverified",
|
| 410 |
+
"review_state": "pending",
|
| 411 |
+
"evidence_refs": [
|
| 412 |
+
{
|
| 413 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 414 |
+
"node_id": "concept::tool_calling",
|
| 415 |
+
"start_line": 3,
|
| 416 |
+
"end_line": 3,
|
| 417 |
+
"snippet_hash": "2ff6b971eeb8ebcc4ebf47cf517b7a7065f9fb3e8b577712d2a65d394b04f681"
|
| 418 |
+
}
|
| 419 |
+
]
|
| 420 |
+
},
|
| 421 |
+
{
|
| 422 |
+
"id": "claim::concept::structured_output",
|
| 423 |
+
"claim_type": "concept_node",
|
| 424 |
+
"target_id": "concept::structured_output",
|
| 425 |
+
"source_id": "",
|
| 426 |
+
"text": "Machine-readable output, such as JSON, required for tool calling and precise RAG citations.",
|
| 427 |
+
"origin": "model_inferred",
|
| 428 |
+
"support_state": "unverified",
|
| 429 |
+
"review_state": "pending",
|
| 430 |
+
"evidence_refs": [
|
| 431 |
+
{
|
| 432 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 433 |
+
"node_id": "concept::structured_output",
|
| 434 |
+
"start_line": 3,
|
| 435 |
+
"end_line": 3,
|
| 436 |
+
"snippet_hash": "d153caffaef0d004d5ea80a9d2d79de76444b0f1af4a22f69436a5b5c4c7a26a"
|
| 437 |
+
}
|
| 438 |
+
]
|
| 439 |
+
},
|
| 440 |
+
{
|
| 441 |
+
"id": "claim::concept::fine_tuning",
|
| 442 |
+
"claim_type": "concept_node",
|
| 443 |
+
"target_id": "concept::fine_tuning",
|
| 444 |
+
"source_id": "",
|
| 445 |
+
"text": "Adjusting a model's weights on specific examples to teach lasting behavior.",
|
| 446 |
+
"origin": "model_inferred",
|
| 447 |
+
"support_state": "unverified",
|
| 448 |
+
"review_state": "pending",
|
| 449 |
+
"evidence_refs": [
|
| 450 |
+
{
|
| 451 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 452 |
+
"node_id": "concept::fine_tuning",
|
| 453 |
+
"start_line": 5,
|
| 454 |
+
"end_line": 5,
|
| 455 |
+
"snippet_hash": "8bd39e784347707c5d4b96bc28d11ba3b99a139e2798a6e29a67ff5377212fae"
|
| 456 |
+
}
|
| 457 |
+
]
|
| 458 |
+
},
|
| 459 |
+
{
|
| 460 |
+
"id": "claim::concept::lora",
|
| 461 |
+
"claim_type": "concept_node",
|
| 462 |
+
"target_id": "concept::lora",
|
| 463 |
+
"source_id": "",
|
| 464 |
+
"text": "A parameter-efficient fine-tuning method that trains only small adapter layers.",
|
| 465 |
+
"origin": "model_inferred",
|
| 466 |
+
"support_state": "unverified",
|
| 467 |
+
"review_state": "pending",
|
| 468 |
+
"evidence_refs": [
|
| 469 |
+
{
|
| 470 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 471 |
+
"node_id": "concept::lora",
|
| 472 |
+
"start_line": 5,
|
| 473 |
+
"end_line": 5,
|
| 474 |
+
"snippet_hash": "44d1331560c7ce9bd6e813333f32e3ab6b816117dfbbf6d1faf83f5dd9e9ce6b"
|
| 475 |
+
}
|
| 476 |
+
]
|
| 477 |
+
},
|
| 478 |
+
{
|
| 479 |
+
"id": "claim::concept::open_source_models",
|
| 480 |
+
"claim_type": "concept_node",
|
| 481 |
+
"target_id": "concept::open_source_models",
|
| 482 |
+
"source_id": "",
|
| 483 |
+
"text": "Models with open weights that enable privacy, inspection, and customization.",
|
| 484 |
+
"origin": "model_inferred",
|
| 485 |
+
"support_state": "unverified",
|
| 486 |
+
"review_state": "pending",
|
| 487 |
+
"evidence_refs": [
|
| 488 |
+
{
|
| 489 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 490 |
+
"node_id": "concept::open_source_models",
|
| 491 |
+
"start_line": 5,
|
| 492 |
+
"end_line": 5,
|
| 493 |
+
"snippet_hash": "87998699bfff48c57e15c35e7fbfb2a952e5b52bb9fcbc3260ca6bac16c5d30c"
|
| 494 |
+
}
|
| 495 |
+
]
|
| 496 |
+
},
|
| 497 |
+
{
|
| 498 |
+
"id": "claim::semantic::concept::rag->concept::embeddings::prerequisite_of",
|
| 499 |
+
"claim_type": "semantic_edge",
|
| 500 |
+
"target_id": "concept::rag::prerequisite_of::concept::embeddings",
|
| 501 |
+
"source_id": "concept::rag",
|
| 502 |
+
"text": "prerequisite_of",
|
| 503 |
+
"origin": "model_inferred",
|
| 504 |
+
"support_state": "unverified",
|
| 505 |
+
"review_state": "pending",
|
| 506 |
+
"evidence_refs": [
|
| 507 |
+
{
|
| 508 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 509 |
+
"node_id": "concept::rag::prerequisite_of::concept::embeddings",
|
| 510 |
+
"start_line": 1,
|
| 511 |
+
"end_line": 10,
|
| 512 |
+
"snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
|
| 513 |
+
}
|
| 514 |
+
]
|
| 515 |
+
},
|
| 516 |
+
{
|
| 517 |
+
"id": "claim::semantic::concept::embeddings->concept::vector_database::related_to",
|
| 518 |
+
"claim_type": "semantic_edge",
|
| 519 |
+
"target_id": "concept::embeddings::related_to::concept::vector_database",
|
| 520 |
+
"source_id": "concept::embeddings",
|
| 521 |
+
"text": "related_to",
|
| 522 |
+
"origin": "model_inferred",
|
| 523 |
+
"support_state": "unverified",
|
| 524 |
+
"review_state": "pending",
|
| 525 |
+
"evidence_refs": [
|
| 526 |
+
{
|
| 527 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 528 |
+
"node_id": "concept::embeddings::related_to::concept::vector_database",
|
| 529 |
+
"start_line": 1,
|
| 530 |
+
"end_line": 10,
|
| 531 |
+
"snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
|
| 532 |
+
}
|
| 533 |
+
]
|
| 534 |
+
},
|
| 535 |
+
{
|
| 536 |
+
"id": "claim::semantic::concept::agent->concept::tool_calling::explains",
|
| 537 |
+
"claim_type": "semantic_edge",
|
| 538 |
+
"target_id": "concept::agent::explains::concept::tool_calling",
|
| 539 |
+
"source_id": "concept::agent",
|
| 540 |
+
"text": "explains",
|
| 541 |
+
"origin": "model_inferred",
|
| 542 |
+
"support_state": "unverified",
|
| 543 |
+
"review_state": "pending",
|
| 544 |
+
"evidence_refs": [
|
| 545 |
+
{
|
| 546 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 547 |
+
"node_id": "concept::agent::explains::concept::tool_calling",
|
| 548 |
+
"start_line": 1,
|
| 549 |
+
"end_line": 10,
|
| 550 |
+
"snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
|
| 551 |
+
}
|
| 552 |
+
]
|
| 553 |
+
},
|
| 554 |
+
{
|
| 555 |
+
"id": "claim::semantic::concept::tool_calling->concept::structured_output::prerequisite_of",
|
| 556 |
+
"claim_type": "semantic_edge",
|
| 557 |
+
"target_id": "concept::tool_calling::prerequisite_of::concept::structured_output",
|
| 558 |
+
"source_id": "concept::tool_calling",
|
| 559 |
+
"text": "prerequisite_of",
|
| 560 |
+
"origin": "model_inferred",
|
| 561 |
+
"support_state": "unverified",
|
| 562 |
+
"review_state": "pending",
|
| 563 |
+
"evidence_refs": [
|
| 564 |
+
{
|
| 565 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 566 |
+
"node_id": "concept::tool_calling::prerequisite_of::concept::structured_output",
|
| 567 |
+
"start_line": 1,
|
| 568 |
+
"end_line": 10,
|
| 569 |
+
"snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
|
| 570 |
+
}
|
| 571 |
+
]
|
| 572 |
+
},
|
| 573 |
+
{
|
| 574 |
+
"id": "claim::semantic::concept::structured_output->concept::rag::related_to",
|
| 575 |
+
"claim_type": "semantic_edge",
|
| 576 |
+
"target_id": "concept::structured_output::related_to::concept::rag",
|
| 577 |
+
"source_id": "concept::structured_output",
|
| 578 |
+
"text": "related_to",
|
| 579 |
+
"origin": "model_inferred",
|
| 580 |
+
"support_state": "unverified",
|
| 581 |
+
"review_state": "pending",
|
| 582 |
+
"evidence_refs": [
|
| 583 |
+
{
|
| 584 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 585 |
+
"node_id": "concept::structured_output::related_to::concept::rag",
|
| 586 |
+
"start_line": 1,
|
| 587 |
+
"end_line": 10,
|
| 588 |
+
"snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
|
| 589 |
+
}
|
| 590 |
+
]
|
| 591 |
+
},
|
| 592 |
+
{
|
| 593 |
+
"id": "claim::semantic::concept::fine_tuning->concept::lora::explains",
|
| 594 |
+
"claim_type": "semantic_edge",
|
| 595 |
+
"target_id": "concept::fine_tuning::explains::concept::lora",
|
| 596 |
+
"source_id": "concept::fine_tuning",
|
| 597 |
+
"text": "explains",
|
| 598 |
+
"origin": "model_inferred",
|
| 599 |
+
"support_state": "unverified",
|
| 600 |
+
"review_state": "pending",
|
| 601 |
+
"evidence_refs": [
|
| 602 |
+
{
|
| 603 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 604 |
+
"node_id": "concept::fine_tuning::explains::concept::lora",
|
| 605 |
+
"start_line": 1,
|
| 606 |
+
"end_line": 10,
|
| 607 |
+
"snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
|
| 608 |
+
}
|
| 609 |
+
]
|
| 610 |
+
},
|
| 611 |
+
{
|
| 612 |
+
"id": "claim::semantic::concept::rag->concept::fine_tuning::related_to",
|
| 613 |
+
"claim_type": "semantic_edge",
|
| 614 |
+
"target_id": "concept::rag::related_to::concept::fine_tuning",
|
| 615 |
+
"source_id": "concept::rag",
|
| 616 |
+
"text": "related_to",
|
| 617 |
+
"origin": "model_inferred",
|
| 618 |
+
"support_state": "unverified",
|
| 619 |
+
"review_state": "pending",
|
| 620 |
+
"evidence_refs": [
|
| 621 |
+
{
|
| 622 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 623 |
+
"node_id": "concept::rag::related_to::concept::fine_tuning",
|
| 624 |
+
"start_line": 1,
|
| 625 |
+
"end_line": 10,
|
| 626 |
+
"snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
|
| 627 |
+
}
|
| 628 |
+
]
|
| 629 |
+
},
|
| 630 |
+
{
|
| 631 |
+
"id": "claim::semantic::concept::open_source_models->concept::fine_tuning::prerequisite_of",
|
| 632 |
+
"claim_type": "semantic_edge",
|
| 633 |
+
"target_id": "concept::open_source_models::prerequisite_of::concept::fine_tuning",
|
| 634 |
+
"source_id": "concept::open_source_models",
|
| 635 |
+
"text": "prerequisite_of",
|
| 636 |
+
"origin": "model_inferred",
|
| 637 |
+
"support_state": "unverified",
|
| 638 |
+
"review_state": "pending",
|
| 639 |
+
"evidence_refs": [
|
| 640 |
+
{
|
| 641 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 642 |
+
"node_id": "concept::open_source_models::prerequisite_of::concept::fine_tuning",
|
| 643 |
+
"start_line": 1,
|
| 644 |
+
"end_line": 10,
|
| 645 |
+
"snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
|
| 646 |
+
}
|
| 647 |
+
]
|
| 648 |
+
},
|
| 649 |
+
{
|
| 650 |
+
"id": "claim::semantic::concept::open_source_models->concept::local_language_model::related_to",
|
| 651 |
+
"claim_type": "semantic_edge",
|
| 652 |
+
"target_id": "concept::open_source_models::related_to::concept::local_language_model",
|
| 653 |
+
"source_id": "concept::open_source_models",
|
| 654 |
+
"text": "related_to",
|
| 655 |
+
"origin": "model_inferred",
|
| 656 |
+
"support_state": "unverified",
|
| 657 |
+
"review_state": "pending",
|
| 658 |
+
"evidence_refs": [
|
| 659 |
+
{
|
| 660 |
+
"source_id": "text::what-you-build-with-local-ai",
|
| 661 |
+
"node_id": "concept::open_source_models::related_to::concept::local_language_model",
|
| 662 |
+
"start_line": 1,
|
| 663 |
+
"end_line": 10,
|
| 664 |
+
"snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
|
| 665 |
+
}
|
| 666 |
+
]
|
| 667 |
+
}
|
| 668 |
+
],
|
| 669 |
+
"metadata": {
|
| 670 |
+
"schema_version": "0.1.0",
|
| 671 |
+
"node_count": 10,
|
| 672 |
+
"edge_count": 9
|
| 673 |
+
}
|
| 674 |
+
},
|
| 675 |
+
"scene": {
|
| 676 |
+
"scene_id": "seed_local_ai_initial",
|
| 677 |
+
"visible_node_ids": [
|
| 678 |
+
"concept::rag",
|
| 679 |
+
"concept::fine_tuning",
|
| 680 |
+
"concept::structured_output",
|
| 681 |
+
"concept::embeddings",
|
| 682 |
+
"concept::tool_calling",
|
| 683 |
+
"concept::open_source_models",
|
| 684 |
+
"concept::lora",
|
| 685 |
+
"concept::vector_database",
|
| 686 |
+
"concept::local_language_model",
|
| 687 |
+
"concept::agent"
|
| 688 |
+
],
|
| 689 |
+
"visible_edge_ids": [
|
| 690 |
+
"concept::rag::prerequisite_of::concept::embeddings",
|
| 691 |
+
"concept::embeddings::related_to::concept::vector_database",
|
| 692 |
+
"concept::agent::explains::concept::tool_calling",
|
| 693 |
+
"concept::tool_calling::prerequisite_of::concept::structured_output",
|
| 694 |
+
"concept::structured_output::related_to::concept::rag",
|
| 695 |
+
"concept::fine_tuning::explains::concept::lora",
|
| 696 |
+
"concept::rag::related_to::concept::fine_tuning",
|
| 697 |
+
"concept::open_source_models::prerequisite_of::concept::fine_tuning",
|
| 698 |
+
"concept::open_source_models::related_to::concept::local_language_model"
|
| 699 |
+
],
|
| 700 |
+
"fogged_node_ids": [],
|
| 701 |
+
"highlighted_ids": [],
|
| 702 |
+
"selected_id": "",
|
| 703 |
+
"callouts": [],
|
| 704 |
+
"node_positions": {
|
| 705 |
+
"concept::rag": [359.3254889740797, 413.93124693993104],
|
| 706 |
+
"concept::fine_tuning": [427.90556718584685, 501.1807331162357],
|
| 707 |
+
"concept::structured_output": [459.9388127470801, 266.3831793829436],
|
| 708 |
+
"concept::embeddings": [263.19949505847364, 575.2638023784646],
|
| 709 |
+
"concept::tool_calling": [562.8504920579213, 157.5174883009194],
|
| 710 |
+
"concept::open_source_models": [667.7714962697, 680.5737168938729],
|
| 711 |
+
"concept::lora": [445.549280906602, 680.8558430944295],
|
| 712 |
+
"concept::vector_database": [200.66715948740705, 724.4091315938329],
|
| 713 |
+
"concept::local_language_model": [900.0, 876.1056318950547],
|
| 714 |
+
"concept::agent": [712.7922073128894, 123.77922640431535]
|
| 715 |
+
},
|
| 716 |
+
"node_cluster_map": {
|
| 717 |
+
"concept::tool_calling": 0,
|
| 718 |
+
"concept::rag": 0,
|
| 719 |
+
"concept::agent": 0,
|
| 720 |
+
"concept::structured_output": 0,
|
| 721 |
+
"concept::fine_tuning": 1,
|
| 722 |
+
"concept::local_language_model": 1,
|
| 723 |
+
"concept::open_source_models": 1,
|
| 724 |
+
"concept::lora": 1,
|
| 725 |
+
"concept::vector_database": 2,
|
| 726 |
+
"concept::embeddings": 2
|
| 727 |
+
}
|
| 728 |
+
}
|
| 729 |
+
}
|
scripts/playwright_cxtmenu.py
CHANGED
|
@@ -109,6 +109,51 @@ EDGE_POINT = """
|
|
| 109 |
}
|
| 110 |
"""
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
def main() -> int:
|
| 114 |
console: list[str] = []
|
|
@@ -217,13 +262,61 @@ def main() -> int:
|
|
| 217 |
report["edge_menu_wedges"] = edge_items
|
| 218 |
page.screenshot(path=str(OUT / "cxtmenu_04_edge.png"))
|
| 219 |
edge_joined = " ".join(edge_items).lower()
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
| 224 |
page.mouse.up(button="right")
|
| 225 |
page.wait_for_timeout(150)
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
browser.close()
|
| 228 |
|
| 229 |
# C. No page errors (console 'error' lines or pageerror entries).
|
|
|
|
| 109 |
}
|
| 110 |
"""
|
| 111 |
|
| 112 |
+
# First non-fogged node, preferring one still awaiting review (amber). Used to
|
| 113 |
+
# verify the reject-removes-node FIX: rejecting must KEEP the node on canvas.
|
| 114 |
+
PICK_REVIEWABLE_NODE = """
|
| 115 |
+
() => {
|
| 116 |
+
const cy = window.__cy;
|
| 117 |
+
if (!cy) return null;
|
| 118 |
+
const pending = cy.nodes().filter((n) => !n.hasClass('fogged') && n.hasClass('review-pending'));
|
| 119 |
+
const n = pending[0] || cy.nodes().filter((n) => !n.hasClass('fogged'))[0];
|
| 120 |
+
if (!n) return null;
|
| 121 |
+
const rp = n.renderedPosition();
|
| 122 |
+
const rect = cy.container().getBoundingClientRect();
|
| 123 |
+
return {
|
| 124 |
+
id: n.id(), x: rect.left + rp.x, y: rect.top + rp.y,
|
| 125 |
+
reviewPending: n.hasClass('review-pending'),
|
| 126 |
+
};
|
| 127 |
+
}
|
| 128 |
+
"""
|
| 129 |
+
|
| 130 |
+
# Live state of a specific node id (plus total node count) β for before/after a reject.
|
| 131 |
+
NODE_STATE = """
|
| 132 |
+
(id) => {
|
| 133 |
+
const cy = window.__cy;
|
| 134 |
+
if (!cy) return null;
|
| 135 |
+
const n = cy.getElementById(id);
|
| 136 |
+
return { exists: n.length > 0, classes: n.length ? n.classes() : [], total: cy.nodes().length };
|
| 137 |
+
}
|
| 138 |
+
"""
|
| 139 |
+
|
| 140 |
+
# Page-coord center of the OPEN menu wedge whose text matches `label` (case-insensitive).
|
| 141 |
+
WEDGE_BBOX = """
|
| 142 |
+
(label) => {
|
| 143 |
+
const wraps = Array.from(document.querySelectorAll('.cxtmenu'));
|
| 144 |
+
const open = wraps.find((w) => {
|
| 145 |
+
const p = w.querySelector('div');
|
| 146 |
+
return p && getComputedStyle(p).display === 'block';
|
| 147 |
+
});
|
| 148 |
+
if (!open) return null;
|
| 149 |
+
const el = Array.from(open.querySelectorAll('.cxtmenu-content'))
|
| 150 |
+
.find((e) => (e.textContent || '').toLowerCase().includes(label.toLowerCase()));
|
| 151 |
+
if (!el) return null;
|
| 152 |
+
const r = el.getBoundingClientRect();
|
| 153 |
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
| 154 |
+
}
|
| 155 |
+
"""
|
| 156 |
+
|
| 157 |
|
| 158 |
def main() -> int:
|
| 159 |
console: list[str] = []
|
|
|
|
| 262 |
report["edge_menu_wedges"] = edge_items
|
| 263 |
page.screenshot(path=str(OUT / "cxtmenu_04_edge.png"))
|
| 264 |
edge_joined = " ".join(edge_items).lower()
|
| 265 |
+
# WF-A: the edge menu now offers Accept / Reject / Edit label / Delete
|
| 266 |
+
# (parity with the node review menu), not just Delete.
|
| 267 |
+
for want in ("accept", "reject", "edit", "delete"):
|
| 268 |
+
if want not in edge_joined:
|
| 269 |
+
failures.append(
|
| 270 |
+
f"edge menu missing a '{want}' wedge; got {edge_items}"
|
| 271 |
+
)
|
| 272 |
page.mouse.up(button="right")
|
| 273 |
page.wait_for_timeout(150)
|
| 274 |
|
| 275 |
+
# F. Reject a node's claim β the node must STAY on the canvas.
|
| 276 |
+
# This is the rendered proof of the reject-removes-node FIX: before the fix,
|
| 277 |
+
# a node-claim rejection emitted remove_element(#node_id) and the canvas
|
| 278 |
+
# deleted the whole node; now it clears the amber badge but keeps the node.
|
| 279 |
+
rnode = page.evaluate(PICK_REVIEWABLE_NODE)
|
| 280 |
+
report["reject_node"] = rnode
|
| 281 |
+
if not rnode:
|
| 282 |
+
failures.append("no node available to test reject")
|
| 283 |
+
else:
|
| 284 |
+
before = page.evaluate(NODE_STATE, rnode["id"])
|
| 285 |
+
report["reject_node_before"] = before
|
| 286 |
+
page.mouse.move(rnode["x"], rnode["y"])
|
| 287 |
+
page.mouse.down(button="right")
|
| 288 |
+
page.wait_for_timeout(250)
|
| 289 |
+
wedge = page.evaluate(WEDGE_BBOX, "reject")
|
| 290 |
+
report["reject_wedge_bbox"] = wedge
|
| 291 |
+
if not wedge:
|
| 292 |
+
failures.append("no Reject wedge found in the node menu")
|
| 293 |
+
page.mouse.up(button="right")
|
| 294 |
+
else:
|
| 295 |
+
# Swipe to the Reject wedge and release β fires the reject dispatch.
|
| 296 |
+
page.mouse.move(wedge["x"], wedge["y"])
|
| 297 |
+
page.wait_for_timeout(150)
|
| 298 |
+
page.mouse.up(button="right")
|
| 299 |
+
# Wait for the backend round-trip + renderer patch to apply.
|
| 300 |
+
page.wait_for_timeout(2000)
|
| 301 |
+
after = page.evaluate(NODE_STATE, rnode["id"])
|
| 302 |
+
report["reject_node_after"] = after
|
| 303 |
+
page.screenshot(path=str(OUT / "cxtmenu_05_after_reject.png"))
|
| 304 |
+
if not after["exists"]:
|
| 305 |
+
failures.append(
|
| 306 |
+
f"REJECT REMOVED NODE {rnode['id']!r} β the reject-removes-node "
|
| 307 |
+
"bug is NOT fixed (node gone from cy after reject)"
|
| 308 |
+
)
|
| 309 |
+
if before and after["total"] != before["total"]:
|
| 310 |
+
failures.append(
|
| 311 |
+
f"node count changed on reject: {before['total']} -> "
|
| 312 |
+
f"{after['total']} (reject must not delete nodes)"
|
| 313 |
+
)
|
| 314 |
+
# Soft signal: a single-claim node should also lose its amber badge.
|
| 315 |
+
report["reject_amber_cleared"] = (
|
| 316 |
+
rnode.get("reviewPending")
|
| 317 |
+
and "review-pending" not in after["classes"]
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
browser.close()
|
| 321 |
|
| 322 |
# C. No page errors (console 'error' lines or pageerror entries).
|
src/loosecanvas/agent_harness.py
CHANGED
|
@@ -19,6 +19,7 @@ llama.cpp gotcha (verified by the spike): do NOT pass ``stream_options`` to
|
|
| 19 |
from __future__ import annotations
|
| 20 |
|
| 21 |
import asyncio
|
|
|
|
| 22 |
import os
|
| 23 |
import time
|
| 24 |
from collections.abc import AsyncIterator
|
|
@@ -53,41 +54,41 @@ from loosecanvas.turn_logic import (
|
|
| 53 |
SessionRecord,
|
| 54 |
TurnResult,
|
| 55 |
_get_lock,
|
|
|
|
| 56 |
session_store,
|
| 57 |
)
|
| 58 |
|
|
|
|
|
|
|
| 59 |
# ββ System prompt ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
|
| 61 |
-
SYSTEM_PROMPT = """You are a
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
-
|
| 68 |
-
-
|
| 69 |
-
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
-
|
| 73 |
-
-
|
| 74 |
-
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
-
|
| 79 |
-
-
|
| 80 |
-
-
|
| 81 |
-
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
TRUST MODEL:
|
| 89 |
-
- Your proposals are proposals: the user (and the validator) decide what becomes real. Never assert unsupported claims as fact.
|
| 90 |
-
- Ground what you propose in evidence visible on the canvas or in the conversation.
|
| 91 |
"""
|
| 92 |
|
| 93 |
# ββ Streaming events βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -318,7 +319,7 @@ def _compose_user_message(
|
|
| 318 |
if record is not None:
|
| 319 |
record.attention.last_selected_node_id = sel_id
|
| 320 |
return f"[The user currently has node '{sel_id}' selected.]\n\n{message}"
|
| 321 |
-
|
| 322 |
remembered = record.attention.last_selected_node_id
|
| 323 |
record.attention.last_selected_node_id = "" # consume β one-turn grace
|
| 324 |
return f"[The user was just looking at node '{remembered}'.]\n\n{message}"
|
|
@@ -370,6 +371,41 @@ def _build_turn_messages(
|
|
| 370 |
return messages
|
| 371 |
|
| 372 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
async def run_agent_turn_streaming(
|
| 374 |
session_id: str,
|
| 375 |
selection: dict[str, Any],
|
|
@@ -425,7 +461,7 @@ async def run_agent_turn_streaming(
|
|
| 425 |
for call in tool_call_chunks:
|
| 426 |
name = call.get("name") if isinstance(call, dict) else None
|
| 427 |
if name:
|
| 428 |
-
yield ToolActivityEvent(message=f"{name}β¦")
|
| 429 |
content = getattr(token, "content", "")
|
| 430 |
if isinstance(content, str) and content and node == "model":
|
| 431 |
parts.append(content)
|
|
@@ -442,3 +478,68 @@ async def run_agent_turn_streaming(
|
|
| 442 |
)
|
| 443 |
finally:
|
| 444 |
lock.release()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
from __future__ import annotations
|
| 20 |
|
| 21 |
import asyncio
|
| 22 |
+
import logging
|
| 23 |
import os
|
| 24 |
import time
|
| 25 |
from collections.abc import AsyncIterator
|
|
|
|
| 54 |
SessionRecord,
|
| 55 |
TurnResult,
|
| 56 |
_get_lock,
|
| 57 |
+
effective_node_label,
|
| 58 |
session_store,
|
| 59 |
)
|
| 60 |
|
| 61 |
+
logger = logging.getLogger(__name__)
|
| 62 |
+
|
| 63 |
# ββ System prompt ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
|
| 65 |
+
SYSTEM_PROMPT = """You are a warm, curious thinking partner helping the user grow a shared map of ideas on a canvas. You build understanding together: the user sets the direction, and you bring initiative, a sharp eye for connections, and a real willingness to act. Talk like a thoughtful person, not a system -- you are a collaborator, never a form to fill out or a function that emits JSON.
|
| 66 |
+
|
| 67 |
+
The canvas is where the thinking becomes visible. In any turn you can speak, act (call tools to shape the canvas), or do both -- whatever actually helps the user think.
|
| 68 |
+
|
| 69 |
+
HOW YOU TALK
|
| 70 |
+
- Be a genuine conversational partner. A sentence of orientation almost always helps: what you notice, what you just did, what you're curious about. Reserve silence for pure navigation (a pan or a zoom) where words would only be noise.
|
| 71 |
+
- You don't need to read back changes the canvas already shows -- instead say what they mean, or ask what's worth doing next.
|
| 72 |
+
- Match your certainty to the evidence. State what's plainly on the canvas matter-of-factly. Offer an inferred relationship or pattern as an idea -- "I notice...", "this looks like..." -- that the user can keep or drop. On the genuinely open questions, be a friendly provocateur: ask "but what if...", surface the tension, invite pushback.
|
| 73 |
+
- Warm and natural, but no filler and no emojis. Ask a real follow-up when it would move the thinking forward.
|
| 74 |
+
|
| 75 |
+
CALIBRATE YOUR VOICE TO YOUR CONFIDENCE
|
| 76 |
+
- Facts the user explicitly named or drew (user-asserted, deterministic): state them plainly and matter-of-factly. Do not hedge what is already established.
|
| 77 |
+
- Your own inferences, suggestions, or pattern-readings (model-inferred): hedge clearly. Use "I noticeβ¦", "one reading isβ¦", "this looks likeβ¦" so the user can accept or drop the idea.
|
| 78 |
+
- Open questions, hypotheses, genuine unknowns: be a friendly provocateur. Ask "but what ifβ¦", surface the tension, invite pushback. Make clear you are raising a question, not asserting an answer.
|
| 79 |
+
|
| 80 |
+
If the user asks you to shift how you talk β be more skeptical, more concise, blunter, gentler, whatever β just go with it for the rest of the conversation; you'll remember.
|
| 81 |
+
|
| 82 |
+
HOW YOU ACT -- you can reshape the map the way the user could by hand: add ideas, rename and annotate them, connect them, surface hidden links, and (with the user's yes) remove things.
|
| 83 |
+
- When you say you'll do something, do it in the same turn. Don't promise an edit and then stop -- if you mention it, make the tool call now.
|
| 84 |
+
- Carry a request all the way through. If "add these ideas and connect them" implies several moves, make every tool call needed to finish it this turn. A new node has no edges until you draw them, so add the node first, then connect it -- never leave a node you just created floating.
|
| 85 |
+
- Reach for whatever fits: reveal_node / center_on / highlight to guide attention; create_node, create_edge, update_node, update_edge to grow and refine (update_edge relabels an existing connection so it shows on the canvas β use it when the user wants the edges clearer or more informative); propose_edge_label / propose_note / propose_hypothesis / propose_question to float an idea the user can accept or dismiss. Follow where the user wants to go rather than running a fixed script.
|
| 86 |
+
- Removing is the one move you always confirm first. Ask in plain conversation, and only call remove_node or remove_edge once the user has clearly said yes.
|
| 87 |
+
- When the user says to accept, approve, reject, or dismiss a proposal or a node/edge with an amber "awaiting review" badge, call review_element(target_id, action) where action is "accepted" or "rejected" β call get_canvas_state first to find the right id.
|
| 88 |
+
- get_canvas_state is your eyes on the current map -- lean on it whenever you're unsure what's visible or selected. If you reference something that isn't there, the system tells you, so act and adjust rather than over-checking everything first.
|
| 89 |
+
|
| 90 |
+
WHAT STAYS TRUE
|
| 91 |
+
- What you add starts as a proposal: the user (and the validator) decide what becomes real. Don't state a guess as a fact -- ground what you propose in what's on the canvas or what the user has said.
|
|
|
|
|
|
|
|
|
|
| 92 |
"""
|
| 93 |
|
| 94 |
# ββ Streaming events βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 319 |
if record is not None:
|
| 320 |
record.attention.last_selected_node_id = sel_id
|
| 321 |
return f"[The user currently has node '{sel_id}' selected.]\n\n{message}"
|
| 322 |
+
elif record is not None and record.attention.last_selected_node_id:
|
| 323 |
remembered = record.attention.last_selected_node_id
|
| 324 |
record.attention.last_selected_node_id = "" # consume β one-turn grace
|
| 325 |
return f"[The user was just looking at node '{remembered}'.]\n\n{message}"
|
|
|
|
| 371 |
return messages
|
| 372 |
|
| 373 |
|
| 374 |
+
# Friendly, present-tense narration for the streamed tool-activity ("what the agent
|
| 375 |
+
# is doing now") so the chat reads like a collaborator, not a function trace. Unknown
|
| 376 |
+
# tools fall back to a humanized name. Only the STREAMED narration is humanized; the
|
| 377 |
+
# final collapsible receipts keep the precise tool:id labels (they're the audit detail).
|
| 378 |
+
_FRIENDLY_TOOL_NAMES = {
|
| 379 |
+
"create_node": "Adding a concept",
|
| 380 |
+
"add_concept": "Adding a concept",
|
| 381 |
+
"create_concept": "Adding a concept",
|
| 382 |
+
"create_edge": "Drawing a connection",
|
| 383 |
+
"connect_nodes": "Drawing a connection",
|
| 384 |
+
"add_edge": "Drawing a connection",
|
| 385 |
+
"propose_hypothesis": "Proposing a hidden connection",
|
| 386 |
+
"rename_node": "Renaming a concept",
|
| 387 |
+
"update_node": "Updating a concept",
|
| 388 |
+
"update_edge": "Relabeling a connection",
|
| 389 |
+
"set_summary": "Annotating a concept",
|
| 390 |
+
"annotate_node": "Annotating a concept",
|
| 391 |
+
"reveal_node": "Revealing a related idea",
|
| 392 |
+
"expand_node": "Revealing related ideas",
|
| 393 |
+
"remove_node": "Removing a concept",
|
| 394 |
+
"remove_edge": "Removing a connection",
|
| 395 |
+
"get_canvas_state": "Looking at the map",
|
| 396 |
+
"search_nodes": "Searching the map",
|
| 397 |
+
"review_element": "Reviewing a proposal",
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def _friendly_tool(name: str) -> str:
|
| 402 |
+
"""Map a raw tool name to a friendly present-tense phrase for live narration."""
|
| 403 |
+
if name in _FRIENDLY_TOOL_NAMES:
|
| 404 |
+
return _FRIENDLY_TOOL_NAMES[name]
|
| 405 |
+
words = name.replace("_", " ").strip()
|
| 406 |
+
return (words[:1].upper() + words[1:]) if words else name
|
| 407 |
+
|
| 408 |
+
|
| 409 |
async def run_agent_turn_streaming(
|
| 410 |
session_id: str,
|
| 411 |
selection: dict[str, Any],
|
|
|
|
| 461 |
for call in tool_call_chunks:
|
| 462 |
name = call.get("name") if isinstance(call, dict) else None
|
| 463 |
if name:
|
| 464 |
+
yield ToolActivityEvent(message=f"{_friendly_tool(name)}β¦")
|
| 465 |
content = getattr(token, "content", "")
|
| 466 |
if isinstance(content, str) and content and node == "model":
|
| 467 |
parts.append(content)
|
|
|
|
| 478 |
)
|
| 479 |
finally:
|
| 480 |
lock.release()
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
# ββ First-turn opener (magic build) βββββββββββββββββββββββββββββββββββββββββββββ
|
| 484 |
+
#
|
| 485 |
+
# The very first Send builds the graph via the deterministic textβgraph extractor
|
| 486 |
+
# (turn_logic.stream_text_session), which mints the session but does NOT run the
|
| 487 |
+
# conversational agent. To make turn one feel like every later turn, ``main.py``
|
| 488 |
+
# streams THIS opener into the chat right after the graph paints β the same warm
|
| 489 |
+
# voice the user meets on turn two, naming the concepts that were actually pulled
|
| 490 |
+
# out. It is a plain (tool-less) streaming completion, so it never mutates the
|
| 491 |
+
# canvas and never pollutes the agent's conversation memory; the source document is
|
| 492 |
+
# seeded into that memory on the first real agent turn (see ``_build_turn_messages``).
|
| 493 |
+
|
| 494 |
+
_OPENER_SYSTEM = """You just helped the user turn their own text into a first map of ideas on a shared canvas. This is the opening line of your conversation with them β speak in your own warm, curious voice as their thinking partner.
|
| 495 |
+
|
| 496 |
+
In two to four sentences: weave in a few of the concepts you actually pulled out (they are listed below), make clear this first pass is your inference β a starting point they own and can reshape β and invite the next move (tightening a connection, renaming something, adding what's missing, or digging into one idea). No filler, no emojis, and don't just recite counts. The user is the driver."""
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
async def stream_build_opener(session_id: str) -> AsyncIterator[str]:
|
| 500 |
+
"""Stream a warm, topic-aware opening line for a just-built magic-build graph.
|
| 501 |
+
|
| 502 |
+
Yields prose chunks (to be appended into the chat bubble exactly like a normal
|
| 503 |
+
turn's ``TokenEvent``). Replaces the old canned "Built a graph: N concepts"
|
| 504 |
+
template so turn one sounds like the same collaborator the user meets on every
|
| 505 |
+
later turn. If the model call fails for any reason, falls back to a single warm
|
| 506 |
+
static line that still names the extracted concepts β so turn one never lands
|
| 507 |
+
silent or with a bare count.
|
| 508 |
+
"""
|
| 509 |
+
record = session_store.get(session_id)
|
| 510 |
+
labels: list[str] = []
|
| 511 |
+
n_edges = 0
|
| 512 |
+
if record is not None:
|
| 513 |
+
labels = [
|
| 514 |
+
effective_node_label(node, record.graph.claims.values())
|
| 515 |
+
for node in record.graph.nodes.values()
|
| 516 |
+
]
|
| 517 |
+
n_edges = len(record.graph.edges)
|
| 518 |
+
sample = ", ".join(labels[:8]) if labels else "a handful of ideas"
|
| 519 |
+
user_msg = (
|
| 520 |
+
f"Concepts you extracted: {sample}. "
|
| 521 |
+
f"You also drew {n_edges} relation(s) between them."
|
| 522 |
+
)
|
| 523 |
+
emitted = False
|
| 524 |
+
try:
|
| 525 |
+
model = make_model()
|
| 526 |
+
async for chunk in model.astream(
|
| 527 |
+
[("system", _OPENER_SYSTEM), ("human", user_msg)]
|
| 528 |
+
):
|
| 529 |
+
content = getattr(chunk, "content", "")
|
| 530 |
+
if isinstance(content, str) and content:
|
| 531 |
+
emitted = True
|
| 532 |
+
yield content
|
| 533 |
+
except Exception: # noqa: BLE001 β an opener hiccup must never break the build
|
| 534 |
+
logger.debug("build opener stream failed; using static fallback", exc_info=True)
|
| 535 |
+
# Only fall back when NOTHING streamed β a mid-stream failure that already
|
| 536 |
+
# emitted real tokens must not double-post the static line on top of them.
|
| 537 |
+
if emitted:
|
| 538 |
+
return
|
| 539 |
+
# Fallback: warm, names the concepts, never a bare count.
|
| 540 |
+
connected = " and connected them" if n_edges else ""
|
| 541 |
+
yield (
|
| 542 |
+
f"Here's a first pass at your map β I pulled out {sample}{connected}. "
|
| 543 |
+
"It's all my inference for now, a starting point you own. "
|
| 544 |
+
"Tell me what to sharpen, rename, or add."
|
| 545 |
+
)
|
src/loosecanvas/agent_tools.py
CHANGED
|
@@ -27,12 +27,23 @@ from typing import Any
|
|
| 27 |
|
| 28 |
from langchain.tools import tool
|
| 29 |
|
| 30 |
-
from loosecanvas.contracts import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
from loosecanvas.turn_logic import (
|
| 32 |
_AGENT_MAX_ACTIONS,
|
| 33 |
_AGENT_MAX_REVEAL,
|
|
|
|
|
|
|
| 34 |
SessionRecord,
|
| 35 |
TurnResult,
|
|
|
|
|
|
|
|
|
|
| 36 |
session_store,
|
| 37 |
)
|
| 38 |
from loosecanvas.validator import validate_sceneplan
|
|
@@ -47,6 +58,7 @@ class AgentTurnContext:
|
|
| 47 |
session_id: str
|
| 48 |
accumulated_actions: list[SceneAction] = field(default_factory=list)
|
| 49 |
activity: list[str] = field(default_factory=list)
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
_current_turn: ContextVar[AgentTurnContext | None] = ContextVar(
|
|
@@ -127,19 +139,36 @@ def _validate_and_accumulate(action: SceneAction, activity_label: str) -> str:
|
|
| 127 |
|
| 128 |
@tool
|
| 129 |
def get_canvas_state() -> str:
|
| 130 |
-
"""Return a compact JSON snapshot
|
| 131 |
-
|
|
|
|
|
|
|
| 132 |
ctx = get_turn_context()
|
| 133 |
record: SessionRecord = session_store[ctx.session_id]
|
| 134 |
scene: SceneState = record.scene
|
|
|
|
| 135 |
visible_nodes = []
|
| 136 |
for nid in scene.visible_node_ids:
|
| 137 |
node = record.graph.get_node(nid)
|
| 138 |
label = node.label if node is not None else nid
|
| 139 |
visible_nodes.append({"id": nid, "label": label})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
return json.dumps(
|
| 141 |
{
|
| 142 |
"visible_nodes": visible_nodes,
|
|
|
|
| 143 |
"fogged_count": len(scene.fogged_node_ids),
|
| 144 |
"visible_edge_ids": list(scene.visible_edge_ids),
|
| 145 |
"selected_id": scene.selected_id,
|
|
@@ -334,6 +363,18 @@ def update_node(node_id: str, label: str = "", note: str = "") -> str:
|
|
| 334 |
)
|
| 335 |
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
@tool
|
| 338 |
def remove_node(node_id: str) -> str:
|
| 339 |
"""Remove a visible node from the graph (permanent).
|
|
@@ -379,6 +420,76 @@ def expand_around(node_id: str) -> str:
|
|
| 379 |
return json.dumps({"status": "rejected", "reason": reason, "hint": hint})
|
| 380 |
|
| 381 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
# ββ Exported tool list βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 383 |
|
| 384 |
TOOLS: list[Any] = [
|
|
@@ -395,9 +506,11 @@ TOOLS: list[Any] = [
|
|
| 395 |
create_node,
|
| 396 |
create_edge,
|
| 397 |
update_node,
|
|
|
|
| 398 |
remove_node,
|
| 399 |
remove_edge,
|
| 400 |
expand_around,
|
|
|
|
| 401 |
]
|
| 402 |
|
| 403 |
|
|
@@ -411,18 +524,67 @@ def finalize_agent_turn(
|
|
| 411 |
) -> TurnResult | None:
|
| 412 |
"""Validate accumulated actions, reduce, apply patches, and finalize the turn.
|
| 413 |
|
| 414 |
-
Returns ``None`` when no actions were accumulated
|
| 415 |
-
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
"""
|
| 418 |
from loosecanvas.turn_logic import (
|
| 419 |
apply_agent_actions,
|
| 420 |
) # imported here to avoid import-time circularity
|
| 421 |
|
| 422 |
ctx = get_turn_context()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
if not ctx.accumulated_actions:
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
record, ctx.accumulated_actions, selection, assistant_message
|
| 428 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
from langchain.tools import tool
|
| 29 |
|
| 30 |
+
from loosecanvas.contracts import (
|
| 31 |
+
RendererCommand,
|
| 32 |
+
SceneAction,
|
| 33 |
+
SceneActionType,
|
| 34 |
+
ScenePlan,
|
| 35 |
+
SceneState,
|
| 36 |
+
)
|
| 37 |
from loosecanvas.turn_logic import (
|
| 38 |
_AGENT_MAX_ACTIONS,
|
| 39 |
_AGENT_MAX_REVEAL,
|
| 40 |
+
RendererPatch,
|
| 41 |
+
ReviewClaimResult,
|
| 42 |
SessionRecord,
|
| 43 |
TurnResult,
|
| 44 |
+
effective_edge_label,
|
| 45 |
+
review_edge_claim,
|
| 46 |
+
review_node_claim,
|
| 47 |
session_store,
|
| 48 |
)
|
| 49 |
from loosecanvas.validator import validate_sceneplan
|
|
|
|
| 58 |
session_id: str
|
| 59 |
accumulated_actions: list[SceneAction] = field(default_factory=list)
|
| 60 |
activity: list[str] = field(default_factory=list)
|
| 61 |
+
review_patches: list[RendererPatch] = field(default_factory=list)
|
| 62 |
|
| 63 |
|
| 64 |
_current_turn: ContextVar[AgentTurnContext | None] = ContextVar(
|
|
|
|
| 139 |
|
| 140 |
@tool
|
| 141 |
def get_canvas_state() -> str:
|
| 142 |
+
"""Return a compact JSON snapshot of the canvas. Call this first to discover valid IDs.
|
| 143 |
+
Includes visible_nodes (id+label), visible_edges (id, source, target, current label β
|
| 144 |
+
use these to relabel a connection with update_edge), fogged_count, and selected_id.
|
| 145 |
+
"""
|
| 146 |
ctx = get_turn_context()
|
| 147 |
record: SessionRecord = session_store[ctx.session_id]
|
| 148 |
scene: SceneState = record.scene
|
| 149 |
+
claims = record.graph.claims.values()
|
| 150 |
visible_nodes = []
|
| 151 |
for nid in scene.visible_node_ids:
|
| 152 |
node = record.graph.get_node(nid)
|
| 153 |
label = node.label if node is not None else nid
|
| 154 |
visible_nodes.append({"id": nid, "label": label})
|
| 155 |
+
visible_edges = []
|
| 156 |
+
for eid in scene.visible_edge_ids:
|
| 157 |
+
edge = record.graph.get_edge(eid)
|
| 158 |
+
if edge is None:
|
| 159 |
+
continue
|
| 160 |
+
visible_edges.append(
|
| 161 |
+
{
|
| 162 |
+
"id": eid,
|
| 163 |
+
"source": edge.source,
|
| 164 |
+
"target": edge.target,
|
| 165 |
+
"label": effective_edge_label(edge, claims),
|
| 166 |
+
}
|
| 167 |
+
)
|
| 168 |
return json.dumps(
|
| 169 |
{
|
| 170 |
"visible_nodes": visible_nodes,
|
| 171 |
+
"visible_edges": visible_edges,
|
| 172 |
"fogged_count": len(scene.fogged_node_ids),
|
| 173 |
"visible_edge_ids": list(scene.visible_edge_ids),
|
| 174 |
"selected_id": scene.selected_id,
|
|
|
|
| 363 |
)
|
| 364 |
|
| 365 |
|
| 366 |
+
@tool
|
| 367 |
+
def update_edge(edge_id: str, label: str) -> str:
|
| 368 |
+
"""Relabel an existing visible edge so the new, clearer label shows on the canvas.
|
| 369 |
+
Use this to make a connection more informative or descriptive. Requires edge_id
|
| 370 |
+
(call get_canvas_state to list visible_edges with their ids and current labels) and
|
| 371 |
+
a non-empty label."""
|
| 372 |
+
return _validate_and_accumulate(
|
| 373 |
+
SceneAction(type=SceneActionType.update_edge, edge_id=edge_id, label=label),
|
| 374 |
+
f"update_edge:{edge_id}",
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
@tool
|
| 379 |
def remove_node(node_id: str) -> str:
|
| 380 |
"""Remove a visible node from the graph (permanent).
|
|
|
|
| 420 |
return json.dumps({"status": "rejected", "reason": reason, "hint": hint})
|
| 421 |
|
| 422 |
|
| 423 |
+
@tool
|
| 424 |
+
def review_element(target_id: str, action: str) -> str:
|
| 425 |
+
"""Accept or reject a model-inferred proposal (node or edge) awaiting review.
|
| 426 |
+
|
| 427 |
+
Call get_canvas_state first to discover valid node and edge ids on the canvas.
|
| 428 |
+
``action`` must be "accepted" or "rejected".
|
| 429 |
+
Only works on model-inferred elements whose review badge (amber border) is still
|
| 430 |
+
showing β i.e. elements with review_state "pending".
|
| 431 |
+
|
| 432 |
+
Returns a JSON string with status, target_id, and the new review_state.
|
| 433 |
+
"""
|
| 434 |
+
if action not in ("accepted", "rejected"):
|
| 435 |
+
return json.dumps(
|
| 436 |
+
{
|
| 437 |
+
"status": "error",
|
| 438 |
+
"reason": "action must be 'accepted' or 'rejected'",
|
| 439 |
+
"target_id": target_id,
|
| 440 |
+
}
|
| 441 |
+
)
|
| 442 |
+
ctx = get_turn_context()
|
| 443 |
+
record: SessionRecord | None = session_store.get(ctx.session_id)
|
| 444 |
+
if record is None:
|
| 445 |
+
return json.dumps(
|
| 446 |
+
{"status": "error", "reason": "session not found", "target_id": target_id}
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
# Determine whether target is a node or an edge and call the appropriate helper.
|
| 450 |
+
result: ReviewClaimResult | None
|
| 451 |
+
if target_id in record.graph.nodes:
|
| 452 |
+
try:
|
| 453 |
+
result = review_node_claim(ctx.session_id, target_id, action)
|
| 454 |
+
except Exception as exc: # noqa: BLE001
|
| 455 |
+
return json.dumps(
|
| 456 |
+
{"status": "error", "reason": str(exc), "target_id": target_id}
|
| 457 |
+
)
|
| 458 |
+
elif target_id in record.graph.edges:
|
| 459 |
+
try:
|
| 460 |
+
result = review_edge_claim(ctx.session_id, target_id, action)
|
| 461 |
+
except Exception as exc: # noqa: BLE001
|
| 462 |
+
return json.dumps(
|
| 463 |
+
{"status": "error", "reason": str(exc), "target_id": target_id}
|
| 464 |
+
)
|
| 465 |
+
else:
|
| 466 |
+
return json.dumps(
|
| 467 |
+
{
|
| 468 |
+
"status": "not_found",
|
| 469 |
+
"reason": f"no node or edge with id {target_id!r} β call get_canvas_state to see valid ids",
|
| 470 |
+
"target_id": target_id,
|
| 471 |
+
}
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
if result is None:
|
| 475 |
+
return json.dumps(
|
| 476 |
+
{
|
| 477 |
+
"status": "not_reviewable",
|
| 478 |
+
"reason": f"{target_id!r} has no model-inferred claims awaiting review",
|
| 479 |
+
"target_id": target_id,
|
| 480 |
+
}
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
ctx.review_patches.append(result.renderer_patch)
|
| 484 |
+
return json.dumps(
|
| 485 |
+
{
|
| 486 |
+
"status": "ok",
|
| 487 |
+
"target_id": target_id,
|
| 488 |
+
"review_state": result.review_state,
|
| 489 |
+
}
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
|
| 493 |
# ββ Exported tool list βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 494 |
|
| 495 |
TOOLS: list[Any] = [
|
|
|
|
| 506 |
create_node,
|
| 507 |
create_edge,
|
| 508 |
update_node,
|
| 509 |
+
update_edge,
|
| 510 |
remove_node,
|
| 511 |
remove_edge,
|
| 512 |
expand_around,
|
| 513 |
+
review_element,
|
| 514 |
]
|
| 515 |
|
| 516 |
|
|
|
|
| 524 |
) -> TurnResult | None:
|
| 525 |
"""Validate accumulated actions, reduce, apply patches, and finalize the turn.
|
| 526 |
|
| 527 |
+
Returns ``None`` when no actions were accumulated AND no review patches exist
|
| 528 |
+
(pure-speech turn β no canvas patch).
|
| 529 |
+
|
| 530 |
+
When accumulated_actions is empty but review_patches is non-empty (the user only
|
| 531 |
+
asked to accept/reject something), builds a minimal TurnResult carrying the merged
|
| 532 |
+
review ops so the canvas updates.
|
| 533 |
+
|
| 534 |
+
Otherwise runs the authoritative validateβreduceβapplyβ_finalize_turn pipeline and
|
| 535 |
+
merges any review patch operations into the returned RendererPatch.
|
| 536 |
"""
|
| 537 |
from loosecanvas.turn_logic import (
|
| 538 |
apply_agent_actions,
|
| 539 |
) # imported here to avoid import-time circularity
|
| 540 |
|
| 541 |
ctx = get_turn_context()
|
| 542 |
+
review_ops: list[RendererCommand] = [
|
| 543 |
+
op for rp in ctx.review_patches for op in rp.operations
|
| 544 |
+
]
|
| 545 |
+
|
| 546 |
if not ctx.accumulated_actions:
|
| 547 |
+
if not review_ops:
|
| 548 |
+
# Pure-speech turn: no canvas changes at all.
|
| 549 |
+
return None
|
| 550 |
+
# Review-only turn: build a minimal TurnResult with just the review ops.
|
| 551 |
+
record: SessionRecord = session_store[session_id]
|
| 552 |
+
record.patch_id += 1
|
| 553 |
+
patch = RendererPatch(
|
| 554 |
+
patch_id=record.patch_id,
|
| 555 |
+
is_snapshot=False,
|
| 556 |
+
scene_id=record.render_scene_id,
|
| 557 |
+
operations=review_ops,
|
| 558 |
+
)
|
| 559 |
+
return TurnResult(
|
| 560 |
+
renderer_patch=patch,
|
| 561 |
+
inspector_update=None,
|
| 562 |
+
assistant_message=assistant_message,
|
| 563 |
+
scene_patch=None,
|
| 564 |
+
status="success",
|
| 565 |
+
warnings=[],
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
record = session_store[session_id]
|
| 569 |
+
turn_result = apply_agent_actions(
|
| 570 |
record, ctx.accumulated_actions, selection, assistant_message
|
| 571 |
)
|
| 572 |
+
if not review_ops:
|
| 573 |
+
return turn_result
|
| 574 |
+
|
| 575 |
+
# Merge review ops into the returned patch (new patch_id, same scene/snapshot).
|
| 576 |
+
record.patch_id += 1
|
| 577 |
+
merged_patch = RendererPatch(
|
| 578 |
+
patch_id=record.patch_id,
|
| 579 |
+
is_snapshot=turn_result.renderer_patch.is_snapshot,
|
| 580 |
+
scene_id=turn_result.renderer_patch.scene_id,
|
| 581 |
+
operations=list(turn_result.renderer_patch.operations) + review_ops,
|
| 582 |
+
)
|
| 583 |
+
return TurnResult(
|
| 584 |
+
renderer_patch=merged_patch,
|
| 585 |
+
inspector_update=turn_result.inspector_update,
|
| 586 |
+
assistant_message=turn_result.assistant_message,
|
| 587 |
+
scene_patch=turn_result.scene_patch,
|
| 588 |
+
status=turn_result.status,
|
| 589 |
+
warnings=turn_result.warnings,
|
| 590 |
+
)
|
src/loosecanvas/contracts.py
CHANGED
|
@@ -215,7 +215,7 @@ class SuggestedPathway(BaseModel):
|
|
| 215 |
|
| 216 |
|
| 217 |
class SceneActionType(StrEnum):
|
| 218 |
-
"""The
|
| 219 |
|
| 220 |
# Visual actions (low-risk; no Claims created).
|
| 221 |
center_on = "center_on"
|
|
@@ -234,6 +234,7 @@ class SceneActionType(StrEnum):
|
|
| 234 |
remove_edge = "remove_edge" # uses SceneAction.edge_id
|
| 235 |
# User-edit actions (T2-03):
|
| 236 |
update_node = "update_node" # uses target_id + (label | note)
|
|
|
|
| 237 |
create_edge = "create_edge" # uses source_id + target_id + label
|
| 238 |
# Graph-growth action: the agent proposes a NEW concept node (model_inferred).
|
| 239 |
create_node = "create_node" # uses target_id (new id) + label + note (summary)
|
|
|
|
| 215 |
|
| 216 |
|
| 217 |
class SceneActionType(StrEnum):
|
| 218 |
+
"""The 16 ``SceneAction`` variants. Must serialize INLINE (no ``$ref``)."""
|
| 219 |
|
| 220 |
# Visual actions (low-risk; no Claims created).
|
| 221 |
center_on = "center_on"
|
|
|
|
| 234 |
remove_edge = "remove_edge" # uses SceneAction.edge_id
|
| 235 |
# User-edit actions (T2-03):
|
| 236 |
update_node = "update_node" # uses target_id + (label | note)
|
| 237 |
+
update_edge = "update_edge" # uses edge_id + label (relabel an existing edge)
|
| 238 |
create_edge = "create_edge" # uses source_id + target_id + label
|
| 239 |
# Graph-growth action: the agent proposes a NEW concept node (model_inferred).
|
| 240 |
create_node = "create_node" # uses target_id (new id) + label + note (summary)
|
src/loosecanvas/exporter.py
CHANGED
|
@@ -15,6 +15,16 @@ Trust filtering (guards against bug **R14** β user-confirmed inferences being
|
|
| 15 |
|
| 16 |
``open_question`` claims are routed to their own ``open_questions`` section, mapping
|
| 17 |
``Claim.text`` onto the output ``note`` key (``Claim`` has **no** ``note`` field).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
|
@@ -22,7 +32,7 @@ from __future__ import annotations
|
|
| 22 |
from datetime import UTC, datetime
|
| 23 |
from typing import Any
|
| 24 |
|
| 25 |
-
from loosecanvas.contracts import Claim, MapEvent, SuggestedPathway
|
| 26 |
from loosecanvas.graph_repository import LooseGraphRepository
|
| 27 |
|
| 28 |
SCHEMA_VERSION = "0.1.0"
|
|
@@ -46,6 +56,45 @@ def _include_claim(claim: Claim) -> bool:
|
|
| 46 |
return False
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
async def export_session(
|
| 50 |
graph: LooseGraphRepository,
|
| 51 |
*,
|
|
@@ -58,16 +107,41 @@ async def export_session(
|
|
| 58 |
The result is deterministic for a fixed input (aside from the ``created_at`` stamp)
|
| 59 |
and round-trips through ``json.dumps``/``json.loads`` unchanged.
|
| 60 |
"""
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
edges = [
|
| 63 |
-
edge.model_dump(mode="json", by_alias=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
]
|
| 65 |
|
| 66 |
claims: list[dict[str, Any]] = []
|
| 67 |
open_questions: list[dict[str, str]] = []
|
| 68 |
for claim in graph.claims.values():
|
| 69 |
if claim.claim_type == _OPEN_QUESTION_TYPE:
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
continue
|
| 72 |
if _include_claim(claim):
|
| 73 |
claims.append(claim.model_dump(mode="json"))
|
|
|
|
| 15 |
|
| 16 |
``open_question`` claims are routed to their own ``open_questions`` section, mapping
|
| 17 |
``Claim.text`` onto the output ``note`` key (``Claim`` has **no** ``note`` field).
|
| 18 |
+
|
| 19 |
+
Topology redaction (guards against the **trust-gate leak** β unreviewed model guesses
|
| 20 |
+
leaking as full topology): the same trust rule that filters claims also filters the
|
| 21 |
+
``nodes`` and ``edges`` arrays. Dropping the claim alone is not enough β an unreviewed
|
| 22 |
+
``model_inferred`` node (``create_node``) or edge (``find_connection``/``propose_hypothesis``)
|
| 23 |
+
would otherwise still ship its full shape. Element trust is derived from its governing
|
| 24 |
+
claims (claims whose ``target_id`` is the element id), falling back to the element's own
|
| 25 |
+
``properties["origin"]`` / ``metadata["origin"]`` only when it has **no** governing claim
|
| 26 |
+
(e.g. an element that is not yet claim-backed). After excluding nodes, any edge whose
|
| 27 |
+
source or target was dropped is cascade-removed.
|
| 28 |
"""
|
| 29 |
|
| 30 |
from __future__ import annotations
|
|
|
|
| 32 |
from datetime import UTC, datetime
|
| 33 |
from typing import Any
|
| 34 |
|
| 35 |
+
from loosecanvas.contracts import Claim, Edge, MapEvent, Node, SuggestedPathway
|
| 36 |
from loosecanvas.graph_repository import LooseGraphRepository
|
| 37 |
|
| 38 |
SCHEMA_VERSION = "0.1.0"
|
|
|
|
| 56 |
return False
|
| 57 |
|
| 58 |
|
| 59 |
+
def _include_origin(origin: str, *, has_accepting_review: bool) -> bool:
|
| 60 |
+
"""Trust decision for an element, mirroring :func:`_include_claim`.
|
| 61 |
+
|
| 62 |
+
``deterministic``/``user_asserted`` always pass; ``model_inferred`` passes only when
|
| 63 |
+
an accepting review exists. Any unknown origin fails closed.
|
| 64 |
+
"""
|
| 65 |
+
if origin in (_DETERMINISTIC_ORIGIN, _USER_ASSERTED_ORIGIN):
|
| 66 |
+
return True
|
| 67 |
+
if origin == _MODEL_INFERRED_ORIGIN:
|
| 68 |
+
return has_accepting_review
|
| 69 |
+
return False
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _element_origin(element: Node | Edge) -> str:
|
| 73 |
+
"""Best-effort origin for an element with no governing claim.
|
| 74 |
+
|
| 75 |
+
Producers disagree on where they stamp origin: ``_model_node``/``_hypothesis_edge``
|
| 76 |
+
use ``properties["origin"]`` while the seed fixture's nodes use ``metadata``. Check
|
| 77 |
+
both. Absent any stamp, treat as ``deterministic`` (extraction output is trusted).
|
| 78 |
+
"""
|
| 79 |
+
origin = element.properties.get("origin")
|
| 80 |
+
if origin is None and isinstance(element, Node):
|
| 81 |
+
origin = element.metadata.get("origin")
|
| 82 |
+
return origin if origin is not None else _DETERMINISTIC_ORIGIN
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _include_element(element: Node | Edge, governing: list[Claim]) -> bool:
|
| 86 |
+
"""Return ``True`` if ``element`` (a node or edge) is trustworthy enough to export.
|
| 87 |
+
|
| 88 |
+
With governing claims: pass if **any** governing claim passes :func:`_include_claim`
|
| 89 |
+
(an accepting review on one claim promotes the element). Without governing claims:
|
| 90 |
+
fall back to the element's own stamped origin.
|
| 91 |
+
"""
|
| 92 |
+
if governing:
|
| 93 |
+
return any(_include_claim(c) for c in governing)
|
| 94 |
+
origin = _element_origin(element)
|
| 95 |
+
return _include_origin(origin, has_accepting_review=False)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
async def export_session(
|
| 99 |
graph: LooseGraphRepository,
|
| 100 |
*,
|
|
|
|
| 107 |
The result is deterministic for a fixed input (aside from the ``created_at`` stamp)
|
| 108 |
and round-trips through ``json.dumps``/``json.loads`` unchanged.
|
| 109 |
"""
|
| 110 |
+
# Group governing claims by the id they target so element trust can aggregate them.
|
| 111 |
+
claims_by_target: dict[str, list[Claim]] = {}
|
| 112 |
+
for claim in graph.claims.values():
|
| 113 |
+
claims_by_target.setdefault(claim.target_id, []).append(claim)
|
| 114 |
+
|
| 115 |
+
# Topology redaction: drop unreviewed model inferences at the artifact level, then
|
| 116 |
+
# cascade-drop any edge whose endpoint was excluded.
|
| 117 |
+
kept_node_ids: set[str] = {
|
| 118 |
+
node.id
|
| 119 |
+
for node in graph.nodes.values()
|
| 120 |
+
if _include_element(node, claims_by_target.get(node.id, []))
|
| 121 |
+
}
|
| 122 |
+
nodes = [
|
| 123 |
+
node.model_dump(mode="json")
|
| 124 |
+
for node in graph.nodes.values()
|
| 125 |
+
if node.id in kept_node_ids
|
| 126 |
+
]
|
| 127 |
edges = [
|
| 128 |
+
edge.model_dump(mode="json", by_alias=True)
|
| 129 |
+
for edge in graph.edges.values()
|
| 130 |
+
if edge.source in kept_node_ids
|
| 131 |
+
and edge.target in kept_node_ids
|
| 132 |
+
and _include_element(edge, claims_by_target.get(edge.id, []))
|
| 133 |
]
|
| 134 |
|
| 135 |
claims: list[dict[str, Any]] = []
|
| 136 |
open_questions: list[dict[str, str]] = []
|
| 137 |
for claim in graph.claims.values():
|
| 138 |
if claim.claim_type == _OPEN_QUESTION_TYPE:
|
| 139 |
+
# Open questions are also trust-gated: an unreviewed model_inferred question
|
| 140 |
+
# (reducer._question_claim mints exactly that) must not leak.
|
| 141 |
+
if _include_claim(claim):
|
| 142 |
+
open_questions.append(
|
| 143 |
+
{"target_id": claim.target_id, "note": claim.text}
|
| 144 |
+
)
|
| 145 |
continue
|
| 146 |
if _include_claim(claim):
|
| 147 |
claims.append(claim.model_dump(mode="json"))
|
src/loosecanvas/extractors/fixture_adapter.py
CHANGED
|
@@ -38,9 +38,18 @@ def load_fixture_from_dict(data: dict[str, Any]) -> tuple[LooseGraph, SceneState
|
|
| 38 |
"""Validate an inline fixture ``dict`` into ``(LooseGraph, SceneState)``.
|
| 39 |
|
| 40 |
The explicit ``scene`` is read verbatim β no auto-curation (M12) is performed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
"""
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
return graph, scene
|
| 45 |
|
| 46 |
|
|
|
|
| 38 |
"""Validate an inline fixture ``dict`` into ``(LooseGraph, SceneState)``.
|
| 39 |
|
| 40 |
The explicit ``scene`` is read verbatim β no auto-curation (M12) is performed.
|
| 41 |
+
|
| 42 |
+
A corrupt/partial fixture (missing the top-level ``graph`` or ``scene`` key) raises
|
| 43 |
+
``ValueError`` β NOT a bare ``KeyError``. ``/api/load_graph`` maps ``ValueError`` to
|
| 44 |
+
HTTP 400 (a ``KeyError`` would surface as an opaque 500), and on_seed's resilience
|
| 45 |
+
handler treats it as a normal load failure and falls back instead of crashing.
|
| 46 |
"""
|
| 47 |
+
raw_graph = data.get("graph")
|
| 48 |
+
raw_scene = data.get("scene")
|
| 49 |
+
if raw_graph is None or raw_scene is None:
|
| 50 |
+
raise ValueError("fixture missing graph/scene")
|
| 51 |
+
graph = LooseGraph.model_validate(raw_graph)
|
| 52 |
+
scene = SceneState.model_validate(raw_scene)
|
| 53 |
return graph, scene
|
| 54 |
|
| 55 |
|
src/loosecanvas/extractors/text_graph_adapter.py
CHANGED
|
@@ -72,18 +72,34 @@ _ALLOWED_EDGE_KINDS: frozenset[str] = frozenset(
|
|
| 72 |
_SLUG_RE = re.compile(r"[^a-z0-9_]+")
|
| 73 |
|
| 74 |
_SYSTEM_PROMPT = """\
|
| 75 |
-
Read this text and
|
| 76 |
-
Return a JSON object only. No prose outside the JSON.
|
|
|
|
| 77 |
Schema:
|
| 78 |
{
|
| 79 |
"concept_nodes": [{"id": "concept::<snake_case>", "label": "<Name>",
|
| 80 |
"summary": "<1 sentence>", "start_char": <int>, "end_char": <int>}],
|
| 81 |
"semantic_edges": [{"source_id": "concept::<...>", "target_id": "concept::<...>",
|
| 82 |
"type": "explains|prerequisite_of|contradicts|related_to|mentions",
|
| 83 |
-
"label": "<
|
| 84 |
}
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
"""
|
| 88 |
|
| 89 |
_TEXT_GRAPH_SCHEMA: dict[str, Any] = {
|
|
@@ -114,7 +130,10 @@ _TEXT_GRAPH_SCHEMA: dict[str, Any] = {
|
|
| 114 |
"type": {"type": "string", "enum": sorted(_ALLOWED_EDGE_KINDS)},
|
| 115 |
"label": {"type": "string"},
|
| 116 |
},
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
| 118 |
"additionalProperties": False,
|
| 119 |
},
|
| 120 |
},
|
|
@@ -375,11 +394,11 @@ def _ingest_edge(
|
|
| 375 |
id=claim_id,
|
| 376 |
claim_type=_SEMANTIC_EDGE_CLAIM,
|
| 377 |
source_id=src,
|
| 378 |
-
target_id=
|
| 379 |
text=kind,
|
| 380 |
origin=_MODEL_INFERRED,
|
| 381 |
support_state=_UNVERIFIED,
|
| 382 |
-
evidence_refs=[_chunk_span_evidence(chunk, source_id,
|
| 383 |
)
|
| 384 |
|
| 385 |
|
|
|
|
| 72 |
_SLUG_RE = re.compile(r"[^a-z0-9_]+")
|
| 73 |
|
| 74 |
_SYSTEM_PROMPT = """\
|
| 75 |
+
Read this text and build a clear, genuinely useful concept map: the key ideas and how
|
| 76 |
+
they connect. Return a JSON object only. No prose outside the JSON.
|
| 77 |
+
|
| 78 |
Schema:
|
| 79 |
{
|
| 80 |
"concept_nodes": [{"id": "concept::<snake_case>", "label": "<Name>",
|
| 81 |
"summary": "<1 sentence>", "start_char": <int>, "end_char": <int>}],
|
| 82 |
"semantic_edges": [{"source_id": "concept::<...>", "target_id": "concept::<...>",
|
| 83 |
"type": "explains|prerequisite_of|contradicts|related_to|mentions",
|
| 84 |
+
"label": "<relationship phrase>"}]
|
| 85 |
}
|
| 86 |
+
|
| 87 |
+
Quality bar β write it for a human reader, not a parser:
|
| 88 |
+
- NODE label: the specific, recognizable name of the concept the way a person would say
|
| 89 |
+
it β a concrete noun phrase in Title Case ("Retrieval-Augmented Generation", "Local AI",
|
| 90 |
+
"llama.cpp"), preferring the exact term the text uses. Never a vague umbrella word, a
|
| 91 |
+
whole sentence, or a bare fragment. The id is that same name in snake_case.
|
| 92 |
+
- NODE summary: one plain, informative sentence saying what the concept actually is.
|
| 93 |
+
- EDGE label: a short, SPECIFIC phrase describing how source relates to target, written so
|
| 94 |
+
it reads naturally as "source <label> target" β e.g. "grounds answers in your documents",
|
| 95 |
+
"runs models offline through", "adapts a base model to your domain". Do NOT fall back on
|
| 96 |
+
empty connectors like "uses", "related to", "is part of", or the bare relationship type
|
| 97 |
+
when the text supports something more precise. Every edge needs a descriptive label.
|
| 98 |
+
- "type" is the relationship CATEGORY (it picks the arrow); "label" is the human-readable
|
| 99 |
+
detail. They are different β never just copy the type into the label.
|
| 100 |
+
|
| 101 |
+
start_char/end_char are character offsets into the provided text marking where the concept
|
| 102 |
+
is introduced. Every edge source_id/target_id must reference a concept id you listed.\
|
| 103 |
"""
|
| 104 |
|
| 105 |
_TEXT_GRAPH_SCHEMA: dict[str, Any] = {
|
|
|
|
| 130 |
"type": {"type": "string", "enum": sorted(_ALLOWED_EDGE_KINDS)},
|
| 131 |
"label": {"type": "string"},
|
| 132 |
},
|
| 133 |
+
# ``label`` is required so the grammar always emits a descriptive
|
| 134 |
+
# relationship phrase β a blank edge label renders as a bare line and
|
| 135 |
+
# is exactly the "generic/uninformative edges" problem we're fixing.
|
| 136 |
+
"required": ["source_id", "target_id", "type", "label"],
|
| 137 |
"additionalProperties": False,
|
| 138 |
},
|
| 139 |
},
|
|
|
|
| 394 |
id=claim_id,
|
| 395 |
claim_type=_SEMANTIC_EDGE_CLAIM,
|
| 396 |
source_id=src,
|
| 397 |
+
target_id=edge_id,
|
| 398 |
text=kind,
|
| 399 |
origin=_MODEL_INFERRED,
|
| 400 |
support_state=_UNVERIFIED,
|
| 401 |
+
evidence_refs=[_chunk_span_evidence(chunk, source_id, edge_id)],
|
| 402 |
)
|
| 403 |
|
| 404 |
|
src/loosecanvas/graph_repository.py
CHANGED
|
@@ -212,15 +212,29 @@ class LooseGraphRepository:
|
|
| 212 |
else patch.set_selected_id
|
| 213 |
)
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
return SceneState(
|
| 216 |
scene_id=scene.scene_id,
|
| 217 |
visible_node_ids=visible_nodes,
|
| 218 |
visible_edge_ids=visible_edges,
|
| 219 |
fogged_node_ids=fogged_nodes,
|
| 220 |
-
highlighted_ids=
|
| 221 |
selected_id=selected_id,
|
| 222 |
callouts=callouts,
|
| 223 |
node_positions=dict(scene.node_positions),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
)
|
| 225 |
|
| 226 |
# ββ Fog replenishment (own adjacency only; no NetworkX, no M12) βββββββββββ
|
|
|
|
| 212 |
else patch.set_selected_id
|
| 213 |
)
|
| 214 |
|
| 215 |
+
# ``set_highlighted_ids`` is "unset" when empty (the real producer, reducer.py,
|
| 216 |
+
# only assigns a NON-empty list β ``if highlighted:``). Mirror ``selected_id``:
|
| 217 |
+
# preserve the existing highlight when unset, override when explicitly set. Else a
|
| 218 |
+
# benign turn after a search would wipe the highlight (diff_scene β remove_class).
|
| 219 |
+
highlighted_ids = (
|
| 220 |
+
list(scene.highlighted_ids)
|
| 221 |
+
if not patch.set_highlighted_ids
|
| 222 |
+
else list(patch.set_highlighted_ids)
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
return SceneState(
|
| 226 |
scene_id=scene.scene_id,
|
| 227 |
visible_node_ids=visible_nodes,
|
| 228 |
visible_edge_ids=visible_edges,
|
| 229 |
fogged_node_ids=fogged_nodes,
|
| 230 |
+
highlighted_ids=highlighted_ids,
|
| 231 |
selected_id=selected_id,
|
| 232 |
callouts=callouts,
|
| 233 |
node_positions=dict(scene.node_positions),
|
| 234 |
+
# ``ScenePatch`` has no cluster slot β cluster colours live on the scene and
|
| 235 |
+
# are recomputed only by the 'Colour clusters' path; carry them forward so a
|
| 236 |
+
# later turn never drops them (diff_scene β remove_class cluster-N).
|
| 237 |
+
node_cluster_map=dict(scene.node_cluster_map),
|
| 238 |
)
|
| 239 |
|
| 240 |
# ββ Fog replenishment (own adjacency only; no NetworkX, no M12) βββββββββββ
|
src/loosecanvas/main.py
CHANGED
|
@@ -29,6 +29,7 @@ from loosecanvas.agent_harness import (
|
|
| 29 |
TokenEvent,
|
| 30 |
ToolActivityEvent,
|
| 31 |
run_agent_turn_streaming,
|
|
|
|
| 32 |
)
|
| 33 |
from loosecanvas.contracts import RendererCommand
|
| 34 |
from loosecanvas.llm_client import LLMClient
|
|
@@ -40,6 +41,7 @@ from loosecanvas.turn_logic import (
|
|
| 40 |
_commit_snapshot,
|
| 41 |
apply_user_edge,
|
| 42 |
apply_user_edit,
|
|
|
|
| 43 |
inspector_for_selection,
|
| 44 |
load_fixture_session,
|
| 45 |
load_repo_session,
|
|
@@ -49,8 +51,8 @@ from loosecanvas.turn_logic import (
|
|
| 49 |
push_focus,
|
| 50 |
redo_turn,
|
| 51 |
save_session,
|
|
|
|
| 52 |
session_store,
|
| 53 |
-
stream_text_session,
|
| 54 |
undo_turn,
|
| 55 |
)
|
| 56 |
|
|
@@ -59,7 +61,105 @@ _WORKSPACE_ROOT = Path(__file__).resolve().parents[2]
|
|
| 59 |
_FIXTURES: dict[str, Path] = {
|
| 60 |
"annotated_graph": _WORKSPACE_ROOT / "fixtures" / "annotated_graph.json",
|
| 61 |
"small_graph": _WORKSPACE_ROOT / "fixtures" / "small_graph.json",
|
|
|
|
| 62 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
# Public-Space safety: cap pasted/loaded text so one request can't monopolize the
|
| 65 |
# single (--parallel 1) GPU slot. ~100 KB is generous for an article paste; the
|
|
@@ -208,6 +308,21 @@ def _tool_receipt_messages(activity: list[str]) -> list[dict[str, Any]]:
|
|
| 208 |
return messages
|
| 209 |
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
async def _search_nodes(session_id: str, query: str, limit: int = 50) -> dict[str, Any]:
|
| 212 |
"""Shared fog-aware search core. Pure-read; never mutates session state."""
|
| 213 |
limit = max(0, min(limit, _SEARCH_LIMIT_CAP))
|
|
@@ -471,6 +586,13 @@ _CSS = """
|
|
| 471 |
padding: 0.5rem;
|
| 472 |
opacity: 0.7;
|
| 473 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
/* ββ P0-03: CSS-grid layout shell ββββββββββββββββββββββββββββββββββββ */
|
| 475 |
/* Expand the Gradio app container to full viewport width */
|
| 476 |
.gradio-container {
|
|
@@ -485,17 +607,20 @@ _CSS = """
|
|
| 485 |
/* Gradio wraps custom components in a <span> β that span is the actual flex item,
|
| 486 |
not #lc-canvas itself. Target it directly to defeat the 25%/75% default split. */
|
| 487 |
#lc-canvas-row > span {
|
| 488 |
-
flex: 1 1
|
| 489 |
min-width: 0 !important;
|
| 490 |
}
|
| 491 |
-
/*
|
| 492 |
-
|
|
|
|
| 493 |
#lc-inspector-col {
|
| 494 |
-
flex: 0 0
|
| 495 |
min-width: 0 !important;
|
| 496 |
overflow-y: auto;
|
| 497 |
max-height: 540px;
|
| 498 |
}
|
|
|
|
|
|
|
| 499 |
/* Cap the inspector JSON so the chat below it stays on screen; it scrolls if a node
|
| 500 |
has many claims. */
|
| 501 |
#lc-inspector { max-height: 150px; overflow: auto; }
|
|
@@ -565,8 +690,11 @@ _HEADER_HTML = """
|
|
| 565 |
|
| 566 |
# Trust legend. The SVG swatches MUST match renderer.ts's originβshape rules
|
| 567 |
# (round-rectangle/solid = deterministic, ellipse/solid-green = user-asserted,
|
| 568 |
-
# hexagon/dashed = model-inferred, +
|
| 569 |
-
# encoding is decodable at a glance instead of only on hover.
|
|
|
|
|
|
|
|
|
|
| 570 |
_LEGEND_HTML = """
|
| 571 |
<div class="lc-legend" role="note" aria-label="Trust legend">
|
| 572 |
<span class="lc-legend-title">Trust map</span>
|
|
@@ -594,8 +722,7 @@ _LEGEND_HTML = """
|
|
| 594 |
<span class="lc-legend-item">
|
| 595 |
<svg class="lc-legend-svg" width="26" height="22" aria-hidden="true">
|
| 596 |
<polygon points="13,2 22,7 22,15 13,20 4,15 4,7"
|
| 597 |
-
fill="#3b5bdb" stroke="#
|
| 598 |
-
<circle cx="21" cy="4" r="3.6" fill="#f59e0b"/>
|
| 599 |
</svg>
|
| 600 |
Awaiting your review
|
| 601 |
</span>
|
|
@@ -651,6 +778,20 @@ async def on_search(q: str, sid: str) -> tuple[dict[str, Any], list[dict[str, An
|
|
| 651 |
)
|
| 652 |
)
|
| 653 |
record.scene.highlighted_ids = vis_ids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
|
| 655 |
record.patch_id += 1
|
| 656 |
patch2 = RendererPatch(
|
|
@@ -784,28 +925,10 @@ def build_app() -> tuple[Any, Any]:
|
|
| 784 |
session_id = gr.State()
|
| 785 |
current_selection = gr.State({"kind": "none", "id": ""})
|
| 786 |
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
label="Fixture",
|
| 792 |
-
scale=3,
|
| 793 |
-
)
|
| 794 |
-
load_btn = gr.Button("Load graph", scale=1)
|
| 795 |
-
|
| 796 |
-
with gr.Accordion("Build from text (Gemma)", open=False):
|
| 797 |
-
text_title = gr.Textbox(
|
| 798 |
-
label="Title",
|
| 799 |
-
placeholder="e.g. Gradient Descent",
|
| 800 |
-
value="Pasted text",
|
| 801 |
-
)
|
| 802 |
-
text_content = gr.Textbox(
|
| 803 |
-
label="Content",
|
| 804 |
-
placeholder="Paste prose, notes, or an article β Gemma extracts a "
|
| 805 |
-
"concept graph (requires the LLM server).",
|
| 806 |
-
lines=8,
|
| 807 |
-
)
|
| 808 |
-
build_btn = gr.Button("Build graph from text", variant="primary")
|
| 809 |
|
| 810 |
# P0-03 + whitespace reclaim: canvas (left) shares one row with a right rail
|
| 811 |
# holding the inspector + chat, so the canvas and the conversation are on
|
|
@@ -813,15 +936,17 @@ def build_app() -> tuple[Any, Any]:
|
|
| 813 |
# dead band. CSS flex gives canvas ~64% / rail ~34% (defeating Gradio's
|
| 814 |
# min-width collapse); Index.svelte's ResizeObserver re-fits the canvas.
|
| 815 |
onboarding = gr.Markdown(
|
| 816 |
-
"**loosecanvas** β
|
| 817 |
-
"
|
|
|
|
| 818 |
visible=True,
|
| 819 |
elem_classes=["lc-onboarding"],
|
| 820 |
)
|
| 821 |
gr.HTML(_LEGEND_HTML)
|
|
|
|
| 822 |
with gr.Row(elem_id="lc-canvas-row"):
|
| 823 |
canvas = CytoscapeCanvas(elem_id="lc-canvas")
|
| 824 |
-
with gr.Column(scale=
|
| 825 |
inspector = gr.JSON(label="Inspector", elem_id="lc-inspector")
|
| 826 |
gr.Markdown(
|
| 827 |
"*Select a node to see where each claim came from and whether "
|
|
@@ -834,25 +959,46 @@ def build_app() -> tuple[Any, Any]:
|
|
| 834 |
reject_btn = gr.Button("β Reject", variant="secondary", scale=1)
|
| 835 |
dispute_btn = gr.Button("? Dispute", variant="secondary", scale=1)
|
| 836 |
claim_review_status = gr.Markdown("", visible=False)
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 850 |
status_md = gr.Markdown(value="", elem_classes=["lc-turn-status"])
|
|
|
|
|
|
|
|
|
|
| 851 |
with gr.Row():
|
| 852 |
-
undo_btn = gr.Button("β© Undo", scale=1)
|
| 853 |
-
redo_btn = gr.Button("βͺ Redo", scale=1)
|
| 854 |
cluster_btn = gr.Button("π¨ Colour clusters", scale=1)
|
| 855 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 856 |
search_box = gr.Textbox(
|
| 857 |
placeholder="Search graph nodesβ¦",
|
| 858 |
scale=9,
|
|
@@ -869,98 +1015,129 @@ def build_app() -> tuple[Any, Any]:
|
|
| 869 |
|
| 870 |
# ββ Handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 871 |
|
| 872 |
-
async def
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
"",
|
| 905 |
-
[
|
| 906 |
-
{
|
| 907 |
-
"role": "assistant",
|
| 908 |
-
"content": (
|
| 909 |
-
f"β οΈ That text is too long (>{_MAX_TEXT_LEN:,} chars). "
|
| 910 |
-
"Please shorten it and try again."
|
| 911 |
-
),
|
| 912 |
-
}
|
| 913 |
-
],
|
| 914 |
-
gr.update(),
|
| 915 |
-
"",
|
| 916 |
-
gr.update(),
|
| 917 |
-
)
|
| 918 |
-
return
|
| 919 |
-
async for prog in stream_text_session(
|
| 920 |
-
content, title or "Pasted text", LLMClient()
|
| 921 |
-
):
|
| 922 |
-
if prog.is_partial:
|
| 923 |
yield (
|
| 924 |
gr.update(),
|
| 925 |
gr.update(),
|
| 926 |
gr.update(),
|
| 927 |
gr.update(),
|
| 928 |
-
|
| 929 |
gr.update(),
|
| 930 |
-
|
| 931 |
-
elif prog.is_error:
|
| 932 |
-
yield (
|
| 933 |
-
gr.update(),
|
| 934 |
-
gr.update(),
|
| 935 |
-
prog.chat_message,
|
| 936 |
-
gr.update(),
|
| 937 |
-
"",
|
| 938 |
gr.update(),
|
| 939 |
)
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
prog.canvas_patch,
|
| 943 |
-
prog.session_id,
|
| 944 |
-
prog.chat_message,
|
| 945 |
-
gr.update(visible=False),
|
| 946 |
-
"",
|
| 947 |
-
gr.update(value=""), # A4: clear the source textarea
|
| 948 |
-
)
|
| 949 |
-
|
| 950 |
-
async def on_turn(
|
| 951 |
-
message: str, sid: str, selection: dict[str, Any], history: list[Any]
|
| 952 |
-
) -> AsyncGenerator[Any]:
|
| 953 |
-
# Outputs: [canvas, inspector, chatbot, msg, status_md, send_btn].
|
| 954 |
-
if not sid:
|
| 955 |
-
# A3: preserve the draft β warn in status, don't commit to history.
|
| 956 |
yield (
|
| 957 |
gr.update(),
|
| 958 |
gr.update(),
|
| 959 |
-
|
| 960 |
-
gr.update(),
|
| 961 |
-
"
|
|
|
|
|
|
|
| 962 |
gr.update(),
|
| 963 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 964 |
return
|
| 965 |
base_history = list(history) + [{"role": "user", "content": message}]
|
| 966 |
# A2: disable the composer + Send for the turn; keep the text visible.
|
|
@@ -971,15 +1148,27 @@ def build_app() -> tuple[Any, Any]:
|
|
| 971 |
gr.update(interactive=False),
|
| 972 |
"Thinkingβ¦",
|
| 973 |
gr.update(interactive=False),
|
|
|
|
|
|
|
| 974 |
)
|
| 975 |
reply = ""
|
|
|
|
| 976 |
try:
|
| 977 |
async for ev in run_agent_turn_streaming(sid, selection, message):
|
| 978 |
if isinstance(ev, TokenEvent):
|
| 979 |
reply += ev.text
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 983 |
yield (
|
| 984 |
gr.update(),
|
| 985 |
gr.update(),
|
|
@@ -987,15 +1176,23 @@ def build_app() -> tuple[Any, Any]:
|
|
| 987 |
gr.update(),
|
| 988 |
gr.update(),
|
| 989 |
gr.update(),
|
|
|
|
|
|
|
| 990 |
)
|
| 991 |
elif isinstance(ev, ToolActivityEvent):
|
|
|
|
|
|
|
|
|
|
| 992 |
yield (
|
| 993 |
gr.update(),
|
| 994 |
gr.update(),
|
| 995 |
-
|
|
|
|
| 996 |
gr.update(),
|
| 997 |
ev.message,
|
| 998 |
gr.update(),
|
|
|
|
|
|
|
| 999 |
)
|
| 1000 |
elif isinstance(ev, FinalEvent):
|
| 1001 |
reply = ev.assistant_message or reply
|
|
@@ -1005,6 +1202,8 @@ def build_app() -> tuple[Any, Any]:
|
|
| 1005 |
prefix = f"{reply}\n\n" if reply else ""
|
| 1006 |
reply = f"{prefix}β οΈ Some actions were rejected: {reasons}"
|
| 1007 |
tool_receipts = _tool_receipt_messages(ev.tool_activity)
|
|
|
|
|
|
|
| 1008 |
final_history = list(base_history)
|
| 1009 |
if reply:
|
| 1010 |
final_history.append(
|
|
@@ -1032,6 +1231,8 @@ def build_app() -> tuple[Any, Any]:
|
|
| 1032 |
gr.update(value="", interactive=True),
|
| 1033 |
"",
|
| 1034 |
gr.update(interactive=True),
|
|
|
|
|
|
|
| 1035 |
)
|
| 1036 |
except Exception as exc: # noqa: BLE001
|
| 1037 |
error_history = base_history + [
|
|
@@ -1045,6 +1246,8 @@ def build_app() -> tuple[Any, Any]:
|
|
| 1045 |
gr.update(interactive=True),
|
| 1046 |
"",
|
| 1047 |
gr.update(interactive=True),
|
|
|
|
|
|
|
| 1048 |
)
|
| 1049 |
|
| 1050 |
async def on_select(
|
|
@@ -1204,6 +1407,126 @@ def build_app() -> tuple[Any, Any]:
|
|
| 1204 |
pass
|
| 1205 |
return {"kind": "none", "id": ""}, None, {}, gr.update(visible=False)
|
| 1206 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1207 |
# Normal selection
|
| 1208 |
selection: dict[str, Any] = {"kind": kind, "id": str(raw.get("id", ""))}
|
| 1209 |
# T3-03: update focus history for node selections
|
|
@@ -1223,41 +1546,39 @@ def build_app() -> tuple[Any, Any]:
|
|
| 1223 |
# real class object so the event-data parameter is detected.
|
| 1224 |
on_select.__annotations__["evt"] = gr.SelectData
|
| 1225 |
|
| 1226 |
-
#
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
)
|
| 1232 |
-
|
| 1233 |
-
# Deploy-only first-paint seed. Default OFF preserves the P0-01 blank-slate
|
| 1234 |
-
# workspace; the public HF Space sets LOOSECANVAS_DEMO_SEED=1 so a visitor
|
| 1235 |
-
# lands on a populated canvas (instant-magic) instead of an empty one. This
|
| 1236 |
-
# is a narrowly-scoped, opt-in deviation from P0-01 for the demo deployment.
|
| 1237 |
-
if os.environ.get("LOOSECANVAS_DEMO_SEED", "0") == "1":
|
| 1238 |
-
demo.load(
|
| 1239 |
-
on_load_graph,
|
| 1240 |
-
inputs=[fixture_dropdown],
|
| 1241 |
-
outputs=[canvas, session_id, onboarding],
|
| 1242 |
-
)
|
| 1243 |
-
|
| 1244 |
-
# Build a graph from pasted text via Gemma (M04b adapter).
|
| 1245 |
-
build_btn.click(
|
| 1246 |
-
on_build_text,
|
| 1247 |
-
inputs=[text_content, text_title],
|
| 1248 |
-
outputs=[canvas, session_id, chatbot, onboarding, status_md, text_content],
|
| 1249 |
-
)
|
| 1250 |
|
| 1251 |
turn_inputs = [msg, session_id, current_selection, chatbot]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1252 |
turn_outputs = [canvas, inspector, chatbot, msg, status_md, send_btn]
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1256 |
async def on_undo(
|
| 1257 |
sid: str, history: list[Any]
|
| 1258 |
-
) -> tuple[Any, Any, list[Any], str, str]:
|
| 1259 |
if not sid:
|
| 1260 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1261 |
result = await undo_turn(sid)
|
| 1262 |
return (
|
| 1263 |
result.renderer_patch.model_dump(mode="json"),
|
|
@@ -1265,13 +1586,21 @@ def build_app() -> tuple[Any, Any]:
|
|
| 1265 |
history,
|
| 1266 |
"",
|
| 1267 |
result.status,
|
|
|
|
| 1268 |
)
|
| 1269 |
|
| 1270 |
async def on_redo(
|
| 1271 |
sid: str, history: list[Any]
|
| 1272 |
-
) -> tuple[Any, Any, list[Any], str, str]:
|
| 1273 |
if not sid:
|
| 1274 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1275 |
result = await redo_turn(sid)
|
| 1276 |
return (
|
| 1277 |
result.renderer_patch.model_dump(mode="json"),
|
|
@@ -1279,6 +1608,7 @@ def build_app() -> tuple[Any, Any]:
|
|
| 1279 |
history,
|
| 1280 |
"",
|
| 1281 |
result.status,
|
|
|
|
| 1282 |
)
|
| 1283 |
|
| 1284 |
undo_btn.click(on_undo, inputs=[session_id, chatbot], outputs=turn_outputs)
|
|
@@ -1296,18 +1626,64 @@ def build_app() -> tuple[Any, Any]:
|
|
| 1296 |
|
| 1297 |
cluster_btn.click(on_cluster, inputs=[session_id], outputs=[canvas])
|
| 1298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1299 |
async def on_back(
|
| 1300 |
sid: str, history: list[Any]
|
| 1301 |
-
) -> tuple[dict[str, Any] | None, Any, list[Any], str, str, list[Any]]:
|
| 1302 |
"""T3-03: navigate back in focus history via pop_focus."""
|
| 1303 |
if not sid:
|
| 1304 |
-
return None, None, history, "", "No session", []
|
| 1305 |
record = session_store.get(sid)
|
| 1306 |
if record is None:
|
| 1307 |
-
return None, None, history, "", "No session", []
|
| 1308 |
prev = pop_focus(record)
|
| 1309 |
if prev is None:
|
| 1310 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1311 |
record.patch_id += 1
|
| 1312 |
patch = RendererPatch(
|
| 1313 |
patch_id=record.patch_id,
|
|
@@ -1321,7 +1697,15 @@ def build_app() -> tuple[Any, Any]:
|
|
| 1321 |
],
|
| 1322 |
)
|
| 1323 |
insp = inspector_for_selection(sid, {"kind": "node", "id": prev})
|
| 1324 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1325 |
|
| 1326 |
search_box.submit(
|
| 1327 |
on_search,
|
|
|
|
| 29 |
TokenEvent,
|
| 30 |
ToolActivityEvent,
|
| 31 |
run_agent_turn_streaming,
|
| 32 |
+
stream_build_opener,
|
| 33 |
)
|
| 34 |
from loosecanvas.contracts import RendererCommand
|
| 35 |
from loosecanvas.llm_client import LLMClient
|
|
|
|
| 41 |
_commit_snapshot,
|
| 42 |
apply_user_edge,
|
| 43 |
apply_user_edit,
|
| 44 |
+
find_connection_session,
|
| 45 |
inspector_for_selection,
|
| 46 |
load_fixture_session,
|
| 47 |
load_repo_session,
|
|
|
|
| 51 |
push_focus,
|
| 52 |
redo_turn,
|
| 53 |
save_session,
|
| 54 |
+
session_locks,
|
| 55 |
session_store,
|
|
|
|
| 56 |
undo_turn,
|
| 57 |
)
|
| 58 |
|
|
|
|
| 61 |
_FIXTURES: dict[str, Path] = {
|
| 62 |
"annotated_graph": _WORKSPACE_ROOT / "fixtures" / "annotated_graph.json",
|
| 63 |
"small_graph": _WORKSPACE_ROOT / "fixtures" / "small_graph.json",
|
| 64 |
+
"seed_local_ai": _WORKSPACE_ROOT / "fixtures" / "seed_local_ai.json",
|
| 65 |
}
|
| 66 |
+
# Open-world entry seeds this graph on page load (zero-LLM sync path) so a cold
|
| 67 |
+
# model never paints an empty canvas. on_seed falls back to annotated_graph until
|
| 68 |
+
# the baked hackathon-meta seed (seed_local_ai.json) exists, so this is safe to
|
| 69 |
+
# point at the baked seed before the file lands β it upgrades on the next restart.
|
| 70 |
+
_SEED_FIXTURE_NAME = "seed_local_ai"
|
| 71 |
+
|
| 72 |
+
# Open-world entry: the composer is PREFILLED with this editable seed text and the canvas
|
| 73 |
+
# starts empty. The first Send builds a graph FROM this text (on_turn's no-session branch
|
| 74 |
+
# runs Gemma extraction) β edit it and you get a different map; the non-determinism is the
|
| 75 |
+
# point. Kept short enough to extract fast and rich enough to yield a few linked concepts.
|
| 76 |
+
_SEED_TEXT = (
|
| 77 |
+
"Local AI puts the whole stack on your own machine: open-weight models like Gemma "
|
| 78 |
+
"run offline through llama.cpp, so your data never leaves the room. "
|
| 79 |
+
"Retrieval-augmented generation grounds answers in your own documents, agents call "
|
| 80 |
+
"tools to act on them, and fine-tuning adapts a base model to your domain. "
|
| 81 |
+
"The thread tying them together is sovereignty: you own the model, the data, and "
|
| 82 |
+
"the meaning you build from them."
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# Friendly onboarding copy shown when seeding can't paint a starter map (e.g. every
|
| 86 |
+
# fixture is missing/corrupt). NEVER leaks 'session_cap' or a stack trace, and never
|
| 87 |
+
# leaves the "Building your starter mapβ¦" banner stuck over an empty canvas.
|
| 88 |
+
_SEED_FALLBACK_MESSAGE = (
|
| 89 |
+
"**loosecanvas** β the local AI proposes, you decide. "
|
| 90 |
+
"Your starter map isn't available right now, but you can begin: "
|
| 91 |
+
"tell me what you're trying to map and I'll start sketching it with you."
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _evict_lru_session() -> bool:
|
| 96 |
+
"""Evict the single least-recently-seen session (smallest ``last_seen``).
|
| 97 |
+
|
| 98 |
+
Drops the record AND its paired lock together (mirrors ``_evict_stale_sessions``).
|
| 99 |
+
Returns ``True`` if a session was evicted, ``False`` if the store was already empty.
|
| 100 |
+
|
| 101 |
+
The alternate loaders (``load_fixture_session`` et al.) raise
|
| 102 |
+
``OverflowError("session_cap")`` at capacity. The existing eviction is TTL-only, so
|
| 103 |
+
a store full of FRESH sessions hard-rejects a new visitor β bricking the open-world
|
| 104 |
+
seed-on-load entry. Evicting the LRU session here makes room so a fresh visitor
|
| 105 |
+
ALWAYS gets a seeded canvas (mirrors a classic LRU cache under pressure).
|
| 106 |
+
"""
|
| 107 |
+
if not session_store:
|
| 108 |
+
return False
|
| 109 |
+
victim = min(session_store, key=lambda sid: session_store[sid].last_seen)
|
| 110 |
+
session_store.pop(victim, None)
|
| 111 |
+
session_locks.pop(victim, None)
|
| 112 |
+
return True
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
async def _seed_snapshot() -> tuple[Any, str, str]:
|
| 116 |
+
"""Resolve the seed-on-load snapshot resiliently. Returns
|
| 117 |
+
``(renderer_value, session_id, onboarding_message)``.
|
| 118 |
+
|
| 119 |
+
Resilience contract (adversarial-QA hardened):
|
| 120 |
+
|
| 121 |
+
* **Session cap** β on ``OverflowError("session_cap")`` evict the LRU session and
|
| 122 |
+
retry, so a busy HF Space always seeds a fresh visitor instead of leaving an empty
|
| 123 |
+
canvas + a leaked 'session_cap' toast.
|
| 124 |
+
* **Missing/corrupt seed** β fall back from the configured seed fixture to the
|
| 125 |
+
shipped ``annotated_graph`` on ANY load failure (not only ``not path.exists()``;
|
| 126 |
+
a partial/corrupt JSON now raises ``ValueError`` rather than ``KeyError``).
|
| 127 |
+
* **Total failure** β if nothing loads, return an empty-but-valid ``RendererPatch``
|
| 128 |
+
snapshot + a FRIENDLY onboarding message; never crash, never leak internals, never
|
| 129 |
+
leave the "Buildingβ¦" banner stuck over a blank canvas.
|
| 130 |
+
"""
|
| 131 |
+
# Candidate fixtures in preference order, de-duped (seed may equal the fallback).
|
| 132 |
+
candidates: list[Path] = []
|
| 133 |
+
for name in (_SEED_FIXTURE_NAME, "annotated_graph"):
|
| 134 |
+
path = _FIXTURES.get(name)
|
| 135 |
+
if path is not None and path not in candidates:
|
| 136 |
+
candidates.append(path)
|
| 137 |
+
|
| 138 |
+
for path in candidates:
|
| 139 |
+
if not path.exists():
|
| 140 |
+
continue
|
| 141 |
+
# One retry budget per fixture for the cap: evict LRU, then try again.
|
| 142 |
+
for attempt in range(2):
|
| 143 |
+
try:
|
| 144 |
+
sid, snapshot = await load_fixture_session(fixture_path=path)
|
| 145 |
+
return snapshot.model_dump(), sid, ""
|
| 146 |
+
except OverflowError:
|
| 147 |
+
# Session cap: make room and retry once. If nothing could be evicted
|
| 148 |
+
# (store somehow empty), stop retrying this fixture.
|
| 149 |
+
if attempt == 0 and _evict_lru_session():
|
| 150 |
+
continue
|
| 151 |
+
break
|
| 152 |
+
except Exception: # corrupt/partial fixture, path error, etc.
|
| 153 |
+
logger.exception("seed-on-load failed for fixture %s", path)
|
| 154 |
+
break # try the next candidate fixture
|
| 155 |
+
|
| 156 |
+
# Nothing loaded: empty-but-valid snapshot + friendly onboarding (no leaked cap).
|
| 157 |
+
logger.warning("seed-on-load exhausted all fixtures; serving empty starter canvas")
|
| 158 |
+
empty = RendererPatch(
|
| 159 |
+
patch_id=0, is_snapshot=True, scene_id="empty-seed", operations=[]
|
| 160 |
+
)
|
| 161 |
+
return empty.model_dump(), "", _SEED_FALLBACK_MESSAGE
|
| 162 |
+
|
| 163 |
|
| 164 |
# Public-Space safety: cap pasted/loaded text so one request can't monopolize the
|
| 165 |
# single (--parallel 1) GPU slot. ~100 KB is generous for an article paste; the
|
|
|
|
| 308 |
return messages
|
| 309 |
|
| 310 |
|
| 311 |
+
def _tool_steplist_message(steps: list[str], status: str) -> dict[str, Any]:
|
| 312 |
+
"""Return a Gradio collapsible thought-panel message showing live tool steps.
|
| 313 |
+
|
| 314 |
+
``steps`` is the ordered list of friendly tool-activity labels accumulated so far.
|
| 315 |
+
``status`` is ``"pending"`` (spinner, panel open mid-turn) or ``"done"`` (closed).
|
| 316 |
+
An empty ``steps`` list produces a placeholder bullet so the panel is never blank.
|
| 317 |
+
"""
|
| 318 |
+
lines = [f"- {s}" for s in steps] if steps else ["- Workingβ¦"]
|
| 319 |
+
return {
|
| 320 |
+
"role": "assistant",
|
| 321 |
+
"content": "\n".join(lines),
|
| 322 |
+
"metadata": {"title": "Workingβ¦", "status": status},
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
|
| 326 |
async def _search_nodes(session_id: str, query: str, limit: int = 50) -> dict[str, Any]:
|
| 327 |
"""Shared fog-aware search core. Pure-read; never mutates session state."""
|
| 328 |
limit = max(0, min(limit, _SEARCH_LIMIT_CAP))
|
|
|
|
| 586 |
padding: 0.5rem;
|
| 587 |
opacity: 0.7;
|
| 588 |
}
|
| 589 |
+
/* Consequence copy under the Export button: reads as quiet helper text, not a heading. */
|
| 590 |
+
.lc-export-note {
|
| 591 |
+
text-align: center;
|
| 592 |
+
opacity: 0.6;
|
| 593 |
+
font-size: 0.8rem;
|
| 594 |
+
margin-top: 0.15rem;
|
| 595 |
+
}
|
| 596 |
/* ββ P0-03: CSS-grid layout shell ββββββββββββββββββββββββββββββββββββ */
|
| 597 |
/* Expand the Gradio app container to full viewport width */
|
| 598 |
.gradio-container {
|
|
|
|
| 607 |
/* Gradio wraps custom components in a <span> β that span is the actual flex item,
|
| 608 |
not #lc-canvas itself. Target it directly to defeat the 25%/75% default split. */
|
| 609 |
#lc-canvas-row > span {
|
| 610 |
+
flex: 1 1 100% !important;
|
| 611 |
min-width: 0 !important;
|
| 612 |
}
|
| 613 |
+
/* Slim inspector rail (the LIGHT path β chat is now a co-equal hero below the canvas,
|
| 614 |
+
not in this rail). Height is a FIXED px value, never vh β see the canvas rule below
|
| 615 |
+
for why. overflow-y lets the rail scroll if its content is tall. */
|
| 616 |
#lc-inspector-col {
|
| 617 |
+
flex: 0 0 26% !important;
|
| 618 |
min-width: 0 !important;
|
| 619 |
overflow-y: auto;
|
| 620 |
max-height: 540px;
|
| 621 |
}
|
| 622 |
+
/* Chat + composer are full-width heroes under the canvas. */
|
| 623 |
+
#lc-chat-row, #lc-composer-row { width: 100%; }
|
| 624 |
/* Cap the inspector JSON so the chat below it stays on screen; it scrolls if a node
|
| 625 |
has many claims. */
|
| 626 |
#lc-inspector { max-height: 150px; overflow: auto; }
|
|
|
|
| 690 |
|
| 691 |
# Trust legend. The SVG swatches MUST match renderer.ts's originβshape rules
|
| 692 |
# (round-rectangle/solid = deterministic, ellipse/solid-green = user-asserted,
|
| 693 |
+
# hexagon/dashed-indigo = model-inferred, + AMBER dashed border = awaiting-review)
|
| 694 |
+
# so the on-canvas encoding is decodable at a glance instead of only on hover.
|
| 695 |
+
# The awaiting-review swatch mirrors the .review-pending.origin-model-inferred rule
|
| 696 |
+
# (amber dashed border + gentle pulse) β NOT a corner dot (Cytoscape pies centre under
|
| 697 |
+
# the label and were invisible); keep this swatch in sync with that stylesheet rule.
|
| 698 |
_LEGEND_HTML = """
|
| 699 |
<div class="lc-legend" role="note" aria-label="Trust legend">
|
| 700 |
<span class="lc-legend-title">Trust map</span>
|
|
|
|
| 722 |
<span class="lc-legend-item">
|
| 723 |
<svg class="lc-legend-svg" width="26" height="22" aria-hidden="true">
|
| 724 |
<polygon points="13,2 22,7 22,15 13,20 4,15 4,7"
|
| 725 |
+
fill="#3b5bdb" stroke="#f59e0b" stroke-width="2.5" stroke-dasharray="3 2"/>
|
|
|
|
| 726 |
</svg>
|
| 727 |
Awaiting your review
|
| 728 |
</span>
|
|
|
|
| 778 |
)
|
| 779 |
)
|
| 780 |
record.scene.highlighted_ids = vis_ids
|
| 781 |
+
else:
|
| 782 |
+
# No visible matches β clear any PRIOR search dim/highlight so a stale match
|
| 783 |
+
# from a previous query doesn't stay lit (QA: 'Ret' then a no-match term left
|
| 784 |
+
# the old highlight + dim stuck). Mirrors the empty-query reset above.
|
| 785 |
+
ops2.append(RendererCommand(op="clear_dim"))
|
| 786 |
+
for nid in record.scene.highlighted_ids:
|
| 787 |
+
ops2.append(
|
| 788 |
+
RendererCommand(
|
| 789 |
+
op="remove_class",
|
| 790 |
+
selector=f"#{nid}",
|
| 791 |
+
data={"class": "highlighted"},
|
| 792 |
+
)
|
| 793 |
+
)
|
| 794 |
+
record.scene.highlighted_ids = []
|
| 795 |
|
| 796 |
record.patch_id += 1
|
| 797 |
patch2 = RendererPatch(
|
|
|
|
| 925 |
session_id = gr.State()
|
| 926 |
current_selection = gr.State({"kind": "none", "id": ""})
|
| 927 |
|
| 928 |
+
# Open-world entry: no fixture picker, no "build from text" accordion. The
|
| 929 |
+
# canvas is seeded on load (demo.load -> on_seed) and the user shapes it by
|
| 930 |
+
# chatting β "chat in, spatial review out". (The text->graph path still lives
|
| 931 |
+
# in turn_logic.stream_text_session + the /api build route; just not in the UI.)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
|
| 933 |
# P0-03 + whitespace reclaim: canvas (left) shares one row with a right rail
|
| 934 |
# holding the inspector + chat, so the canvas and the conversation are on
|
|
|
|
| 936 |
# dead band. CSS flex gives canvas ~64% / rail ~34% (defeating Gradio's
|
| 937 |
# min-width collapse); Index.svelte's ResizeObserver re-fits the canvas.
|
| 938 |
onboarding = gr.Markdown(
|
| 939 |
+
"**loosecanvas** β the local AI proposes, you decide. "
|
| 940 |
+
"βοΈ The text below is a starting point β edit it however you like, then press "
|
| 941 |
+
"**Send** to grow a knowledge graph from it. Nothing is built until you do.",
|
| 942 |
visible=True,
|
| 943 |
elem_classes=["lc-onboarding"],
|
| 944 |
)
|
| 945 |
gr.HTML(_LEGEND_HTML)
|
| 946 |
+
# The graph is the hero; the inspector is a slim LIGHT-path rail beside it.
|
| 947 |
with gr.Row(elem_id="lc-canvas-row"):
|
| 948 |
canvas = CytoscapeCanvas(elem_id="lc-canvas")
|
| 949 |
+
with gr.Column(scale=1, elem_id="lc-inspector-col", visible=False):
|
| 950 |
inspector = gr.JSON(label="Inspector", elem_id="lc-inspector")
|
| 951 |
gr.Markdown(
|
| 952 |
"*Select a node to see where each claim came from and whether "
|
|
|
|
| 959 |
reject_btn = gr.Button("β Reject", variant="secondary", scale=1)
|
| 960 |
dispute_btn = gr.Button("? Dispute", variant="secondary", scale=1)
|
| 961 |
claim_review_status = gr.Markdown("", visible=False)
|
| 962 |
+
# Chat is the second hero: a full-width row under the canvas (not tucked in the
|
| 963 |
+
# inspector rail), so "chat in, spatial review out" reads at a glance. The
|
| 964 |
+
# composer is PREFILLED with editable seed text β the first Send builds the map.
|
| 965 |
+
with gr.Row(elem_id="lc-chat-row"):
|
| 966 |
+
chatbot = gr.Chatbot(label="Assistant", height=360, elem_id="lc-chat")
|
| 967 |
+
with gr.Row(elem_id="lc-composer-row"):
|
| 968 |
+
msg = gr.Textbox(
|
| 969 |
+
value=_SEED_TEXT,
|
| 970 |
+
placeholder="Describe a topic, then press Send to grow a mapβ¦",
|
| 971 |
+
scale=8,
|
| 972 |
+
show_label=False,
|
| 973 |
+
# Multi-line so the seed paragraph is comfortably editable; Enter still
|
| 974 |
+
# submits (Shift+Enter for a newline).
|
| 975 |
+
lines=3,
|
| 976 |
+
max_lines=10,
|
| 977 |
+
autofocus=True,
|
| 978 |
+
)
|
| 979 |
+
send_btn = gr.Button("Send", variant="primary", scale=1)
|
| 980 |
status_md = gr.Markdown(value="", elem_classes=["lc-turn-status"])
|
| 981 |
+
# Time-travel (undo/redo/back) + search are hidden for now β they don't fit the
|
| 982 |
+
# chat+graph paradigm. The handlers + wiring stay intact (flip visible=True to
|
| 983 |
+
# restore); see on_undo/on_redo/on_back and test_time_travel_output_arity.
|
| 984 |
with gr.Row():
|
| 985 |
+
undo_btn = gr.Button("β© Undo", scale=1, visible=False)
|
| 986 |
+
redo_btn = gr.Button("βͺ Redo", scale=1, visible=False)
|
| 987 |
cluster_btn = gr.Button("π¨ Colour clusters", scale=1)
|
| 988 |
+
# On-demand "surprising knowledge": propose a cross-cluster link the
|
| 989 |
+
# structure implies but no edge yet states. Never auto-applied β it
|
| 990 |
+
# lands as an amber model proposal you accept or reject.
|
| 991 |
+
find_conn_btn = gr.Button("β¨ Find a hidden connection", scale=1)
|
| 992 |
+
# Export is the trust-gate payoff: the exporter drops model_inferred
|
| 993 |
+
# claims you haven't accepted, so the file is exactly what you vouched for.
|
| 994 |
+
export_btn = gr.DownloadButton(
|
| 995 |
+
"β¬ Export map", scale=1, elem_classes=["lc-export-btn"]
|
| 996 |
+
)
|
| 997 |
+
gr.Markdown(
|
| 998 |
+
"*Export carries only the facts you've accepted β unreviewed AI guesses stay out.*",
|
| 999 |
+
elem_classes=["lc-export-note"],
|
| 1000 |
+
)
|
| 1001 |
+
with gr.Row(visible=False): # search/back hidden for now (kept wired)
|
| 1002 |
search_box = gr.Textbox(
|
| 1003 |
placeholder="Search graph nodesβ¦",
|
| 1004 |
scale=9,
|
|
|
|
| 1015 |
|
| 1016 |
# ββ Handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1017 |
|
| 1018 |
+
async def on_open() -> tuple[Any, str, Any]:
|
| 1019 |
+
"""Open-world entry: paint an EMPTY canvas and leave the editable seed text in
|
| 1020 |
+
the composer. No graph is built until the user presses Send β then on_turn's
|
| 1021 |
+
no-session branch runs Gemma extraction on whatever text is there (the magic).
|
| 1022 |
+
|
| 1023 |
+
Returns ``(empty snapshot, empty session id, invitation banner)``. The empty
|
| 1024 |
+
session id routes the first Send into the build branch; seeing the graph
|
| 1025 |
+
appear from your own words is the moment that sells 'chat in, map out'."""
|
| 1026 |
+
empty = RendererPatch(
|
| 1027 |
+
patch_id=0,
|
| 1028 |
+
is_snapshot=True,
|
| 1029 |
+
scene_id="open-world-empty",
|
| 1030 |
+
operations=[],
|
| 1031 |
+
)
|
| 1032 |
+
return empty.model_dump(mode="json"), "", gr.update(visible=True)
|
| 1033 |
+
|
| 1034 |
+
async def on_turn(
|
| 1035 |
+
message: str, sid: str, selection: dict[str, Any], history: list[Any]
|
| 1036 |
+
) -> AsyncGenerator[Any]:
|
| 1037 |
+
# Outputs: [canvas, inspector, chatbot, msg, status_md, send_btn,
|
| 1038 |
+
# session_id, onboarding]. The last two are set ONLY by the
|
| 1039 |
+
# no-session "magic build" branch below; normal turns pass them
|
| 1040 |
+
# through unchanged (the same sid + a no-op onboarding update).
|
| 1041 |
+
if not sid:
|
| 1042 |
+
# MAGIC BUILD: no graph yet β the composer text IS the seed. Gemma
|
| 1043 |
+
# extracts a graph from it (non-deterministic β edit the text, get a
|
| 1044 |
+
# different map). Stream a growing-state, then the built snapshot + the
|
| 1045 |
+
# new session id, and retire the onboarding banner. The textβgraph path
|
| 1046 |
+
# lives in turn_logic.stream_text_session (lazy-imported like the others).
|
| 1047 |
+
from loosecanvas.turn_logic import stream_text_session
|
| 1048 |
+
|
| 1049 |
+
if not message.strip():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1050 |
yield (
|
| 1051 |
gr.update(),
|
| 1052 |
gr.update(),
|
| 1053 |
gr.update(),
|
| 1054 |
gr.update(),
|
| 1055 |
+
"βοΈ Add or edit some text, then press Send to grow your map.",
|
| 1056 |
gr.update(),
|
| 1057 |
+
sid,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1058 |
gr.update(),
|
| 1059 |
)
|
| 1060 |
+
return
|
| 1061 |
+
base_history = list(history) + [{"role": "user", "content": message}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1062 |
yield (
|
| 1063 |
gr.update(),
|
| 1064 |
gr.update(),
|
| 1065 |
+
base_history,
|
| 1066 |
+
gr.update(interactive=False),
|
| 1067 |
+
"π± Growing your map from your textβ¦",
|
| 1068 |
+
gr.update(interactive=False),
|
| 1069 |
+
sid,
|
| 1070 |
gr.update(),
|
| 1071 |
)
|
| 1072 |
+
async for prog in stream_text_session(
|
| 1073 |
+
message, "Your text", LLMClient()
|
| 1074 |
+
):
|
| 1075 |
+
if prog.is_partial:
|
| 1076 |
+
yield (
|
| 1077 |
+
gr.update(),
|
| 1078 |
+
gr.update(),
|
| 1079 |
+
gr.update(),
|
| 1080 |
+
gr.update(interactive=False),
|
| 1081 |
+
prog.status or "Calling Gemmaβ¦",
|
| 1082 |
+
gr.update(interactive=False),
|
| 1083 |
+
sid,
|
| 1084 |
+
gr.update(),
|
| 1085 |
+
)
|
| 1086 |
+
elif prog.is_error:
|
| 1087 |
+
# Build failed (e.g. LLM down) β surface in chat, keep the text
|
| 1088 |
+
# for a retry, leave the session empty + the banner up.
|
| 1089 |
+
yield (
|
| 1090 |
+
gr.update(),
|
| 1091 |
+
gr.update(),
|
| 1092 |
+
base_history + list(prog.chat_message),
|
| 1093 |
+
gr.update(interactive=True),
|
| 1094 |
+
"",
|
| 1095 |
+
gr.update(interactive=True),
|
| 1096 |
+
sid,
|
| 1097 |
+
gr.update(),
|
| 1098 |
+
)
|
| 1099 |
+
else:
|
| 1100 |
+
# Success β paint the built graph, adopt the new session id,
|
| 1101 |
+
# and retire the invitation banner FIRST (the map appears),
|
| 1102 |
+
# then stream a warm, agent-voiced opening line into the chat
|
| 1103 |
+
# so turn one reads like every later turn instead of a canned
|
| 1104 |
+
# summary. Keep the composer disabled until the opener finishes.
|
| 1105 |
+
new_sid = prog.session_id or sid
|
| 1106 |
+
yield (
|
| 1107 |
+
prog.canvas_patch or gr.update(),
|
| 1108 |
+
gr.update(),
|
| 1109 |
+
gr.update(),
|
| 1110 |
+
gr.update(interactive=False),
|
| 1111 |
+
"",
|
| 1112 |
+
gr.update(interactive=False),
|
| 1113 |
+
new_sid,
|
| 1114 |
+
gr.update(visible=False),
|
| 1115 |
+
)
|
| 1116 |
+
opener = ""
|
| 1117 |
+
async for tok in stream_build_opener(new_sid):
|
| 1118 |
+
opener += tok
|
| 1119 |
+
yield (
|
| 1120 |
+
gr.update(),
|
| 1121 |
+
gr.update(),
|
| 1122 |
+
base_history
|
| 1123 |
+
+ [{"role": "assistant", "content": opener}],
|
| 1124 |
+
gr.update(interactive=False),
|
| 1125 |
+
gr.update(),
|
| 1126 |
+
gr.update(interactive=False),
|
| 1127 |
+
new_sid,
|
| 1128 |
+
gr.update(visible=False),
|
| 1129 |
+
)
|
| 1130 |
+
# Finalize turn one: clear the composer and re-enable Send.
|
| 1131 |
+
yield (
|
| 1132 |
+
gr.update(),
|
| 1133 |
+
gr.update(),
|
| 1134 |
+
base_history + [{"role": "assistant", "content": opener}],
|
| 1135 |
+
gr.update(value="", interactive=True),
|
| 1136 |
+
"",
|
| 1137 |
+
gr.update(interactive=True),
|
| 1138 |
+
new_sid,
|
| 1139 |
+
gr.update(visible=False),
|
| 1140 |
+
)
|
| 1141 |
return
|
| 1142 |
base_history = list(history) + [{"role": "user", "content": message}]
|
| 1143 |
# A2: disable the composer + Send for the turn; keep the text visible.
|
|
|
|
| 1148 |
gr.update(interactive=False),
|
| 1149 |
"Thinkingβ¦",
|
| 1150 |
gr.update(interactive=False),
|
| 1151 |
+
sid,
|
| 1152 |
+
gr.update(),
|
| 1153 |
)
|
| 1154 |
reply = ""
|
| 1155 |
+
step_labels: list[str] = []
|
| 1156 |
try:
|
| 1157 |
async for ev in run_agent_turn_streaming(sid, selection, message):
|
| 1158 |
if isinstance(ev, TokenEvent):
|
| 1159 |
reply += ev.text
|
| 1160 |
+
# Build streaming history: if steps are accumulating, keep the
|
| 1161 |
+
# live step-list bubble followed by the partial prose bubble so
|
| 1162 |
+
# both are visible mid-turn.
|
| 1163 |
+
if step_labels:
|
| 1164 |
+
streaming_history = list(base_history) + [
|
| 1165 |
+
_tool_steplist_message(step_labels, "pending"),
|
| 1166 |
+
{"role": "assistant", "content": reply},
|
| 1167 |
+
]
|
| 1168 |
+
else:
|
| 1169 |
+
streaming_history = base_history + [
|
| 1170 |
+
{"role": "assistant", "content": reply}
|
| 1171 |
+
]
|
| 1172 |
yield (
|
| 1173 |
gr.update(),
|
| 1174 |
gr.update(),
|
|
|
|
| 1176 |
gr.update(),
|
| 1177 |
gr.update(),
|
| 1178 |
gr.update(),
|
| 1179 |
+
sid,
|
| 1180 |
+
gr.update(),
|
| 1181 |
)
|
| 1182 |
elif isinstance(ev, ToolActivityEvent):
|
| 1183 |
+
# Accumulate the friendly step label and yield a live collapsible
|
| 1184 |
+
# step-list bubble in the chatbot; also update the status strip.
|
| 1185 |
+
step_labels.append(ev.message)
|
| 1186 |
yield (
|
| 1187 |
gr.update(),
|
| 1188 |
gr.update(),
|
| 1189 |
+
list(base_history)
|
| 1190 |
+
+ [_tool_steplist_message(step_labels, "pending")],
|
| 1191 |
gr.update(),
|
| 1192 |
ev.message,
|
| 1193 |
gr.update(),
|
| 1194 |
+
sid,
|
| 1195 |
+
gr.update(),
|
| 1196 |
)
|
| 1197 |
elif isinstance(ev, FinalEvent):
|
| 1198 |
reply = ev.assistant_message or reply
|
|
|
|
| 1202 |
prefix = f"{reply}\n\n" if reply else ""
|
| 1203 |
reply = f"{prefix}β οΈ Some actions were rejected: {reasons}"
|
| 1204 |
tool_receipts = _tool_receipt_messages(ev.tool_activity)
|
| 1205 |
+
# Drop the pending step-list bubble: final history starts clean
|
| 1206 |
+
# from base_history (user message only) then appends prose + receipts.
|
| 1207 |
final_history = list(base_history)
|
| 1208 |
if reply:
|
| 1209 |
final_history.append(
|
|
|
|
| 1231 |
gr.update(value="", interactive=True),
|
| 1232 |
"",
|
| 1233 |
gr.update(interactive=True),
|
| 1234 |
+
sid,
|
| 1235 |
+
gr.update(),
|
| 1236 |
)
|
| 1237 |
except Exception as exc: # noqa: BLE001
|
| 1238 |
error_history = base_history + [
|
|
|
|
| 1246 |
gr.update(interactive=True),
|
| 1247 |
"",
|
| 1248 |
gr.update(interactive=True),
|
| 1249 |
+
sid,
|
| 1250 |
+
gr.update(),
|
| 1251 |
)
|
| 1252 |
|
| 1253 |
async def on_select(
|
|
|
|
| 1407 |
pass
|
| 1408 |
return {"kind": "none", "id": ""}, None, {}, gr.update(visible=False)
|
| 1409 |
|
| 1410 |
+
if kind == "review_edge" and sid:
|
| 1411 |
+
# B2 radial-cxtmenu: Accept/Reject fired on an EDGE β transition the
|
| 1412 |
+
# edge's first actionable governing claim. Mirrors the kind=='review'
|
| 1413 |
+
# branch above. Payload: {kind:'review_edge', id:<edge_id>,
|
| 1414 |
+
# action:'accepted'|'rejected'|'disputed'}.
|
| 1415 |
+
from loosecanvas.claim_state_machine import InvalidTransitionError
|
| 1416 |
+
from loosecanvas.turn_logic import review_edge_claim
|
| 1417 |
+
|
| 1418 |
+
edge_id = str(raw.get("id", ""))
|
| 1419 |
+
action = str(raw.get("action", ""))
|
| 1420 |
+
if edge_id and action in {"accepted", "rejected", "disputed"}:
|
| 1421 |
+
try:
|
| 1422 |
+
review_result = review_edge_claim(sid, edge_id, action)
|
| 1423 |
+
except (KeyError, InvalidTransitionError):
|
| 1424 |
+
review_result = None
|
| 1425 |
+
if review_result is not None:
|
| 1426 |
+
sel = {"kind": "edge", "id": edge_id}
|
| 1427 |
+
has_claims = bool(
|
| 1428 |
+
review_result.inspector.get("governing_claims")
|
| 1429 |
+
if review_result.inspector is not None
|
| 1430 |
+
else []
|
| 1431 |
+
)
|
| 1432 |
+
return (
|
| 1433 |
+
sel,
|
| 1434 |
+
review_result.inspector,
|
| 1435 |
+
review_result.renderer_patch.model_dump(mode="json"),
|
| 1436 |
+
gr.update(visible=has_claims),
|
| 1437 |
+
)
|
| 1438 |
+
# Graceful no-op: refresh the edge inspector without mutating the graph.
|
| 1439 |
+
sel = {"kind": "edge", "id": edge_id}
|
| 1440 |
+
insp = inspector_for_selection(sid, sel)
|
| 1441 |
+
insp_claims = insp.get("governing_claims") if insp is not None else []
|
| 1442 |
+
return sel, insp, {}, gr.update(visible=bool(insp_claims))
|
| 1443 |
+
|
| 1444 |
+
if kind == "relabel_edge" and sid:
|
| 1445 |
+
# B2 radial-cxtmenu: "Edit edge label" β overlay a user_asserted
|
| 1446 |
+
# label claim on the edge (no LLM round-trip). Mirrors the
|
| 1447 |
+
# kind=='rename' branch above.
|
| 1448 |
+
# Payload: {kind:'relabel_edge', id:<edge_id>, label:<new_label>}.
|
| 1449 |
+
from loosecanvas.turn_logic import apply_user_edge_edit
|
| 1450 |
+
|
| 1451 |
+
edge_id = str(raw.get("id", ""))
|
| 1452 |
+
new_label = str(raw.get("label", ""))
|
| 1453 |
+
if edge_id and new_label:
|
| 1454 |
+
try:
|
| 1455 |
+
result = await apply_user_edge_edit(
|
| 1456 |
+
sid, edge_id, label=new_label
|
| 1457 |
+
)
|
| 1458 |
+
sel = {"kind": "edge", "id": edge_id}
|
| 1459 |
+
return (
|
| 1460 |
+
sel,
|
| 1461 |
+
result.inspector_update,
|
| 1462 |
+
result.renderer_patch.model_dump(mode="json"),
|
| 1463 |
+
gr.update(),
|
| 1464 |
+
)
|
| 1465 |
+
except (KeyError, ValueError):
|
| 1466 |
+
pass
|
| 1467 |
+
sel = {"kind": "edge", "id": edge_id}
|
| 1468 |
+
return sel, inspector_for_selection(sid, sel), {}, gr.update()
|
| 1469 |
+
|
| 1470 |
+
if kind == "edit_summary" and sid:
|
| 1471 |
+
# Radial cxtmenu: "Edit summary" β overlay a user_asserted summary
|
| 1472 |
+
# claim on the node (reuses the existing apply_user_edit path).
|
| 1473 |
+
node_id = str(raw.get("id", ""))
|
| 1474 |
+
text = str(raw.get("text", ""))
|
| 1475 |
+
if node_id and text:
|
| 1476 |
+
try:
|
| 1477 |
+
result = await apply_user_edit(sid, node_id, summary=text)
|
| 1478 |
+
sel = {"kind": "node", "id": node_id}
|
| 1479 |
+
return (
|
| 1480 |
+
sel,
|
| 1481 |
+
result.inspector_update,
|
| 1482 |
+
result.renderer_patch.model_dump(mode="json"),
|
| 1483 |
+
gr.update(),
|
| 1484 |
+
)
|
| 1485 |
+
except (KeyError, ValueError):
|
| 1486 |
+
pass
|
| 1487 |
+
sel = {"kind": "node", "id": node_id}
|
| 1488 |
+
return sel, inspector_for_selection(sid, sel), {}, gr.update()
|
| 1489 |
+
|
| 1490 |
+
if kind == "delete_node" and sid:
|
| 1491 |
+
# Radial cxtmenu node menu: "Delete node" (confirmed in-canvas before
|
| 1492 |
+
# this fires). Removal cascades incident edges and is undoable.
|
| 1493 |
+
from loosecanvas.turn_logic import remove_user_node
|
| 1494 |
+
|
| 1495 |
+
node_id = str(raw.get("id", ""))
|
| 1496 |
+
if node_id:
|
| 1497 |
+
try:
|
| 1498 |
+
node_result = await remove_user_node(sid, node_id)
|
| 1499 |
+
return (
|
| 1500 |
+
{"kind": "none", "id": ""},
|
| 1501 |
+
node_result.inspector_update,
|
| 1502 |
+
node_result.renderer_patch.model_dump(mode="json"),
|
| 1503 |
+
gr.update(visible=False),
|
| 1504 |
+
)
|
| 1505 |
+
except KeyError:
|
| 1506 |
+
pass
|
| 1507 |
+
return {"kind": "none", "id": ""}, None, {}, gr.update(visible=False)
|
| 1508 |
+
|
| 1509 |
+
if kind == "add_node" and sid:
|
| 1510 |
+
# Radial cxtmenu background menu: "Add node" β mint a user_asserted
|
| 1511 |
+
# node on the open periphery and reveal it (no LLM round-trip).
|
| 1512 |
+
from loosecanvas.turn_logic import apply_user_node
|
| 1513 |
+
|
| 1514 |
+
label = str(raw.get("label", ""))
|
| 1515 |
+
if label:
|
| 1516 |
+
try:
|
| 1517 |
+
add_result = await apply_user_node(
|
| 1518 |
+
sid, label, str(raw.get("text", ""))
|
| 1519 |
+
)
|
| 1520 |
+
return (
|
| 1521 |
+
{"kind": "none", "id": ""},
|
| 1522 |
+
None,
|
| 1523 |
+
add_result.renderer_patch.model_dump(mode="json"),
|
| 1524 |
+
gr.update(visible=False),
|
| 1525 |
+
)
|
| 1526 |
+
except (KeyError, ValueError):
|
| 1527 |
+
pass
|
| 1528 |
+
return {"kind": "none", "id": ""}, None, {}, gr.update(visible=False)
|
| 1529 |
+
|
| 1530 |
# Normal selection
|
| 1531 |
selection: dict[str, Any] = {"kind": kind, "id": str(raw.get("id", ""))}
|
| 1532 |
# T3-03: update focus history for node selections
|
|
|
|
| 1546 |
# real class object so the event-data parameter is detected.
|
| 1547 |
on_select.__annotations__["evt"] = gr.SelectData
|
| 1548 |
|
| 1549 |
+
# Open-world entry: always seed the canvas on page load via the zero-LLM
|
| 1550 |
+
# sync path. No fixture picker, no manual load/build buttons β the user
|
| 1551 |
+
# shapes the map by chatting. Seeding IS the entry now (no DEMO_SEED gate);
|
| 1552 |
+
# on_seed falls back to annotated_graph if the baked seed isn't present.
|
| 1553 |
+
demo.load(on_open, inputs=[], outputs=[canvas, session_id, onboarding])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1554 |
|
| 1555 |
turn_inputs = [msg, session_id, current_selection, chatbot]
|
| 1556 |
+
# turn_outputs (6) is shared with on_undo/on_redo. on_turn needs two MORE
|
| 1557 |
+
# outputs β session_id (the magic build mints a new session) and onboarding
|
| 1558 |
+
# (retire the banner once a map exists) β so it gets its own wider list while
|
| 1559 |
+
# the time-travel handlers keep the 6-wide one untouched.
|
| 1560 |
turn_outputs = [canvas, inspector, chatbot, msg, status_md, send_btn]
|
| 1561 |
+
turn_outputs_full = turn_outputs + [session_id, onboarding]
|
| 1562 |
+
msg.submit(on_turn, turn_inputs, turn_outputs_full)
|
| 1563 |
+
send_btn.click(on_turn, turn_inputs, turn_outputs_full)
|
| 1564 |
+
|
| 1565 |
+
# NB: these are wired to ``turn_outputs`` (6 components, incl. send_btn). EVERY
|
| 1566 |
+
# return must supply all 6 values or Gradio raises "didn't return enough output
|
| 1567 |
+
# values" the moment the button is pressed β the live time-travel bug the unit
|
| 1568 |
+
# tests (which call undo_turn/redo_turn directly) never exercised. The 6th slot
|
| 1569 |
+
# is send_btn; keep Send enabled after a time-travel action, like on_turn does.
|
| 1570 |
async def on_undo(
|
| 1571 |
sid: str, history: list[Any]
|
| 1572 |
+
) -> tuple[Any, Any, list[Any], str, str, Any]:
|
| 1573 |
if not sid:
|
| 1574 |
+
return (
|
| 1575 |
+
gr.update(),
|
| 1576 |
+
gr.update(),
|
| 1577 |
+
history,
|
| 1578 |
+
"",
|
| 1579 |
+
"No session",
|
| 1580 |
+
gr.update(interactive=True),
|
| 1581 |
+
)
|
| 1582 |
result = await undo_turn(sid)
|
| 1583 |
return (
|
| 1584 |
result.renderer_patch.model_dump(mode="json"),
|
|
|
|
| 1586 |
history,
|
| 1587 |
"",
|
| 1588 |
result.status,
|
| 1589 |
+
gr.update(interactive=True),
|
| 1590 |
)
|
| 1591 |
|
| 1592 |
async def on_redo(
|
| 1593 |
sid: str, history: list[Any]
|
| 1594 |
+
) -> tuple[Any, Any, list[Any], str, str, Any]:
|
| 1595 |
if not sid:
|
| 1596 |
+
return (
|
| 1597 |
+
gr.update(),
|
| 1598 |
+
gr.update(),
|
| 1599 |
+
history,
|
| 1600 |
+
"",
|
| 1601 |
+
"No session",
|
| 1602 |
+
gr.update(interactive=True),
|
| 1603 |
+
)
|
| 1604 |
result = await redo_turn(sid)
|
| 1605 |
return (
|
| 1606 |
result.renderer_patch.model_dump(mode="json"),
|
|
|
|
| 1608 |
history,
|
| 1609 |
"",
|
| 1610 |
result.status,
|
| 1611 |
+
gr.update(interactive=True),
|
| 1612 |
)
|
| 1613 |
|
| 1614 |
undo_btn.click(on_undo, inputs=[session_id, chatbot], outputs=turn_outputs)
|
|
|
|
| 1626 |
|
| 1627 |
cluster_btn.click(on_cluster, inputs=[session_id], outputs=[canvas])
|
| 1628 |
|
| 1629 |
+
async def on_find_connection(sid: str) -> Any:
|
| 1630 |
+
"""C14/C15: surface a surprising cross-cluster link as a model proposal
|
| 1631 |
+
(amber, awaiting review). No-op (no canvas change) if none is found."""
|
| 1632 |
+
if not sid:
|
| 1633 |
+
return {}
|
| 1634 |
+
patch = await find_connection_session(sid)
|
| 1635 |
+
return {} if patch is None else patch.model_dump(mode="json")
|
| 1636 |
+
|
| 1637 |
+
find_conn_btn.click(on_find_connection, inputs=[session_id], outputs=[canvas])
|
| 1638 |
+
|
| 1639 |
+
async def on_export(sid: str) -> Any:
|
| 1640 |
+
"""Export the map as JSON. Trust gate: ``export_session`` drops any
|
| 1641 |
+
model_inferred claim you haven't accepted, so the downloaded file is
|
| 1642 |
+
exactly the knowledge you've vouched for β the review loop made provable.
|
| 1643 |
+
"""
|
| 1644 |
+
if not sid:
|
| 1645 |
+
return gr.update()
|
| 1646 |
+
record = session_store.get(sid)
|
| 1647 |
+
if record is None:
|
| 1648 |
+
return gr.update()
|
| 1649 |
+
import json
|
| 1650 |
+
import tempfile
|
| 1651 |
+
|
| 1652 |
+
from loosecanvas.exporter import export_session
|
| 1653 |
+
|
| 1654 |
+
data = await export_session(record.graph, session_id=sid)
|
| 1655 |
+
out_path = Path(tempfile.gettempdir()) / f"loosecanvas-map-{sid[:8]}.json"
|
| 1656 |
+
out_path.write_text(
|
| 1657 |
+
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
| 1658 |
+
)
|
| 1659 |
+
return str(out_path)
|
| 1660 |
+
|
| 1661 |
+
export_btn.click(on_export, inputs=[session_id], outputs=[export_btn])
|
| 1662 |
+
|
| 1663 |
+
# Wired to ``turn_outputs + [search_results_display]`` (7 components). The 6th
|
| 1664 |
+
# slot is send_btn (keep Send enabled), the 7th is the search results β every
|
| 1665 |
+
# return must supply all 7 (see the on_undo note for why the unit tests missed
|
| 1666 |
+
# the original 6-value returns that errored live).
|
| 1667 |
async def on_back(
|
| 1668 |
sid: str, history: list[Any]
|
| 1669 |
+
) -> tuple[dict[str, Any] | None, Any, list[Any], str, str, Any, list[Any]]:
|
| 1670 |
"""T3-03: navigate back in focus history via pop_focus."""
|
| 1671 |
if not sid:
|
| 1672 |
+
return None, None, history, "", "No session", gr.update(), []
|
| 1673 |
record = session_store.get(sid)
|
| 1674 |
if record is None:
|
| 1675 |
+
return None, None, history, "", "No session", gr.update(), []
|
| 1676 |
prev = pop_focus(record)
|
| 1677 |
if prev is None:
|
| 1678 |
+
return (
|
| 1679 |
+
gr.update(),
|
| 1680 |
+
gr.update(),
|
| 1681 |
+
history,
|
| 1682 |
+
"",
|
| 1683 |
+
"no_back_history",
|
| 1684 |
+
gr.update(),
|
| 1685 |
+
[],
|
| 1686 |
+
)
|
| 1687 |
record.patch_id += 1
|
| 1688 |
patch = RendererPatch(
|
| 1689 |
patch_id=record.patch_id,
|
|
|
|
| 1697 |
],
|
| 1698 |
)
|
| 1699 |
insp = inspector_for_selection(sid, {"kind": "node", "id": prev})
|
| 1700 |
+
return (
|
| 1701 |
+
patch.model_dump(mode="json"),
|
| 1702 |
+
insp,
|
| 1703 |
+
history,
|
| 1704 |
+
"",
|
| 1705 |
+
"back",
|
| 1706 |
+
gr.update(interactive=True),
|
| 1707 |
+
[],
|
| 1708 |
+
)
|
| 1709 |
|
| 1710 |
search_box.submit(
|
| 1711 |
on_search,
|
src/loosecanvas/reducer.py
CHANGED
|
@@ -126,6 +126,11 @@ def reduce_turn(
|
|
| 126 |
graph_patch.upsert_claims.append(
|
| 127 |
_user_overlay_claim(action, "summary", action.note)
|
| 128 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
elif action.type is SceneActionType.create_node:
|
| 130 |
node = _model_node(action)
|
| 131 |
graph_patch.upsert_nodes.append(node)
|
|
@@ -134,10 +139,13 @@ def reduce_turn(
|
|
| 134 |
if node.id not in scene_patch.add_visible_node_ids:
|
| 135 |
scene_patch.add_visible_node_ids.append(node.id)
|
| 136 |
elif action.type is SceneActionType.create_edge:
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
| 141 |
graph_patch.upsert_edges.append(edge)
|
| 142 |
graph_patch.upsert_claims.append(claim)
|
| 143 |
# Reveal the edge if both endpoints are visible (incl. same-batch new nodes).
|
|
@@ -306,6 +314,24 @@ def _user_overlay_claim(action: SceneAction, claim_type: str, text: str) -> Clai
|
|
| 306 |
)
|
| 307 |
|
| 308 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
def _model_node(action: SceneAction) -> Node:
|
| 310 |
"""A model_inferred concept node proposed by the agent (the model is proposing)."""
|
| 311 |
return Node(
|
|
@@ -321,7 +347,8 @@ def _model_node_claim(node_id: str, label: str) -> Claim:
|
|
| 321 |
"""Governing claim for an agent-created concept node (unverified + pending review).
|
| 322 |
|
| 323 |
Trust stays unflattened: the model proposing a node is ``model_inferred`` /
|
| 324 |
-
``unverified`` / ``pending`` β
|
|
|
|
| 325 |
"""
|
| 326 |
return Claim(
|
| 327 |
id=_stable_id("claim", "concept_node", node_id, label),
|
|
@@ -334,6 +361,36 @@ def _model_node_claim(node_id: str, label: str) -> Claim:
|
|
| 334 |
)
|
| 335 |
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
def _user_edge(source_id: str, target_id: str, label: str) -> Edge:
|
| 338 |
"""A user_asserted edge (accepted-by-construction)."""
|
| 339 |
return Edge(
|
|
|
|
| 126 |
graph_patch.upsert_claims.append(
|
| 127 |
_user_overlay_claim(action, "summary", action.note)
|
| 128 |
)
|
| 129 |
+
elif action.type is SceneActionType.update_edge:
|
| 130 |
+
# Relabel an existing edge via a user_asserted overlay claim β never mutate
|
| 131 |
+
# edge.label in place. effective_edge_label resolves the overlay for both
|
| 132 |
+
# rendering and export, exactly like update_node does for node labels.
|
| 133 |
+
graph_patch.upsert_claims.append(_user_edge_label_claim(action))
|
| 134 |
elif action.type is SceneActionType.create_node:
|
| 135 |
node = _model_node(action)
|
| 136 |
graph_patch.upsert_nodes.append(node)
|
|
|
|
| 139 |
if node.id not in scene_patch.add_visible_node_ids:
|
| 140 |
scene_patch.add_visible_node_ids.append(node.id)
|
| 141 |
elif action.type is SceneActionType.create_edge:
|
| 142 |
+
# The AGENT is proposing a relationship β model_inferred + pending, exactly
|
| 143 |
+
# like create_node. (Direct user-UI connects go through apply_user_edge,
|
| 144 |
+
# which stays user_asserted.) Stamping an agent edge user_asserted/accepted
|
| 145 |
+
# flattened trust β the model-invented relationship passed as user-vouched
|
| 146 |
+
# on export (plan/06 forbids this collapse).
|
| 147 |
+
edge = _model_edge(action.source_id, action.target_id, action.label)
|
| 148 |
+
claim = _model_edge_claim(edge.id, action.label)
|
| 149 |
graph_patch.upsert_edges.append(edge)
|
| 150 |
graph_patch.upsert_claims.append(claim)
|
| 151 |
# Reveal the edge if both endpoints are visible (incl. same-batch new nodes).
|
|
|
|
| 314 |
)
|
| 315 |
|
| 316 |
|
| 317 |
+
def _user_edge_label_claim(action: SceneAction) -> Claim:
|
| 318 |
+
"""A user_asserted overlay claim relabeling an existing edge (edge.label preserved).
|
| 319 |
+
|
| 320 |
+
The ``"useredit"`` discriminator in the stable id keeps this distinct from a
|
| 321 |
+
model-inferred ``propose_edge_label`` claim on the same edge with the same text,
|
| 322 |
+
so a user edit and a model proposal never collide on a single claim id.
|
| 323 |
+
"""
|
| 324 |
+
return Claim(
|
| 325 |
+
id=_stable_id("claim", "label", "useredit", action.edge_id, action.label),
|
| 326 |
+
claim_type="label",
|
| 327 |
+
target_id=action.edge_id,
|
| 328 |
+
text=action.label,
|
| 329 |
+
origin=_USER_ORIGIN,
|
| 330 |
+
support_state="unverified",
|
| 331 |
+
review_state="accepted",
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
|
| 335 |
def _model_node(action: SceneAction) -> Node:
|
| 336 |
"""A model_inferred concept node proposed by the agent (the model is proposing)."""
|
| 337 |
return Node(
|
|
|
|
| 347 |
"""Governing claim for an agent-created concept node (unverified + pending review).
|
| 348 |
|
| 349 |
Trust stays unflattened: the model proposing a node is ``model_inferred`` /
|
| 350 |
+
``unverified`` / ``pending`` β like an agent-proposed edge (see _model_edge_claim)
|
| 351 |
+
and unlike a direct user_asserted edge (apply_user_edge).
|
| 352 |
"""
|
| 353 |
return Claim(
|
| 354 |
id=_stable_id("claim", "concept_node", node_id, label),
|
|
|
|
| 361 |
)
|
| 362 |
|
| 363 |
|
| 364 |
+
def _model_edge(source_id: str, target_id: str, label: str) -> Edge:
|
| 365 |
+
"""A model_inferred edge proposed by the agent (the model proposes a relationship)."""
|
| 366 |
+
return Edge(
|
| 367 |
+
id=_stable_id("modeledge", source_id, target_id, label),
|
| 368 |
+
source=source_id,
|
| 369 |
+
target=target_id,
|
| 370 |
+
type="related",
|
| 371 |
+
label=label,
|
| 372 |
+
properties={"origin": _MODEL_ORIGIN},
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def _model_edge_claim(edge_id: str, label: str) -> Claim:
|
| 377 |
+
"""Governing claim for an agent-created edge: model_inferred / unverified / pending.
|
| 378 |
+
|
| 379 |
+
Keeps trust unflattened (plan/06) β the model proposing a relationship is a
|
| 380 |
+
reviewable proposal awaiting the user's decision, NOT a user-vouched fact.
|
| 381 |
+
Mirrors :func:`_model_node_claim`.
|
| 382 |
+
"""
|
| 383 |
+
return Claim(
|
| 384 |
+
id=_stable_id("claim", "relationship", edge_id, label),
|
| 385 |
+
claim_type="relationship",
|
| 386 |
+
target_id=edge_id,
|
| 387 |
+
text=label,
|
| 388 |
+
origin=_MODEL_ORIGIN,
|
| 389 |
+
support_state="unverified",
|
| 390 |
+
review_state="pending",
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
|
| 394 |
def _user_edge(source_id: str, target_id: str, label: str) -> Edge:
|
| 395 |
"""A user_asserted edge (accepted-by-construction)."""
|
| 396 |
return Edge(
|
src/loosecanvas/scene_curator.py
CHANGED
|
@@ -77,6 +77,53 @@ def compute_node_clusters(repo: LooseGraphRepository) -> dict[str, int]:
|
|
| 77 |
return mapping
|
| 78 |
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
class SceneCurator:
|
| 81 |
"""Ranks a ``LooseGraph`` into an initial scene and suggested pathways (M12)."""
|
| 82 |
|
|
@@ -86,9 +133,18 @@ class SceneCurator:
|
|
| 86 |
# ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 87 |
|
| 88 |
def curate_initial_scene(
|
| 89 |
-
self, max_visible: int = 12, max_fog: int = 25
|
| 90 |
) -> tuple[SceneState, list[SuggestedPathway]]:
|
| 91 |
-
"""Return ``(scene, pathways)`` curated from the repository's whole graph.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
graph = self.repo.to_networkx()
|
| 93 |
if len(graph) == 0:
|
| 94 |
return SceneState(scene_id=_new_scene_id()), []
|
|
@@ -96,7 +152,7 @@ class SceneCurator:
|
|
| 96 |
undirected = graph.to_undirected()
|
| 97 |
composite, pagerank = self._score(graph, undirected)
|
| 98 |
|
| 99 |
-
visible = self._select_visible(undirected, composite, max_visible)
|
| 100 |
fog = self._select_fog(undirected, visible, composite, max_fog)
|
| 101 |
positions = self._layout(graph, visible)
|
| 102 |
|
|
@@ -201,9 +257,20 @@ class SceneCurator:
|
|
| 201 |
# ββ Visible selection βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 202 |
|
| 203 |
def _select_visible(
|
| 204 |
-
self,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
) -> list[str]:
|
| 206 |
-
"""Force eligible articulation points first, then fill by composite score.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
visible: list[str] = []
|
| 208 |
|
| 209 |
articulation = [
|
|
@@ -229,6 +296,17 @@ class SceneCurator:
|
|
| 229 |
if node_id not in visible:
|
| 230 |
visible.append(node_id)
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
return visible[:max_visible]
|
| 233 |
|
| 234 |
# ββ Fog selection βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 77 |
return mapping
|
| 78 |
|
| 79 |
|
| 80 |
+
def find_bridge_candidate(
|
| 81 |
+
repo: LooseGraphRepository,
|
| 82 |
+
clusters: dict[str, int],
|
| 83 |
+
visible_ids: list[str],
|
| 84 |
+
) -> tuple[str, str] | None:
|
| 85 |
+
"""Pick a SURPRISING cross-cluster link to propose ("find a hidden connection").
|
| 86 |
+
|
| 87 |
+
Returns ``(src, tgt)`` for two **visible** nodes that sit in *different*
|
| 88 |
+
communities, are **not already directly connected**, and form the most
|
| 89 |
+
structurally interesting bridge β high combined betweenness endpoints joined by
|
| 90 |
+
a short path (a near-miss structural hole). Returns ``None`` when no such pair
|
| 91 |
+
exists (single cluster, every cross pair already linked, or fewer than two
|
| 92 |
+
visible nodes) so the caller can no-op gracefully.
|
| 93 |
+
|
| 94 |
+
Deterministic: betweenness uses a fixed pivot ``seed`` so the same graph always
|
| 95 |
+
surfaces the same connection. Endpoints are restricted to visible nodes because
|
| 96 |
+
the validator requires both endpoints visible for a hypothesis edge.
|
| 97 |
+
"""
|
| 98 |
+
undirected = repo.to_networkx().to_undirected()
|
| 99 |
+
if undirected.number_of_nodes() < 2:
|
| 100 |
+
return None
|
| 101 |
+
btw = nx.betweenness_centrality(
|
| 102 |
+
undirected,
|
| 103 |
+
k=min(_BETWEENNESS_PIVOTS, undirected.number_of_nodes()),
|
| 104 |
+
seed=42,
|
| 105 |
+
)
|
| 106 |
+
visible = [n for n in visible_ids if n in undirected]
|
| 107 |
+
best: tuple[float, str, str] | None = None
|
| 108 |
+
for i, a in enumerate(visible):
|
| 109 |
+
for b in visible[i + 1 :]:
|
| 110 |
+
if clusters.get(a) == clusters.get(b):
|
| 111 |
+
continue # same community β not a cross-cluster surprise
|
| 112 |
+
if undirected.has_edge(a, b):
|
| 113 |
+
continue # already directly linked β not hidden
|
| 114 |
+
try:
|
| 115 |
+
dist = nx.shortest_path_length(undirected, a, b)
|
| 116 |
+
except nx.NetworkXNoPath:
|
| 117 |
+
continue
|
| 118 |
+
# Prefer high-betweenness endpoints joined by a short bridge.
|
| 119 |
+
score = (btw.get(a, 0.0) + btw.get(b, 0.0)) - 0.01 * dist
|
| 120 |
+
if best is None or score > best[0]:
|
| 121 |
+
best = (score, a, b)
|
| 122 |
+
if best is None:
|
| 123 |
+
return None
|
| 124 |
+
return best[1], best[2]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
class SceneCurator:
|
| 128 |
"""Ranks a ``LooseGraph`` into an initial scene and suggested pathways (M12)."""
|
| 129 |
|
|
|
|
| 133 |
# ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 134 |
|
| 135 |
def curate_initial_scene(
|
| 136 |
+
self, max_visible: int = 12, max_fog: int = 25, min_visible: int = 8
|
| 137 |
) -> tuple[SceneState, list[SuggestedPathway]]:
|
| 138 |
+
"""Return ``(scene, pathways)`` curated from the repository's whole graph.
|
| 139 |
+
|
| 140 |
+
``min_visible`` sets a floor: if fewer than ``min_visible`` nodes are
|
| 141 |
+
selected by the scoring pass, eligible nodes (passing ``_is_visible_excluded``)
|
| 142 |
+
are promoted in stable composite-score order until ``min(min_visible,
|
| 143 |
+
eligible_count)`` are visible. The floor is capped at ``max_visible``.
|
| 144 |
+
"""
|
| 145 |
+
assert (
|
| 146 |
+
min_visible <= max_visible
|
| 147 |
+
), f"min_visible ({min_visible}) must not exceed max_visible ({max_visible})"
|
| 148 |
graph = self.repo.to_networkx()
|
| 149 |
if len(graph) == 0:
|
| 150 |
return SceneState(scene_id=_new_scene_id()), []
|
|
|
|
| 152 |
undirected = graph.to_undirected()
|
| 153 |
composite, pagerank = self._score(graph, undirected)
|
| 154 |
|
| 155 |
+
visible = self._select_visible(undirected, composite, max_visible, min_visible)
|
| 156 |
fog = self._select_fog(undirected, visible, composite, max_fog)
|
| 157 |
positions = self._layout(graph, visible)
|
| 158 |
|
|
|
|
| 257 |
# ββ Visible selection βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 258 |
|
| 259 |
def _select_visible(
|
| 260 |
+
self,
|
| 261 |
+
undirected: nx.Graph,
|
| 262 |
+
composite: dict[str, float],
|
| 263 |
+
max_visible: int,
|
| 264 |
+
min_visible: int = 0,
|
| 265 |
) -> list[str]:
|
| 266 |
+
"""Force eligible articulation points first, then fill by composite score.
|
| 267 |
+
|
| 268 |
+
After the normal scoring pass, if ``len(visible) < min_visible`` and there are
|
| 269 |
+
still unused eligible nodes, those are promoted in stable composite-score order
|
| 270 |
+
(ties broken by node id) until ``min(min_visible, eligible_count)`` are visible.
|
| 271 |
+
Excluded nodes (``_is_visible_excluded``) are never promoted regardless of the
|
| 272 |
+
floor. The result never exceeds ``max_visible``.
|
| 273 |
+
"""
|
| 274 |
visible: list[str] = []
|
| 275 |
|
| 276 |
articulation = [
|
|
|
|
| 296 |
if node_id not in visible:
|
| 297 |
visible.append(node_id)
|
| 298 |
|
| 299 |
+
# min_visible floor: promote remaining eligible nodes if we are under the floor.
|
| 300 |
+
if min_visible > 0 and len(visible) < min_visible:
|
| 301 |
+
visible_set = set(visible)
|
| 302 |
+
# eligible is already sorted stably; iterate in that order to keep determinism.
|
| 303 |
+
for node_id in eligible:
|
| 304 |
+
if len(visible) >= min(min_visible, len(eligible)):
|
| 305 |
+
break
|
| 306 |
+
if node_id not in visible_set:
|
| 307 |
+
visible.append(node_id)
|
| 308 |
+
visible_set.add(node_id)
|
| 309 |
+
|
| 310 |
return visible[:max_visible]
|
| 311 |
|
| 312 |
# ββ Fog selection βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
src/loosecanvas/turn_logic.py
CHANGED
|
@@ -142,6 +142,18 @@ def effective_node_summary(node: Node, claims: Iterable[Claim]) -> str:
|
|
| 142 |
return overlay.text if overlay is not None else ""
|
| 143 |
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
def _latest_user_claim(
|
| 146 |
claims: Iterable[Claim], node_id: str, *, claim_type: str
|
| 147 |
) -> Claim | None:
|
|
@@ -442,7 +454,10 @@ def _restore(record: SessionRecord, snap: GraphSnapshot, *, status: str) -> Turn
|
|
| 442 |
nid: effective_node_label(n, record.graph.claims.values())
|
| 443 |
for nid, n in record.graph.nodes.items()
|
| 444 |
}
|
| 445 |
-
edge_label_map = {
|
|
|
|
|
|
|
|
|
|
| 446 |
edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
|
| 447 |
operations = diff_scene(
|
| 448 |
prev_scene,
|
|
@@ -637,7 +652,10 @@ async def load_session(
|
|
| 637 |
nid: effective_node_label(n, repo.claims.values())
|
| 638 |
for nid, n in repo.nodes.items()
|
| 639 |
}
|
| 640 |
-
edge_label_map = {
|
|
|
|
|
|
|
|
|
|
| 641 |
edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
|
| 642 |
ops = diff_scene(
|
| 643 |
SceneState(),
|
|
@@ -713,7 +731,10 @@ async def load_fixture_session(
|
|
| 713 |
nid: effective_node_label(n, repo.claims.values())
|
| 714 |
for nid, n in repo.nodes.items()
|
| 715 |
}
|
| 716 |
-
edge_label_map = {
|
|
|
|
|
|
|
|
|
|
| 717 |
edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
|
| 718 |
ops = diff_scene(
|
| 719 |
SceneState(),
|
|
@@ -789,7 +810,10 @@ async def load_text_session(
|
|
| 789 |
nid: effective_node_label(n, repo.claims.values())
|
| 790 |
for nid, n in repo.nodes.items()
|
| 791 |
}
|
| 792 |
-
edge_label_map = {
|
|
|
|
|
|
|
|
|
|
| 793 |
edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
|
| 794 |
ops = diff_scene(
|
| 795 |
SceneState(),
|
|
@@ -851,24 +875,16 @@ async def stream_text_session(
|
|
| 851 |
is_error=True,
|
| 852 |
)
|
| 853 |
return
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
|
|
|
| 857 |
yield TextBuildProgress(
|
| 858 |
is_partial=False,
|
| 859 |
status="",
|
| 860 |
canvas_patch=snapshot.model_dump(mode="json"),
|
| 861 |
session_id=sid,
|
| 862 |
-
chat_message=[
|
| 863 |
-
{
|
| 864 |
-
"role": "assistant",
|
| 865 |
-
"content": (
|
| 866 |
-
f"Built a graph from your text: {n_nodes} concepts, "
|
| 867 |
-
f"{n_edges} relations. Everything here is model-inferred β "
|
| 868 |
-
"ask me to refine it."
|
| 869 |
-
),
|
| 870 |
-
}
|
| 871 |
-
],
|
| 872 |
)
|
| 873 |
|
| 874 |
|
|
@@ -927,7 +943,10 @@ async def load_repo_session(
|
|
| 927 |
nid: effective_node_label(n, repo.claims.values())
|
| 928 |
for nid, n in repo.nodes.items()
|
| 929 |
}
|
| 930 |
-
edge_label_map = {
|
|
|
|
|
|
|
|
|
|
| 931 |
edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
|
| 932 |
ops = diff_scene(
|
| 933 |
SceneState(),
|
|
@@ -1199,6 +1218,73 @@ async def apply_user_edit(
|
|
| 1199 |
)
|
| 1200 |
|
| 1201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1202 |
async def apply_user_edge(
|
| 1203 |
session_id: str,
|
| 1204 |
source_id: str,
|
|
@@ -1310,6 +1396,144 @@ async def remove_user_edge(session_id: str, edge_id: str) -> TurnResult:
|
|
| 1310 |
)
|
| 1311 |
|
| 1312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1313 |
# ββ Intent classification (pure; routes navigate vs. expand) ββββββββββββββββββ
|
| 1314 |
|
| 1315 |
_EXPAND_INTENT_RE = re.compile(
|
|
@@ -1770,11 +1994,15 @@ def _finalize_turn(
|
|
| 1770 |
status: str,
|
| 1771 |
warnings: list[str],
|
| 1772 |
selection: dict[str, Any],
|
|
|
|
| 1773 |
) -> TurnResult:
|
| 1774 |
"""Replenish fog, lay out reveals, diff to a RendererPatch, and persist.
|
| 1775 |
|
| 1776 |
Shared by ``_navigate_turn`` and ``_expand_turn`` so both paths produce
|
| 1777 |
-
identical render/camera/lineage behaviour.
|
|
|
|
|
|
|
|
|
|
| 1778 |
"""
|
| 1779 |
if revealed_ids:
|
| 1780 |
replenished = record.graph.fog_replenish(new_scene, max_fog=_MAX_FOG)
|
|
@@ -1791,7 +2019,10 @@ def _finalize_turn(
|
|
| 1791 |
nid: effective_node_label(n, record.graph.claims.values())
|
| 1792 |
for nid, n in record.graph.nodes.items()
|
| 1793 |
}
|
| 1794 |
-
edge_label_map = {
|
|
|
|
|
|
|
|
|
|
| 1795 |
edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
|
| 1796 |
operations = diff_scene(
|
| 1797 |
prev_scene,
|
|
@@ -1805,6 +2036,42 @@ def _finalize_turn(
|
|
| 1805 |
)
|
| 1806 |
if operations:
|
| 1807 |
new_scene = new_scene.model_copy(update={"scene_id": str(uuid.uuid4())})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1808 |
# After any reveal, re-fit the camera so newly-placed nodes are in view.
|
| 1809 |
if revealed_ids and new_scene.visible_node_ids:
|
| 1810 |
operations.append(
|
|
@@ -2008,22 +2275,9 @@ def _aggregate_trust(governing: list[Claim]) -> _TrustSummary:
|
|
| 2008 |
}
|
| 2009 |
|
| 2010 |
|
| 2011 |
-
def
|
| 2012 |
-
|
| 2013 |
-
|
| 2014 |
-
"""Inspector payload: ``None`` unless a known node is selected."""
|
| 2015 |
-
if selection.get("kind") != "node":
|
| 2016 |
-
return None
|
| 2017 |
-
node_id = selection.get("id")
|
| 2018 |
-
if not node_id:
|
| 2019 |
-
return None
|
| 2020 |
-
node = record.graph.get_node(node_id)
|
| 2021 |
-
if node is None:
|
| 2022 |
-
return None
|
| 2023 |
-
effective = effective_node_label(node, record.graph.claims.values())
|
| 2024 |
-
# --- governing claims: all claims targeting this node ---
|
| 2025 |
-
governing = [c for c in record.graph.claims.values() if c.target_id == node_id]
|
| 2026 |
-
governing_claims: list[_ClaimSummary] = [
|
| 2027 |
{
|
| 2028 |
"id": c.id,
|
| 2029 |
"claim_type": c.claim_type,
|
|
@@ -2032,20 +2286,56 @@ def _inspector_update(
|
|
| 2032 |
"support_state": c.support_state,
|
| 2033 |
"review_state": c.review_state,
|
| 2034 |
}
|
| 2035 |
-
for c in
|
| 2036 |
]
|
| 2037 |
-
|
| 2038 |
-
|
| 2039 |
-
|
| 2040 |
-
|
| 2041 |
-
|
| 2042 |
-
|
| 2043 |
-
|
| 2044 |
-
|
| 2045 |
-
|
| 2046 |
-
|
| 2047 |
-
|
| 2048 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2049 |
|
| 2050 |
|
| 2051 |
def inspector_for_selection(
|
|
@@ -2086,19 +2376,66 @@ class ReviewClaimResult:
|
|
| 2086 |
renderer_patch: RendererPatch # proper patch for Gradio β CytoscapeCanvas
|
| 2087 |
|
| 2088 |
|
| 2089 |
-
def
|
| 2090 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2091 |
if claim.review_state == "accepted":
|
| 2092 |
-
|
|
|
|
|
|
|
| 2093 |
if claim.review_state == "rejected":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2094 |
return {"action": "remove", "target_id": claim.target_id}
|
| 2095 |
return {"action": "none", "target_id": claim.target_id}
|
| 2096 |
|
| 2097 |
|
| 2098 |
def _canvas_hint_to_renderer_patch(
|
| 2099 |
-
record: SessionRecord,
|
|
|
|
|
|
|
| 2100 |
) -> RendererPatch:
|
| 2101 |
-
"""Convert a canvas-hint dict to a proper RendererPatch for the Gradio layer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2102 |
ops: list[RendererCommand] = []
|
| 2103 |
action = hint.get("action", "none")
|
| 2104 |
target_id = hint.get("target_id", "")
|
|
@@ -2107,9 +2444,22 @@ def _canvas_hint_to_renderer_patch(
|
|
| 2107 |
RendererCommand(
|
| 2108 |
op="remove_class",
|
| 2109 |
selector=f"#{target_id}",
|
| 2110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2111 |
)
|
| 2112 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2113 |
elif action == "remove":
|
| 2114 |
ops.append(RendererCommand(op="remove_element", selector=f"#{target_id}"))
|
| 2115 |
record.patch_id += 1
|
|
@@ -2140,11 +2490,17 @@ def do_review_claim(
|
|
| 2140 |
record.graph.upsert_claims([updated])
|
| 2141 |
record.graph_version += 1
|
| 2142 |
# T3-01: emit MapEvent(review, before=claim.review_state, after=updated.review_state)
|
| 2143 |
-
|
| 2144 |
-
|
| 2145 |
-
|
| 2146 |
-
|
|
|
|
|
|
|
| 2147 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2148 |
return ReviewClaimResult(
|
| 2149 |
review_state=updated.review_state,
|
| 2150 |
graph_version=record.graph_version,
|
|
@@ -2196,6 +2552,44 @@ def review_node_claim(
|
|
| 2196 |
return do_review_claim(session_id, preferred[0].id, state)
|
| 2197 |
|
| 2198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2199 |
def focus_node(session_id: str, node_id: str) -> RendererPatch | None:
|
| 2200 |
"""Build a transient *focus* patch for a node: dim everything except the node
|
| 2201 |
and its currently-visible neighbours, then animate the camera to the node.
|
|
@@ -2292,7 +2686,10 @@ def expand_node(
|
|
| 2292 |
nid: effective_node_label(n, record.graph.claims.values())
|
| 2293 |
for nid, n in record.graph.nodes.items()
|
| 2294 |
}
|
| 2295 |
-
edge_label_map = {
|
|
|
|
|
|
|
|
|
|
| 2296 |
edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
|
| 2297 |
ops = diff_scene(
|
| 2298 |
old_scene,
|
|
@@ -2323,6 +2720,145 @@ def expand_node(
|
|
| 2323 |
return patch
|
| 2324 |
|
| 2325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2326 |
# ββ Agent-turn pipeline (called by agent_tools.finalize_agent_turn) βββββββββββ
|
| 2327 |
|
| 2328 |
# The agent composes a turn from many individually-validated tool calls; the legacy
|
|
@@ -2362,6 +2898,13 @@ def apply_agent_actions(
|
|
| 2362 |
)
|
| 2363 |
record.graph.apply_graph_patch(graph_patch)
|
| 2364 |
new_scene = record.graph.apply_scene_patch(record.scene, scene_patch)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2365 |
return _finalize_turn(
|
| 2366 |
record,
|
| 2367 |
prev_scene=record.scene,
|
|
@@ -2373,4 +2916,5 @@ def apply_agent_actions(
|
|
| 2373 |
status=_status(result),
|
| 2374 |
warnings=result.rejection_reasons,
|
| 2375 |
selection=selection,
|
|
|
|
| 2376 |
)
|
|
|
|
| 142 |
return overlay.text if overlay is not None else ""
|
| 143 |
|
| 144 |
|
| 145 |
+
def effective_edge_label(edge: Edge, claims: Iterable[Claim]) -> str:
|
| 146 |
+
"""Displayed edge label: newest user_asserted label claim, else edge.label.
|
| 147 |
+
|
| 148 |
+
The edge analogue of :func:`effective_node_label` β a user relabel (from the
|
| 149 |
+
agent's ``update_edge`` tool or the inline edge-edit UI) is stored as a
|
| 150 |
+
user_asserted overlay claim targeting the edge id; the raw ``edge.label`` is
|
| 151 |
+
preserved underneath so provenance is never destroyed.
|
| 152 |
+
"""
|
| 153 |
+
overlay = _latest_user_claim(claims, edge.id, claim_type="label")
|
| 154 |
+
return overlay.text if overlay is not None else edge.label
|
| 155 |
+
|
| 156 |
+
|
| 157 |
def _latest_user_claim(
|
| 158 |
claims: Iterable[Claim], node_id: str, *, claim_type: str
|
| 159 |
) -> Claim | None:
|
|
|
|
| 454 |
nid: effective_node_label(n, record.graph.claims.values())
|
| 455 |
for nid, n in record.graph.nodes.items()
|
| 456 |
}
|
| 457 |
+
edge_label_map = {
|
| 458 |
+
eid: effective_edge_label(e, record.graph.claims.values())
|
| 459 |
+
for eid, e in record.graph.edges.items()
|
| 460 |
+
}
|
| 461 |
edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
|
| 462 |
operations = diff_scene(
|
| 463 |
prev_scene,
|
|
|
|
| 652 |
nid: effective_node_label(n, repo.claims.values())
|
| 653 |
for nid, n in repo.nodes.items()
|
| 654 |
}
|
| 655 |
+
edge_label_map = {
|
| 656 |
+
eid: effective_edge_label(e, repo.claims.values())
|
| 657 |
+
for eid, e in repo.edges.items()
|
| 658 |
+
}
|
| 659 |
edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
|
| 660 |
ops = diff_scene(
|
| 661 |
SceneState(),
|
|
|
|
| 731 |
nid: effective_node_label(n, repo.claims.values())
|
| 732 |
for nid, n in repo.nodes.items()
|
| 733 |
}
|
| 734 |
+
edge_label_map = {
|
| 735 |
+
eid: effective_edge_label(e, repo.claims.values())
|
| 736 |
+
for eid, e in repo.edges.items()
|
| 737 |
+
}
|
| 738 |
edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
|
| 739 |
ops = diff_scene(
|
| 740 |
SceneState(),
|
|
|
|
| 810 |
nid: effective_node_label(n, repo.claims.values())
|
| 811 |
for nid, n in repo.nodes.items()
|
| 812 |
}
|
| 813 |
+
edge_label_map = {
|
| 814 |
+
eid: effective_edge_label(e, repo.claims.values())
|
| 815 |
+
for eid, e in repo.edges.items()
|
| 816 |
+
}
|
| 817 |
edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
|
| 818 |
ops = diff_scene(
|
| 819 |
SceneState(),
|
|
|
|
| 875 |
is_error=True,
|
| 876 |
)
|
| 877 |
return
|
| 878 |
+
# Success carries the snapshot + new session id but NO chat message: the
|
| 879 |
+
# conversational opening line is streamed separately by main.py via
|
| 880 |
+
# agent_harness.stream_build_opener, so turn one greets the user in the same
|
| 881 |
+
# warm agent voice as every later turn instead of a canned count template.
|
| 882 |
yield TextBuildProgress(
|
| 883 |
is_partial=False,
|
| 884 |
status="",
|
| 885 |
canvas_patch=snapshot.model_dump(mode="json"),
|
| 886 |
session_id=sid,
|
| 887 |
+
chat_message=[],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
)
|
| 889 |
|
| 890 |
|
|
|
|
| 943 |
nid: effective_node_label(n, repo.claims.values())
|
| 944 |
for nid, n in repo.nodes.items()
|
| 945 |
}
|
| 946 |
+
edge_label_map = {
|
| 947 |
+
eid: effective_edge_label(e, repo.claims.values())
|
| 948 |
+
for eid, e in repo.edges.items()
|
| 949 |
+
}
|
| 950 |
edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
|
| 951 |
ops = diff_scene(
|
| 952 |
SceneState(),
|
|
|
|
| 1218 |
)
|
| 1219 |
|
| 1220 |
|
| 1221 |
+
async def apply_user_edge_edit(
|
| 1222 |
+
session_id: str,
|
| 1223 |
+
edge_id: str,
|
| 1224 |
+
*,
|
| 1225 |
+
label: str,
|
| 1226 |
+
) -> TurnResult:
|
| 1227 |
+
"""Apply a user-asserted label overlay on an EDGE directly (no LLM round-trip).
|
| 1228 |
+
|
| 1229 |
+
Mirror of :func:`apply_user_edit` for edges: validates the edge exists, builds a
|
| 1230 |
+
``user_asserted`` label :class:`Claim` overlay (raw ``edge.label`` is preserved β
|
| 1231 |
+
only the effective label is updated), applies the claim, and emits an
|
| 1232 |
+
``update_data`` :class:`RendererCommand` carrying the new effective label so the
|
| 1233 |
+
canvas repaints immediately without a full diff. Commits an undo snapshot like its
|
| 1234 |
+
node sibling.
|
| 1235 |
+
|
| 1236 |
+
Raises ``KeyError`` for an unknown session or a missing edge id.
|
| 1237 |
+
"""
|
| 1238 |
+
record = session_store.get(session_id)
|
| 1239 |
+
if record is None:
|
| 1240 |
+
raise KeyError(session_id)
|
| 1241 |
+
edge = record.graph.get_edge(edge_id)
|
| 1242 |
+
if edge is None:
|
| 1243 |
+
raise KeyError(f"edge not found: {edge_id}")
|
| 1244 |
+
async with _get_lock(session_id):
|
| 1245 |
+
claim = Claim(
|
| 1246 |
+
id=_stable_id("claim", "label", "useredit", edge_id, label),
|
| 1247 |
+
claim_type="label",
|
| 1248 |
+
target_id=edge_id,
|
| 1249 |
+
text=label,
|
| 1250 |
+
origin="user_asserted",
|
| 1251 |
+
support_state="unverified",
|
| 1252 |
+
review_state="accepted",
|
| 1253 |
+
)
|
| 1254 |
+
graph_patch = GraphPatch(upsert_claims=[claim])
|
| 1255 |
+
scene_patch = ScenePatch()
|
| 1256 |
+
record.graph.apply_graph_patch(graph_patch)
|
| 1257 |
+
# Emit explicit update_data op so the canvas edge label refreshes immediately.
|
| 1258 |
+
effective = effective_edge_label(edge, record.graph.claims.values())
|
| 1259 |
+
extra_ops: list[RendererCommand] = []
|
| 1260 |
+
if edge_id in record.scene.visible_edge_ids:
|
| 1261 |
+
extra_ops.append(
|
| 1262 |
+
RendererCommand(
|
| 1263 |
+
op="update_data",
|
| 1264 |
+
data={"id": edge_id, "data": {"label": effective}},
|
| 1265 |
+
)
|
| 1266 |
+
)
|
| 1267 |
+
record.patch_id += 1
|
| 1268 |
+
patch = RendererPatch(
|
| 1269 |
+
patch_id=record.patch_id,
|
| 1270 |
+
is_snapshot=False,
|
| 1271 |
+
scene_id=record.render_scene_id,
|
| 1272 |
+
operations=extra_ops,
|
| 1273 |
+
)
|
| 1274 |
+
record.graph_version += 1
|
| 1275 |
+
record.last_seen = time.time()
|
| 1276 |
+
_commit_snapshot(record)
|
| 1277 |
+
selection: dict[str, Any] = {"kind": "edge", "id": edge_id}
|
| 1278 |
+
return TurnResult(
|
| 1279 |
+
renderer_patch=patch,
|
| 1280 |
+
inspector_update=_inspector_update(record, selection),
|
| 1281 |
+
assistant_message=f"Updated edge {edge_id}.",
|
| 1282 |
+
scene_patch=scene_patch,
|
| 1283 |
+
status="success",
|
| 1284 |
+
warnings=[],
|
| 1285 |
+
)
|
| 1286 |
+
|
| 1287 |
+
|
| 1288 |
async def apply_user_edge(
|
| 1289 |
session_id: str,
|
| 1290 |
source_id: str,
|
|
|
|
| 1396 |
)
|
| 1397 |
|
| 1398 |
|
| 1399 |
+
async def remove_user_node(session_id: str, node_id: str) -> TurnResult:
|
| 1400 |
+
"""Remove a visible node directly (user-trusted, no LLM) β the radial menu's
|
| 1401 |
+
"Delete node" command (confirmed in-canvas before it reaches here).
|
| 1402 |
+
|
| 1403 |
+
Mirrors :func:`remove_user_edge`: the user *proposes* the deletion, the
|
| 1404 |
+
validator + reducer still *decide*. The validator's ``would_empty_scene`` guard
|
| 1405 |
+
keeps the last visible node, and the reducer cascades incident visible edges and
|
| 1406 |
+
emits ``actor="user"`` β both free. Serialised under the per-session lock. An
|
| 1407 |
+
unknown / non-visible node degrades to a graceful failure (no mutation); an
|
| 1408 |
+
unknown session raises ``KeyError``.
|
| 1409 |
+
"""
|
| 1410 |
+
record = session_store.get(session_id)
|
| 1411 |
+
if record is None:
|
| 1412 |
+
raise KeyError(session_id)
|
| 1413 |
+
selection: dict[str, Any] = {"kind": "none", "id": ""}
|
| 1414 |
+
async with _get_lock(session_id):
|
| 1415 |
+
if node_id not in record.scene.visible_node_ids:
|
| 1416 |
+
return _graceful_failure(record, selection, ["node not visible"])
|
| 1417 |
+
plan = ScenePlan(
|
| 1418 |
+
actions=[SceneAction(type=SceneActionType.remove_node, target_id=node_id)],
|
| 1419 |
+
summary=f"Removing node {node_id}.",
|
| 1420 |
+
)
|
| 1421 |
+
val_result = validate_sceneplan(plan, record.graph, record.scene)
|
| 1422 |
+
if not val_result.valid_actions:
|
| 1423 |
+
return _graceful_failure(record, selection, val_result.rejection_reasons)
|
| 1424 |
+
graph_patch, scene_patch, _events = reduce_turn(
|
| 1425 |
+
val_result.valid_actions, record.graph, record.scene
|
| 1426 |
+
)
|
| 1427 |
+
record.graph.apply_graph_patch(graph_patch)
|
| 1428 |
+
new_scene = record.graph.apply_scene_patch(record.scene, scene_patch)
|
| 1429 |
+
return _finalize_turn(
|
| 1430 |
+
record,
|
| 1431 |
+
prev_scene=record.scene,
|
| 1432 |
+
new_scene=new_scene,
|
| 1433 |
+
revealed_ids=[],
|
| 1434 |
+
graph_mutated=True,
|
| 1435 |
+
assistant_message="Removed node.",
|
| 1436 |
+
scene_patch=scene_patch,
|
| 1437 |
+
status="success",
|
| 1438 |
+
warnings=[],
|
| 1439 |
+
selection=selection,
|
| 1440 |
+
)
|
| 1441 |
+
|
| 1442 |
+
|
| 1443 |
+
def _place_user_node(scene: SceneState, node_id: str) -> SceneState:
|
| 1444 |
+
"""Return a copy of ``scene`` with ``node_id`` placed on the open periphery.
|
| 1445 |
+
|
| 1446 |
+
A user-added node is an orphan (no edges yet), so it has no positioned visible
|
| 1447 |
+
neighbour β :func:`_assign_reveal_positions` would drop it on the cluster
|
| 1448 |
+
centroid (on top of the existing graph). Instead we place it deterministically
|
| 1449 |
+
to the right of the rightmost positioned node, at the vertical centroid; if no
|
| 1450 |
+
positions exist yet, a fixed default. ``_assign_reveal_positions`` then skips it
|
| 1451 |
+
because the entry already exists.
|
| 1452 |
+
"""
|
| 1453 |
+
positions = dict(scene.node_positions)
|
| 1454 |
+
if positions:
|
| 1455 |
+
new_x = max(x for x, _y in positions.values()) + 200.0
|
| 1456 |
+
new_y = sum(y for _x, y in positions.values()) / len(positions)
|
| 1457 |
+
else:
|
| 1458 |
+
new_x, new_y = 400.0, 400.0
|
| 1459 |
+
positions[node_id] = (new_x, new_y)
|
| 1460 |
+
return scene.model_copy(update={"node_positions": positions})
|
| 1461 |
+
|
| 1462 |
+
|
| 1463 |
+
async def apply_user_node(session_id: str, label: str, summary: str = "") -> TurnResult:
|
| 1464 |
+
"""Create a user_asserted node directly (no LLM round-trip) β the radial menu's
|
| 1465 |
+
"Add node" command.
|
| 1466 |
+
|
| 1467 |
+
Mirrors :func:`apply_user_edge`: mints a unique node id, builds the node + its
|
| 1468 |
+
``user_asserted`` label (and optional summary) claim(s), reveals it, places it on
|
| 1469 |
+
the open periphery, and finalizes so the renderer diff + camera re-fit fire.
|
| 1470 |
+
Empty label raises ``ValueError``; unknown session raises ``KeyError``.
|
| 1471 |
+
"""
|
| 1472 |
+
record = session_store.get(session_id)
|
| 1473 |
+
if record is None:
|
| 1474 |
+
raise KeyError(session_id)
|
| 1475 |
+
label = label.strip()
|
| 1476 |
+
if not label:
|
| 1477 |
+
raise ValueError("apply_user_node requires a non-empty label")
|
| 1478 |
+
async with _get_lock(session_id):
|
| 1479 |
+
# Deterministic, collision-free id: append a numeric suffix until unique.
|
| 1480 |
+
node_id = _stable_id("usernode", label)
|
| 1481 |
+
if node_id in record.graph.nodes:
|
| 1482 |
+
suffix = 2
|
| 1483 |
+
while f"{node_id}-{suffix}" in record.graph.nodes:
|
| 1484 |
+
suffix += 1
|
| 1485 |
+
node_id = f"{node_id}-{suffix}"
|
| 1486 |
+
node = Node(
|
| 1487 |
+
id=node_id,
|
| 1488 |
+
kind="concept",
|
| 1489 |
+
label=label,
|
| 1490 |
+
properties={"origin": "user_asserted"},
|
| 1491 |
+
)
|
| 1492 |
+
claims: list[Claim] = [
|
| 1493 |
+
Claim(
|
| 1494 |
+
id=_stable_id("claim", "label", node_id, label),
|
| 1495 |
+
claim_type="label",
|
| 1496 |
+
target_id=node_id,
|
| 1497 |
+
text=label,
|
| 1498 |
+
origin="user_asserted",
|
| 1499 |
+
support_state="unverified",
|
| 1500 |
+
review_state="accepted",
|
| 1501 |
+
)
|
| 1502 |
+
]
|
| 1503 |
+
if summary:
|
| 1504 |
+
claims.append(
|
| 1505 |
+
Claim(
|
| 1506 |
+
id=_stable_id("claim", "summary", node_id, summary),
|
| 1507 |
+
claim_type="summary",
|
| 1508 |
+
target_id=node_id,
|
| 1509 |
+
text=summary,
|
| 1510 |
+
origin="user_asserted",
|
| 1511 |
+
support_state="unverified",
|
| 1512 |
+
review_state="accepted",
|
| 1513 |
+
)
|
| 1514 |
+
)
|
| 1515 |
+
graph_patch = GraphPatch(upsert_nodes=[node], upsert_claims=claims)
|
| 1516 |
+
scene_patch = ScenePatch(add_visible_node_ids=[node_id])
|
| 1517 |
+
record.graph.apply_graph_patch(graph_patch)
|
| 1518 |
+
new_scene = record.graph.apply_scene_patch(record.scene, scene_patch)
|
| 1519 |
+
# An orphan node has no positioned neighbour β place it on the periphery so
|
| 1520 |
+
# _assign_reveal_positions doesn't drop it on the centroid (on the graph).
|
| 1521 |
+
new_scene = _place_user_node(new_scene, node_id)
|
| 1522 |
+
selection: dict[str, Any] = {"kind": "node", "id": node_id}
|
| 1523 |
+
return _finalize_turn(
|
| 1524 |
+
record,
|
| 1525 |
+
prev_scene=record.scene,
|
| 1526 |
+
new_scene=new_scene,
|
| 1527 |
+
revealed_ids=[node_id],
|
| 1528 |
+
graph_mutated=True,
|
| 1529 |
+
assistant_message=f"Added {label}.",
|
| 1530 |
+
scene_patch=scene_patch,
|
| 1531 |
+
status="success",
|
| 1532 |
+
warnings=[],
|
| 1533 |
+
selection=selection,
|
| 1534 |
+
)
|
| 1535 |
+
|
| 1536 |
+
|
| 1537 |
# ββ Intent classification (pure; routes navigate vs. expand) ββββββββββββββββββ
|
| 1538 |
|
| 1539 |
_EXPAND_INTENT_RE = re.compile(
|
|
|
|
| 1994 |
status: str,
|
| 1995 |
warnings: list[str],
|
| 1996 |
selection: dict[str, Any],
|
| 1997 |
+
relabeled_ids: list[str] | None = None,
|
| 1998 |
) -> TurnResult:
|
| 1999 |
"""Replenish fog, lay out reveals, diff to a RendererPatch, and persist.
|
| 2000 |
|
| 2001 |
Shared by ``_navigate_turn`` and ``_expand_turn`` so both paths produce
|
| 2002 |
+
identical render/camera/lineage behaviour. ``relabeled_ids`` lists node/edge ids
|
| 2003 |
+
whose overlay label changed this turn: diff_scene only writes labels onto
|
| 2004 |
+
newly-added elements, so a pure relabel of a persisting element needs an explicit
|
| 2005 |
+
``update_data`` op (the mechanism apply_user_edit already uses for inline edits).
|
| 2006 |
"""
|
| 2007 |
if revealed_ids:
|
| 2008 |
replenished = record.graph.fog_replenish(new_scene, max_fog=_MAX_FOG)
|
|
|
|
| 2019 |
nid: effective_node_label(n, record.graph.claims.values())
|
| 2020 |
for nid, n in record.graph.nodes.items()
|
| 2021 |
}
|
| 2022 |
+
edge_label_map = {
|
| 2023 |
+
eid: effective_edge_label(e, record.graph.claims.values())
|
| 2024 |
+
for eid, e in record.graph.edges.items()
|
| 2025 |
+
}
|
| 2026 |
edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
|
| 2027 |
operations = diff_scene(
|
| 2028 |
prev_scene,
|
|
|
|
| 2036 |
)
|
| 2037 |
if operations:
|
| 2038 |
new_scene = new_scene.model_copy(update={"scene_id": str(uuid.uuid4())})
|
| 2039 |
+
# Explicit label refresh for elements relabeled THIS turn that persist (visible in
|
| 2040 |
+
# both scenes): diff_scene only writes labels onto newly-added elements, so a pure
|
| 2041 |
+
# overlay relabel of a still-visible node/edge needs an update_data op of its own.
|
| 2042 |
+
for rid in relabeled_ids or []:
|
| 2043 |
+
edge = record.graph.get_edge(rid)
|
| 2044 |
+
if edge is not None:
|
| 2045 |
+
if rid in new_scene.visible_edge_ids:
|
| 2046 |
+
operations.append(
|
| 2047 |
+
RendererCommand(
|
| 2048 |
+
op="update_data",
|
| 2049 |
+
data={
|
| 2050 |
+
"id": rid,
|
| 2051 |
+
"data": {
|
| 2052 |
+
"label": effective_edge_label(
|
| 2053 |
+
edge, record.graph.claims.values()
|
| 2054 |
+
)
|
| 2055 |
+
},
|
| 2056 |
+
},
|
| 2057 |
+
)
|
| 2058 |
+
)
|
| 2059 |
+
continue
|
| 2060 |
+
node = record.graph.get_node(rid)
|
| 2061 |
+
if node is not None and rid in new_scene.visible_node_ids:
|
| 2062 |
+
operations.append(
|
| 2063 |
+
RendererCommand(
|
| 2064 |
+
op="update_data",
|
| 2065 |
+
data={
|
| 2066 |
+
"id": rid,
|
| 2067 |
+
"data": {
|
| 2068 |
+
"label": effective_node_label(
|
| 2069 |
+
node, record.graph.claims.values()
|
| 2070 |
+
)
|
| 2071 |
+
},
|
| 2072 |
+
},
|
| 2073 |
+
)
|
| 2074 |
+
)
|
| 2075 |
# After any reveal, re-fit the camera so newly-placed nodes are in view.
|
| 2076 |
if revealed_ids and new_scene.visible_node_ids:
|
| 2077 |
operations.append(
|
|
|
|
| 2275 |
}
|
| 2276 |
|
| 2277 |
|
| 2278 |
+
def _claim_summaries(claims: list[Any]) -> list[_ClaimSummary]:
|
| 2279 |
+
"""Project full Claim objects to the inspector's claim-summary shape."""
|
| 2280 |
+
return [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2281 |
{
|
| 2282 |
"id": c.id,
|
| 2283 |
"claim_type": c.claim_type,
|
|
|
|
| 2286 |
"support_state": c.support_state,
|
| 2287 |
"review_state": c.review_state,
|
| 2288 |
}
|
| 2289 |
+
for c in claims
|
| 2290 |
]
|
| 2291 |
+
|
| 2292 |
+
|
| 2293 |
+
def _inspector_update(
|
| 2294 |
+
record: SessionRecord, selection: dict[str, Any]
|
| 2295 |
+
) -> InspectorPayload | None:
|
| 2296 |
+
"""Inspector payload for a selected NODE or EDGE; ``None`` otherwise.
|
| 2297 |
+
|
| 2298 |
+
Edges expose their governing claims too, so a model-inferred *proposed* edge
|
| 2299 |
+
(e.g. a "find a hidden connection" result) is reviewable via the same
|
| 2300 |
+
Accept/Reject affordance as nodes β the review loop applies to relationships,
|
| 2301 |
+
not just concepts.
|
| 2302 |
+
"""
|
| 2303 |
+
kind = selection.get("kind")
|
| 2304 |
+
sel_id = selection.get("id")
|
| 2305 |
+
if not sel_id:
|
| 2306 |
+
return None
|
| 2307 |
+
if kind == "node":
|
| 2308 |
+
node = record.graph.get_node(sel_id)
|
| 2309 |
+
if node is None:
|
| 2310 |
+
return None
|
| 2311 |
+
effective = effective_node_label(node, record.graph.claims.values())
|
| 2312 |
+
governing = [c for c in record.graph.claims.values() if c.target_id == sel_id]
|
| 2313 |
+
return {
|
| 2314 |
+
"selected_id": node.id,
|
| 2315 |
+
"label": effective,
|
| 2316 |
+
"original_label": node.label,
|
| 2317 |
+
"kind": node.kind,
|
| 2318 |
+
"edited": effective != node.label,
|
| 2319 |
+
"trust": _aggregate_trust(governing),
|
| 2320 |
+
"governing_claims": _claim_summaries(governing),
|
| 2321 |
+
"evidence_count": len(node.evidence_refs),
|
| 2322 |
+
}
|
| 2323 |
+
if kind == "edge":
|
| 2324 |
+
edge = record.graph.edges.get(sel_id)
|
| 2325 |
+
if edge is None:
|
| 2326 |
+
return None
|
| 2327 |
+
governing = [c for c in record.graph.claims.values() if c.target_id == sel_id]
|
| 2328 |
+
return {
|
| 2329 |
+
"selected_id": edge.id,
|
| 2330 |
+
"label": edge.label or edge.kind,
|
| 2331 |
+
"original_label": edge.label,
|
| 2332 |
+
"kind": edge.kind,
|
| 2333 |
+
"edited": False,
|
| 2334 |
+
"trust": _aggregate_trust(governing),
|
| 2335 |
+
"governing_claims": _claim_summaries(governing),
|
| 2336 |
+
"evidence_count": 0,
|
| 2337 |
+
}
|
| 2338 |
+
return None
|
| 2339 |
|
| 2340 |
|
| 2341 |
def inspector_for_selection(
|
|
|
|
| 2376 |
renderer_patch: RendererPatch # proper patch for Gradio β CytoscapeCanvas
|
| 2377 |
|
| 2378 |
|
| 2379 |
+
def _target_fully_reviewed(graph: LooseGraphRepository, target_id: str) -> bool:
|
| 2380 |
+
"""True iff no model_inferred claim on ``target_id`` is still awaiting review.
|
| 2381 |
+
|
| 2382 |
+
The amber ``review-pending`` affordance covers a node/edge as a whole, but a
|
| 2383 |
+
single target may govern several ``model_inferred`` claims (seed nodes carry
|
| 2384 |
+
2β3). The amber may only clear once EVERY model_inferred claim on the target
|
| 2385 |
+
has been reviewed (``review_state != "pending"`` β i.e. accepted or rejected);
|
| 2386 |
+
while any remain pending the affordance must stay up. Non-model_inferred claims
|
| 2387 |
+
(user_asserted / deterministic) never drive the amber, so they're ignored.
|
| 2388 |
+
"""
|
| 2389 |
+
return not any(
|
| 2390 |
+
c.target_id == target_id
|
| 2391 |
+
and c.origin == "model_inferred"
|
| 2392 |
+
and c.review_state == "pending"
|
| 2393 |
+
for c in graph.claims.values()
|
| 2394 |
+
)
|
| 2395 |
+
|
| 2396 |
+
|
| 2397 |
+
def _review_canvas_hint_dict(claim: Any, graph: LooseGraphRepository) -> dict[str, Any]:
|
| 2398 |
+
"""Produce the canvas-hint dict for a just-reviewed claim.
|
| 2399 |
+
|
| 2400 |
+
``clear_pending`` (which drops the amber awaiting-review border) fires when:
|
| 2401 |
+
- the target has NO remaining pending model_inferred claims after accept; or
|
| 2402 |
+
- the claim's target is a NODE that was just rejected (nodes are durable entities
|
| 2403 |
+
β rejecting a governing claim must NOT remove the node from canvas; the amber
|
| 2404 |
+
badge is simply cleared, matching the accept path's badge-clear behaviour).
|
| 2405 |
+
|
| 2406 |
+
For EDGE claims, rejection keeps the ``remove`` action β a rejected proposed
|
| 2407 |
+
relationship revokes the edge from canvas (the intended behaviour, locked by
|
| 2408 |
+
``test_edge_claim_review.py::test_reject_edge_claim_still_removes_edge``).
|
| 2409 |
+
"""
|
| 2410 |
if claim.review_state == "accepted":
|
| 2411 |
+
if _target_fully_reviewed(graph, claim.target_id):
|
| 2412 |
+
return {"action": "clear_pending", "target_id": claim.target_id}
|
| 2413 |
+
return {"action": "none", "target_id": claim.target_id}
|
| 2414 |
if claim.review_state == "rejected":
|
| 2415 |
+
# Distinguish node vs edge: nodes are retained on canvas; edges are revoked.
|
| 2416 |
+
if claim.target_id in graph.nodes:
|
| 2417 |
+
# Node claim rejection β keep the node; only clear the amber badge.
|
| 2418 |
+
if _target_fully_reviewed(graph, claim.target_id):
|
| 2419 |
+
return {"action": "clear_pending", "target_id": claim.target_id}
|
| 2420 |
+
return {"action": "none", "target_id": claim.target_id}
|
| 2421 |
+
# Edge claim rejection β remove the proposed relationship from canvas.
|
| 2422 |
return {"action": "remove", "target_id": claim.target_id}
|
| 2423 |
return {"action": "none", "target_id": claim.target_id}
|
| 2424 |
|
| 2425 |
|
| 2426 |
def _canvas_hint_to_renderer_patch(
|
| 2427 |
+
record: SessionRecord,
|
| 2428 |
+
hint: dict[str, Any],
|
| 2429 |
+
review_state: str = "",
|
| 2430 |
) -> RendererPatch:
|
| 2431 |
+
"""Convert a canvas-hint dict to a proper RendererPatch for the Gradio layer.
|
| 2432 |
+
|
| 2433 |
+
``review_state`` is the snake_case value of the just-reviewed claim after
|
| 2434 |
+
transition (e.g. ``"accepted"`` or ``"rejected"``). When the action is
|
| 2435 |
+
``"clear_pending"`` an ``update_data`` op is also emitted so the element's
|
| 2436 |
+
Cytoscape ``data('review')`` field is refreshed β without it the hover tooltip
|
| 2437 |
+
still says "pending review" even after the amber badge is cleared.
|
| 2438 |
+
"""
|
| 2439 |
ops: list[RendererCommand] = []
|
| 2440 |
action = hint.get("action", "none")
|
| 2441 |
target_id = hint.get("target_id", "")
|
|
|
|
| 2444 |
RendererCommand(
|
| 2445 |
op="remove_class",
|
| 2446 |
selector=f"#{target_id}",
|
| 2447 |
+
# Must match the stylesheet selector `.review-pending` (renderer.ts) β
|
| 2448 |
+
# the class is hyphenated from the snake_case review_state "pending".
|
| 2449 |
+
# A prior typo ("pending-review") meant Accept never cleared the amber
|
| 2450 |
+
# awaiting-review border. Keep this string and the stylesheet in sync.
|
| 2451 |
+
data={"class": "review-pending"},
|
| 2452 |
)
|
| 2453 |
)
|
| 2454 |
+
# Also refresh the element's data('review') so the hover tooltip reflects the
|
| 2455 |
+
# new state rather than the stale "pending" value set at add_element time.
|
| 2456 |
+
if review_state:
|
| 2457 |
+
ops.append(
|
| 2458 |
+
RendererCommand(
|
| 2459 |
+
op="update_data",
|
| 2460 |
+
data={"id": target_id, "data": {"review": review_state}},
|
| 2461 |
+
)
|
| 2462 |
+
)
|
| 2463 |
elif action == "remove":
|
| 2464 |
ops.append(RendererCommand(op="remove_element", selector=f"#{target_id}"))
|
| 2465 |
record.patch_id += 1
|
|
|
|
| 2490 |
record.graph.upsert_claims([updated])
|
| 2491 |
record.graph_version += 1
|
| 2492 |
# T3-01: emit MapEvent(review, before=claim.review_state, after=updated.review_state)
|
| 2493 |
+
# Pass the live graph (already carrying the upserted ``updated`` claim) so the
|
| 2494 |
+
# hint only clears the amber when the WHOLE target is reviewed, not on the first
|
| 2495 |
+
# of several model_inferred claims.
|
| 2496 |
+
hint = _review_canvas_hint_dict(updated, record.graph)
|
| 2497 |
+
patch = _canvas_hint_to_renderer_patch(
|
| 2498 |
+
record, hint, review_state=updated.review_state
|
| 2499 |
)
|
| 2500 |
+
# Infer kind dynamically: hardcoding "node" was wrong for edge claims and
|
| 2501 |
+
# produced an inspector for the wrong element type. Determine from the graph.
|
| 2502 |
+
_kind = "edge" if updated.target_id in record.graph.edges else "node"
|
| 2503 |
+
insp = inspector_for_selection(session_id, {"kind": _kind, "id": updated.target_id})
|
| 2504 |
return ReviewClaimResult(
|
| 2505 |
review_state=updated.review_state,
|
| 2506 |
graph_version=record.graph_version,
|
|
|
|
| 2552 |
return do_review_claim(session_id, preferred[0].id, state)
|
| 2553 |
|
| 2554 |
|
| 2555 |
+
def review_edge_claim(
|
| 2556 |
+
session_id: str,
|
| 2557 |
+
edge_id: str,
|
| 2558 |
+
state: str,
|
| 2559 |
+
) -> ReviewClaimResult | None:
|
| 2560 |
+
"""Transition the review_state of the most meaningful governing claim of an EDGE.
|
| 2561 |
+
|
| 2562 |
+
Mirror of :func:`review_node_claim` β the radial context menu can fire Accept/
|
| 2563 |
+
Reject on an EDGE as well as a node. Resolves the edge's governing reviewable
|
| 2564 |
+
claim using the same priority logic (skip ``open_question``, prefer
|
| 2565 |
+
``model_inferred`` whose ``review_state`` gates the export), then delegates to
|
| 2566 |
+
:func:`do_review_claim`.
|
| 2567 |
+
|
| 2568 |
+
Returns ``None`` when the edge has no reviewable governing claims β a graceful
|
| 2569 |
+
no-op. Propagates ``InvalidTransitionError`` on an illegal transition and
|
| 2570 |
+
``KeyError`` for an unknown session/claim; the caller (``on_select``) maps these
|
| 2571 |
+
to its own idiom.
|
| 2572 |
+
"""
|
| 2573 |
+
record = session_store.get(session_id)
|
| 2574 |
+
if record is None or edge_id not in record.graph.edges:
|
| 2575 |
+
return None
|
| 2576 |
+
insp = inspector_for_selection(session_id, {"kind": "edge", "id": edge_id})
|
| 2577 |
+
governing = (insp.get("governing_claims") if insp is not None else None) or []
|
| 2578 |
+
if not governing:
|
| 2579 |
+
return None
|
| 2580 |
+
resolved = [
|
| 2581 |
+
record.graph.claims[c["id"]]
|
| 2582 |
+
for c in governing
|
| 2583 |
+
if c.get("id") in record.graph.claims
|
| 2584 |
+
]
|
| 2585 |
+
reviewable = [c for c in resolved if c.claim_type != "open_question"] or resolved
|
| 2586 |
+
if not reviewable:
|
| 2587 |
+
return None
|
| 2588 |
+
actionable = [c for c in reviewable if c.review_state != "accepted"] or reviewable
|
| 2589 |
+
preferred = [c for c in actionable if c.origin == "model_inferred"] or actionable
|
| 2590 |
+
return do_review_claim(session_id, preferred[0].id, state)
|
| 2591 |
+
|
| 2592 |
+
|
| 2593 |
def focus_node(session_id: str, node_id: str) -> RendererPatch | None:
|
| 2594 |
"""Build a transient *focus* patch for a node: dim everything except the node
|
| 2595 |
and its currently-visible neighbours, then animate the camera to the node.
|
|
|
|
| 2686 |
nid: effective_node_label(n, record.graph.claims.values())
|
| 2687 |
for nid, n in record.graph.nodes.items()
|
| 2688 |
}
|
| 2689 |
+
edge_label_map = {
|
| 2690 |
+
eid: effective_edge_label(e, record.graph.claims.values())
|
| 2691 |
+
for eid, e in record.graph.edges.items()
|
| 2692 |
+
}
|
| 2693 |
edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
|
| 2694 |
ops = diff_scene(
|
| 2695 |
old_scene,
|
|
|
|
| 2720 |
return patch
|
| 2721 |
|
| 2722 |
|
| 2723 |
+
# Max outstanding (still-pending) model-inferred hypothesis edges per session.
|
| 2724 |
+
# "Find a hidden connection" proposes a fresh cross-cluster bridge each click; as
|
| 2725 |
+
# accepted/rejected ones drop out of the pending set the budget refills, so this
|
| 2726 |
+
# caps how many AMBER awaiting-review hypotheses can pile up at once β it does not
|
| 2727 |
+
# cap the lifetime total.
|
| 2728 |
+
_MAX_PENDING_HYPOTHESES = 4
|
| 2729 |
+
|
| 2730 |
+
|
| 2731 |
+
def _pending_hypothesis_count(graph: LooseGraphRepository) -> int:
|
| 2732 |
+
"""Count outstanding model-inferred hypothesis edges still awaiting review.
|
| 2733 |
+
|
| 2734 |
+
A hypothesis edge is governed by a ``model_inferred`` / ``review_state="pending"``
|
| 2735 |
+
relationship claim whose ``target_id`` is a ``hypedge`` edge id (the prefix
|
| 2736 |
+
minted by :func:`find_connection_session`).
|
| 2737 |
+
"""
|
| 2738 |
+
return sum(
|
| 2739 |
+
1
|
| 2740 |
+
for c in graph.claims.values()
|
| 2741 |
+
if c.origin == "model_inferred"
|
| 2742 |
+
and c.review_state == "pending"
|
| 2743 |
+
and c.target_id.startswith("hypedge")
|
| 2744 |
+
and c.target_id in graph.edges
|
| 2745 |
+
)
|
| 2746 |
+
|
| 2747 |
+
|
| 2748 |
+
async def find_connection_session(session_id: str) -> RendererPatch | None:
|
| 2749 |
+
""" "Find a hidden connection": surface a SURPRISING cross-cluster link as a
|
| 2750 |
+
model-inferred *proposal* (amber, awaiting review) β never auto-applied.
|
| 2751 |
+
|
| 2752 |
+
Picks two visible nodes in different communities that aren't already linked but
|
| 2753 |
+
sit on a short structural bridge (``scene_curator.find_bridge_candidate``), then
|
| 2754 |
+
proposes a ``model_inferred`` / ``review_state="pending"`` hypothesis edge between
|
| 2755 |
+
them, flashes it, and pans the camera to it. The edge carries a governing pending
|
| 2756 |
+
Claim so it renders as the amber awaiting-review affordance and is reviewable /
|
| 2757 |
+
redacted-on-export exactly like any other model proposal (trust model intact).
|
| 2758 |
+
|
| 2759 |
+
Mirrors :func:`expand_node`'s self-contained diff path (not the LLM pipeline).
|
| 2760 |
+
Returns ``None`` for an unknown session, when there's no cross-cluster candidate
|
| 2761 |
+
(single cluster / all pairs already linked), or when there are already
|
| 2762 |
+
``_MAX_PENDING_HYPOTHESES`` outstanding pending hypotheses β all graceful no-ops.
|
| 2763 |
+
"""
|
| 2764 |
+
record = session_store.get(session_id)
|
| 2765 |
+
if record is None:
|
| 2766 |
+
return None
|
| 2767 |
+
async with _get_lock(session_id):
|
| 2768 |
+
# Cap outstanding amber hypotheses: each click adds a NEW cross-cluster pair
|
| 2769 |
+
# (the bridge finder skips existing-edge pairs, so a fresh pair never collides
|
| 2770 |
+
# β the old ``edge_id in record.graph.edges`` guard was dead). Without this cap
|
| 2771 |
+
# repeated clicks flood the canvas with unreviewed proposals. Review some first
|
| 2772 |
+
# to refill the budget.
|
| 2773 |
+
if _pending_hypothesis_count(record.graph) >= _MAX_PENDING_HYPOTHESES:
|
| 2774 |
+
return None
|
| 2775 |
+
|
| 2776 |
+
from loosecanvas.scene_curator import (
|
| 2777 |
+
compute_node_clusters,
|
| 2778 |
+
find_bridge_candidate,
|
| 2779 |
+
)
|
| 2780 |
+
|
| 2781 |
+
clusters = compute_node_clusters(record.graph)
|
| 2782 |
+
pair = find_bridge_candidate(
|
| 2783 |
+
record.graph, clusters, list(record.scene.visible_node_ids)
|
| 2784 |
+
)
|
| 2785 |
+
if pair is None:
|
| 2786 |
+
return None
|
| 2787 |
+
src, tgt = pair
|
| 2788 |
+
label = "might relate to"
|
| 2789 |
+
edge_id = _stable_id("hypedge", src, tgt, label)
|
| 2790 |
+
|
| 2791 |
+
# The model proposes a hypothesis edge; the governing pending Claim is what
|
| 2792 |
+
# makes it render amber + reviewable (edge.properties alone don't drive the
|
| 2793 |
+
# trust map). Origin is frozen model_inferred β review never upgrades it.
|
| 2794 |
+
edge = Edge(
|
| 2795 |
+
id=edge_id,
|
| 2796 |
+
source=src,
|
| 2797 |
+
target=tgt,
|
| 2798 |
+
type="hypothesis",
|
| 2799 |
+
label=label,
|
| 2800 |
+
properties={"origin": "model_inferred"},
|
| 2801 |
+
)
|
| 2802 |
+
edge_claim = Claim(
|
| 2803 |
+
id=_stable_id("claim", "relationship", edge_id, label),
|
| 2804 |
+
claim_type="relationship",
|
| 2805 |
+
target_id=edge_id,
|
| 2806 |
+
text=label,
|
| 2807 |
+
origin="model_inferred",
|
| 2808 |
+
support_state="unverified",
|
| 2809 |
+
review_state="pending",
|
| 2810 |
+
)
|
| 2811 |
+
graph_patch = GraphPatch(upsert_edges=[edge], upsert_claims=[edge_claim])
|
| 2812 |
+
scene_patch = ScenePatch(add_visible_edge_ids=[edge_id])
|
| 2813 |
+
|
| 2814 |
+
old_scene = record.scene
|
| 2815 |
+
record.graph.apply_graph_patch(graph_patch)
|
| 2816 |
+
candidate = record.graph.apply_scene_patch(old_scene, scene_patch)
|
| 2817 |
+
|
| 2818 |
+
edge_map = {eid: (e.source, e.target) for eid, e in record.graph.edges.items()}
|
| 2819 |
+
node_map = {
|
| 2820 |
+
nid: effective_node_label(n, record.graph.claims.values())
|
| 2821 |
+
for nid, n in record.graph.nodes.items()
|
| 2822 |
+
}
|
| 2823 |
+
edge_label_map = {
|
| 2824 |
+
eid: effective_edge_label(e, record.graph.claims.values())
|
| 2825 |
+
for eid, e in record.graph.edges.items()
|
| 2826 |
+
}
|
| 2827 |
+
edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
|
| 2828 |
+
ops = diff_scene(
|
| 2829 |
+
old_scene,
|
| 2830 |
+
candidate,
|
| 2831 |
+
edge_map=edge_map,
|
| 2832 |
+
node_map=node_map,
|
| 2833 |
+
edge_label_map=edge_label_map,
|
| 2834 |
+
edge_kind_map=edge_kind_map,
|
| 2835 |
+
node_trust_map=_build_node_trust_map(record.graph),
|
| 2836 |
+
edge_trust_map=_build_edge_trust_map(record.graph),
|
| 2837 |
+
)
|
| 2838 |
+
ops.append(
|
| 2839 |
+
RendererCommand(op="flash", data={"ids": [edge_id], "duration": 1400})
|
| 2840 |
+
)
|
| 2841 |
+
ops.append(
|
| 2842 |
+
RendererCommand(
|
| 2843 |
+
op="animate_camera",
|
| 2844 |
+
data={"fit_selector": f"#{edge_id}", "padding": 80},
|
| 2845 |
+
)
|
| 2846 |
+
)
|
| 2847 |
+
|
| 2848 |
+
record.scene = candidate # atomic: only after diff_scene succeeded
|
| 2849 |
+
record.graph_version += 1
|
| 2850 |
+
record.last_seen = time.time()
|
| 2851 |
+
record.patch_id += 1
|
| 2852 |
+
patch = RendererPatch(
|
| 2853 |
+
patch_id=record.patch_id,
|
| 2854 |
+
is_snapshot=False,
|
| 2855 |
+
scene_id=record.render_scene_id,
|
| 2856 |
+
operations=ops,
|
| 2857 |
+
)
|
| 2858 |
+
_commit_snapshot(record)
|
| 2859 |
+
return patch
|
| 2860 |
+
|
| 2861 |
+
|
| 2862 |
# ββ Agent-turn pipeline (called by agent_tools.finalize_agent_turn) βββββββββββ
|
| 2863 |
|
| 2864 |
# The agent composes a turn from many individually-validated tool calls; the legacy
|
|
|
|
| 2898 |
)
|
| 2899 |
record.graph.apply_graph_patch(graph_patch)
|
| 2900 |
new_scene = record.graph.apply_scene_patch(record.scene, scene_patch)
|
| 2901 |
+
# Label overlays upserted this turn (update_edge / update_node) need an explicit
|
| 2902 |
+
# render refresh β diff_scene won't repaint a persisting element's label.
|
| 2903 |
+
relabeled_ids = [
|
| 2904 |
+
claim.target_id
|
| 2905 |
+
for claim in graph_patch.upsert_claims
|
| 2906 |
+
if claim.claim_type == "label"
|
| 2907 |
+
]
|
| 2908 |
return _finalize_turn(
|
| 2909 |
record,
|
| 2910 |
prev_scene=record.scene,
|
|
|
|
| 2916 |
status=_status(result),
|
| 2917 |
warnings=result.rejection_reasons,
|
| 2918 |
selection=selection,
|
| 2919 |
+
relabeled_ids=relabeled_ids,
|
| 2920 |
)
|
src/loosecanvas/validator.py
CHANGED
|
@@ -244,6 +244,8 @@ def _missing_required_field(action: SceneAction) -> str | None:
|
|
| 244 |
if not action.label and not action.note:
|
| 245 |
return "missing_required_field:label_or_note"
|
| 246 |
required = () # already checked above
|
|
|
|
|
|
|
| 247 |
elif action.type == SceneActionType.create_edge:
|
| 248 |
required = (
|
| 249 |
("source_id", action.source_id),
|
|
@@ -343,6 +345,13 @@ def _existence_and_scene_reason(
|
|
| 343 |
return f"id_not_found:{action.target_id}"
|
| 344 |
return None
|
| 345 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
if action.type == SceneActionType.create_node:
|
| 347 |
if graph.get_node(action.target_id) is not None or (
|
| 348 |
action.target_id in pending_created
|
|
@@ -373,12 +382,18 @@ _USER_ASSERTED_ORIGIN = "user_asserted"
|
|
| 373 |
|
| 374 |
|
| 375 |
def _has_duplicate_user_edge(action: SceneAction, graph: LooseGraphRepository) -> bool:
|
| 376 |
-
"""True if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
for edge in graph.edges.values():
|
| 378 |
if edge.source != action.source_id or edge.target != action.target_id:
|
| 379 |
continue
|
| 380 |
-
if edge.properties.get("origin") != _USER_ASSERTED_ORIGIN:
|
| 381 |
-
continue
|
| 382 |
if _labels_similar(action.label, edge.label):
|
| 383 |
return True
|
| 384 |
return False
|
|
|
|
| 244 |
if not action.label and not action.note:
|
| 245 |
return "missing_required_field:label_or_note"
|
| 246 |
required = () # already checked above
|
| 247 |
+
elif action.type == SceneActionType.update_edge:
|
| 248 |
+
required = (("edge_id", action.edge_id), ("label", action.label))
|
| 249 |
elif action.type == SceneActionType.create_edge:
|
| 250 |
required = (
|
| 251 |
("source_id", action.source_id),
|
|
|
|
| 345 |
return f"id_not_found:{action.target_id}"
|
| 346 |
return None
|
| 347 |
|
| 348 |
+
if action.type == SceneActionType.update_edge:
|
| 349 |
+
if graph.get_edge(action.edge_id) is None:
|
| 350 |
+
return f"id_not_found:{action.edge_id}"
|
| 351 |
+
if action.edge_id not in scene.visible_edge_ids:
|
| 352 |
+
return f"edge_not_visible:{action.edge_id}"
|
| 353 |
+
return None
|
| 354 |
+
|
| 355 |
if action.type == SceneActionType.create_node:
|
| 356 |
if graph.get_node(action.target_id) is not None or (
|
| 357 |
action.target_id in pending_created
|
|
|
|
| 382 |
|
| 383 |
|
| 384 |
def _has_duplicate_user_edge(action: SceneAction, graph: LooseGraphRepository) -> bool:
|
| 385 |
+
"""True if ANY edge already connects sourceβtarget with a similar label.
|
| 386 |
+
|
| 387 |
+
A ``create_edge`` re-asserting an existing relationship is redundant regardless
|
| 388 |
+
of that edge's origin β including a model_inferred one (agent ``create_edge`` is
|
| 389 |
+
now model_inferred, so origin-gating this to user_asserted let the agent pile up
|
| 390 |
+
duplicate edges). ``propose_hypothesis`` keeps its own laxer dedup
|
| 391 |
+
(:func:`_has_duplicate_deterministic_edge`) so a hypothesis isn't blocked by a
|
| 392 |
+
prior model guess.
|
| 393 |
+
"""
|
| 394 |
for edge in graph.edges.values():
|
| 395 |
if edge.source != action.source_id or edge.target != action.target_id:
|
| 396 |
continue
|
|
|
|
|
|
|
| 397 |
if _labels_similar(action.label, edge.label):
|
| 398 |
return True
|
| 399 |
return False
|
tests/test_agent_harness.py
CHANGED
|
@@ -147,7 +147,11 @@ async def test_streaming_emits_tool_activity(live_session_id: str) -> None:
|
|
| 147 |
)
|
| 148 |
]
|
| 149 |
activity = [ev for ev in events if isinstance(ev, ToolActivityEvent)]
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
|
| 153 |
async def test_source_text_seeded_once_at_front(live_session_id: str) -> None:
|
|
@@ -284,3 +288,22 @@ def test_compose_without_record_is_backward_compatible() -> None:
|
|
| 284 |
"[The user currently has node 'n2' selected.]"
|
| 285 |
)
|
| 286 |
assert _compose_user_message("hi", {"kind": "none", "id": ""}) == "hi"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
)
|
| 148 |
]
|
| 149 |
activity = [ev for ev in events if isinstance(ev, ToolActivityEvent)]
|
| 150 |
+
# Streamed activity is narrated in friendly present tense, not the raw tool name.
|
| 151 |
+
from loosecanvas.agent_harness import _friendly_tool
|
| 152 |
+
|
| 153 |
+
assert any(_friendly_tool("reveal_node") in ev.message for ev in activity)
|
| 154 |
+
assert all("reveal_node" not in ev.message for ev in activity)
|
| 155 |
|
| 156 |
|
| 157 |
async def test_source_text_seeded_once_at_front(live_session_id: str) -> None:
|
|
|
|
| 288 |
"[The user currently has node 'n2' selected.]"
|
| 289 |
)
|
| 290 |
assert _compose_user_message("hi", {"kind": "none", "id": ""}) == "hi"
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
# ββ Slice (b): confidence-tier voice βββββββββββββββββββββββββββββββββββββββββ
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def test_system_prompt_contains_confidence_tier_voice_cue() -> None:
|
| 297 |
+
"""SYSTEM_PROMPT must contain the calibrate-voice-to-confidence block."""
|
| 298 |
+
assert "CALIBRATE YOUR VOICE TO YOUR CONFIDENCE" in SYSTEM_PROMPT
|
| 299 |
+
# Check all three tiers are present.
|
| 300 |
+
assert "user-asserted" in SYSTEM_PROMPT or "explicitly named" in SYSTEM_PROMPT
|
| 301 |
+
assert "model-inferred" in SYSTEM_PROMPT or "I notice" in SYSTEM_PROMPT
|
| 302 |
+
assert "provocateur" in SYSTEM_PROMPT or "but what if" in SYSTEM_PROMPT
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def test_system_prompt_honors_conversational_tone_requests() -> None:
|
| 306 |
+
"""SYSTEM_PROMPT must instruct the agent to honor user tone/style requests."""
|
| 307 |
+
# The sentence added in place of the old steering-lens machinery tells the
|
| 308 |
+
# agent to shift its voice when the user asks (e.g. "be more skeptical").
|
| 309 |
+
assert "skeptical" in SYSTEM_PROMPT
|
tests/test_agent_tools.py
CHANGED
|
@@ -23,6 +23,7 @@ from loosecanvas.agent_tools import (
|
|
| 23 |
remove_node,
|
| 24 |
reveal_node,
|
| 25 |
set_turn_context,
|
|
|
|
| 26 |
)
|
| 27 |
from loosecanvas.turn_logic import (
|
| 28 |
TurnResult,
|
|
@@ -94,6 +95,34 @@ async def test_reveal_node_accept_path(live_session_id: str) -> None:
|
|
| 94 |
assert len(ctx.activity) == 1
|
| 95 |
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
async def test_reveal_node_reject_path(live_session_id: str) -> None:
|
| 98 |
set_turn_context(live_session_id)
|
| 99 |
# Completely nonexistent node id β validator gives id_not_found
|
|
|
|
| 23 |
remove_node,
|
| 24 |
reveal_node,
|
| 25 |
set_turn_context,
|
| 26 |
+
update_edge,
|
| 27 |
)
|
| 28 |
from loosecanvas.turn_logic import (
|
| 29 |
TurnResult,
|
|
|
|
| 95 |
assert len(ctx.activity) == 1
|
| 96 |
|
| 97 |
|
| 98 |
+
async def test_get_canvas_state_includes_visible_edges(live_session_id: str) -> None:
|
| 99 |
+
set_turn_context(live_session_id)
|
| 100 |
+
state = json.loads(get_canvas_state.invoke({}))
|
| 101 |
+
assert "visible_edges" in state, "agent must be able to see edges to relabel them"
|
| 102 |
+
for edge in state["visible_edges"]:
|
| 103 |
+
assert set(edge) >= {"id", "source", "target", "label"}
|
| 104 |
+
assert edge["id"] in state["visible_edge_ids"]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def test_update_edge_accumulates_for_visible_edge(live_session_id: str) -> None:
|
| 108 |
+
set_turn_context(live_session_id)
|
| 109 |
+
state = json.loads(get_canvas_state.invoke({}))
|
| 110 |
+
if not state["visible_edges"]:
|
| 111 |
+
pytest.skip("fixture has no visible edge to relabel")
|
| 112 |
+
edge_id = state["visible_edges"][0]["id"]
|
| 113 |
+
result = json.loads(
|
| 114 |
+
update_edge.invoke({"edge_id": edge_id, "label": "clearer link"})
|
| 115 |
+
)
|
| 116 |
+
assert result["status"] == "accepted"
|
| 117 |
+
ctx = get_turn_context()
|
| 118 |
+
assert any(
|
| 119 |
+
a.type.value == "update_edge"
|
| 120 |
+
and a.edge_id == edge_id
|
| 121 |
+
and a.label == "clearer link"
|
| 122 |
+
for a in ctx.accumulated_actions
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
async def test_reveal_node_reject_path(live_session_id: str) -> None:
|
| 127 |
set_turn_context(live_session_id)
|
| 128 |
# Completely nonexistent node id β validator gives id_not_found
|
tests/test_b2_cxtmenu_review.py
CHANGED
|
@@ -25,6 +25,7 @@ from loosecanvas.turn_logic import (
|
|
| 25 |
expand_node,
|
| 26 |
focus_node,
|
| 27 |
remove_user_edge,
|
|
|
|
| 28 |
review_node_claim,
|
| 29 |
)
|
| 30 |
|
|
@@ -333,3 +334,146 @@ async def test_remove_user_edge_unknown_session_raises() -> None:
|
|
| 333 |
"""An unknown session raises KeyError (the on_select branch catches it)."""
|
| 334 |
with pytest.raises(KeyError):
|
| 335 |
await remove_user_edge("no-such-session", "e1")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
expand_node,
|
| 26 |
focus_node,
|
| 27 |
remove_user_edge,
|
| 28 |
+
review_edge_claim,
|
| 29 |
review_node_claim,
|
| 30 |
)
|
| 31 |
|
|
|
|
| 334 |
"""An unknown session raises KeyError (the on_select branch catches it)."""
|
| 335 |
with pytest.raises(KeyError):
|
| 336 |
await remove_user_edge("no-such-session", "e1")
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# ββ TASK 1: reject a node claim must NOT remove the node βββββββββββββββββββββ
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def test_reject_node_claim_keeps_node_on_canvas() -> None:
|
| 343 |
+
"""Bug fix: rejecting a governing model_inferred claim on a NODE must NOT emit
|
| 344 |
+
remove_element for the node id. The node stays on canvas; only the amber
|
| 345 |
+
'awaiting review' badge is cleared (clear_pending action, same as accept).
|
| 346 |
+
|
| 347 |
+
The backend graph_repository retains the node β this test verifies that the
|
| 348 |
+
renderer patch also does NOT contain a remove_element op targeting the node id,
|
| 349 |
+
i.e. the canvas/backend desync is prevented.
|
| 350 |
+
"""
|
| 351 |
+
claim = Claim(
|
| 352 |
+
id="c-node",
|
| 353 |
+
claim_type="label",
|
| 354 |
+
target_id="n1",
|
| 355 |
+
origin="model_inferred",
|
| 356 |
+
review_state="pending",
|
| 357 |
+
support_state="unverified",
|
| 358 |
+
text="test label",
|
| 359 |
+
)
|
| 360 |
+
sid = _make_session([claim])
|
| 361 |
+
result = turn_logic.do_review_claim(sid, "c-node", "rejected")
|
| 362 |
+
record = turn_logic.session_store[sid]
|
| 363 |
+
|
| 364 |
+
# Node still present in the graph (backend).
|
| 365 |
+
assert (
|
| 366 |
+
"n1" in record.graph.nodes
|
| 367 |
+
), "node must NOT be removed from the graph on claim reject"
|
| 368 |
+
|
| 369 |
+
# Renderer patch must NOT contain a remove_element op targeting the node id.
|
| 370 |
+
remove_ops = [
|
| 371 |
+
o
|
| 372 |
+
for o in result.renderer_patch.operations
|
| 373 |
+
if o.op == "remove_element" and "n1" in (o.selector or "")
|
| 374 |
+
]
|
| 375 |
+
assert (
|
| 376 |
+
not remove_ops
|
| 377 |
+
), f"rejecting a node claim must not emit remove_element for the node; got: {remove_ops}"
|
| 378 |
+
|
| 379 |
+
# The canvas hint should clear the pending badge, not remove the element.
|
| 380 |
+
assert (
|
| 381 |
+
result.canvas_hint["action"] == "clear_pending"
|
| 382 |
+
), f"expected clear_pending for rejected node claim, got {result.canvas_hint['action']!r}"
|
| 383 |
+
assert result.canvas_hint["target_id"] == "n1"
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
# ββ TASK 2: review_edge_claim (wrapper parity with review_node_claim) βββββββββ
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def _make_session_with_pending_edge_claim() -> str:
|
| 390 |
+
"""Session with a model_inferred pending edge claim on e1 (n1βn2)."""
|
| 391 |
+
nodes = [
|
| 392 |
+
Node(id="n1", kind="concept", label="N1"),
|
| 393 |
+
Node(id="n2", kind="concept", label="N2"),
|
| 394 |
+
]
|
| 395 |
+
edge = Edge(id="e1", source="n1", target="n2", kind="related_to", label="links")
|
| 396 |
+
claim = Claim(
|
| 397 |
+
id="c-edge",
|
| 398 |
+
claim_type="relationship",
|
| 399 |
+
target_id="e1",
|
| 400 |
+
origin="model_inferred",
|
| 401 |
+
review_state="pending",
|
| 402 |
+
support_state="unverified",
|
| 403 |
+
text="links",
|
| 404 |
+
)
|
| 405 |
+
repo = LooseGraphRepository.from_extraction(
|
| 406 |
+
nodes, [edge], [claim], SourceSnapshot()
|
| 407 |
+
)
|
| 408 |
+
scene = SceneState(
|
| 409 |
+
scene_id="s1",
|
| 410 |
+
visible_node_ids=["n1", "n2"],
|
| 411 |
+
visible_edge_ids=["e1"],
|
| 412 |
+
)
|
| 413 |
+
sid = str(uuid.uuid4())
|
| 414 |
+
now = time.time()
|
| 415 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 416 |
+
session_id=sid,
|
| 417 |
+
graph=repo,
|
| 418 |
+
scene=scene,
|
| 419 |
+
history=[],
|
| 420 |
+
graph_version=1,
|
| 421 |
+
created_at=now,
|
| 422 |
+
last_seen=now,
|
| 423 |
+
render_scene_id="s1",
|
| 424 |
+
)
|
| 425 |
+
return sid
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def test_review_edge_claim_accept_transitions_claim() -> None:
|
| 429 |
+
"""review_edge_claim accept β claim transitions to accepted, result carries inspector."""
|
| 430 |
+
sid = _make_session_with_pending_edge_claim()
|
| 431 |
+
result = review_edge_claim(sid, "e1", "accepted")
|
| 432 |
+
assert isinstance(result, ReviewClaimResult)
|
| 433 |
+
assert result.review_state == "accepted"
|
| 434 |
+
assert (
|
| 435 |
+
turn_logic.session_store[sid].graph.claims["c-edge"].review_state == "accepted"
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def test_review_edge_claim_reject_transitions_claim() -> None:
|
| 440 |
+
"""review_edge_claim reject β claim transitions to rejected."""
|
| 441 |
+
sid = _make_session_with_pending_edge_claim()
|
| 442 |
+
result = review_edge_claim(sid, "e1", "rejected")
|
| 443 |
+
assert isinstance(result, ReviewClaimResult)
|
| 444 |
+
assert result.review_state == "rejected"
|
| 445 |
+
assert (
|
| 446 |
+
turn_logic.session_store[sid].graph.claims["c-edge"].review_state == "rejected"
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def test_review_edge_claim_returns_none_for_edge_without_pending_claims() -> None:
|
| 451 |
+
"""An edge with no reviewable claims is a graceful no-op."""
|
| 452 |
+
nodes = [
|
| 453 |
+
Node(id="n1", kind="concept", label="N1"),
|
| 454 |
+
Node(id="n2", kind="concept", label="N2"),
|
| 455 |
+
]
|
| 456 |
+
edge = Edge(id="e1", source="n1", target="n2", kind="related_to")
|
| 457 |
+
repo = LooseGraphRepository.from_extraction(nodes, [edge], [], SourceSnapshot())
|
| 458 |
+
scene = SceneState(
|
| 459 |
+
scene_id="s1", visible_node_ids=["n1", "n2"], visible_edge_ids=["e1"]
|
| 460 |
+
)
|
| 461 |
+
sid = str(uuid.uuid4())
|
| 462 |
+
now = time.time()
|
| 463 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 464 |
+
session_id=sid,
|
| 465 |
+
graph=repo,
|
| 466 |
+
scene=scene,
|
| 467 |
+
history=[],
|
| 468 |
+
graph_version=1,
|
| 469 |
+
created_at=now,
|
| 470 |
+
last_seen=now,
|
| 471 |
+
render_scene_id="s1",
|
| 472 |
+
)
|
| 473 |
+
assert review_edge_claim(sid, "e1", "accepted") is None
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def test_review_edge_claim_returns_none_for_unknown_edge() -> None:
|
| 477 |
+
"""An unknown edge_id is a graceful no-op."""
|
| 478 |
+
sid = _make_session_with_pending_edge_claim()
|
| 479 |
+
assert review_edge_claim(sid, "does_not_exist", "accepted") is None
|
tests/test_contracts.py
CHANGED
|
@@ -258,8 +258,8 @@ def test_edgespec_position_roundtrip() -> None:
|
|
| 258 |
|
| 259 |
def test_scene_action_type_has_ten_values() -> None:
|
| 260 |
# T2-01 added remove_node/remove_edge (10 β 12); T2-03 added update_node/create_edge (12 β 14);
|
| 261 |
-
# agent graph-growth added create_node (14 β 15).
|
| 262 |
-
assert len(list(SceneActionType)) ==
|
| 263 |
|
| 264 |
|
| 265 |
@pytest.mark.parametrize("action_type", list(SceneActionType))
|
|
@@ -295,6 +295,7 @@ def test_scene_action_type_values() -> None:
|
|
| 295 |
"remove_edge",
|
| 296 |
# T2-03 user-edit actions:
|
| 297 |
"update_node",
|
|
|
|
| 298 |
"create_edge",
|
| 299 |
# Agent graph-growth action:
|
| 300 |
"create_node",
|
|
|
|
| 258 |
|
| 259 |
def test_scene_action_type_has_ten_values() -> None:
|
| 260 |
# T2-01 added remove_node/remove_edge (10 β 12); T2-03 added update_node/create_edge (12 β 14);
|
| 261 |
+
# agent graph-growth added create_node (14 β 15); edge editing added update_edge (15 β 16).
|
| 262 |
+
assert len(list(SceneActionType)) == 16
|
| 263 |
|
| 264 |
|
| 265 |
@pytest.mark.parametrize("action_type", list(SceneActionType))
|
|
|
|
| 295 |
"remove_edge",
|
| 296 |
# T2-03 user-edit actions:
|
| 297 |
"update_node",
|
| 298 |
+
"update_edge",
|
| 299 |
"create_edge",
|
| 300 |
# Agent graph-growth action:
|
| 301 |
"create_node",
|
tests/test_edge_claim_review.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Edge/claim-level review (#4 multidigraph).
|
| 2 |
+
|
| 3 |
+
A model-inferred *proposed* edge (a "find a hidden connection" hypothesis, or an
|
| 4 |
+
agent ``create_edge``) carries its own relationship claim and must be reviewable
|
| 5 |
+
with the same Accept/Reject affordance as a node β the review loop applies to
|
| 6 |
+
relationships, not just concepts. The Gradio review handlers are claim-id based and
|
| 7 |
+
kind-agnostic (``do_review_claim`` / ``on_review_claim``), and the edge inspector
|
| 8 |
+
exposes ``governing_claims``, so selecting an edge surfaces its pending claim and the
|
| 9 |
+
generic Accept/Reject buttons transition it.
|
| 10 |
+
|
| 11 |
+
These tests lock that end-to-end backend capability so a refactor can't silently
|
| 12 |
+
regress edge review back to node-only.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import time
|
| 19 |
+
import uuid
|
| 20 |
+
|
| 21 |
+
from loosecanvas import turn_logic
|
| 22 |
+
from loosecanvas.contracts import Claim, Edge, Node, SceneState, SourceSnapshot
|
| 23 |
+
from loosecanvas.graph_repository import LooseGraphRepository
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _session_with_model_edge() -> tuple[str, str, str]:
|
| 27 |
+
"""A session whose single edge is a model_inferred PROPOSAL (pending review)."""
|
| 28 |
+
nodes = [
|
| 29 |
+
Node(id="n1", kind="concept", label="N1"),
|
| 30 |
+
Node(id="n2", kind="concept", label="N2"),
|
| 31 |
+
]
|
| 32 |
+
edge = Edge(
|
| 33 |
+
id="e1",
|
| 34 |
+
source="n1",
|
| 35 |
+
target="n2",
|
| 36 |
+
kind="related",
|
| 37 |
+
label="links",
|
| 38 |
+
properties={"origin": "model_inferred"},
|
| 39 |
+
)
|
| 40 |
+
claim = Claim(
|
| 41 |
+
id="c-rel",
|
| 42 |
+
claim_type="relationship",
|
| 43 |
+
target_id="e1",
|
| 44 |
+
text="links",
|
| 45 |
+
origin="model_inferred",
|
| 46 |
+
support_state="unverified",
|
| 47 |
+
review_state="pending",
|
| 48 |
+
)
|
| 49 |
+
repo = LooseGraphRepository.from_extraction(
|
| 50 |
+
nodes, [edge], [claim], SourceSnapshot()
|
| 51 |
+
)
|
| 52 |
+
scene = SceneState(
|
| 53 |
+
scene_id="s1", visible_node_ids=["n1", "n2"], visible_edge_ids=["e1"]
|
| 54 |
+
)
|
| 55 |
+
sid = str(uuid.uuid4())
|
| 56 |
+
now = time.time()
|
| 57 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 58 |
+
session_id=sid,
|
| 59 |
+
graph=repo,
|
| 60 |
+
scene=scene,
|
| 61 |
+
history=[],
|
| 62 |
+
graph_version=1,
|
| 63 |
+
created_at=now,
|
| 64 |
+
last_seen=now,
|
| 65 |
+
render_scene_id="s1",
|
| 66 |
+
)
|
| 67 |
+
return sid, "e1", "c-rel"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_edge_inspector_surfaces_pending_claim() -> None:
|
| 71 |
+
sid, edge_id, claim_id = _session_with_model_edge()
|
| 72 |
+
try:
|
| 73 |
+
insp = turn_logic.inspector_for_selection(sid, {"kind": "edge", "id": edge_id})
|
| 74 |
+
assert insp is not None, "selecting an edge must yield an inspector payload"
|
| 75 |
+
claims = insp["governing_claims"]
|
| 76 |
+
actionable = [c for c in claims if c["review_state"] != "accepted"]
|
| 77 |
+
assert any(c["id"] == claim_id for c in actionable), (
|
| 78 |
+
"a pending model-inferred edge claim must surface as reviewable β this is "
|
| 79 |
+
"what the generic Accept/Reject buttons act on"
|
| 80 |
+
)
|
| 81 |
+
finally:
|
| 82 |
+
turn_logic.session_store.pop(sid, None)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_do_review_claim_accepts_edge_claim() -> None:
|
| 86 |
+
sid, edge_id, claim_id = _session_with_model_edge()
|
| 87 |
+
try:
|
| 88 |
+
result = turn_logic.do_review_claim(sid, claim_id, "accepted")
|
| 89 |
+
record = turn_logic.session_store[sid]
|
| 90 |
+
assert record.graph.claims[claim_id].review_state == "accepted"
|
| 91 |
+
# The review result targets the EDGE β proving review works at edge level.
|
| 92 |
+
assert result is not None
|
| 93 |
+
assert result.renderer_patch is not None
|
| 94 |
+
finally:
|
| 95 |
+
turn_logic.session_store.pop(sid, None)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_do_review_claim_rejects_edge_claim() -> None:
|
| 99 |
+
sid, edge_id, claim_id = _session_with_model_edge()
|
| 100 |
+
try:
|
| 101 |
+
turn_logic.do_review_claim(sid, claim_id, "rejected")
|
| 102 |
+
record = turn_logic.session_store[sid]
|
| 103 |
+
assert record.graph.claims[claim_id].review_state == "rejected"
|
| 104 |
+
finally:
|
| 105 |
+
turn_logic.session_store.pop(sid, None)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_reject_edge_claim_still_removes_edge() -> None:
|
| 109 |
+
"""Regression guard: rejecting a model_inferred EDGE claim must still emit a
|
| 110 |
+
remove_element op for the edge β edge rejection removing the proposed
|
| 111 |
+
relationship from canvas is the intended behaviour (locked by this test).
|
| 112 |
+
|
| 113 |
+
This is the OPPOSITE of the node-claim fix: a rejected node claim must NOT
|
| 114 |
+
remove the node (the node is a durable entity); a rejected edge claim MUST
|
| 115 |
+
remove the edge (the proposed relationship is revoked). Both paths must be
|
| 116 |
+
preserved independently.
|
| 117 |
+
"""
|
| 118 |
+
sid, edge_id, claim_id = _session_with_model_edge()
|
| 119 |
+
try:
|
| 120 |
+
result = turn_logic.do_review_claim(sid, claim_id, "rejected")
|
| 121 |
+
# Claim is marked rejected.
|
| 122 |
+
assert (
|
| 123 |
+
turn_logic.session_store[sid].graph.claims[claim_id].review_state
|
| 124 |
+
== "rejected"
|
| 125 |
+
)
|
| 126 |
+
# The renderer patch MUST contain a remove_element op for the edge.
|
| 127 |
+
remove_ops = [
|
| 128 |
+
o
|
| 129 |
+
for o in result.renderer_patch.operations
|
| 130 |
+
if o.op == "remove_element" and edge_id in (o.selector or "")
|
| 131 |
+
]
|
| 132 |
+
assert remove_ops, (
|
| 133 |
+
"rejecting an edge claim must emit remove_element for the edge id; "
|
| 134 |
+
f"got ops: {[o.op for o in result.renderer_patch.operations]}"
|
| 135 |
+
)
|
| 136 |
+
# Canvas hint action must be 'remove' (not 'clear_pending').
|
| 137 |
+
assert result.canvas_hint["action"] == "remove"
|
| 138 |
+
assert result.canvas_hint["target_id"] == edge_id
|
| 139 |
+
finally:
|
| 140 |
+
turn_logic.session_store.pop(sid, None)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# --------------------------------------------------------------------------- #
|
| 144 |
+
# End-to-end: text-extracted edge (semantic_edge claim) review #
|
| 145 |
+
# --------------------------------------------------------------------------- #
|
| 146 |
+
|
| 147 |
+
_HAPPY_EDGE_JSON = json.dumps(
|
| 148 |
+
{
|
| 149 |
+
"concept_nodes": [
|
| 150 |
+
{
|
| 151 |
+
"id": "concept::alpha",
|
| 152 |
+
"label": "Alpha",
|
| 153 |
+
"summary": "First concept.",
|
| 154 |
+
"start_char": 0,
|
| 155 |
+
"end_char": 5,
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
"id": "concept::beta",
|
| 159 |
+
"label": "Beta",
|
| 160 |
+
"summary": "Second concept.",
|
| 161 |
+
"start_char": 7,
|
| 162 |
+
"end_char": 11,
|
| 163 |
+
},
|
| 164 |
+
],
|
| 165 |
+
"semantic_edges": [
|
| 166 |
+
{
|
| 167 |
+
"source_id": "concept::alpha",
|
| 168 |
+
"target_id": "concept::beta",
|
| 169 |
+
"type": "explains",
|
| 170 |
+
"label": "introduces",
|
| 171 |
+
}
|
| 172 |
+
],
|
| 173 |
+
}
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
async def test_text_extracted_edge_review_end_to_end() -> None:
|
| 178 |
+
"""Build a session from text-extracted output; call review_edge_claim on the
|
| 179 |
+
text-extracted edge and assert the governing semantic_edge claim transitions to
|
| 180 |
+
'accepted'. This locks the no-longer-no-op review path.
|
| 181 |
+
"""
|
| 182 |
+
import httpx
|
| 183 |
+
from loosecanvas.extractors.text_graph_adapter import extract_graph_from_text
|
| 184 |
+
from loosecanvas.llm_client import LLMClient
|
| 185 |
+
|
| 186 |
+
def handler(_request: httpx.Request) -> httpx.Response:
|
| 187 |
+
return httpx.Response(
|
| 188 |
+
200, json={"choices": [{"message": {"content": _HAPPY_EDGE_JSON}}]}
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
client = LLMClient(base_url="http://test", transport=httpx.MockTransport(handler))
|
| 192 |
+
graph = await extract_graph_from_text("Alpha explains Beta.", "test", client)
|
| 193 |
+
|
| 194 |
+
assert len(graph.edges) == 1, f"expected 1 edge, got {len(graph.edges)}"
|
| 195 |
+
edge = graph.edges[0]
|
| 196 |
+
# The semantic_edge claim must target the edge id (not the target node id).
|
| 197 |
+
semantic_claims = [c for c in graph.claims if c.claim_type == "semantic_edge"]
|
| 198 |
+
assert len(semantic_claims) == 1
|
| 199 |
+
governing_claim = semantic_claims[0]
|
| 200 |
+
assert (
|
| 201 |
+
governing_claim.target_id == edge.id
|
| 202 |
+
), f"governing claim targets {governing_claim.target_id!r}, not edge id {edge.id!r}"
|
| 203 |
+
assert governing_claim.review_state == "pending"
|
| 204 |
+
|
| 205 |
+
# Build a live session via the real repository/session_store path.
|
| 206 |
+
repo = LooseGraphRepository.from_extraction(
|
| 207 |
+
graph.nodes, graph.edges, graph.claims, SourceSnapshot()
|
| 208 |
+
)
|
| 209 |
+
scene = SceneState(
|
| 210 |
+
scene_id="s1",
|
| 211 |
+
visible_node_ids=[n.id for n in graph.nodes],
|
| 212 |
+
visible_edge_ids=[edge.id],
|
| 213 |
+
)
|
| 214 |
+
sid = str(uuid.uuid4())
|
| 215 |
+
now = time.time()
|
| 216 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 217 |
+
session_id=sid,
|
| 218 |
+
graph=repo,
|
| 219 |
+
scene=scene,
|
| 220 |
+
history=[],
|
| 221 |
+
graph_version=1,
|
| 222 |
+
created_at=now,
|
| 223 |
+
last_seen=now,
|
| 224 |
+
render_scene_id="s1",
|
| 225 |
+
)
|
| 226 |
+
try:
|
| 227 |
+
result = turn_logic.do_review_claim(sid, governing_claim.id, "accepted")
|
| 228 |
+
record = turn_logic.session_store[sid]
|
| 229 |
+
assert (
|
| 230 |
+
record.graph.claims[governing_claim.id].review_state == "accepted"
|
| 231 |
+
), "review_edge_claim was a no-op: governing semantic_edge claim not updated"
|
| 232 |
+
assert result is not None
|
| 233 |
+
assert result.renderer_patch is not None
|
| 234 |
+
# After accepting, the canvas hint must clear the amber (single-claim edge).
|
| 235 |
+
assert result.canvas_hint["action"] == "clear_pending"
|
| 236 |
+
assert result.canvas_hint["target_id"] == edge.id
|
| 237 |
+
finally:
|
| 238 |
+
turn_logic.session_store.pop(sid, None)
|
tests/test_exporter.py
CHANGED
|
@@ -4,7 +4,17 @@ from __future__ import annotations
|
|
| 4 |
|
| 5 |
import json
|
| 6 |
|
| 7 |
-
from loosecanvas
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from loosecanvas.exporter import export_session
|
| 9 |
from loosecanvas.graph_repository import LooseGraphRepository
|
| 10 |
|
|
@@ -29,7 +39,7 @@ def _repo() -> LooseGraphRepository:
|
|
| 29 |
Node(id="n2", kind="function", label="bar"),
|
| 30 |
]
|
| 31 |
)
|
| 32 |
-
repo.upsert_edges([Edge(id="e1", source="n1", target="n2",
|
| 33 |
repo.upsert_claims(
|
| 34 |
[
|
| 35 |
Claim(id="c1", claim_type="fact", target_id="n1", origin="deterministic"),
|
|
@@ -55,6 +65,7 @@ def _repo() -> LooseGraphRepository:
|
|
| 55 |
claim_type="open_question",
|
| 56 |
target_id="n1",
|
| 57 |
origin="model_inferred",
|
|
|
|
| 58 |
text="Why does n1 exist?",
|
| 59 |
),
|
| 60 |
]
|
|
@@ -86,7 +97,11 @@ async def test_unreviewed_model_inferred_excluded() -> None:
|
|
| 86 |
assert "c4" not in claim_ids
|
| 87 |
|
| 88 |
|
| 89 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
export = await export_session(_repo(), session_id="s1")
|
| 91 |
claim_ids = {c["id"] for c in export["claims"]}
|
| 92 |
assert "c5" not in claim_ids
|
|
@@ -148,3 +163,307 @@ async def test_export_size_is_reasonable() -> None:
|
|
| 148 |
events = [MapEvent(actor="user", action="noop", detail=str(i)) for i in range(200)]
|
| 149 |
export = await export_session(repo, session_id="s1", events=events)
|
| 150 |
assert len(json.dumps(export)) < 10_000_000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
import json
|
| 6 |
|
| 7 |
+
from loosecanvas import reducer
|
| 8 |
+
from loosecanvas.contracts import (
|
| 9 |
+
Claim,
|
| 10 |
+
Edge,
|
| 11 |
+
MapEvent,
|
| 12 |
+
Node,
|
| 13 |
+
SceneAction,
|
| 14 |
+
SceneActionType,
|
| 15 |
+
SceneState,
|
| 16 |
+
SuggestedPathway,
|
| 17 |
+
)
|
| 18 |
from loosecanvas.exporter import export_session
|
| 19 |
from loosecanvas.graph_repository import LooseGraphRepository
|
| 20 |
|
|
|
|
| 39 |
Node(id="n2", kind="function", label="bar"),
|
| 40 |
]
|
| 41 |
)
|
| 42 |
+
repo.upsert_edges([Edge(id="e1", source="n1", target="n2", type="calls")])
|
| 43 |
repo.upsert_claims(
|
| 44 |
[
|
| 45 |
Claim(id="c1", claim_type="fact", target_id="n1", origin="deterministic"),
|
|
|
|
| 65 |
claim_type="open_question",
|
| 66 |
target_id="n1",
|
| 67 |
origin="model_inferred",
|
| 68 |
+
review_state="accepted", # reviewed: a trusted open question
|
| 69 |
text="Why does n1 exist?",
|
| 70 |
),
|
| 71 |
]
|
|
|
|
| 97 |
assert "c4" not in claim_ids
|
| 98 |
|
| 99 |
|
| 100 |
+
async def test_reviewed_open_question_routed_to_open_questions_with_text_as_note() -> (
|
| 101 |
+
None
|
| 102 |
+
):
|
| 103 |
+
"""A reviewed (accepted) open_question routes to ``open_questions`` mapping
|
| 104 |
+
``text`` -> ``note``; it is never emitted into the ``claims`` array."""
|
| 105 |
export = await export_session(_repo(), session_id="s1")
|
| 106 |
claim_ids = {c["id"] for c in export["claims"]}
|
| 107 |
assert "c5" not in claim_ids
|
|
|
|
| 163 |
events = [MapEvent(actor="user", action="noop", detail=str(i)) for i in range(200)]
|
| 164 |
export = await export_session(repo, session_id="s1", events=events)
|
| 165 |
assert len(json.dumps(export)) < 10_000_000
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ββ Topology-redaction tests (grounded on the REAL producers) ββββββββββββββββββ
|
| 169 |
+
#
|
| 170 |
+
# These assert the trust gate at the ARTIFACT level: an unreviewed model_inferred
|
| 171 |
+
# NODE/EDGE must not leak as topology even though its governing pending claim is
|
| 172 |
+
# already dropped. State is built by the real producers (``reducer.reduce_turn`` for
|
| 173 |
+
# create_node, the ``find_connection_session`` edge/claim shape, ``_question_claim``)
|
| 174 |
+
# rather than a hand-built fixture, because the leak shipped green under fiction.
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _real_model_node_repo() -> LooseGraphRepository:
|
| 178 |
+
"""Repo with a real model_inferred *pending* node from the create_node producer.
|
| 179 |
+
|
| 180 |
+
A trusted ``anchor`` node is seeded so cascade behaviour can be observed against a
|
| 181 |
+
node that should survive the redaction.
|
| 182 |
+
"""
|
| 183 |
+
repo = LooseGraphRepository()
|
| 184 |
+
repo.upsert_nodes([Node(id="anchor", kind="concept", label="Anchor")])
|
| 185 |
+
scene = SceneState(scene_id="s", visible_node_ids=["anchor"])
|
| 186 |
+
action = SceneAction(
|
| 187 |
+
type=SceneActionType.create_node,
|
| 188 |
+
target_id="concept::proposed",
|
| 189 |
+
label="Proposed Concept",
|
| 190 |
+
note="a model guess",
|
| 191 |
+
)
|
| 192 |
+
graph_patch, _scene_patch, _events = reducer.reduce_turn([action], repo, scene)
|
| 193 |
+
repo.apply_graph_patch(graph_patch)
|
| 194 |
+
return repo
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _add_find_connection_edge(repo: LooseGraphRepository, src: str, tgt: str) -> str:
|
| 198 |
+
"""Mint a model_inferred *pending* hypothesis edge exactly like find_connection does.
|
| 199 |
+
|
| 200 |
+
Mirrors ``turn_logic.find_connection_session``: an Edge stamped
|
| 201 |
+
``properties["origin"]="model_inferred"`` plus a governing pending relationship Claim.
|
| 202 |
+
Returns the edge id.
|
| 203 |
+
"""
|
| 204 |
+
label = "might relate to"
|
| 205 |
+
edge_id = reducer._stable_id("hypedge", src, tgt, label)
|
| 206 |
+
edge = Edge(
|
| 207 |
+
id=edge_id,
|
| 208 |
+
source=src,
|
| 209 |
+
target=tgt,
|
| 210 |
+
type="hypothesis",
|
| 211 |
+
label=label,
|
| 212 |
+
properties={"origin": "model_inferred"},
|
| 213 |
+
)
|
| 214 |
+
edge_claim = Claim(
|
| 215 |
+
id=reducer._stable_id("claim", "relationship", edge_id, label),
|
| 216 |
+
claim_type="relationship",
|
| 217 |
+
target_id=edge_id,
|
| 218 |
+
text=label,
|
| 219 |
+
origin="model_inferred",
|
| 220 |
+
support_state="unverified",
|
| 221 |
+
review_state="pending",
|
| 222 |
+
)
|
| 223 |
+
repo.upsert_edges([edge])
|
| 224 |
+
repo.upsert_claims([edge_claim])
|
| 225 |
+
return edge_id
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
async def test_unreviewed_model_node_is_redacted_from_topology() -> None:
|
| 229 |
+
"""The create_node producer's pending node must NOT leak into exported nodes."""
|
| 230 |
+
repo = _real_model_node_repo()
|
| 231 |
+
export = await export_session(repo, session_id="s1")
|
| 232 |
+
node_ids = {n["id"] for n in export["nodes"]}
|
| 233 |
+
assert "concept::proposed" not in node_ids
|
| 234 |
+
assert "anchor" in node_ids # trusted node survives
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
async def test_accepted_model_node_appears_in_topology() -> None:
|
| 238 |
+
"""Once the governing claim is accepted, the node ships."""
|
| 239 |
+
repo = _real_model_node_repo()
|
| 240 |
+
# Accept the governing claim (the only claim targeting the proposed node).
|
| 241 |
+
[claim] = [c for c in repo.claims.values() if c.target_id == "concept::proposed"]
|
| 242 |
+
repo.upsert_claims([claim.model_copy(update={"review_state": "accepted"})])
|
| 243 |
+
export = await export_session(repo, session_id="s1")
|
| 244 |
+
node_ids = {n["id"] for n in export["nodes"]}
|
| 245 |
+
assert "concept::proposed" in node_ids
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
async def test_unreviewed_model_edge_is_redacted_from_topology() -> None:
|
| 249 |
+
"""A find_connection-style pending hypothesis edge must NOT leak into edges."""
|
| 250 |
+
repo = LooseGraphRepository()
|
| 251 |
+
repo.upsert_nodes(
|
| 252 |
+
[
|
| 253 |
+
Node(id="a", kind="concept", label="A"),
|
| 254 |
+
Node(id="b", kind="concept", label="B"),
|
| 255 |
+
]
|
| 256 |
+
)
|
| 257 |
+
edge_id = _add_find_connection_edge(repo, "a", "b")
|
| 258 |
+
export = await export_session(repo, session_id="s1")
|
| 259 |
+
edge_ids = {e["id"] for e in export["edges"]}
|
| 260 |
+
assert edge_id not in edge_ids
|
| 261 |
+
# Both endpoints are trusted, so they still ship β only the edge is redacted.
|
| 262 |
+
assert {n["id"] for n in export["nodes"]} == {"a", "b"}
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
async def test_accepted_model_edge_appears_in_topology() -> None:
|
| 266 |
+
"""Accepting the governing claim promotes the edge into the export."""
|
| 267 |
+
repo = LooseGraphRepository()
|
| 268 |
+
repo.upsert_nodes(
|
| 269 |
+
[
|
| 270 |
+
Node(id="a", kind="concept", label="A"),
|
| 271 |
+
Node(id="b", kind="concept", label="B"),
|
| 272 |
+
]
|
| 273 |
+
)
|
| 274 |
+
edge_id = _add_find_connection_edge(repo, "a", "b")
|
| 275 |
+
[claim] = [c for c in repo.claims.values() if c.target_id == edge_id]
|
| 276 |
+
repo.upsert_claims([claim.model_copy(update={"review_state": "accepted"})])
|
| 277 |
+
export = await export_session(repo, session_id="s1")
|
| 278 |
+
edge_ids = {e["id"] for e in export["edges"]}
|
| 279 |
+
assert edge_id in edge_ids
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
async def test_model_edge_redacted_via_properties_when_no_governing_claim() -> None:
|
| 283 |
+
"""Edge with NO governing claim falls back to ``properties['origin']`` for redaction.
|
| 284 |
+
|
| 285 |
+
An element that is not claim-backed (e.g. an agent-created edge not yet reviewed)
|
| 286 |
+
derives its trust from ``properties['origin']``; a ``model_inferred`` such edge must
|
| 287 |
+
still be redacted from export.
|
| 288 |
+
"""
|
| 289 |
+
repo = LooseGraphRepository()
|
| 290 |
+
repo.upsert_nodes(
|
| 291 |
+
[
|
| 292 |
+
Node(id="a", kind="concept", label="A"),
|
| 293 |
+
Node(id="b", kind="concept", label="B"),
|
| 294 |
+
]
|
| 295 |
+
)
|
| 296 |
+
repo.upsert_edges(
|
| 297 |
+
[
|
| 298 |
+
Edge(
|
| 299 |
+
id="a::rel::b",
|
| 300 |
+
source="a",
|
| 301 |
+
target="b",
|
| 302 |
+
type="prerequisite_of",
|
| 303 |
+
label="depends on",
|
| 304 |
+
properties={"origin": "model_inferred"},
|
| 305 |
+
)
|
| 306 |
+
]
|
| 307 |
+
)
|
| 308 |
+
export = await export_session(repo, session_id="s1")
|
| 309 |
+
assert {e["id"] for e in export["edges"]} == set()
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
async def test_cascade_drops_edge_whose_endpoint_is_redacted() -> None:
|
| 313 |
+
"""A user_asserted edge is dropped when one endpoint node is redacted."""
|
| 314 |
+
repo = _real_model_node_repo() # anchor (trusted) + concept::proposed (redacted)
|
| 315 |
+
# A user-accepted edge between the trusted anchor and the redacted proposed node.
|
| 316 |
+
repo.upsert_edges(
|
| 317 |
+
[
|
| 318 |
+
Edge(
|
| 319 |
+
id="anchor::rel::proposed",
|
| 320 |
+
source="anchor",
|
| 321 |
+
target="concept::proposed",
|
| 322 |
+
type="related",
|
| 323 |
+
label="relates",
|
| 324 |
+
properties={"origin": "user_asserted"},
|
| 325 |
+
)
|
| 326 |
+
]
|
| 327 |
+
)
|
| 328 |
+
repo.upsert_claims(
|
| 329 |
+
[
|
| 330 |
+
Claim(
|
| 331 |
+
id="claim::rel::proposed",
|
| 332 |
+
claim_type="relationship",
|
| 333 |
+
target_id="anchor::rel::proposed",
|
| 334 |
+
origin="user_asserted",
|
| 335 |
+
support_state="unverified",
|
| 336 |
+
review_state="accepted",
|
| 337 |
+
)
|
| 338 |
+
]
|
| 339 |
+
)
|
| 340 |
+
export = await export_session(repo, session_id="s1")
|
| 341 |
+
# Even though the edge itself is user_asserted/accepted, its target node was
|
| 342 |
+
# redacted, so the edge must cascade-drop (no dangling edge in the artifact).
|
| 343 |
+
assert {e["id"] for e in export["edges"]} == set()
|
| 344 |
+
assert "concept::proposed" not in {n["id"] for n in export["nodes"]}
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
async def test_unreviewed_open_question_does_not_export() -> None:
|
| 348 |
+
"""``reducer._question_claim`` mints a model_inferred/pending open_question β it must
|
| 349 |
+
not leak into ``open_questions``."""
|
| 350 |
+
repo = LooseGraphRepository()
|
| 351 |
+
repo.upsert_nodes([Node(id="n1", kind="concept", label="N1")])
|
| 352 |
+
action = SceneAction(
|
| 353 |
+
type=SceneActionType.propose_question,
|
| 354 |
+
target_id="n1",
|
| 355 |
+
note="Is this even real?",
|
| 356 |
+
)
|
| 357 |
+
question_claim = reducer._question_claim(action)
|
| 358 |
+
assert question_claim.origin == "model_inferred"
|
| 359 |
+
assert question_claim.review_state == "pending"
|
| 360 |
+
repo.upsert_claims([question_claim])
|
| 361 |
+
export = await export_session(repo, session_id="s1")
|
| 362 |
+
assert export["open_questions"] == []
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
async def test_accepted_open_question_does_export() -> None:
|
| 366 |
+
"""A reviewed (accepted) open_question still surfaces in ``open_questions``."""
|
| 367 |
+
repo = LooseGraphRepository()
|
| 368 |
+
repo.upsert_nodes([Node(id="n1", kind="concept", label="N1")])
|
| 369 |
+
action = SceneAction(
|
| 370 |
+
type=SceneActionType.propose_question,
|
| 371 |
+
target_id="n1",
|
| 372 |
+
note="Worth keeping?",
|
| 373 |
+
)
|
| 374 |
+
question_claim = reducer._question_claim(action).model_copy(
|
| 375 |
+
update={"review_state": "accepted"}
|
| 376 |
+
)
|
| 377 |
+
repo.upsert_claims([question_claim])
|
| 378 |
+
export = await export_session(repo, session_id="s1")
|
| 379 |
+
assert any(
|
| 380 |
+
q["target_id"] == "n1" and q["note"] == "Worth keeping?"
|
| 381 |
+
for q in export["open_questions"]
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
async def test_metadata_counts_reflect_filtered_topology() -> None:
|
| 386 |
+
"""node_count/edge_count must match the FILTERED lists, not the raw store."""
|
| 387 |
+
repo = _real_model_node_repo() # anchor kept, concept::proposed redacted
|
| 388 |
+
_add_find_connection_edge(repo, "anchor", "concept::proposed") # pending edge
|
| 389 |
+
export = await export_session(repo, session_id="s1")
|
| 390 |
+
meta = export["metadata"]
|
| 391 |
+
assert meta["node_count"] == len(export["nodes"]) == 1 # only anchor
|
| 392 |
+
assert meta["edge_count"] == len(export["edges"]) == 0
|
| 393 |
+
assert meta["claim_count"] == len(export["claims"])
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
# ββ Text-extracted semantic_edge claim β exporter trust gate βββββββββββββββββ
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _text_extracted_repo() -> tuple[LooseGraphRepository, str, str]:
|
| 400 |
+
"""Build a repo mirroring a text-extracted graph with a semantic_edge claim.
|
| 401 |
+
|
| 402 |
+
Uses the corrected claim shape (target_id = edge_id, not target node id) so
|
| 403 |
+
the exporter's governing-claim lookup works as intended.
|
| 404 |
+
|
| 405 |
+
Returns (repo, edge_id, claim_id).
|
| 406 |
+
"""
|
| 407 |
+
repo = LooseGraphRepository()
|
| 408 |
+
repo.upsert_nodes(
|
| 409 |
+
[
|
| 410 |
+
Node(id="concept::alpha", kind="concept", label="Alpha"),
|
| 411 |
+
Node(id="concept::beta", kind="concept", label="Beta"),
|
| 412 |
+
]
|
| 413 |
+
)
|
| 414 |
+
edge_id = "concept::alpha::explains::concept::beta"
|
| 415 |
+
repo.upsert_edges(
|
| 416 |
+
[
|
| 417 |
+
Edge(
|
| 418 |
+
id=edge_id,
|
| 419 |
+
source="concept::alpha",
|
| 420 |
+
target="concept::beta",
|
| 421 |
+
type="explains",
|
| 422 |
+
label="introduces",
|
| 423 |
+
properties={"origin": "model_inferred", "support_state": "unverified"},
|
| 424 |
+
)
|
| 425 |
+
]
|
| 426 |
+
)
|
| 427 |
+
# Governing claim with the CORRECTED target_id = edge_id (not target node id).
|
| 428 |
+
claim_id = "claim::semantic::concept::alpha->concept::beta::explains"
|
| 429 |
+
repo.upsert_claims(
|
| 430 |
+
[
|
| 431 |
+
Claim(
|
| 432 |
+
id=claim_id,
|
| 433 |
+
claim_type="semantic_edge",
|
| 434 |
+
source_id="concept::alpha",
|
| 435 |
+
target_id=edge_id,
|
| 436 |
+
text="explains",
|
| 437 |
+
origin="model_inferred",
|
| 438 |
+
support_state="unverified",
|
| 439 |
+
review_state="pending",
|
| 440 |
+
)
|
| 441 |
+
]
|
| 442 |
+
)
|
| 443 |
+
return repo, edge_id, claim_id
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
async def test_text_extracted_pending_edge_excluded_from_export() -> None:
|
| 447 |
+
"""A text-extracted model_inferred edge is EXCLUDED while its governing claim
|
| 448 |
+
is pending β the exporter's trust gate applies to semantic_edge claims just as
|
| 449 |
+
it does to relationship claims from find_connection."""
|
| 450 |
+
repo, edge_id, _claim_id = _text_extracted_repo()
|
| 451 |
+
export = await export_session(repo, session_id="s1")
|
| 452 |
+
edge_ids = {e["id"] for e in export["edges"]}
|
| 453 |
+
assert (
|
| 454 |
+
edge_id not in edge_ids
|
| 455 |
+
), "pending model_inferred text-extracted edge must be excluded from export"
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
async def test_text_extracted_accepted_edge_included_in_export() -> None:
|
| 459 |
+
"""After the governing semantic_edge claim is accepted, the text-extracted edge
|
| 460 |
+
IS included in the export β the trust gate opens correctly."""
|
| 461 |
+
repo, edge_id, claim_id = _text_extracted_repo()
|
| 462 |
+
# Accept the governing claim.
|
| 463 |
+
claim = repo.claims[claim_id]
|
| 464 |
+
repo.upsert_claims([claim.model_copy(update={"review_state": "accepted"})])
|
| 465 |
+
export = await export_session(repo, session_id="s1")
|
| 466 |
+
edge_ids = {e["id"] for e in export["edges"]}
|
| 467 |
+
assert (
|
| 468 |
+
edge_id in edge_ids
|
| 469 |
+
), "accepted model_inferred text-extracted edge must appear in export"
|
tests/test_find_connection.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the on-demand "find a hidden connection" beat (C14/C15).
|
| 2 |
+
|
| 3 |
+
Runs against the REAL baked seed fixture (seed_local_ai.json: 3 communities with
|
| 4 |
+
RAG as the articulation hub) so the cross-cluster candidate selection is exercised
|
| 5 |
+
on a genuine graph, not a hand-built one.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from loosecanvas import turn_logic
|
| 13 |
+
from loosecanvas.scene_curator import compute_node_clusters, find_bridge_candidate
|
| 14 |
+
from loosecanvas.turn_logic import (
|
| 15 |
+
do_review_claim,
|
| 16 |
+
find_connection_session,
|
| 17 |
+
inspector_for_selection,
|
| 18 |
+
load_fixture_session,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
_SEED = Path(__file__).resolve().parents[1] / "fixtures" / "seed_local_ai.json"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def test_find_connection_proposes_amber_cross_cluster_edge() -> None:
|
| 25 |
+
"""The button proposes ONE model_inferred/pending edge across two clusters,
|
| 26 |
+
rendered with the trust hints that drive the amber awaiting-review styling."""
|
| 27 |
+
sid, _ = await load_fixture_session(fixture_path=_SEED)
|
| 28 |
+
record = turn_logic.session_store[sid]
|
| 29 |
+
clusters = compute_node_clusters(record.graph)
|
| 30 |
+
before_edges = set(record.graph.edges)
|
| 31 |
+
|
| 32 |
+
patch = await find_connection_session(sid)
|
| 33 |
+
assert patch is not None, "seed has 3 clusters β a cross-cluster candidate exists"
|
| 34 |
+
|
| 35 |
+
new_edges = set(record.graph.edges) - before_edges
|
| 36 |
+
assert len(new_edges) == 1, "exactly one connection proposed per press"
|
| 37 |
+
edge_id = next(iter(new_edges))
|
| 38 |
+
edge = record.graph.edges[edge_id]
|
| 39 |
+
|
| 40 |
+
# Genuinely cross-cluster (the "surprise").
|
| 41 |
+
assert clusters.get(edge.source) != clusters.get(edge.target)
|
| 42 |
+
|
| 43 |
+
# A model proposal awaiting review β origin frozen model_inferred, review pending.
|
| 44 |
+
assert edge.properties.get("origin") == "model_inferred"
|
| 45 |
+
governing = [c for c in record.graph.claims.values() if c.target_id == edge_id]
|
| 46 |
+
assert governing, "the proposed edge must carry a governing claim"
|
| 47 |
+
assert governing[0].origin == "model_inferred"
|
| 48 |
+
assert governing[0].review_state == "pending"
|
| 49 |
+
|
| 50 |
+
# The patch actually renders it: add the edge + flash + pan to it, with the
|
| 51 |
+
# trust hints that make the frontend draw it amber/awaiting-review.
|
| 52 |
+
ops = patch.operations
|
| 53 |
+
add_ops = [o for o in ops if o.op == "add_element" and o.data.get("id") == edge_id]
|
| 54 |
+
assert add_ops, "patch must add the proposed edge element"
|
| 55 |
+
assert add_ops[0].data.get("origin") == "model_inferred"
|
| 56 |
+
assert add_ops[0].data.get("review") == "pending"
|
| 57 |
+
assert any(o.op == "flash" for o in ops)
|
| 58 |
+
assert any(o.op == "animate_camera" for o in ops)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def test_find_connection_proposes_a_different_pair_each_press() -> None:
|
| 62 |
+
"""Two presses never re-propose the same edge (the first is now linked, so it's
|
| 63 |
+
excluded); the second is a different connection or a graceful None."""
|
| 64 |
+
sid, _ = await load_fixture_session(fixture_path=_SEED)
|
| 65 |
+
first = await find_connection_session(sid)
|
| 66 |
+
assert first is not None
|
| 67 |
+
second = await find_connection_session(sid)
|
| 68 |
+
if second is not None:
|
| 69 |
+
first_edge = next(
|
| 70 |
+
o.data["id"] for o in first.operations if o.op == "add_element"
|
| 71 |
+
)
|
| 72 |
+
second_edge = next(
|
| 73 |
+
o.data["id"] for o in second.operations if o.op == "add_element"
|
| 74 |
+
)
|
| 75 |
+
assert first_edge != second_edge
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
async def test_found_connection_edge_is_reviewable_end_to_end() -> None:
|
| 79 |
+
"""The 'you decide' half: selecting the proposed EDGE exposes its governing
|
| 80 |
+
model_inferred/pending claim, and accepting it clears the amber (remove_class
|
| 81 |
+
review-pending on the edge id). Edges are reviewable just like nodes."""
|
| 82 |
+
sid, _ = await load_fixture_session(fixture_path=_SEED)
|
| 83 |
+
record = turn_logic.session_store[sid]
|
| 84 |
+
before = set(record.graph.edges)
|
| 85 |
+
patch = await find_connection_session(sid)
|
| 86 |
+
assert patch is not None
|
| 87 |
+
edge_id = next(iter(set(record.graph.edges) - before))
|
| 88 |
+
|
| 89 |
+
# Selecting the EDGE surfaces its governing claim (previously edges returned None).
|
| 90 |
+
insp = inspector_for_selection(sid, {"kind": "edge", "id": edge_id})
|
| 91 |
+
assert insp is not None, "edge selection must yield an inspector payload"
|
| 92 |
+
gov = insp.get("governing_claims") or []
|
| 93 |
+
assert gov, "the proposed edge must expose a governing claim to review"
|
| 94 |
+
claim = gov[0]
|
| 95 |
+
assert claim["origin"] == "model_inferred"
|
| 96 |
+
assert claim["review_state"] == "pending"
|
| 97 |
+
|
| 98 |
+
# Accepting it clears the amber awaiting-review affordance on the edge.
|
| 99 |
+
result = do_review_claim(sid, claim["id"], "accepted")
|
| 100 |
+
assert result.review_state == "accepted"
|
| 101 |
+
remove_ops = [
|
| 102 |
+
op
|
| 103 |
+
for op in result.renderer_patch.operations
|
| 104 |
+
if op.op == "remove_class" and op.data.get("class") == "review-pending"
|
| 105 |
+
]
|
| 106 |
+
assert any(
|
| 107 |
+
op.selector == f"#{edge_id}" for op in remove_ops
|
| 108 |
+
), "accepting the edge must clear review-pending on the edge id"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_find_bridge_candidate_none_when_too_small() -> None:
|
| 112 |
+
"""Fewer than two visible nodes β no cross-cluster pair β graceful None."""
|
| 113 |
+
from loosecanvas.contracts import Node, SourceSnapshot
|
| 114 |
+
from loosecanvas.graph_repository import LooseGraphRepository
|
| 115 |
+
|
| 116 |
+
repo = LooseGraphRepository.from_extraction(
|
| 117 |
+
[Node(id="n1", kind="concept", label="A")], [], [], SourceSnapshot()
|
| 118 |
+
)
|
| 119 |
+
clusters = compute_node_clusters(repo)
|
| 120 |
+
assert find_bridge_candidate(repo, clusters, ["n1"]) is None
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ββ BUG 2: repeated clicks must not flood the graph with hypothesis edges ββββββ
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _pending_hypedge_claims(record: turn_logic.SessionRecord) -> list[str]:
|
| 127 |
+
"""Ids of outstanding model_inferred / pending claims governing hypedge edges."""
|
| 128 |
+
return [
|
| 129 |
+
c.id
|
| 130 |
+
for c in record.graph.claims.values()
|
| 131 |
+
if c.origin == "model_inferred"
|
| 132 |
+
and c.review_state == "pending"
|
| 133 |
+
and c.target_id.startswith("hypedge")
|
| 134 |
+
and c.target_id in record.graph.edges
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
async def test_find_connection_caps_outstanding_pending_hypotheses() -> None:
|
| 139 |
+
"""BUG 2 regression. Loop find_connection_session until it returns None: it must
|
| 140 |
+
stop at the per-session cap, not flood (the dead ``edge_id in edges`` guard let
|
| 141 |
+
~23 amber proposals pile up on this real fixture).
|
| 142 |
+
|
| 143 |
+
Grounded on the REAL producer (load_fixture_session over seed_local_ai), so the
|
| 144 |
+
actual bridge-finder + cluster computation are exercised β not a hand-built graph.
|
| 145 |
+
"""
|
| 146 |
+
from loosecanvas.turn_logic import _MAX_PENDING_HYPOTHESES
|
| 147 |
+
|
| 148 |
+
sid, _ = await load_fixture_session(fixture_path=_SEED)
|
| 149 |
+
record = turn_logic.session_store[sid]
|
| 150 |
+
|
| 151 |
+
successes = 0
|
| 152 |
+
for _ in range(40): # loop well past the cap; correct impl no-ops once hit
|
| 153 |
+
patch = await find_connection_session(sid)
|
| 154 |
+
if patch is None:
|
| 155 |
+
break
|
| 156 |
+
successes += 1
|
| 157 |
+
else: # pragma: no cover - only reached if the cap never engages (the bug)
|
| 158 |
+
raise AssertionError(
|
| 159 |
+
"find_connection_session never returned None in 40 calls β "
|
| 160 |
+
"the pending-hypothesis cap is not engaging (flood bug)"
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
assert successes == _MAX_PENDING_HYPOTHESES, (
|
| 164 |
+
f"expected exactly {_MAX_PENDING_HYPOTHESES} proposals before the cap "
|
| 165 |
+
f"no-ops, got {successes} (pre-fix this fixture yielded ~23)"
|
| 166 |
+
)
|
| 167 |
+
assert len(_pending_hypedge_claims(record)) == _MAX_PENDING_HYPOTHESES
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
async def test_reviewing_a_hypothesis_refills_the_budget() -> None:
|
| 171 |
+
"""The cap is on OUTSTANDING pending hypotheses, not lifetime total: after the
|
| 172 |
+
cap is hit, accepting one frees a slot so a new proposal can land."""
|
| 173 |
+
from loosecanvas.turn_logic import _MAX_PENDING_HYPOTHESES
|
| 174 |
+
|
| 175 |
+
sid, _ = await load_fixture_session(fixture_path=_SEED)
|
| 176 |
+
record = turn_logic.session_store[sid]
|
| 177 |
+
|
| 178 |
+
for _ in range(_MAX_PENDING_HYPOTHESES):
|
| 179 |
+
assert await find_connection_session(sid) is not None
|
| 180 |
+
assert await find_connection_session(sid) is None, "cap should be engaged"
|
| 181 |
+
|
| 182 |
+
pending = _pending_hypedge_claims(record)
|
| 183 |
+
assert len(pending) == _MAX_PENDING_HYPOTHESES
|
| 184 |
+
do_review_claim(sid, pending[0], "accepted")
|
| 185 |
+
assert len(_pending_hypedge_claims(record)) == _MAX_PENDING_HYPOTHESES - 1
|
| 186 |
+
|
| 187 |
+
# A genuinely new cross-cluster pair exists (pre-fix probe found ~23), so freeing
|
| 188 |
+
# a slot lets a fresh proposal through.
|
| 189 |
+
assert await find_connection_session(sid) is not None
|
tests/test_fixture_adapter.py
CHANGED
|
@@ -121,3 +121,34 @@ def test_annotated_graph_loads() -> None:
|
|
| 121 |
assert isinstance(scene, SceneState)
|
| 122 |
# The annotated variant adds notes / hypotheses / open questions on top of the base.
|
| 123 |
assert len(graph.claims) > 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
assert isinstance(scene, SceneState)
|
| 122 |
# The annotated variant adds notes / hypotheses / open questions on top of the base.
|
| 123 |
assert len(graph.claims) > 3
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_load_fixture_from_dict_missing_graph_raises_value_error() -> None:
|
| 127 |
+
"""A partial/corrupt fixture (no ``graph`` key) must raise ``ValueError`` β NOT a
|
| 128 |
+
bare ``KeyError`` that bubbles up as an opaque 500 on /api/load_graph."""
|
| 129 |
+
data = {
|
| 130 |
+
"scene": {
|
| 131 |
+
"scene_id": "s0",
|
| 132 |
+
"visible_node_ids": [],
|
| 133 |
+
"visible_edge_ids": [],
|
| 134 |
+
"fogged_node_ids": [],
|
| 135 |
+
"node_positions": {},
|
| 136 |
+
},
|
| 137 |
+
}
|
| 138 |
+
with pytest.raises(ValueError, match="fixture missing graph/scene"):
|
| 139 |
+
load_fixture_from_dict(data)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_load_fixture_from_dict_missing_scene_raises_value_error() -> None:
|
| 143 |
+
"""A partial fixture (no ``scene`` key) must raise ``ValueError`` (mapped to 400),
|
| 144 |
+
not ``KeyError`` (opaque 500)."""
|
| 145 |
+
data = {
|
| 146 |
+
"graph": {
|
| 147 |
+
"nodes": [{"id": "n1", "kind": "concept", "label": "Concept One"}],
|
| 148 |
+
"edges": [],
|
| 149 |
+
"claims": [],
|
| 150 |
+
"metadata": {"schema_version": "0.1.0", "node_count": 1, "edge_count": 0},
|
| 151 |
+
},
|
| 152 |
+
}
|
| 153 |
+
with pytest.raises(ValueError, match="fixture missing graph/scene"):
|
| 154 |
+
load_fixture_from_dict(data)
|
tests/test_graph_repository.py
CHANGED
|
@@ -19,11 +19,14 @@ from loosecanvas.contracts import (
|
|
| 19 |
Edge,
|
| 20 |
GraphPatch,
|
| 21 |
Node,
|
|
|
|
|
|
|
| 22 |
ScenePatch,
|
| 23 |
SceneState,
|
| 24 |
SourceSnapshot,
|
| 25 |
)
|
| 26 |
from loosecanvas.graph_repository import LooseGraphRepository
|
|
|
|
| 27 |
|
| 28 |
# ββ Fixtures ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
|
|
@@ -167,6 +170,53 @@ def test_apply_scene_patch_idempotent() -> None:
|
|
| 167 |
assert once == twice
|
| 168 |
|
| 169 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
# ββ fog_replenish βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 171 |
|
| 172 |
|
|
|
|
| 19 |
Edge,
|
| 20 |
GraphPatch,
|
| 21 |
Node,
|
| 22 |
+
SceneAction,
|
| 23 |
+
SceneActionType,
|
| 24 |
ScenePatch,
|
| 25 |
SceneState,
|
| 26 |
SourceSnapshot,
|
| 27 |
)
|
| 28 |
from loosecanvas.graph_repository import LooseGraphRepository
|
| 29 |
+
from loosecanvas.reducer import reduce_turn
|
| 30 |
|
| 31 |
# ββ Fixtures ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
|
|
|
|
| 170 |
assert once == twice
|
| 171 |
|
| 172 |
|
| 173 |
+
# ββ apply_scene_patch: carry forward cluster colours + search highlight ββββββββ
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_apply_scene_patch_benign_turn_preserves_clusters_and_highlight() -> None:
|
| 177 |
+
"""Grounded on the REAL producer: a benign turn (a ``reveal``) reduces to a
|
| 178 |
+
``ScenePatch`` that does NOT touch highlights, so apply must carry the existing
|
| 179 |
+
``node_cluster_map`` and ``highlighted_ids`` forward.
|
| 180 |
+
|
| 181 |
+
Regression for the bug where the next turn after 'Colour clusters' / a search wiped
|
| 182 |
+
both β diff_scene then emitted ``remove_class cluster-N`` and removed the highlight.
|
| 183 |
+
"""
|
| 184 |
+
repo = _populated() # nodes a,b,c,d; edges a-b, b-c, a-c
|
| 185 |
+
# A scene that already has cluster colours (from 'Colour clusters') and a search
|
| 186 |
+
# highlight, plus a fogged node "d" that the benign turn will reveal.
|
| 187 |
+
scene = SceneState(
|
| 188 |
+
scene_id="s1",
|
| 189 |
+
visible_node_ids=["a", "b"],
|
| 190 |
+
fogged_node_ids=["d"],
|
| 191 |
+
highlighted_ids=["a"], # e.g. a prior search highlight
|
| 192 |
+
node_cluster_map={"a": 0, "b": 1}, # e.g. 'Colour clusters'
|
| 193 |
+
)
|
| 194 |
+
# Build the patch via the real reducer with a benign reveal β NOT a hand-built patch.
|
| 195 |
+
reveal = SceneAction(type=SceneActionType.reveal, target_id="d")
|
| 196 |
+
_graph_patch, scene_patch, _events = reduce_turn([reveal], repo, scene)
|
| 197 |
+
# Ground the premise: the real producer leaves highlights unset on a benign turn.
|
| 198 |
+
assert scene_patch.set_highlighted_ids == []
|
| 199 |
+
|
| 200 |
+
result = repo.apply_scene_patch(scene, scene_patch)
|
| 201 |
+
|
| 202 |
+
# The benign reveal happened ...
|
| 203 |
+
assert "d" in result.visible_node_ids
|
| 204 |
+
# ... and BOTH the cluster colours and the search highlight survived.
|
| 205 |
+
assert result.node_cluster_map == {"a": 0, "b": 1}
|
| 206 |
+
assert result.highlighted_ids == ["a"]
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def test_apply_scene_patch_explicit_highlight_still_overrides() -> None:
|
| 210 |
+
"""An explicit (non-empty) ``set_highlighted_ids`` must still replace the old set."""
|
| 211 |
+
repo = LooseGraphRepository()
|
| 212 |
+
scene = SceneState(highlighted_ids=["a"], node_cluster_map={"a": 0})
|
| 213 |
+
patch = ScenePatch(set_highlighted_ids=["b", "c"])
|
| 214 |
+
result = repo.apply_scene_patch(scene, patch)
|
| 215 |
+
assert result.highlighted_ids == ["b", "c"]
|
| 216 |
+
# node_cluster_map is still carried forward (the patch has no cluster slot).
|
| 217 |
+
assert result.node_cluster_map == {"a": 0}
|
| 218 |
+
|
| 219 |
+
|
| 220 |
# ββ fog_replenish βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 221 |
|
| 222 |
|
tests/test_magic_build_opener.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Magic-build turn-one opener: turn one greets in the agent's voice, not a canned
|
| 2 |
+
count template.
|
| 3 |
+
|
| 4 |
+
The first Send builds the graph via the deterministic extractor (which mints the
|
| 5 |
+
session but does NOT run the conversational agent). To make turn one feel like every
|
| 6 |
+
later turn, ``main.py`` streams ``agent_harness.stream_build_opener`` into the chat
|
| 7 |
+
right after the graph paints. These tests pin that contract:
|
| 8 |
+
|
| 9 |
+
* ``stream_text_session`` success no longer carries a canned chat message (the voice
|
| 10 |
+
moved to the streamed opener).
|
| 11 |
+
* ``stream_build_opener`` streams the model's own tokens, hands the model the concepts
|
| 12 |
+
that were actually extracted (topic-aware), and falls back to a warm, concept-naming
|
| 13 |
+
line β never silent, never a bare count β if the model call fails.
|
| 14 |
+
|
| 15 |
+
Real ``SessionRecord`` / ``LooseGraphRepository`` objects are used (not MagicMocks) so
|
| 16 |
+
the asserted shapes match the real producer; only the LLM is faked.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import time
|
| 22 |
+
from collections.abc import AsyncIterator
|
| 23 |
+
from typing import Any
|
| 24 |
+
from unittest.mock import AsyncMock, patch
|
| 25 |
+
|
| 26 |
+
from loosecanvas import turn_logic
|
| 27 |
+
from loosecanvas.agent_harness import stream_build_opener
|
| 28 |
+
from loosecanvas.contracts import Node, SceneState, SourceSnapshot
|
| 29 |
+
from loosecanvas.graph_repository import LooseGraphRepository
|
| 30 |
+
from loosecanvas.llm_client import LLMClient
|
| 31 |
+
from loosecanvas.turn_logic import RendererPatch, SessionRecord
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _record_with(sid: str, labels: list[str]) -> SessionRecord:
|
| 35 |
+
nodes = [
|
| 36 |
+
Node(id=f"n{i}", kind="concept", label=lbl) for i, lbl in enumerate(labels)
|
| 37 |
+
]
|
| 38 |
+
repo = LooseGraphRepository.from_extraction(nodes, [], [], SourceSnapshot())
|
| 39 |
+
return SessionRecord(
|
| 40 |
+
session_id=sid,
|
| 41 |
+
graph=repo,
|
| 42 |
+
scene=SceneState(scene_id="s"),
|
| 43 |
+
history=[],
|
| 44 |
+
graph_version=1,
|
| 45 |
+
created_at=time.time(),
|
| 46 |
+
last_seen=time.time(),
|
| 47 |
+
render_scene_id="s",
|
| 48 |
+
patch_id=0,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class _FakeChunk:
|
| 53 |
+
def __init__(self, content: str) -> None:
|
| 54 |
+
self.content = content
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class _FakeModel:
|
| 58 |
+
"""Captures the prompt messages and streams the given chunks back as content."""
|
| 59 |
+
|
| 60 |
+
def __init__(self, chunks: list[str]) -> None:
|
| 61 |
+
self._chunks = chunks
|
| 62 |
+
self.seen: Any = None
|
| 63 |
+
|
| 64 |
+
async def astream(self, messages: Any) -> AsyncIterator[_FakeChunk]:
|
| 65 |
+
self.seen = messages
|
| 66 |
+
for chunk in self._chunks:
|
| 67 |
+
yield _FakeChunk(chunk)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class _BoomModel:
|
| 71 |
+
async def astream(self, messages: Any) -> AsyncIterator[_FakeChunk]:
|
| 72 |
+
raise RuntimeError("model down")
|
| 73 |
+
yield _FakeChunk("") # pragma: no cover β makes this a generator
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class _MidStreamBoomModel:
|
| 77 |
+
"""Streams one good chunk, then dies β exercises the no-double-post guard."""
|
| 78 |
+
|
| 79 |
+
async def astream(self, messages: Any) -> AsyncIterator[_FakeChunk]:
|
| 80 |
+
yield _FakeChunk("Nice start β ")
|
| 81 |
+
raise RuntimeError("model died mid-stream")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ββ stream_text_session: the canned turn-one template is gone ββ
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
async def test_stream_text_session_success_has_no_chat_message() -> None:
|
| 88 |
+
sid = "opener-no-canned"
|
| 89 |
+
patch_obj = RendererPatch(patch_id=0, is_snapshot=True, scene_id="s", operations=[])
|
| 90 |
+
with patch(
|
| 91 |
+
"loosecanvas.turn_logic.load_text_session", new_callable=AsyncMock
|
| 92 |
+
) as mock_load:
|
| 93 |
+
mock_load.return_value = (sid, patch_obj)
|
| 94 |
+
turn_logic.session_store[sid] = _record_with(sid, ["Alpha", "Beta"])
|
| 95 |
+
try:
|
| 96 |
+
events = [
|
| 97 |
+
e async for e in turn_logic.stream_text_session("t", "t", LLMClient())
|
| 98 |
+
]
|
| 99 |
+
finally:
|
| 100 |
+
turn_logic.session_store.pop(sid, None)
|
| 101 |
+
|
| 102 |
+
final = next(e for e in events if not e.is_partial)
|
| 103 |
+
assert not final.is_error
|
| 104 |
+
assert final.session_id == sid
|
| 105 |
+
assert final.canvas_patch == patch_obj.model_dump(mode="json")
|
| 106 |
+
assert (
|
| 107 |
+
final.chat_message == []
|
| 108 |
+
), "turn-one voice now streams via the opener, not a canned count template"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ββ stream_build_opener: streams the model's own voice, topic-aware ββ
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
async def test_opener_streams_model_tokens() -> None:
|
| 115 |
+
sid = "opener-ok"
|
| 116 |
+
turn_logic.session_store[sid] = _record_with(sid, ["Alpha", "Beta"])
|
| 117 |
+
fake = _FakeModel(["Nice β ", "I see Alpha", " and Beta here."])
|
| 118 |
+
try:
|
| 119 |
+
with patch("loosecanvas.agent_harness.make_model", return_value=fake):
|
| 120 |
+
chunks = [c async for c in stream_build_opener(sid)]
|
| 121 |
+
finally:
|
| 122 |
+
turn_logic.session_store.pop(sid, None)
|
| 123 |
+
|
| 124 |
+
assert chunks == ["Nice β ", "I see Alpha", " and Beta here."]
|
| 125 |
+
# The model was handed the concepts that were actually extracted (topic-aware).
|
| 126 |
+
assert fake.seen is not None
|
| 127 |
+
human_msg = fake.seen[-1]
|
| 128 |
+
assert "Alpha" in human_msg[1] and "Beta" in human_msg[1]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def test_opener_falls_back_when_model_fails() -> None:
|
| 132 |
+
sid = "opener-fail"
|
| 133 |
+
turn_logic.session_store[sid] = _record_with(sid, ["Alpha", "Beta"])
|
| 134 |
+
try:
|
| 135 |
+
with patch("loosecanvas.agent_harness.make_model", return_value=_BoomModel()):
|
| 136 |
+
chunks = [c async for c in stream_build_opener(sid)]
|
| 137 |
+
finally:
|
| 138 |
+
turn_logic.session_store.pop(sid, None)
|
| 139 |
+
|
| 140 |
+
text = "".join(chunks)
|
| 141 |
+
assert text, "opener must never land silent, even when the model call fails"
|
| 142 |
+
assert "Alpha" in text, "fallback must still name a real extracted concept"
|
| 143 |
+
# Not a bare count: it should read like a sentence, not 'N concepts, M relations'.
|
| 144 |
+
assert "first pass" in text.lower()
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
async def test_opener_does_not_double_post_on_midstream_failure() -> None:
|
| 148 |
+
# If the model dies AFTER emitting real tokens, we keep what streamed and do
|
| 149 |
+
# NOT also append the static fallback on top of it.
|
| 150 |
+
sid = "opener-midboom"
|
| 151 |
+
turn_logic.session_store[sid] = _record_with(sid, ["Alpha"])
|
| 152 |
+
try:
|
| 153 |
+
with patch(
|
| 154 |
+
"loosecanvas.agent_harness.make_model", return_value=_MidStreamBoomModel()
|
| 155 |
+
):
|
| 156 |
+
chunks = [c async for c in stream_build_opener(sid)]
|
| 157 |
+
finally:
|
| 158 |
+
turn_logic.session_store.pop(sid, None)
|
| 159 |
+
|
| 160 |
+
text = "".join(chunks)
|
| 161 |
+
assert text == "Nice start β ", "must keep streamed tokens, not append a fallback"
|
| 162 |
+
assert "first pass" not in text.lower(), "fallback must not double-post"
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
async def test_opener_handles_missing_session_gracefully() -> None:
|
| 166 |
+
# A session that vanished (evicted) must not crash the opener β it falls back.
|
| 167 |
+
fake = _FakeModel(["hello"])
|
| 168 |
+
with patch("loosecanvas.agent_harness.make_model", return_value=fake):
|
| 169 |
+
chunks = [c async for c in stream_build_opener("does-not-exist")]
|
| 170 |
+
assert "".join(chunks) == "hello"
|
| 171 |
+
# Even with no record, the model was still handed a (generic) opener prompt.
|
| 172 |
+
assert fake.seen is not None
|
tests/test_p1_03_claim_review_consumer.py
CHANGED
|
@@ -68,6 +68,41 @@ async def test_do_review_claim_returns_renderer_patch() -> None:
|
|
| 68 |
assert isinstance(result.renderer_patch, RendererPatch)
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
async def test_illegal_transition_raises_invalid_transition_error() -> None:
|
| 72 |
"""SC-C: do_review_claim raises InvalidTransitionError (not HTTPException) for 409 cases."""
|
| 73 |
claim = Claim(
|
|
|
|
| 68 |
assert isinstance(result.renderer_patch, RendererPatch)
|
| 69 |
|
| 70 |
|
| 71 |
+
async def test_accept_clears_the_review_pending_class_with_the_real_selector() -> None:
|
| 72 |
+
"""Lynchpin regression: accepting a claim must emit remove_class for the SAME
|
| 73 |
+
class the stylesheet uses (`review-pending`, hyphenated from review_state
|
| 74 |
+
"pending"). A prior typo emitted `pending-review`, so the amber awaiting-review
|
| 75 |
+
border never cleared on Accept. Pin the exact payload β the older SC-B test only
|
| 76 |
+
checked the patch existed, which is how the bug shipped green.
|
| 77 |
+
"""
|
| 78 |
+
claim = Claim(
|
| 79 |
+
id="c1",
|
| 80 |
+
claim_type="label",
|
| 81 |
+
target_id="n1",
|
| 82 |
+
origin="model_inferred",
|
| 83 |
+
review_state="pending",
|
| 84 |
+
support_state="unverified",
|
| 85 |
+
text="test",
|
| 86 |
+
)
|
| 87 |
+
sid = _make_session([claim])
|
| 88 |
+
result = do_review_claim(sid, "c1", "accepted")
|
| 89 |
+
remove_class_ops = [
|
| 90 |
+
op for op in result.renderer_patch.operations if op.op == "remove_class"
|
| 91 |
+
]
|
| 92 |
+
assert (
|
| 93 |
+
remove_class_ops
|
| 94 |
+
), "accept must emit a remove_class op to clear the amber border"
|
| 95 |
+
classes = {op.data.get("class") for op in remove_class_ops}
|
| 96 |
+
assert (
|
| 97 |
+
"review-pending" in classes
|
| 98 |
+
), f"accept must remove the stylesheet class 'review-pending', got {classes}"
|
| 99 |
+
assert (
|
| 100 |
+
"pending-review" not in classes
|
| 101 |
+
), "regression: the old buggy class name is back"
|
| 102 |
+
# Targets the reviewed node by id.
|
| 103 |
+
assert any(op.selector == "#n1" for op in remove_class_ops)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
async def test_illegal_transition_raises_invalid_transition_error() -> None:
|
| 107 |
"""SC-C: do_review_claim raises InvalidTransitionError (not HTTPException) for 409 cases."""
|
| 108 |
claim = Claim(
|
tests/test_renderer_contract_parity.py
CHANGED
|
@@ -10,8 +10,10 @@ unknown ops). This test makes that drift fail loudly.
|
|
| 10 |
We DERIVE the produced op-set from source (rather than hardcoding it) so adding
|
| 11 |
a NEW producer with a NEW op trips this test until ``types.ts`` is updated. We
|
| 12 |
assert SUBSET, not equality: the TS union legitimately carries union-only extras
|
| 13 |
-
(``
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
from __future__ import annotations
|
|
@@ -25,9 +27,10 @@ SRC_DIR = REPO_ROOT / "src" / "loosecanvas"
|
|
| 25 |
TYPES_TS = REPO_ROOT / "cytoscapecanvas" / "frontend" / "types.ts"
|
| 26 |
|
| 27 |
# Union-only extras the TS side declares but the backend never produces:
|
| 28 |
-
# flash β unimplemented M18 highlight op (planned, no producer yet)
|
| 29 |
# select β a frontendβbackend event op, not a backend-emitted render command
|
| 30 |
-
|
|
|
|
|
|
|
| 31 |
|
| 32 |
# op="..." in Python sources (the RendererCommand constructor argument).
|
| 33 |
_PY_OP_RE = re.compile(r'op="([a-z_]+)"')
|
|
|
|
| 10 |
We DERIVE the produced op-set from source (rather than hardcoding it) so adding
|
| 11 |
a NEW producer with a NEW op trips this test until ``types.ts`` is updated. We
|
| 12 |
assert SUBSET, not equality: the TS union legitimately carries union-only extras
|
| 13 |
+
(``select`` is a frontendβbackend event op, never produced by the backend renderer
|
| 14 |
+
adapter). NB: ``flash`` USED to be union-only but now has a backend producer
|
| 15 |
+
(``turn_logic.find_connection_session`` flashes a freshly-proposed hidden-connection
|
| 16 |
+
edge), so it is no longer in the allow-list.
|
| 17 |
"""
|
| 18 |
|
| 19 |
from __future__ import annotations
|
|
|
|
| 27 |
TYPES_TS = REPO_ROOT / "cytoscapecanvas" / "frontend" / "types.ts"
|
| 28 |
|
| 29 |
# Union-only extras the TS side declares but the backend never produces:
|
|
|
|
| 30 |
# select β a frontendβbackend event op, not a backend-emitted render command
|
| 31 |
+
# (flash WAS here, but find_connection_session now emits op="flash", so it's
|
| 32 |
+
# produced by the backend and no longer union-only.)
|
| 33 |
+
KNOWN_UNION_ONLY = {"select"}
|
| 34 |
|
| 35 |
# op="..." in Python sources (the RendererCommand constructor argument).
|
| 36 |
_PY_OP_RE = re.compile(r'op="([a-z_]+)"')
|
tests/test_review_agent_tool.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the review_element agent tool (Task A).
|
| 2 |
+
|
| 3 |
+
Covers:
|
| 4 |
+
- review_element on a model_inferred pending NODE claim
|
| 5 |
+
- review_element on a model_inferred pending EDGE claim (edge variant)
|
| 6 |
+
- finalize_agent_turn with EMPTY accumulated_actions but non-empty review_patches
|
| 7 |
+
returns a TurnResult (not None) carrying the review ops
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import time
|
| 14 |
+
import uuid
|
| 15 |
+
from collections.abc import Iterator
|
| 16 |
+
|
| 17 |
+
import pytest
|
| 18 |
+
from loosecanvas import turn_logic
|
| 19 |
+
from loosecanvas.agent_tools import (
|
| 20 |
+
_current_turn,
|
| 21 |
+
finalize_agent_turn,
|
| 22 |
+
get_turn_context,
|
| 23 |
+
review_element,
|
| 24 |
+
set_turn_context,
|
| 25 |
+
)
|
| 26 |
+
from loosecanvas.contracts import Claim, Edge, Node, SceneState, SourceSnapshot
|
| 27 |
+
from loosecanvas.graph_repository import LooseGraphRepository
|
| 28 |
+
from loosecanvas.turn_logic import (
|
| 29 |
+
RendererPatch,
|
| 30 |
+
TurnResult,
|
| 31 |
+
session_locks,
|
| 32 |
+
session_store,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# ββ Session isolation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@pytest.fixture(autouse=True)
|
| 39 |
+
def _isolate_sessions() -> Iterator[None]:
|
| 40 |
+
session_store.clear()
|
| 41 |
+
session_locks.clear()
|
| 42 |
+
yield
|
| 43 |
+
session_store.clear()
|
| 44 |
+
session_locks.clear()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@pytest.fixture(autouse=True)
|
| 48 |
+
def _reset_agent_context() -> Iterator[None]:
|
| 49 |
+
_current_turn.set(None)
|
| 50 |
+
yield
|
| 51 |
+
_current_turn.set(None)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ββ Session helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _make_session(claims: list[Claim] | None = None) -> str:
|
| 58 |
+
"""Create a minimal session with node n1 in session_store; return session_id."""
|
| 59 |
+
node = Node(id="n1", kind="concept", label="Test Node")
|
| 60 |
+
repo = LooseGraphRepository.from_extraction(
|
| 61 |
+
[node], [], claims or [], SourceSnapshot()
|
| 62 |
+
)
|
| 63 |
+
scene = SceneState(scene_id="s1", visible_node_ids=["n1"])
|
| 64 |
+
sid = str(uuid.uuid4())
|
| 65 |
+
now = time.time()
|
| 66 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 67 |
+
session_id=sid,
|
| 68 |
+
graph=repo,
|
| 69 |
+
scene=scene,
|
| 70 |
+
history=[],
|
| 71 |
+
graph_version=1,
|
| 72 |
+
created_at=now,
|
| 73 |
+
last_seen=now,
|
| 74 |
+
render_scene_id="s1",
|
| 75 |
+
)
|
| 76 |
+
return sid
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _pending_claim() -> Claim:
|
| 80 |
+
return Claim(
|
| 81 |
+
id="c1",
|
| 82 |
+
claim_type="label",
|
| 83 |
+
target_id="n1",
|
| 84 |
+
origin="model_inferred",
|
| 85 |
+
review_state="pending",
|
| 86 |
+
support_state="unverified",
|
| 87 |
+
text="test",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _make_session_with_pending_edge_claim() -> str:
|
| 92 |
+
"""Session with n1, n2, and a model_inferred pending edge claim on e1."""
|
| 93 |
+
nodes = [
|
| 94 |
+
Node(id="n1", kind="concept", label="N1"),
|
| 95 |
+
Node(id="n2", kind="concept", label="N2"),
|
| 96 |
+
]
|
| 97 |
+
edge = Edge(id="e1", source="n1", target="n2", kind="related_to", label="links")
|
| 98 |
+
claim = Claim(
|
| 99 |
+
id="c-edge",
|
| 100 |
+
claim_type="relationship",
|
| 101 |
+
target_id="e1",
|
| 102 |
+
origin="model_inferred",
|
| 103 |
+
review_state="pending",
|
| 104 |
+
support_state="unverified",
|
| 105 |
+
text="links",
|
| 106 |
+
)
|
| 107 |
+
repo = LooseGraphRepository.from_extraction(
|
| 108 |
+
nodes, [edge], [claim], SourceSnapshot()
|
| 109 |
+
)
|
| 110 |
+
scene = SceneState(
|
| 111 |
+
scene_id="s1",
|
| 112 |
+
visible_node_ids=["n1", "n2"],
|
| 113 |
+
visible_edge_ids=["e1"],
|
| 114 |
+
)
|
| 115 |
+
sid = str(uuid.uuid4())
|
| 116 |
+
now = time.time()
|
| 117 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 118 |
+
session_id=sid,
|
| 119 |
+
graph=repo,
|
| 120 |
+
scene=scene,
|
| 121 |
+
history=[],
|
| 122 |
+
graph_version=1,
|
| 123 |
+
created_at=now,
|
| 124 |
+
last_seen=now,
|
| 125 |
+
render_scene_id="s1",
|
| 126 |
+
)
|
| 127 |
+
return sid
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ββ review_element: node tests ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_review_element_node_accept_returns_ok_and_transitions_claim() -> None:
|
| 134 |
+
"""review_element('n1', 'accepted') returns status=ok and flips the claim."""
|
| 135 |
+
sid = _make_session([_pending_claim()])
|
| 136 |
+
set_turn_context(sid)
|
| 137 |
+
|
| 138 |
+
raw = review_element.invoke({"target_id": "n1", "action": "accepted"})
|
| 139 |
+
data = json.loads(raw)
|
| 140 |
+
|
| 141 |
+
assert data["status"] == "ok", f"expected ok, got: {data}"
|
| 142 |
+
assert data["target_id"] == "n1"
|
| 143 |
+
assert data["review_state"] == "accepted"
|
| 144 |
+
# The stored claim actually transitioned.
|
| 145 |
+
assert turn_logic.session_store[sid].graph.claims["c1"].review_state == "accepted"
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_review_element_node_accept_appends_review_patch() -> None:
|
| 149 |
+
"""Accepted review appends a RendererPatch to ctx.review_patches."""
|
| 150 |
+
sid = _make_session([_pending_claim()])
|
| 151 |
+
set_turn_context(sid)
|
| 152 |
+
|
| 153 |
+
review_element.invoke({"target_id": "n1", "action": "accepted"})
|
| 154 |
+
ctx = get_turn_context()
|
| 155 |
+
|
| 156 |
+
assert len(ctx.review_patches) == 1
|
| 157 |
+
patch = ctx.review_patches[0]
|
| 158 |
+
assert isinstance(patch, RendererPatch)
|
| 159 |
+
# Should carry a remove_class (clear_pending) op.
|
| 160 |
+
ops = [o.op for o in patch.operations]
|
| 161 |
+
assert "remove_class" in ops or "clear_pending" in ops or len(patch.operations) > 0
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_review_element_node_reject_returns_ok_and_transitions_claim() -> None:
|
| 165 |
+
"""review_element('n1', 'rejected') returns status=ok with rejected state."""
|
| 166 |
+
sid = _make_session([_pending_claim()])
|
| 167 |
+
set_turn_context(sid)
|
| 168 |
+
|
| 169 |
+
raw = review_element.invoke({"target_id": "n1", "action": "rejected"})
|
| 170 |
+
data = json.loads(raw)
|
| 171 |
+
|
| 172 |
+
assert data["status"] == "ok"
|
| 173 |
+
assert data["review_state"] == "rejected"
|
| 174 |
+
assert turn_logic.session_store[sid].graph.claims["c1"].review_state == "rejected"
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def test_review_element_unknown_id_returns_not_found() -> None:
|
| 178 |
+
"""An unknown id returns status=not_found gracefully."""
|
| 179 |
+
sid = _make_session([_pending_claim()])
|
| 180 |
+
set_turn_context(sid)
|
| 181 |
+
|
| 182 |
+
raw = review_element.invoke({"target_id": "does_not_exist", "action": "accepted"})
|
| 183 |
+
data = json.loads(raw)
|
| 184 |
+
|
| 185 |
+
assert data["status"] == "not_found"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def test_review_element_invalid_action_returns_error() -> None:
|
| 189 |
+
"""An invalid action value returns status=error."""
|
| 190 |
+
sid = _make_session([_pending_claim()])
|
| 191 |
+
set_turn_context(sid)
|
| 192 |
+
|
| 193 |
+
raw = review_element.invoke({"target_id": "n1", "action": "approve"})
|
| 194 |
+
data = json.loads(raw)
|
| 195 |
+
|
| 196 |
+
assert data["status"] == "error"
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def test_review_element_no_pending_claims_returns_not_reviewable() -> None:
|
| 200 |
+
"""A node without reviewable pending claims returns not_reviewable."""
|
| 201 |
+
sid = _make_session([]) # no claims
|
| 202 |
+
set_turn_context(sid)
|
| 203 |
+
|
| 204 |
+
raw = review_element.invoke({"target_id": "n1", "action": "accepted"})
|
| 205 |
+
data = json.loads(raw)
|
| 206 |
+
|
| 207 |
+
assert data["status"] == "not_reviewable"
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ββ review_element: edge tests ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def test_review_element_edge_accept_transitions_claim() -> None:
|
| 214 |
+
"""review_element on an edge id transitions the edge claim."""
|
| 215 |
+
sid = _make_session_with_pending_edge_claim()
|
| 216 |
+
set_turn_context(sid)
|
| 217 |
+
|
| 218 |
+
raw = review_element.invoke({"target_id": "e1", "action": "accepted"})
|
| 219 |
+
data = json.loads(raw)
|
| 220 |
+
|
| 221 |
+
assert data["status"] == "ok"
|
| 222 |
+
assert data["review_state"] == "accepted"
|
| 223 |
+
assert (
|
| 224 |
+
turn_logic.session_store[sid].graph.claims["c-edge"].review_state == "accepted"
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_review_element_edge_appends_review_patch() -> None:
|
| 229 |
+
"""Edge review appends a RendererPatch to ctx.review_patches."""
|
| 230 |
+
sid = _make_session_with_pending_edge_claim()
|
| 231 |
+
set_turn_context(sid)
|
| 232 |
+
|
| 233 |
+
review_element.invoke({"target_id": "e1", "action": "accepted"})
|
| 234 |
+
ctx = get_turn_context()
|
| 235 |
+
|
| 236 |
+
assert len(ctx.review_patches) == 1
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# ββ finalize_agent_turn: review-only (empty accumulated_actions) ββββββββββββββ
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def test_finalize_with_review_patches_only_returns_turn_result() -> None:
|
| 243 |
+
"""finalize_agent_turn with no SceneActions but non-empty review_patches must
|
| 244 |
+
return a TurnResult (not None) whose renderer_patch carries the review ops."""
|
| 245 |
+
sid = _make_session([_pending_claim()])
|
| 246 |
+
set_turn_context(sid)
|
| 247 |
+
|
| 248 |
+
# Invoke review_element β this adds a RendererPatch to ctx.review_patches without
|
| 249 |
+
# touching accumulated_actions.
|
| 250 |
+
review_element.invoke({"target_id": "n1", "action": "accepted"})
|
| 251 |
+
ctx = get_turn_context()
|
| 252 |
+
assert ctx.accumulated_actions == []
|
| 253 |
+
assert len(ctx.review_patches) == 1
|
| 254 |
+
|
| 255 |
+
result = finalize_agent_turn(sid, {}, "I accepted that for you.")
|
| 256 |
+
|
| 257 |
+
assert result is not None, "review-only finalize must return TurnResult, not None"
|
| 258 |
+
assert isinstance(result, TurnResult)
|
| 259 |
+
# The renderer patch must contain the review ops.
|
| 260 |
+
assert result.renderer_patch is not None
|
| 261 |
+
assert len(result.renderer_patch.operations) > 0
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def test_finalize_with_no_actions_no_reviews_returns_none() -> None:
|
| 265 |
+
"""Pure-speech turn (no actions, no reviews) still returns None."""
|
| 266 |
+
sid = _make_session([_pending_claim()])
|
| 267 |
+
set_turn_context(sid)
|
| 268 |
+
|
| 269 |
+
result = finalize_agent_turn(sid, {}, "Just talking.")
|
| 270 |
+
|
| 271 |
+
assert result is None
|
tests/test_review_amber_clear.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BUG 1 regression: the amber 'review-pending' affordance must NOT clear after
|
| 2 |
+
accepting just ONE of a node's several model_inferred claims.
|
| 3 |
+
|
| 4 |
+
A target (node/edge) may govern 2-3 model_inferred claims. The amber awaiting-review
|
| 5 |
+
border covers the WHOLE target, so it may only clear once EVERY model_inferred claim
|
| 6 |
+
on it has been reviewed. The original ``_review_canvas_hint_dict`` emitted
|
| 7 |
+
``clear_pending`` on ANY accept, so the amber vanished while sibling claims were still
|
| 8 |
+
pending β silently telling the user "reviewed" when it wasn't.
|
| 9 |
+
|
| 10 |
+
These tests are grounded on the REAL producer: ``load_fixture_session`` over the
|
| 11 |
+
canonical ``seed_local_ai`` fixture (whose node ``concept::local_language_model``
|
| 12 |
+
genuinely carries two model_inferred pending claims), not a hand-built fixture β the
|
| 13 |
+
prompt-assumed shape (two claims both ``claim_type`` matching) is NOT how the real
|
| 14 |
+
producer emits per-node claims (the second is a ``semantic_edge`` claim), and only a
|
| 15 |
+
real-shape test exercises that.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import time
|
| 21 |
+
import uuid
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
from loosecanvas import turn_logic
|
| 25 |
+
from loosecanvas.contracts import Claim, Node, SceneState, SourceSnapshot
|
| 26 |
+
from loosecanvas.graph_repository import LooseGraphRepository
|
| 27 |
+
from loosecanvas.turn_logic import do_review_claim, load_fixture_session
|
| 28 |
+
|
| 29 |
+
_FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "seed_local_ai.json"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _make_session(claims: list[Claim]) -> str:
|
| 33 |
+
"""Minimal single-node session in turn_logic.session_store; return session_id."""
|
| 34 |
+
node = Node(id="n1", kind="concept", label="Test Node")
|
| 35 |
+
repo = LooseGraphRepository.from_extraction([node], [], claims, SourceSnapshot())
|
| 36 |
+
scene = SceneState(scene_id="s1", visible_node_ids=["n1"])
|
| 37 |
+
sid = str(uuid.uuid4())
|
| 38 |
+
now = time.time()
|
| 39 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 40 |
+
session_id=sid,
|
| 41 |
+
graph=repo,
|
| 42 |
+
scene=scene,
|
| 43 |
+
history=[],
|
| 44 |
+
graph_version=1,
|
| 45 |
+
created_at=now,
|
| 46 |
+
last_seen=now,
|
| 47 |
+
render_scene_id="s1",
|
| 48 |
+
)
|
| 49 |
+
return sid
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _remove_class_classes(result: turn_logic.ReviewClaimResult) -> set[str]:
|
| 53 |
+
return {
|
| 54 |
+
str(op.data.get("class"))
|
| 55 |
+
for op in result.renderer_patch.operations
|
| 56 |
+
if op.op == "remove_class"
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def test_amber_persists_until_all_node_claims_reviewed_real_fixture() -> None:
|
| 61 |
+
"""REAL-producer grounding: the ``seed_local_ai`` node
|
| 62 |
+
``concept::local_language_model`` carries exactly ONE model_inferred pending
|
| 63 |
+
claim (its ``concept_node`` claim) after the edge-claim target_id bug was fixed
|
| 64 |
+
(semantic_edge claims now correctly target edge ids, not the target node id).
|
| 65 |
+
|
| 66 |
+
This test verifies that accepting the single governing claim on a real-fixture
|
| 67 |
+
node fires ``clear_pending`` exactly once β the correct single-claim clear behavior
|
| 68 |
+
on a real-producer shape.
|
| 69 |
+
|
| 70 |
+
The multi-claim path (accept #1 leaves amber, accept #2 clears) is exercised by
|
| 71 |
+
the hand-built ``test_accept_one_of_two_leaves_amber_then_second_clears`` below,
|
| 72 |
+
which directly constructs a two-claim node session.
|
| 73 |
+
"""
|
| 74 |
+
sid, _snapshot = await load_fixture_session(fixture_path=_FIXTURE)
|
| 75 |
+
record = turn_logic.session_store[sid]
|
| 76 |
+
target = "concept::local_language_model"
|
| 77 |
+
pending = [
|
| 78 |
+
c.id
|
| 79 |
+
for c in record.graph.claims.values()
|
| 80 |
+
if c.target_id == target
|
| 81 |
+
and c.origin == "model_inferred"
|
| 82 |
+
and c.review_state == "pending"
|
| 83 |
+
]
|
| 84 |
+
assert len(pending) == 1, (
|
| 85 |
+
"fixture changed: after the edge-claim bug fix, "
|
| 86 |
+
f"concept::local_language_model should have exactly 1 model_inferred "
|
| 87 |
+
f"pending claim (the concept_node claim), got {len(pending)}"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
result = do_review_claim(sid, pending[0], "accepted")
|
| 91 |
+
assert (
|
| 92 |
+
result.canvas_hint["action"] == "clear_pending"
|
| 93 |
+
), "accepting the only model_inferred claim on the node must fire clear_pending"
|
| 94 |
+
assert "review-pending" in _remove_class_classes(result)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
async def test_accept_one_of_two_leaves_amber_then_second_clears() -> None:
|
| 98 |
+
"""Focused unit form of BUG 1 on a node governing two model_inferred pending
|
| 99 |
+
claims (both targeting the node): accept #1 -> no clear_pending (amber stays);
|
| 100 |
+
accept #2 -> clear_pending fires."""
|
| 101 |
+
c1 = Claim(
|
| 102 |
+
id="c1",
|
| 103 |
+
claim_type="label",
|
| 104 |
+
target_id="n1",
|
| 105 |
+
origin="model_inferred",
|
| 106 |
+
review_state="pending",
|
| 107 |
+
support_state="unverified",
|
| 108 |
+
text="first",
|
| 109 |
+
)
|
| 110 |
+
c2 = Claim(
|
| 111 |
+
id="c2",
|
| 112 |
+
claim_type="summary",
|
| 113 |
+
target_id="n1",
|
| 114 |
+
origin="model_inferred",
|
| 115 |
+
review_state="pending",
|
| 116 |
+
support_state="unverified",
|
| 117 |
+
text="second",
|
| 118 |
+
)
|
| 119 |
+
sid = _make_session([c1, c2])
|
| 120 |
+
|
| 121 |
+
r1 = do_review_claim(sid, "c1", "accepted")
|
| 122 |
+
assert r1.canvas_hint == {"action": "none", "target_id": "n1"}
|
| 123 |
+
assert "review-pending" not in _remove_class_classes(r1)
|
| 124 |
+
|
| 125 |
+
r2 = do_review_claim(sid, "c2", "accepted")
|
| 126 |
+
assert r2.canvas_hint == {"action": "clear_pending", "target_id": "n1"}
|
| 127 |
+
assert "review-pending" in _remove_class_classes(r2)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
async def test_single_claim_node_still_clears_amber_on_accept() -> None:
|
| 131 |
+
"""No regression for the simple case: a node with ONE model_inferred pending
|
| 132 |
+
claim clears the amber on accept (the old behavior, preserved)."""
|
| 133 |
+
c1 = Claim(
|
| 134 |
+
id="c1",
|
| 135 |
+
claim_type="label",
|
| 136 |
+
target_id="n1",
|
| 137 |
+
origin="model_inferred",
|
| 138 |
+
review_state="pending",
|
| 139 |
+
support_state="unverified",
|
| 140 |
+
text="only",
|
| 141 |
+
)
|
| 142 |
+
sid = _make_session([c1])
|
| 143 |
+
result = do_review_claim(sid, "c1", "accepted")
|
| 144 |
+
assert result.canvas_hint == {"action": "clear_pending", "target_id": "n1"}
|
| 145 |
+
assert "review-pending" in _remove_class_classes(result)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
async def test_rejection_does_not_remove_node_when_sibling_claims_remain() -> None:
|
| 149 |
+
"""Bug fix: rejecting a NODE claim must NOT remove the node from canvas, even when
|
| 150 |
+
sibling model_inferred claims remain pending. A rejected node claim clears the
|
| 151 |
+
amber badge only when all model_inferred claims are resolved (same gate as accept),
|
| 152 |
+
otherwise it leaves the badge up ('none') β the node stays on canvas either way.
|
| 153 |
+
|
| 154 |
+
This test previously asserted action=='remove' (the buggy behavior that caused
|
| 155 |
+
the canvas/backend desync). It is corrected to assert the fixed behavior:
|
| 156 |
+
rejecting c1 with c2 still pending β action='none' (sibling pending β badge stays);
|
| 157 |
+
the node is NOT removed.
|
| 158 |
+
|
| 159 |
+
For EDGE claims, rejection still emits action='remove' β that is a separate
|
| 160 |
+
code path locked by test_edge_claim_review.py::test_reject_edge_claim_still_removes_edge.
|
| 161 |
+
"""
|
| 162 |
+
c1 = Claim(
|
| 163 |
+
id="c1",
|
| 164 |
+
claim_type="label",
|
| 165 |
+
target_id="n1",
|
| 166 |
+
origin="model_inferred",
|
| 167 |
+
review_state="pending",
|
| 168 |
+
support_state="unverified",
|
| 169 |
+
text="first",
|
| 170 |
+
)
|
| 171 |
+
c2 = Claim(
|
| 172 |
+
id="c2",
|
| 173 |
+
claim_type="summary",
|
| 174 |
+
target_id="n1",
|
| 175 |
+
origin="model_inferred",
|
| 176 |
+
review_state="pending",
|
| 177 |
+
support_state="unverified",
|
| 178 |
+
text="second",
|
| 179 |
+
)
|
| 180 |
+
sid = _make_session([c1, c2])
|
| 181 |
+
result = do_review_claim(sid, "c1", "rejected")
|
| 182 |
+
# c2 is still pending β _target_fully_reviewed returns False β badge stays up.
|
| 183 |
+
# The node must NOT be removed from canvas.
|
| 184 |
+
assert result.canvas_hint == {"action": "none", "target_id": "n1"}
|
| 185 |
+
# Node still in the graph (backend retained it).
|
| 186 |
+
assert "n1" in turn_logic.session_store[sid].graph.nodes
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
async def test_non_model_claims_do_not_hold_the_amber() -> None:
|
| 190 |
+
"""Only model_inferred claims gate the amber. A node with one accepted
|
| 191 |
+
model_inferred claim plus a user_asserted pending-ish claim still clears β
|
| 192 |
+
user_asserted/deterministic claims never drive the awaiting-review affordance."""
|
| 193 |
+
mi = Claim(
|
| 194 |
+
id="mi",
|
| 195 |
+
claim_type="label",
|
| 196 |
+
target_id="n1",
|
| 197 |
+
origin="model_inferred",
|
| 198 |
+
review_state="pending",
|
| 199 |
+
support_state="unverified",
|
| 200 |
+
text="model",
|
| 201 |
+
)
|
| 202 |
+
user = Claim(
|
| 203 |
+
id="user",
|
| 204 |
+
claim_type="summary",
|
| 205 |
+
target_id="n1",
|
| 206 |
+
origin="user_asserted",
|
| 207 |
+
review_state="accepted",
|
| 208 |
+
support_state="unverified",
|
| 209 |
+
text="user",
|
| 210 |
+
)
|
| 211 |
+
sid = _make_session([mi, user])
|
| 212 |
+
result = do_review_claim(sid, "mi", "accepted")
|
| 213 |
+
# The only model_inferred claim is now accepted -> amber clears.
|
| 214 |
+
assert result.canvas_hint == {"action": "clear_pending", "target_id": "n1"}
|
tests/test_scene_curator.py
CHANGED
|
@@ -170,3 +170,85 @@ def test_compute_node_clusters_empty_graph_returns_empty() -> None:
|
|
| 170 |
from loosecanvas.scene_curator import compute_node_clusters
|
| 171 |
|
| 172 |
assert compute_node_clusters(LooseGraphRepository()) == {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
from loosecanvas.scene_curator import compute_node_clusters
|
| 171 |
|
| 172 |
assert compute_node_clusters(LooseGraphRepository()) == {}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ββ min_visible floor ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _sparse_repo() -> LooseGraphRepository:
|
| 179 |
+
"""3 connected concept nodes + 5 isolated concept nodes + 2 excluded nodes.
|
| 180 |
+
|
| 181 |
+
With max_visible=20 the classic scoring would normally surface only the connected
|
| 182 |
+
nodes first (higher betweenness/pagerank), so a min_visible floor must promote the
|
| 183 |
+
isolated nodes to reach 8 visible.
|
| 184 |
+
"""
|
| 185 |
+
nodes = [
|
| 186 |
+
# Connected triple
|
| 187 |
+
Node(id="a", kind="concept", label="alpha"),
|
| 188 |
+
Node(id="b", kind="concept", label="beta"),
|
| 189 |
+
Node(id="c", kind="concept", label="gamma"),
|
| 190 |
+
# Isolated concept nodes (low score, but eligible)
|
| 191 |
+
Node(id="d", kind="concept", label="delta"),
|
| 192 |
+
Node(id="e", kind="concept", label="epsilon"),
|
| 193 |
+
Node(id="f", kind="concept", label="zeta"),
|
| 194 |
+
Node(id="g", kind="concept", label="eta"),
|
| 195 |
+
Node(id="h", kind="concept", label="theta"),
|
| 196 |
+
# Excluded nodes (must NOT be promoted by min_visible)
|
| 197 |
+
Node(id="test_module", kind="module", label="test_module"),
|
| 198 |
+
Node(id="ext_dep", kind="external_dep", label="numpy"),
|
| 199 |
+
]
|
| 200 |
+
edges = [
|
| 201 |
+
Edge(id="ab", source="a", target="b", kind="relates_to"),
|
| 202 |
+
Edge(id="bc", source="b", target="c", kind="relates_to"),
|
| 203 |
+
]
|
| 204 |
+
return _repo(nodes, edges)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def test_min_visible_promotes_eligible_nodes_on_sparse_graph() -> None:
|
| 208 |
+
"""A small graph with 8 eligible nodes should yield >= min(8, eligible) visible."""
|
| 209 |
+
repo = _sparse_repo()
|
| 210 |
+
# 8 eligible nodes (a,b,c,d,e,f,g,h), 2 excluded (test_module, ext_dep)
|
| 211 |
+
scene, _ = SceneCurator(repo).curate_initial_scene(max_visible=20, min_visible=8)
|
| 212 |
+
|
| 213 |
+
eligible_ids = {"a", "b", "c", "d", "e", "f", "g", "h"}
|
| 214 |
+
assert len(scene.visible_node_ids) >= 8
|
| 215 |
+
# All promoted nodes must still be eligible (not excluded)
|
| 216 |
+
for nid in scene.visible_node_ids:
|
| 217 |
+
assert nid in eligible_ids
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def test_min_visible_never_promotes_excluded_nodes() -> None:
|
| 221 |
+
"""Even when min_visible exceeds eligible count, excluded nodes must stay out."""
|
| 222 |
+
repo = _sparse_repo()
|
| 223 |
+
# 8 eligible nodes; requesting min_visible=12 exceeds eligible, so we cap at eligible
|
| 224 |
+
scene, _ = SceneCurator(repo).curate_initial_scene(max_visible=20, min_visible=12)
|
| 225 |
+
|
| 226 |
+
assert "test_module" not in scene.visible_node_ids
|
| 227 |
+
assert "ext_dep" not in scene.visible_node_ids
|
| 228 |
+
# Should have all 8 eligible nodes (capped at eligible_count=8, not min_visible=12)
|
| 229 |
+
assert len(scene.visible_node_ids) == 8
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def test_min_visible_default_unchanged_for_small_graph() -> None:
|
| 233 |
+
"""Default min_visible=8 still works; a graph with <8 eligible nodes is fine."""
|
| 234 |
+
# Only 3 eligible nodes: solo, pair_a, pair_b
|
| 235 |
+
nodes = [
|
| 236 |
+
Node(id="solo", kind="concept", label="Solo"),
|
| 237 |
+
Node(id="pair_a", kind="concept", label="PairA"),
|
| 238 |
+
Node(id="pair_b", kind="concept", label="PairB"),
|
| 239 |
+
]
|
| 240 |
+
edges = [Edge(id="e1", source="pair_a", target="pair_b", kind="relates_to")]
|
| 241 |
+
repo = _repo(nodes, edges)
|
| 242 |
+
# min_visible=8, but only 3 eligible β should surface all 3 without crash
|
| 243 |
+
scene, _ = SceneCurator(repo).curate_initial_scene(max_visible=20, min_visible=8)
|
| 244 |
+
assert len(scene.visible_node_ids) == 3
|
| 245 |
+
assert set(scene.visible_node_ids) == {"solo", "pair_a", "pair_b"}
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def test_min_visible_guard_fires_when_exceeds_max_visible() -> None:
|
| 249 |
+
"""assert min_visible <= max_visible must raise AssertionError."""
|
| 250 |
+
import pytest
|
| 251 |
+
|
| 252 |
+
repo = _sparse_repo()
|
| 253 |
+
with pytest.raises(AssertionError):
|
| 254 |
+
SceneCurator(repo).curate_initial_scene(max_visible=5, min_visible=8)
|
tests/test_search_no_match_reset.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression: a no-match search after a match must clear the prior highlight/dim.
|
| 2 |
+
|
| 3 |
+
QA found: search 'Ret' (highlights RAG, dims the rest), then a no-match term without
|
| 4 |
+
clearing β the previous highlight + dim stayed lit, so the graph looked like the old
|
| 5 |
+
match was still selected.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
import uuid
|
| 12 |
+
|
| 13 |
+
from loosecanvas import turn_logic
|
| 14 |
+
from loosecanvas.contracts import Node, SceneState, SourceSnapshot
|
| 15 |
+
from loosecanvas.graph_repository import LooseGraphRepository
|
| 16 |
+
from loosecanvas.main import on_search
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _make_session() -> str:
|
| 20 |
+
nodes = [
|
| 21 |
+
Node(id="n1", kind="concept", label="Alpha"),
|
| 22 |
+
Node(id="n2", kind="concept", label="Beta"),
|
| 23 |
+
]
|
| 24 |
+
repo = LooseGraphRepository.from_extraction(nodes, [], [], SourceSnapshot())
|
| 25 |
+
scene = SceneState(scene_id="s1", visible_node_ids=["n1", "n2"])
|
| 26 |
+
sid = str(uuid.uuid4())
|
| 27 |
+
now = time.time()
|
| 28 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 29 |
+
session_id=sid,
|
| 30 |
+
graph=repo,
|
| 31 |
+
scene=scene,
|
| 32 |
+
history=[],
|
| 33 |
+
graph_version=1,
|
| 34 |
+
created_at=now,
|
| 35 |
+
last_seen=now,
|
| 36 |
+
render_scene_id="s1",
|
| 37 |
+
)
|
| 38 |
+
return sid
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def test_no_match_search_clears_prior_highlight_and_dim() -> None:
|
| 42 |
+
sid = _make_session()
|
| 43 |
+
record = turn_logic.session_store[sid]
|
| 44 |
+
|
| 45 |
+
# First search matches a visible node β it highlights + dims the rest.
|
| 46 |
+
patch1, _ = await on_search("Alpha", sid)
|
| 47 |
+
assert record.scene.highlighted_ids, "a matching search must highlight something"
|
| 48 |
+
|
| 49 |
+
# A second, NO-MATCH search must clear the prior highlight/dim (not leave it lit).
|
| 50 |
+
patch2, matches2 = await on_search("zzz-no-such-node", sid)
|
| 51 |
+
assert record.scene.highlighted_ids == [], "stale highlight must be cleared"
|
| 52 |
+
ops = patch2["operations"]
|
| 53 |
+
assert any(o["op"] == "clear_dim" for o in ops), "no-match must lift the dim filter"
|
| 54 |
+
assert any(
|
| 55 |
+
o["op"] == "remove_class" and o.get("data", {}).get("class") == "highlighted"
|
| 56 |
+
for o in ops
|
| 57 |
+
), "no-match must remove the prior 'highlighted' class"
|
tests/test_seed_resilience.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Seed-on-load resilience (on_seed / _seed_snapshot).
|
| 2 |
+
|
| 3 |
+
These are the bugs an adversarial QA + audit sweep surfaced:
|
| 4 |
+
|
| 5 |
+
1. Every page load creates a NEW session. Once ``session_store`` hits ``_MAX_SESSIONS``
|
| 6 |
+
the alternate loaders raise ``OverflowError("session_cap")``. ``on_seed`` had no
|
| 7 |
+
try/except, so on a busy HF Space the canvas stayed permanently EMPTY with the
|
| 8 |
+
"Building your starter mapβ¦" banner stuck and an "Error / session_cap" toast leaking
|
| 9 |
+
the raw identifier. A fresh visitor must ALWAYS get a seeded canvas β the cap should
|
| 10 |
+
EVICT the least-recently-seen session to make room, not hard-reject.
|
| 11 |
+
|
| 12 |
+
2. A corrupt/partial seed (bad JSON / missing ``scene`` key) got no fallback.
|
| 13 |
+
|
| 14 |
+
3. ``on_seed`` only fell back when ``not path.exists()`` β any other failure crashed.
|
| 15 |
+
|
| 16 |
+
Grounded against the REAL producer (``load_fixture_session`` + the shipped fixtures),
|
| 17 |
+
not a hand-built fiction fixture.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import time
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
import pytest
|
| 27 |
+
from loosecanvas.contracts import SceneState, SourceSnapshot
|
| 28 |
+
from loosecanvas.graph_repository import LooseGraphRepository
|
| 29 |
+
from loosecanvas.main import _FIXTURES, _seed_snapshot
|
| 30 |
+
from loosecanvas.turn_logic import (
|
| 31 |
+
_MAX_SESSIONS,
|
| 32 |
+
RendererPatch,
|
| 33 |
+
SessionRecord,
|
| 34 |
+
session_store,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _stuff_session(session_id: str, *, last_seen: float) -> None:
|
| 39 |
+
"""Insert a minimal-but-REAL ``SessionRecord`` directly into ``session_store``.
|
| 40 |
+
|
| 41 |
+
Mirrors how ``load_fixture_session`` builds a record (a ``LooseGraphRepository``
|
| 42 |
+
plus a ``SceneState``), so the cap math sees the same shape the live path creates.
|
| 43 |
+
"""
|
| 44 |
+
repo = LooseGraphRepository.from_extraction([], [], [], SourceSnapshot())
|
| 45 |
+
now = time.time()
|
| 46 |
+
session_store[session_id] = SessionRecord(
|
| 47 |
+
session_id=session_id,
|
| 48 |
+
graph=repo,
|
| 49 |
+
scene=SceneState(),
|
| 50 |
+
history=[],
|
| 51 |
+
graph_version=1,
|
| 52 |
+
created_at=now,
|
| 53 |
+
last_seen=last_seen,
|
| 54 |
+
patch_id=0,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@pytest.mark.asyncio
|
| 59 |
+
async def test_seed_succeeds_when_store_is_at_cap_via_lru_eviction() -> None:
|
| 60 |
+
"""The lynchpin bug: with the store FULL of fresh (non-stale) sessions, a new
|
| 61 |
+
visitor must still get a SEEDED canvas. LRU eviction makes room; no empty canvas,
|
| 62 |
+
no leaked ``session_cap`` string."""
|
| 63 |
+
now = time.time()
|
| 64 |
+
# Fill to the cap with NON-stale sessions (well within the TTL) so the existing
|
| 65 |
+
# TTL-only ``_evict_stale_sessions`` can't help β only LRU eviction can.
|
| 66 |
+
for i in range(_MAX_SESSIONS):
|
| 67 |
+
_stuff_session(f"full-{i}", last_seen=now - i) # full-0 newest, last is oldest
|
| 68 |
+
assert len(session_store) == _MAX_SESSIONS
|
| 69 |
+
|
| 70 |
+
snapshot, sid, onboarding_msg = await _seed_snapshot()
|
| 71 |
+
|
| 72 |
+
# The renderer value is the RendererPatch serialized for the Gradio component value
|
| 73 |
+
# (``model_dump()`` dict), carrying a real, seeded snapshot.
|
| 74 |
+
assert isinstance(snapshot, dict)
|
| 75 |
+
assert snapshot["is_snapshot"] is True
|
| 76 |
+
assert snapshot[
|
| 77 |
+
"operations"
|
| 78 |
+
], "expected a seeded (non-empty) canvas, not a blank one"
|
| 79 |
+
# A fresh session id was minted and lives in the store.
|
| 80 |
+
assert sid
|
| 81 |
+
assert sid in session_store
|
| 82 |
+
# The least-recently-seen session ("full-19") was evicted to make room.
|
| 83 |
+
assert f"full-{_MAX_SESSIONS - 1}" not in session_store
|
| 84 |
+
assert len(session_store) <= _MAX_SESSIONS
|
| 85 |
+
# The user-visible onboarding text must NEVER leak the raw cap identifier.
|
| 86 |
+
assert "session_cap" not in str(onboarding_msg)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@pytest.mark.asyncio
|
| 90 |
+
async def test_seed_falls_back_to_annotated_when_seed_file_missing(
|
| 91 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 92 |
+
) -> None:
|
| 93 |
+
"""If the configured seed file does not exist, on_seed must fall back to the
|
| 94 |
+
shipped ``annotated_graph`` and still paint a canvas."""
|
| 95 |
+
monkeypatch.setitem(_FIXTURES, "seed_local_ai", Path("does-not-exist-on-disk.json"))
|
| 96 |
+
|
| 97 |
+
snapshot, sid, onboarding_msg = await _seed_snapshot()
|
| 98 |
+
|
| 99 |
+
assert isinstance(snapshot, dict)
|
| 100 |
+
assert snapshot["operations"], "annotated_graph fallback should seed a real canvas"
|
| 101 |
+
assert sid in session_store
|
| 102 |
+
assert "session_cap" not in str(onboarding_msg)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@pytest.mark.asyncio
|
| 106 |
+
async def test_seed_survives_corrupt_seed_and_corrupt_fallback(
|
| 107 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 108 |
+
) -> None:
|
| 109 |
+
"""A corrupt/partial seed AND a corrupt fallback must never crash on_seed or leave
|
| 110 |
+
the 'Buildingβ¦' banner stuck over an empty canvas: a valid (possibly empty)
|
| 111 |
+
snapshot + a FRIENDLY message is returned, with no leaked 'session_cap'/stacktrace.
|
| 112 |
+
"""
|
| 113 |
+
bad: dict[str, dict[str, Any]] = {"graph": {}} # missing 'scene' -> ValueError path
|
| 114 |
+
|
| 115 |
+
async def boom(*args: Any, **kwargs: Any) -> tuple[str, RendererPatch]:
|
| 116 |
+
from loosecanvas.extractors.fixture_adapter import load_fixture_from_dict
|
| 117 |
+
|
| 118 |
+
# Exercise the real producer's failure mode (ValueError), not a synthetic one.
|
| 119 |
+
load_fixture_from_dict(bad)
|
| 120 |
+
raise AssertionError("unreachable")
|
| 121 |
+
|
| 122 |
+
# Every fixture load fails the same way the real producer would on a corrupt dict.
|
| 123 |
+
monkeypatch.setattr("loosecanvas.main.load_fixture_session", boom)
|
| 124 |
+
snapshot, sid, onboarding_msg = await _seed_snapshot()
|
| 125 |
+
|
| 126 |
+
# Never crash; always a valid (serialized) snapshot envelope.
|
| 127 |
+
assert isinstance(snapshot, dict)
|
| 128 |
+
assert snapshot["is_snapshot"] is True
|
| 129 |
+
# Friendly onboarding copy, no leaked internals.
|
| 130 |
+
assert "session_cap" not in str(onboarding_msg)
|
| 131 |
+
assert "Traceback" not in str(onboarding_msg)
|
| 132 |
+
assert str(onboarding_msg).strip(), "must show a friendly onboarding message"
|
tests/test_t1_03_polish.py
CHANGED
|
@@ -38,12 +38,27 @@ def test_main_has_onboarding_markdown() -> None:
|
|
| 38 |
assert "lc-onboarding" in content, "main.py should have lc-onboarding elem_classes"
|
| 39 |
|
| 40 |
|
| 41 |
-
def
|
| 42 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
content = (ROOT / "src/loosecanvas/main.py").read_text(encoding="utf-8")
|
| 44 |
assert (
|
| 45 |
-
"
|
| 46 |
-
), "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
def test_main_config_streaming_true() -> None:
|
|
|
|
| 38 |
assert "lc-onboarding" in content, "main.py should have lc-onboarding elem_classes"
|
| 39 |
|
| 40 |
|
| 41 |
+
def test_main_open_world_entry() -> None:
|
| 42 |
+
"""Chat-first magic-build entry (supersedes the seed-on-load graph): the canvas
|
| 43 |
+
opens EMPTY via on_open, the composer is prefilled with editable seed text, and the
|
| 44 |
+
first Send builds the graph from that text via stream_text_session. Export stays; the
|
| 45 |
+
fixture Dropdown / build Accordion remain gone.
|
| 46 |
+
"""
|
| 47 |
content = (ROOT / "src/loosecanvas/main.py").read_text(encoding="utf-8")
|
| 48 |
assert (
|
| 49 |
+
"demo.load(on_open" in content
|
| 50 |
+
), "page load wires the empty-canvas on_open entry"
|
| 51 |
+
assert "_SEED_TEXT" in content, "composer must be prefilled with editable seed text"
|
| 52 |
+
assert (
|
| 53 |
+
"stream_text_session" in content
|
| 54 |
+
), "the first Send must build a graph from the seed text (magic build)"
|
| 55 |
+
assert (
|
| 56 |
+
"gr.DownloadButton" in content
|
| 57 |
+
), "Export button (DownloadButton) must be present"
|
| 58 |
+
assert (
|
| 59 |
+
"gr.Accordion(" not in content
|
| 60 |
+
), "open-world entry removes the build accordion"
|
| 61 |
+
assert "gr.Dropdown(" not in content, "open-world entry removes the fixture picker"
|
| 62 |
|
| 63 |
|
| 64 |
def test_main_config_streaming_true() -> None:
|
tests/test_t2_01_removal.py
CHANGED
|
@@ -61,7 +61,7 @@ def test_schema_has_remove_variants() -> None:
|
|
| 61 |
assert "remove_edge" in schema_str
|
| 62 |
assert "remove_node" in [e.value for e in SceneActionType]
|
| 63 |
assert "remove_edge" in [e.value for e in SceneActionType]
|
| 64 |
-
assert len(SceneActionType) ==
|
| 65 |
|
| 66 |
|
| 67 |
# ββ SC2: Validator rejects malformed removals βββββββββββββββββββββββββββββββββ
|
|
|
|
| 61 |
assert "remove_edge" in schema_str
|
| 62 |
assert "remove_node" in [e.value for e in SceneActionType]
|
| 63 |
assert "remove_edge" in [e.value for e in SceneActionType]
|
| 64 |
+
assert len(SceneActionType) == 16
|
| 65 |
|
| 66 |
|
| 67 |
# ββ SC2: Validator rejects malformed removals βββββββββββββββββββββββββββββββββ
|
tests/test_t2_02_claim_review.py
CHANGED
|
@@ -209,7 +209,13 @@ async def test_response_shape_and_graph_version() -> None:
|
|
| 209 |
|
| 210 |
|
| 211 |
async def test_reject_canvas_hint() -> None:
|
| 212 |
-
"""SC6 (rejected): canvas hint is action=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
claim = Claim(
|
| 214 |
id="c1",
|
| 215 |
claim_type="label",
|
|
@@ -227,7 +233,9 @@ async def test_reject_canvas_hint() -> None:
|
|
| 227 |
resp = await client.post(f"/api/claim/{sid}/c1/review?state=rejected")
|
| 228 |
assert resp.status_code == 200
|
| 229 |
canvas = resp.json()["canvas"]
|
| 230 |
-
|
|
|
|
|
|
|
| 231 |
|
| 232 |
|
| 233 |
async def test_do_review_claim_round_trip_inspector_updated() -> None:
|
|
|
|
| 209 |
|
| 210 |
|
| 211 |
async def test_reject_canvas_hint() -> None:
|
| 212 |
+
"""SC6 (rejected): for a NODE claim, canvas hint is action=clear_pending (NOT
|
| 213 |
+
remove). Rejecting a claim on a node must keep the node on canvas β only the
|
| 214 |
+
amber 'awaiting review' badge is cleared. This test was previously encoding the
|
| 215 |
+
bug (action='remove'), and is corrected to assert the fixed behaviour.
|
| 216 |
+
(For EDGE claims, rejection still emits action='remove' β locked by
|
| 217 |
+
test_edge_claim_review.py::test_reject_edge_claim_still_removes_edge.)
|
| 218 |
+
"""
|
| 219 |
claim = Claim(
|
| 220 |
id="c1",
|
| 221 |
claim_type="label",
|
|
|
|
| 233 |
resp = await client.post(f"/api/claim/{sid}/c1/review?state=rejected")
|
| 234 |
assert resp.status_code == 200
|
| 235 |
canvas = resp.json()["canvas"]
|
| 236 |
+
# Node claims: reject clears the badge but keeps the node (clear_pending).
|
| 237 |
+
assert canvas["action"] == "clear_pending"
|
| 238 |
+
assert canvas["target_id"] == "n1"
|
| 239 |
|
| 240 |
|
| 241 |
async def test_do_review_claim_round_trip_inspector_updated() -> None:
|
tests/test_t2_03_edit_edges.py
CHANGED
|
@@ -88,8 +88,9 @@ def test_schema_has_edit_variants() -> None:
|
|
| 88 |
|
| 89 |
schema_str = str(sceneplan_validation_schema())
|
| 90 |
assert "update_node" in schema_str
|
|
|
|
| 91 |
assert "create_edge" in schema_str
|
| 92 |
-
assert len(SceneActionType) ==
|
| 93 |
|
| 94 |
|
| 95 |
# ββ SC1: Overlay without destroying original ββββββββββββββββββββββββββββββββββ
|
|
@@ -197,9 +198,11 @@ def test_create_edge_valid() -> None:
|
|
| 197 |
result = validate_sceneplan(ScenePlan(actions=[action]), graph, scene)
|
| 198 |
assert len(result.valid_actions) == 1
|
| 199 |
gp, sp, events = reduce_turn([action], graph, scene)
|
| 200 |
-
|
|
|
|
|
|
|
| 201 |
assert any(
|
| 202 |
-
c.claim_type == "relationship" and c.origin == "
|
| 203 |
for c in gp.upsert_claims
|
| 204 |
)
|
| 205 |
# Edge is surfaced since both endpoints are visible
|
|
@@ -221,7 +224,11 @@ def test_reducer_update_node_idempotent() -> None:
|
|
| 221 |
# ββ SC5: Trust renders correctly βββββββββββββββββββββββββββββββββββββββββββββ
|
| 222 |
|
| 223 |
|
| 224 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
graph = _graph("n1", "n2")
|
| 226 |
scene = _scene(visible_nodes=["n1", "n2"])
|
| 227 |
action = SceneAction(
|
|
@@ -229,8 +236,8 @@ def test_create_edge_claim_has_user_asserted_origin() -> None:
|
|
| 229 |
)
|
| 230 |
gp, sp, ev = reduce_turn([action], graph, scene)
|
| 231 |
edge_claim = next(c for c in gp.upsert_claims if c.claim_type == "relationship")
|
| 232 |
-
assert edge_claim.origin == "
|
| 233 |
-
assert edge_claim.review_state == "
|
| 234 |
assert edge_claim.support_state == "unverified"
|
| 235 |
|
| 236 |
|
|
@@ -317,6 +324,185 @@ def test_update_node_id_not_found() -> None:
|
|
| 317 |
assert "id_not_found" in result.rejection_reasons[0]
|
| 318 |
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
# ββ Duplicate user_asserted edge rejection ββββββββββββββββββββββββββββββββββββ
|
| 321 |
|
| 322 |
|
|
|
|
| 88 |
|
| 89 |
schema_str = str(sceneplan_validation_schema())
|
| 90 |
assert "update_node" in schema_str
|
| 91 |
+
assert "update_edge" in schema_str
|
| 92 |
assert "create_edge" in schema_str
|
| 93 |
+
assert len(SceneActionType) == 16
|
| 94 |
|
| 95 |
|
| 96 |
# ββ SC1: Overlay without destroying original ββββββββββββββββββββββββββββββββββ
|
|
|
|
| 198 |
result = validate_sceneplan(ScenePlan(actions=[action]), graph, scene)
|
| 199 |
assert len(result.valid_actions) == 1
|
| 200 |
gp, sp, events = reduce_turn([action], graph, scene)
|
| 201 |
+
# Agent create_edge is model_inferred (the model PROPOSES a relationship), not
|
| 202 |
+
# user_asserted β keeps trust unflattened so it renders amber + redacts on export.
|
| 203 |
+
assert any(e.properties.get("origin") == "model_inferred" for e in gp.upsert_edges)
|
| 204 |
assert any(
|
| 205 |
+
c.claim_type == "relationship" and c.origin == "model_inferred"
|
| 206 |
for c in gp.upsert_claims
|
| 207 |
)
|
| 208 |
# Edge is surfaced since both endpoints are visible
|
|
|
|
| 224 |
# ββ SC5: Trust renders correctly βββββββββββββββββββββββββββββββββββββββββββββ
|
| 225 |
|
| 226 |
|
| 227 |
+
def test_create_edge_claim_has_model_inferred_origin() -> None:
|
| 228 |
+
"""Agent-proposed edges are model_inferred + pending (awaiting review), NOT
|
| 229 |
+
user_asserted/accepted β the model proposing a relationship must not pass as
|
| 230 |
+
user-vouched on export (plan/06 trust model). Direct user connects
|
| 231 |
+
(apply_user_edge) remain user_asserted/accepted."""
|
| 232 |
graph = _graph("n1", "n2")
|
| 233 |
scene = _scene(visible_nodes=["n1", "n2"])
|
| 234 |
action = SceneAction(
|
|
|
|
| 236 |
)
|
| 237 |
gp, sp, ev = reduce_turn([action], graph, scene)
|
| 238 |
edge_claim = next(c for c in gp.upsert_claims if c.claim_type == "relationship")
|
| 239 |
+
assert edge_claim.origin == "model_inferred"
|
| 240 |
+
assert edge_claim.review_state == "pending"
|
| 241 |
assert edge_claim.support_state == "unverified"
|
| 242 |
|
| 243 |
|
|
|
|
| 324 |
assert "id_not_found" in result.rejection_reasons[0]
|
| 325 |
|
| 326 |
|
| 327 |
+
# ββ update_edge: relabel an existing edge (user_asserted overlay) βββββββββββββ
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def _edge_l(edge_id: str, src: str, tgt: str, label: str) -> Edge:
|
| 331 |
+
return Edge(id=edge_id, source=src, target=tgt, kind="relates_to", label=label)
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def test_effective_edge_label_prefers_user_overlay() -> None:
|
| 335 |
+
from loosecanvas.contracts import Claim
|
| 336 |
+
from loosecanvas.turn_logic import effective_edge_label
|
| 337 |
+
|
| 338 |
+
edge = _edge_l("e1", "n1", "n2", "original")
|
| 339 |
+
# No claims β the raw edge.label.
|
| 340 |
+
assert effective_edge_label(edge, []) == "original"
|
| 341 |
+
# A model_inferred label claim is a PROPOSAL β it does not change the displayed label.
|
| 342 |
+
model_claim = Claim(
|
| 343 |
+
id="c-model",
|
| 344 |
+
claim_type="label",
|
| 345 |
+
target_id="e1",
|
| 346 |
+
text="model guess",
|
| 347 |
+
origin="model_inferred",
|
| 348 |
+
)
|
| 349 |
+
assert effective_edge_label(edge, [model_claim]) == "original"
|
| 350 |
+
# A user_asserted label claim overrides the displayed label.
|
| 351 |
+
user_claim = Claim(
|
| 352 |
+
id="c-user",
|
| 353 |
+
claim_type="label",
|
| 354 |
+
target_id="e1",
|
| 355 |
+
text="clearer label",
|
| 356 |
+
origin="user_asserted",
|
| 357 |
+
review_state="accepted",
|
| 358 |
+
)
|
| 359 |
+
assert effective_edge_label(edge, [model_claim, user_claim]) == "clearer label"
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def test_reducer_update_edge_user_overlay() -> None:
|
| 363 |
+
graph = _graph("n1", "n2", edges=[_edge_l("e1", "n1", "n2", "original")])
|
| 364 |
+
scene = _scene(visible_nodes=["n1", "n2"], visible_edges=["e1"])
|
| 365 |
+
action = SceneAction(
|
| 366 |
+
type=SceneActionType.update_edge, edge_id="e1", label="clearer"
|
| 367 |
+
)
|
| 368 |
+
gp, sp, events = reduce_turn([action], graph, scene)
|
| 369 |
+
claim = next(c for c in gp.upsert_claims if c.claim_type == "label")
|
| 370 |
+
assert claim.target_id == "e1"
|
| 371 |
+
assert claim.origin == "user_asserted"
|
| 372 |
+
assert claim.review_state == "accepted"
|
| 373 |
+
assert claim.text == "clearer"
|
| 374 |
+
# Original edge.label is preserved underneath the overlay.
|
| 375 |
+
graph.apply_graph_patch(gp)
|
| 376 |
+
assert graph.get_edge("e1").label == "original"
|
| 377 |
+
from loosecanvas.turn_logic import effective_edge_label
|
| 378 |
+
|
| 379 |
+
assert (
|
| 380 |
+
effective_edge_label(graph.get_edge("e1"), graph.claims.values()) == "clearer"
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def test_validator_update_edge_missing_fields() -> None:
|
| 385 |
+
graph = _graph("n1", "n2", edges=[_edge_l("e1", "n1", "n2", "x")])
|
| 386 |
+
scene = _scene(visible_nodes=["n1", "n2"], visible_edges=["e1"])
|
| 387 |
+
r1 = validate_sceneplan(
|
| 388 |
+
ScenePlan(actions=[SceneAction(type=SceneActionType.update_edge, label="y")]),
|
| 389 |
+
graph,
|
| 390 |
+
scene,
|
| 391 |
+
)
|
| 392 |
+
assert "edge_id" in r1.rejection_reasons[0]
|
| 393 |
+
r2 = validate_sceneplan(
|
| 394 |
+
ScenePlan(
|
| 395 |
+
actions=[SceneAction(type=SceneActionType.update_edge, edge_id="e1")]
|
| 396 |
+
),
|
| 397 |
+
graph,
|
| 398 |
+
scene,
|
| 399 |
+
)
|
| 400 |
+
assert "label" in r2.rejection_reasons[0]
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def test_validator_update_edge_not_found_and_not_visible() -> None:
|
| 404 |
+
graph = _graph("n1", "n2", edges=[_edge_l("e1", "n1", "n2", "x")])
|
| 405 |
+
scene = _scene(visible_nodes=["n1", "n2"], visible_edges=["e1"])
|
| 406 |
+
r1 = validate_sceneplan(
|
| 407 |
+
ScenePlan(
|
| 408 |
+
actions=[
|
| 409 |
+
SceneAction(
|
| 410 |
+
type=SceneActionType.update_edge, edge_id="ghost", label="y"
|
| 411 |
+
)
|
| 412 |
+
]
|
| 413 |
+
),
|
| 414 |
+
graph,
|
| 415 |
+
scene,
|
| 416 |
+
)
|
| 417 |
+
assert "id_not_found" in r1.rejection_reasons[0]
|
| 418 |
+
scene_hidden = _scene(visible_nodes=["n1", "n2"], visible_edges=[])
|
| 419 |
+
r2 = validate_sceneplan(
|
| 420 |
+
ScenePlan(
|
| 421 |
+
actions=[
|
| 422 |
+
SceneAction(type=SceneActionType.update_edge, edge_id="e1", label="y")
|
| 423 |
+
]
|
| 424 |
+
),
|
| 425 |
+
graph,
|
| 426 |
+
scene_hidden,
|
| 427 |
+
)
|
| 428 |
+
assert "edge_not_visible" in r2.rejection_reasons[0]
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def test_validator_update_edge_valid() -> None:
|
| 432 |
+
graph = _graph("n1", "n2", edges=[_edge_l("e1", "n1", "n2", "x")])
|
| 433 |
+
scene = _scene(visible_nodes=["n1", "n2"], visible_edges=["e1"])
|
| 434 |
+
result = validate_sceneplan(
|
| 435 |
+
ScenePlan(
|
| 436 |
+
actions=[
|
| 437 |
+
SceneAction(
|
| 438 |
+
type=SceneActionType.update_edge, edge_id="e1", label="clearer"
|
| 439 |
+
)
|
| 440 |
+
]
|
| 441 |
+
),
|
| 442 |
+
graph,
|
| 443 |
+
scene,
|
| 444 |
+
)
|
| 445 |
+
assert len(result.valid_actions) == 1
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def _make_edge_session() -> str:
|
| 449 |
+
nodes = [
|
| 450 |
+
Node(id="n1", kind="concept", label="N1"),
|
| 451 |
+
Node(id="n2", kind="concept", label="N2"),
|
| 452 |
+
]
|
| 453 |
+
edges = [_edge_l("e1", "n1", "n2", "original")]
|
| 454 |
+
repo = LooseGraphRepository.from_extraction(nodes, edges, [], SourceSnapshot())
|
| 455 |
+
scene = SceneState(
|
| 456 |
+
scene_id="s1", visible_node_ids=["n1", "n2"], visible_edge_ids=["e1"]
|
| 457 |
+
)
|
| 458 |
+
sid = str(uuid.uuid4())
|
| 459 |
+
now = time.time()
|
| 460 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 461 |
+
session_id=sid,
|
| 462 |
+
graph=repo,
|
| 463 |
+
scene=scene,
|
| 464 |
+
history=[],
|
| 465 |
+
graph_version=1,
|
| 466 |
+
created_at=now,
|
| 467 |
+
last_seen=now,
|
| 468 |
+
render_scene_id="s1",
|
| 469 |
+
)
|
| 470 |
+
return sid
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def test_apply_agent_actions_update_edge_emits_update_data() -> None:
|
| 474 |
+
"""The load-bearing render assertion: relabeling an already-visible edge must
|
| 475 |
+
emit an explicit update_data op carrying the new effective label (diff_scene
|
| 476 |
+
alone won't repaint a persisting element's label)."""
|
| 477 |
+
sid = _make_edge_session()
|
| 478 |
+
record = turn_logic.session_store[sid]
|
| 479 |
+
try:
|
| 480 |
+
action = SceneAction(
|
| 481 |
+
type=SceneActionType.update_edge, edge_id="e1", label="clearer link"
|
| 482 |
+
)
|
| 483 |
+
result = turn_logic.apply_agent_actions(
|
| 484 |
+
record, [action], {"kind": "edge", "id": "e1"}, "done"
|
| 485 |
+
)
|
| 486 |
+
ops = result.renderer_patch.operations
|
| 487 |
+
update_ops = [
|
| 488 |
+
o for o in ops if o.op == "update_data" and o.data.get("id") == "e1"
|
| 489 |
+
]
|
| 490 |
+
assert len(update_ops) == 1, "relabeled visible edge must emit update_data"
|
| 491 |
+
assert update_ops[0].data["data"]["label"] == "clearer link"
|
| 492 |
+
# Effective label resolves to the overlay; raw edge.label preserved.
|
| 493 |
+
from loosecanvas.turn_logic import effective_edge_label
|
| 494 |
+
|
| 495 |
+
assert record.graph.get_edge("e1").label == "original"
|
| 496 |
+
assert (
|
| 497 |
+
effective_edge_label(
|
| 498 |
+
record.graph.get_edge("e1"), record.graph.claims.values()
|
| 499 |
+
)
|
| 500 |
+
== "clearer link"
|
| 501 |
+
)
|
| 502 |
+
finally:
|
| 503 |
+
turn_logic.session_store.pop(sid, None)
|
| 504 |
+
|
| 505 |
+
|
| 506 |
# ββ Duplicate user_asserted edge rejection ββββββββββββββββββββββββββββββββββββ
|
| 507 |
|
| 508 |
|
tests/test_t2_04_node_edits.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""T2-04 β user-initiated node edits (radial menu: add / remove node, edit summary).
|
| 2 |
+
|
| 3 |
+
These mirror the sibling edge-edit tests in ``tests/test_b2_cxtmenu_review.py``
|
| 4 |
+
(``remove_user_edge``). User edits are ``user_asserted`` (never ``model_inferred`` β
|
| 5 |
+
the trust model in plan/06 must not be flattened):
|
| 6 |
+
|
| 7 |
+
- ``remove_user_node`` routes through the authoritative validateβreduceβfinalize
|
| 8 |
+
pipeline (same as the agent's remove-node turn); the reducer cascades incident
|
| 9 |
+
visible edges and the validator's ``would_empty_scene`` guard keeps the last node.
|
| 10 |
+
- ``apply_user_node`` mints a unique node + ``user_asserted`` label/summary claims,
|
| 11 |
+
reveals it, and places it on the open periphery (an orphan has no positioned
|
| 12 |
+
neighbour, so ``_assign_reveal_positions`` would otherwise drop it on the centroid).
|
| 13 |
+
- edit-summary reuses the existing ``apply_user_edit(summary=...)`` overlay path.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import time
|
| 19 |
+
import uuid
|
| 20 |
+
|
| 21 |
+
import pytest
|
| 22 |
+
from loosecanvas import turn_logic
|
| 23 |
+
from loosecanvas.contracts import Edge, Node, SceneState, SourceSnapshot
|
| 24 |
+
from loosecanvas.graph_repository import LooseGraphRepository
|
| 25 |
+
from loosecanvas.turn_logic import (
|
| 26 |
+
RendererPatch,
|
| 27 |
+
apply_user_edge_edit,
|
| 28 |
+
apply_user_edit,
|
| 29 |
+
apply_user_node,
|
| 30 |
+
effective_edge_label,
|
| 31 |
+
effective_node_summary,
|
| 32 |
+
remove_user_node,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _make_session_with_visible_nodes() -> str:
|
| 37 |
+
"""n1βn2βn3 with all three nodes visible and one visible edge (e1: n1βn2).
|
| 38 |
+
|
| 39 |
+
Three visible nodes so ``remove_node`` does not trip ``would_empty_scene``.
|
| 40 |
+
"""
|
| 41 |
+
nodes = [
|
| 42 |
+
Node(id="n1", kind="concept", label="One"),
|
| 43 |
+
Node(id="n2", kind="concept", label="Two"),
|
| 44 |
+
Node(id="n3", kind="concept", label="Three"),
|
| 45 |
+
]
|
| 46 |
+
edges = [Edge(id="e1", source="n1", target="n2", kind="related_to", label="rel")]
|
| 47 |
+
repo = LooseGraphRepository.from_extraction(nodes, edges, [], SourceSnapshot())
|
| 48 |
+
scene = SceneState(
|
| 49 |
+
scene_id="s1",
|
| 50 |
+
visible_node_ids=["n1", "n2", "n3"],
|
| 51 |
+
visible_edge_ids=["e1"],
|
| 52 |
+
node_positions={
|
| 53 |
+
"n1": (0.0, 0.0),
|
| 54 |
+
"n2": (100.0, 0.0),
|
| 55 |
+
"n3": (50.0, 100.0),
|
| 56 |
+
},
|
| 57 |
+
)
|
| 58 |
+
sid = str(uuid.uuid4())
|
| 59 |
+
now = time.time()
|
| 60 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 61 |
+
session_id=sid,
|
| 62 |
+
graph=repo,
|
| 63 |
+
scene=scene,
|
| 64 |
+
history=[],
|
| 65 |
+
graph_version=1,
|
| 66 |
+
created_at=now,
|
| 67 |
+
last_seen=now,
|
| 68 |
+
render_scene_id="s1",
|
| 69 |
+
)
|
| 70 |
+
return sid
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _make_session_single_node() -> str:
|
| 74 |
+
"""A scene with exactly one visible node (to exercise ``would_empty_scene``)."""
|
| 75 |
+
node = Node(id="solo", kind="concept", label="Solo")
|
| 76 |
+
repo = LooseGraphRepository.from_extraction([node], [], [], SourceSnapshot())
|
| 77 |
+
scene = SceneState(
|
| 78 |
+
scene_id="s1",
|
| 79 |
+
visible_node_ids=["solo"],
|
| 80 |
+
node_positions={"solo": (0.0, 0.0)},
|
| 81 |
+
)
|
| 82 |
+
sid = str(uuid.uuid4())
|
| 83 |
+
now = time.time()
|
| 84 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 85 |
+
session_id=sid,
|
| 86 |
+
graph=repo,
|
| 87 |
+
scene=scene,
|
| 88 |
+
history=[],
|
| 89 |
+
graph_version=1,
|
| 90 |
+
created_at=now,
|
| 91 |
+
last_seen=now,
|
| 92 |
+
render_scene_id="s1",
|
| 93 |
+
)
|
| 94 |
+
return sid
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ββ remove_user_node βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
async def test_remove_user_node_removes_visible_node() -> None:
|
| 101 |
+
"""Delete-node removes a visible node from graph + scene via the authoritative
|
| 102 |
+
validateβreduce pipeline and emits a remove patch."""
|
| 103 |
+
sid = _make_session_with_visible_nodes()
|
| 104 |
+
assert "n3" in turn_logic.session_store[sid].graph.nodes # precondition
|
| 105 |
+
result = await remove_user_node(sid, "n3")
|
| 106 |
+
assert isinstance(result.renderer_patch, RendererPatch)
|
| 107 |
+
assert "n3" not in turn_logic.session_store[sid].graph.nodes
|
| 108 |
+
assert "n3" not in turn_logic.session_store[sid].scene.visible_node_ids
|
| 109 |
+
assert any(o.op == "remove_element" for o in result.renderer_patch.operations)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
async def test_remove_user_node_cascades_incident_edges() -> None:
|
| 113 |
+
"""Removing n1 cascades the incident visible edge e1 (n1βn2) out of the scene."""
|
| 114 |
+
sid = _make_session_with_visible_nodes()
|
| 115 |
+
assert "e1" in turn_logic.session_store[sid].scene.visible_edge_ids
|
| 116 |
+
result = await remove_user_node(sid, "n1")
|
| 117 |
+
assert "n1" not in turn_logic.session_store[sid].graph.nodes
|
| 118 |
+
assert "e1" not in turn_logic.session_store[sid].scene.visible_edge_ids
|
| 119 |
+
assert isinstance(result.renderer_patch, RendererPatch)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
async def test_remove_user_node_graceful_on_unknown_node() -> None:
|
| 123 |
+
"""An unknown / non-visible node degrades gracefully (no crash, no mutation)."""
|
| 124 |
+
sid = _make_session_with_visible_nodes()
|
| 125 |
+
result = await remove_user_node(sid, "no_such_node")
|
| 126 |
+
assert result is not None # a TurnResult (graceful failure), not an exception
|
| 127 |
+
assert "n1" in turn_logic.session_store[sid].graph.nodes # untouched
|
| 128 |
+
assert "n3" in turn_logic.session_store[sid].graph.nodes
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def test_remove_user_node_would_empty_scene_is_graceful() -> None:
|
| 132 |
+
"""Removing the last visible node is rejected (``would_empty_scene``) gracefully."""
|
| 133 |
+
sid = _make_session_single_node()
|
| 134 |
+
result = await remove_user_node(sid, "solo")
|
| 135 |
+
assert result.status == "failed"
|
| 136 |
+
assert "solo" in turn_logic.session_store[sid].graph.nodes # not mutated
|
| 137 |
+
assert "solo" in turn_logic.session_store[sid].scene.visible_node_ids
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def test_remove_user_node_unknown_session_raises() -> None:
|
| 141 |
+
"""An unknown session raises KeyError (the on_select branch catches it)."""
|
| 142 |
+
with pytest.raises(KeyError):
|
| 143 |
+
await remove_user_node("no-such-session", "n1")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ββ apply_user_node ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
async def test_apply_user_node_adds_node_to_graph_and_scene() -> None:
|
| 150 |
+
"""Add-node mints a node into the graph and reveals it in the scene."""
|
| 151 |
+
sid = _make_session_with_visible_nodes()
|
| 152 |
+
result = await apply_user_node(sid, "Fresh Concept")
|
| 153 |
+
record = turn_logic.session_store[sid]
|
| 154 |
+
new_ids = [
|
| 155 |
+
nid
|
| 156 |
+
for nid in record.graph.nodes
|
| 157 |
+
if record.graph.nodes[nid].label == "Fresh Concept"
|
| 158 |
+
]
|
| 159 |
+
assert len(new_ids) == 1
|
| 160 |
+
new_id = new_ids[0]
|
| 161 |
+
assert new_id in record.scene.visible_node_ids
|
| 162 |
+
assert isinstance(result.renderer_patch, RendererPatch)
|
| 163 |
+
assert any(o.op == "add_element" for o in result.renderer_patch.operations)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
async def test_apply_user_node_label_claim_is_user_asserted() -> None:
|
| 167 |
+
"""The new node's label claim is origin=user_asserted (trust not flattened)."""
|
| 168 |
+
sid = _make_session_with_visible_nodes()
|
| 169 |
+
await apply_user_node(sid, "Trust Me")
|
| 170 |
+
record = turn_logic.session_store[sid]
|
| 171 |
+
label_claims = [
|
| 172 |
+
c
|
| 173 |
+
for c in record.graph.claims.values()
|
| 174 |
+
if c.claim_type == "label" and c.text == "Trust Me"
|
| 175 |
+
]
|
| 176 |
+
assert label_claims, "expected a label claim for the new node"
|
| 177 |
+
assert all(c.origin == "user_asserted" for c in label_claims)
|
| 178 |
+
assert all(c.review_state == "accepted" for c in label_claims)
|
| 179 |
+
assert all(c.support_state == "unverified" for c in label_claims)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
async def test_apply_user_node_assigns_a_position() -> None:
|
| 183 |
+
"""A position is assigned for the new (orphan) node so it is not dropped on the centroid."""
|
| 184 |
+
sid = _make_session_with_visible_nodes()
|
| 185 |
+
await apply_user_node(sid, "Floating")
|
| 186 |
+
record = turn_logic.session_store[sid]
|
| 187 |
+
new_id = next(
|
| 188 |
+
nid for nid in record.graph.nodes if record.graph.nodes[nid].label == "Floating"
|
| 189 |
+
)
|
| 190 |
+
assert new_id in record.scene.node_positions
|
| 191 |
+
new_x, _new_y = record.scene.node_positions[new_id]
|
| 192 |
+
# Periphery: to the right of the rightmost pre-existing node (max x was 100.0).
|
| 193 |
+
assert new_x > 100.0
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
async def test_apply_user_node_with_summary_creates_summary_claim() -> None:
|
| 197 |
+
"""A summary= argument creates a user_asserted summary claim reflected in the effective summary."""
|
| 198 |
+
sid = _make_session_with_visible_nodes()
|
| 199 |
+
await apply_user_node(sid, "Documented", "A helpful summary.")
|
| 200 |
+
record = turn_logic.session_store[sid]
|
| 201 |
+
new_id = next(
|
| 202 |
+
nid
|
| 203 |
+
for nid in record.graph.nodes
|
| 204 |
+
if record.graph.nodes[nid].label == "Documented"
|
| 205 |
+
)
|
| 206 |
+
summary_claims = [
|
| 207 |
+
c
|
| 208 |
+
for c in record.graph.claims.values()
|
| 209 |
+
if c.claim_type == "summary" and c.target_id == new_id
|
| 210 |
+
]
|
| 211 |
+
assert summary_claims
|
| 212 |
+
assert all(c.origin == "user_asserted" for c in summary_claims)
|
| 213 |
+
node = record.graph.get_node(new_id)
|
| 214 |
+
assert node is not None
|
| 215 |
+
assert (
|
| 216 |
+
effective_node_summary(node, record.graph.claims.values())
|
| 217 |
+
== "A helpful summary."
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
async def test_apply_user_node_empty_label_raises() -> None:
|
| 222 |
+
"""An empty / whitespace label is rejected with ValueError."""
|
| 223 |
+
sid = _make_session_with_visible_nodes()
|
| 224 |
+
with pytest.raises(ValueError):
|
| 225 |
+
await apply_user_node(sid, " ")
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
async def test_apply_user_node_unique_id_on_collision() -> None:
|
| 229 |
+
"""Two nodes with the same label get distinct ids (deterministic suffixing)."""
|
| 230 |
+
sid = _make_session_with_visible_nodes()
|
| 231 |
+
await apply_user_node(sid, "Dup")
|
| 232 |
+
await apply_user_node(sid, "Dup")
|
| 233 |
+
record = turn_logic.session_store[sid]
|
| 234 |
+
dup_ids = [
|
| 235 |
+
nid for nid in record.graph.nodes if record.graph.nodes[nid].label == "Dup"
|
| 236 |
+
]
|
| 237 |
+
assert len(dup_ids) == 2
|
| 238 |
+
assert len(set(dup_ids)) == 2 # ids are distinct
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
async def test_apply_user_node_unknown_session_raises() -> None:
|
| 242 |
+
"""An unknown session raises KeyError (the on_select branch catches it)."""
|
| 243 |
+
with pytest.raises(KeyError):
|
| 244 |
+
await apply_user_node("no-such-session", "Whatever")
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# ββ edit summary (reuses apply_user_edit(summary=...)) βββββββββββββββββββββββ
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
async def test_apply_user_edit_summary_adds_user_asserted_claim() -> None:
|
| 251 |
+
"""Editing a node's summary adds a user_asserted summary claim; the effective
|
| 252 |
+
summary reflects it."""
|
| 253 |
+
sid = _make_session_with_visible_nodes()
|
| 254 |
+
result = await apply_user_edit(sid, "n2", summary="My own note.")
|
| 255 |
+
assert isinstance(result.renderer_patch, RendererPatch)
|
| 256 |
+
record = turn_logic.session_store[sid]
|
| 257 |
+
summary_claims = [
|
| 258 |
+
c
|
| 259 |
+
for c in record.graph.claims.values()
|
| 260 |
+
if c.claim_type == "summary" and c.target_id == "n2"
|
| 261 |
+
]
|
| 262 |
+
assert summary_claims
|
| 263 |
+
assert all(c.origin == "user_asserted" for c in summary_claims)
|
| 264 |
+
node = record.graph.get_node("n2")
|
| 265 |
+
assert node is not None
|
| 266 |
+
assert effective_node_summary(node, record.graph.claims.values()) == "My own note."
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# ββ TASK 3: apply_user_edge_edit βββββββββββββββββββββββββββββββββββββββββββββ
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _make_session_with_visible_edge_for_edit() -> str:
|
| 273 |
+
"""Session with n1βn2 (e1, 'original label') both visible."""
|
| 274 |
+
from loosecanvas.contracts import Edge
|
| 275 |
+
|
| 276 |
+
nodes = [
|
| 277 |
+
Node(id="n1", kind="concept", label="N1"),
|
| 278 |
+
Node(id="n2", kind="concept", label="N2"),
|
| 279 |
+
]
|
| 280 |
+
edges = [
|
| 281 |
+
Edge(id="e1", source="n1", target="n2", kind="related_to", label="original")
|
| 282 |
+
]
|
| 283 |
+
repo = LooseGraphRepository.from_extraction(nodes, edges, [], SourceSnapshot())
|
| 284 |
+
scene = SceneState(
|
| 285 |
+
scene_id="s1",
|
| 286 |
+
visible_node_ids=["n1", "n2"],
|
| 287 |
+
visible_edge_ids=["e1"],
|
| 288 |
+
node_positions={"n1": (0.0, 0.0), "n2": (100.0, 0.0)},
|
| 289 |
+
)
|
| 290 |
+
sid = str(uuid.uuid4())
|
| 291 |
+
now = time.time()
|
| 292 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 293 |
+
session_id=sid,
|
| 294 |
+
graph=repo,
|
| 295 |
+
scene=scene,
|
| 296 |
+
history=[],
|
| 297 |
+
graph_version=1,
|
| 298 |
+
created_at=now,
|
| 299 |
+
last_seen=now,
|
| 300 |
+
render_scene_id="s1",
|
| 301 |
+
)
|
| 302 |
+
return sid
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
async def test_apply_user_edge_edit_effective_label_updated() -> None:
|
| 306 |
+
"""After apply_user_edge_edit, effective_edge_label returns the new label."""
|
| 307 |
+
sid = _make_session_with_visible_edge_for_edit()
|
| 308 |
+
record = turn_logic.session_store[sid]
|
| 309 |
+
result = await apply_user_edge_edit(sid, "e1", label="clearer link")
|
| 310 |
+
assert result.status == "success"
|
| 311 |
+
edge = record.graph.get_edge("e1")
|
| 312 |
+
assert edge is not None
|
| 313 |
+
# Raw edge.label is preserved.
|
| 314 |
+
assert edge.label == "original"
|
| 315 |
+
# Effective label reflects the overlay.
|
| 316 |
+
assert effective_edge_label(edge, record.graph.claims.values()) == "clearer link"
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
async def test_apply_user_edge_edit_raw_label_unchanged() -> None:
|
| 320 |
+
"""The raw edge.label is preserved underneath the user_asserted overlay."""
|
| 321 |
+
sid = _make_session_with_visible_edge_for_edit()
|
| 322 |
+
record = turn_logic.session_store[sid]
|
| 323 |
+
await apply_user_edge_edit(sid, "e1", label="new label")
|
| 324 |
+
edge = record.graph.get_edge("e1")
|
| 325 |
+
assert edge is not None
|
| 326 |
+
assert edge.label == "original"
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
async def test_apply_user_edge_edit_claim_is_user_asserted() -> None:
|
| 330 |
+
"""The overlay claim has origin=user_asserted and review_state=accepted."""
|
| 331 |
+
sid = _make_session_with_visible_edge_for_edit()
|
| 332 |
+
record = turn_logic.session_store[sid]
|
| 333 |
+
await apply_user_edge_edit(sid, "e1", label="labeled")
|
| 334 |
+
label_claims = [
|
| 335 |
+
c
|
| 336 |
+
for c in record.graph.claims.values()
|
| 337 |
+
if c.claim_type == "label" and c.target_id == "e1"
|
| 338 |
+
]
|
| 339 |
+
assert label_claims, "expected a label claim for the edge"
|
| 340 |
+
assert all(c.origin == "user_asserted" for c in label_claims)
|
| 341 |
+
assert all(c.review_state == "accepted" for c in label_claims)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
async def test_apply_user_edge_edit_emits_update_data_op() -> None:
|
| 345 |
+
"""The renderer patch carries an update_data op for the edge with the new label."""
|
| 346 |
+
sid = _make_session_with_visible_edge_for_edit()
|
| 347 |
+
result = await apply_user_edge_edit(sid, "e1", label="updated")
|
| 348 |
+
update_ops = [
|
| 349 |
+
o
|
| 350 |
+
for o in result.renderer_patch.operations
|
| 351 |
+
if o.op == "update_data" and o.data.get("id") == "e1"
|
| 352 |
+
]
|
| 353 |
+
assert update_ops, "must emit update_data for visible edge"
|
| 354 |
+
assert update_ops[0].data["data"]["label"] == "updated"
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
async def test_apply_user_edge_edit_unknown_edge_raises() -> None:
|
| 358 |
+
"""An unknown edge id raises KeyError."""
|
| 359 |
+
sid = _make_session_with_visible_edge_for_edit()
|
| 360 |
+
with pytest.raises(KeyError):
|
| 361 |
+
await apply_user_edge_edit(sid, "no_such_edge", label="x")
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
async def test_apply_user_edge_edit_unknown_session_raises() -> None:
|
| 365 |
+
"""An unknown session raises KeyError."""
|
| 366 |
+
with pytest.raises(KeyError):
|
| 367 |
+
await apply_user_edge_edit("no-such-session", "e1", label="x")
|
tests/test_text_graph_adapter.py
CHANGED
|
@@ -29,6 +29,19 @@ from loosecanvas.llm_client import LLMClient
|
|
| 29 |
|
| 30 |
_TITLE = "Gradient Descent"
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
_HAPPY_JSON = json.dumps(
|
| 33 |
{
|
| 34 |
"concept_nodes": [
|
|
@@ -365,3 +378,64 @@ async def test_extract_from_file_reads_allowed_path(tmp_path: Path) -> None:
|
|
| 365 |
)
|
| 366 |
graph = await extract_graph_from_file(source, _TITLE, _mock_client(_HAPPY_JSON))
|
| 367 |
assert len(graph.nodes) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
_TITLE = "Gradient Descent"
|
| 31 |
|
| 32 |
+
|
| 33 |
+
def test_edge_schema_requires_label() -> None:
|
| 34 |
+
"""Every extracted edge must carry a label (the grammar requires it) so the magic
|
| 35 |
+
build never produces bare, uninformative connections β the KG-quality contract."""
|
| 36 |
+
from loosecanvas.extractors.text_graph_adapter import TEXT_GRAPH_RESPONSE_FORMAT
|
| 37 |
+
|
| 38 |
+
edge_items = TEXT_GRAPH_RESPONSE_FORMAT["json_schema"]["schema"]["properties"][
|
| 39 |
+
"semantic_edges"
|
| 40 |
+
]["items"]
|
| 41 |
+
assert "label" in edge_items["required"]
|
| 42 |
+
assert {"source_id", "target_id", "type"} <= set(edge_items["required"])
|
| 43 |
+
|
| 44 |
+
|
| 45 |
_HAPPY_JSON = json.dumps(
|
| 46 |
{
|
| 47 |
"concept_nodes": [
|
|
|
|
| 378 |
)
|
| 379 |
graph = await extract_graph_from_file(source, _TITLE, _mock_client(_HAPPY_JSON))
|
| 380 |
assert len(graph.nodes) == 2
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
# --------------------------------------------------------------------------- #
|
| 384 |
+
# BUG FIX: semantic_edge claim must target the edge id, not the target node #
|
| 385 |
+
# --------------------------------------------------------------------------- #
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
async def test_edge_claim_targets_edge_id() -> None:
|
| 389 |
+
"""semantic_edge claims must have target_id == edge_id, NOT a node id.
|
| 390 |
+
|
| 391 |
+
The edge_id in _ingest_edge is computed as ``f"{src}::{kind}::{tgt}"``.
|
| 392 |
+
The bug had target_id=tgt (the target NODE id), which made _build_edge_trust_map
|
| 393 |
+
always miss β edges showed fallback deterministic/pending, review was a no-op.
|
| 394 |
+
"""
|
| 395 |
+
text = "Gradient descent\nis a loss function method used widely in practice."
|
| 396 |
+
graph = await extract_graph_from_text(text, _TITLE, _mock_client(_HAPPY_JSON))
|
| 397 |
+
|
| 398 |
+
edge_ids = {e.id for e in graph.edges}
|
| 399 |
+
node_ids = {n.id for n in graph.nodes}
|
| 400 |
+
semantic_edge_claims = [c for c in graph.claims if c.claim_type == "semantic_edge"]
|
| 401 |
+
assert semantic_edge_claims, "expected at least one semantic_edge claim"
|
| 402 |
+
for claim in semantic_edge_claims:
|
| 403 |
+
assert claim.target_id in edge_ids, (
|
| 404 |
+
f"semantic_edge claim {claim.id} has target_id={claim.target_id!r} "
|
| 405 |
+
f"which is NOT an edge id; edge ids: {edge_ids}"
|
| 406 |
+
)
|
| 407 |
+
assert claim.target_id not in node_ids, (
|
| 408 |
+
f"semantic_edge claim {claim.id} has target_id={claim.target_id!r} "
|
| 409 |
+
f"which is a node id β the bug has recurred"
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
async def test_text_session_edges_render_amber() -> None:
|
| 414 |
+
"""From an extracted graph, every edge must be in the trust map as model_inferred/pending.
|
| 415 |
+
|
| 416 |
+
_build_edge_trust_map checks claim.target_id in graph.edges; with the bug
|
| 417 |
+
(target_id = node id) this always misses β trust map is empty β no amber.
|
| 418 |
+
After the fix (target_id = edge_id) every edge gets an entry.
|
| 419 |
+
"""
|
| 420 |
+
from loosecanvas.contracts import SourceSnapshot
|
| 421 |
+
from loosecanvas.turn_logic import _build_edge_trust_map
|
| 422 |
+
|
| 423 |
+
text = "Gradient descent\nis a loss function method used widely in practice."
|
| 424 |
+
graph = await extract_graph_from_text(text, _TITLE, _mock_client(_HAPPY_JSON))
|
| 425 |
+
|
| 426 |
+
repo = LooseGraphRepository.from_extraction(
|
| 427 |
+
graph.nodes, graph.edges, graph.claims, SourceSnapshot()
|
| 428 |
+
)
|
| 429 |
+
trust_map = _build_edge_trust_map(repo)
|
| 430 |
+
|
| 431 |
+
for edge in graph.edges:
|
| 432 |
+
assert (
|
| 433 |
+
edge.id in trust_map
|
| 434 |
+
), f"edge {edge.id!r} missing from trust map β amber affordance will not render"
|
| 435 |
+
entry = trust_map[edge.id]
|
| 436 |
+
assert (
|
| 437 |
+
entry["origin"] == "model_inferred"
|
| 438 |
+
), f"edge {edge.id!r} trust origin={entry['origin']!r}, want 'model_inferred'"
|
| 439 |
+
assert (
|
| 440 |
+
entry["review"] == "pending"
|
| 441 |
+
), f"edge {edge.id!r} review={entry['review']!r}, want 'pending'"
|
tests/test_time_travel_output_arity.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression guard: time-travel handlers return as many values as their wired outputs.
|
| 2 |
+
|
| 3 |
+
The undo/redo/back buttons errored live with "A function (on_undo) didn't return enough
|
| 4 |
+
output values (needed: 6, returned: 5)" the moment they were pressed, yet the whole suite
|
| 5 |
+
was green β because the unit tests call ``undo_turn``/``redo_turn`` directly and never
|
| 6 |
+
exercise the Gradio output-arity binding (``turn_outputs`` grew to include ``send_btn``
|
| 7 |
+
for ``on_turn`` but the time-travel handlers were not updated). This builds the REAL
|
| 8 |
+
Blocks app and asserts each handler returns exactly ``len(outputs)`` values, so an
|
| 9 |
+
output/arity drift fails the gate instead of only the live button.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import time
|
| 15 |
+
import uuid
|
| 16 |
+
|
| 17 |
+
import pytest
|
| 18 |
+
from loosecanvas import turn_logic
|
| 19 |
+
from loosecanvas.contracts import Node, SceneState, SourceSnapshot
|
| 20 |
+
from loosecanvas.graph_repository import LooseGraphRepository
|
| 21 |
+
from loosecanvas.main import build_app
|
| 22 |
+
from loosecanvas.turn_logic import SessionRecord, _commit_snapshot
|
| 23 |
+
|
| 24 |
+
TIME_TRAVEL = ("on_undo", "on_redo", "on_back")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _handlers() -> dict[str, tuple[object, int]]:
|
| 28 |
+
"""Map each time-travel handler name -> (fn, declared output count) from the app."""
|
| 29 |
+
demo, _app = build_app()
|
| 30 |
+
out: dict[str, tuple[object, int]] = {}
|
| 31 |
+
for block_fn in demo.fns.values():
|
| 32 |
+
fn = getattr(block_fn, "fn", None)
|
| 33 |
+
name = getattr(fn, "__name__", "")
|
| 34 |
+
if name in TIME_TRAVEL and name not in out:
|
| 35 |
+
out[name] = (fn, len(block_fn.outputs))
|
| 36 |
+
return out
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@pytest.fixture(scope="module")
|
| 40 |
+
def handlers() -> dict[str, tuple[object, int]]:
|
| 41 |
+
h = _handlers()
|
| 42 |
+
missing = [n for n in TIME_TRAVEL if n not in h]
|
| 43 |
+
assert not missing, f"handlers not found in demo.fns: {missing}"
|
| 44 |
+
return h
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _session_with_undo_history() -> str:
|
| 48 |
+
"""A session with two committed snapshots so undo_turn has a prior state to pop."""
|
| 49 |
+
nodes = [
|
| 50 |
+
Node(id="n1", kind="concept", label="One"),
|
| 51 |
+
Node(id="n2", kind="concept", label="Two"),
|
| 52 |
+
]
|
| 53 |
+
repo = LooseGraphRepository.from_extraction(nodes, [], [], SourceSnapshot())
|
| 54 |
+
scene = SceneState(
|
| 55 |
+
scene_id="s1",
|
| 56 |
+
visible_node_ids=["n1", "n2"],
|
| 57 |
+
node_positions={"n1": (0.0, 0.0), "n2": (100.0, 0.0)},
|
| 58 |
+
)
|
| 59 |
+
sid = str(uuid.uuid4())
|
| 60 |
+
now = time.time()
|
| 61 |
+
record = SessionRecord(
|
| 62 |
+
session_id=sid,
|
| 63 |
+
graph=repo,
|
| 64 |
+
scene=scene,
|
| 65 |
+
history=[],
|
| 66 |
+
graph_version=1,
|
| 67 |
+
created_at=now,
|
| 68 |
+
last_seen=now,
|
| 69 |
+
render_scene_id="s1",
|
| 70 |
+
)
|
| 71 |
+
turn_logic.session_store[sid] = record
|
| 72 |
+
_commit_snapshot(record) # baseline (load) snapshot
|
| 73 |
+
record.graph_version = 2
|
| 74 |
+
_commit_snapshot(record) # a second state so undo can revert to the baseline
|
| 75 |
+
return sid
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@pytest.mark.parametrize("name", TIME_TRAVEL)
|
| 79 |
+
async def test_no_session_return_matches_output_arity(handlers, name: str) -> None:
|
| 80 |
+
"""The no-session early-return path supplies exactly len(outputs) values."""
|
| 81 |
+
fn, n_outputs = handlers[name]
|
| 82 |
+
result = await fn("", []) # type: ignore[operator]
|
| 83 |
+
assert isinstance(result, tuple)
|
| 84 |
+
assert (
|
| 85 |
+
len(result) == n_outputs
|
| 86 |
+
), f"{name} returned {len(result)} values for {n_outputs} wired outputs"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
async def test_on_undo_success_path_matches_output_arity(handlers) -> None:
|
| 90 |
+
"""The success path (real session with undo history) also matches output arity."""
|
| 91 |
+
fn, n_outputs = handlers["on_undo"]
|
| 92 |
+
sid = _session_with_undo_history()
|
| 93 |
+
result = await fn(sid, []) # type: ignore[operator]
|
| 94 |
+
assert isinstance(result, tuple)
|
| 95 |
+
assert len(result) == n_outputs
|
tests/test_tool_narration_chat.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for workstream (a): live collapsible step-list inside the chat thread.
|
| 2 |
+
|
| 3 |
+
Covers:
|
| 4 |
+
* _tool_steplist_message shape (pending / done / empty steps).
|
| 5 |
+
* on_turn streaming: mid-turn chatbot output contains a metadata-titled pending
|
| 6 |
+
bubble; final chatbot output drops the pending bubble and appends tool_receipts.
|
| 7 |
+
|
| 8 |
+
The tests drive on_turn via a thin harness that replaces run_agent_turn_streaming
|
| 9 |
+
with a fake generator β no LLM, no session lookup required for the event-path under
|
| 10 |
+
test. The fake yields one ToolActivityEvent then a FinalEvent with one tool-activity
|
| 11 |
+
label so both branches are exercised in a single streaming run.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from collections.abc import AsyncGenerator
|
| 17 |
+
from typing import Any
|
| 18 |
+
from unittest.mock import patch
|
| 19 |
+
|
| 20 |
+
import pytest
|
| 21 |
+
from loosecanvas.agent_harness import FinalEvent, ToolActivityEvent
|
| 22 |
+
from loosecanvas.main import _tool_steplist_message
|
| 23 |
+
|
| 24 |
+
# ββ unit tests for _tool_steplist_message βββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_steplist_pending_shape() -> None:
|
| 28 |
+
"""pending status produces a message with metadata title and status."""
|
| 29 |
+
msg = _tool_steplist_message(["step one", "step two"], "pending")
|
| 30 |
+
assert msg["role"] == "assistant"
|
| 31 |
+
assert msg["metadata"]["title"] == "Workingβ¦"
|
| 32 |
+
assert msg["metadata"]["status"] == "pending"
|
| 33 |
+
assert "- step one" in msg["content"]
|
| 34 |
+
assert "- step two" in msg["content"]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_steplist_done_shape() -> None:
|
| 38 |
+
"""done status produces same structure but status='done'."""
|
| 39 |
+
msg = _tool_steplist_message(["finished"], "done")
|
| 40 |
+
assert msg["metadata"]["status"] == "done"
|
| 41 |
+
assert "- finished" in msg["content"]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_steplist_empty_steps_placeholder() -> None:
|
| 45 |
+
"""Empty step list must not produce a blank content field (shows placeholder)."""
|
| 46 |
+
msg = _tool_steplist_message([], "pending")
|
| 47 |
+
assert msg["content"].strip() != ""
|
| 48 |
+
assert "Working" in msg["content"]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ββ helpers for on_turn harness βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def _fake_streaming(
|
| 55 |
+
tool_label: str = "relabel_edge:woody->dylan",
|
| 56 |
+
) -> AsyncGenerator[Any]:
|
| 57 |
+
"""Fake run_agent_turn_streaming: one ToolActivityEvent then a FinalEvent."""
|
| 58 |
+
yield ToolActivityEvent(message=tool_label)
|
| 59 |
+
|
| 60 |
+
# FinalEvent with no TurnResult (pure-speech path without canvas mutation);
|
| 61 |
+
# tool_activity carries the label so _tool_receipt_messages has something to show.
|
| 62 |
+
yield FinalEvent(
|
| 63 |
+
turn_result=None,
|
| 64 |
+
assistant_message="Here is what I did.",
|
| 65 |
+
tool_activity=[tool_label],
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ββ on_turn streaming event-path tests βββββββββββββββββββββββββββββββββββββββ
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@pytest.fixture()
|
| 73 |
+
def _on_turn_fn() -> Any:
|
| 74 |
+
"""Extract the on_turn coroutine from the real Blocks app."""
|
| 75 |
+
from loosecanvas.main import build_app
|
| 76 |
+
|
| 77 |
+
demo, _app = build_app()
|
| 78 |
+
for block_fn in demo.fns.values():
|
| 79 |
+
fn = getattr(block_fn, "fn", None)
|
| 80 |
+
if getattr(fn, "__name__", "") == "on_turn":
|
| 81 |
+
return fn
|
| 82 |
+
pytest.fail("on_turn not found in demo.fns")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def _collect_chatbot_snapshots(
|
| 86 |
+
on_turn: Any,
|
| 87 |
+
message: str = "relabel the edges",
|
| 88 |
+
sid: str = "fake-session",
|
| 89 |
+
selection: dict[str, Any] | None = None,
|
| 90 |
+
history: list[Any] | None = None,
|
| 91 |
+
) -> list[Any]:
|
| 92 |
+
"""Run the on_turn generator and return every chatbot value that is NOT gr.update().
|
| 93 |
+
|
| 94 |
+
We only collect concrete list values (the chatbot history snapshots) so we can
|
| 95 |
+
assert on what was pushed to the Gradio Chatbot at each stage.
|
| 96 |
+
"""
|
| 97 |
+
import gradio as gr # noqa: PLC0415
|
| 98 |
+
|
| 99 |
+
snapshots: list[Any] = []
|
| 100 |
+
async for output_tuple in on_turn(message, sid, selection or {}, history or []):
|
| 101 |
+
# on_turn yields 8-tuples; chatbot is index 2.
|
| 102 |
+
chatbot_val = output_tuple[2]
|
| 103 |
+
if not isinstance(chatbot_val, gr.update().__class__): # type: ignore[arg-type]
|
| 104 |
+
snapshots.append(chatbot_val)
|
| 105 |
+
return snapshots
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
async def test_pending_bubble_appears_mid_stream(_on_turn_fn: Any) -> None:
|
| 109 |
+
"""A metadata-titled pending bubble must appear in at least one mid-stream snapshot."""
|
| 110 |
+
with patch(
|
| 111 |
+
"loosecanvas.main.run_agent_turn_streaming",
|
| 112 |
+
side_effect=lambda *_a, **_kw: _fake_streaming(),
|
| 113 |
+
):
|
| 114 |
+
snapshots = await _collect_chatbot_snapshots(_on_turn_fn)
|
| 115 |
+
|
| 116 |
+
# At least one snapshot must contain a metadata message with status="pending".
|
| 117 |
+
pending_found = False
|
| 118 |
+
for snap in snapshots:
|
| 119 |
+
if not isinstance(snap, list):
|
| 120 |
+
continue
|
| 121 |
+
for msg in snap:
|
| 122 |
+
meta = msg.get("metadata", {}) if isinstance(msg, dict) else {}
|
| 123 |
+
if meta.get("status") == "pending":
|
| 124 |
+
pending_found = True
|
| 125 |
+
break
|
| 126 |
+
assert pending_found, (
|
| 127 |
+
f"No pending step-list bubble found in any mid-stream chatbot snapshot.\n"
|
| 128 |
+
f"Snapshots: {snapshots}"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def test_final_chatbot_has_no_pending_bubble(_on_turn_fn: Any) -> None:
|
| 133 |
+
"""The final chatbot snapshot must NOT contain any pending bubble."""
|
| 134 |
+
with patch(
|
| 135 |
+
"loosecanvas.main.run_agent_turn_streaming",
|
| 136 |
+
side_effect=lambda *_a, **_kw: _fake_streaming(),
|
| 137 |
+
):
|
| 138 |
+
snapshots = await _collect_chatbot_snapshots(_on_turn_fn)
|
| 139 |
+
|
| 140 |
+
assert snapshots, "on_turn yielded no chatbot snapshots"
|
| 141 |
+
final_snap = snapshots[-1]
|
| 142 |
+
assert isinstance(final_snap, list), f"Final snapshot is not a list: {final_snap}"
|
| 143 |
+
for msg in final_snap:
|
| 144 |
+
meta = msg.get("metadata", {}) if isinstance(msg, dict) else {}
|
| 145 |
+
assert (
|
| 146 |
+
meta.get("status") != "pending"
|
| 147 |
+
), f"Pending bubble found in final chatbot snapshot: {msg}"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
async def test_final_chatbot_contains_tool_receipts(_on_turn_fn: Any) -> None:
|
| 151 |
+
"""The final chatbot snapshot must include the tool-receipt messages from FinalEvent."""
|
| 152 |
+
tool_label = "relabel_edge:edge-1->edge-2"
|
| 153 |
+
|
| 154 |
+
async def _fake_with_label() -> AsyncGenerator[Any]:
|
| 155 |
+
yield ToolActivityEvent(message="relabelling edges")
|
| 156 |
+
yield FinalEvent(
|
| 157 |
+
turn_result=None,
|
| 158 |
+
assistant_message="Done.",
|
| 159 |
+
tool_activity=[tool_label],
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
with patch(
|
| 163 |
+
"loosecanvas.main.run_agent_turn_streaming",
|
| 164 |
+
side_effect=lambda *_a, **_kw: _fake_with_label(),
|
| 165 |
+
):
|
| 166 |
+
snapshots = await _collect_chatbot_snapshots(_on_turn_fn)
|
| 167 |
+
|
| 168 |
+
assert snapshots, "on_turn yielded no chatbot snapshots"
|
| 169 |
+
final_snap = snapshots[-1]
|
| 170 |
+
assert isinstance(final_snap, list)
|
| 171 |
+
|
| 172 |
+
# The tool receipt appears as a metadata-titled message with title containing the
|
| 173 |
+
# tool name extracted from the label (the part before the first ':').
|
| 174 |
+
tool_name = tool_label.split(":")[0]
|
| 175 |
+
receipt_found = any(
|
| 176 |
+
isinstance(msg, dict)
|
| 177 |
+
and tool_name in (msg.get("metadata") or {}).get("title", "")
|
| 178 |
+
for msg in final_snap
|
| 179 |
+
)
|
| 180 |
+
assert receipt_found, (
|
| 181 |
+
f"No tool-receipt message for '{tool_name}' found in final snapshot.\n"
|
| 182 |
+
f"Final snapshot: {final_snap}"
|
| 183 |
+
)
|
tests/test_tooltip_stale_review.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Task B β stale hover-tooltip fix.
|
| 2 |
+
|
| 3 |
+
After a review the clear_pending RendererPatch must ALSO emit an update_data op
|
| 4 |
+
carrying {'id': node_id, 'data': {'review': <new_review_state>}} so the element's
|
| 5 |
+
Cytoscape data('review') field is refreshed and the hover tooltip no longer shows
|
| 6 |
+
the stale "pending review" text.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import time
|
| 12 |
+
import uuid
|
| 13 |
+
from collections.abc import Iterator
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
from loosecanvas import turn_logic
|
| 17 |
+
from loosecanvas.contracts import Claim, Node, SceneState, SourceSnapshot
|
| 18 |
+
from loosecanvas.graph_repository import LooseGraphRepository
|
| 19 |
+
from loosecanvas.turn_logic import (
|
| 20 |
+
do_review_claim,
|
| 21 |
+
session_locks,
|
| 22 |
+
session_store,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# ββ Session isolation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@pytest.fixture(autouse=True)
|
| 29 |
+
def _isolate_sessions() -> Iterator[None]:
|
| 30 |
+
session_store.clear()
|
| 31 |
+
session_locks.clear()
|
| 32 |
+
yield
|
| 33 |
+
session_store.clear()
|
| 34 |
+
session_locks.clear()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ββ Session helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _make_session(claims: list[Claim] | None = None) -> str:
|
| 41 |
+
node = Node(id="n1", kind="concept", label="Test Node")
|
| 42 |
+
repo = LooseGraphRepository.from_extraction(
|
| 43 |
+
[node], [], claims or [], SourceSnapshot()
|
| 44 |
+
)
|
| 45 |
+
scene = SceneState(scene_id="s1", visible_node_ids=["n1"])
|
| 46 |
+
sid = str(uuid.uuid4())
|
| 47 |
+
now = time.time()
|
| 48 |
+
turn_logic.session_store[sid] = turn_logic.SessionRecord(
|
| 49 |
+
session_id=sid,
|
| 50 |
+
graph=repo,
|
| 51 |
+
scene=scene,
|
| 52 |
+
history=[],
|
| 53 |
+
graph_version=1,
|
| 54 |
+
created_at=now,
|
| 55 |
+
last_seen=now,
|
| 56 |
+
render_scene_id="s1",
|
| 57 |
+
)
|
| 58 |
+
return sid
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _pending_claim() -> Claim:
|
| 62 |
+
return Claim(
|
| 63 |
+
id="c1",
|
| 64 |
+
claim_type="label",
|
| 65 |
+
target_id="n1",
|
| 66 |
+
origin="model_inferred",
|
| 67 |
+
review_state="pending",
|
| 68 |
+
support_state="unverified",
|
| 69 |
+
text="test",
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ββ Tooltip fix tests βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_do_review_claim_accept_emits_update_data_with_accepted_state() -> None:
|
| 77 |
+
"""Accepting a pending node claim must emit an update_data op refreshing
|
| 78 |
+
data('review') to 'accepted' so the hover tooltip no longer shows 'pending'."""
|
| 79 |
+
sid = _make_session([_pending_claim()])
|
| 80 |
+
result = do_review_claim(sid, "c1", "accepted")
|
| 81 |
+
|
| 82 |
+
update_ops = [
|
| 83 |
+
o
|
| 84 |
+
for o in result.renderer_patch.operations
|
| 85 |
+
if o.op == "update_data"
|
| 86 |
+
and o.data.get("id") == "n1"
|
| 87 |
+
and isinstance(o.data.get("data"), dict)
|
| 88 |
+
and "review" in o.data["data"]
|
| 89 |
+
]
|
| 90 |
+
assert update_ops, (
|
| 91 |
+
"do_review_claim(accept) must emit an update_data op for the reviewed node; "
|
| 92 |
+
f"got operations: {[o.op for o in result.renderer_patch.operations]}"
|
| 93 |
+
)
|
| 94 |
+
review_value = update_ops[0].data["data"]["review"]
|
| 95 |
+
assert (
|
| 96 |
+
review_value == "accepted"
|
| 97 |
+
), f"update_data review field must be 'accepted', got {review_value!r}"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_do_review_claim_reject_node_emits_update_data_with_rejected_state() -> None:
|
| 101 |
+
"""Rejecting a pending node claim clears the badge AND refreshes data('review')
|
| 102 |
+
to 'rejected' (not 'pending') so the tooltip reflects the real state."""
|
| 103 |
+
sid = _make_session([_pending_claim()])
|
| 104 |
+
result = do_review_claim(sid, "c1", "rejected")
|
| 105 |
+
|
| 106 |
+
update_ops = [
|
| 107 |
+
o
|
| 108 |
+
for o in result.renderer_patch.operations
|
| 109 |
+
if o.op == "update_data"
|
| 110 |
+
and o.data.get("id") == "n1"
|
| 111 |
+
and isinstance(o.data.get("data"), dict)
|
| 112 |
+
and "review" in o.data["data"]
|
| 113 |
+
]
|
| 114 |
+
assert update_ops, (
|
| 115 |
+
"do_review_claim(reject) on a node claim must emit an update_data op; "
|
| 116 |
+
f"got operations: {[o.op for o in result.renderer_patch.operations]}"
|
| 117 |
+
)
|
| 118 |
+
review_value = update_ops[0].data["data"]["review"]
|
| 119 |
+
assert (
|
| 120 |
+
review_value == "rejected"
|
| 121 |
+
), f"update_data review field must be 'rejected', got {review_value!r}"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_do_review_claim_update_data_contains_snake_case_state() -> None:
|
| 125 |
+
"""The review value in update_data must be the snake_case state string
|
| 126 |
+
('accepted', 'rejected') β NOT a class name ('review-accepted' etc.)."""
|
| 127 |
+
sid = _make_session([_pending_claim()])
|
| 128 |
+
result = do_review_claim(sid, "c1", "accepted")
|
| 129 |
+
|
| 130 |
+
for op in result.renderer_patch.operations:
|
| 131 |
+
if op.op == "update_data" and op.data.get("id") == "n1":
|
| 132 |
+
review_val = op.data.get("data", {}).get("review", "")
|
| 133 |
+
assert (
|
| 134 |
+
"-" not in review_val
|
| 135 |
+
), f"review value must be snake_case, not a class name; got {review_val!r}"
|