yamlgraph 0.3.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. examples/__init__.py +1 -0
  2. examples/codegen/__init__.py +5 -0
  3. examples/codegen/models/__init__.py +13 -0
  4. examples/codegen/models/schemas.py +76 -0
  5. examples/codegen/tests/__init__.py +1 -0
  6. examples/codegen/tests/test_ai_helpers.py +235 -0
  7. examples/codegen/tests/test_ast_analysis.py +174 -0
  8. examples/codegen/tests/test_code_analysis.py +134 -0
  9. examples/codegen/tests/test_code_context.py +301 -0
  10. examples/codegen/tests/test_code_nav.py +89 -0
  11. examples/codegen/tests/test_dependency_tools.py +119 -0
  12. examples/codegen/tests/test_example_tools.py +185 -0
  13. examples/codegen/tests/test_git_tools.py +112 -0
  14. examples/codegen/tests/test_impl_agent_schemas.py +193 -0
  15. examples/codegen/tests/test_impl_agent_v4_graph.py +94 -0
  16. examples/codegen/tests/test_jedi_analysis.py +226 -0
  17. examples/codegen/tests/test_meta_tools.py +250 -0
  18. examples/codegen/tests/test_plan_discovery_prompt.py +98 -0
  19. examples/codegen/tests/test_syntax_tools.py +85 -0
  20. examples/codegen/tests/test_synthesize_prompt.py +94 -0
  21. examples/codegen/tests/test_template_tools.py +244 -0
  22. examples/codegen/tools/__init__.py +80 -0
  23. examples/codegen/tools/ai_helpers.py +420 -0
  24. examples/codegen/tools/ast_analysis.py +92 -0
  25. examples/codegen/tools/code_context.py +180 -0
  26. examples/codegen/tools/code_nav.py +52 -0
  27. examples/codegen/tools/dependency_tools.py +120 -0
  28. examples/codegen/tools/example_tools.py +188 -0
  29. examples/codegen/tools/git_tools.py +151 -0
  30. examples/codegen/tools/impl_executor.py +614 -0
  31. examples/codegen/tools/jedi_analysis.py +311 -0
  32. examples/codegen/tools/meta_tools.py +202 -0
  33. examples/codegen/tools/syntax_tools.py +26 -0
  34. examples/codegen/tools/template_tools.py +356 -0
  35. examples/fastapi_interview.py +167 -0
  36. examples/npc/api/__init__.py +1 -0
  37. examples/npc/api/app.py +100 -0
  38. examples/npc/api/routes/__init__.py +5 -0
  39. examples/npc/api/routes/encounter.py +182 -0
  40. examples/npc/api/session.py +330 -0
  41. examples/npc/demo.py +387 -0
  42. examples/npc/nodes/__init__.py +5 -0
  43. examples/npc/nodes/image_node.py +92 -0
  44. examples/npc/run_encounter.py +230 -0
  45. examples/shared/__init__.py +0 -0
  46. examples/shared/replicate_tool.py +238 -0
  47. examples/storyboard/__init__.py +1 -0
  48. examples/storyboard/generate_videos.py +335 -0
  49. examples/storyboard/nodes/__init__.py +12 -0
  50. examples/storyboard/nodes/animated_character_node.py +248 -0
  51. examples/storyboard/nodes/animated_image_node.py +138 -0
  52. examples/storyboard/nodes/character_node.py +162 -0
  53. examples/storyboard/nodes/image_node.py +118 -0
  54. examples/storyboard/nodes/replicate_tool.py +49 -0
  55. examples/storyboard/retry_images.py +118 -0
  56. scripts/demo_async_executor.py +212 -0
  57. scripts/demo_interview_e2e.py +200 -0
  58. scripts/demo_streaming.py +140 -0
  59. scripts/run_interview_demo.py +94 -0
  60. scripts/test_interrupt_fix.py +26 -0
  61. tests/__init__.py +1 -0
  62. tests/conftest.py +178 -0
  63. tests/integration/__init__.py +1 -0
  64. tests/integration/test_animated_storyboard.py +63 -0
  65. tests/integration/test_cli_commands.py +242 -0
  66. tests/integration/test_colocated_prompts.py +139 -0
  67. tests/integration/test_map_demo.py +50 -0
  68. tests/integration/test_memory_demo.py +283 -0
  69. tests/integration/test_npc_api/__init__.py +1 -0
  70. tests/integration/test_npc_api/test_routes.py +357 -0
  71. tests/integration/test_npc_api/test_session.py +216 -0
  72. tests/integration/test_pipeline_flow.py +105 -0
  73. tests/integration/test_providers.py +163 -0
  74. tests/integration/test_resume.py +75 -0
  75. tests/integration/test_subgraph_integration.py +295 -0
  76. tests/integration/test_subgraph_interrupt.py +106 -0
  77. tests/unit/__init__.py +1 -0
  78. tests/unit/test_agent_nodes.py +355 -0
  79. tests/unit/test_async_executor.py +346 -0
  80. tests/unit/test_checkpointer.py +212 -0
  81. tests/unit/test_checkpointer_factory.py +212 -0
  82. tests/unit/test_cli.py +121 -0
  83. tests/unit/test_cli_package.py +81 -0
  84. tests/unit/test_compile_graph_map.py +132 -0
  85. tests/unit/test_conditions_routing.py +253 -0
  86. tests/unit/test_config.py +93 -0
  87. tests/unit/test_conversation_memory.py +276 -0
  88. tests/unit/test_database.py +145 -0
  89. tests/unit/test_deprecation.py +104 -0
  90. tests/unit/test_executor.py +172 -0
  91. tests/unit/test_executor_async.py +179 -0
  92. tests/unit/test_export.py +149 -0
  93. tests/unit/test_expressions.py +178 -0
  94. tests/unit/test_feature_brainstorm.py +194 -0
  95. tests/unit/test_format_prompt.py +145 -0
  96. tests/unit/test_generic_report.py +200 -0
  97. tests/unit/test_graph_commands.py +327 -0
  98. tests/unit/test_graph_linter.py +627 -0
  99. tests/unit/test_graph_loader.py +357 -0
  100. tests/unit/test_graph_schema.py +193 -0
  101. tests/unit/test_inline_schema.py +151 -0
  102. tests/unit/test_interrupt_node.py +182 -0
  103. tests/unit/test_issues.py +164 -0
  104. tests/unit/test_jinja2_prompts.py +85 -0
  105. tests/unit/test_json_extract.py +134 -0
  106. tests/unit/test_langsmith.py +600 -0
  107. tests/unit/test_langsmith_tools.py +204 -0
  108. tests/unit/test_llm_factory.py +109 -0
  109. tests/unit/test_llm_factory_async.py +118 -0
  110. tests/unit/test_loops.py +403 -0
  111. tests/unit/test_map_node.py +144 -0
  112. tests/unit/test_no_backward_compat.py +56 -0
  113. tests/unit/test_node_factory.py +348 -0
  114. tests/unit/test_passthrough_node.py +126 -0
  115. tests/unit/test_prompts.py +324 -0
  116. tests/unit/test_python_nodes.py +198 -0
  117. tests/unit/test_reliability.py +298 -0
  118. tests/unit/test_result_export.py +234 -0
  119. tests/unit/test_router.py +296 -0
  120. tests/unit/test_sanitize.py +99 -0
  121. tests/unit/test_schema_loader.py +295 -0
  122. tests/unit/test_shell_tools.py +229 -0
  123. tests/unit/test_state_builder.py +331 -0
  124. tests/unit/test_state_builder_map.py +104 -0
  125. tests/unit/test_state_config.py +197 -0
  126. tests/unit/test_streaming.py +307 -0
  127. tests/unit/test_subgraph.py +596 -0
  128. tests/unit/test_template.py +190 -0
  129. tests/unit/test_tool_call_integration.py +164 -0
  130. tests/unit/test_tool_call_node.py +178 -0
  131. tests/unit/test_tool_nodes.py +129 -0
  132. tests/unit/test_websearch.py +234 -0
  133. yamlgraph/__init__.py +35 -0
  134. yamlgraph/builder.py +110 -0
  135. yamlgraph/cli/__init__.py +159 -0
  136. yamlgraph/cli/__main__.py +6 -0
  137. yamlgraph/cli/commands.py +231 -0
  138. yamlgraph/cli/deprecation.py +92 -0
  139. yamlgraph/cli/graph_commands.py +541 -0
  140. yamlgraph/cli/validators.py +37 -0
  141. yamlgraph/config.py +67 -0
  142. yamlgraph/constants.py +70 -0
  143. yamlgraph/error_handlers.py +227 -0
  144. yamlgraph/executor.py +290 -0
  145. yamlgraph/executor_async.py +288 -0
  146. yamlgraph/graph_loader.py +451 -0
  147. yamlgraph/map_compiler.py +150 -0
  148. yamlgraph/models/__init__.py +36 -0
  149. yamlgraph/models/graph_schema.py +181 -0
  150. yamlgraph/models/schemas.py +124 -0
  151. yamlgraph/models/state_builder.py +236 -0
  152. yamlgraph/node_factory.py +768 -0
  153. yamlgraph/routing.py +87 -0
  154. yamlgraph/schema_loader.py +240 -0
  155. yamlgraph/storage/__init__.py +20 -0
  156. yamlgraph/storage/checkpointer.py +72 -0
  157. yamlgraph/storage/checkpointer_factory.py +123 -0
  158. yamlgraph/storage/database.py +320 -0
  159. yamlgraph/storage/export.py +269 -0
  160. yamlgraph/tools/__init__.py +1 -0
  161. yamlgraph/tools/agent.py +320 -0
  162. yamlgraph/tools/graph_linter.py +388 -0
  163. yamlgraph/tools/langsmith_tools.py +125 -0
  164. yamlgraph/tools/nodes.py +126 -0
  165. yamlgraph/tools/python_tool.py +179 -0
  166. yamlgraph/tools/shell.py +205 -0
  167. yamlgraph/tools/websearch.py +242 -0
  168. yamlgraph/utils/__init__.py +48 -0
  169. yamlgraph/utils/conditions.py +157 -0
  170. yamlgraph/utils/expressions.py +245 -0
  171. yamlgraph/utils/json_extract.py +104 -0
  172. yamlgraph/utils/langsmith.py +416 -0
  173. yamlgraph/utils/llm_factory.py +118 -0
  174. yamlgraph/utils/llm_factory_async.py +105 -0
  175. yamlgraph/utils/logging.py +104 -0
  176. yamlgraph/utils/prompts.py +171 -0
  177. yamlgraph/utils/sanitize.py +98 -0
  178. yamlgraph/utils/template.py +102 -0
  179. yamlgraph/utils/validators.py +181 -0
  180. yamlgraph-0.3.9.dist-info/METADATA +1105 -0
  181. yamlgraph-0.3.9.dist-info/RECORD +185 -0
  182. yamlgraph-0.3.9.dist-info/WHEEL +5 -0
  183. yamlgraph-0.3.9.dist-info/entry_points.txt +2 -0
  184. yamlgraph-0.3.9.dist-info/licenses/LICENSE +33 -0
  185. yamlgraph-0.3.9.dist-info/top_level.txt +4 -0
