Coverage for src / agent_contracts / visualizer.py: 90%
273 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-09 00:42 +0900
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-09 00:42 +0900
1"""ContractVisualizer - Architecture Document Generator.
3Generates comprehensive architecture documentation from NodeRegistry
4contracts, including Mermaid diagrams for visual representation.
5"""
6from __future__ import annotations
8from typing import TYPE_CHECKING
9from collections import defaultdict
11if TYPE_CHECKING:
12 from langgraph.graph.state import CompiledStateGraph
13 from agent_contracts.registry import NodeRegistry
14 from agent_contracts.contracts import NodeContract, TriggerCondition
17class ContractVisualizer:
18 """Generate architecture documentation from NodeRegistry contracts.
20 Example:
21 visualizer = ContractVisualizer(registry)
22 doc = visualizer.generate_architecture_doc()
23 with open("ARCHITECTURE.md", "w") as f:
24 f.write(doc)
25 """
27 def __init__(self, registry: "NodeRegistry", graph: "CompiledStateGraph | None" = None):
28 """Initialize visualizer.
30 Args:
31 registry: NodeRegistry with registered nodes
32 graph: Optional compiled LangGraph for flow visualization
33 """
34 self.registry = registry
35 self.graph = graph
37 def generate_architecture_doc(self) -> str:
38 """Generate complete architecture document.
40 Returns:
41 Markdown document with all sections
42 """
43 sections = [
44 self._generate_header(),
45 self.generate_langgraph_flow(), # Added LangGraph flow
46 self.generate_state_slices_section(),
47 self.generate_hierarchy_diagram(),
48 self.generate_dataflow_diagram(),
49 self.generate_trigger_hierarchy(),
50 self.generate_nodes_reference(),
51 self._generate_footer(),
52 ]
53 return "\n\n---\n\n".join(s for s in sections if s)
55 def _generate_header(self) -> str:
56 """Generate document header."""
57 return """# 🏗️ Agent Architecture
59> Auto-generated from `NodeRegistry` contracts"""
61 def _generate_footer(self) -> str:
62 """Generate document footer."""
63 return "<sub>Generated by `agent-contracts` visualizer</sub>"
65 def generate_langgraph_flow(self) -> str:
66 """Generate LangGraph flow visualization from compiled graph."""
67 if not self.graph:
68 return ""
70 try:
71 mermaid = self.graph.get_graph().draw_mermaid()
72 return f"""## 🔗 LangGraph Node Flow
74> Auto-generated from compiled LangGraph
76```mermaid
77{mermaid}
78```"""
79 except Exception:
80 return ""
83 # =========================================================================
84 # State Slices Section
85 # =========================================================================
87 def generate_state_slices_section(self) -> str:
88 """Generate State Slices documentation.
90 Collects all slices from node contracts and creates documentation.
91 """
92 # Collect slices from all nodes
93 all_reads: set[str] = set()
94 all_writes: set[str] = set()
96 for name in self.registry.get_all_nodes():
97 contract = self.registry.get_contract(name)
98 if contract:
99 all_reads.update(contract.reads)
100 all_writes.update(contract.writes)
102 all_slices = sorted(all_reads | all_writes)
104 if not all_slices:
105 return ""
107 # Build table
108 lines = [
109 "## 📦 State Slices",
110 "",
111 "State is organized into isolated **slices** for separation of concerns.",
112 "",
113 "| Slice | Read By | Written By |",
114 "|:------|:--------|:-----------|",
115 ]
117 for slice_name in all_slices:
118 readers = [n for n in self.registry.get_all_nodes()
119 if slice_name in (self.registry.get_contract(n).reads or [])]
120 writers = [n for n in self.registry.get_all_nodes()
121 if slice_name in (self.registry.get_contract(n).writes or [])]
123 readers_str = ", ".join(f"`{r}`" for r in readers)
124 writers_str = ", ".join(f"`{w}`" for w in writers)
126 lines.append(f"| `{slice_name}` | {readers_str or '-'} | {writers_str or '-'} |")
128 return "\n".join(lines)
130 def _build_slice_relationships(self) -> list[tuple[str, str, str]]:
131 """Build slice relationships from node I/O patterns."""
132 # Count how many nodes transform from one slice to another
133 transform_counts: dict[tuple[str, str], int] = defaultdict(int)
135 for name in self.registry.get_all_nodes():
136 contract = self.registry.get_contract(name)
137 if not contract:
138 continue
140 for read_slice in contract.reads:
141 for write_slice in contract.writes:
142 if read_slice != write_slice:
143 transform_counts[(read_slice, write_slice)] += 1
145 # Sort by count and take top relationships
146 sorted_transforms = sorted(
147 transform_counts.items(),
148 key=lambda x: x[1],
149 reverse=True
150 )[:10] # Limit to 10 most common to avoid diagram clutter
152 return [
153 (src, dst, f"{count} nodes")
154 for (src, dst), count in sorted_transforms
155 ]
157 # =========================================================================
158 # Hierarchy Diagram
159 # =========================================================================
161 def generate_hierarchy_diagram(self) -> str:
162 """Generate Supervisor-Node hierarchy diagram."""
163 # Group nodes by supervisor
164 supervisors: dict[str, list[str]] = defaultdict(list)
166 for name in self.registry.get_all_nodes():
167 contract = self.registry.get_contract(name)
168 if contract:
169 supervisors[contract.supervisor].append(name)
171 if not supervisors:
172 return ""
174 lines = [
175 "## 🎯 System Hierarchy",
176 "",
177 "```mermaid",
178 "flowchart TB",
179 ]
181 # Generate subgraphs for each supervisor
182 for sup_name, nodes in sorted(supervisors.items()):
183 safe_sup = self._safe_id(sup_name)
184 lines.append(f' subgraph {safe_sup}["🎯 {sup_name.replace("_", " ").title()}"]')
185 lines.append(" direction LR")
187 for node_name in nodes:
188 contract = self.registry.get_contract(node_name)
189 icon = self._get_node_icon(contract)
190 safe_node = self._safe_id(node_name)
191 lines.append(f' {safe_node}["{icon} {node_name}"]')
193 lines.append(" end")
195 # Add terminal node styling
196 terminal_nodes = [
197 self._safe_id(n) for n in self.registry.get_all_nodes()
198 if self.registry.get_contract(n) and self.registry.get_contract(n).is_terminal
199 ]
200 if terminal_nodes:
201 lines.append("")
202 lines.append(" classDef terminal fill:#e94560,stroke:#16213e,color:#fff")
203 lines.append(f" class {','.join(terminal_nodes)} terminal")
205 lines.append("```")
207 return "\n".join(lines)
209 def _get_node_icon(self, contract: "NodeContract | None") -> str:
210 """Get emoji icon for node based on its properties.
212 Combines icons: [LLM?] + [Terminal/Domain Type]
213 """
214 if not contract:
215 return "📦"
217 # 1. Determine base icon (Type/Domain)
218 if contract.is_terminal:
219 base_icon = "🔚"
220 elif "search" in contract.name.lower():
221 base_icon = "🔍"
222 elif "interview" in contract.name.lower():
223 base_icon = "💬"
224 elif "like" in contract.name.lower() or "heart" in contract.name.lower():
225 base_icon = "❤️"
226 elif "card" in contract.name.lower():
227 base_icon = "🃏"
228 else:
229 base_icon = "📦"
231 # 2. Add LLM indicator if applicable
232 if contract.requires_llm:
233 return f"🤖{base_icon}"
235 return base_icon
237 def _safe_id(self, name: str) -> str:
238 """Convert name to safe Mermaid ID."""
239 return name.replace("-", "_").replace(" ", "_")
241 # =========================================================================
242 # Data Flow Diagram
243 # =========================================================================
245 def generate_dataflow_diagram(self) -> str:
246 """Generate data flow diagram showing key data paths.
248 Shows:
249 - Slices as central data stores
250 - Entry points (nodes reading request)
251 - Terminal nodes (marked with is_terminal)
252 - Cross-supervisor data connections
253 """
254 # Group nodes by supervisor
255 supervisors: dict[str, list[str]] = defaultdict(list)
256 for name in self.registry.get_all_nodes():
257 contract = self.registry.get_contract(name)
258 if contract:
259 supervisors[contract.supervisor].append(name)
261 if not supervisors:
262 return ""
264 lines = [
265 "## 🔀 Data Flow",
266 "",
267 "> Key data paths through the system",
268 "",
269 "```mermaid",
270 "flowchart TB",
271 ]
273 # Find key nodes: entry points (read request) and terminals
274 entry_nodes: list[str] = []
275 terminal_nodes: list[str] = []
276 for name in self.registry.get_all_nodes():
277 contract = self.registry.get_contract(name)
278 if contract:
279 if "request" in contract.reads:
280 entry_nodes.append(name)
281 if contract.is_terminal:
282 terminal_nodes.append(name)
284 # Add slices (excluding _internal)
285 key_slices = ["request", "response"]
286 for name in self.registry.get_all_nodes():
287 contract = self.registry.get_contract(name)
288 if contract:
289 for s in contract.reads + contract.writes:
290 if not s.startswith('_') and s not in key_slices:
291 key_slices.append(s)
293 # Slices subgraph
294 lines.append(' subgraph slices["📦 State"]')
295 for slice_name in sorted(set(key_slices)):
296 icon = "📥" if slice_name == "request" else "📤" if slice_name == "response" else "📁"
297 slice_id = f"slice_{self._safe_id(slice_name)}"
298 lines.append(f' {slice_id}[("{icon} {slice_name}")]')
299 lines.append(" end")
300 lines.append("")
302 # Add supervisor subgraphs with their nodes
303 for sup_name, nodes in sorted(supervisors.items()):
304 safe_sup = f"sup_{self._safe_id(sup_name)}"
305 lines.append(f' subgraph {safe_sup}["🎯 {sup_name}"]')
306 lines.append(" direction LR")
307 for node_name in nodes:
308 contract = self.registry.get_contract(node_name)
309 icon = self._get_node_icon(contract)
310 safe_node = self._safe_id(node_name)
311 lines.append(f' {safe_node}["{icon} {node_name}"]')
312 lines.append(" end")
314 lines.append("")
316 # Add edges: request -> entry nodes
317 lines.append(" %% Entry points")
318 for node_name in entry_nodes: # Show all entry points
319 lines.append(f" slice_request --> {self._safe_id(node_name)}")
321 # Add edges: nodes writing to response -> response
322 lines.append(" %% Response outputs")
323 response_writers: list[str] = []
324 for name in self.registry.get_all_nodes():
325 contract = self.registry.get_contract(name)
326 if contract and "response" in contract.writes:
327 response_writers.append(name)
328 for node_name in response_writers: # Show all response writers
329 lines.append(f" {self._safe_id(node_name)} --> slice_response")
331 # Add edges for other slices (excluding request/response/_internal)
332 lines.append(" %% Slice data flows")
333 other_slices = [s for s in key_slices if s not in ["request", "response"]]
334 for slice_name in other_slices:
335 slice_id = f"slice_{self._safe_id(slice_name)}"
336 # Find readers and writers for this slice
337 readers: list[str] = []
338 writers: list[str] = []
339 for name in self.registry.get_all_nodes():
340 contract = self.registry.get_contract(name)
341 if contract:
342 if slice_name in contract.reads:
343 readers.append(name)
344 if slice_name in contract.writes:
345 writers.append(name)
346 # Add writer -> slice edges
347 for node_name in writers:
348 lines.append(f" {self._safe_id(node_name)} -.-> {slice_id}")
349 # Add slice -> reader edges
350 for node_name in readers:
351 lines.append(f" {slice_id} -.-> {self._safe_id(node_name)}")
353 # Styling
354 lines.extend([
355 "",
356 " classDef slice fill:#f5f5f5,stroke:#999",
357 " classDef terminal fill:#e94560,stroke:#16213e,color:#fff",
358 ])
359 terminal_ids = [self._safe_id(n) for n in terminal_nodes]
360 if terminal_ids:
361 lines.append(f" class {','.join(terminal_ids)} terminal")
363 lines.append("```")
365 # Add detailed node dependencies in collapsible section
366 lines.extend([
367 "",
368 "<details>",
369 "<summary>📊 Detailed Node Dependencies</summary>",
370 "",
371 ])
373 # Build dependency table grouped by supervisor
374 dependencies = self.registry.analyze_data_flow()
376 for sup_name, nodes in sorted(supervisors.items()):
377 sup_deps = [(n, dependencies.get(n, [])) for n in nodes if dependencies.get(n)]
378 if not sup_deps:
379 continue
381 lines.append(f"**{sup_name}**")
382 lines.append("")
383 lines.append("| Node | Depends On (via shared slices) |")
384 lines.append("|:-----|:-------------------------------|")
386 for node_name, deps in sup_deps:
387 if deps:
388 # Find shared slices for each dependency
389 dep_info = []
390 node_contract = self.registry.get_contract(node_name)
391 for dep in deps: # Show all dependencies
392 dep_contract = self.registry.get_contract(dep)
393 if node_contract and dep_contract:
394 shared = set(node_contract.reads) & set(dep_contract.writes)
395 shared_str = ", ".join(sorted(s for s in shared if not s.startswith('_')))
396 if shared_str:
397 dep_info.append(f"`{dep}` ({shared_str})")
398 else:
399 dep_info.append(f"`{dep}`")
400 if dep_info:
401 lines.append(f"| `{node_name}` | {', '.join(dep_info)} |")
403 lines.append("")
405 lines.extend([
406 "</details>",
407 ])
409 return "\n".join(lines)
411 # =========================================================================
412 # Trigger Hierarchy
413 # =========================================================================
415 def generate_trigger_hierarchy(self) -> str:
416 """Generate trigger hierarchy documentation."""
417 # Group by supervisor
418 supervisors: dict[str, list[tuple[str, "NodeContract"]]] = defaultdict(list)
420 for name in self.registry.get_all_nodes():
421 contract = self.registry.get_contract(name)
422 if contract:
423 supervisors[contract.supervisor].append((name, contract))
425 if not supervisors:
426 return ""
428 lines = [
429 "## ⚡ Trigger Hierarchy",
430 "",
431 "> Nodes are evaluated by **priority** (highest first)",
432 ]
434 for sup_name, nodes in sorted(supervisors.items()):
435 lines.extend([
436 "",
437 f"### 🎯 {sup_name.replace('_', ' ').title()}",
438 "",
439 "| Priority | Node | Condition | Hint |",
440 "|:--------:|:-----|:----------|:-----|",
441 ])
443 # Sort nodes by highest trigger priority
444 def get_max_priority(item: tuple[str, "NodeContract"]) -> int:
445 _, contract = item
446 if not contract.trigger_conditions:
447 return -1
448 return max(c.priority for c in contract.trigger_conditions)
450 sorted_nodes = sorted(nodes, key=get_max_priority, reverse=True)
452 for name, contract in sorted_nodes:
453 priority = get_max_priority((name, contract))
454 priority_icon = self._get_priority_icon(priority)
456 # Get condition summary
457 condition = self._summarize_conditions(contract.trigger_conditions)
458 hint = contract.get_llm_hints()[0] if contract.get_llm_hints() else "-"
460 priority_str = f"{priority_icon} **{priority}**" if priority >= 0 else "🟢 _(default)_"
461 lines.append(f"| {priority_str} | `{name}` | {condition} | {hint} |")
463 # Add Mermaid diagram for this supervisor
464 lines.extend([
465 "",
466 "<details>",
467 f"<summary>📊 {sup_name} Priority Chain</summary>",
468 "",
469 "```mermaid",
470 "flowchart TD",
471 f' subgraph {self._safe_id(sup_name)}["{sup_name}"]',
472 " direction TB",
473 ])
475 prev_node = None
476 for name, contract in sorted_nodes:
477 priority = get_max_priority((name, contract))
478 priority_icon = self._get_priority_icon(priority)
479 safe_name = self._safe_id(name)
480 lines.append(f' {safe_name}["{priority_icon} P{priority}: {name}"]')
482 if prev_node:
483 lines.append(f' {prev_node} -->|"not matched"| {safe_name}')
484 prev_node = safe_name
486 lines.extend([
487 " end",
488 "```",
489 "",
490 "</details>",
491 ])
493 return "\n".join(lines)
495 def _get_priority_icon(self, priority: int) -> str:
496 """Get priority indicator icon."""
497 if priority >= 100:
498 return "🔴"
499 elif priority >= 50:
500 return "🟡"
501 elif priority >= 10:
502 return "🟢"
503 else:
504 return "⚪"
506 def _summarize_conditions(self, conditions: list["TriggerCondition"]) -> str:
507 """Summarize trigger conditions for display."""
508 if not conditions:
509 return "_(default)_"
511 # Get highest priority condition
512 highest = max(conditions, key=lambda c: c.priority)
514 parts = []
515 if highest.when:
516 for key, value in list(highest.when.items())[:2]:
517 parts.append(f"`{key}={value}`")
518 if highest.when_not:
519 for key, value in list(highest.when_not.items())[:1]:
520 parts.append(f"`{key}≠{value}`")
522 return ", ".join(parts) if parts else "_(always)_"
524 # =========================================================================
525 # Nodes Reference
526 # =========================================================================
528 def generate_nodes_reference(self) -> str:
529 """Generate nodes reference table."""
530 nodes = self.registry.get_all_nodes()
532 if not nodes:
533 return ""
535 lines = [
536 "## 📚 Nodes Reference",
537 "",
538 "| Node | Supervisor | Reads | Writes | LLM | Terminal |",
539 "|:-----|:-----------|:------|:-------|:---:|:--------:|",
540 ]
542 for name in sorted(nodes):
543 contract = self.registry.get_contract(name)
544 if not contract:
545 continue
547 reads = ", ".join(f"`{r}`" for r in contract.reads) or "-"
548 writes = ", ".join(f"`{w}`" for w in contract.writes) or "-"
549 llm = "✅" if contract.requires_llm else ""
550 terminal = "🔚" if contract.is_terminal else ""
552 lines.append(f"| `{name}` | {contract.supervisor} | {reads} | {writes} | {llm} | {terminal} |")
554 lines.extend([
555 "",
556 "<details>",
557 "<summary>🔍 Legend</summary>",
558 "",
559 "- ✅ = Requires LLM",
560 "- 🔚 = Terminal node (exits to END)",
561 "- Reads/Writes = State slices accessed",
562 "",
563 "</details>",
564 ])
566 return "\n".join(lines)