docs: add cross-reference / loop-back implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-28 20:43:34 -05:00
parent 01e7ad7578
commit 208807cf94

View File

@@ -0,0 +1,656 @@
# Cross-Reference / Loop-Back Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Enable tree nodes to reference any other node in the tree (not just direct children), supporting loop-back patterns like "remediate → re-verify from earlier checkpoint."
**Architecture:** Ghost references on existing tree structure. No schema change, no migration. A cross-reference is any `next_node_id` that points outside the current node's `children` array. The canvas renders these as dashed overlay arrows. Navigation already supports this pattern.
**Tech Stack:** Python FastAPI (backend validation), React + @xyflow/react (canvas rendering), Zustand (store validation), TypeScript
**Design Doc:** `docs/plans/2026-02-28-cross-reference-loopback-design.md`
---
## Task 1: Backend — Relax Decision Option Validation
Allow decision option `next_node_id` to reference ANY node in the tree, not just direct children.
**Files:**
- Modify: `backend/app/core/ai_tree_validator.py:111-118`
- Test: `backend/tests/test_ai_tree_validator.py`
**Step 1: Write the failing test — cross-reference option passes validation**
Add a new test class `TestCrossReferenceSupport` at the bottom of `test_ai_tree_validator.py`:
```python
class TestCrossReferenceSupport:
def test_option_referencing_non_child_node_in_tree_is_valid(self):
"""A decision option can reference any node in the tree, not just direct children."""
tree = _make_valid_tree()
# Make root option point to a grandchild (not a direct child) — cross-reference
tree["options"][0]["next_node_id"] = "fix-errors" # grandchild of root
errors = validate_generated_tree(tree)
# Should NOT have the "non-existent child" error for this reference
assert not any("non-existent child" in e for e in errors)
def test_option_referencing_nonexistent_node_still_fails(self):
"""Cross-references must still point to nodes that exist in the tree."""
tree = _make_valid_tree()
tree["options"][0]["next_node_id"] = "totally-fake-id"
errors = validate_generated_tree(tree)
assert any("does not exist" in e for e in errors)
def test_action_next_node_id_to_ancestor_is_valid(self):
"""Action node can loop back to an ancestor node (the whole point of cross-refs)."""
tree = _make_valid_tree()
# Make the action node loop back to root
tree["children"][1]["next_node_id"] = "root"
errors = validate_generated_tree(tree)
assert not any("does not exist" in e for e in errors)
```
**Step 2: Run the tests to verify they fail**
Run: `cd backend && python -m pytest tests/test_ai_tree_validator.py::TestCrossReferenceSupport -v`
Expected: `test_option_referencing_non_child_node_in_tree_is_valid` FAILS (currently raises "non-existent child" error). The other two should already pass.
**Step 3: Update validator — check global existence, not just children**
In `backend/app/core/ai_tree_validator.py`, replace lines 111-118 (the decision option next_node_id check):
Old code (lines 111-118):
```python
next_id = opt.get("next_node_id")
if next_id:
all_referenced_ids.add(next_id)
if child_ids and next_id not in child_ids:
errors.append(
f"Option '{opt.get('label', '?')}' in node '{node_id}' "
f"references non-existent child '{next_id}'"
)
```
New code:
```python
next_id = opt.get("next_node_id")
if next_id:
all_referenced_ids.add(next_id)
```
Then add a new global check after line 145 (after the action next_node_id existence check). This checks ALL option references exist anywhere in the tree:
After the existing `for ref_id in action_next_ids:` block, add:
```python
# Check that all option next_node_ids exist in the tree (allows cross-references)
for ref_id in all_referenced_ids:
if ref_id not in all_ids:
errors.append(
f"Option next_node_id '{ref_id}' references a node that does not exist in the tree"
)
```
**Step 4: Run all validator tests to verify they pass**
Run: `cd backend && python -m pytest tests/test_ai_tree_validator.py -v`
Expected: ALL tests pass. The old `test_option_references_nonexistent_child` test in `TestReferenceIntegrity` will now fail because the error message changed from "non-existent child" to "does not exist in the tree". Update that test:
In `TestReferenceIntegrity.test_option_references_nonexistent_child`, change:
```python
def test_option_references_nonexistent_child(self):
tree = _make_valid_tree()
tree["options"][0]["next_node_id"] = "nonexistent"
errors = validate_generated_tree(tree)
assert any("does not exist" in e for e in errors)
```
Run again: `cd backend && python -m pytest tests/test_ai_tree_validator.py -v`
Expected: ALL PASS
**Step 5: Commit**
```bash
git add backend/app/core/ai_tree_validator.py backend/tests/test_ai_tree_validator.py
git commit -m "feat: relax decision option validation — allow cross-references to any node in tree"
```
---
## Task 2: Frontend — Change Circular Reference Detection From Error to Warning
Loop-backs are now intentional. The circular reference detector should warn instead of error.
**Files:**
- Modify: `frontend/src/store/treeEditorStore.ts:791-824`
**Step 1: Update severity from 'error' to 'warning' and improve messages**
In `frontend/src/store/treeEditorStore.ts`, find the `detectCircularRefs` function (lines 792-824). Change both `severity: 'error'` to `severity: 'warning'` and update messages:
Replace line 803-807:
```typescript
errors.push({
nodeId: node.id,
message: `Circular reference detected: "${opt.label}" creates a loop`,
severity: 'error'
})
```
With:
```typescript
errors.push({
nodeId: node.id,
message: `This path loops back to an earlier node via "${opt.label}"`,
severity: 'warning'
})
```
Replace lines 815-819:
```typescript
errors.push({
nodeId: node.id,
message: `Circular reference detected in node "${node.title || node.id}"`,
severity: 'error'
})
```
With:
```typescript
errors.push({
nodeId: node.id,
message: `This node loops back to an earlier node ("${node.title || node.id}")`,
severity: 'warning'
})
```
**Step 2: Build to verify**
Run: `cd frontend && npm run build`
Expected: Build succeeds with no errors.
**Step 3: Commit**
```bash
git add frontend/src/store/treeEditorStore.ts
git commit -m "feat: change circular reference detection from error to warning — loops are intentional"
```
---
## Task 3: Canvas — Render Cross-Reference Edges as Dashed Arrows
Add dashed purple overlay edges for any `next_node_id` pointing outside the current node's children.
**Files:**
- Modify: `frontend/src/components/tree-editor/useTreeLayout.ts`
**Step 1: Add cross-reference edge collection to the `walk` function**
In `useTreeLayout.ts`, inside the `useMemo` callback (line 57), after the `walk(treeStructure, null)` call on line 129, add a second pass to collect cross-reference edges.
Add this helper function before the `useTreeLayout` export (around line 40):
```typescript
/** Collect all node IDs in the tree. */
function collectAllIds(root: TreeStructure): Set<string> {
const ids = new Set<string>()
function walk(node: TreeStructure) {
ids.add(node.id)
node.children?.forEach(walk)
}
walk(root)
return ids
}
/** Find all cross-reference edges (next_node_id pointing outside children). */
function collectCrossRefEdges(root: TreeStructure): Array<{ source: string; target: string; label?: string }> {
const refs: Array<{ source: string; target: string; label?: string }> = []
const allIds = collectAllIds(root)
function walk(node: TreeStructure) {
const childIds = new Set(node.children?.map(c => c.id) ?? [])
// Decision options pointing outside children
if (node.type === 'decision' && node.options) {
for (const opt of node.options) {
if (opt.next_node_id && !childIds.has(opt.next_node_id) && allIds.has(opt.next_node_id)) {
refs.push({ source: node.id, target: opt.next_node_id, label: opt.label })
}
}
}
// Action next_node_id pointing to non-child (always a cross-ref since actions use next_node_id not children)
if (node.type === 'action' && node.next_node_id && allIds.has(node.next_node_id) && !childIds.has(node.next_node_id)) {
refs.push({ source: node.id, target: node.next_node_id, label: 'loops back' })
}
node.children?.forEach(walk)
}
walk(root)
return refs
}
```
**Step 2: Add cross-reference edges to the edges array**
In the `useMemo` callback, after `walk(treeStructure, null)` (line 129) and before the return (line 131), add:
```typescript
// Add cross-reference edges (dashed, purple)
if (treeStructure) {
const crossRefs = collectCrossRefEdges(treeStructure)
for (const ref of crossRefs) {
// Only add if both source and target nodes are visible (not collapsed away)
const sourceVisible = nodes.some(n => n.id === ref.source)
const targetVisible = nodes.some(n => n.id === ref.target)
if (sourceVisible && targetVisible) {
edges.push({
id: `xref-${ref.source}->${ref.target}`,
source: ref.source,
target: ref.target,
type: 'smoothstep',
animated: true,
label: ref.label ? truncateLabel(ref.label) : undefined,
labelStyle: { fill: 'hsl(var(--primary))', fontSize: 10, fontWeight: 500 },
labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.95 },
labelBgPadding: [4, 2] as [number, number],
style: {
stroke: 'hsl(var(--primary))',
strokeWidth: 2,
strokeDasharray: '6 3',
},
markerEnd: {
type: 'arrowclosed' as const,
color: 'hsl(var(--primary))',
width: 16,
height: 16,
},
})
}
}
}
```
**Step 3: Build to verify**
Run: `cd frontend && npm run build`
Expected: Build succeeds. Cross-reference edges render as animated, dashed purple arrows with arrowheads.
**Step 4: Commit**
```bash
git add frontend/src/components/tree-editor/useTreeLayout.ts
git commit -m "feat: render cross-reference edges as dashed purple arrows on canvas"
```
---
## Task 4: Editor UX — Node Picker Dropdown for Action Nodes
Add a "Link to existing node" dropdown to `NodeFormAction.tsx`.
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormAction.tsx`
- Modify: `frontend/src/store/treeEditorStore.ts` (add helper to collect all nodes)
**Step 1: Add `collectAllNodes` helper to the tree editor store**
In `frontend/src/store/treeEditorStore.ts`, add a standalone exported helper function (near the top of the file, after imports, or as a utility):
Find the `findNodeInTree` helper function. Near it, add:
```typescript
/** Collect all nodes in the tree as a flat list with depth info. */
export function collectAllNodesFlat(
root: TreeStructure | null
): Array<{ id: string; label: string; type: string; depth: number }> {
if (!root) return []
const result: Array<{ id: string; label: string; type: string; depth: number }> = []
function walk(node: TreeStructure, depth: number) {
const label = node.type === 'decision'
? (node.question || 'Untitled Decision')
: (node.title || `Untitled ${node.type}`)
result.push({ id: node.id, label, type: node.type, depth })
node.children?.forEach(child => walk(child, depth + 1))
}
walk(root, 0)
return result
}
```
**Step 2: Update NodeFormAction to include the node picker**
Replace the "Next step hint" section at the bottom of `NodeFormAction.tsx` (lines 161-170) with a full node picker:
```tsx
import { Link2, X } from 'lucide-react'
import { collectAllNodesFlat } from '@/store/treeEditorStore'
```
(Add these to existing imports at top of file)
Replace lines 161-170 (the `{hasNextNode ? ... : ...}` block):
```tsx
{/* Link to existing node */}
<div>
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<Link2 className="h-3.5 w-3.5" />
Next Step
</label>
{hasNextNode ? (
<div className="mt-1 flex items-center gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2">
<span className="flex-1 truncate text-sm text-foreground">
Linked to: {(() => {
const treeStructure = useTreeEditorStore.getState().treeStructure
const allNodes = collectAllNodesFlat(treeStructure)
const target = allNodes.find(n => n.id === node.next_node_id)
return target ? target.label : node.next_node_id
})()}
</span>
<button
type="button"
onClick={() => onUpdate({ next_node_id: undefined })}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Remove link"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<select
value=""
onChange={(e) => {
if (e.target.value) {
onUpdate({ next_node_id: e.target.value })
}
}}
className={cn(
'mt-1 block w-full rounded-md border border-border px-3 py-2 text-sm',
'bg-card text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
>
<option value="">Link to existing node...</option>
{(() => {
const treeStructure = useTreeEditorStore.getState().treeStructure
const allNodes = collectAllNodesFlat(treeStructure)
return allNodes
.filter(n => n.id !== node.id && n.type !== 'answer')
.map(n => (
<option key={n.id} value={n.id}>
{' '.repeat(n.depth)}{n.type === 'decision' ? '?' : n.type === 'action' ? '>' : '*'} {n.label}
</option>
))
})()}
</select>
)}
<p className="mt-1 text-xs text-muted-foreground">
{hasNextNode
? 'This action will navigate to the linked node.'
: 'Select a node to navigate to after this action, or save to create a new placeholder.'}
</p>
</div>
```
**Step 3: Build to verify**
Run: `cd frontend && npm run build`
Expected: Build succeeds.
**Step 4: Commit**
```bash
git add frontend/src/components/tree-editor/NodeFormAction.tsx frontend/src/store/treeEditorStore.ts
git commit -m "feat: add node picker dropdown to action node form for cross-references"
```
---
## Task 5: Editor UX — Node Picker for Decision Option Rows
Add "Link to existing node" capability to each decision option row.
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`
**Step 1: Add link icon and dropdown per option row**
Add imports at top of `NodeFormDecision.tsx`:
```tsx
import { Link2 } from 'lucide-react'
import { collectAllNodesFlat } from '@/store/treeEditorStore'
```
In the option render callback (inside `renderItem` around line 161), after the label input and its error message, add a cross-reference link indicator per option. After the closing `</div>` of the `flex-1` wrapper (around line 197), add:
```tsx
{/* Cross-reference link indicator */}
{option.next_node_id && (() => {
const treeStructure = useTreeEditorStore.getState().treeStructure
const childIds = new Set(node.children?.map(c => c.id) ?? [])
// Only show if it's a cross-reference (points outside children)
if (childIds.has(option.next_node_id)) return null
const allNodes = collectAllNodesFlat(treeStructure)
const target = allNodes.find(n => n.id === option.next_node_id)
if (!target) return null
return (
<div className="flex items-center gap-1 text-xs text-primary" title={`Links to: ${target.label}`}>
<Link2 className="h-3 w-3" />
</div>
)
})()}
```
**Step 2: Add "Link to node" option below the options list**
After the `DynamicArrayField` closing tag (line 201, before the root tip), add:
```tsx
{/* Quick-link: assign an option to an existing node */}
<details className="mt-2">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
<Link2 className="inline h-3 w-3 mr-1" />
Link an option to an existing node (cross-reference)
</summary>
<div className="mt-2 space-y-2 rounded-md border border-border bg-accent/30 p-3">
<p className="text-xs text-muted-foreground">
Select an option, then pick a target node. This creates a loop-back or cross-reference.
</p>
<div className="flex gap-2">
<select
id="xref-option-select"
className={cn(
'flex-1 rounded-md border border-border px-2 py-1.5 text-xs',
'bg-card text-foreground'
)}
defaultValue=""
>
<option value="">Select option...</option>
{(node.options || []).map((opt, i) => (
<option key={opt.id} value={i}>
{indexToLetter(i)}: {opt.label || '(empty)'}
</option>
))}
</select>
<select
id="xref-target-select"
className={cn(
'flex-1 rounded-md border border-border px-2 py-1.5 text-xs',
'bg-card text-foreground'
)}
defaultValue=""
onChange={(e) => {
const optSelect = document.getElementById('xref-option-select') as HTMLSelectElement
const optIndex = parseInt(optSelect?.value, 10)
const targetId = e.target.value
if (!isNaN(optIndex) && targetId) {
handleUpdateOption(optIndex, { next_node_id: targetId })
// Reset selects
optSelect.value = ''
e.target.value = ''
}
}}
>
<option value="">Select target node...</option>
{(() => {
const treeStructure = useTreeEditorStore.getState().treeStructure
const allNodes = collectAllNodesFlat(treeStructure)
return allNodes
.filter(n => n.id !== node.id && n.type !== 'answer')
.map(n => (
<option key={n.id} value={n.id}>
{' '.repeat(n.depth)}{n.type === 'decision' ? '?' : n.type === 'action' ? '>' : '*'} {n.label}
</option>
))
})()}
</select>
</div>
</div>
</details>
```
**Step 2: Build to verify**
Run: `cd frontend && npm run build`
Expected: Build succeeds.
**Step 3: Commit**
```bash
git add frontend/src/components/tree-editor/NodeFormDecision.tsx
git commit -m "feat: add cross-reference node picker to decision option rows"
```
---
## Task 6: AI System Prompt — Update Structural Rules for Cross-References
Update the AI chat system prompt to allow and encourage loop-back patterns.
**Files:**
- Modify: `backend/app/core/ai_chat_service.py:68-75`
**Step 1: Update STRUCTURAL RULES in SCHEMA_CONTEXT**
Replace the STRUCTURAL RULES section (lines 68-75 of `ai_chat_service.py`):
Old:
```python
STRUCTURAL RULES:
- Root node MUST be type "decision"
- Decision nodes contain their children in the "children" array
- Each decision option's next_node_id must reference a child node's id
- Action nodes use next_node_id to chain to the next step (NOT children)
- Solution nodes are terminal no next_node_id or children
- All IDs must be unique strings (use descriptive slugs like "check-service-status")
```
New:
```python
STRUCTURAL RULES:
- Root node MUST be type "decision"
- Decision nodes contain their children in the "children" array
- Each decision option's next_node_id typically references a child node's id, BUT can also reference ANY other node in the tree for loop-back / re-verification patterns
- Action nodes use next_node_id to chain to the next step this can point to any node in the tree, including ancestors, for loop-backs (e.g., "remediate → re-verify from earlier checkpoint")
- Solution nodes are terminal no next_node_id or children
- All IDs must be unique strings (use descriptive slugs like "check-service-status")
CROSS-REFERENCE / LOOP-BACK PATTERN:
When a troubleshooting path needs to loop back (e.g., after remediation, re-verify from an earlier checkpoint), set next_node_id to the target node's ID. Example: an action node "restart-ssh-service" can set next_node_id to "verify-ssh-connection" (an ancestor decision node) to create a re-verification loop.
```
**Step 2: Build backend to verify syntax**
Run: `cd backend && python -c "from app.core.ai_chat_service import SCHEMA_CONTEXT; print('OK')"`
Expected: Prints "OK" with no import errors.
**Step 3: Commit**
```bash
git add backend/app/core/ai_chat_service.py
git commit -m "feat: update AI system prompt to allow cross-reference loop-back patterns"
```
---
## Task 7: Backend — Update Option Validation Error for `all_referenced_ids`
The `all_referenced_ids` set currently holds only option `next_node_id` values. After Task 1's change, the global existence check also needs to handle the case where `action_next_ids` and `all_referenced_ids` may overlap.
**Files:**
- Modify: `backend/app/core/ai_tree_validator.py`
- Test: `backend/tests/test_ai_tree_validator.py`
**Step 1: Verify no double-counting between action and option refs**
Check: `action_next_ids` are added to `all_referenced_ids` on line 128. After Task 1, we added a global check for `all_referenced_ids`. This means action refs get checked twice — once in the action-specific loop (lines 141-145) and once in the new option loop. We should only check option refs in the new loop.
Update the global check added in Task 1 to exclude action refs:
```python
# Check that all option next_node_ids exist in the tree (allows cross-references)
for ref_id in all_referenced_ids - action_next_ids:
if ref_id not in all_ids:
errors.append(
f"Option next_node_id '{ref_id}' references a node that does not exist in the tree"
)
```
**Step 2: Run all validator tests**
Run: `cd backend && python -m pytest tests/test_ai_tree_validator.py -v`
Expected: ALL PASS
**Step 3: Commit**
```bash
git add backend/app/core/ai_tree_validator.py
git commit -m "fix: prevent double-counting action refs in global option cross-reference check"
```
---
## Task 8: Full Integration Test
Run the full backend test suite and frontend build to verify nothing is broken.
**Files:** (none — testing only)
**Step 1: Run backend tests**
Run: `cd backend && python -m pytest --override-ini="addopts=" -v`
Expected: ALL PASS
**Step 2: Run frontend build**
Run: `cd frontend && npm run build`
Expected: Build succeeds with no errors.
**Step 3: Manual smoke test**
1. Start backend: `cd backend && uvicorn app.main:app --reload`
2. Start frontend: `cd frontend && npm run dev`
3. Open tree editor with an existing tree
4. Edit an action node → verify "Next Step" dropdown appears with all nodes listed
5. Select a node from a different branch → verify dashed purple arrow appears on canvas
6. Edit a decision node → expand "Link an option to an existing node" → create a cross-reference
7. Verify circular reference warning (not error) appears in validation panel
8. Navigate the tree → verify loop-back works (session follows `next_node_id`)
---
## Summary
| Task | What | Files |
|------|------|-------|
| 1 | Backend: relax option validation | `ai_tree_validator.py`, `test_ai_tree_validator.py` |
| 2 | Frontend: circular ref → warning | `treeEditorStore.ts` |
| 3 | Canvas: dashed purple cross-ref edges | `useTreeLayout.ts` |
| 4 | Editor: action node picker | `NodeFormAction.tsx`, `treeEditorStore.ts` |
| 5 | Editor: decision option picker | `NodeFormDecision.tsx` |
| 6 | AI prompt: loop-back awareness | `ai_chat_service.py` |
| 7 | Backend: fix ref overlap check | `ai_tree_validator.py` |
| 8 | Integration test | (testing only) |