From 3432474d29f9577c7a23e04b15ad32f0a1370eef Mon Sep 17 00:00:00 2001
From: aytzey <42713311+aytzey@users.noreply.github.com>
Date: Fri, 23 May 2025 16:45:15 +0300
Subject: [PATCH] Add actor generator wizard
---
docs/api_ref.md | 1 +
lib/hooks/useHomePageLogic.ts | 4 +
pages/index.tsx | 4 +
.../controllers/actor_generator_controller.py | 33 +++
.../models/actor_generate_request.py | 26 ++
.../services/actor_generator_service.py | 230 ++++++++++++++++++
services/actorWizardServiceHooks.ts | 37 +++
views/layout/HeaderView.tsx | 31 +++
8 files changed, 366 insertions(+)
create mode 100644 python_backend/controllers/actor_generator_controller.py
create mode 100644 python_backend/models/actor_generate_request.py
create mode 100644 python_backend/services/actor_generator_service.py
create mode 100644 services/actorWizardServiceHooks.ts
diff --git a/docs/api_ref.md b/docs/api_ref.md
index 68efd53..9404fb2 100644
--- a/docs/api_ref.md
+++ b/docs/api_ref.md
@@ -299,3 +299,4 @@ All responses follow the envelope
| PUT | `/api/actors/{id}?projectPath=
` | Update an actor |
| DELETE | `/api/actors/{id}?projectPath=` | Delete an actor |
| POST | `/api/actors/suggest?projectPath=` | Suggest an actor from description. Body → `{ "description": "..." }` |
+| POST | `/api/actors/generate` | Generate actors from project context. Body → `{ "baseDir": "", "treePaths": ["..."] }` |
diff --git a/lib/hooks/useHomePageLogic.ts b/lib/hooks/useHomePageLogic.ts
index fbb2438..0527c99 100644
--- a/lib/hooks/useHomePageLogic.ts
+++ b/lib/hooks/useHomePageLogic.ts
@@ -19,6 +19,7 @@ import {
import { useExclusionService } from "@/services/exclusionServiceHooks";
import { useTodoService } from "@/services/todoServiceHooks";
import { useAutoSelectService } from "@/services/autoSelectServiceHooks";
+ import { useActorWizardService } from "@/services/actorWizardServiceHooks";
import {
applyExtensionFilter,
@@ -60,6 +61,7 @@ import {
const { fetchGlobalExclusions, fetchLocalExclusions } = useExclusionService();
const { loadTodos } = useTodoService();
const { autoSelect, isSelecting } = useAutoSelectService();
+ const { generateActors, isGenerating } = useActorWizardService();
// --- Refs & Local UI State ---
const treeRef = useRef(null);
@@ -218,6 +220,8 @@ import {
// handleDismissWelcome, // Removed
// toggleDark, // Removed
autoSelect,
+ generateActors,
+ isGeneratingActors: isGenerating,
setShowSettings,
saveApiKey,
setApiKeyDraft,
diff --git a/pages/index.tsx b/pages/index.tsx
index ef6b0fa..f412e19 100644
--- a/pages/index.tsx
+++ b/pages/index.tsx
@@ -47,6 +47,8 @@ export default function Home() {
apiKeyDraft,
handlePathSelected,
autoSelect,
+ generateActors,
+ isGeneratingActors,
setShowSettings,
saveApiKey,
setApiKeyDraft,
@@ -75,7 +77,9 @@ export default function Home() {
setShowSettings(true)}
onAutoSelect={autoSelect}
+ onGenerateActors={generateActors}
isSelecting={isSelecting}
+ isGeneratingActors={isGeneratingActors}
projectPath={projectPath}
/>
diff --git a/python_backend/controllers/actor_generator_controller.py b/python_backend/controllers/actor_generator_controller.py
new file mode 100644
index 0000000..7b6d9c4
--- /dev/null
+++ b/python_backend/controllers/actor_generator_controller.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+import logging
+from flask import Blueprint, request
+
+from models.actor_generate_request import ActorGenerateRequest
+from services.actor_generator_service import ActorGeneratorService
+from utils.response_utils import success_response, error_response
+
+logger = logging.getLogger(__name__)
+
+actor_gen_bp = Blueprint("actor_gen_bp", __name__, url_prefix="/api/actors")
+_service = ActorGeneratorService()
+
+
+@actor_gen_bp.post("/generate")
+def generate_actors_endpoint():
+ payload = request.get_json(silent=True) or {}
+ try:
+ req = ActorGenerateRequest.from_dict(payload)
+ except Exception as exc:
+ return error_response(str(exc), status_code=400)
+
+ try:
+ actors, raw = _service.generate_actors(req)
+ return success_response(data={"actors": actors, "llmRaw": raw})
+ except ActorGeneratorService.ConfigError as exc:
+ return error_response(str(exc), status_code=500)
+ except ActorGeneratorService.UpstreamError as exc:
+ return error_response(str(exc), status_code=502)
+ except Exception as exc: # pragma: no cover
+ logger.exception("Actor generation failed")
+ return error_response(str(exc), status_code=500)
diff --git a/python_backend/models/actor_generate_request.py b/python_backend/models/actor_generate_request.py
new file mode 100644
index 0000000..c282485
--- /dev/null
+++ b/python_backend/models/actor_generate_request.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import List, Any, Optional
+
+@dataclass(slots=True)
+class ActorGenerateRequest:
+ """Validated payload for /api/actors/generate."""
+ treePaths: List[str] = field(default_factory=list)
+ baseDir: Optional[str] = None
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "ActorGenerateRequest":
+ if not isinstance(data, dict):
+ raise ValueError("JSON body must be an object.")
+ tree_paths = data.get("treePaths")
+ base_dir = data.get("baseDir") or data.get("projectPath")
+ if (
+ not isinstance(tree_paths, list)
+ or not all(isinstance(p, str) for p in tree_paths)
+ or not tree_paths
+ ):
+ raise ValueError("'treePaths' must be a non-empty array of strings.")
+ if base_dir is not None and not isinstance(base_dir, str):
+ raise ValueError("'baseDir' must be a string if provided.")
+ return cls(treePaths=[p.strip() for p in tree_paths], baseDir=base_dir)
diff --git a/python_backend/services/actor_generator_service.py b/python_backend/services/actor_generator_service.py
new file mode 100644
index 0000000..35e4965
--- /dev/null
+++ b/python_backend/services/actor_generator_service.py
@@ -0,0 +1,230 @@
+from __future__ import annotations
+
+import os
+import json
+import logging
+import pathlib
+from typing import Any, Dict, List, Optional, Tuple
+
+import httpx
+
+from models.actor_generate_request import ActorGenerateRequest
+from repositories.file_storage import FileStorageRepository
+from services.codemap_service import CodemapService
+
+logger = logging.getLogger(__name__)
+
+
+class ActorGeneratorService:
+ _URL: str = "https://openrouter.ai/api/v1/chat/completions"
+ _DEFAULT_MODEL: str = "meta-llama/llama-4-maverick:free"
+
+ _SCHEMA: Dict[str, Any] = {
+ "name": "actors",
+ "strict": True,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "actors": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "role": {"type": "string"},
+ "permissions": {"type": "array", "items": {"type": "string"}},
+ "goals": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": ["name", "role"],
+ },
+ }
+ },
+ "required": ["actors"],
+ "additionalProperties": False,
+ },
+ }
+
+ _README_MAX_CHARS = 8000
+ _TREE_MAX_LINES = 30000
+ _SUMMARY_MAX_CHARS = 10000
+ _TOKEN_HARD_LIMIT = 45000
+
+ class UpstreamError(RuntimeError): ...
+ class ConfigError(RuntimeError): ...
+
+ def __init__(self) -> None:
+ self.api_key: Optional[str] = os.getenv("OPENROUTER_API_KEY")
+ self.model: str = os.getenv("ACTOR_MODEL", self._DEFAULT_MODEL)
+ if not self.api_key:
+ logger.warning(
+ "ActorGeneratorService started WITHOUT OPENROUTER_API_KEY – actor generation will fail."
+ )
+ self._storage = FileStorageRepository()
+ self._codemap = CodemapService(storage_repo=self._storage)
+
+ # ------------------------------------------------------------------ public
+ def generate_actors(
+ self, request_obj: ActorGenerateRequest, *, timeout: float = 30.0
+ ) -> Tuple[List[Dict[str, Any]], str]:
+ if not self.api_key:
+ raise self.ConfigError("OPENROUTER_API_KEY is not set on the server")
+
+ prompt = self._build_prompt(request_obj)
+ logger.info("▶︎ Actor generation prompt (first 1000 chars)\n%s", prompt[:1000])
+
+ payload: Dict[str, Any] = {
+ "model": self.model,
+ "messages": [
+ {
+ "role": "system",
+ "content": (
+ "You are a strict JSON generator. "
+ "Identify user actors according to the provided guide and project context. "
+ "Return ONLY the JSON matching the schema."
+ ),
+ },
+ {"role": "user", "content": prompt},
+ ],
+ "temperature": 0.3,
+ "max_tokens": 1024,
+ "response_format": {"type": "json_schema", "json_schema": self._SCHEMA},
+ "structured_outputs": True,
+ }
+ headers: Dict[str, str] = {
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ }
+
+ try:
+ with httpx.Client(timeout=timeout) as client:
+ response = client.post(self._URL, json=payload, headers=headers)
+ response.raise_for_status()
+ except httpx.TimeoutException as exc:
+ logger.error("Timeout calling OpenRouter for actor generation: %s", exc)
+ raise self.UpstreamError("OpenRouter request timed out") from exc
+ except httpx.RequestError as exc:
+ logger.error("Request error calling OpenRouter: %s", exc)
+ raise self.UpstreamError(f"Could not connect to OpenRouter: {exc}") from exc
+ except httpx.HTTPStatusError as exc:
+ text = exc.response.text
+ logger.error("HTTP error from OpenRouter (%s): %s", exc.response.status_code, text[:200])
+ raise self.UpstreamError(f"OpenRouter API error ({exc.response.status_code})") from exc
+
+ try:
+ data = response.json()
+ if not isinstance(data, dict) or "choices" not in data or not data["choices"]:
+ raise self.UpstreamError("Invalid response structure from OpenRouter")
+ choice = data["choices"][0]
+ content = choice.get("message", {}).get("content", "").strip()
+ except Exception as exc:
+ logger.error("Failed to parse OpenRouter response: %s", exc)
+ raise self.UpstreamError("Invalid response from OpenRouter") from exc
+
+ try:
+ obj = json.loads(content)
+ actors = obj.get("actors")
+ if not isinstance(actors, list):
+ raise ValueError("Missing 'actors' array")
+ except Exception as exc:
+ logger.error("Failed to decode actors JSON: %s", exc)
+ raise self.UpstreamError("Failed to parse JSON from model") from exc
+
+ for i, actor in enumerate(actors, 1):
+ if isinstance(actor, dict):
+ actor.setdefault("id", i)
+ return actors, content
+
+ # ---------------------------------------------------------------- helpers
+ def _build_prompt(self, req: ActorGenerateRequest) -> str:
+ tree_block = self._tree_text(req.treePaths)
+ graph_block = self._build_graph(req)
+ readme_block = self._read_readme(req.baseDir)
+ guide = (
+ "Follow this step-by-step guide to identify actors:\n"
+ "1. Understand the product context.\n"
+ "2. Identify primary and secondary users or systems.\n"
+ "3. Extract potential roles grouped by function, goal or permissions.\n"
+ "4. Define responsibilities and goals.\n"
+ "5. List key permissions or access rights."
+ )
+ return (
+ f"{guide}\n\n"
+ f"### README\n{readme_block}\n\n"
+ f"### Project Tree\n{tree_block}\n\n"
+ f"### File Graph\n{graph_block}"
+ )
+
+ def _read_readme(self, base_dir: Optional[str]) -> str:
+ if not base_dir:
+ return ""
+ for name in ("README.md", "readme.md", "Readme.md"):
+ path = os.path.join(base_dir, name)
+ if os.path.isfile(path):
+ try:
+ txt = self._storage.read_text(path) or ""
+ txt = txt.strip()[: self._README_MAX_CHARS]
+ if len(txt) < self._README_MAX_CHARS:
+ return txt
+ return txt + "\n... (truncated)"
+ except Exception as exc:
+ logger.warning("Failed to read README %s: %s", path, exc)
+ return ""
+ return ""
+
+ def _tree_text(self, paths: List[str]) -> str:
+ out: List[str] = []
+ for rel in paths[: self._TREE_MAX_LINES]:
+ depth = rel.count("/")
+ out.append(" " * depth + "• " + rel.rsplit("/", 1)[-1])
+ if len(paths) > self._TREE_MAX_LINES:
+ out.append(f"… (+{len(paths) - self._TREE_MAX_LINES} more)")
+ return "\n".join(out)
+
+ def _build_graph(self, req: ActorGenerateRequest) -> str:
+ if not req.baseDir or not os.path.isdir(req.baseDir):
+ return ""
+ blocks: List[str] = []
+ used_tokens = 0
+ for rel in req.treePaths:
+ abs_path = os.path.normpath(os.path.join(req.baseDir, rel))
+ if not os.path.isfile(abs_path):
+ continue
+ summary = self._file_summary(abs_path, rel)
+ est_tokens = len(summary) // 4
+ if used_tokens + est_tokens > self._TOKEN_HARD_LIMIT:
+ break
+ lang = pathlib.Path(abs_path).suffix.lstrip(".") or "txt"
+ blocks.append(
+ f'\n```{lang}\n{summary}\n```\n'
+ )
+ used_tokens += est_tokens
+ return "\n\n".join(blocks)
+
+ def _file_summary(self, abs_path: str, rel_path: str) -> str:
+ try:
+ codemap = self._codemap.extract_codemap(os.path.dirname(abs_path), [os.path.basename(abs_path)])
+ data = codemap.get(os.path.basename(abs_path))
+ except Exception as exc:
+ logger.warning("Codemap extraction failed for %s: %s", rel_path, exc)
+ data = {"error": "Extraction failed"}
+ if data and "error" not in data:
+ parts: List[str] = []
+ cls = data.get("classes")
+ if isinstance(cls, list) and cls:
+ parts.append(f"Classes: {', '.join(cls[:10])}")
+ fns = data.get("functions")
+ if isinstance(fns, list) and fns:
+ parts.append(f"Functions: {', '.join(fns[:15])}")
+ refs = data.get("references")
+ if isinstance(refs, list) and refs:
+ parts.append(f"Refs: {', '.join(refs[:20])}")
+ if parts:
+ s = "; ".join(parts)
+ return s[: self._SUMMARY_MAX_CHARS] if len(s) > self._SUMMARY_MAX_CHARS else s
+ text = self._storage.read_text(abs_path) or ""
+ return self._cheap_summary(text)
+
+ def _cheap_summary(self, text: str, max_len: int = _SUMMARY_MAX_CHARS) -> str:
+ if not text:
+ return ""
+ return " ".join(text.split()[: max_len // 5])
diff --git a/services/actorWizardServiceHooks.ts b/services/actorWizardServiceHooks.ts
new file mode 100644
index 0000000..14cc135
--- /dev/null
+++ b/services/actorWizardServiceHooks.ts
@@ -0,0 +1,37 @@
+import { useCallback, useState } from "react";
+import { fetchApi } from "@/services/apiService";
+import { useProjectStore } from "@/stores/useProjectStore";
+import { useActorStore } from "@/stores/useActorStore";
+import { flattenTree } from "@/lib/fileFilters";
+import { ActorSchema } from "@/types";
+import { z } from "zod";
+import { useAppStore } from "@/stores/useAppStore";
+
+export function useActorWizardService() {
+ const projectStore = useProjectStore;
+ const actorStore = useActorStore;
+ const { setError } = useAppStore();
+ const [isGenerating, setIsGenerating] = useState(false);
+
+ const generateActors = useCallback(async () => {
+ const { projectPath, fileTree } = projectStore.getState();
+ if (!projectPath || isGenerating) return;
+ const treePaths = flattenTree(fileTree);
+ setIsGenerating(true);
+ const res = await fetchApi<{ actors: unknown }>("/api/actors/generate", {
+ method: "POST",
+ body: JSON.stringify({ baseDir: projectPath, treePaths }),
+ });
+ setIsGenerating(false);
+ if (!res) return;
+ const parsed = z.array(ActorSchema).safeParse(res.actors);
+ if (!parsed.success) {
+ console.error("Actor wizard schema validation failed", parsed.error);
+ setError("Received malformed data from server.");
+ return;
+ }
+ actorStore.getState().setActors(parsed.data);
+ }, [isGenerating, projectStore, actorStore, setError]);
+
+ return { generateActors, isGenerating };
+}
diff --git a/views/layout/HeaderView.tsx b/views/layout/HeaderView.tsx
index 092d0dd..dffbac6 100644
--- a/views/layout/HeaderView.tsx
+++ b/views/layout/HeaderView.tsx
@@ -6,6 +6,7 @@ import {
Github,
Zap,
RefreshCw,
+ Users,
Terminal,
ChevronRight,
ExternalLink,
@@ -21,14 +22,18 @@ import {
interface HeaderViewProps {
onShowSettings: () => void;
onAutoSelect: () => void;
+ onGenerateActors: () => void;
isSelecting: boolean;
+ isGeneratingActors: boolean;
projectPath: string;
}
const HeaderView: React.FC = ({
onShowSettings,
onAutoSelect,
+ onGenerateActors,
isSelecting,
+ isGeneratingActors,
projectPath,
}) => {
return (
@@ -86,6 +91,32 @@ const HeaderView: React.FC = ({
+ {/* Actor Wizard */}
+
+
+
+
+
+
+ Generate actors using Llama‑4
+
+
+
+
{/* Settings with hover animation */}