hexdag-forge 0.2.0.dev2__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.
- hexdag_forge/__init__.py +3 -0
- hexdag_forge/cli.py +71 -0
- hexdag_forge/domain/__init__.py +20 -0
- hexdag_forge/domain/business_profile.py +100 -0
- hexdag_forge/domain/enums.py +29 -0
- hexdag_forge/domain/generated_system.py +23 -0
- hexdag_forge/generator/__init__.py +1 -0
- hexdag_forge/generator/assembler.py +121 -0
- hexdag_forge/generator/business_analyzer.py +161 -0
- hexdag_forge/generator/django_generator.py +27 -0
- hexdag_forge/generator/entity_generator.py +18 -0
- hexdag_forge/generator/pipeline_generator.py +24 -0
- hexdag_forge/generator/service_generator.py +55 -0
- hexdag_forge/generator/system_generator.py +21 -0
- hexdag_forge/generator/template_engine.py +27 -0
- hexdag_forge/mcp/__init__.py +0 -0
- hexdag_forge/mcp/__main__.py +5 -0
- hexdag_forge/mcp/server.py +180 -0
- hexdag_forge/skills/forge.md +34 -0
- hexdag_forge/skills/forge_entity.md +25 -0
- hexdag_forge/skills/forge_implement.md +39 -0
- hexdag_forge/skills/forge_test.md +23 -0
- hexdag_forge/templates/django_admin.py.j2 +21 -0
- hexdag_forge/templates/django_manage.py.j2 +19 -0
- hexdag_forge/templates/django_model.py.j2 +62 -0
- hexdag_forge/templates/django_settings.py.j2 +63 -0
- hexdag_forge/templates/django_urls.py.j2 +11 -0
- hexdag_forge/templates/pipeline.yaml.j2 +40 -0
- hexdag_forge/templates/service.py.j2 +97 -0
- hexdag_forge/templates/state_machines.py.j2 +27 -0
- hexdag_forge/templates/system.yaml.j2 +45 -0
- hexdag_forge-0.2.0.dev2.dist-info/METADATA +101 -0
- hexdag_forge-0.2.0.dev2.dist-info/RECORD +36 -0
- hexdag_forge-0.2.0.dev2.dist-info/WHEEL +4 -0
- hexdag_forge-0.2.0.dev2.dist-info/entry_points.txt +2 -0
- hexdag_forge-0.2.0.dev2.dist-info/licenses/LICENSE +17 -0
hexdag_forge/__init__.py
ADDED
hexdag_forge/cli.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""CLI entry point for hexdag-forge — a code generator for operational systems."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path # noqa: TC003 — needed at runtime by typer
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="hexdag-forge",
|
|
12
|
+
help="Generate operational systems from prompts — powered by hexDAG",
|
|
13
|
+
no_args_is_help=True,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
mcp_app = typer.Typer(help="MCP server commands")
|
|
17
|
+
app.add_typer(mcp_app, name="mcp")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def generate(
|
|
22
|
+
description: Annotated[str, typer.Argument(help="Business description")],
|
|
23
|
+
output: Annotated[
|
|
24
|
+
Path, typer.Option(help="Output directory for generated files")
|
|
25
|
+
] = "./generated",
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Generate an operational system from a business description."""
|
|
28
|
+
from hexdag_forge.generator.assembler import assemble
|
|
29
|
+
from hexdag_forge.generator.business_analyzer import create_default_profile
|
|
30
|
+
|
|
31
|
+
profile = create_default_profile(description)
|
|
32
|
+
result = assemble(profile, output)
|
|
33
|
+
typer.echo(f"Generated system in {output}/")
|
|
34
|
+
typer.echo(f" Entities: {', '.join(e.name for e in result.profile.entities)}")
|
|
35
|
+
typer.echo(f" Pipelines: {len(result.pipelines)} files")
|
|
36
|
+
typer.echo(f" Services: {len(result.services)} files")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def validate(
|
|
41
|
+
output: Annotated[Path, typer.Argument(help="Path to generated system")],
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Validate generated files (YAML pipelines, Python syntax, system manifest)."""
|
|
44
|
+
from hexdag_forge.generator.assembler import validate_system
|
|
45
|
+
|
|
46
|
+
errors = validate_system(output_dir=output)
|
|
47
|
+
if errors:
|
|
48
|
+
for err in errors:
|
|
49
|
+
typer.echo(f" ERROR: {err}", err=True)
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
typer.echo("All generated files are valid.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@mcp_app.command("serve")
|
|
55
|
+
def mcp_serve(
|
|
56
|
+
transport: Annotated[str, typer.Option(help="Transport: stdio or sse")] = "stdio",
|
|
57
|
+
port: Annotated[int, typer.Option(help="Port for SSE transport")] = 3001,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Start the forge MCP server for Claude Code integration."""
|
|
60
|
+
from hexdag_forge.mcp.server import run_server
|
|
61
|
+
|
|
62
|
+
run_server(transport=transport, port=port)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main() -> None:
|
|
66
|
+
"""Entry point."""
|
|
67
|
+
app()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Domain models for hexdag-forge."""
|
|
2
|
+
|
|
3
|
+
from hexdag_forge.domain.business_profile import (
|
|
4
|
+
BusinessProfile,
|
|
5
|
+
EntitySpec,
|
|
6
|
+
FieldSpec,
|
|
7
|
+
RelationshipSpec,
|
|
8
|
+
)
|
|
9
|
+
from hexdag_forge.domain.enums import FieldType, RelationshipKind
|
|
10
|
+
from hexdag_forge.domain.generated_system import GeneratedSystem
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BusinessProfile",
|
|
14
|
+
"EntitySpec",
|
|
15
|
+
"FieldSpec",
|
|
16
|
+
"FieldType",
|
|
17
|
+
"GeneratedSystem",
|
|
18
|
+
"RelationshipKind",
|
|
19
|
+
"RelationshipSpec",
|
|
20
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Domain models for business profiles and entity specifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, field_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FieldSpec(BaseModel):
|
|
11
|
+
"""Specification for a single field on an entity."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
type: str = "str"
|
|
15
|
+
required: bool = True
|
|
16
|
+
default: Any = None
|
|
17
|
+
description: str = ""
|
|
18
|
+
|
|
19
|
+
@field_validator("name")
|
|
20
|
+
@classmethod
|
|
21
|
+
def validate_name(cls, v: str) -> str:
|
|
22
|
+
if not v.isidentifier():
|
|
23
|
+
msg = f"Field name must be a valid Python identifier, got: {v!r}"
|
|
24
|
+
raise ValueError(msg)
|
|
25
|
+
return v
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RelationshipSpec(BaseModel):
|
|
29
|
+
"""Specification for a relationship between entities."""
|
|
30
|
+
|
|
31
|
+
target: str
|
|
32
|
+
kind: str = "belongs_to"
|
|
33
|
+
field_name: str
|
|
34
|
+
description: str = ""
|
|
35
|
+
|
|
36
|
+
@field_validator("kind")
|
|
37
|
+
@classmethod
|
|
38
|
+
def validate_kind(cls, v: str) -> str:
|
|
39
|
+
valid = {"belongs_to", "has_many", "has_one"}
|
|
40
|
+
if v not in valid:
|
|
41
|
+
msg = f"Relationship kind must be one of {valid}, got: {v!r}"
|
|
42
|
+
raise ValueError(msg)
|
|
43
|
+
return v
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EntitySpec(BaseModel):
|
|
47
|
+
"""Specification for an entity type in the generated system."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
display_name: str
|
|
51
|
+
fields: list[FieldSpec]
|
|
52
|
+
relationships: list[RelationshipSpec] = []
|
|
53
|
+
states: list[str]
|
|
54
|
+
initial_state: str
|
|
55
|
+
transitions: dict[str, list[str]]
|
|
56
|
+
description: str = ""
|
|
57
|
+
|
|
58
|
+
@field_validator("name")
|
|
59
|
+
@classmethod
|
|
60
|
+
def validate_name(cls, v: str) -> str:
|
|
61
|
+
if not v.isidentifier():
|
|
62
|
+
msg = f"Entity name must be a valid Python identifier, got: {v!r}"
|
|
63
|
+
raise ValueError(msg)
|
|
64
|
+
return v
|
|
65
|
+
|
|
66
|
+
@field_validator("initial_state")
|
|
67
|
+
@classmethod
|
|
68
|
+
def validate_initial_state(cls, v: str, info: Any) -> str:
|
|
69
|
+
states = info.data.get("states", [])
|
|
70
|
+
if states and v not in states:
|
|
71
|
+
msg = f"Initial state {v!r} must be one of the declared states: {states}"
|
|
72
|
+
raise ValueError(msg)
|
|
73
|
+
return v
|
|
74
|
+
|
|
75
|
+
@field_validator("transitions")
|
|
76
|
+
@classmethod
|
|
77
|
+
def validate_transitions(cls, v: dict[str, list[str]], info: Any) -> dict[str, list[str]]:
|
|
78
|
+
states = set(info.data.get("states", []))
|
|
79
|
+
if not states:
|
|
80
|
+
return v
|
|
81
|
+
for from_state, to_states in v.items():
|
|
82
|
+
if from_state not in states:
|
|
83
|
+
msg = f"Transition source state {from_state!r} not in declared states"
|
|
84
|
+
raise ValueError(msg)
|
|
85
|
+
for to_state in to_states:
|
|
86
|
+
if to_state not in states:
|
|
87
|
+
msg = f"Transition target state {to_state!r} not in declared states"
|
|
88
|
+
raise ValueError(msg)
|
|
89
|
+
return v
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BusinessProfile(BaseModel):
|
|
93
|
+
"""Complete business profile extracted from a natural language description."""
|
|
94
|
+
|
|
95
|
+
business_name: str
|
|
96
|
+
business_type: str
|
|
97
|
+
description: str
|
|
98
|
+
entities: list[EntitySpec]
|
|
99
|
+
workflows: list[str] = []
|
|
100
|
+
modules: list[str] = []
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Enums for hexdag-forge domain models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FieldType(StrEnum):
|
|
9
|
+
"""Supported field types for entity fields."""
|
|
10
|
+
|
|
11
|
+
STR = "str"
|
|
12
|
+
INT = "int"
|
|
13
|
+
FLOAT = "float"
|
|
14
|
+
DECIMAL = "Decimal"
|
|
15
|
+
BOOL = "bool"
|
|
16
|
+
DATETIME = "datetime"
|
|
17
|
+
DATE = "date"
|
|
18
|
+
TEXT = "text"
|
|
19
|
+
EMAIL = "email"
|
|
20
|
+
URL = "url"
|
|
21
|
+
JSON = "json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RelationshipKind(StrEnum):
|
|
25
|
+
"""Types of entity relationships."""
|
|
26
|
+
|
|
27
|
+
BELONGS_TO = "belongs_to"
|
|
28
|
+
HAS_MANY = "has_many"
|
|
29
|
+
HAS_ONE = "has_one"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Domain model for a fully generated operational system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from hexdag_forge.domain.business_profile import BusinessProfile # noqa: TC001
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GeneratedSystem(BaseModel):
|
|
11
|
+
"""The complete output artifact of the forge generation process.
|
|
12
|
+
|
|
13
|
+
Contains the business profile that was analyzed, plus all generated
|
|
14
|
+
artifacts: YAML pipelines, Python service source code, Django model
|
|
15
|
+
source (for export), state machine configs, and the system manifest.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
profile: BusinessProfile
|
|
19
|
+
pipelines: dict[str, str] = {}
|
|
20
|
+
services: dict[str, str] = {}
|
|
21
|
+
models_source: dict[str, str] = {}
|
|
22
|
+
state_machines: dict[str, dict] = {}
|
|
23
|
+
system_yaml: str = ""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""hexdag-forge generator engine — transforms BusinessProfile into runnable code."""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Assembler — orchestrates all generators and writes output files.
|
|
2
|
+
|
|
3
|
+
This is the main entry point for code generation. It takes a BusinessProfile
|
|
4
|
+
(from LLM analysis or manual construction) and produces a complete runnable
|
|
5
|
+
system in the output directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from hexdag_forge.domain.business_profile import BusinessProfile
|
|
14
|
+
from hexdag_forge.domain.generated_system import GeneratedSystem
|
|
15
|
+
from hexdag_forge.generator.django_generator import generate_django
|
|
16
|
+
from hexdag_forge.generator.pipeline_generator import generate_pipelines
|
|
17
|
+
from hexdag_forge.generator.service_generator import generate_services
|
|
18
|
+
from hexdag_forge.generator.system_generator import generate_system
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def assemble(profile: BusinessProfile, output_dir: Path) -> GeneratedSystem:
|
|
22
|
+
"""Generate all files from a BusinessProfile and write to output_dir.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
profile: The business profile to generate from.
|
|
26
|
+
output_dir: Directory to write generated files to.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
GeneratedSystem with metadata about what was generated.
|
|
30
|
+
"""
|
|
31
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
# Generate all artifacts
|
|
34
|
+
pipelines = generate_pipelines(profile)
|
|
35
|
+
services = generate_services(profile)
|
|
36
|
+
system_yaml = generate_system(profile)
|
|
37
|
+
django_files = generate_django(profile)
|
|
38
|
+
|
|
39
|
+
# Write pipelines
|
|
40
|
+
pipelines_dir = output_dir / "pipelines"
|
|
41
|
+
pipelines_dir.mkdir(exist_ok=True)
|
|
42
|
+
for filename, content in pipelines.items():
|
|
43
|
+
(pipelines_dir / filename).write_text(content)
|
|
44
|
+
|
|
45
|
+
# Write services
|
|
46
|
+
services_dir = output_dir / "services"
|
|
47
|
+
services_dir.mkdir(exist_ok=True)
|
|
48
|
+
for filename, content in services.items():
|
|
49
|
+
(services_dir / filename).write_text(content)
|
|
50
|
+
|
|
51
|
+
# Write system manifest
|
|
52
|
+
(output_dir / "system.yaml").write_text(system_yaml)
|
|
53
|
+
|
|
54
|
+
# Write Django files
|
|
55
|
+
for rel_path, content in django_files.items():
|
|
56
|
+
file_path = output_dir / rel_path
|
|
57
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
file_path.write_text(content)
|
|
59
|
+
|
|
60
|
+
# Write models/__init__.py
|
|
61
|
+
(output_dir / "models" / "__init__.py").write_text("")
|
|
62
|
+
|
|
63
|
+
# Write profile.json for refinement
|
|
64
|
+
(output_dir / "profile.json").write_text(profile.model_dump_json(indent=2))
|
|
65
|
+
|
|
66
|
+
return GeneratedSystem(
|
|
67
|
+
profile=profile,
|
|
68
|
+
pipelines=pipelines,
|
|
69
|
+
services=services,
|
|
70
|
+
models_source={"models/models.py": django_files["models/models.py"]},
|
|
71
|
+
state_machines={e.name: e.transitions for e in profile.entities},
|
|
72
|
+
system_yaml=system_yaml,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_system(output_dir: Path) -> list[str]:
|
|
77
|
+
"""Validate all generated files in an output directory.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
List of error messages. Empty list means valid.
|
|
81
|
+
"""
|
|
82
|
+
errors: list[str] = []
|
|
83
|
+
output_dir = Path(output_dir)
|
|
84
|
+
|
|
85
|
+
# Check required files exist
|
|
86
|
+
required = ["system.yaml", "profile.json", "settings.py", "manage.py"]
|
|
87
|
+
errors.extend(f"Missing required file: {f}" for f in required if not (output_dir / f).exists())
|
|
88
|
+
|
|
89
|
+
# Check pipelines directory
|
|
90
|
+
pipelines_dir = output_dir / "pipelines"
|
|
91
|
+
if not pipelines_dir.exists():
|
|
92
|
+
errors.append("Missing pipelines/ directory")
|
|
93
|
+
elif not list(pipelines_dir.glob("*.yaml")):
|
|
94
|
+
errors.append("No YAML files in pipelines/")
|
|
95
|
+
|
|
96
|
+
# Check services directory
|
|
97
|
+
services_dir = output_dir / "services"
|
|
98
|
+
if not services_dir.exists():
|
|
99
|
+
errors.append("Missing services/ directory")
|
|
100
|
+
elif not list(services_dir.glob("*.py")):
|
|
101
|
+
errors.append("No Python files in services/")
|
|
102
|
+
|
|
103
|
+
# Validate Python syntax
|
|
104
|
+
for py_file in output_dir.rglob("*.py"):
|
|
105
|
+
try:
|
|
106
|
+
import ast
|
|
107
|
+
|
|
108
|
+
ast.parse(py_file.read_text())
|
|
109
|
+
except SyntaxError as e:
|
|
110
|
+
errors.append(f"Syntax error in {py_file.relative_to(output_dir)}: {e}")
|
|
111
|
+
|
|
112
|
+
# Validate profile.json
|
|
113
|
+
profile_path = output_dir / "profile.json"
|
|
114
|
+
if profile_path.exists():
|
|
115
|
+
try:
|
|
116
|
+
profile_data = json.loads(profile_path.read_text())
|
|
117
|
+
BusinessProfile.model_validate(profile_data)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
errors.append(f"Invalid profile.json: {e}")
|
|
120
|
+
|
|
121
|
+
return errors
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""LLM-powered business analysis — prompt → BusinessProfile.
|
|
2
|
+
|
|
3
|
+
Uses hexDAG's LLM port to extract entities, fields, states,
|
|
4
|
+
transitions, and relationships from a natural language description.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from hexdag_forge.domain.business_profile import BusinessProfile, EntitySpec, FieldSpec
|
|
13
|
+
|
|
14
|
+
# System prompt for the LLM
|
|
15
|
+
_SYSTEM_PROMPT = """You are a business systems architect. Given a business description,
|
|
16
|
+
extract the operational entities, their fields, state machines, and relationships.
|
|
17
|
+
|
|
18
|
+
Return a JSON object with this exact structure:
|
|
19
|
+
{
|
|
20
|
+
"business_name": "short name",
|
|
21
|
+
"business_type": "freight_brokerage|retail|saas|pharma_sales"
|
|
22
|
+
"|manufacturing|services|logistics|general",
|
|
23
|
+
"entities": [
|
|
24
|
+
{
|
|
25
|
+
"name": "snake_case_name",
|
|
26
|
+
"display_name": "Human Name",
|
|
27
|
+
"description": "what this entity represents",
|
|
28
|
+
"fields": [
|
|
29
|
+
{"name": "field_name",
|
|
30
|
+
"type": "str|int|float|Decimal|bool|datetime|date|text|email|url",
|
|
31
|
+
"required": true, "description": "..."}
|
|
32
|
+
],
|
|
33
|
+
"relationships": [
|
|
34
|
+
{"target": "other_entity_name",
|
|
35
|
+
"kind": "belongs_to|has_many|has_one",
|
|
36
|
+
"field_name": "fk_field_name"}
|
|
37
|
+
],
|
|
38
|
+
"states": ["STATE_A", "STATE_B", "STATE_C"],
|
|
39
|
+
"initial_state": "STATE_A",
|
|
40
|
+
"transitions": {
|
|
41
|
+
"STATE_A": ["STATE_B", "STATE_C"],
|
|
42
|
+
"STATE_B": ["STATE_C"]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"workflows": ["short description of a business process that needs custom logic"],
|
|
47
|
+
"modules": ["what modules this system covers"]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
Rules:
|
|
51
|
+
- Entity names must be valid Python identifiers (snake_case)
|
|
52
|
+
- Every entity must have at least 2 states and a valid initial_state
|
|
53
|
+
- Transitions must only reference declared states
|
|
54
|
+
- Include at least id-like fields and status-relevant fields
|
|
55
|
+
- workflows should describe domain-specific logic that needs custom code (not CRUD)
|
|
56
|
+
- Be thorough but practical — extract the entities that actually matter for operations"""
|
|
57
|
+
|
|
58
|
+
_REFINE_PROMPT = """You are refining an existing business profile. The current profile is:
|
|
59
|
+
|
|
60
|
+
{current_profile}
|
|
61
|
+
|
|
62
|
+
The user wants to make this change:
|
|
63
|
+
{refinement}
|
|
64
|
+
|
|
65
|
+
Return the COMPLETE updated profile JSON (same structure as before), incorporating the change.
|
|
66
|
+
Only modify what the user asked for. Keep everything else the same."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def analyze_business(description: str, llm_adapter: Any) -> BusinessProfile:
|
|
70
|
+
"""Analyze a business description and extract a BusinessProfile.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
description: Natural language business description.
|
|
74
|
+
llm_adapter: Any hexDAG LLM adapter (OpenAI, Anthropic, mock, etc.)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Validated BusinessProfile.
|
|
78
|
+
"""
|
|
79
|
+
messages = [
|
|
80
|
+
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
81
|
+
{"role": "user", "content": description},
|
|
82
|
+
]
|
|
83
|
+
response = await llm_adapter.aresponse(messages)
|
|
84
|
+
profile_data = _parse_json_response(response)
|
|
85
|
+
profile_data["description"] = description
|
|
86
|
+
return BusinessProfile.model_validate(profile_data)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def refine_profile(
|
|
90
|
+
profile: BusinessProfile,
|
|
91
|
+
refinement: str,
|
|
92
|
+
llm_adapter: Any,
|
|
93
|
+
) -> BusinessProfile:
|
|
94
|
+
"""Refine an existing BusinessProfile with additional instructions.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
profile: Current BusinessProfile.
|
|
98
|
+
refinement: What to change.
|
|
99
|
+
llm_adapter: Any hexDAG LLM adapter.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Updated BusinessProfile.
|
|
103
|
+
"""
|
|
104
|
+
current_json = profile.model_dump_json(indent=2)
|
|
105
|
+
prompt = _REFINE_PROMPT.format(current_profile=current_json, refinement=refinement)
|
|
106
|
+
|
|
107
|
+
messages = [
|
|
108
|
+
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
109
|
+
{"role": "user", "content": prompt},
|
|
110
|
+
]
|
|
111
|
+
response = await llm_adapter.aresponse(messages)
|
|
112
|
+
profile_data = _parse_json_response(response)
|
|
113
|
+
profile_data.setdefault("description", profile.description)
|
|
114
|
+
return BusinessProfile.model_validate(profile_data)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def create_default_profile(description: str) -> BusinessProfile:
|
|
118
|
+
"""Create a minimal default profile when no LLM is available.
|
|
119
|
+
|
|
120
|
+
Useful for testing or offline mode.
|
|
121
|
+
"""
|
|
122
|
+
return BusinessProfile(
|
|
123
|
+
business_name="My Business",
|
|
124
|
+
business_type="general",
|
|
125
|
+
description=description,
|
|
126
|
+
entities=[
|
|
127
|
+
EntitySpec(
|
|
128
|
+
name="item",
|
|
129
|
+
display_name="Item",
|
|
130
|
+
fields=[
|
|
131
|
+
FieldSpec(name="name", type="str", required=True),
|
|
132
|
+
FieldSpec(name="description", type="text", required=False),
|
|
133
|
+
],
|
|
134
|
+
states=["DRAFT", "ACTIVE", "COMPLETED", "ARCHIVED"],
|
|
135
|
+
initial_state="DRAFT",
|
|
136
|
+
transitions={
|
|
137
|
+
"DRAFT": ["ACTIVE", "ARCHIVED"],
|
|
138
|
+
"ACTIVE": ["COMPLETED", "ARCHIVED"],
|
|
139
|
+
"COMPLETED": ["ARCHIVED"],
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
],
|
|
143
|
+
workflows=[],
|
|
144
|
+
modules=["general"],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_json_response(response: str) -> dict:
|
|
149
|
+
"""Extract JSON from LLM response, handling markdown code blocks."""
|
|
150
|
+
text = response.strip()
|
|
151
|
+
# Handle ```json ... ``` blocks
|
|
152
|
+
if "```" in text:
|
|
153
|
+
start = text.find("```")
|
|
154
|
+
end = text.rfind("```")
|
|
155
|
+
if start != end:
|
|
156
|
+
block = text[start:end]
|
|
157
|
+
# Remove ```json or ``` prefix
|
|
158
|
+
first_newline = block.find("\n")
|
|
159
|
+
if first_newline != -1:
|
|
160
|
+
text = block[first_newline + 1 :]
|
|
161
|
+
return json.loads(text)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Generate Django project files from a BusinessProfile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from hexdag_forge.generator.template_engine import render_template
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from hexdag_forge.domain.business_profile import BusinessProfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_django(profile: BusinessProfile) -> dict[str, str]:
|
|
14
|
+
"""Generate Django models, settings, urls, admin, and manage.py.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Dict of relative path -> file content.
|
|
18
|
+
"""
|
|
19
|
+
files: dict[str, str] = {
|
|
20
|
+
"models/models.py": render_template("django_model.py.j2", profile=profile),
|
|
21
|
+
"settings.py": render_template("django_settings.py.j2", profile=profile),
|
|
22
|
+
"urls.py": render_template("django_urls.py.j2", profile=profile),
|
|
23
|
+
"admin.py": render_template("django_admin.py.j2", profile=profile),
|
|
24
|
+
"manage.py": render_template("django_manage.py.j2", profile=profile),
|
|
25
|
+
"state_machines.py": render_template("state_machines.py.j2", profile=profile),
|
|
26
|
+
}
|
|
27
|
+
return files
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Generate StateMachineConfig definitions from a BusinessProfile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from hexdag_forge.domain.business_profile import BusinessProfile, EntitySpec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def detect_terminal_states(entity: EntitySpec) -> list[str]:
|
|
12
|
+
"""Detect terminal states — states with no outgoing transitions."""
|
|
13
|
+
return [s for s in entity.states if s not in entity.transitions or not entity.transitions[s]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_terminal_states_map(profile: BusinessProfile) -> dict[str, list[str]]:
|
|
17
|
+
"""Return {entity_name: [terminal_states]} for all entities."""
|
|
18
|
+
return {entity.name: detect_terminal_states(entity) for entity in profile.entities}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Generate YAML pipeline files from a BusinessProfile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from hexdag_forge.generator.template_engine import render_template
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from hexdag_forge.domain.business_profile import BusinessProfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_pipelines(profile: BusinessProfile) -> dict[str, str]:
|
|
14
|
+
"""Generate a lifecycle pipeline YAML file per entity.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Dict of filename -> YAML content.
|
|
18
|
+
"""
|
|
19
|
+
pipelines: dict[str, str] = {}
|
|
20
|
+
for entity in profile.entities:
|
|
21
|
+
filename = f"{entity.name}_lifecycle.yaml"
|
|
22
|
+
content = render_template("pipeline.yaml.j2", entity=entity)
|
|
23
|
+
pipelines[filename] = content
|
|
24
|
+
return pipelines
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Generate Python service files from a BusinessProfile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from hexdag_forge.generator.template_engine import render_template
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from hexdag_forge.domain.business_profile import BusinessProfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_services(profile: BusinessProfile) -> dict[str, str]:
|
|
14
|
+
"""Generate a hexDAG Service class per entity.
|
|
15
|
+
|
|
16
|
+
Each service has:
|
|
17
|
+
- Fully implemented CRUD methods (@tool/@step)
|
|
18
|
+
- Domain-specific stub methods (NotImplementedError) from workflow descriptions
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Dict of filename -> Python source code.
|
|
22
|
+
"""
|
|
23
|
+
services: dict[str, str] = {}
|
|
24
|
+
|
|
25
|
+
# Associate workflows with entities by keyword matching
|
|
26
|
+
entity_workflows = _match_workflows_to_entities(profile)
|
|
27
|
+
|
|
28
|
+
for entity in profile.entities:
|
|
29
|
+
filename = f"{entity.name}_service.py"
|
|
30
|
+
workflows = entity_workflows.get(entity.name, [])
|
|
31
|
+
content = render_template("service.py.j2", entity=entity, workflows=workflows)
|
|
32
|
+
services[filename] = content
|
|
33
|
+
|
|
34
|
+
return services
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _match_workflows_to_entities(profile: BusinessProfile) -> dict[str, list[str]]:
|
|
38
|
+
"""Simple keyword matching: assign workflows to entities they mention."""
|
|
39
|
+
result: dict[str, list[str]] = {e.name: [] for e in profile.entities}
|
|
40
|
+
|
|
41
|
+
for workflow in profile.workflows:
|
|
42
|
+
workflow_lower = workflow.lower()
|
|
43
|
+
matched = False
|
|
44
|
+
for entity in profile.entities:
|
|
45
|
+
if (
|
|
46
|
+
entity.name.lower() in workflow_lower
|
|
47
|
+
or entity.display_name.lower() in workflow_lower
|
|
48
|
+
):
|
|
49
|
+
result[entity.name].append(workflow)
|
|
50
|
+
matched = True
|
|
51
|
+
# If no entity matched, assign to first entity as a catch-all
|
|
52
|
+
if not matched and profile.entities:
|
|
53
|
+
result[profile.entities[0].name].append(workflow)
|
|
54
|
+
|
|
55
|
+
return result
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Generate kind: System YAML manifest from a BusinessProfile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from hexdag_forge.generator.entity_generator import generate_terminal_states_map
|
|
8
|
+
from hexdag_forge.generator.template_engine import render_template
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from hexdag_forge.domain.business_profile import BusinessProfile
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def generate_system(profile: BusinessProfile) -> str:
|
|
15
|
+
"""Generate the kind: System YAML manifest.
|
|
16
|
+
|
|
17
|
+
Uses LifecycleRunner (because state_machines is declared).
|
|
18
|
+
Each entity state maps to a process pipeline via on_enter.
|
|
19
|
+
"""
|
|
20
|
+
terminal_states = generate_terminal_states_map(profile)
|
|
21
|
+
return render_template("system.yaml.j2", profile=profile, terminal_states=terminal_states)
|