Pipelinen parannuksia building blockeilla

This commit is contained in:
Jaakko Vanhala
2026-04-12 18:48:14 +03:00
parent c1a5f8aff5
commit b2ee8b9031
175 changed files with 13311 additions and 237 deletions

View File

@@ -230,6 +230,144 @@ mitä luokkia importata.
---
## Rakennuspalaset vs. vapaa generointi
Kielimalli voi generoida koodia kahdella perustavanlaatuisesti eri tavalla.
Ymmärtäminen milloin kumpikin toimii on avain luotettavaan koodigenerointi-pipelineen.
### Tapa 1: Vapaa generointi (naivi)
LLM generoi jokaisen tiedoston tyhjästä. Prompti kuvaa mitä halutaan,
malli tuottaa koko tiedoston — importeista lähtien.
```mermaid
flowchart LR
P["Prompti"] --> LLM1["LLM: models.py"]
LLM1 --> V1{"Validointi"}
V1 -->|virhe| LLM1
V1 -->|ok| LLM2["LLM: schemas.py"]
LLM2 --> V2{"Validointi"}
V2 -->|virhe| LLM2
V2 -->|ok| LLM3["LLM: main.py"]
LLM3 --> V3{"..."}
style V1 fill:#1a1e2e,stroke:#f85149,color:#c9d1d9
style V2 fill:#1a1e2e,stroke:#f85149,color:#c9d1d9
style V3 fill:#1a1e2e,stroke:#f85149,color:#c9d1d9
```
**Ongelma:** Pieni malli (0.5B7B) tekee toistuvia rakenteellisia virheitä:
| Virhe | Esiintymistiheys | Selitys |
|-------|:---:|------|
| Puuttuva import | ~60% | `from datetime import date` unohtuu |
| SQLite `connect_args` | ~80% | Malli ei muista SQLite-erityisyyttä |
| Väärä Enum-käyttö | ~50% | Sekoittaa `sqlalchemy.Enum` ja `enum.Enum` |
| Poetry pyproject.toml:ssa | ~40% | Malli suosii Poetryä vaikka ohje sanoo uv |
| Testit kopioivat koko appin | ~70% | Malli ei osaa importata, luo uudet reitit |
Retry-loopilla (virhe → uusi yritys virheviestin kanssa) osa korjautuu,
mutta **sama malli toistaa samoja virheitä** koska ne johtuvat harjoitusdatasta.
7 tiedoston projekti vaatii 714 LLM-kutsua ja 80120 sekuntia.
### Tapa 2: Rakennuspalaset (template pipeline)
LLM:ltä pyydetään **vain JSON-speksi** — entiteetit, kentät ja tyypit.
Koodi kootaan mekaanisesti valmiista pohjista joiden rakenne on todistettavasti
oikein.
```mermaid
flowchart LR
P["Projektin kuvaus"] --> LLM["LLM: JSON-speksi"]
LLM --> S["{ entities: [...] }"]
S --> T1["Template: models.py"]
S --> T2["Template: schemas.py"]
S --> T3["Template: main.py"]
S --> T4["Template: test_main.py"]
S --> T5["Template: Dockerfile"]
T1 & T2 & T3 & T4 & T5 --> D["Docker build + pytest"]
style LLM fill:#1a1e2e,stroke:#d29922,color:#c9d1d9
style S fill:#1a1e2e,stroke:#3fb950,color:#c9d1d9
style D fill:#1a1e2e,stroke:#58a6ff,color:#c9d1d9
```
**Idea:** Malli on hyvä päättämään *mitä* (entiteetit, kentät), mutta huono
muistamaan *miten* (importit, engine setup, testikonfiguraatio). Annetaan
mallin tehdä se missä se on hyvä, ja hoidetaan loput mekaanisesti.
### LLM:n ainoa tehtävä
Malli tuottaa JSON-rakenteen kuten:
```json
{
"project_name": "todo-app",
"entities": [
{
"name": "Todo",
"table_name": "todos",
"fields": [
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false},
{"name": "due_date", "sa_type": "Date", "py_type": "date | None", "nullable": true},
{"name": "status", "sa_type": "String(20)", "py_type": "str", "default": "pending"}
]
}
],
"extra_imports": ["from datetime import date"]
}
```
Tämä on yksinkertainen tehtävä jossa pienikin malli onnistuu luotettavasti:
entiteettien tunnistus projektin kuvauksesta ja kenttätyyppien valinta.
### Template täyttää loput
Jokainen template on kuin madlib — aukot täytetään speksin datalla:
**models.py template (yksinkertaistettu):**
```python
from sqlalchemy import create_engine, Column, Integer, {sa_types}
# ... aina samat importit, engine setup, SessionLocal ...
class {entity.name}(Base):
__tablename__ = "{entity.table_name}"
id = Column(Integer, primary_key=True, index=True)
{field.name} = Column({field.sa_type}, nullable={field.nullable})
# ... jokainen kenttä speksistä ...
```
Tulos: importit ovat aina oikein, `connect_args` on aina mukana,
testit importoivat aina `main.py`:stä eivätkä kopioi sitä.
### Vertailu: mittaustulokset
| | Vapaa generointi | Rakennuspalaset |
|---|:---:|:---:|
| LLM-kutsuja | 714 | **1** |
| Aika | 80120s | **~20s** |
| Syntaksi OK | ~70% | **100%** |
| Docker build | vaihteleva | **100%** |
| Pytest läpi | 0% | **100%** |
| API toimii | ~30% | **100%** |
### Milloin kumpikin toimii
**Rakennuspalaset** kun:
- Projektin rakenne on tunnettu (FastAPI + SQLAlchemy CRUD)
- Laatu ja luotettavuus ovat tärkeitä
- Malli on pieni (0.5B7B)
**Vapaa generointi** kun:
- Projektin rakenne on epätavallinen
- Tarvitaan custom-logiikkaa jota template ei kata
- Malli on riittävän iso (>70B tai pilvi-API)
Paras lopputulos syntyy yhdistelmällä: **rakennuspalaset perusrakenteelle,
vapaa generointi business-logiikalle**.
---
## Laadun parantaminen
### 1. Isompi malli (suurin vaikutus)

View File

