hexdag-forge 0.2.0.dev3__tar.gz → 0.2.0.dev5__tar.gz
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.
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/PKG-INFO +1 -1
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/cli.py +32 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/business_profile.py +1 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/assembler.py +49 -16
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/django_generator.py +37 -2
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/migration_generator.py +45 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/pipeline_generator.py +4 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/system_generator.py +12 -2
- hexdag_forge-0.2.0.dev5/hexdag_forge/generator/template_engine.py +209 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/mcp/server.py +1 -1
- hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/__init__.py +1 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/django_store.py +124 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/entity_store.py +59 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/memory_store.py +95 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/chat_pipeline.yaml.j2 +39 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_base.html.j2 +153 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat.html.j2 +119 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat_agent.py.j2 +97 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat_conversations.html.j2 +27 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat_messages_partial.html.j2 +10 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_dashboard.html.j2 +47 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_entity_detail_partial.html.j2 +43 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_form.html.j2 +5 -5
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_entity_form_modal.html.j2 +13 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_list.html.j2 +13 -7
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_entity_list_partial.html.j2 +15 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_settings.py.j2 +6 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_templatetags.py.j2 +43 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_urls.py.j2 +3 -2
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_views.py.j2 +58 -3
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/pipeline.yaml.j2 +6 -10
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/system.yaml.j2 +9 -1
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/pyproject.toml +1 -1
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/tests/test_generator/test_assembler.py +206 -5
- hexdag_forge-0.2.0.dev5/tests/test_runtime/__init__.py +0 -0
- hexdag_forge-0.2.0.dev5/tests/test_runtime/test_memory_store.py +129 -0
- hexdag_forge-0.2.0.dev3/hexdag_forge/generator/template_engine.py +0 -64
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_base.html.j2 +0 -50
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_chat.html.j2 +0 -43
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_chat_conversations.html.j2 +0 -29
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_chat_messages_partial.html.j2 +0 -8
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_entity_detail_partial.html.j2 +0 -29
- hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_entity_list_partial.html.j2 +0 -11
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/.gitignore +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/LICENSE +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/README.md +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/enums.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/generated_system.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/profile_diff.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/diff.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/entity_generator.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/field_mapping.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/service_generator.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/mcp/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/mcp/__main__.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge.md +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge_entity.md +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge_implement.md +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge_test.md +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_admin.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_apps.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_confirm_delete.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_detail.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_forms.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_logged_out.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_login.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_manage.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_migration.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_model.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_register.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/service.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/state_machines.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/tests/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/tests/test_domain.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/tests/test_generator/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/tests/test_generator/test_diff.py +0 -0
- {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/tests/test_generator/test_migration_generator.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexdag-forge
|
|
3
|
-
Version: 0.2.0.
|
|
3
|
+
Version: 0.2.0.dev5
|
|
4
4
|
Summary: Generate operational systems from prompts — code generator powered by hexDAG
|
|
5
5
|
Project-URL: Homepage, https://hexdag.ai
|
|
6
6
|
Project-URL: Repository, https://github.com/omniviser/hexdag
|
|
@@ -51,6 +51,38 @@ def validate(
|
|
|
51
51
|
typer.echo("All generated files are valid.")
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
@app.command()
|
|
55
|
+
def run(
|
|
56
|
+
output: Annotated[
|
|
57
|
+
Path, typer.Option(help="Path to the generated system directory")
|
|
58
|
+
] = "./generated",
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Load and run a generated system via hexDAG System API."""
|
|
61
|
+
import asyncio
|
|
62
|
+
|
|
63
|
+
system_path = output / "system.yaml"
|
|
64
|
+
if not system_path.exists():
|
|
65
|
+
typer.echo(
|
|
66
|
+
f"No system.yaml found in {output}/ — run 'hexdag-forge generate' first.",
|
|
67
|
+
err=True,
|
|
68
|
+
)
|
|
69
|
+
raise typer.Exit(1)
|
|
70
|
+
|
|
71
|
+
async def _run() -> None:
|
|
72
|
+
from hexdag.kernel import System
|
|
73
|
+
|
|
74
|
+
typer.echo(f"Loading system from {system_path} ...")
|
|
75
|
+
async with System.from_yaml(str(system_path)) as system:
|
|
76
|
+
typer.echo(f"System '{system.config.metadata.get('name', '?')}' ready.")
|
|
77
|
+
typer.echo(f" Mode: {'lifecycle' if system.is_lifecycle else 'DAG'}")
|
|
78
|
+
typer.echo(f" Processes: {', '.join(system.process_names)}")
|
|
79
|
+
typer.echo("System loaded successfully. Use the Python API to interact:")
|
|
80
|
+
typer.echo(" await system.transition(entity_type, entity_id, to_state)")
|
|
81
|
+
typer.echo(" await system.run_process(process_name, input_data)")
|
|
82
|
+
|
|
83
|
+
asyncio.run(_run())
|
|
84
|
+
|
|
85
|
+
|
|
54
86
|
@mcp_app.command("serve")
|
|
55
87
|
def mcp_serve(
|
|
56
88
|
transport: Annotated[str, typer.Option(help="Transport: stdio or sse")] = "stdio",
|
|
@@ -18,22 +18,44 @@ from hexdag_forge.generator.service_generator import generate_services
|
|
|
18
18
|
from hexdag_forge.generator.system_generator import generate_system
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def
|
|
21
|
+
def _derive_output_module(output_dir: Path) -> str:
|
|
22
|
+
"""Derive a Python module name from the output directory.
|
|
23
|
+
|
|
24
|
+
``Path("./generated")`` → ``"generated"``
|
|
25
|
+
``Path("/home/user/my_app")`` → ``"my_app"``
|
|
26
|
+
"""
|
|
27
|
+
name = output_dir.resolve().name
|
|
28
|
+
# Sanitise to valid Python identifier
|
|
29
|
+
import re
|
|
30
|
+
|
|
31
|
+
name = re.sub(r"[^a-zA-Z0-9_]", "_", name).strip("_").lower()
|
|
32
|
+
return name or "generated"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def assemble(
|
|
36
|
+
profile: BusinessProfile,
|
|
37
|
+
output_dir: Path,
|
|
38
|
+
*,
|
|
39
|
+
skip_migration: bool = False,
|
|
40
|
+
) -> GeneratedSystem:
|
|
22
41
|
"""Generate all files from a BusinessProfile and write to output_dir.
|
|
23
42
|
|
|
24
43
|
Args:
|
|
25
44
|
profile: The business profile to generate from.
|
|
26
45
|
output_dir: Directory to write generated files to.
|
|
46
|
+
skip_migration: If True, skip migration generation (used when the
|
|
47
|
+
caller has already generated the migration, e.g. ``forge_migrate``).
|
|
27
48
|
|
|
28
49
|
Returns:
|
|
29
50
|
GeneratedSystem with metadata about what was generated.
|
|
30
51
|
"""
|
|
31
52
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
output_module = profile.output_module or _derive_output_module(output_dir)
|
|
32
54
|
|
|
33
55
|
# Generate all artifacts
|
|
34
56
|
pipelines = generate_pipelines(profile)
|
|
35
57
|
services = generate_services(profile)
|
|
36
|
-
system_yaml = generate_system(profile)
|
|
58
|
+
system_yaml = generate_system(profile, output_module=output_module)
|
|
37
59
|
django_files = generate_django(profile)
|
|
38
60
|
|
|
39
61
|
# Write pipelines
|
|
@@ -42,9 +64,10 @@ def assemble(profile: BusinessProfile, output_dir: Path) -> GeneratedSystem:
|
|
|
42
64
|
for filename, content in pipelines.items():
|
|
43
65
|
(pipelines_dir / filename).write_text(content)
|
|
44
66
|
|
|
45
|
-
# Write services
|
|
67
|
+
# Write services as a proper Python package
|
|
46
68
|
services_dir = output_dir / "services"
|
|
47
69
|
services_dir.mkdir(exist_ok=True)
|
|
70
|
+
(services_dir / "__init__.py").write_text("")
|
|
48
71
|
for filename, content in services.items():
|
|
49
72
|
(services_dir / filename).write_text(content)
|
|
50
73
|
|
|
@@ -67,19 +90,29 @@ def assemble(profile: BusinessProfile, output_dir: Path) -> GeneratedSystem:
|
|
|
67
90
|
migrations_dir.mkdir(exist_ok=True)
|
|
68
91
|
(migrations_dir / "__init__.py").write_text("")
|
|
69
92
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
if not skip_migration:
|
|
94
|
+
from hexdag_forge.generator.migration_generator import generate_migration
|
|
95
|
+
|
|
96
|
+
# Use existing profile.json (from a previous run) as the old state so
|
|
97
|
+
# that diff_profiles produces incremental operations (AddField, etc.)
|
|
98
|
+
# instead of CreateModel for every entity. On the first run the file
|
|
99
|
+
# does not exist yet, so we fall back to an empty profile.
|
|
100
|
+
profile_path = output_dir / "profile.json"
|
|
101
|
+
if profile_path.exists():
|
|
102
|
+
old_profile = BusinessProfile.model_validate_json(profile_path.read_text())
|
|
103
|
+
else:
|
|
104
|
+
old_profile = BusinessProfile(
|
|
105
|
+
business_name=profile.business_name,
|
|
106
|
+
business_type=profile.business_type,
|
|
107
|
+
description=profile.description,
|
|
108
|
+
entities=[],
|
|
109
|
+
app_label=profile.app_label,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
result = generate_migration(old_profile, profile, migrations_dir)
|
|
113
|
+
if result is not None:
|
|
114
|
+
filename, migration_content = result
|
|
115
|
+
(migrations_dir / filename).write_text(migration_content)
|
|
83
116
|
|
|
84
117
|
# Write profile.json for refinement
|
|
85
118
|
(output_dir / "profile.json").write_text(profile.model_dump_json(indent=2))
|
{hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/django_generator.py
RENAMED
|
@@ -4,7 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
|
-
from hexdag_forge.generator.template_engine import
|
|
7
|
+
from hexdag_forge.generator.template_engine import (
|
|
8
|
+
build_badge_map,
|
|
9
|
+
build_fk_fields,
|
|
10
|
+
build_list_columns,
|
|
11
|
+
render_html_template,
|
|
12
|
+
render_template,
|
|
13
|
+
)
|
|
8
14
|
|
|
9
15
|
if TYPE_CHECKING:
|
|
10
16
|
from hexdag_forge.domain.business_profile import BusinessProfile
|
|
@@ -17,8 +23,13 @@ def generate_django(profile: BusinessProfile) -> dict[str, str]:
|
|
|
17
23
|
Dict of relative path -> file content.
|
|
18
24
|
"""
|
|
19
25
|
app = profile.app_label
|
|
26
|
+
badge_colors = build_badge_map(profile)
|
|
20
27
|
files: dict[str, str] = {}
|
|
21
28
|
|
|
29
|
+
# Pre-compute per-entity context
|
|
30
|
+
list_columns_map = {e.name: build_list_columns(e) for e in profile.entities}
|
|
31
|
+
fk_fields_map = {e.name: build_fk_fields(e) for e in profile.entities}
|
|
32
|
+
|
|
22
33
|
# ── Python files (standard Jinja2 delimiters) ──────────
|
|
23
34
|
files[f"{app}/models.py"] = render_template("django_model.py.j2", profile=profile)
|
|
24
35
|
files[f"{app}/apps.py"] = render_template("django_apps.py.j2", profile=profile)
|
|
@@ -29,10 +40,24 @@ def generate_django(profile: BusinessProfile) -> dict[str, str]:
|
|
|
29
40
|
files["state_machines.py"] = render_template("state_machines.py.j2", profile=profile)
|
|
30
41
|
files["views.py"] = render_template("django_views.py.j2", profile=profile)
|
|
31
42
|
files["forms.py"] = render_template("django_forms.py.j2", profile=profile)
|
|
43
|
+
files["chat_agent.py"] = render_template("django_chat_agent.py.j2", profile=profile)
|
|
44
|
+
|
|
45
|
+
# Templatetags
|
|
46
|
+
files[f"{app}/templatetags/__init__.py"] = ""
|
|
47
|
+
files[f"{app}/templatetags/{app}_tags.py"] = render_template(
|
|
48
|
+
"django_templatetags.py.j2", profile=profile, badge_colors=badge_colors
|
|
49
|
+
)
|
|
32
50
|
|
|
33
51
|
# ── HTML templates (alt delimiters — outputs Django template syntax) ──
|
|
34
52
|
files["templates/base.html"] = render_html_template("django_base.html.j2", profile=profile)
|
|
35
53
|
|
|
54
|
+
# Dashboard
|
|
55
|
+
files["templates/dashboard/index.html"] = render_html_template(
|
|
56
|
+
"django_dashboard.html.j2",
|
|
57
|
+
profile=profile,
|
|
58
|
+
list_columns=list_columns_map,
|
|
59
|
+
)
|
|
60
|
+
|
|
36
61
|
# Auth
|
|
37
62
|
files["templates/registration/login.html"] = render_html_template(
|
|
38
63
|
"django_login.html.j2", profile=profile
|
|
@@ -47,7 +72,14 @@ def generate_django(profile: BusinessProfile) -> dict[str, str]:
|
|
|
47
72
|
# Per-entity CRUD templates
|
|
48
73
|
for entity in profile.entities:
|
|
49
74
|
prefix = f"templates/{entity.name}"
|
|
50
|
-
|
|
75
|
+
list_cols = list_columns_map[entity.name]
|
|
76
|
+
fk_flds = fk_fields_map[entity.name]
|
|
77
|
+
ctx = {
|
|
78
|
+
"entity": entity,
|
|
79
|
+
"profile": profile,
|
|
80
|
+
"list_columns": list_cols,
|
|
81
|
+
"fk_fields": fk_flds,
|
|
82
|
+
}
|
|
51
83
|
files[f"{prefix}/list.html"] = render_html_template("django_entity_list.html.j2", **ctx)
|
|
52
84
|
files[f"{prefix}/_list_rows.html"] = render_html_template(
|
|
53
85
|
"django_entity_list_partial.html.j2", **ctx
|
|
@@ -57,6 +89,9 @@ def generate_django(profile: BusinessProfile) -> dict[str, str]:
|
|
|
57
89
|
"django_entity_detail_partial.html.j2", **ctx
|
|
58
90
|
)
|
|
59
91
|
files[f"{prefix}/form.html"] = render_html_template("django_entity_form.html.j2", **ctx)
|
|
92
|
+
files[f"{prefix}/_form_modal.html"] = render_html_template(
|
|
93
|
+
"django_entity_form_modal.html.j2", **ctx
|
|
94
|
+
)
|
|
60
95
|
files[f"{prefix}/confirm_delete.html"] = render_html_template(
|
|
61
96
|
"django_entity_confirm_delete.html.j2", **ctx
|
|
62
97
|
)
|
{hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/migration_generator.py
RENAMED
|
@@ -48,6 +48,11 @@ def generate_migration(
|
|
|
48
48
|
number = _next_migration_number(migrations_dir)
|
|
49
49
|
dependency = _latest_migration_name(migrations_dir)
|
|
50
50
|
operations = _build_operations(diff)
|
|
51
|
+
|
|
52
|
+
# Include chat infrastructure models in the initial migration
|
|
53
|
+
if number == 1:
|
|
54
|
+
operations.extend(_chat_model_operations())
|
|
55
|
+
|
|
51
56
|
has_fk = any("ForeignKey" in op for op in operations)
|
|
52
57
|
|
|
53
58
|
migration_name = f"{number:04d}_auto"
|
|
@@ -247,3 +252,43 @@ def _render_alter_states(sc: StateChange) -> str:
|
|
|
247
252
|
f" ),\n"
|
|
248
253
|
f" )"
|
|
249
254
|
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _chat_model_operations() -> list[str]:
|
|
258
|
+
"""CreateModel operations for the built-in Conversation and Message models."""
|
|
259
|
+
return [
|
|
260
|
+
(
|
|
261
|
+
"migrations.CreateModel(\n"
|
|
262
|
+
' name="Conversation",\n'
|
|
263
|
+
" fields=[\n"
|
|
264
|
+
' ("id", models.BigAutoField(auto_created=True, primary_key=True, '
|
|
265
|
+
'serialize=False, verbose_name="ID")),\n'
|
|
266
|
+
' ("user", models.ForeignKey('
|
|
267
|
+
"on_delete=django.db.models.deletion.CASCADE, "
|
|
268
|
+
'related_name="conversations", to="auth.user")),\n'
|
|
269
|
+
' ("title", models.CharField('
|
|
270
|
+
'default="New Conversation", max_length=255)),\n'
|
|
271
|
+
' ("created_at", models.DateTimeField(auto_now_add=True)),\n'
|
|
272
|
+
' ("updated_at", models.DateTimeField(auto_now=True)),\n'
|
|
273
|
+
" ],\n"
|
|
274
|
+
' options={"ordering": ["-updated_at"]},\n'
|
|
275
|
+
" )"
|
|
276
|
+
),
|
|
277
|
+
(
|
|
278
|
+
"migrations.CreateModel(\n"
|
|
279
|
+
' name="Message",\n'
|
|
280
|
+
" fields=[\n"
|
|
281
|
+
' ("id", models.BigAutoField(auto_created=True, primary_key=True, '
|
|
282
|
+
'serialize=False, verbose_name="ID")),\n'
|
|
283
|
+
' ("conversation", models.ForeignKey('
|
|
284
|
+
"on_delete=django.db.models.deletion.CASCADE, "
|
|
285
|
+
'related_name="messages", to="conversation")),\n'
|
|
286
|
+
' ("sender", models.CharField('
|
|
287
|
+
'choices=[("user", "User"), ("system", "System")], max_length=10)),\n'
|
|
288
|
+
' ("content", models.TextField()),\n'
|
|
289
|
+
' ("created_at", models.DateTimeField(auto_now_add=True)),\n'
|
|
290
|
+
" ],\n"
|
|
291
|
+
' options={"ordering": ["created_at"]},\n'
|
|
292
|
+
" )"
|
|
293
|
+
),
|
|
294
|
+
]
|
{hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/pipeline_generator.py
RENAMED
|
@@ -21,4 +21,8 @@ def generate_pipelines(profile: BusinessProfile) -> dict[str, str]:
|
|
|
21
21
|
filename = f"{entity.name}_lifecycle.yaml"
|
|
22
22
|
content = render_template("pipeline.yaml.j2", entity=entity)
|
|
23
23
|
pipelines[filename] = content
|
|
24
|
+
|
|
25
|
+
# Chat agent pipeline
|
|
26
|
+
pipelines["chat_agent.yaml"] = render_template("chat_pipeline.yaml.j2", profile=profile)
|
|
27
|
+
|
|
24
28
|
return pipelines
|
{hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/system_generator.py
RENAMED
|
@@ -11,11 +11,21 @@ if TYPE_CHECKING:
|
|
|
11
11
|
from hexdag_forge.domain.business_profile import BusinessProfile
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def generate_system(profile: BusinessProfile) -> str:
|
|
14
|
+
def generate_system(profile: BusinessProfile, *, output_module: str = "generated") -> str:
|
|
15
15
|
"""Generate the kind: System YAML manifest.
|
|
16
16
|
|
|
17
17
|
Uses LifecycleRunner (because state_machines is declared).
|
|
18
18
|
Each entity state maps to a process pipeline via on_enter.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
profile: The business profile.
|
|
22
|
+
output_module: Python module name for the generated output directory.
|
|
23
|
+
Used to build absolute import paths for services.
|
|
19
24
|
"""
|
|
20
25
|
terminal_states = generate_terminal_states_map(profile)
|
|
21
|
-
return render_template(
|
|
26
|
+
return render_template(
|
|
27
|
+
"system.yaml.j2",
|
|
28
|
+
profile=profile,
|
|
29
|
+
terminal_states=terminal_states,
|
|
30
|
+
output_module=output_module,
|
|
31
|
+
)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Jinja2 template rendering engine for code generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path # noqa: TC003
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from jinja2 import Environment, FileSystemLoader
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from hexdag_forge.domain.business_profile import BusinessProfile, EntitySpec
|
|
12
|
+
|
|
13
|
+
_TEMPLATE_DIR = Path(__file__).parent.parent / "templates"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── Jinja2 filters ──────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _to_pascal(value: str) -> str:
|
|
20
|
+
"""Convert snake_case to PascalCase: ``freight_brokerage`` → ``FreightBrokerage``."""
|
|
21
|
+
return "".join(word.capitalize() for word in value.split("_"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _humanize(value: str) -> str:
|
|
25
|
+
"""Convert snake_case to human-readable: ``company_name`` → ``Company Name``."""
|
|
26
|
+
return value.replace("_", " ").title()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _status_color(state: str) -> str:
|
|
30
|
+
"""Map a state name to a CSS badge color class using naming heuristics."""
|
|
31
|
+
s = state.upper()
|
|
32
|
+
# Exact matches
|
|
33
|
+
_GREEN = {
|
|
34
|
+
"COMPLETED",
|
|
35
|
+
"DELIVERED",
|
|
36
|
+
"DONE",
|
|
37
|
+
"APPROVED",
|
|
38
|
+
"ACTIVE",
|
|
39
|
+
"PAID",
|
|
40
|
+
"RESOLVED",
|
|
41
|
+
"AVAILABLE",
|
|
42
|
+
"PICKED_UP",
|
|
43
|
+
}
|
|
44
|
+
_RED = {
|
|
45
|
+
"CANCELLED",
|
|
46
|
+
"REJECTED",
|
|
47
|
+
"FAILED",
|
|
48
|
+
"SUSPENDED",
|
|
49
|
+
"BLOCKED",
|
|
50
|
+
"EXPIRED",
|
|
51
|
+
"DELETED",
|
|
52
|
+
"TERMINATED",
|
|
53
|
+
"VOID",
|
|
54
|
+
"RETIRED",
|
|
55
|
+
"CLOSED",
|
|
56
|
+
}
|
|
57
|
+
_BLUE = {
|
|
58
|
+
"DISPATCHED",
|
|
59
|
+
"SENT",
|
|
60
|
+
"ONBOARDING",
|
|
61
|
+
"CONFIRMED",
|
|
62
|
+
"ASSIGNED",
|
|
63
|
+
}
|
|
64
|
+
_PURPLE = {
|
|
65
|
+
"IN_TRANSIT",
|
|
66
|
+
"IN_PROGRESS",
|
|
67
|
+
"PROCESSING",
|
|
68
|
+
"ON_TRIP",
|
|
69
|
+
"IN_USE",
|
|
70
|
+
"COVERED",
|
|
71
|
+
}
|
|
72
|
+
_YELLOW = {
|
|
73
|
+
"SUSPENDED",
|
|
74
|
+
"MAINTENANCE",
|
|
75
|
+
"INVOICED",
|
|
76
|
+
"OVERDUE",
|
|
77
|
+
"OFF_DUTY",
|
|
78
|
+
}
|
|
79
|
+
_GRAY = {
|
|
80
|
+
"DRAFT",
|
|
81
|
+
"PENDING",
|
|
82
|
+
"NEW",
|
|
83
|
+
"POSTED",
|
|
84
|
+
"PROSPECT",
|
|
85
|
+
"CREATED",
|
|
86
|
+
"QUEUED",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if s in _GREEN:
|
|
90
|
+
return "badge-green"
|
|
91
|
+
if s in _RED:
|
|
92
|
+
return "badge-red"
|
|
93
|
+
if s in _PURPLE:
|
|
94
|
+
return "badge-purple"
|
|
95
|
+
if s in _YELLOW:
|
|
96
|
+
return "badge-yellow"
|
|
97
|
+
if s in _BLUE:
|
|
98
|
+
return "badge-blue"
|
|
99
|
+
if s in _GRAY:
|
|
100
|
+
return "badge-gray"
|
|
101
|
+
|
|
102
|
+
# Keyword heuristic fallback
|
|
103
|
+
if any(kw in s for kw in ("COMPLET", "DELIVER", "DONE", "APPROV", "ACTIV", "AVAIL", "PAID")):
|
|
104
|
+
return "badge-green"
|
|
105
|
+
if any(kw in s for kw in ("CANCEL", "REJECT", "FAIL", "TERMINAT", "VOID", "RETIR", "CLOSE")):
|
|
106
|
+
return "badge-red"
|
|
107
|
+
if any(kw in s for kw in ("TRANSIT", "TRIP", "PROGRESS", "PROCESS", "USE")):
|
|
108
|
+
return "badge-purple"
|
|
109
|
+
if any(kw in s for kw in ("SUSPEND", "MAINT", "OVERDUE")):
|
|
110
|
+
return "badge-yellow"
|
|
111
|
+
if any(kw in s for kw in ("DISPATCH", "SENT", "ONBOARD", "REVIEW")):
|
|
112
|
+
return "badge-blue"
|
|
113
|
+
return "badge-gray"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _transition_color(state: str) -> str:
|
|
117
|
+
"""Map a target state to a button CSS class for transition buttons."""
|
|
118
|
+
color = _status_color(state)
|
|
119
|
+
return {
|
|
120
|
+
"badge-green": "btn-success",
|
|
121
|
+
"badge-red": "btn-danger",
|
|
122
|
+
"badge-purple": "btn-primary",
|
|
123
|
+
"badge-blue": "btn-primary",
|
|
124
|
+
"badge-yellow": "btn-warning",
|
|
125
|
+
"badge-gray": "btn-outline",
|
|
126
|
+
}.get(color, "btn-outline")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── Context builders ────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_badge_map(profile: BusinessProfile) -> dict[str, str]:
|
|
133
|
+
"""Build a {state_name: badge_class} map for all states across all entities."""
|
|
134
|
+
result: dict[str, str] = {}
|
|
135
|
+
for entity in profile.entities:
|
|
136
|
+
for state in entity.states:
|
|
137
|
+
result[state] = _status_color(state)
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_list_columns(entity: EntitySpec) -> list[object]:
|
|
142
|
+
"""Select fields suitable for list table display (skip text/json, cap at 5)."""
|
|
143
|
+
return [f for f in entity.fields if f.type not in ("text", "json")][:5]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_fk_fields(entity: EntitySpec) -> list[object]:
|
|
147
|
+
"""Return belongs_to relationship specs for select_related."""
|
|
148
|
+
return [r for r in entity.relationships if r.kind == "belongs_to"]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── Environments ────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _register_filters(env: Environment) -> None:
|
|
155
|
+
"""Register all custom filters on a Jinja2 environment."""
|
|
156
|
+
env.filters["to_pascal"] = _to_pascal
|
|
157
|
+
env.filters["humanize"] = _humanize
|
|
158
|
+
env.filters["status_color"] = _status_color
|
|
159
|
+
env.filters["transition_color"] = _transition_color
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def create_env() -> Environment:
|
|
163
|
+
"""Create a Jinja2 environment configured for Python code generation."""
|
|
164
|
+
env = Environment( # nosec B701 — code generation templates, not HTML
|
|
165
|
+
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
|
|
166
|
+
keep_trailing_newline=True,
|
|
167
|
+
trim_blocks=True,
|
|
168
|
+
lstrip_blocks=True,
|
|
169
|
+
autoescape=False,
|
|
170
|
+
)
|
|
171
|
+
_register_filters(env)
|
|
172
|
+
return env
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def create_html_env() -> Environment:
|
|
176
|
+
"""Create a Jinja2 environment for templates that OUTPUT Django template syntax.
|
|
177
|
+
|
|
178
|
+
Uses alternate delimiters to avoid collision with Django's ``{% %}`` and ``{{ }}``.
|
|
179
|
+
Jinja2 generation uses ``[% %]`` and ``[[ ]]``, Django tags pass through untouched.
|
|
180
|
+
"""
|
|
181
|
+
env = Environment( # nosec B701 — code generation templates, not HTML
|
|
182
|
+
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
|
|
183
|
+
keep_trailing_newline=True,
|
|
184
|
+
trim_blocks=True,
|
|
185
|
+
lstrip_blocks=True,
|
|
186
|
+
autoescape=False,
|
|
187
|
+
block_start_string="[%",
|
|
188
|
+
block_end_string="%]",
|
|
189
|
+
variable_start_string="[[",
|
|
190
|
+
variable_end_string="]]",
|
|
191
|
+
comment_start_string="[#",
|
|
192
|
+
comment_end_string="#]",
|
|
193
|
+
)
|
|
194
|
+
_register_filters(env)
|
|
195
|
+
return env
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def render_template(template_name: str, **context: object) -> str:
|
|
199
|
+
"""Render a Jinja2 template with the given context (standard delimiters)."""
|
|
200
|
+
env = create_env()
|
|
201
|
+
template = env.get_template(template_name)
|
|
202
|
+
return template.render(**context)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def render_html_template(template_name: str, **context: object) -> str:
|
|
206
|
+
"""Render a Jinja2 template that outputs Django template syntax (alt delimiters)."""
|
|
207
|
+
env = create_html_env()
|
|
208
|
+
template = env.get_template(template_name)
|
|
209
|
+
return template.render(**context)
|
|
@@ -117,7 +117,7 @@ async def forge_migrate(new_profile_json: str, output_dir: str = "./generated")
|
|
|
117
117
|
migration_info = {"migration": filename, "operations": content.count("migrations.")}
|
|
118
118
|
|
|
119
119
|
# Regenerate all files with new profile
|
|
120
|
-
gen_result = assemble(new_profile, output)
|
|
120
|
+
gen_result = assemble(new_profile, output, skip_migration=True)
|
|
121
121
|
|
|
122
122
|
return json.dumps(
|
|
123
123
|
{
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Runtime adapters for forge-generated systems."""
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Django ORM implementation of SupportsEntityStore.
|
|
2
|
+
|
|
3
|
+
Uses ``django.apps.registry.apps.get_model()`` for dynamic model lookup,
|
|
4
|
+
matching forge's pattern where entity names map directly to Django model names.
|
|
5
|
+
|
|
6
|
+
Requires Django to be installed and configured (``DJANGO_SETTINGS_MODULE``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DjangoEntityStore:
|
|
15
|
+
"""Django ORM adapter for forge-generated entity models.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
app_label:
|
|
20
|
+
The Django app label (e.g. ``"freight_brokerage"``).
|
|
21
|
+
Must match the app_label used by forge during generation.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, app_label: str) -> None:
|
|
25
|
+
self._app_label = app_label
|
|
26
|
+
|
|
27
|
+
def _get_model(self, entity_type: str) -> Any:
|
|
28
|
+
"""Resolve a Django model class from entity_type name."""
|
|
29
|
+
from django.apps import apps # type: ignore[import-not-found] # noqa: PLC0415
|
|
30
|
+
|
|
31
|
+
return apps.get_model(self._app_label, entity_type)
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _to_dict(obj: Any) -> dict[str, Any]:
|
|
35
|
+
"""Convert a Django model instance to a dict."""
|
|
36
|
+
from django.forms.models import (
|
|
37
|
+
model_to_dict, # type: ignore[import-not-found] # noqa: PLC0415
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
d: dict[str, Any] = model_to_dict(obj)
|
|
41
|
+
d["id"] = str(obj.pk)
|
|
42
|
+
return d
|
|
43
|
+
|
|
44
|
+
async def create(
|
|
45
|
+
self, entity_type: str, data: dict[str, Any], initial_state: str
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Create a new entity record via Django ORM."""
|
|
48
|
+
from asgiref.sync import sync_to_async # type: ignore[import-not-found] # noqa: PLC0415
|
|
49
|
+
|
|
50
|
+
model = self._get_model(entity_type)
|
|
51
|
+
obj = await sync_to_async(model.objects.create)(**data, status=initial_state)
|
|
52
|
+
return self._to_dict(obj)
|
|
53
|
+
|
|
54
|
+
async def get(self, entity_type: str, entity_id: str) -> dict[str, Any]:
|
|
55
|
+
"""Retrieve a single entity by primary key."""
|
|
56
|
+
from asgiref.sync import sync_to_async # type: ignore[import-not-found] # noqa: PLC0415
|
|
57
|
+
|
|
58
|
+
model = self._get_model(entity_type)
|
|
59
|
+
obj = await sync_to_async(model.objects.get)(pk=entity_id)
|
|
60
|
+
return self._to_dict(obj)
|
|
61
|
+
|
|
62
|
+
async def list_all(
|
|
63
|
+
self,
|
|
64
|
+
entity_type: str,
|
|
65
|
+
status: str | None = None,
|
|
66
|
+
limit: int = 50,
|
|
67
|
+
) -> list[dict[str, Any]]:
|
|
68
|
+
"""List entities, optionally filtered by status field."""
|
|
69
|
+
from asgiref.sync import sync_to_async # type: ignore[import-not-found] # noqa: PLC0415
|
|
70
|
+
|
|
71
|
+
model = self._get_model(entity_type)
|
|
72
|
+
qs = model.objects.all()
|
|
73
|
+
if status is not None:
|
|
74
|
+
qs = qs.filter(status=status)
|
|
75
|
+
qs = qs[:limit]
|
|
76
|
+
objects = await sync_to_async(list)(qs)
|
|
77
|
+
return [self._to_dict(obj) for obj in objects]
|
|
78
|
+
|
|
79
|
+
async def update(
|
|
80
|
+
self, entity_type: str, entity_id: str, data: dict[str, Any]
|
|
81
|
+
) -> dict[str, Any]:
|
|
82
|
+
"""Update fields on an existing entity."""
|
|
83
|
+
from asgiref.sync import sync_to_async # type: ignore[import-not-found] # noqa: PLC0415
|
|
84
|
+
|
|
85
|
+
model = self._get_model(entity_type)
|
|
86
|
+
obj = await sync_to_async(model.objects.get)(pk=entity_id)
|
|
87
|
+
for key, value in data.items():
|
|
88
|
+
setattr(obj, key, value)
|
|
89
|
+
await sync_to_async(obj.save)()
|
|
90
|
+
return self._to_dict(obj)
|
|
91
|
+
|
|
92
|
+
async def delete(self, entity_type: str, entity_id: str) -> dict[str, Any]:
|
|
93
|
+
"""Delete an entity by primary key."""
|
|
94
|
+
from asgiref.sync import sync_to_async # type: ignore[import-not-found] # noqa: PLC0415
|
|
95
|
+
|
|
96
|
+
model = self._get_model(entity_type)
|
|
97
|
+
obj = await sync_to_async(model.objects.get)(pk=entity_id)
|
|
98
|
+
result = self._to_dict(obj)
|
|
99
|
+
await sync_to_async(obj.delete)()
|
|
100
|
+
return {"deleted": True, **result}
|
|
101
|
+
|
|
102
|
+
async def transition(
|
|
103
|
+
self,
|
|
104
|
+
entity_type: str,
|
|
105
|
+
entity_id: str,
|
|
106
|
+
to_state: str,
|
|
107
|
+
reason: str = "",
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
"""Transition an entity to a new state by updating the status field."""
|
|
110
|
+
from asgiref.sync import sync_to_async # type: ignore[import-not-found] # noqa: PLC0415
|
|
111
|
+
|
|
112
|
+
model = self._get_model(entity_type)
|
|
113
|
+
obj = await sync_to_async(model.objects.get)(pk=entity_id)
|
|
114
|
+
from_state = obj.status
|
|
115
|
+
obj.status = to_state
|
|
116
|
+
await sync_to_async(obj.save)(update_fields=["status"])
|
|
117
|
+
return {
|
|
118
|
+
"id": str(obj.pk),
|
|
119
|
+
"entity_type": entity_type,
|
|
120
|
+
"from_state": from_state,
|
|
121
|
+
"to_state": to_state,
|
|
122
|
+
"reason": reason,
|
|
123
|
+
"status": to_state,
|
|
124
|
+
}
|