@@ -0,0 +1,356 @@
1
+ """Template extraction tools for implementation agent.
2
+
3
+ Extract reusable templates from existing code for consistent new code generation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ pass
14
+
15
+
16
+ def extract_function_template(file_path: str, function_name: str) -> dict:
17
+ """Extract reusable function template.
18
+
19
+ Args:
20
+ file_path: Path to the Python file
21
+ function_name: Name of the function to extract
22
+
23
+ Returns:
24
+ dict with 'template' string showing reusable pattern
25
+ or dict with 'error' key if failed
26
+ """
27
+ path = Path(file_path)
28
+ if not path.exists():
29
+ return {"error": f"File not found: {file_path}"}
30
+
31
+ try:
32
+ source = path.read_text()
33
+ tree = ast.parse(source)
34
+ except SyntaxError as e:
35
+ return {"error": f"Syntax error: {e}"}
36
+
37
+ # Find the function
38
+ target = None
39
+ for node in ast.walk(tree):
40
+ if isinstance(node, ast.FunctionDef) and node.name == function_name:
41
+ target = node
42
+ break
43
+
44
+ if target is None:
45
+ return {"error": f"Function '{function_name}' not found in {file_path}"}
46
+
47
+ # Extract template
48
+ lines = source.splitlines()
49
+ template_parts = []
50
+
51
+ # Build signature line
52
+ sig = _build_signature(target)
53
+ template_parts.append(sig)
54
+
55
+ # Extract docstring
56
+ docstring = ast.get_docstring(target)
57
+ if docstring:
58
+ template_parts.append(f' """{docstring}"""')
59
+
60
+ # Extract body structure
61
+ body_template = _extract_body_structure(target, lines)
62
+ template_parts.append(body_template)
63
+
64
+ return {"template": "\n".join(template_parts)}
65
+
66
+
67
+ def _build_signature(func: ast.FunctionDef) -> str:
68
+ """Build function signature with type hints."""
69
+ args = []
70
+ for arg in func.args.args:
71
+ if arg.annotation:
72
+ ann = _get_annotation(arg.annotation)
73
+ args.append(f"{arg.arg}: {ann}")
74
+ else:
75
+ args.append(arg.arg)
76
+
77
+ # Handle defaults
78
+ defaults = func.args.defaults
79
+ num_defaults = len(defaults)
80
+ if num_defaults > 0:
81
+ for i, default in enumerate(defaults):
82
+ idx = len(args) - num_defaults + i
83
+ default_val = _get_value(default)
84
+ args[idx] = f"{args[idx]} = {default_val}"
85
+
86
+ ret = ""
87
+ if func.returns:
88
+ ret = f" -> {_get_annotation(func.returns)}"
89
+
90
+ return f"def {func.name}({', '.join(args)}){ret}:"
91
+
92
+
93
+ def _get_annotation(node: ast.expr) -> str:
94
+ """Extract type annotation as string."""
95
+ if isinstance(node, ast.Name):
96
+ return node.id
97
+ elif isinstance(node, ast.Constant):
98
+ return repr(node.value)
99
+ elif isinstance(node, ast.Subscript):
100
+ value = _get_annotation(node.value)
101
+ slice_val = _get_annotation(node.slice)
102
+ return f"{value}[{slice_val}]"
103
+ elif isinstance(node, ast.Attribute):
104
+ return f"{_get_annotation(node.value)}.{node.attr}"
105
+ elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
106
+ left = _get_annotation(node.left)
107
+ right = _get_annotation(node.right)
108
+ return f"{left} | {right}"
109
+ return "..."
110
+
111
+
112
+ def _get_value(node: ast.expr) -> str:
113
+ """Extract default value as string."""
114
+ if isinstance(node, ast.Constant):
115
+ return repr(node.value)
116
+ elif isinstance(node, ast.Name):
117
+ return node.id
118
+ elif isinstance(node, ast.List):
119
+ return "[]"
120
+ elif isinstance(node, ast.Dict):
121
+ return "{}"
122
+ return "..."
123
+
124
+
125
+ def _extract_body_structure(func: ast.FunctionDef, lines: list[str]) -> str:
126
+ """Extract body structure as template."""
127
+ body_parts = []
128
+
129
+ for node in func.body:
130
+ # Skip docstring
131
+ if (
132
+ isinstance(node, ast.Expr)
133
+ and isinstance(node.value, ast.Constant)
134
+ and isinstance(node.value.value, str)
135
+ ):
136
+ continue
137
+
138
+ if isinstance(node, ast.Try):
139
+ body_parts.append(" try:")
140
+ body_parts.append(" {try_block}")
141
+ for handler in node.handlers:
142
+ exc = _get_annotation(handler.type) if handler.type else "Exception"
143
+ body_parts.append(f" except {exc}:")
144
+ body_parts.append(" {except_block}")
145
+ elif isinstance(node, ast.If):
146
+ body_parts.append(" if {condition}:")
147
+ body_parts.append(" {if_block}")
148
+ elif isinstance(node, ast.For):
149
+ body_parts.append(" for {item} in {iterable}:")
150
+ body_parts.append(" {loop_block}")
151
+ elif isinstance(node, ast.Return):
152
+ if node.value:
153
+ ret_val = _format_return(node.value)
154
+ body_parts.append(f" return {ret_val}")
155
+ else:
156
+ body_parts.append(" return")
157
+ else:
158
+ # Generic statement
159
+ stmt_lines = lines[node.lineno - 1 : node.end_lineno]
160
+ for line in stmt_lines:
161
+ body_parts.append(line)
162
+
163
+ if not body_parts:
164
+ body_parts.append(" pass")
165
+
166
+ return "\n".join(body_parts)
167
+
168
+
169
+ def _format_return(node: ast.expr) -> str:
170
+ """Format return value for template."""
171
+ if isinstance(node, ast.Dict):
172
+ items = []
173
+ for k, v in zip(node.keys, node.values, strict=False):
174
+ key = _get_value(k) if k else "..."
175
+ val = "{result}" if isinstance(v, ast.Name) else _get_value(v)
176
+ items.append(f"{key}: {val}")
177
+ return "{" + ", ".join(items) + "}"
178
+ elif isinstance(node, ast.Call):
179
+ return "{function_call}"
180
+ return "{result}"
181
+
182
+
183
+ def extract_class_template(file_path: str, class_name: str) -> dict:
184
+ """Extract reusable class template.
185
+
186
+ Args:
187
+ file_path: Path to the Python file
188
+ class_name: Name of the class to extract
189
+
190
+ Returns:
191
+ dict with 'template' string showing reusable pattern
192
+ or dict with 'error' key if failed
193
+ """
194
+ path = Path(file_path)
195
+ if not path.exists():
196
+ return {"error": f"File not found: {file_path}"}
197
+
198
+ try:
199
+ source = path.read_text()
200
+ tree = ast.parse(source)
201
+ except SyntaxError as e:
202
+ return {"error": f"Syntax error: {e}"}
203
+
204
+ # Find the class
205
+ target = None
206
+ for node in ast.walk(tree):
207
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
208
+ target = node
209
+ break
210
+
211
+ if target is None:
212
+ return {"error": f"Class '{class_name}' not found in {file_path}"}
213
+
214
+ template_parts = []
215
+
216
+ # Class signature with bases
217
+ bases = [_get_annotation(b) for b in target.bases]
218
+ if bases:
219
+ template_parts.append(f"class {class_name}({', '.join(bases)}):")
220
+ else:
221
+ template_parts.append(f"class {class_name}:")
222
+
223
+ # Docstring
224
+ docstring = ast.get_docstring(target)
225
+ if docstring:
226
+ template_parts.append(f' """{docstring}"""')
227
+
228
+ # Class variables
229
+ for node in target.body:
230
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
231
+ name = node.target.id
232
+ ann = _get_annotation(node.annotation)
233
+ if node.value:
234
+ val = _get_value(node.value)
235
+ template_parts.append(f" {name}: {ann} = {val}")
236
+ else:
237
+ template_parts.append(f" {name}: {ann}")
238
+
239
+ # Methods (signatures only)
240
+ for node in target.body:
241
+ if isinstance(node, ast.FunctionDef):
242
+ sig = _build_signature(node)
243
+ template_parts.append("")
244
+ template_parts.append(f" {sig}")
245
+ # Method docstring
246
+ method_doc = ast.get_docstring(node)
247
+ if method_doc:
248
+ short_doc = method_doc.split("\n")[0]
249
+ template_parts.append(f' """{short_doc}"""')
250
+ template_parts.append(" ...")
251
+
252
+ return {"template": "\n".join(template_parts)}
253
+
254
+
255
+ def extract_test_template(test_file: str, target_module: str) -> dict:
256
+ """Extract test patterns for reuse.
257
+
258
+ Args:
259
+ test_file: Path to the test file
260
+ target_module: Module being tested
261
+
262
+ Returns:
263
+ dict with 'template' string showing test patterns
264
+ or dict with 'error' key if failed
265
+ """
266
+ path = Path(test_file)
267
+ if not path.exists():
268
+ return {"error": f"File not found: {test_file}"}
269
+
270
+ try:
271
+ source = path.read_text()
272
+ tree = ast.parse(source)
273
+ except SyntaxError as e:
274
+ return {"error": f"Syntax error: {e}"}
275
+
276
+ template_parts = []
277
+ lines = source.splitlines()
278
+
279
+ # Find imports
280
+ imports = []
281
+ for node in ast.walk(tree):
282
+ if isinstance(node, ast.Import):
283
+ for alias in node.names:
284
+ if alias.name == "pytest":
285
+ imports.append("import pytest")
286
+ elif isinstance(node, ast.ImportFrom) and node.module and "mock" in node.module:
287
+ names = ", ".join(a.name for a in node.names)
288
+ imports.append(f"from {node.module} import {names}")
289
+
290
+ if imports:
291
+ template_parts.extend(imports)
292
+ template_parts.append("")
293
+
294
+ # Find fixtures
295
+ for node in ast.walk(tree):
296
+ if isinstance(node, ast.FunctionDef):
297
+ for decorator in node.decorator_list:
298
+ if isinstance(decorator, ast.Attribute) and decorator.attr == "fixture":
299
+ template_parts.append("@pytest.fixture")
300
+ sig = _build_signature(node)
301
+ template_parts.append(f"{sig}")
302
+ template_parts.append(" {fixture_implementation}")
303
+ template_parts.append("")
304
+ elif isinstance(decorator, ast.Name) and "fixture" in decorator.id:
305
+ template_parts.append(f"@{decorator.id}")
306
+ sig = _build_signature(node)
307
+ template_parts.append(f"{sig}")
308
+ template_parts.append(" {fixture_implementation}")
309
+ template_parts.append("")
310
+
311
+ # Find test classes
312
+ for node in ast.walk(tree):
313
+ if isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
314
+ template_parts.append(f"class {node.name}:")
315
+ docstring = ast.get_docstring(node)
316
+ if docstring:
317
+ template_parts.append(f' """{docstring}"""')
318
+ template_parts.append("")
319
+
320
+ # Extract test method patterns
321
+ for method in node.body:
322
+ if isinstance(method, ast.FunctionDef) and method.name.startswith(
323
+ "test_"
324
+ ):
325
+ sig = _build_signature(method)
326
+ template_parts.append(f" {sig}")
327
+ template_parts.append(" {test_implementation}")
328
+ template_parts.append("")
329
+ break # Just use first test class as pattern
330
+
331
+ # Find standalone test functions with patch
332
+ for node in ast.walk(tree):
333
+ if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"):
334
+ # Check for patch usage in body
335
+ func_source = "\n".join(lines[node.lineno - 1 : node.end_lineno])
336
+ if "patch" in func_source:
337
+ template_parts.append("def {test_name}():")
338
+ template_parts.append(' with patch("{module}.{target}") as mock:')
339
+ template_parts.append(" mock.return_value = {mock_value}")
340
+ template_parts.append(" {test_implementation}")
341
+ break
342
+
343
+ if not template_parts:
344
+ # Provide minimal template
345
+ template_parts = [
346
+ "import pytest",
347
+ f"from {target_module} import {{symbols}}",
348
+ "",
349
+ "class Test{{ClassName}}:",
350
+ ' """Tests for {{symbol}}."""',
351
+ "",
352
+ " def test_basic(self):",
353
+ " {test_implementation}",
354
+ ]
355
+
356
+ return {"template": "\n".join(template_parts)}
@@ -0,0 +1,167 @@
1
+ """FastAPI example with async graph execution.
2
+
3
+ Demonstrates:
4
+ - Async graph loading and compilation
5
+ - Interrupt handling via HTTP endpoints
6
+ - Thread-based session management
7
+
8
+ Run:
9
+ pip install fastapi uvicorn
10
+ uvicorn examples.fastapi_interview:app --reload
11
+
12
+ Usage:
13
+ # Start a new session
14
+ curl -X POST http://localhost:8000/chat/session1 -d '{"message": "start"}'
15
+
16
+ # Resume after interrupt (provide user's name)
17
+ curl -X POST http://localhost:8000/chat/session1/resume -d '{"answer": "Alice"}'
18
+ """
19
+
20
+ import logging
21
+ from contextlib import asynccontextmanager
22
+ from typing import Any
23
+
24
+ from fastapi import FastAPI, HTTPException
25
+ from langgraph.types import Command
26
+ from pydantic import BaseModel
27
+
28
+ from yamlgraph.executor_async import load_and_compile_async, run_graph_async
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Global compiled graph (loaded once at startup)
33
+ _app = None
34
+
35
+
36
+ @asynccontextmanager
37
+ async def lifespan(app: FastAPI):
38
+ """Load graph at startup."""
39
+ global _app
40
+ try:
41
+ _app = await load_and_compile_async("graphs/interview-demo.yaml")
42
+ logger.info("✅ Graph loaded and compiled")
43
+ except FileNotFoundError:
44
+ logger.warning("⚠️ interview-demo.yaml not found, using mock mode")
45
+ _app = None
46
+ yield
47
+
48
+
49
+ app = FastAPI(
50
+ title="YAMLGraph Interview Demo",
51
+ description="Async interview graph with interrupt support",
52
+ lifespan=lifespan,
53
+ )
54
+
55
+
56
+ class ChatRequest(BaseModel):
57
+ """Request to start or continue chat."""
58
+
59
+ message: str = "start"
60
+
61
+
62
+ class ResumeRequest(BaseModel):
63
+ """Request to resume after interrupt."""
64
+
65
+ answer: str
66
+
67
+
68
+ class ChatResponse(BaseModel):
69
+ """Response from chat endpoint."""
70
+
71
+ status: str
72
+ question: str | None = None
73
+ response: str | None = None
74
+ state: dict[str, Any] | None = None
75
+
76
+
77
+ @app.post("/chat/{thread_id}", response_model=ChatResponse)
78
+ async def chat(thread_id: str, request: ChatRequest) -> ChatResponse:
79
+ """Start or continue a chat session.
80
+
81
+ If graph hits an interrupt, returns status="waiting" with the question.
82
+ Otherwise returns status="complete" with the response.
83
+ """
84
+ if _app is None:
85
+ raise HTTPException(status_code=503, detail="Graph not loaded")
86
+
87
+ config = {"configurable": {"thread_id": thread_id}}
88
+
89
+ try:
90
+ result = await run_graph_async(
91
+ _app,
92
+ initial_state={"input": request.message},
93
+ config=config,
94
+ )
95
+ except Exception as e:
96
+ logger.error(f"Graph execution error: {e}")
97
+ raise HTTPException(status_code=500, detail=str(e))
98
+
99
+ # Check for interrupt
100
+ if "__interrupt__" in result:
101
+ interrupt_value = result["__interrupt__"][0].value
102
+ question = interrupt_value.get("question") or interrupt_value.get(
103
+ "prompt", str(interrupt_value)
104
+ )
105
+ return ChatResponse(
106
+ status="waiting",
107
+ question=question,
108
+ state={k: v for k, v in result.items() if not k.startswith("_")},
109
+ )
110
+
111
+ return ChatResponse(
112
+ status="complete",
113
+ response=result.get("greeting") or result.get("response"),
114
+ state={k: v for k, v in result.items() if not k.startswith("_")},
115
+ )
116
+
117
+
118
+ @app.post("/chat/{thread_id}/resume", response_model=ChatResponse)
119
+ async def resume(thread_id: str, request: ResumeRequest) -> ChatResponse:
120
+ """Resume a paused chat session with user's answer.
121
+
122
+ Uses LangGraph's Command(resume=...) to continue execution.
123
+ """
124
+ if _app is None:
125
+ raise HTTPException(status_code=503, detail="Graph not loaded")
126
+
127
+ config = {"configurable": {"thread_id": thread_id}}
128
+
129
+ try:
130
+ result = await run_graph_async(
131
+ _app,
132
+ initial_state=Command(resume=request.answer),
133
+ config=config,
134
+ )
135
+ except Exception as e:
136
+ logger.error(f"Graph resume error: {e}")
137
+ raise HTTPException(status_code=500, detail=str(e))
138
+
139
+ # Check for another interrupt
140
+ if "__interrupt__" in result:
141
+ interrupt_value = result["__interrupt__"][0].value
142
+ question = interrupt_value.get("question") or interrupt_value.get(
143
+ "prompt", str(interrupt_value)
144
+ )
145
+ return ChatResponse(
146
+ status="waiting",
147
+ question=question,
148
+ state={k: v for k, v in result.items() if not k.startswith("_")},
149
+ )
150
+
151
+ return ChatResponse(
152
+ status="complete",
153
+ response=result.get("greeting") or result.get("response"),
154
+ state={k: v for k, v in result.items() if not k.startswith("_")},
155
+ )
156
+
157
+
158
+ @app.get("/health")
159
+ async def health():
160
+ """Health check endpoint."""
161
+ return {"status": "ok", "graph_loaded": _app is not None}
162
+
163
+
164
+ if __name__ == "__main__":
165
+ import uvicorn
166
+
167
+ uvicorn.run(app, host="0.0.0.0", port=8000)
@@ -0,0 +1 @@
1
+ """NPC Encounter Web API."""
@@ -0,0 +1,100 @@
1
+ """NPC Encounter Web UI - FastAPI Application.
2
+
3
+ HTMX-powered web interface for running NPC encounters.
4
+ Uses YAMLGraph for encounter logic with session persistence.
5
+
6
+ Usage:
7
+ uvicorn examples.npc.api.app:app --reload
8
+
9
+ # Or with custom port
10
+ uvicorn examples.npc.api.app:app --reload --port 8080
11
+ """
12
+
13
+ import logging
14
+ import uuid
15
+ from contextlib import asynccontextmanager
16
+
17
+ from fastapi import FastAPI, Request
18
+ from fastapi.responses import HTMLResponse
19
+ from fastapi.staticfiles import StaticFiles
20
+ from fastapi.templating import Jinja2Templates
21
+
22
+ from examples.npc.api.routes.encounter import router as encounter_router
23
+
24
+ # Configure logging
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
28
+ )
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ @asynccontextmanager
33
+ async def lifespan(app: FastAPI):
34
+ """Application lifecycle handler."""
35
+ logger.info("🚀 Starting NPC Encounter Web UI...")
36
+ logger.info("📍 Templates: examples/npc/api/templates")
37
+ logger.info("📦 Static files: examples/npc/api/static")
38
+ yield
39
+ logger.info("👋 Shutting down NPC Encounter Web UI...")
40
+
41
+
42
+ # Create FastAPI app
43
+ app = FastAPI(
44
+ title="NPC Encounter Web UI",
45
+ description="HTMX-powered web interface for running NPC encounters",
46
+ version="0.1.0",
47
+ lifespan=lifespan,
48
+ )
49
+
50
+ # Templates
51
+ templates = Jinja2Templates(directory="examples/npc/api/templates")
52
+
53
+ # Include routers
54
+ app.include_router(encounter_router)
55
+
56
+ # Mount static files (create directory if needed)
57
+ try:
58
+ app.mount(
59
+ "/static", StaticFiles(directory="examples/npc/api/static"), name="static"
60
+ )
61
+ except RuntimeError:
62
+ # Static directory doesn't exist, that's fine
63
+ logger.warning("⚠️ Static directory not found, skipping static mount")
64
+
65
+ # Mount outputs directory for generated images
66
+ try:
67
+ app.mount("/outputs", StaticFiles(directory="outputs"), name="outputs")
68
+ logger.info("📁 Mounted outputs directory for generated images")
69
+ except RuntimeError:
70
+ logger.warning("⚠️ Outputs directory not found, skipping outputs mount")
71
+
72
+
73
+ @app.get("/", response_class=HTMLResponse)
74
+ async def index(request: Request):
75
+ """Render the main encounter page."""
76
+ # Generate a unique session ID for this encounter
77
+ session_id = str(uuid.uuid4())[:8]
78
+
79
+ return templates.TemplateResponse(
80
+ request=request,
81
+ name="index.html",
82
+ context={"session_id": session_id},
83
+ )
84
+
85
+
86
+ @app.get("/health")
87
+ async def health():
88
+ """Health check endpoint."""
89
+ return {"status": "healthy", "service": "npc-encounter-api"}
90
+
91
+
92
+ if __name__ == "__main__":
93
+ import uvicorn
94
+
95
+ uvicorn.run(
96
+ "examples.npc.api.app:app",
97
+ host="0.0.0.0",
98
+ port=8000,
99
+ reload=True,
100
+ )
@@ -0,0 +1,5 @@
1
+ """API routes for NPC encounters."""
2
+
3
+ from .encounter import router as encounter_router
4
+
5
+ __all__ = ["encounter_router"]