@@ -1,37 +1,37 @@
<!-- Agenttigalleria + konfigurointipaneeli -->
<!-- Agent gallery + configuration panel -->
<div style="display:flex;gap:16px;padding:10px 0;align-items:flex-start">
<!-- Agenttilista (drag & drop) -->
<div id="agent-bar" style="display:flex;gap:6px;align-items:flex-end;flex-wrap:wrap">
<!-- Renderöidään JS:stä -->
</div>
<!-- + Lisää agentti -->
<div id="add-agent-btn" class="agent-avatar" onclick="addCustomAgent()" title="Lisää oma agentti" style="opacity:0.4">
<!-- + Add agent -->
<div id="add-agent-btn" class="agent-avatar" onclick="addCustomAgent()" title="Add custom agent" style="opacity:0.4">
<div style="width:48px;height:48px;border-radius:50%;border:2px dashed var(--border);display:flex;align-items:center;justify-content:center;font-size:24px;color:var(--border)">+</div>
<span style="font-size:10px;color:#8b949e;text-align:center;display:block">Lisää</span>
<span style="font-size:10px;color:#8b949e;text-align:center;display:block">Add</span>
</div>
</div>
<!-- Agentin konfigurointipaneeli (avautuu klikkaamalla avataria) -->
<!-- Agent configuration panel (opens clicking avatar) -->
<div id="agent-config" style="display:none;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:16px;margin-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<div style="display:flex;align-items:center;gap:10px">
<img id="config-avatar" src="" style="width:40px;height:40px;border-radius:50%">
<div>
<input id="config-name" style="background:transparent;border:none;color:var(--text);font-size:16px;font-weight:600;outline:none;width:200px" placeholder="Agentin nimi">
<input id="config-name" style="background:transparent;border:none;color:var(--text);font-size:16px;font-weight:600;outline:none;width:200px" placeholder="Agent Name">
<div id="config-role" style="font-size:11px;color:#8b949e"></div>
</div>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-red" onclick="deleteAgent()" title="Poista agentti">Poista</button>
<button class="btn btn-muted" onclick="closeAgentConfig()">Sulje</button>
<button class="btn btn-red" onclick="deleteAgent()" title="Delete agent">Delete</button>
<button class="btn btn-muted" onclick="closeAgentConfig()">Close</button>
</div>
</div>
<!-- Malli -->
<!-- Model -->
<div style="margin-bottom:10px">
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px">Kielimalli</label>
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px">Model</label>
<select id="config-model" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 10px;font-size:13px;width:100%">
<option value="qwen-coder">Qwen2.5-Coder:0.5B (selain)</option>
<option value="qwen-coder">Qwen2.5-Coder:0.5B (browser)</option>
<option value="qwen-coder-3b">Qwen2.5-Coder:3B (Ollama)</option>
<option value="qwen2.5-coder:7b">Qwen2.5-Coder:7B (Ollama)</option>
<option value="qwen2.5-coder:1.5b">Qwen2.5-Coder:1.5B (Ollama)</option>
@@ -39,41 +39,41 @@
</div>
<!-- System prompt -->
<div style="margin-bottom:10px" title="Agentin perusohje joka lähetetään kielimallille jokaisessa pyynnössä.&#10;&#10;Hyvän promptin rakenne:&#10;1. Rooli: 'You are an expert...'&#10;2. Säännöt: RULES/CRITICAL RULES listana&#10;3. Esimerkit: EXAMPLE OUTPUT&#10;4. Kiellot: NEVER-lista&#10;&#10;Vinkki: käytä englantia — malli ymmärtää sen paremmin ja se kuluttaa vähemmän tokeneita.">
<div style="margin-bottom:10px" title="System prompt sent to the LLM on every request.&#10;&#10;Good prompt structure:&#10;1. Role: 'You are an expert...'&#10;2. Rules: RULES/CRITICAL RULES as list&#10;3. Examples: EXAMPLE OUTPUT&#10;4. Restrictions: NEVER-list&#10;">
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px;cursor:help">System prompt 💡</label>
<textarea id="config-prompt" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px;font-size:13px;font-family:'Courier New',monospace;resize:vertical;overflow:hidden;min-height:60px" placeholder="Kuvaa agentin rooli ja käyttäytyminen..."></textarea>
<textarea id="config-prompt" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px;font-size:13px;font-family:'Courier New',monospace;resize:vertical;overflow:hidden;min-height:60px" placeholder="Describe the agent's role and behavior..."></textarea>
</div>
<!-- Sampling-parametrit -->
<!-- Sampling Parameters -->
<div style="margin-bottom:10px">
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:8px">Sampling-parametrit</label>
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:8px">Sampling Parameters</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div title="Kontrolloi 'luovuutta'. Matala arvo (0.2-0.4) tuottaa ennustettavaa, toistettavaa koodia — hyvä testaajille ja reviewereille. Keskiarvo (0.6-0.8) on paras koodin generointiin. Korkea arvo (1.0+) lisää vaihtelua mutta myös virheitä.&#10;&#10;Suositus:&#10;• Manageri: 0.5 (tarkat tiedostolistat)&#10;• Koodari: 0.7 (toimiva koodi + vaihtelu)&#10;• Testaaja: 0.3 (deterministinen arviointi)">
<div title="Controls 'creativity'. Low value (0.2-0.4) produces predictable, repeatable code — good for testers and reviewers. Medium value (0.6-0.8) is best for generating code. High value (1.0+) adds variation but also errors.&#10;&#10;Recommendation:&#10;• Manager: 0.5 (precise file lists)&#10;• Coder: 0.7 (working code + variation)&#10;• Tester: 0.3 (deterministic evaluation)">
<label style="font-size:11px;color:#8b949e;cursor:help">Temperature 💡 <span id="config-temp-val" style="color:var(--accent);float:right">0.7</span></label>
<input type="range" id="config-temperature" min="0" max="1.5" step="0.1" value="0.7" style="width:100%;accent-color:var(--accent)">
<div style="font-size:10px;color:#30363d">0=tarkka · 0.7=oletus · 1.5=luova</div>
<div style="font-size:10px;color:#30363d">0=strict · 0.7=default · 1.5=creative</div>
</div>
<div title="Vastauksen maksimipituus tokeneina (~1 token ≈ 4 merkkiä).&#10;&#10;Suositus:&#10;• Manageri: 256-512 (lyhyet tiedostolistat)&#10;• Koodari: 1024-2048 (täydet tiedostot, CRUD-endpointit)&#10;• Testaaja: 256-512 (lyhyet arvioinnit)&#10;&#10;Jos koodi katkeaa kesken, nosta tätä. Jos malli tuottaa turhaa toistoa, laske.">
<div title="Maximum response length in tokens (~1 token ≈ 4 chars).&#10;&#10;Recommendation:&#10;• Manager: 256-512 (short lists)&#10;• Coder: 1024-2048 (full files, CRUD endpoints)&#10;• Tester: 256-512 (short evaluations)&#10;&#10;If code cuts off early, increase this.">
<label style="font-size:11px;color:#8b949e;cursor:help">Max tokens 💡 <span id="config-maxtok-val" style="color:var(--accent);float:right">1024</span></label>
<input type="range" id="config-maxtokens" min="64" max="4096" step="64" value="1024" style="width:100%;accent-color:var(--accent)">
<div style="font-size:10px;color:#30363d">Vastauksen maksimipituus</div>
<div style="font-size:10px;color:#30363d">Maximum response length</div>
</div>
<div title="Montako todennäköisintä tokenia huomioidaan valinnassa. Pieni arvo (1-10) tekee vastauksesta deterministisen. Suuri arvo (50-100) sallii harvinaisempia sanoja.&#10;&#10;Suositus:&#10;• Boilerplate-koodi: 20-30 (tutut patternit)&#10;• Yleiskoodi: 40 (hyvä oletus)&#10;• Luova teksti: 60-80&#10;&#10;Yleensä ei tarvitse muuttaa oletuksesta.">
<div title="How many most probable tokens are considered. Low value (1-10) makes response deterministic. High value (50-100) allows rarer words.&#10;&#10;Recommendation:&#10;• Boilerplate code: 20-30 (familiar patterns)&#10;• General code: 40 (good default)&#10;• Creative text: 60-80">
<label style="font-size:11px;color:#8b949e;cursor:help">Top-K 💡 <span id="config-topk-val" style="color:var(--accent);float:right">40</span></label>
<input type="range" id="config-topk" min="1" max="100" step="1" value="40" style="width:100%;accent-color:var(--accent)">
<div style="font-size:10px;color:#30363d">1=greedy · 40=oletus · 100=laaja</div>
<div style="font-size:10px;color:#30363d">1=greedy · 40=default · 100=wide</div>
</div>
<div title="Vähentää jo tuotettujen sanojen todennäköisyyttä. Estää mallia toistamasta samaa lausetta. Liian korkea arvo (>1.5) voi rikkoa koodin koska samat avainsanat (return, if, def) ovat tarpeellisia.&#10;&#10;Suositus:&#10;• Koodi: 1.1-1.2 (lievä, sallii toiston)&#10;• Teksti: 1.15-1.3 (vahvempi)&#10;• Review: 1.0-1.1 (ei rangaistusta, lyhyet vastaukset)">
<div title="Reduces the probability of already generated words. Prevents model from repeating same sentences. Too high value (>1.5) can break code because common keywords (return, if, def) are necessary.&#10;&#10;Recommendation:&#10;• Code: 1.1-1.2 (mild, allows repetition)&#10;• Text: 1.15-1.3 (stronger penalty)&#10;• Review: 1.0-1.1 (no penalty, short answers)">
<label style="font-size:11px;color:#8b949e;cursor:help">Repetition penalty 💡 <span id="config-rep-val" style="color:var(--accent);float:right">1.15</span></label>
<input type="range" id="config-repeat" min="1.0" max="2.0" step="0.05" value="1.15" style="width:100%;accent-color:var(--accent)">
<div style="font-size:10px;color:#30363d">1.0=ei · 1.15=oletus · 2.0=vahva</div>
<div style="font-size:10px;color:#30363d">1.0=none · 1.15=default · 2.0=strong</div>
</div>
</div>
</div>
<!-- Pipeline-järjestys -->
<!-- Pipeline order -->
<div>
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px">Pipeline-järjestys <span style="color:var(--border)">(vedä järjestääksesi)</span></label>
<label style="font-size:12px;color:#8b949e;display:block;margin-bottom:4px">Pipeline Order <span style="color:var(--border)">(drag to sort)</span></label>
<div id="config-pipeline" style="display:flex;gap:4px;flex-wrap:wrap"></div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<!-- Monaco Editor paneeli -->
<div id="panel-editor" class="panel">
<div style="display:flex;height:calc(100vh - 200px);gap:0;border:1px solid var(--border);border-radius:6px;overflow:hidden">
<div style="display:flex;flex:1;min-height:0;gap:0;border:1px solid var(--border);border-radius:6px;overflow:hidden">
<div id="editor-filetree" style="width:200px;min-width:150px;background:var(--bg);border-right:1px solid var(--border);overflow:auto;resize:horizontal;font-family:'Courier New',monospace;font-size:13px">
<div style="padding:10px 12px;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;border-bottom:1px solid var(--border)">Tiedostot</div>
<div id="editor-file-list" style="padding:4px 0">

