ruslanmv commited on
Commit
1dd3e68
Β·
verified Β·
1 Parent(s): 401eeb8

Add LLM settings modal + Ollama/OllaBridge/OpenAI support

Browse files
app/__pycache__/inference.cpython-311.pyc CHANGED
Binary files a/app/__pycache__/inference.cpython-311.pyc and b/app/__pycache__/inference.cpython-311.pyc differ
 
app/__pycache__/main.cpython-311.pyc CHANGED
Binary files a/app/__pycache__/main.cpython-311.pyc and b/app/__pycache__/main.cpython-311.pyc differ
 
app/inference.py CHANGED
@@ -1,41 +1,139 @@
1
- """OllaBridge / OpenAI-compatible inference client."""
2
- import os
 
 
 
 
 
 
 
 
 
 
3
  import json
 
4
  import re
5
- import requests
6
  from typing import Optional
7
 
8
- DEFAULT_OLLABRIDGE_URL = "http://localhost:8000"
9
- DEFAULT_MODEL = "qwen2.5:1.5b"
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
- class InferenceClient:
13
- """Calls OpenAI-compatible /v1/chat/completions endpoint."""
 
14
 
15
  def __init__(self):
16
- self.base_url = os.environ.get("OLLABRIDGE_BASE_URL", DEFAULT_OLLABRIDGE_URL).rstrip("/")
17
- self.model = os.environ.get("OLLABRIDGE_MODEL", DEFAULT_MODEL)
18
- self.api_key = os.environ.get("OLLABRIDGE_API_KEY", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  @property
21
  def available(self) -> bool:
22
- """Check if inference endpoint is reachable."""
23
  try:
24
- headers = {}
25
- if self.api_key:
26
- headers["Authorization"] = f"Bearer {self.api_key}"
27
- resp = requests.get(f"{self.base_url}/v1/models", headers=headers, timeout=3)
 
28
  return resp.status_code == 200
29
  except Exception:
30
  return False
31
 
32
  def list_models(self) -> list[str]:
33
- """Get available models from the endpoint."""
34
  try:
35
- headers = {}
36
- if self.api_key:
37
- headers["Authorization"] = f"Bearer {self.api_key}"
38
- resp = requests.get(f"{self.base_url}/v1/models", headers=headers, timeout=5)
 
39
  if resp.status_code == 200:
40
  data = resp.json()
41
  return [m.get("id", "") for m in data.get("data", [])]
@@ -43,27 +141,25 @@ class InferenceClient:
43
  pass
44
  return []
45
 
46
- def generate(self, prompt: str, system: str = "", temperature: float = 0.7, max_tokens: int = 4096) -> str:
47
- """Generate completion using chat/completions endpoint."""
48
- headers = {"Content-Type": "application/json"}
49
- if self.api_key:
50
- headers["Authorization"] = f"Bearer {self.api_key}"
51
-
52
  messages = []
53
  if system:
54
  messages.append({"role": "system", "content": system})
55
  messages.append({"role": "user", "content": prompt})
56
 
57
  payload = {
58
- "model": self.model,
59
  "messages": messages,
60
- "temperature": temperature,
61
- "max_tokens": max_tokens,
62
  }
63
 
64
  resp = requests.post(
65
- f"{self.base_url}/v1/chat/completions",
66
- headers=headers,
67
  json=payload,
68
  timeout=120,
69
  )
@@ -72,37 +168,28 @@ class InferenceClient:
72
  return data["choices"][0]["message"]["content"]
73
 
74
  def generate_plan(self, prompt: str) -> Optional[dict]:
75
- """Use LLM to generate a structured project plan from prompt."""
76
- system_prompt = """You are an expert AI systems architect. Given a user request, produce a JSON project plan.
77
-
78
- Output ONLY valid JSON with this structure:
79
- {
80
- "name": "project-slug",
81
- "description": "One-line description",
82
- "framework": "crewai",
83
- "agents": [
84
- {"id": "agent_id", "role": "Role Name", "goal": "What they do", "backstory": "Background", "tools": ["tool_id"]}
85
- ],
86
- "tasks": [
87
- {"id": "task_id", "description": "What to do", "agent_id": "agent_id", "expected_output": "What it produces", "depends_on": []}
88
- ],
89
- "tools": [
90
- {"id": "tool_id", "template": "web_search"}
91
- ]
92
- }
93
-
94
- Available frameworks: crewai, langgraph, watsonx_orchestrate, crewai_flow, react
95
- Available tools: web_search, pdf_reader, http_client, sql_query, vector_search, file_writer
96
- Use snake_case for IDs. Create distinct agents with clear roles."""
97
-
98
  try:
99
  raw = self.generate(prompt, system=system_prompt, temperature=0.3)
100
- # Extract JSON from response
101
- match = re.search(r'```(?:json)?\s*\n?(.*?)```', raw, re.DOTALL)
102
  if match:
103
  raw = match.group(1)
104
  else:
105
- match = re.search(r'\{.*\}', raw, re.DOTALL)
106
  if match:
107
  raw = match.group(0)
108
  return json.loads(raw.strip())
@@ -110,12 +197,21 @@ Use snake_case for IDs. Create distinct agents with clear roles."""
110
  return None
111
 
112
 
113
- # Singleton
 
 
114
  _client: Optional[InferenceClient] = None
115
 
116
 
 
 
 
 
 
 
 
117
  def get_inference_client() -> InferenceClient:
118
  global _client
119
  if _client is None:
120
- _client = InferenceClient()
121
  return _client
 
1
+ """
2
+ Unified inference client supporting Ollama, OllaBridge, and OpenAI-compatible endpoints.
3
+
4
+ Provider hierarchy:
5
+ 1. OllaBridge (remote, OpenAI-compatible /v1 API)
6
+ 2. Ollama (local, OpenAI-compatible /v1 API at :11434)
7
+ 3. OpenAI / any OpenAI-compatible server
8
+
9
+ All three expose the same /v1/chat/completions and /v1/models interface.
10
+ """
11
+ from __future__ import annotations
12
+
13
  import json
14
+ import os
15
  import re
16
+ import threading
17
  from typing import Optional
18
 
19
+ import requests
 
20
 
21
+ # ── Defaults ────────────────────────────────────────────────────────
22
+
23
+ PROVIDER_DEFAULTS = {
24
+ "ollama": {
25
+ "base_url": "http://localhost:11434",
26
+ "model": "qwen2.5:1.5b",
27
+ "api_key": "",
28
+ },
29
+ "ollabridge": {
30
+ "base_url": "http://localhost:8000",
31
+ "model": "qwen2.5:1.5b",
32
+ "api_key": "",
33
+ },
34
+ "openai": {
35
+ "base_url": "https://api.openai.com",
36
+ "model": "gpt-4o",
37
+ "api_key": "",
38
+ },
39
+ }
40
 
41
+
42
+ class InferenceSettings:
43
+ """Thread-safe mutable settings for the inference provider."""
44
 
45
  def __init__(self):
46
+ self._lock = threading.Lock()
47
+ # Determine initial provider
48
+ self.provider: str = os.environ.get("LLM_PROVIDER", "ollama")
49
+ defaults = PROVIDER_DEFAULTS.get(self.provider, PROVIDER_DEFAULTS["ollama"])
50
+
51
+ self.base_url: str = os.environ.get(
52
+ "OLLABRIDGE_BASE_URL",
53
+ os.environ.get("OLLAMA_BASE_URL", defaults["base_url"]),
54
+ )
55
+ self.model: str = os.environ.get(
56
+ "OLLABRIDGE_MODEL",
57
+ os.environ.get("OLLAMA_MODEL", defaults["model"]),
58
+ )
59
+ self.api_key: str = os.environ.get(
60
+ "OLLABRIDGE_API_KEY",
61
+ os.environ.get("OPENAI_API_KEY", defaults["api_key"]),
62
+ )
63
+ self.temperature: float = float(os.environ.get("LLM_TEMPERATURE", "0.7"))
64
+ self.max_tokens: int = int(os.environ.get("LLM_MAX_TOKENS", "4096"))
65
+
66
+ def update(self, **kwargs) -> dict:
67
+ """Update settings. Returns the new state."""
68
+ with self._lock:
69
+ for key in ("provider", "base_url", "model", "api_key", "temperature", "max_tokens"):
70
+ if key in kwargs and kwargs[key] is not None:
71
+ val = kwargs[key]
72
+ if key == "temperature":
73
+ val = max(0.0, min(2.0, float(val)))
74
+ elif key == "max_tokens":
75
+ val = max(1, int(val))
76
+ setattr(self, key, val)
77
+ # If provider changed, apply defaults for empty fields
78
+ if "provider" in kwargs:
79
+ defaults = PROVIDER_DEFAULTS.get(self.provider, {})
80
+ if not self.base_url or self.base_url in [d["base_url"] for d in PROVIDER_DEFAULTS.values()]:
81
+ self.base_url = defaults.get("base_url", self.base_url)
82
+ if not self.model or self.model in [d["model"] for d in PROVIDER_DEFAULTS.values()]:
83
+ self.model = defaults.get("model", self.model)
84
+ return self.to_dict()
85
+
86
+ def to_dict(self) -> dict:
87
+ return {
88
+ "provider": self.provider,
89
+ "base_url": self.base_url,
90
+ "model": self.model,
91
+ "api_key": "***" if self.api_key else "",
92
+ "temperature": self.temperature,
93
+ "max_tokens": self.max_tokens,
94
+ }
95
+
96
+
97
+ class InferenceClient:
98
+ """Unified client for Ollama / OllaBridge / OpenAI-compatible endpoints."""
99
+
100
+ def __init__(self, settings: InferenceSettings):
101
+ self.settings = settings
102
+
103
+ def _headers(self) -> dict:
104
+ h = {"Content-Type": "application/json"}
105
+ if self.settings.api_key:
106
+ h["Authorization"] = f"Bearer {self.settings.api_key}"
107
+ return h
108
+
109
+ def _api_base(self) -> str:
110
+ base = self.settings.base_url.rstrip("/")
111
+ # Ollama native API is at /v1, OllaBridge too
112
+ if not base.endswith("/v1"):
113
+ return f"{base}/v1"
114
+ return base
115
 
116
  @property
117
  def available(self) -> bool:
118
+ """Check if the endpoint is reachable."""
119
  try:
120
+ resp = requests.get(
121
+ f"{self._api_base()}/models",
122
+ headers=self._headers(),
123
+ timeout=3,
124
+ )
125
  return resp.status_code == 200
126
  except Exception:
127
  return False
128
 
129
  def list_models(self) -> list[str]:
130
+ """Get available models."""
131
  try:
132
+ resp = requests.get(
133
+ f"{self._api_base()}/models",
134
+ headers=self._headers(),
135
+ timeout=5,
136
+ )
137
  if resp.status_code == 200:
138
  data = resp.json()
139
  return [m.get("id", "") for m in data.get("data", [])]
 
141
  pass
142
  return []
143
 
144
+ def generate(self, prompt: str, system: str = "",
145
+ temperature: float | None = None,
146
+ max_tokens: int | None = None) -> str:
147
+ """Generate a chat completion."""
 
 
148
  messages = []
149
  if system:
150
  messages.append({"role": "system", "content": system})
151
  messages.append({"role": "user", "content": prompt})
152
 
153
  payload = {
154
+ "model": self.settings.model,
155
  "messages": messages,
156
+ "temperature": temperature if temperature is not None else self.settings.temperature,
157
+ "max_tokens": max_tokens if max_tokens is not None else self.settings.max_tokens,
158
  }
159
 
160
  resp = requests.post(
161
+ f"{self._api_base()}/chat/completions",
162
+ headers=self._headers(),
163
  json=payload,
164
  timeout=120,
165
  )
 
168
  return data["choices"][0]["message"]["content"]
169
 
170
  def generate_plan(self, prompt: str) -> Optional[dict]:
171
+ """Use the LLM to generate a structured project plan."""
172
+ system_prompt = (
173
+ "You are an expert AI systems architect. Given a user request, produce a JSON project plan.\n\n"
174
+ "Output ONLY valid JSON with this structure:\n"
175
+ '{\n "name": "project-slug",\n "description": "One-line description",\n'
176
+ ' "framework": "crewai",\n'
177
+ ' "agents": [{"id": "agent_id", "role": "Role Name", "goal": "What they do", '
178
+ '"backstory": "Background", "tools": ["tool_id"]}],\n'
179
+ ' "tasks": [{"id": "task_id", "description": "What to do", "agent_id": "agent_id", '
180
+ '"expected_output": "What it produces", "depends_on": []}],\n'
181
+ ' "tools": [{"id": "tool_id", "template": "web_search"}]\n}\n\n'
182
+ "Available frameworks: crewai, langgraph, watsonx_orchestrate, crewai_flow, react\n"
183
+ "Available tools: web_search, pdf_reader, http_client, sql_query, vector_search, file_writer\n"
184
+ "Use snake_case for IDs. Create distinct agents with clear roles."
185
+ )
 
 
 
 
 
 
 
 
186
  try:
187
  raw = self.generate(prompt, system=system_prompt, temperature=0.3)
188
+ match = re.search(r"```(?:json)?\s*\n?(.*?)```", raw, re.DOTALL)
 
189
  if match:
190
  raw = match.group(1)
191
  else:
192
+ match = re.search(r"\{.*\}", raw, re.DOTALL)
193
  if match:
194
  raw = match.group(0)
195
  return json.loads(raw.strip())
 
197
  return None
198
 
199
 
200
+ # ── Singletons ──────────────────────────────────────────────────────
201
+
202
+ _settings: Optional[InferenceSettings] = None
203
  _client: Optional[InferenceClient] = None
204
 
205
 
206
+ def get_inference_settings() -> InferenceSettings:
207
+ global _settings
208
+ if _settings is None:
209
+ _settings = InferenceSettings()
210
+ return _settings
211
+
212
+
213
  def get_inference_client() -> InferenceClient:
214
  global _client
215
  if _client is None:
216
+ _client = InferenceClient(get_inference_settings())
217
  return _client
app/main.py CHANGED
@@ -16,9 +16,9 @@ from fastapi.staticfiles import StaticFiles
16
  from fastapi.templating import Jinja2Templates
17
 
18
  from app.generator import generate_project, plan_project
19
- from app.inference import get_inference_client
20
 
21
- app = FastAPI(title="Agent Generator", version="2.0.0")
22
 
23
  BASE_DIR = Path(__file__).resolve().parent
24
  app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
@@ -416,17 +416,64 @@ async def health():
416
  inference = get_inference_client()
417
  return {
418
  "status": "ok",
419
- "version": "2.0.0",
420
  "inference_available": inference.available,
421
  }
422
 
423
 
424
  @app.get("/api/models")
425
  async def api_models():
426
- """List available LLM models from OllaBridge."""
427
  inference = get_inference_client()
428
  models = inference.list_models()
429
- return {"models": models, "current": inference.model}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
 
431
 
432
  @app.get("/api/inference-status")
 
16
  from fastapi.templating import Jinja2Templates
17
 
18
  from app.generator import generate_project, plan_project
19
+ from app.inference import get_inference_client, get_inference_settings, PROVIDER_DEFAULTS
20
 
21
+ app = FastAPI(title="Agent Generator", version="0.1.3")
22
 
23
  BASE_DIR = Path(__file__).resolve().parent
24
  app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
 
416
  inference = get_inference_client()
417
  return {
418
  "status": "ok",
419
+ "version": "0.1.3",
420
  "inference_available": inference.available,
421
  }
422
 
423
 
424
  @app.get("/api/models")
425
  async def api_models():
426
+ """List available LLM models."""
427
  inference = get_inference_client()
428
  models = inference.list_models()
429
+ settings = get_inference_settings()
430
+ return {"models": models, "current": settings.model, "provider": settings.provider}
431
+
432
+
433
+ @app.get("/api/settings")
434
+ async def api_get_settings():
435
+ """Get current LLM settings."""
436
+ settings = get_inference_settings()
437
+ client = get_inference_client()
438
+ return {
439
+ **settings.to_dict(),
440
+ "available": client.available,
441
+ "available_models": client.list_models(),
442
+ "providers": list(PROVIDER_DEFAULTS.keys()),
443
+ }
444
+
445
+
446
+ @app.post("/api/settings")
447
+ async def api_update_settings(request: Request):
448
+ """Update LLM settings (provider, model, base_url, api_key, temperature, max_tokens)."""
449
+ body = await request.json()
450
+ settings = get_inference_settings()
451
+ new_state = settings.update(**body)
452
+ client = get_inference_client()
453
+ return {
454
+ **new_state,
455
+ "available": client.available,
456
+ "available_models": client.list_models(),
457
+ }
458
+
459
+
460
+ @app.post("/api/settings/test")
461
+ async def api_test_connection():
462
+ """Test the current LLM connection with a simple prompt."""
463
+ client = get_inference_client()
464
+ if not client.available:
465
+ return JSONResponse(
466
+ content={"ok": False, "error": "Endpoint not reachable"},
467
+ status_code=503,
468
+ )
469
+ try:
470
+ response = client.generate("Say hello in one sentence.", temperature=0.1, max_tokens=50)
471
+ return {"ok": True, "response": response}
472
+ except Exception as e:
473
+ return JSONResponse(
474
+ content={"ok": False, "error": str(e)[:200]},
475
+ status_code=500,
476
+ )
477
 
478
 
479
  @app.get("/api/inference-status")
app/static/app.js CHANGED
@@ -16,18 +16,16 @@ document.addEventListener('DOMContentLoaded', () => {
16
  Inference Status Indicator (all pages)
17
  ---------------------------------------------------------- */
18
  function checkInferenceStatus() {
19
- const dot = document.getElementById('inferenceStatus');
20
- if (!dot) return;
21
  fetch('/api/inference-status')
22
  .then(r => r.json())
23
  .then(data => {
24
- dot.classList.remove('bg-green-500', 'bg-gray-400');
25
- dot.classList.add(data.available ? 'bg-green-500' : 'bg-gray-400');
 
 
 
26
  })
27
- .catch(() => {
28
- dot.classList.remove('bg-green-500');
29
- dot.classList.add('bg-gray-400');
30
- });
31
  }
32
 
33
  /* ----------------------------------------------------------
@@ -500,3 +498,183 @@ function setLoading(btn, text) {
500
  btn.textContent = text;
501
  }
502
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  Inference Status Indicator (all pages)
17
  ---------------------------------------------------------- */
18
  function checkInferenceStatus() {
 
 
19
  fetch('/api/inference-status')
20
  .then(r => r.json())
21
  .then(data => {
22
+ const dot = document.getElementById('statusDot');
23
+ if (dot) {
24
+ dot.style.background = data.available ? '#22c55e' : '#3f3f50';
25
+ dot.style.boxShadow = data.available ? '0 0 8px rgba(34,197,94,0.5)' : 'none';
26
+ }
27
  })
28
+ .catch(() => {});
 
 
 
29
  }
30
 
31
  /* ----------------------------------------------------------
 
498
  btn.textContent = text;
499
  }
500
  }
501
+
502
+
503
+ /* ============================================================
504
+ Settings Modal
505
+ ============================================================ */
506
+
507
+ function openSettings() {
508
+ const modal = document.getElementById('settingsModal');
509
+ if (!modal) return;
510
+ modal.style.display = 'flex';
511
+ loadSettings();
512
+ }
513
+
514
+ function closeSettings() {
515
+ const modal = document.getElementById('settingsModal');
516
+ if (modal) modal.style.display = 'none';
517
+ }
518
+
519
+ // Close on backdrop click
520
+ document.addEventListener('click', function(e) {
521
+ const modal = document.getElementById('settingsModal');
522
+ if (modal && e.target === modal) closeSettings();
523
+ });
524
+
525
+ // Close on Escape
526
+ document.addEventListener('keydown', function(e) {
527
+ if (e.key === 'Escape') closeSettings();
528
+ });
529
+
530
+ function loadSettings() {
531
+ fetch('/api/settings')
532
+ .then(r => r.json())
533
+ .then(data => {
534
+ // Set provider radio
535
+ const radio = document.querySelector(`input[name="s_provider"][value="${data.provider}"]`);
536
+ if (radio) { radio.checked = true; highlightProviderCard(data.provider); }
537
+ // Set fields
538
+ const url = document.getElementById('settingsBaseUrl');
539
+ const model = document.getElementById('settingsModel');
540
+ const key = document.getElementById('settingsApiKey');
541
+ const temp = document.getElementById('settingsTemp');
542
+ const tempVal = document.getElementById('settingsTempValue');
543
+ if (url) url.value = data.base_url || '';
544
+ if (model) model.value = data.model || '';
545
+ if (key) key.value = data.api_key === '***' ? '' : (data.api_key || '');
546
+ if (temp) temp.value = data.temperature || 0.7;
547
+ if (tempVal) tempVal.textContent = data.temperature || '0.7';
548
+ // Status
549
+ updateSettingsStatus(data.available);
550
+ // Model suggestions
551
+ showModelSuggestions(data.available_models || [], data.model);
552
+ })
553
+ .catch(() => updateSettingsStatus(false));
554
+ }
555
+
556
+ function highlightProviderCard(provider) {
557
+ ['Ollama', 'Ollabridge', 'Openai'].forEach(p => {
558
+ const el = document.getElementById('prov' + p);
559
+ if (el) el.classList.toggle('selected', p.toLowerCase() === provider);
560
+ });
561
+ }
562
+
563
+ function onProviderChange(provider) {
564
+ highlightProviderCard(provider);
565
+ // Apply defaults
566
+ const defaults = { ollama: { url: 'http://localhost:11434', model: 'qwen2.5:1.5b' }, ollabridge: { url: 'http://localhost:8000', model: 'qwen2.5:1.5b' }, openai: { url: 'https://api.openai.com', model: 'gpt-4o' } };
567
+ const d = defaults[provider] || defaults.ollama;
568
+ const url = document.getElementById('settingsBaseUrl');
569
+ const model = document.getElementById('settingsModel');
570
+ if (url) url.value = d.url;
571
+ if (model) model.value = d.model;
572
+ }
573
+
574
+ function updateSettingsStatus(available) {
575
+ const dot = document.getElementById('settingsStatusDot');
576
+ const text = document.getElementById('settingsStatusText');
577
+ if (dot) {
578
+ dot.style.background = available ? '#22c55e' : '#ef4444';
579
+ dot.style.boxShadow = available ? '0 0 8px rgba(34,197,94,0.5)' : '0 0 8px rgba(239,68,68,0.3)';
580
+ }
581
+ if (text) {
582
+ text.textContent = available ? 'Connected' : 'Not connected';
583
+ text.style.color = available ? '#22c55e' : '#ef4444';
584
+ }
585
+ }
586
+
587
+ function showModelSuggestions(models, current) {
588
+ const container = document.getElementById('modelSuggestions');
589
+ if (!container) return;
590
+ container.innerHTML = '';
591
+ models.forEach(m => {
592
+ if (!m) return;
593
+ const chip = document.createElement('button');
594
+ chip.type = 'button';
595
+ chip.className = 'tag-chip';
596
+ chip.textContent = m;
597
+ if (m === current) chip.style.borderColor = 'var(--cyan)';
598
+ chip.onclick = function() {
599
+ document.getElementById('settingsModel').value = m;
600
+ container.querySelectorAll('.tag-chip').forEach(c => c.style.borderColor = '');
601
+ chip.style.borderColor = 'var(--cyan)';
602
+ };
603
+ container.appendChild(chip);
604
+ });
605
+ }
606
+
607
+ function refreshModels() {
608
+ fetch('/api/models')
609
+ .then(r => r.json())
610
+ .then(data => {
611
+ showModelSuggestions(data.models || [], data.current);
612
+ })
613
+ .catch(() => {});
614
+ }
615
+
616
+ function saveSettings() {
617
+ const provider = document.querySelector('input[name="s_provider"]:checked');
618
+ const body = {
619
+ provider: provider ? provider.value : undefined,
620
+ base_url: document.getElementById('settingsBaseUrl')?.value || undefined,
621
+ model: document.getElementById('settingsModel')?.value || undefined,
622
+ temperature: parseFloat(document.getElementById('settingsTemp')?.value || '0.7'),
623
+ };
624
+ const apiKey = document.getElementById('settingsApiKey')?.value;
625
+ if (apiKey) body.api_key = apiKey;
626
+
627
+ fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
628
+ .then(r => r.json())
629
+ .then(data => {
630
+ updateSettingsStatus(data.available);
631
+ checkInferenceStatus();
632
+ closeSettings();
633
+ })
634
+ .catch(() => alert('Failed to save settings'));
635
+ }
636
+
637
+ function testConnection() {
638
+ const btn = document.getElementById('testBtn');
639
+ const text = document.getElementById('testBtnText');
640
+ const spin = document.getElementById('testSpinner');
641
+ if (btn) btn.disabled = true;
642
+ if (text) text.textContent = 'Testing...';
643
+ if (spin) spin.style.display = 'inline-block';
644
+
645
+ // Save first, then test
646
+ const provider = document.querySelector('input[name="s_provider"]:checked');
647
+ const body = {
648
+ provider: provider ? provider.value : undefined,
649
+ base_url: document.getElementById('settingsBaseUrl')?.value || undefined,
650
+ model: document.getElementById('settingsModel')?.value || undefined,
651
+ temperature: parseFloat(document.getElementById('settingsTemp')?.value || '0.7'),
652
+ };
653
+ const apiKey = document.getElementById('settingsApiKey')?.value;
654
+ if (apiKey) body.api_key = apiKey;
655
+
656
+ fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
657
+ .then(() => fetch('/api/settings/test', { method: 'POST' }))
658
+ .then(r => r.json())
659
+ .then(data => {
660
+ if (data.ok) {
661
+ updateSettingsStatus(true);
662
+ if (text) text.textContent = 'Connected!';
663
+ setTimeout(() => { if (text) text.textContent = 'Test Connection'; }, 2000);
664
+ } else {
665
+ updateSettingsStatus(false);
666
+ if (text) text.textContent = data.error ? 'Failed' : 'Not reachable';
667
+ setTimeout(() => { if (text) text.textContent = 'Test Connection'; }, 3000);
668
+ }
669
+ })
670
+ .catch(() => {
671
+ updateSettingsStatus(false);
672
+ if (text) text.textContent = 'Error';
673
+ setTimeout(() => { if (text) text.textContent = 'Test Connection'; }, 2000);
674
+ })
675
+ .finally(() => {
676
+ if (btn) btn.disabled = false;
677
+ if (spin) spin.style.display = 'none';
678
+ checkInferenceStatus();
679
+ });
680
+ }
app/templates/base.html CHANGED
@@ -677,14 +677,18 @@
677
  </div>
678
 
679
  <!-- Right side -->
680
- <div style="display: flex; align-items: center; gap: 12px; flex-shrink: 0;">
681
- <!-- Inference status -->
682
- {% set inference_available = inference_available | default(false) %}
683
- <div style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted);" title="{{ 'OllaBridge connected' if inference_available else 'Inference offline' }}">
684
- <div style="width: 8px; height: 8px; border-radius: 50%; background: {{ '#22c55e' if inference_available else '#3f3f50' }}; {{ 'box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);' if inference_available else '' }}"></div>
685
- <span style="display: none;">{{ 'Online' if inference_available else 'Offline' }}</span>
686
  </div>
687
- <a href="https://github.com/ruslanmv/agent-generator" target="_blank" class="btn-glass" style="padding: 6px 12px; font-size: 12px; border-radius: 10px;">
 
 
 
 
 
688
  <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
689
  </a>
690
  </div>
@@ -708,6 +712,99 @@
708
  </div>
709
  </footer>
710
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
711
  <script>
712
  // Show wizard steps on non-tiny screens
713
  (function() {
 
677
  </div>
678
 
679
  <!-- Right side -->
680
+ <div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0;">
681
+ <!-- Inference status dot -->
682
+ <div id="inferenceStatusDot" style="display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-muted); cursor: pointer;" title="Click to open settings" onclick="openSettings()">
683
+ <div id="statusDot" style="width: 8px; height: 8px; border-radius: 50%; background: #3f3f50;"></div>
684
+ <span id="statusLabel" style="display: none;">Offline</span>
 
685
  </div>
686
+ <!-- Settings gear -->
687
+ <button onclick="openSettings()" class="btn-glass" style="padding: 6px 10px; font-size: 12px; border-radius: 10px;" title="LLM Settings">
688
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
689
+ </button>
690
+ <!-- GitHub -->
691
+ <a href="https://github.com/ruslanmv/agent-generator" target="_blank" class="btn-glass" style="padding: 6px 10px; font-size: 12px; border-radius: 10px;" title="GitHub">
692
  <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
693
  </a>
694
  </div>
 
712
  </div>
713
  </footer>
714
 
715
+ <!-- Settings Modal -->
716
+ <div id="settingsModal" style="display:none; position:fixed; inset:0; z-index:100; background:rgba(0,0,0,0.6); backdrop-filter:blur(4px); align-items:center; justify-content:center;">
717
+ <div class="glass" style="width:100%; max-width:520px; margin:24px; padding:0; overflow:hidden; box-shadow: 0 25px 60px rgba(0,0,0,0.5);">
718
+ <!-- Header -->
719
+ <div style="padding:20px 24px; border-bottom:1px solid var(--glass-border); display:flex; justify-content:space-between; align-items:center;">
720
+ <div style="display:flex; align-items:center; gap:10px;">
721
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--cyan)" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
722
+ <span style="font-weight:700; font-size:16px; color:var(--text-primary);">LLM Settings</span>
723
+ </div>
724
+ <button onclick="closeSettings()" style="background:none; border:none; color:var(--text-muted); cursor:pointer; padding:4px;">
725
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
726
+ </button>
727
+ </div>
728
+
729
+ <!-- Body -->
730
+ <div style="padding:24px; display:flex; flex-direction:column; gap:20px; max-height:70vh; overflow-y:auto;">
731
+ <!-- Connection Status -->
732
+ <div id="settingsStatus" style="padding:12px 16px; border-radius:10px; font-size:13px; display:flex; align-items:center; gap:8px; background:rgba(255,255,255,0.03); border:1px solid var(--glass-border);">
733
+ <div id="settingsStatusDot" style="width:8px; height:8px; border-radius:50%; background:#3f3f50; flex-shrink:0;"></div>
734
+ <span id="settingsStatusText" style="color:var(--text-muted);">Checking connection...</span>
735
+ </div>
736
+
737
+ <!-- Provider -->
738
+ <div>
739
+ <label style="display:block; font-size:12px; font-weight:600; color:var(--text-secondary); margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">Provider</label>
740
+ <div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:6px;">
741
+ <label class="framework-card" style="padding:10px 12px; text-align:center; border-radius:10px;" id="provOllama">
742
+ <input type="radio" name="s_provider" value="ollama" onchange="onProviderChange(this.value)">
743
+ <div style="font-weight:600; font-size:13px; color:var(--text-primary);">Ollama</div>
744
+ <div style="font-size:11px; color:var(--text-muted);">Local LLM</div>
745
+ </label>
746
+ <label class="framework-card" style="padding:10px 12px; text-align:center; border-radius:10px;" id="provOllabridge">
747
+ <input type="radio" name="s_provider" value="ollabridge" onchange="onProviderChange(this.value)">
748
+ <div style="font-weight:600; font-size:13px; color:var(--text-primary);">OllaBridge</div>
749
+ <div style="font-size:11px; color:var(--text-muted);">Remote LLM</div>
750
+ </label>
751
+ <label class="framework-card" style="padding:10px 12px; text-align:center; border-radius:10px;" id="provOpenai">
752
+ <input type="radio" name="s_provider" value="openai" onchange="onProviderChange(this.value)">
753
+ <div style="font-weight:600; font-size:13px; color:var(--text-primary);">OpenAI</div>
754
+ <div style="font-size:11px; color:var(--text-muted);">Cloud API</div>
755
+ </label>
756
+ </div>
757
+ </div>
758
+
759
+ <!-- Base URL -->
760
+ <div>
761
+ <label style="display:block; font-size:12px; font-weight:600; color:var(--text-secondary); margin-bottom:6px; text-transform:uppercase; letter-spacing:0.05em;">Endpoint URL</label>
762
+ <input type="text" id="settingsBaseUrl" placeholder="http://localhost:11434" style="font-family:'JetBrains Mono',monospace; font-size:13px;">
763
+ </div>
764
+
765
+ <!-- Model -->
766
+ <div>
767
+ <label style="display:block; font-size:12px; font-weight:600; color:var(--text-secondary); margin-bottom:6px; text-transform:uppercase; letter-spacing:0.05em;">Model</label>
768
+ <div style="display:flex; gap:8px;">
769
+ <input type="text" id="settingsModel" placeholder="qwen2.5:1.5b" style="font-family:'JetBrains Mono',monospace; font-size:13px; flex:1;">
770
+ <button onclick="refreshModels()" class="btn-glass" style="padding:8px 12px; border-radius:10px; white-space:nowrap; font-size:12px;" title="Refresh model list">
771
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2v6h-6M3 12a9 9 0 0115.36-6.36L21 8M3 22v-6h6M21 12a9 9 0 01-15.36 6.36L3 16"/></svg>
772
+ </button>
773
+ </div>
774
+ <div id="modelSuggestions" style="margin-top:6px; display:flex; flex-wrap:wrap; gap:4px;"></div>
775
+ </div>
776
+
777
+ <!-- API Key -->
778
+ <div>
779
+ <label style="display:block; font-size:12px; font-weight:600; color:var(--text-secondary); margin-bottom:6px; text-transform:uppercase; letter-spacing:0.05em;">API Key <span style="font-weight:400; text-transform:none; color:var(--text-muted);">(optional for local)</span></label>
780
+ <input type="password" id="settingsApiKey" placeholder="Leave empty for local Ollama" style="font-family:'JetBrains Mono',monospace; font-size:13px;">
781
+ </div>
782
+
783
+ <!-- Temperature -->
784
+ <div>
785
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
786
+ <label style="font-size:12px; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.05em;">Temperature</label>
787
+ <span id="settingsTempValue" style="font-size:12px; color:var(--cyan); font-family:'JetBrains Mono',monospace;">0.7</span>
788
+ </div>
789
+ <input type="range" id="settingsTemp" min="0" max="2" step="0.1" value="0.7" oninput="document.getElementById('settingsTempValue').textContent=this.value">
790
+ </div>
791
+ </div>
792
+
793
+ <!-- Footer -->
794
+ <div style="padding:16px 24px; border-top:1px solid var(--glass-border); display:flex; justify-content:space-between; align-items:center; gap:8px;">
795
+ <button onclick="testConnection()" class="btn-glass" style="padding:8px 16px; font-size:13px; border-radius:10px;" id="testBtn">
796
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><path d="M22 4L12 14.01l-3-3"/></svg>
797
+ <span id="testBtnText">Test Connection</span>
798
+ <div class="spinner" id="testSpinner" style="display:none; width:14px; height:14px;"></div>
799
+ </button>
800
+ <div style="display:flex; gap:8px;">
801
+ <button onclick="closeSettings()" class="btn-glass" style="padding:8px 16px; font-size:13px; border-radius:10px;">Cancel</button>
802
+ <button onclick="saveSettings()" class="btn-gradient" style="padding:8px 20px; font-size:13px; border-radius:10px;">Save</button>
803
+ </div>
804
+ </div>
805
+ </div>
806
+ </div>
807
+
808
  <script>
809
  // Show wizard steps on non-tiny screens
810
  (function() {
tests/__init__.py ADDED
File without changes
tests/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (150 Bytes). View file
 
tests/__pycache__/test_generate_hello_world.cpython-311-pytest-9.0.2.pyc ADDED
Binary file (23.6 kB). View file
 
tests/test_generate_hello_world.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests: generate a 'hello world' project for each framework.
3
+
4
+ Verifies that every framework produces valid, non-empty files
5
+ and that Python files pass AST parsing.
6
+ """
7
+ import ast
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ # Add the hf/app directory to the path
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "app"))
15
+
16
+ from generator import generate_project
17
+
18
+
19
+ FRAMEWORKS = ["crewai", "langgraph", "watsonx_orchestrate", "react", "crewai_flow"]
20
+
21
+ HELLO_PROMPT = "Build a simple hello-world agent that greets the user and responds to questions."
22
+
23
+
24
+ def _validate_python(code: str, filepath: str):
25
+ """Assert the code is valid Python."""
26
+ try:
27
+ ast.parse(code, filename=filepath)
28
+ except SyntaxError as exc:
29
+ raise AssertionError(f"{filepath}: SyntaxError at line {exc.lineno}: {exc.msg}") from exc
30
+
31
+
32
+ def _validate_yaml(content: str, filepath: str):
33
+ """Assert the content is valid YAML."""
34
+ try:
35
+ data = yaml.safe_load(content)
36
+ assert data is not None, f"{filepath}: YAML parsed as None"
37
+ except yaml.YAMLError as exc:
38
+ raise AssertionError(f"{filepath}: YAML error: {exc}") from exc
39
+
40
+
41
+ # ── Per-framework tests ─────────────────────────────────────────
42
+
43
+
44
+ def test_crewai_hello_world():
45
+ plan, files = generate_project(HELLO_PROMPT, "crewai", "code_and_yaml")
46
+ assert plan["framework"] == "crewai"
47
+ assert len(files) >= 4, f"Expected >=4 files, got {len(files)}: {list(files.keys())}"
48
+
49
+ # Must have YAML configs
50
+ yaml_files = [f for f in files if f.endswith((".yaml", ".yml"))]
51
+ assert yaml_files, "CrewAI code_and_yaml should produce YAML files"
52
+
53
+ # Validate all files
54
+ for path, content in files.items():
55
+ assert content.strip(), f"{path} is empty"
56
+ if path.endswith(".py"):
57
+ _validate_python(content, path)
58
+ elif path.endswith((".yaml", ".yml")):
59
+ _validate_yaml(content, path)
60
+
61
+
62
+ def test_langgraph_hello_world():
63
+ plan, files = generate_project(HELLO_PROMPT, "langgraph", "code_only")
64
+ assert plan["framework"] == "langgraph"
65
+ assert len(files) >= 2
66
+
67
+ py_files = [f for f in files if f.endswith(".py")]
68
+ assert py_files, "LangGraph should produce Python files"
69
+
70
+ for path, content in files.items():
71
+ assert content.strip(), f"{path} is empty"
72
+ if path.endswith(".py"):
73
+ _validate_python(content, path)
74
+
75
+
76
+ def test_watsonx_hello_world():
77
+ plan, files = generate_project(HELLO_PROMPT, "watsonx_orchestrate", "yaml_only")
78
+ assert plan["framework"] == "watsonx_orchestrate"
79
+ assert len(files) >= 1
80
+
81
+ yaml_files = [f for f in files if f.endswith((".yaml", ".yml"))]
82
+ assert yaml_files, "WatsonX should produce YAML files"
83
+
84
+ for path, content in files.items():
85
+ assert content.strip(), f"{path} is empty"
86
+ if path.endswith((".yaml", ".yml")):
87
+ _validate_yaml(content, path)
88
+
89
+
90
+ def test_react_hello_world():
91
+ plan, files = generate_project(HELLO_PROMPT, "react", "code_only")
92
+ assert plan["framework"] == "react"
93
+ assert len(files) >= 2
94
+
95
+ for path, content in files.items():
96
+ assert content.strip(), f"{path} is empty"
97
+ if path.endswith(".py"):
98
+ _validate_python(content, path)
99
+
100
+
101
+ def test_crewai_flow_hello_world():
102
+ plan, files = generate_project(HELLO_PROMPT, "crewai_flow", "code_only")
103
+ assert plan["framework"] == "crewai_flow"
104
+ assert len(files) >= 2
105
+
106
+ for path, content in files.items():
107
+ assert content.strip(), f"{path} is empty"
108
+ if path.endswith(".py"):
109
+ _validate_python(content, path)
110
+
111
+
112
+ # ── Cross-framework tests ───────────────────────────────────────
113
+
114
+
115
+ def test_all_frameworks_produce_readme():
116
+ for fw in FRAMEWORKS:
117
+ _, files = generate_project(HELLO_PROMPT, fw)
118
+ readme = [f for f in files if f.lower() == "readme.md"]
119
+ assert readme, f"{fw} should produce a README.md"
120
+
121
+
122
+ def test_all_frameworks_produce_nonempty():
123
+ for fw in FRAMEWORKS:
124
+ _, files = generate_project(HELLO_PROMPT, fw)
125
+ assert len(files) >= 1, f"{fw} produced no files"
126
+ for path, content in files.items():
127
+ assert len(content) > 10, f"{fw}/{path} is too short ({len(content)} chars)"
128
+
129
+
130
+ def test_plan_detects_framework():
131
+ """Verify framework detection from prompt keywords."""
132
+ from generator import plan_project
133
+
134
+ plan = plan_project("Build a LangGraph state machine pipeline")
135
+ assert plan["framework"] == "langgraph"
136
+
137
+ plan = plan_project("Create a WatsonX Orchestrate assistant")
138
+ assert plan["framework"] == "watsonx_orchestrate"
139
+
140
+ plan = plan_project("Build a CrewAI multi-agent research team")
141
+ assert plan["framework"] == "crewai"