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 */}