View File

@@ -19,8 +19,8 @@ import Settings from "../components/Settings.astro";
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
<script is:inline type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11.14.0/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
window.mermaid = mermaid;
</script>
@@ -40,10 +40,10 @@ import Settings from "../components/Settings.astro";
<h1 class="hero-title">Näe miten AI-agenttitiimi rakentaa projektin.</h1>
<div class="hero-divider"></div>
<p class="hero-desc">
Seuraa reaaliajassa miten kuusi erikoistunutta AI-agenttia suunnittelee, koodaa, testaa ja katselmoi ohjelmistoprojektin askel askeleelta.
Seuraa reaaliajassa miten ohjelmistokehitykseen erikoistuneet AI-agentit suunnittelevat, koodaavat, testaavat ja katselmoivat ohjelmistoprojektin askel askeleelta. Nämä veijarit ovat erityisen hyviä Python-ohjelmoinnissa.
</p>
<p class="hero-notice" style="border-left-color:#ff6b00;color:#ff6b00">
Jokaisen agentin prompti, syöte ja tulos tallennetaan. Lopuksi saat toistettavan CrewAI-projektin.
Jokaisen agentin prompti, syöte ja tulos tallennetaan. Lopputuloksena syntyy CrewAI-projekti, jonka parissa voit jatkaa eteenpäin.
</p>
<div class="hero-input-group">
@@ -153,14 +153,100 @@ import Settings from "../components/Settings.astro";
return esc(code);
}
// === Mekaaninen koodivalidointi (QA-stepin tueksi) ===
function validateProjectCode(files) {
const issues = [];
const fileNames = Object.keys(files);
for (const [fname, code] of Object.entries(files)) {
if (!fname.endsWith('.py')) continue;
const lines = code.split('\n');
// 1. Relatiiviset importit
for (const line of lines) {
const m = line.match(/^from\s+\.(\w*)\s+import/);
if (m) issues.push(`ISSUE: ${fname}: relatiivinen import "from .${m[1]}" — käytä absoluuttista: from ${m[1]} import ...`);
}
// 2. Projektin sisäiset importit — tarkista että importatut nimet löytyvät
for (const line of lines) {
const m = line.match(/^from\s+(models|schemas|main)\s+import\s+(.+)/);
if (!m) continue;
const srcFile = m[1] + '.py';
const srcCode = files[srcFile];
if (!srcCode) { issues.push(`ISSUE: ${fname}: importtaa "${m[1]}" mutta ${srcFile} puuttuu`); continue; }
const names = m[2].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim());
for (const name of names) {
if (name && !srcCode.includes(name)) {
issues.push(`ISSUE: ${fname}: importtaa "${name}" moduulista ${m[1]} mutta sitä ei löydy ${srcFile}:stä`);
}
}
}
// 3. models.py: SQLite connect_args
if (fname === 'models.py') {
if (/sqlite/i.test(code) && !code.includes('check_same_thread'))
issues.push('ISSUE: models.py: SQLite create_engine puuttuu connect_args={"check_same_thread": False}');
}
// 4. schemas.py: date/datetime importit
if (fname === 'schemas.py') {
if (/:\s*date\b/.test(code) && !/from datetime import/.test(code))
issues.push('ISSUE: schemas.py: käyttää date-tyyppiä mutta "from datetime import date" puuttuu');
}
// 5. test_main.py: ei saa uudelleenmääritellä appia tai modeleita
if (fname === 'test_main.py') {
if (/^app\s*=\s*FastAPI\s*\(/m.test(code))
issues.push('ISSUE: test_main.py: luo oman FastAPI()-instanssin — pitäisi importata main.py:stä');
if (/^class\s+(Todo|User|Base)\b/m.test(code))
issues.push('ISSUE: test_main.py: uudelleenmäärittelee model-luokan — pitäisi importata models.py:stä');
}
// 6. main.py: FastAPI query param reitissä
if (fname === 'main.py') {
const routeMatches = code.matchAll(/@app\.\w+\(\s*["']([^"']+)["']/g);
for (const rm of routeMatches) {
if (rm[1].includes('?') && rm[1].includes('{'))
issues.push(`ISSUE: main.py: reitti "${rm[1]}" sisältää query parametrin — FastAPI:ssa query params tulevat funktion parametreina`);
}
}
}
// 7. pyproject.toml: poetry
if (files['pyproject.toml']) {
const toml = files['pyproject.toml'];
if (/\[tool\.poetry\]/.test(toml)) issues.push('ISSUE: pyproject.toml: sisältää [tool.poetry] — käytä vain [project] (PEP 621)');
if (!/fastapi/i.test(toml)) issues.push('ISSUE: pyproject.toml: puuttuu fastapi riippuvuuksista');
if (!/sqlalchemy/i.test(toml)) issues.push('ISSUE: pyproject.toml: puuttuu sqlalchemy riippuvuuksista');
}
// 8. Dockerfile: poetry
if (files['Dockerfile']) {
const df = files['Dockerfile'];
if (/poetry/i.test(df)) issues.push('ISSUE: Dockerfile: käyttää Poetryä — pitäisi käyttää uv:tä');
}
return issues;
}
// === Landing → App siirtymä ===
function startProject(task) {
function startProject(task, skipValidation) {
if (!task || !task.trim()) return;
const trimmed = task.trim();
if (!skipValidation && (trimmed.length < 10 || trimmed.split(/\s+/).length < 2)) {
const input = document.getElementById('landing-input');
if (input) {
input.classList.add('shake');
input.setAttribute('placeholder', 'Kuvaile tarkemmin, esim. "REST API käyttäjähallinnalle"');
setTimeout(() => input.classList.remove('shake'), 500);
}
return;
}
document.getElementById('landing').classList.add('hidden');
document.getElementById('app').classList.add('active');
// Käynnistä pipeline suoraan termExec:n kautta
setTimeout(() => {
if (typeof termExec === 'function') termExec(`kpn project "${task.trim()}"`);
if (typeof termExec === 'function') termExec(`kpn project "${trimmed}"`);
}, 200);
}
@@ -177,7 +263,7 @@ import Settings from "../components/Settings.astro";
if (e.key === 'Enter') startProject(e.target.value);
});
document.querySelectorAll('.example-btn').forEach(btn => {
btn.addEventListener('click', () => startProject(btn.dataset.prompt));
btn.addEventListener('click', () => startProject(btn.dataset.prompt, true));
});
// === Oppimispolku — renderöinti ===
@@ -251,20 +337,22 @@ pyproject.toml: project dependencies` },
prompt: `You are an expert Python developer. Write complete, production-ready code.
CRITICAL RULES:
1. Include ALL imports at the top of every file
2. Import from other project files: from models import User, SessionLocal
3. Pydantic schemas use different names than SQLAlchemy models: UserCreate, UserResponse (not User)
4. SQLAlchemy engine: create_engine(url, connect_args={"check_same_thread": False})
5. SessionLocal: sessionmaker(autocommit=False, autoflush=False, bind=engine)
6. FastAPI dependencies: def get_db(): db = SessionLocal(); try: yield db; finally: db.close()
7. Pydantic v2: use model_dump() not dict(), class Config: from_attributes = True
8. All CRUD endpoints: POST (201), GET list, GET by id, PUT, DELETE (204)
1. Include ALL imports at the top of every file — including stdlib (from datetime import date, etc.)
2. Import from other project files: from models import Todo, SessionLocal
3. NEVER use relative imports (from .models) — ALWAYS absolute: from models import ...
4. Pydantic schemas use different names than SQLAlchemy models: TodoCreate, TodoResponse (not Todo)
5. SQLAlchemy engine: create_engine(url, connect_args={"check_same_thread": False})
6. SessionLocal: sessionmaker(autocommit=False, autoflush=False, bind=engine)
7. FastAPI dependencies: def get_db(): db = SessionLocal(); try: yield db; finally: db.close()
8. Pydantic v2: use model_dump() not dict(), class Config: from_attributes = True
9. All CRUD endpoints: POST (201), GET list, GET by id, PUT, DELETE (204)
NEVER:
- Add explanations or comments like "# Add routes here"
- Leave out any import (EVERY type you use must be imported)
- Use relative imports (from .models)
- Add explanations or comments
- Leave placeholder code or TODO comments
- Use Flask syntax (app.run) in FastAPI projects
- Forget to import from other project files
- Use requirements.txt or Poetry — always use pyproject.toml with [project] format (PEP 621)
- Use pip install — use uv (e.g. uv run uvicorn main:app --reload)` },
data: { name: 'Data Engineer', avatar: '/avatars/pesukarhu_notext.webp', model: 'qwen-coder', order: 3,
@@ -274,16 +362,23 @@ NEVER:
YOUR RESPONSIBILITIES:
1. Design normalized database schemas with proper column types and constraints
2. Define SQLAlchemy models with __tablename__, primary keys, indexes, and relationships
3. Set up engine, SessionLocal, and Base in the same file (models.py or database.py)
3. Set up engine, SessionLocal, and Base in the same file (models.py)
4. Use String(length) not bare String for SQLite compatibility
5. Add nullable=False for required fields, unique=True where appropriate
6. Use Column(Integer, primary_key=True, index=True) for IDs
7. SQLite: create_engine(url, connect_args={"check_same_thread": False})
ENUM HANDLING (IMPORTANT):
- For status fields, use Column(String(20)) with a default value — simpler and SQLite-compatible
- Do NOT define Python Enum classes — use plain strings instead
- Example: status = Column(String(20), default="pending")
ALWAYS INCLUDE:
- from sqlalchemy import create_engine, Column, Integer, String
- from sqlalchemy.ext.declarative import declarative_base
- from sqlalchemy.orm import sessionmaker
- DATABASE_URL, engine, SessionLocal, Base` },
- DATABASE_URL, engine, SessionLocal, Base
- create_engine with connect_args={"check_same_thread": False}` },
qa: { name: 'QA', avatar: '/avatars/susi_notext.webp', model: 'qwen-coder', order: 4,
temperature: 0.4, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
prompt: `You are a QA engineer responsible for code review and automated testing.
@@ -305,29 +400,30 @@ WHEN REVIEWING:
- Be specific and actionable, not vague
WHEN WRITING TESTS:
- pytest as the test framework
- FastAPI TestClient for API endpoint testing
- SQLAlchemy in-memory SQLite for test database isolation
- ALWAYS import app from main.py: from main import app, get_db
- ALWAYS import Base from models.py: from models import Base
- NEVER redefine the app, models, or routes in the test file
- Use file-based SQLite for test isolation: sqlite:///./test.db
- Override the get_db dependency to use test database
- Use TestClient from fastapi.testclient
- Test all CRUD: create (201), list (200), get by id (200/404), update (200), delete (204)
- ALWAYS: from fastapi.testclient import TestClient` },
- Each test should create its own data, not depend on other tests` },
tester: { name: 'DevOps', avatar: '/avatars/laiskiainen_notext.webp', model: 'qwen-coder', order: 5,
temperature: 0.3, topK: 40, repeatPenalty: 1.1, maxTokens: 1024,
prompt: `You are a DevOps engineer specializing in containerization and deployment.
YOUR RESPONSIBILITIES:
1. Write production-ready Dockerfiles
2. Use multi-stage builds when appropriate
3. Follow security best practices (non-root user, minimal base image)
4. Configure health checks and proper signal handling
DOCKERFILE RULES:
- Use python:3.12-slim as base
- Install uv: COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
- Copy pyproject.toml first, then uv sync, then copy source
- Expose appropriate ports
- Use uv run for CMD
- ENV UV_CACHE_DIR=/tmp/uv-cache (MUST set before uv sync)
- Copy pyproject.toml first, then RUN uv sync, then COPY source files
- Set USER AFTER installing dependencies (uv sync needs write access)
- RUN useradd -m appuser && chown -R appuser:appuser /app /tmp/uv-cache
- NEVER use pip, poetry, or requirements.txt
- Expose port 8000
- CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Write ONLY the requested files, no explanations.` },
Write ONLY the Dockerfile, no explanations.` },
observer: { name: 'Observer', avatar: '/avatars/aikuinen_susi.webp', model: 'qwen-coder', order: 6,
temperature: 0.6, topK: 40, repeatPenalty: 1.15, maxTokens: 1024,
prompt: `You are an independent technical observer and risk analyst.
@@ -1023,6 +1119,171 @@ OUTPUT FORMAT:
return crewFiles;
}
// === Template Pipeline — rakennuspalaset ===
const SPEC_SYSTEM = `You are a software architect. Given a project description, output a JSON specification.
Output ONLY valid JSON, no explanations. Follow this exact schema:
{
"project_name": "short-name",
"description": "One sentence",
"entities": [
{
"name": "Todo",
"table_name": "todos",
"fields": [
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false, "default": null},
{"name": "description", "sa_type": "Text", "py_type": "str | None", "nullable": true, "default": null},
{"name": "status", "sa_type": "String(20)", "py_type": "str", "nullable": false, "default": "pending"}
]
}
],
"extra_imports": ["from datetime import date"]
}
RULES:
- sa_type: SQLAlchemy column type (String(N), Text, Integer, Date, DateTime, Boolean, Float)
- py_type: Python type hint (str, int, float, bool, date, datetime, str | None, etc.)
- Do NOT use Enum — use String(20) with a default value for status fields
- nullable: true = optional field
- default: null = no default, otherwise a string/number value
- extra_imports: stdlib imports needed in schemas.py (e.g. "from datetime import date")
- entity name: PascalCase singular, table_name: snake_case plural
- Keep it simple: 1-3 entities, 3-7 fields each`;
function extractJson(text) {
const m = text.match(/```(?:json)?\s*\n([\s\S]*?)```/);
if (m) text = m[1].trim();
let depth = 0, start = null;
for (let i = 0; i < text.length; i++) {
if (text[i] === '{') { if (depth === 0) start = i; depth++; }
else if (text[i] === '}') { depth--; if (depth === 0 && start !== null) { try { return JSON.parse(text.slice(start, i+1)); } catch(e) { continue; } } }
}
return null;
}
function tmplModels(spec) {
const saTypes = new Set(['Integer']);
for (const e of spec.entities) for (const f of e.fields) saTypes.add(f.sa_type.match(/^(\w+)/)[1]);
const imports = [...saTypes].sort().join(', ');
let code = `from sqlalchemy import create_engine, Column, ${imports}\n`;
code += `from sqlalchemy.ext.declarative import declarative_base\nfrom sqlalchemy.orm import sessionmaker\n\n`;
code += `DATABASE_URL = "sqlite:///./app.db"\n`;
code += `engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})\n`;
code += `SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)\nBase = declarative_base()\n\n`;
for (const e of spec.entities) {
code += `class ${e.name}(Base):\n __tablename__ = "${e.table_name}"\n id = Column(Integer, primary_key=True, index=True)\n`;
for (const f of e.fields) {
let parts = [`Column(${f.sa_type}`];
if (!f.nullable) parts.push('nullable=False');
if (f.default !== null && f.default !== undefined) parts.push(typeof f.default === 'string' ? `default="${f.default}"` : `default=${f.default}`);
code += ` ${f.name} = ${parts.join(', ')})\n`;
}
code += '\n';
}
code += 'Base.metadata.create_all(bind=engine)\n';
return code;
}
function tmplSchemas(spec) {
let code = 'from pydantic import BaseModel\n';
for (const imp of (spec.extra_imports || [])) code += imp + '\n';
code += '\n';
for (const e of spec.entities) {
code += `class ${e.name}Create(BaseModel):\n`;
for (const f of e.fields) {
if (f.default !== null && f.default !== undefined) code += ` ${f.name}: ${f.py_type} = ${typeof f.default === 'string' ? '"'+f.default+'"' : f.default}\n`;
else if (f.nullable && f.py_type.includes('None')) code += ` ${f.name}: ${f.py_type} = None\n`;
else code += ` ${f.name}: ${f.py_type}\n`;
}
code += `\nclass ${e.name}Response(${e.name}Create):\n id: int\n\n class Config:\n from_attributes = True\n\n`;
}
return code;
}
function tmplMain(spec) {
const modelNames = spec.entities.map(e => e.name).join(', ');
const createNames = spec.entities.map(e => e.name+'Create').join(', ');
const responseNames = spec.entities.map(e => e.name+'Response').join(', ');
let code = `from fastapi import FastAPI, Depends, HTTPException\nfrom sqlalchemy.orm import Session\n`;
code += `from models import Base, engine, SessionLocal, ${modelNames}\nfrom schemas import ${createNames}, ${responseNames}\n\n`;
code += `app = FastAPI()\n\ndef get_db():\n db = SessionLocal()\n try:\n yield db\n finally:\n db.close()\n\n`;
for (const e of spec.entities) {
const lo = e.name.toLowerCase(), tb = e.table_name;
code += `@app.post("/${tb}/", response_model=${e.name}Response, status_code=201)\n`;
code += `def create_${lo}(item: ${e.name}Create, db: Session = Depends(get_db)):\n`;
code += ` db_item = ${e.name}(**item.model_dump())\n db.add(db_item)\n db.commit()\n db.refresh(db_item)\n return db_item\n\n`;
code += `@app.get("/${tb}/", response_model=list[${e.name}Response])\n`;
code += `def list_${lo}s(db: Session = Depends(get_db)):\n return db.query(${e.name}).all()\n\n`;
code += `@app.get("/${tb}/{item_id}", response_model=${e.name}Response)\n`;
code += `def get_${lo}(item_id: int, db: Session = Depends(get_db)):\n`;
code += ` item = db.query(${e.name}).filter(${e.name}.id == item_id).first()\n`;
code += ` if not item:\n raise HTTPException(status_code=404, detail="${e.name} not found")\n return item\n\n`;
code += `@app.put("/${tb}/{item_id}", response_model=${e.name}Response)\n`;
code += `def update_${lo}(item_id: int, item: ${e.name}Create, db: Session = Depends(get_db)):\n`;
code += ` db_item = db.query(${e.name}).filter(${e.name}.id == item_id).first()\n`;
code += ` if not db_item:\n raise HTTPException(status_code=404, detail="${e.name} not found")\n`;
code += ` for key, value in item.model_dump().items():\n setattr(db_item, key, value)\n db.commit()\n db.refresh(db_item)\n return db_item\n\n`;
code += `@app.delete("/${tb}/{item_id}", status_code=204)\n`;
code += `def delete_${lo}(item_id: int, db: Session = Depends(get_db)):\n`;
code += ` db_item = db.query(${e.name}).filter(${e.name}.id == item_id).first()\n`;
code += ` if not db_item:\n raise HTTPException(status_code=404, detail="${e.name} not found")\n db.delete(db_item)\n db.commit()\n\n`;
}
return code;
}
function tmplTests(spec) {
let code = `from fastapi.testclient import TestClient\nfrom sqlalchemy import create_engine\nfrom sqlalchemy.orm import sessionmaker\n`;
code += `from main import app, get_db\nfrom models import Base\n\n`;
code += `TEST_DB = "sqlite:///./test.db"\ntest_engine = create_engine(TEST_DB, connect_args={"check_same_thread": False})\n`;
code += `TestSession = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)\nBase.metadata.create_all(bind=test_engine)\n\n`;
code += `def override_get_db():\n db = TestSession()\n try:\n yield db\n finally:\n db.close()\n\n`;
code += `app.dependency_overrides[get_db] = override_get_db\nclient = TestClient(app)\n\n`;
for (const e of spec.entities) {
const lo = e.name.toLowerCase(), tb = e.table_name;
const testData = {};
for (const f of e.fields) {
if (f.default !== null && f.default !== undefined) { testData[f.name] = f.default; continue; }
if (f.py_type.includes('str')) testData[f.name] = `Test ${f.name}`;
else if (f.py_type.includes('int')) testData[f.name] = 1;
else if (f.py_type.includes('float')) testData[f.name] = 1.0;
else if (f.py_type.includes('bool')) testData[f.name] = true;
else if (f.py_type.includes('date')) testData[f.name] = '2024-01-15';
}
const td = JSON.stringify(testData);
const firstStr = e.fields.find(f => f.py_type.includes('str') && f.name !== 'status');
const updateData = {...testData};
if (firstStr) updateData[firstStr.name] = `Updated ${firstStr.name}`;
const ud = JSON.stringify(updateData);
code += `def test_create_${lo}():\n response = client.post('/${tb}/', json=${td})\n assert response.status_code == 201\n assert 'id' in response.json()\n\n`;
code += `def test_list_${lo}s():\n client.post('/${tb}/', json=${td})\n response = client.get('/${tb}/')\n assert response.status_code == 200\n assert len(response.json()) >= 1\n\n`;
code += `def test_get_${lo}_by_id():\n created = client.post('/${tb}/', json=${td}).json()\n item_id = created['id']\n response = client.get(f'/${tb}/{item_id}')\n assert response.status_code == 200\n assert response.json()['id'] == item_id\n\n`;
code += `def test_get_${lo}_not_found():\n response = client.get('/${tb}/99999')\n assert response.status_code == 404\n\n`;
code += `def test_update_${lo}():\n created = client.post('/${tb}/', json=${td}).json()\n item_id = created['id']\n response = client.put(f'/${tb}/{item_id}', json=${ud})\n assert response.status_code == 200\n\n`;
code += `def test_delete_${lo}():\n created = client.post('/${tb}/', json=${td}).json()\n item_id = created['id']\n response = client.delete(f'/${tb}/{item_id}')\n assert response.status_code == 204\n response = client.get(f'/${tb}/{item_id}')\n assert response.status_code == 404\n\n`;
}
return code;
}
function tmplPyproject(spec) {
const name = (spec.project_name || 'app').toLowerCase().replace(/\s+/g, '-');
return `[project]\nname = "${name}"\nversion = "0.1.0"\nrequires-python = ">=3.11"\ndependencies = [\n "fastapi",\n "uvicorn[standard]",\n "sqlalchemy",\n "pytest",\n "httpx",\n]\n`;
}
function tmplDockerfile() {
return `FROM python:3.12-slim\nCOPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv\nENV UV_CACHE_DIR=/tmp/uv-cache\nWORKDIR /app\nCOPY pyproject.toml .\nRUN uv sync\nCOPY *.py .\nRUN useradd -m appuser && chown -R appuser:appuser /app /tmp/uv-cache\nUSER appuser\nEXPOSE 8000\nCMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]\n`;
}
function tmplGenerate(spec) {
return {
'models.py': tmplModels(spec),
'schemas.py': tmplSchemas(spec),
'main.py': tmplMain(spec),
'test_main.py': tmplTests(spec),
'pyproject.toml': tmplPyproject(spec),
'Dockerfile': tmplDockerfile(),
};
}
async function kpnProject(task) {
pipelineAbort = new AbortController();
const promptLog = [];
@@ -1040,188 +1301,62 @@ OUTPUT FORMAT:
promptLog.push({ step: 0, agentKey: 'client', agentName: cli.name, model: cli.model, label: 'requirements', systemPrompt: cli.prompt || '', userPrompt: task, response: brief });
termLog(` <span style="color:#8b949e">Requirements ready → Manager</span>`);
// Valitaan mallipohja automaattisesti briefin perusteella
const template = selectTemplate(brief);
// === Vaihe 2: JSON-speksi vaatimuksista ===
termLog(`\n<span style="color:#d29922;font-weight:bold">[1] ${esc(mgr.name)}</span> — JSON-speksi`);
highlightAgent('manager');
explainStep('Arkkitehtuuri', `${mgr.name} analysoi vaatimukset ja tuottaa JSON-speksin: entiteetit, kentät, tyypit.`);
// Tiedostolista: mallipohjasta tai managerin dynaamisesta suunnitelmasta
let fileOrder = [];
let fileDefs = {};
const specRaw = await kpnRun(mgr.model, `${brief}\n\nOutput a JSON spec for this project.`, false, { ...mgr, prompt: SPEC_SYSTEM });
const spec = specRaw ? extractJson(specRaw) : null;
promptLog.push({ step: 1, agentKey: 'manager', agentName: mgr.name, model: mgr.model, label: 'JSON-speksi', systemPrompt: SPEC_SYSTEM, userPrompt: brief, response: specRaw || '' });
if (template) {
// Mallipohja löytyi — käytetään sen rakennetta
fileOrder = template.order;
fileDefs = template.files;
explainStep('Template', `Detected "${template.name}" — ${fileOrder.length} files: ${fileOrder.join(', ')}.`);
} else {
// Vapaa tila — Manageri päättää tiedostorakenteen
termLog(`\n<span style="color:#d29922;font-weight:bold">[1] ${esc(mgr.name)}</span> — File structure`);
highlightAgent('manager');
explainStep('Free mode', 'No suitable template found. Manager plans the architecture.');
const planPrompt = `PROJECT REQUIREMENTS:\n${brief}\n\nPlan the file structure for this project. List each file on its own line:\nfilename.ext: one-line description\n\nMaximum ${pipelineConfig.freeMaxFiles} files. List dependency files first.`;
const plan = await kpnRun(mgr.model, planPrompt, false, mgr);
if (!plan) { termLog(' ✗ Planning failed', '#f85149'); return; }
// Parsitaan managerin tuottama tiedostolista
for (const line of plan.split('\n')) {
const m = line.match(/^\s*[-*]?\s*(\S+\.\w+)\s*[:\-]\s*(.+)/);
if (m) {
const fname = m[1].replace(/^`|`$/g, '');
fileOrder.push(fname);
fileDefs[fname] = { description: m[2].trim(), instructions: m[2].trim() };
}
}
if (fileOrder.length === 0) {
termLog(' ✗ Manager produced no file list', '#f85149');
return;
}
explainStep('Plan', `${fileOrder.length} files: ${fileOrder.join(', ')}`);
promptLog.push({ step: 1, agentKey: 'manager', agentName: mgr.name, model: mgr.model, label: 'file structure', systemPrompt: mgr.prompt || '', userPrompt: planPrompt, response: plan });
if (!spec || !spec.entities || spec.entities.length === 0) {
termLog(' ✗ JSON-speksi epäonnistui — fallback vapaaseen generointiin', '#f85149');
// TODO: fallback vanhaan pipeline-logiikkaan
return;
}
const files = {};
termLog(` <span style="color:#3fb950">✓ ${spec.entities.length} entiteettiä: ${spec.entities.map(e => e.name).join(', ')}</span>`);
// === Vaihe 3: Koodigenerointi templateista ===
const files = tmplGenerate(spec);
const fileOrder = Object.keys(files);
const agentMap = { 'models.py': 'data', 'schemas.py': 'coder', 'main.py': 'coder', 'test_main.py': 'qa', 'pyproject.toml': 'coder', 'Dockerfile': 'tester' };
const agentNames = { data: 'Data Engineer', coder: 'Coder', qa: 'QA', tester: 'DevOps' };
for (let i = 0; i < fileOrder.length; i++) {
const fileName = fileOrder[i];
const fileDef = fileDefs[fileName];
if (!fileDef) continue;
const agentKey = agentMap[fileName] || 'coder';
const agentName = agentNames[agentKey] || 'Coder';
const agent = agents[agentKey] || cdr;
const step = i + 1;
// Valitaan oikea agentti tiedostotyypin mukaan
const isDbFile = fileName === 'models.py' || fileName === 'database.py' || fileName === 'etl.py';
const dataAgent = agents.data || Object.values(agents)[3];
const fileAgent = isDbFile && dataAgent ? dataAgent : cdr;
const fileAgentKey = isDbFile && dataAgent ? 'data' : 'coder';
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${i+2}/${fileOrder.length+1}] ${esc(agent.name)}</span> — ${esc(fileName)}`);
highlightAgent(agentKey);
explainStep(fileName, `${agent.name} generoi ${fileName} rakennuspalasista (template pipeline).`);
termLog(`\n<span style="color:#3fb950;font-weight:bold">[${step}/${fileOrder.length}] ${esc(fileAgent.name)}</span> — ${esc(fileName)}`);
highlightAgent(fileAgentKey);
// Pieni viive UX:ää varten — näyttää agentin "työskentelevän"
await new Promise(r => setTimeout(r, 300));
explainStep(fileName, fileDef.instructions || fileDef.description);
// Rakennetaan prompti
let prompt = '';
if (fileAgent.prompt) prompt += fileAgent.prompt + '\n\n';
// Esimerkki (vain mallipohjatilassa)
if (fileDef.example) {
prompt += `EXAMPLE of ${fileName} (for a different project, adapt to this one):\n`;
prompt += '```\n' + fileDef.example + '\n```\n\n';
}
// Aiemmin generoidut tiedostot (konteksti)
const prevFiles = Object.entries(files);
if (prevFiles.length > 0) {
prompt += 'Already written files in THIS project:\n';
for (const [name, code] of prevFiles) {
prompt += `--- ${name} ---\n${code}\n\n`;
}
}
// Asiakkaan vaatimusmäärittely
prompt += `PROJECT REQUIREMENTS (from product owner):\n${brief}\n\n`;
// Tehtävä
prompt += `NOW write "${fileName}" for THIS project: ${task}\n`;
if (fileDef.instructions) prompt += fileDef.instructions + '\n';
prompt += 'Adapt to the project requirements. Import from already written files. Write ONLY the code, no explanations.';
const code = await kpnRun(fileAgent.model, prompt, false, fileAgent);
if (!code) {
termLog(` ✗ Keskeytyi (${fileName})`, '#f85149');
return;
}
files[fileName] = code;
promptLog.push({ step: promptLog.length, agentKey: fileAgentKey, agentName: fileAgent.name, model: fileAgent.model, label: fileName, systemPrompt: fileAgent.prompt || '', userPrompt: prompt, response: code });
promptLog.push({ step: promptLog.length, agentKey, agentName: agent.name, model: 'template', label: fileName, systemPrompt: '(template pipeline — ei LLM-promptia)', userPrompt: `Generated from spec: ${JSON.stringify(spec.entities.map(e => e.name))}`, response: files[fileName] });
termLog(` <span style="color:#3fb950">✓ ${files[fileName].split('\\n').length} riviä</span>`);
}
const allCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n');
let stepN = fileOrder.length + 1;
let stepN = fileOrder.length + 2;
// QA-katselmointi → Coder-korjaus -luuppi (max N kierrosta)
// === Vaihe 4: Mekaaninen QA-validointi ===
const qaAgent = agents.qa || Object.values(agents)[4];
const MAX_REVIEW_ROUNDS = pipelineConfig.maxReviewRounds;
termLog(`\n<span style="color:#d2a8ff;font-weight:bold">[${stepN}] ${esc(qaAgent.name)}</span> — validointi`);
highlightAgent('qa');
explainStep('Validointi', `${qaAgent.name} ajaa mekaanisen koodivalidoinnin.`);
for (let round = 0; round < MAX_REVIEW_ROUNDS; round++) {
const currentCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n');
// QA katselmoi
termLog(`\n<span style="color:#d2a8ff;font-weight:bold">[${stepN}] ${esc(qaAgent.name)}</span> — katselmointi${round > 0 ? ' (kierros '+(round+1)+')' : ''}`);
highlightAgent('qa');
if (round === 0) explainStep('Katselmointi', `${qaAgent.name} analysoi koodin: importit, nimeämiset, virheenkäsittely, tietoturva.`);
else explainStep('Uudelleentarkistus', `${qaAgent.name} tarkistaa korjaukset.`);
const reviewPrompt = (qaAgent.prompt ? qaAgent.prompt+'\n\n' : '') + `Review this project code for issues. If everything is correct, respond with "LGTM". Otherwise list issues as "ISSUE: filename.py: description".\n\n${currentCode}`;
const review = await kpnRun(qaAgent.model, reviewPrompt, false, qaAgent);
promptLog.push({ step: promptLog.length, agentKey: 'qa', agentName: qaAgent.name, model: qaAgent.model, label: 'review' + (round > 0 ? '_r'+(round+1) : ''), systemPrompt: qaAgent.prompt || '', userPrompt: reviewPrompt, response: review || '' });
stepN++;
// LGTM → ei korjauksia tarvita
if (!review || review.toLowerCase().includes('lgtm')) {
termLog(` <span style="color:#3fb950">✓ ${esc(qaAgent.name)}: LGTM</span>`);
break;
}
// Korjaukset → Coder
termLog(`\n<span style="color:#d29922;font-weight:bold">[${stepN}] ${esc(cdr.name)}</span> — korjaukset${round > 0 ? ' (kierros '+(round+1)+')' : ''}`);
highlightAgent('coder');
explainStep('Korjaus', `${qaAgent.name} löysi ongelmia. ${cdr.name} saa palautteen ja korjaa.`);
const fixPrompt = `${cdr.prompt ? cdr.prompt+'\n\n' : ''}Fix these issues:\n${review}\n\nCurrent code:\n${currentCode}\n\nWrite ALL corrected files. Start each file with: --- filename.py ---`;
const fixedCode = await kpnRun(cdr.model, fixPrompt, false, cdr);
// Parsitaan korjatut tiedostot takaisin files-objektiin
if (fixedCode) {
promptLog.push({ step: promptLog.length, agentKey: 'coder', agentName: cdr.name, model: cdr.model, label: 'korjaus' + (round > 0 ? '_r'+(round+1) : ''), systemPrompt: cdr.prompt || '', userPrompt: fixPrompt, response: fixedCode });
const fixedParts = fixedCode.split(/^---\s*(\S+)\s*---$/m);
for (let j = 1; j < fixedParts.length; j += 2) {
const fname = fixedParts[j].trim();
const fcode = (fixedParts[j+1] || '').trim();
if (fname && fcode && files[fname] !== undefined) {
files[fname] = fcode;
}
}
}
stepN++;
} // for review round
// Päivitetään allCode korjausten jälkeen
const updatedCode = Object.entries(files).map(([n,c]) => `--- ${n} ---\n${c}`).join('\n\n');
// QA: testit (saa katselmoidut ja korjatut tiedostot)
if (qaAgent) {
termLog(`\n<span style="color:#d2a8ff;font-weight:bold">[${stepN}] ${esc(qaAgent.name)}</span> — testit`);
highlightAgent('qa');
explainStep('Testit', `${qaAgent.name} kirjoittaa pytest-testit katselmoidulle koodille.`);
const qaTestPrompt = (qaAgent.prompt ? qaAgent.prompt+'\n\n' : '') + `Write pytest tests for this project:\n\n${updatedCode}\n\nWrite a complete test_main.py file with TestClient.`;
const tests = await kpnRun(qaAgent.model, qaTestPrompt, false, qaAgent);
if (tests) {
files['test_main.py'] = tests;
promptLog.push({ step: promptLog.length, agentKey: 'qa', agentName: qaAgent.name, model: qaAgent.model, label: 'test_main.py', systemPrompt: qaAgent.prompt || '', userPrompt: qaTestPrompt, response: tests });
}
stepN++;
}
// DevOps: Dockerfile + deployment (saa kaikki tiedostot mukaan lukien testit)
const tst = agents.tester || Object.values(agents)[5];
const allFilesNow = Object.keys(files).join(', ');
termLog(`\n<span style="color:var(--accent);font-weight:bold">[${stepN}] ${esc(tst.name)}</span> — Dockerfile`);
highlightAgent('tester');
explainStep('Dockerfile', `${tst.name} generoi Docker-kontin kaikista ${Object.keys(files).length} tiedostosta: ${allFilesNow}`);
const dockerPrompt = (tst.prompt ? tst.prompt+'\n\n' : '') +
`Write a Dockerfile for this Python FastAPI project.\n\n` +
`Project files: ${allFilesNow}\n\n` +
`Requirements:\n` +
`- Use python:3.12-slim as base\n` +
`- Install uv: COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv\n` +
`- Copy pyproject.toml first, then uv sync, then copy source\n` +
`- Expose port 8000\n` +
`- CMD: uv run uvicorn main:app --host 0.0.0.0 --port 8000\n` +
`\nWrite ONLY the Dockerfile, no explanations.`;
const dockerfile = await kpnRun(tst.model, dockerPrompt, false, tst);
if (dockerfile) {
files['Dockerfile'] = dockerfile;
promptLog.push({ step: promptLog.length, agentKey: 'tester', agentName: tst.name, model: tst.model, label: 'Dockerfile', systemPrompt: tst.prompt || '', userPrompt: dockerPrompt, response: dockerfile });
const mechIssues = validateProjectCode(files);
if (mechIssues.length > 0) {
termLog(` <span style="color:#d29922">⚠ ${mechIssues.length} ongelmaa (template-bugeja — korjattava):</span>`);
for (const issue of mechIssues) termLog(` <span style="color:#d29922">${esc(issue)}</span>`);
} else {
termLog(` <span style="color:#3fb950">✓ Kaikki tiedostot validoitu — 0 ongelmaa</span>`);
}
promptLog.push({ step: promptLog.length, agentKey: 'qa', agentName: qaAgent.name, model: 'mekaaninen', label: 'validointi', systemPrompt: '(mekaaninen validointi — ei LLM:ää)', userPrompt: 'validateProjectCode(files)', response: mechIssues.length === 0 ? 'OK — 0 issues' : mechIssues.join('\n') });
stepN++;
// Tarkkailija: yhteenveto + raportti + arvosana
@@ -1380,7 +1515,8 @@ OUTPUT FORMAT:
const lv = new DataView(local.buffer);
lv.setUint32(0, 0x04034b50, true); // signature
lv.setUint16(4, 20, true); // version needed
lv.setUint16(8, 8, true); // UTF-8 flag
lv.setUint16(6, 0x0800, true); // UTF-8 flag (bit 11)
lv.setUint16(8, 0, true); // compression: stored
lv.setUint32(14, crc, true); // CRC-32
lv.setUint32(18, dataBytes.length, true); // compressed size
lv.setUint32(22, dataBytes.length, true); // uncompressed size
@@ -1395,7 +1531,8 @@ OUTPUT FORMAT:
cv.setUint32(0, 0x02014b50, true); // signature
cv.setUint16(4, 20, true); // version made by
cv.setUint16(6, 20, true); // version needed
cv.setUint16(8, 8, true); // UTF-8 flag
cv.setUint16(8, 0x0800, true); // UTF-8 flag (bit 11)
cv.setUint16(10, 0, true); // compression: stored
cv.setUint32(16, crc, true);
cv.setUint32(20, dataBytes.length, true);
cv.setUint32(24, dataBytes.length, true);

View File

@@ -17,13 +17,24 @@ body {
font-size: 16px;
background: var(--bg);
color: var(--text);
min-height: 100vh;
height: 100vh;
overflow: hidden;
}
.container { max-width: 1600px; margin: 0 auto; padding: 20px 40px; }
.container {
max-width: 1600px;
margin: 0 auto;
padding: 20px 40px;
height: 100vh;
display: flex;
flex-direction: column;
}
#app:not(.active) { display: none; }
#landing.hidden { display: none; }
/* Tabs */
.tabs { display: flex; gap: 4px; margin-bottom: 16px; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-shrink: 0; }
.tab {
padding: 10px 20px; border-radius: 6px 6px 0 0; cursor: pointer;
border: 1px solid var(--border); border-bottom: none;
@@ -33,7 +44,7 @@ body {
/* Panels */
.panel { display: none; }
.panel.active { display: block; }
.panel.active { display: flex; flex-direction: column; flex: 1; min-height: 0; }
/* Status bar */
.status-bar {
@@ -52,7 +63,7 @@ body {
.terminal {
background: #010409; border: 1px solid var(--border); border-top: none;
font-family: 'Courier New', monospace; font-size: 16px;
min-height: 400px; max-height: 70vh; overflow-y: auto;
flex: 1; min-height: 0; max-height: none; overflow-y: auto;
padding: 12px 16px;
}
.terminal-line { padding: 1px 0; white-space: pre-wrap; word-break: break-word; }
@@ -172,6 +183,13 @@ body {
.agent-avatar.active img {
border-color: var(--accent);
box-shadow: 0 0 25px rgba(88,166,255,0.8);
animation: agentBlink 1.5s infinite;
}
@keyframes agentBlink {
0% { opacity: 0.8; box-shadow: 0 0 15px rgba(88,166,255,0.5); }
50% { opacity: 1.0; box-shadow: 0 0 35px rgba(88,166,255,1.0); }
100% { opacity: 0.8; box-shadow: 0 0 15px rgba(88,166,255,0.5); }
}
/* Settings */
@@ -265,6 +283,16 @@ body {
border-color: #ff6b00; box-shadow: 0 0 0 3px rgba(255,107,0,0.15);
}
.hero-input::placeholder { color: #484f58; }
.hero-input.shake {
animation: shake 0.4s ease;
border-color: #f85149;
box-shadow: 0 0 0 3px rgba(248,81,73,0.2);
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-6px); }
40%, 80% { transform: translateX(6px); }
}
.hero-btn {
padding: 14px 28px; font-size: 16px; font-weight: 600;
font-family: 'Inter', sans-serif;