25 Commits

Author SHA1 Message Date
20cea8f268 Model benchmark: testaa kaikki Ollama-mallit järjestelmällisesti
Ajaa täyden pipeline-kierroksen per malli × skenaario:
1. Client-prompti → vaatimukset
2. Manager/SPEC_SYSTEM → JSON-speksi
3. Template-generointi → koodi
4. Validointi + LLM-korjaussilmukka
5. uv sync + pytest

Tuottaa vertailutaulukon: speksin laatu, testien tulos, nopeus.
Tukee suoraa Ollamaa (--ollama) ja hub-reittiä (--hub).
2026-04-13 22:08:47 +03:00
38a18c555b Debug: reititys logittaa kaikki solmut ja niiden tilat 2026-04-13 21:53:40 +03:00
8138e41aa1 native-noden tuunausta 2026-04-13 21:29:05 +03:00
6ee5bdf960 Native node: lämmittelykutsu lataa mallin VRAM:iin heti käynnistyksessä 2026-04-13 21:23:56 +03:00
cf3bf54bf8 kipina-node: automaattinen versiopäivitys build-hashilla
Poistettu interaktiivinen "haluatko korvata?" -kysely. Tilalle:
- Bootstrap hakee .build-hash palvelimelta joka käynnistyksellä
- Vertaa paikalliseen kipina-node-bin.hash
- Lataa uuden automaattisesti jos hash eroaa
- Näyttää version käynnistyksen yhteydessä

Ei enää tilannetta jossa vanha binääri jää vahingossa ajoon.
2026-04-13 21:21:48 +03:00
56f21a96c9 TUI: VRAM-tila värikoodattu (vihreä=100% GPU, keltainen=osittainen, punainen=CPU) 2026-04-13 21:12:50 +03:00
763b93396c Reititys: busy-solmut suodatetaan pois — työ jakautuu solmuille
Aiemmin busy-lukko luettiin mutta sitä ei käytetty suodatukseen,
joten sama solmu valittiin aina uudelleen vaikka se oli varattu.
Nyt matching-lista suodattaa pois busy-solmut, joten toinen
vapaa solmu saa tehtävän. Heavy-fallback kevyempään solmuun
jos kaikki isot mallit ovat varattuja.
2026-04-13 21:09:24 +03:00
e09962940a Native node: VRAM-tila TUI:ssa (ollama ps)
- fetch_ps(): hakee /api/ps ja palauttaa ModelVramStatus
- ModelVramStatus: size vs size_vram → 100% GPU / osittainen / CPU
- TUI: uusi "VRAM: ✓ qwen3:32b (20.1 GB) — 100% GPU" -rivi
- Taustapäivitys 30s välein
- Tuore linux-x86_64 binääri
2026-04-13 21:06:27 +03:00
5e44b63b0c Native node: tuore linux-x86_64 -binääri (reconnect, timestamp, node_id) 2026-04-13 16:54:28 +03:00
0f3881aa02 Fix: async RwLock read ennen Mutex-scopea (Send-yhteensopivuus) 2026-04-13 16:34:51 +03:00
fa85dcc5b3 Älykäs reititys: capability=heavy priorisoi isoimman mallin solmun
Hub:
- Parsii node_models:sta suurimman mallin parametrimäärän (B)
  per solmu (esim. qwen3:32b → 32, qwen2.5-coder:7b → 7)
- Tallentaa node_max_param_b: HashMap<u64, u32>
- ChatCompletionRequest: uusi capability-kenttä ("heavy"/"light")
- Reitityslogiikka: capability=heavy → valitsee solmun jolla on
  suurin malli; oletus → natiivi ensin kuten ennenkin

Frontend (pipeline):
- JSON-speksin generointi: capability=heavy
- QA-korjaussilmukan koodikorjaus: capability=heavy
- Observer/README-arviointi: capability=heavy
- Vaatimukset (Client): oletus (kevyt, kelpaa pieni malli)

Tämä mahdollistaa sen, että A40-koneella pyörivä Qwen3:32B
saa raskaat tehtävät ja selaimen 0.5B-malli hoitaa kevyet.
2026-04-13 16:30:47 +03:00
58d93613f0 Hero-kuvat: oikeat kipina.tech-kuvat (forge, serpent, gecko) 2026-04-13 14:33:11 +03:00
66b4435362 Teemavalitsin: painike kiertää gecko/forge/serpent, oletus forge
- Teemapainike (emoji) oikeaan yläkulmaan kuten kipina.tech:ssä
- Oletus forge (syaani), tallennetaan localStorage:iin
- Hero-kuva vaihtuu teeman mukaan fade-efektillä
- Kolme hero-kuvaa: gecko_hero, forge_hero (hämähäkki), serpent_hero
2026-04-13 14:29:14 +03:00
3a00de9b8e Kolme kipina.tech-teemaa: gecko, forge, serpent — satunnaisvalinta
Tuodaan kipina.techin kolme visuaalista teemaa kipina.studioon:
- gecko: lämmin kulta/oranssi (#ff7b00)
- forge: kyber-sininen/syaani (#00e5ff)
- serpent: neon-turkoosi (#00ffff)

Teema arvotaan satunnaisesti joka sivulatauksella. Kaikki aiemmin
hardcoodatut #ff6b00-aksenttivärit korvattu CSS-muuttujilla
(--hero-accent, --hero-glow) jotka mukautuvat teemaan.
2026-04-13 14:22:33 +03:00
670141c8c3 QA-korjaussilmukka: validointi delegoi ongelmat Coder-agentille
Aiemmin mekaaninen validateProjectCode() vain listasi ongelmat terminaaliin.
Nyt pipeline toimii näin:
1. QA-agentti ajaa mekaanisen validoinnin
2. Jos ongelmia → ryhmittelee ne tiedostoittain
3. Delegoi jokaisen tiedoston korjauksen oikealle agentille (Coder/Data/QA)
4. Agentti (LLM) palauttaa korjatun tiedoston
5. Validointi ajetaan uudelleen — max 2 korjauskierrosta
6. Lopullinen tulos näytetään vihreänä/punaisena
7. Tarkkailija arvioi lopullisen version

Kaikki korjausvaiheet tallentuvat promptLog:iin → näkyvät oppimispolussa.
2026-04-13 14:09:10 +03:00
59daebbd38 Template pipeline: docker-compose.yml ja .dockerignore mukaan generointiin
Jokainen generoitu projekti sisältää nyt:
- Dockerfile (oli jo)
- docker-compose.yml (uusi: build + portti 8000 + named volume)
- .dockerignore (uusi: .venv, __pycache__, *.db, .git)

Testattu: docker compose build + kontin käynnistys + API-kutsu OK.
2026-04-13 13:27:50 +03:00
42b71dbf77 Templatejen laatu: declarative_base, ConfigDict, ForeignKey
- models.py: sqlalchemy.ext.declarative → sqlalchemy.orm (poistaa
  MovedIn20Warning-varoituksen)
- schemas.py: class Config → model_config = ConfigDict() (poistaa
  PydanticDeprecatedSince20-varoituksen)
- models.py: _id-kentät saavat ForeignKey("taulu.id") kun speksissä
  on relationship-merkintä

Testattu: 10 erilaista projektia, 78/78 testiä läpi, 0 varoitusta.
2026-04-13 13:18:11 +03:00
b88a741f85 Template pipeline: JS→Python -arvomuunnokset korjattu
Ongelma: generoiduissa Python-tiedostoissa JS-booleanit (false/true)
päätyvät sellaisenaan Python-koodiin, jossa ne eivät ole valideja.
Lisäksi datetime-importit puuttuivat kun LLM antoi extra_imports-kentässä
pelkän "datetime"-merkkijonon eikä kokonaista import-lausetta.

Korjaukset:
- pyLiteral(): muuntaa JS-arvot Python-literaaleiksi (false→False jne.)
- pyJsonLiteral(): testidatan serialisointi Python-dict-muodossa
- tmplSchemas: datetime-importit tunnistetaan automaattisesti kentistä
- tmplModels + tmplSchemas: oletusarvot pyLiteral()-funktion kautta
- tmplTests: JSON.stringify korvattu pyJsonLiteral():lla
- Validaattori: tunnistaa nyt datetime-import-puutteet ja JS-booleanit

Testattu: molemmat aiemmin rikkinäiset speksit generoivat nyt toimivan
koodin — 6/6 pytest-testiä läpi molemmilla.
2026-04-13 12:44:08 +03:00
Jaakko Vanhala
68c7195d54 TEMPLATING.md: periaatteet rakennuspalapohjaiselle koodigeneroinnille 2026-04-13 06:59:12 +03:00
Jaakko Vanhala
3d20238eef Uudelleenreititystä ja templatingia 2026-04-13 06:54:56 +03:00
Jaakko Vanhala
8b8ba01af3 Toipuminen yhteyskatkoksesta: hub ilmoittaa API:lle, node reconnectaa
- Hub: kun node katoaa kesken tehtävän, palauttaa virheen API-kutsulle
- Hub: node_active_task seuraa mikä tehtävä on kesken
- Hub: timeout 600s → 120s
- Node: reconnect nollaa busy-tilan ja näyttää sen TUI:ssa
2026-04-13 06:50:45 +03:00
Jaakko Vanhala
a3b95a56e8 Native node: timestamp lokiin, node_id otsikkoon, yhdistetty-tila 2026-04-13 06:32:11 +03:00
Jaakko Vanhala
5b20ebe800 Opas: terminologia korjattu — relaatio on taulu, relationship on yhteys 2026-04-12 20:32:52 +03:00
Jaakko Vanhala
ffe9bd6902 Opas päivitetty: relaatiotuki, architect-agentin rooli, vertailuluvut 2026-04-12 20:27:41 +03:00
Jaakko Vanhala
d27068b11a UI-korjaus korjattu (GTFO gemini) 2026-04-12 20:18:39 +03:00
19 changed files with 1423 additions and 113 deletions

157
TEMPLATING.md Normal file
View File

@@ -0,0 +1,157 @@
# Templating — rakennuspalaset koodigeneroinnissa
## Perusperiaate
Kielimalli päättää **mitä** rakennetaan (entiteetit, kentät, tyypit, yhteydet).
Template-funktiot päättävät **miten** se rakennetaan (importit, engine setup, testikonfiguraatio).
```
Projektikuvaus → LLM → JSON-speksi → Templateit → Koodi → Validointi
```
LLM:n kontribuutio on yksi JSON-rakenne. Kaikki muu on determinististä —
sama speksi tuottaa aina saman koodin.
## Miksi tämä toimii
Pienen kielimallin (0.5B7B) vahvuudet ja heikkoudet ovat epäsymmetrisiä:
| Tehtävä | LLM:n kyky | Ratkaisu |
|---------|-----------|----------|
| Tunnista entiteetit kuvauksesta | Hyvä | LLM tekee |
| Valitse kenttätyypit | Hyvä | LLM tekee |
| Muista importit oikein | Huono | Template tekee |
| SQLite connect_args | Huono | Template tekee |
| Testikonfiguraatio | Huono | Template tekee |
| Dockerfile-rakenne | Huono | Template tekee |
Annetaan mallin tehdä se missä se on hyvä. Hoidetaan loput mekaanisesti.
## JSON-speksi
Kielimallin ainoa tuotos on JSON joka kuvaa projektin rakenteen:
```json
{
"project_name": "library-app",
"entities": [
{
"name": "Author",
"table_name": "authors",
"fields": [
{"name": "name", "sa_type": "String(255)", "py_type": "str", "nullable": false, "default": null}
]
},
{
"name": "Book",
"table_name": "books",
"fields": [
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false, "default": null},
{"name": "author_id", "sa_type": "Integer", "py_type": "int", "nullable": false, "default": null}
]
}
],
"relationships": [
{"from": "Book", "field": "author_id", "to": "Author", "type": "many-to-one"}
],
"extra_imports": []
}
```
Speksin laatu ratkaisee kaiken. Hyvä speksi → hyvä projekti. Huono speksi →
teknisesti toimiva mutta sisällöllisesti väärä projekti.
## Architect-promptin rooli
Architect-agentti (JSON-speksin generoija) on kriittisin kohta koko pipelinessa.
Sitä ohjataan neljällä keinolla:
1. **Chain-of-thought** — malli miettii ensin entiteetit, sitten kentät,
sitten yhteydet, vasta lopuksi JSON
2. **Domain-esimerkit** — Todo, verkkokauppa, blogi — malli näkee miltä
hyvä speksi näyttää eri domaineissa
3. **Anti-patternit** — turhat ID-kentät, Enum-tyypit, suomenkieliset nimet
4. **Yhteyssäännöt** — jokainen `_id`-kenttä tarvitsee relationship-merkinnän
Isompi malli tässä yhdessä kohdassa parantaisi kaikkien projektien laatua.
## Templateit
Jokainen template on funktio joka ottaa speksin ja palauttaa koodia:
```
tmplModels(spec) → models.py (SQLAlchemy, ForeignKey, relationship)
tmplSchemas(spec) → schemas.py (Pydantic Create/Response/Detail)
tmplMain(spec) → main.py (FastAPI CRUD + nested endpoints + FK-validointi)
tmplTests(spec) → test_main.py (pytest + TestClient + helper-funktiot)
tmplPyproject(spec) → pyproject.toml (PEP 621)
tmplDockerfile() → Dockerfile (uv + non-root user)
```
Templateit generoivat automaattisesti:
- ForeignKey-constraintit ja relationship()-määrittelyt
- Nested endpointit (`GET /authors/{id}/books/`)
- FK-validointi (404 jos parent-entiteettiä ei ole)
- Detail-schemat (Book + author-data mukana)
- Test-helperit jotka luovat parent-entiteetit ensin
- Bad FK -testit (varmistaa että orpo-validointi toimii)
## Validointi
Generoitu koodi validoidaan mekaanisesti ennen käyttöä:
- Syntaksitarkistus (AST parse)
- Projektin sisäiset importit (löytyykö nimi lähdetiedostosta)
- SQLite connect_args
- Relatiiviset importit (kielletty)
- Testien rakenne (ei saa kopioida appia)
- pyproject.toml (ei poetryä)
- Dockerfile (ei poetryä, uv cache -oikeudet)
Docker-testi ajaa koko projektin: build → pytest → API smoke test.
## Rajoitukset
Templateit kattavat rakenteellisesti tunnetut projektit:
| Stack | Kattavuus |
|-------|-----------|
| FastAPI + SQLAlchemy CRUD | Toimii hyvin |
| Streamlit + DuckDB dashboard | Toimii hyvin |
| Muu | Ei templatea → ei toimi |
**Ei kata:**
- Custom business-logiikka (algoritmit, laskenta, ML)
- Epätyypilliset arkkitehtuurit (WebSocket, graafit, tapahtumapohjaiset)
- Frontend-sovellukset (React, Vue)
- Mikä tahansa mitä template ei tunne
Arvio: templateit kattavat ~20% kaikista mahdollisista projekteista, mutta juuri
sen 20% mitä opiskelu- ja prototyyppiympäristöissä tarvitaan useimmin.
## Laajentaminen
Uuden stackin lisääminen vaatii:
1. Uudet template-funktiot (käsityö, ~200400 riviä per stack)
2. JSON-speksin laajennos (uudet kentät jos tarvitaan)
3. Validointisäännöt uudelle stackille
4. Docker-testikonfiguraatio
Jokainen template on staattinen — se ei opi eikä sopeudu. Kattavuus kasvaa
vain kirjoittamalla lisää templateja.
## Hybridi: seuraava askel
Paras lopputulos syntyisi yhdistelmällä:
```
Speksi → Template (runko) → LLM (business-logiikka) → Validointi
```
Template tuottaa toimivan CRUD-pohjan. LLM lisää domain-kohtaisen logiikan
pienissä palasissa (yksi funktio kerrallaan). Mekaaninen validointi
tarkistaa jokaisen lisäyksen.
Tämä palauttaa LLM:n epäluotettavuuden takaisin peliin, mutta rajattuna:
virheet ovat paikallisia (yksi funktio) eivätkä rakenteellisia (koko projekti).

View File

@@ -321,35 +321,79 @@ Malli tuottaa JSON-rakenteen kuten:
Tämä on yksinkertainen tehtävä jossa pienikin malli onnistuu luotettavasti:
entiteettien tunnistus projektin kuvauksesta ja kenttätyyppien valinta.
Speksi sisältää myös **taulujen väliset yhteydet** (relationships):
```json
{
"entities": [
{"name": "Author", "table_name": "authors", "fields": [...]},
{"name": "Book", "table_name": "books", "fields": [
{"name": "title", "sa_type": "String(255)", "py_type": "str", "nullable": false},
{"name": "author_id", "sa_type": "Integer", "py_type": "int", "nullable": false}
]}
],
"relationships": [
{"from": "Book", "field": "author_id", "to": "Author", "type": "many-to-one"}
]
}
```
Templateit generoivat yhteyksistä automaattisesti:
- `ForeignKey('authors.id')` models.py:hin
- `relationship("Book", back_populates="author")` molempiin suuntiin
- `BookDetail`-schema jossa author-data mukana
- `GET /authors/{id}/books/` nested endpoint
- FK-validointi: 404 jos parent-entiteettiä ei ole
### Architect-agentti: speksin laatu ratkaisee
Arkkitehti on **kriittisin agentti** koko pipelinessa. Jos speksi on hyvä
(oikeat taulut, kentät, yhteydet), kaikki muu seuraa automaattisesti.
Jos speksi on huono, templateitkaan eivät pelasta.
Arkkitehtia ohjataan:
1. **Chain-of-thought**: "Mieti ensin taulut, sitten kentät, sitten yhteydet"
2. **Domain-esimerkit**: Todo, verkkokauppa, blogi — malli näkee miltä hyvä speksi näyttää
3. **Anti-patternit**: "Ei turhia ID-kenttiä, ei Enumeita, ei suomenkielisiä nimiä koodissa"
4. **Yhteyssäännöt**: "Jokainen `_id`-kenttä tarvitsee vastaavan relationship-merkinnän"
Isompi malli (tai API) tässä yhdessä kohdassa parantaa kaikkien projektien laatua
koska speksi on ainoa paikka jossa LLM:n ymmärrys vaikuttaa.
### 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}
from sqlalchemy import create_engine, Column, Integer, {sa_types}, ForeignKey
from sqlalchemy.orm import sessionmaker, relationship
# ... 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ä ...
# FK-kentät: ForeignKey + relationship automaattisesti
{fk_field} = Column(Integer, ForeignKey('{parent_table}.id'))
{parent_lower} = relationship("{Parent}", back_populates="{children}")
```
Tulos: importit ovat aina oikein, `connect_args` on aina mukana,
testit importoivat aina `main.py`:stä eivätkä kopioi sitä.
taulujen yhteydet generoituvat oikein, testit importoivat `main.py`:stä eivätkä kopioi sitä.
### Vertailu: mittaustulokset
| | Vapaa generointi | Rakennuspalaset |
|---|:---:|:---:|
| LLM-kutsuja | 714 | **1** |
| Aika | 80120s | **~20s** |
| LLM-kutsuja | 714 | **3** (speksi + requirements + README) |
| Aika | 80120s | **~25s** |
| Syntaksi OK | ~70% | **100%** |
| Docker build | vaihteleva | **100%** |
| Pytest läpi | 0% | **100%** |
| API toimii | ~30% | **100%** |
| Taulujen yhteydet (FK) | ei koskaan | **100%** |
| Nested endpointit | ei koskaan | **automaattisesti** |
### Milloin kumpikin toimii

View File

@@ -1 +1 @@
dirty-3e9cdd70c60dadfb970cee47ebbd912c
cf3bf54

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -99,23 +99,27 @@ if [ -n "$KIPINA_MODEL" ]; then
echo " Malli: $KIPINA_MODEL (Ympäristömuuttujasta)"
fi
# Lataa binääri
# Binäärin automaattinen päivitys — vertaa build-hashia palvelimeen
BIN_PATH="./kipina-node-bin"
if [ -f "$BIN_PATH" ]; then
echo ""
read -p " Löydettiin vanha kipina-node-bin lokaalisti. Haluatko poistaa sen ja ladata uusimman version? [Y/n] " -r DEL_CHOICE
if [[ "$DEL_CHOICE" =~ ^[Nn]$ ]]; then
echo " ✓ Käytetään lokaalia versiota."
else
rm -f "$BIN_PATH"
echo " ✓ Vanha binääri poistettu ja korvataan uudella."
fi
fi
HASH_PATH="./kipina-node-bin.hash"
if [ ! -f "$BIN_PATH" ]; then
echo " Ladataan tuorein $BINARY..."
REMOTE_HASH=$(curl -sSL "$BASE_URL/.build-hash?v=$(date +%s)" 2>/dev/null | tr -d '[:space:]')
LOCAL_HASH=""
[ -f "$HASH_PATH" ] && LOCAL_HASH=$(cat "$HASH_PATH" | tr -d '[:space:]')
if [ -f "$BIN_PATH" ] && [ -n "$REMOTE_HASH" ] && [ "$REMOTE_HASH" = "$LOCAL_HASH" ]; then
echo " ✓ Binääri ajan tasalla (versio: $LOCAL_HASH)"
else
if [ -f "$BIN_PATH" ]; then
echo " ↻ Uusi versio saatavilla ($LOCAL_HASH → $REMOTE_HASH)"
else
echo " Ladataan $BINARY..."
fi
rm -f "$BIN_PATH"
curl -sSL "$BASE_URL/$BINARY?v=$(date +%s)" -o "$BIN_PATH"
chmod +x "$BIN_PATH"
echo "$REMOTE_HASH" > "$HASH_PATH"
echo " ✓ Päivitetty versioon $REMOTE_HASH"
fi
echo ""

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -2,7 +2,10 @@
<div id="panel-editor" class="panel">
<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 style="padding:10px 12px;color:#8b949e;font-size:11px;display:flex;justify-content:space-between;align-items:center;text-transform:uppercase;letter-spacing:0.5px;border-bottom:1px solid var(--border)">
<span>Tiedostot</span>
<button class="btn btn-green" style="padding:2px 6px;font-size:10px" onclick="downloadProjectZip()">.ZIP</button>
</div>
<div id="editor-file-list" style="padding:4px 0">
<div style="padding:8px 16px;color:#8b949e;font-size:12px">Generoi projekti:<br><code style="color:var(--accent)">kpn project "..."</code></div>
</div>

View File

@@ -32,6 +32,7 @@ import Settings from "../components/Settings.astro";
<div class="bg-mesh"></div>
<header class="landing-nav">
<a href="/" class="landing-logo"><span class="logo-accent">KIPINÄ</span> <span class="logo-sub">/ agentic studio</span></a>
<button id="theme-cycle-btn" class="theme-cycle-btn" title="Vaihda teemaa"></button>
</header>
<section class="hero">
@@ -136,12 +137,52 @@ import Settings from "../components/Settings.astro";
</div>
<script is:inline>
// === Teemajärjestelmä (gecko/forge/serpent) — oletus forge ===
const KS_THEMES = [
{ id: 'gecko', icon: '\u{1F98E}' },
{ id: 'forge', icon: '\u{2699}\u{FE0F}' },
{ id: 'serpent', icon: '\u{1F40D}' }
];
const KS_DEFAULT = 'forge';
(function() {
let saved = KS_DEFAULT;
try { saved = localStorage.getItem('kipina-studio-theme') || KS_DEFAULT; } catch(_){}
if (!KS_THEMES.find(t => t.id === saved)) saved = KS_DEFAULT;
document.documentElement.setAttribute('data-theme', saved);
})();
// === Helpers ===
window.showJoinDialog = function() {
const d = document.getElementById('join-dialog');
d.style.display = d.style.display === 'none' ? 'block' : 'none';
};
// === Teemapainike: kierrätys + hero-kuvan vaihto ===
(function() {
const btn = document.getElementById('theme-cycle-btn');
const heroImg = document.querySelector('.hero-orb-img');
function currentTheme() { return document.documentElement.getAttribute('data-theme') || KS_DEFAULT; }
function applyTheme(themeId) {
document.documentElement.setAttribute('data-theme', themeId);
const t = KS_THEMES.find(x => x.id === themeId) || KS_THEMES[1];
if (btn) btn.textContent = t.icon;
if (heroImg) {
heroImg.style.opacity = '0';
setTimeout(() => { heroImg.src = '/' + themeId + '_hero.webp'; heroImg.style.opacity = '1'; }, 200);
}
try { localStorage.setItem('kipina-studio-theme', themeId); } catch(_){}
}
// Alkuasetus
applyTheme(currentTheme());
// Klikkaus kiertää seuraavaan
if (btn) btn.addEventListener('click', () => {
const cur = currentTheme();
const idx = KS_THEMES.findIndex(t => t.id === cur);
const next = KS_THEMES[(idx + 1) % KS_THEMES.length];
applyTheme(next.id);
});
})();
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
@@ -193,6 +234,19 @@ import Settings from "../components/Settings.astro";
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');
if (/:\s*datetime\b/.test(code) && !/from datetime import/.test(code))
issues.push('ISSUE: schemas.py: käyttää datetime-tyyppiä mutta "from datetime import datetime" puuttuu');
}
// 4b. Python-syntaksi: JS-booleanit (false/true ilman isoa alkukirjainta)
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^\s*#/.test(line) || /^\s*$/.test(line)) continue;
// Etsi false/true joita ei ole merkkijonon sisällä ja jotka eivät ole osa isompaa sanaa
if (/(?<!["\w])false(?![\w"])/.test(line))
issues.push(`ISSUE: ${fname}:${i+1}: "false" ei ole Python — pitäisi olla "False"`);
if (/(?<!["\w])true(?![\w"])/.test(line))
issues.push(`ISSUE: ${fname}:${i+1}: "true" ei ole Python — pitäisi olla "True"`);
}
// 5. test_main.py: ei saa uudelleenmääritellä appia tai modeleita
@@ -792,6 +846,7 @@ OUTPUT FORMAT:
top_k: opts.topK ?? settings.topK ?? undefined,
max_tokens: opts.maxTokens ?? settings.maxTokens ?? undefined,
repeat_penalty: opts.repeatPenalty ?? settings.repeatPenalty ?? undefined,
capability: opts.capability || undefined, // "heavy" → isoin malli
};
const res = await fetch('/api/v1/chat/completions', {
@@ -1121,6 +1176,29 @@ OUTPUT FORMAT:
// === Template Pipeline — rakennuspalaset ===
// JS-arvo → Python-literaali: false→False, true→True, "teksti"→'"teksti"'
function pyLiteral(val) {
if (val === true) return 'True';
if (val === false) return 'False';
if (val === null || val === undefined) return 'None';
if (typeof val === 'string') return `"${val}"`;
return String(val);
}
// JSON-merkkijono jossa booleanit on Python-muodossa: {"a": True, "b": False}
function pyJsonLiteral(obj) {
const parts = Object.entries(obj).map(([k, v]) => {
let pyVal;
if (v === true) pyVal = 'True';
else if (v === false) pyVal = 'False';
else if (v === null) pyVal = 'None';
else if (typeof v === 'string') pyVal = `"${v}"`;
else pyVal = String(v);
return `"${k}":${pyVal}`;
});
return '{' + parts.join(',') + '}';
}
const SPEC_SYSTEM = `You are a software architect who designs database schemas for Python web applications.
THINK STEP BY STEP before outputting JSON:
@@ -1166,20 +1244,29 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
}
function tmplModels(spec) {
// Kerää tarvittavat SA-tyypit + ForeignKey jos relaatioita
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 relMap = {};
for (const r of (spec.relationships || [])) {
const target = spec.entities.find(e => e.name === r.to);
if (target) relMap[`${r.from}.${r.field}`] = target.table_name;
}
const hasFk = Object.keys(relMap).length > 0;
if (hasFk) saTypes.add('ForeignKey');
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 += `from sqlalchemy.orm import declarative_base, 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}`];
const fkTarget = relMap[`${e.name}.${f.name}`];
let parts = fkTarget ? [`Column(${f.sa_type}, ForeignKey("${fkTarget}.id")` ] : [`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}`);
if (f.default !== null && f.default !== undefined) parts.push(`default=${pyLiteral(f.default)}`);
code += ` ${f.name} = ${parts.join(', ')})\n`;
}
code += '\n';
@@ -1189,17 +1276,31 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
}
function tmplSchemas(spec) {
let code = 'from pydantic import BaseModel\n';
for (const imp of (spec.extra_imports || [])) code += imp + '\n';
// Tunnista tarvittavat datetime-importit kenttätyypeistä
const dtTypes = new Set();
for (const e of spec.entities) for (const f of e.fields) {
if (/\bdate\b/i.test(f.py_type) && !/datetime/.test(f.py_type)) dtTypes.add('date');
if (/\bdatetime\b/i.test(f.py_type)) dtTypes.add('datetime');
}
let code = 'from pydantic import BaseModel, ConfigDict\n';
if (dtTypes.size > 0) code += `from datetime import ${[...dtTypes].sort().join(', ')}\n`;
// extra_imports: suodata pois pelkät nimet kuten "datetime" (jo käsitelty yllä)
for (const imp of (spec.extra_imports || [])) {
if (/^(date|datetime)$/.test(imp.trim())) continue; // käsitelty jo
if (/^from\s/.test(imp) || /^import\s/.test(imp)) 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`;
if (f.default !== null && f.default !== undefined) code += ` ${f.name}: ${f.py_type} = ${pyLiteral(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`;
code += `\nclass ${e.name}Response(${e.name}Create):\n id: int\n model_config = ConfigDict(from_attributes=True)\n\n`;
}
return code;
}
@@ -1253,11 +1354,11 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
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 td = pyJsonLiteral(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);
const ud = pyJsonLiteral(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`;
@@ -1278,6 +1379,15 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
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 tmplDockerCompose(spec) {
const name = (spec.project_name || 'app').toLowerCase().replace(/[^a-z0-9-]/g, '-');
return `services:\n app:\n build: .\n container_name: ${name}\n ports:\n - "8000:8000"\n volumes:\n - app-data:/app/data\n restart: unless-stopped\n\nvolumes:\n app-data:\n`;
}
function tmplDockerignore() {
return `.venv\n__pycache__\n*.pyc\n*.db\n.pytest_cache\n.git\n`;
}
function tmplGenerate(spec) {
return {
'models.py': tmplModels(spec),
@@ -1286,6 +1396,8 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
'test_main.py': tmplTests(spec),
'pyproject.toml': tmplPyproject(spec),
'Dockerfile': tmplDockerfile(),
'docker-compose.yml': tmplDockerCompose(spec),
'.dockerignore': tmplDockerignore(),
};
}
@@ -1311,7 +1423,7 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
highlightAgent('manager');
explainStep('Arkkitehtuuri', `${mgr.name} analysoi vaatimukset ja tuottaa JSON-speksin: entiteetit, kentät, tyypit.`);
const specRaw = await kpnRun(mgr.model, `${brief}\n\nOutput a JSON spec for this project.`, false, { ...mgr, prompt: SPEC_SYSTEM });
const specRaw = await kpnRun(mgr.model, `${brief}\n\nOutput a JSON spec for this project.`, false, { ...mgr, prompt: SPEC_SYSTEM, capability: 'heavy' });
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 || '' });
@@ -1326,7 +1438,7 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
// === 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 agentMap = { 'models.py': 'data', 'schemas.py': 'coder', 'main.py': 'coder', 'test_main.py': 'qa', 'pyproject.toml': 'coder', 'Dockerfile': 'tester', 'docker-compose.yml': 'tester', '.dockerignore': 'tester' };
const agentNames = { data: 'Data Engineer', coder: 'Coder', qa: 'QA', tester: 'DevOps' };
for (let i = 0; i < fileOrder.length; i++) {
@@ -1348,20 +1460,71 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
let stepN = fileOrder.length + 2;
// === Vaihe 4: Mekaaninen QA-validointi ===
// === Vaihe 4: Mekaaninen QA-validointi + korjaussilmukka ===
const qaAgent = agents.qa || Object.values(agents)[4];
termLog(`\n<span style="color:#d2a8ff;font-weight:bold">[${stepN}] ${esc(qaAgent.name)}</span> — validointi`);
highlightAgent('qa');
explainStep('Validointi', `${qaAgent.name} ajaa mekaanisen koodivalidoinnin.`);
const MAX_FIX_ROUNDS = 2;
let mechIssues = validateProjectCode(files);
let fixRound = 0;
const mechIssues = validateProjectCode(files);
if (mechIssues.length > 0) {
termLog(` <span style="color:#d29922">⚠ ${mechIssues.length} ongelmaa (template-bugeja — korjattava):</span>`);
while (mechIssues.length > 0 && fixRound < MAX_FIX_ROUNDS) {
fixRound++;
termLog(`\n<span style="color:#d2a8ff;font-weight:bold">[${stepN}] ${esc(qaAgent.name)}</span> — validointi (kierros ${fixRound})`);
highlightAgent('qa');
explainStep('Validointi', `${qaAgent.name} löysi ${mechIssues.length} ongelmaa — delegoidaan korjattavaksi.`);
termLog(` <span style="color:#d29922">⚠ ${mechIssues.length} ongelmaa:</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 #${fixRound}`, systemPrompt: '(mekaaninen validointi)', userPrompt: 'validateProjectCode(files)', response: mechIssues.join('\n') });
stepN++;
// Ryhmitellään ongelmat tiedostoittain
const issuesByFile = {};
for (const issue of mechIssues) {
const m = issue.match(/^ISSUE:\s*(\S+?):/);
const fname = m ? m[1] : 'unknown';
if (!issuesByFile[fname]) issuesByFile[fname] = [];
issuesByFile[fname].push(issue);
}
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') });
// Delegoidaan korjaukset Coder-agentille tiedosto kerrallaan
for (const [fname, fIssues] of Object.entries(issuesByFile)) {
if (!files[fname]) continue;
const fixAgent = agents[agentMap[fname]] || cdr;
termLog(`\n<span style="color:#f0883e;font-weight:bold">[${stepN}] ${esc(fixAgent.name)}</span> — korjaa ${esc(fname)} (${fIssues.length} ongelmaa)`);
highlightAgent(agentMap[fname] || 'coder');
explainStep(`Korjaus: ${fname}`, `${fixAgent.name} korjaa validoinnin löytämät ongelmat.`);
const fixPrompt = `Fix the following issues in this Python file. Return ONLY the complete corrected file, no explanations.\n\nISSUES:\n${fIssues.join('\n')}\n\nCURRENT FILE (${fname}):\n\`\`\`python\n${files[fname]}\`\`\``;
const fixResult = await kpnRun(fixAgent.model, fixPrompt, false, { ...fixAgent, prompt: 'You are a Python code fixer. Return ONLY the corrected Python file. No markdown fences, no explanations — just valid Python code.', capability: 'heavy' });
if (fixResult) {
// Poistetaan markdown-koodiblokit jos LLM palauttaa ne
let cleaned = fixResult.replace(/^```(?:python)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim() + '\n';
files[fname] = cleaned;
termLog(` <span style="color:#3fb950">✓ ${esc(fname)} korjattu</span>`);
} else {
termLog(` <span style="color:#f85149">✗ ${esc(fname)} korjaus epäonnistui — pidetään alkuperäinen</span>`);
}
promptLog.push({ step: promptLog.length, agentKey: agentMap[fname] || 'coder', agentName: fixAgent.name, model: fixAgent.model, label: `korjaus: ${fname}`, systemPrompt: '(code fixer)', userPrompt: fixPrompt, response: fixResult || '(epäonnistui)' });
stepN++;
}
// Validoidaan uudelleen
mechIssues = validateProjectCode(files);
}
// Lopullinen validointitulos
termLog(`\n<span style="color:#d2a8ff;font-weight:bold">[${stepN}] ${esc(qaAgent.name)}</span> — lopullinen validointi`);
highlightAgent('qa');
if (mechIssues.length > 0) {
explainStep('Validointi', `${mechIssues.length} ongelmaa jäi korjaamatta ${MAX_FIX_ROUNDS} kierroksen jälkeen.`);
termLog(` <span style="color:#f85149">✗ ${mechIssues.length} ongelmaa jäljellä ${fixRound} korjauskierroksen jälkeen:</span>`);
for (const issue of mechIssues) termLog(` <span style="color:#f85149">${esc(issue)}</span>`);
} else {
const msg = fixRound > 0 ? `✓ Kaikki ongelmat korjattu (${fixRound} kierrosta)` : '✓ Kaikki tiedostot validoitu — 0 ongelmaa';
explainStep('Validointi', msg);
termLog(` <span style="color:#3fb950">${msg}</span>`);
}
promptLog.push({ step: promptLog.length, agentKey: 'qa', agentName: qaAgent.name, model: 'mekaaninen', label: 'lopullinen validointi', systemPrompt: '(mekaaninen validointi)', userPrompt: 'validateProjectCode(files)', response: mechIssues.length === 0 ? `OK — korjattu ${fixRound} kierroksessa` : mechIssues.join('\n') });
stepN++;
// Tarkkailija: yhteenveto + raportti + arvosana
@@ -1395,7 +1558,7 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
`## Architecture\nDescribe the project structure and design decisions.\n\n` +
`## Risk Assessment\n| Severity | Issue |\n|----------|-------|\n| ... | ... |\n\n` +
`Project code:\n${finalCode}`;
const readme = await kpnRun(obs.model, obsPrompt, false, obs);
const readme = await kpnRun(obs.model, obsPrompt, false, { ...obs, capability: 'heavy' });
if (readme) {
files['README.md'] = readme;
// Tallennetaan raportti globaalisti jotta tarkkailija-klikkaus avaa sen
@@ -1430,7 +1593,9 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
// Oppimispolku
renderLearnView(promptLog);
renderProjectCard(files, task);
termLog(`\n<span style="color:#8b949e">Siirretään tiedostot Editoriin...</span>`);
window._currentProjectName = task;
setTimeout(() => window.openInEditor(files), 1000);
}
async function kpnPipelineSimple(task) {
@@ -1456,41 +1621,7 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
termLog(`\n<span style="color:var(--purple);font-weight:bold">━━━ Done ━━━</span>`);
}
// === Project card ===
window._projectFiles = {}; // id → files
function renderProjectCard(files, name) {
const entries = Object.entries(files);
if (!entries.length) return;
const id = 'proj-' + Date.now();
window._projectFiles[id] = files;
const tabs = entries.map(([n],i) =>
`<div class="project-tab${i===0?' active':''}" data-card="${id}" data-i="${i}" onclick="switchProjTab('${id}',${i})">${esc(n)}</div>`
).join('');
const panels = entries.map(([n,c],i) =>
`<div class="proj-panel" data-card="${id}" data-i="${i}" style="${i>0?'display:none':''}">` +
`<div style="text-align:right;padding:4px 8px;background:var(--bg);border-bottom:1px solid #21262d">` +
`<button class="btn btn-muted" onclick="copyProjectFile('${id}','${esc(n)}')">Kopioi</button></div>` +
`<pre class="code-block">${highlightCode(c)}</pre></div>`
).join('');
const html = `<div id="${id}" class="project-card">` +
`<div class="project-header">` +
`<span style="color:var(--purple);font-weight:600">${esc(name||'Projekti')} <span style="color:#8b949e;font-weight:normal">(${entries.length})</span></span>` +
`<span style="display:flex;gap:6px">` +
`<button class="btn btn-muted" onclick="copyAllProjectFiles('${id}')">Kopioi kaikki</button>` +
`<button class="btn btn-muted" onclick="downloadProjectZip('${id}','${esc(name||'projekti')}')">Lataa .zip</button>` +
`<button class="btn btn-green" onclick="openInEditor(window._projectFiles['${id}'])">Avaa editorissa</button>` +
`</span></div>` +
`<div class="project-tabs">${tabs}</div>${panels}</div>`;
const div = document.createElement('div');
div.innerHTML = html;
termPanel.appendChild(div.firstElementChild);
termPanel.scrollTop = termPanel.scrollHeight;
}
// === Poistettiin renderProjectCard ja siirryttiin suoraan Editorin käyttöön ===
window.copyProjectFile = function(id, name) {
const files = window._projectFiles[id];
if (files && files[name]) navigator.clipboard.writeText(files[name]);
@@ -1501,8 +1632,9 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
const text = Object.entries(files).map(([n,c]) => '# --- ' + n + ' ---\n' + c).join('\n\n');
navigator.clipboard.writeText(text);
};
window.downloadProjectZip = function(id, name) {
const files = window._projectFiles[id];
window.downloadProjectZip = function() {
const files = window._currentEditorFiles;
const name = window._currentProjectName || 'projekti';
if (!files) return;
const enc = new TextEncoder();
const entries = Object.entries(files);
@@ -1618,6 +1750,7 @@ Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_
const langMap = {py:'python',rs:'rust',js:'javascript',ts:'typescript',toml:'toml',json:'json',html:'html',css:'css',md:'markdown',txt:'plaintext'};
window.openInEditor = async function(files) {
window._currentEditorFiles = files;
switchTab('editor');
try { await initMonaco(); } catch(e) { console.error('Monaco-virhe:', e); return; }
const m = window.monaco;

View File

@@ -1,3 +1,4 @@
/* Oletusvärit — ylikirjoitetaan teemalla */
:root {
--bg: #0d1117;
--panel: #161b22;
@@ -8,6 +9,53 @@
--red: #f85149;
--purple: #a371f7;
--border: #30363d;
--hero-accent: #ff6b00;
--hero-glow: rgba(255, 107, 0, 0.15);
}
/* Gecko — lämmin kulta/oranssi (kipina.tech) */
[data-theme="gecko"] {
--bg: #0a0500;
--panel: #1f1000;
--text: #fff5e6;
--accent: #ff7b00;
--green: #3fb950;
--yellow: #ffae00;
--red: #f85149;
--purple: #ff9d4d;
--border: rgba(255, 174, 0, 0.2);
--hero-accent: #ff7b00;
--hero-glow: rgba(255, 123, 0, 0.15);
}
/* Forge — kyber-sininen/syaani (kipina.tech) */
[data-theme="forge"] {
--bg: #060b11;
--panel: #121e2d;
--text: #e0f2fe;
--accent: #00e5ff;
--green: #3fb950;
--yellow: #ff5e3a;
--red: #f85149;
--purple: #7dd3fc;
--border: rgba(0, 229, 255, 0.15);
--hero-accent: #00e5ff;
--hero-glow: rgba(0, 229, 255, 0.15);
}
/* Serpent — neon-turkoosi/teal (kipina.tech) */
[data-theme="serpent"] {
--bg: #000808;
--panel: #001e1e;
--text: #ccffff;
--accent: #00ffff;
--green: #00ffaa;
--yellow: #d29922;
--red: #f85149;
--purple: #66cccc;
--border: rgba(0, 255, 255, 0.15);
--hero-accent: #00ffff;
--hero-glow: rgba(0, 255, 255, 0.15);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -235,17 +283,27 @@ body {
.bg-mesh {
position: fixed; inset: 0; z-index: -1;
background:
radial-gradient(ellipse 80% 60% at 20% 40%, rgba(255,107,0,0.08) 0%, transparent 70%),
radial-gradient(ellipse 80% 60% at 20% 40%, var(--hero-glow) 0%, transparent 70%),
radial-gradient(ellipse 60% 50% at 80% 20%, rgba(88,166,255,0.06) 0%, transparent 70%),
var(--bg);
}
.landing-nav {
padding: 20px 40px;
display: flex; align-items: center; justify-content: space-between;
}
.landing-logo { text-decoration: none; font-size: 18px; font-weight: 700; }
.logo-accent { color: #ff6b00; }
.logo-accent { color: var(--hero-accent); }
.logo-sub { color: #8b949e; font-weight: 400; }
.theme-cycle-btn {
background: none; border: 1px solid var(--border); border-radius: 8px;
width: 38px; height: 38px; font-size: 20px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: border-color 0.2s, transform 0.15s;
}
.theme-cycle-btn:hover {
border-color: var(--hero-accent); transform: scale(1.1);
}
/* Hero */
.hero {
@@ -260,7 +318,7 @@ body {
line-height: 1.15; color: #e6edf3; margin-bottom: 16px;
}
.hero-divider {
width: 60px; height: 3px; background: #ff6b00;
width: 60px; height: 3px; background: var(--hero-accent);
border-radius: 2px; margin-bottom: 20px;
}
.hero-desc {
@@ -283,7 +341,7 @@ body {
outline: none; transition: border-color 0.2s;
}
.hero-input:focus {
border-color: #ff6b00; box-shadow: 0 0 0 3px rgba(255,107,0,0.15);
border-color: var(--hero-accent); box-shadow: 0 0 0 3px var(--hero-glow);
}
.hero-input::placeholder { color: #484f58; }
.hero-input.shake {
@@ -299,11 +357,11 @@ body {
.hero-btn {
padding: 14px 28px; font-size: 16px; font-weight: 600;
font-family: 'Inter', sans-serif;
background: #ff6b00; color: #fff; border: none; border-radius: 8px;
background: var(--hero-accent); color: #fff; border: none; border-radius: 8px;
cursor: pointer; transition: background 0.2s, transform 0.1s;
white-space: nowrap;
}
.hero-btn:hover { background: #e05e00; transform: translateY(-1px); }
.hero-btn:hover { filter: brightness(0.85); transform: translateY(-1px); }
.hero-btn:active { transform: translateY(0); }
/* Example buttons */
@@ -325,13 +383,14 @@ body {
}
.hero-orb {
width: 340px; height: 340px; border-radius: 50%;
background: radial-gradient(circle at 30% 30%, rgba(255,107,0,0.15) 0%, transparent 70%);
background: radial-gradient(circle at 30% 30%, var(--hero-glow) 0%, transparent 70%);
display: flex; align-items: center; justify-content: center;
animation: orb-float 6s ease-in-out infinite;
}
.hero-orb-img {
width: 100%; height: 100%; object-fit: contain;
filter: drop-shadow(0 0 40px rgba(255,107,0,0.25));
filter: drop-shadow(0 0 40px var(--hero-glow));
transition: opacity 0.2s ease;
}
@keyframes orb-float {
0%, 100% { transform: translateY(0); }
@@ -357,11 +416,11 @@ body {
background: var(--panel); border: 1px solid var(--border);
border-radius: 12px; transition: border-color 0.3s;
}
.how-step:hover { border-color: rgba(255,107,0,0.4); }
.how-step:hover { border-color: var(--hero-accent); }
.how-step-num {
width: 40px; height: 40px; line-height: 40px;
border-radius: 50%; background: rgba(255,107,0,0.12);
color: #ff6b00; font-weight: 700; font-size: 18px;
border-radius: 50%; background: var(--hero-glow);
color: var(--hero-accent); font-weight: 700; font-size: 18px;
margin: 0 auto 14px;
}
.how-step h3 { color: #e6edf3; font-size: 1rem; margin-bottom: 8px; }
@@ -397,8 +456,8 @@ body {
.learn-step-header:hover { background: rgba(88,166,255,0.04); }
.learn-step-num {
width: 28px; height: 28px; line-height: 28px; text-align: center;
border-radius: 50%; background: rgba(255,107,0,0.12);
color: #ff6b00; font-weight: 700; font-size: 13px; flex-shrink: 0;
border-radius: 50%; background: var(--hero-glow);
color: var(--hero-accent); font-weight: 700; font-size: 13px; flex-shrink: 0;
}
.learn-step-agent {
font-weight: 600; color: #e6edf3; font-size: 14px;

View File

@@ -42,10 +42,12 @@ struct AppState {
node_types: Mutex<HashMap<u64, String>>, // node_id → "native" | "browser"
node_paused: Mutex<std::collections::HashSet<u64>>, // node_id → onko tauolla
node_busy: Mutex<std::collections::HashSet<u64>>, // Solmut joilla on aktiivinen tehtävä
node_active_task: Mutex<HashMap<u64, String>>, // node_id → task_id (mikä tehtävä on kesken)
pending_task_ids: Mutex<std::collections::HashSet<String>>, // Hubin jakamat task_id:t (gamification-validointi)
pending_responses: Mutex<HashMap<String, tokio::sync::oneshot::Sender<serde_json::Value>>>, // task_id → oneshot API-vastaukselle
api_rate_limits: Mutex<HashMap<IpAddr, (std::time::Instant, u32)>>, // IP → (ikkuna-alku, pyyntömäärä)
node_models: tokio::sync::RwLock<HashMap<u64, serde_json::Value>>, // node_id → ollama tags JSON
node_max_param_b: tokio::sync::RwLock<HashMap<u64, u32>>, // node_id → suurimman mallin parametrit (B)
db: db::NodeDb,
}
@@ -329,10 +331,12 @@ async fn main() {
node_types: Mutex::new(HashMap::new()),
node_paused: Mutex::new(std::collections::HashSet::new()),
node_busy: Mutex::new(std::collections::HashSet::new()),
node_active_task: Mutex::new(HashMap::new()),
pending_task_ids: Mutex::new(std::collections::HashSet::new()),
pending_responses: Mutex::new(HashMap::new()),
api_rate_limits: Mutex::new(HashMap::new()),
node_models: tokio::sync::RwLock::new(HashMap::new()),
node_max_param_b: tokio::sync::RwLock::new(HashMap::new()),
db: db::NodeDb::new(&std::env::var("DATABASE_PATH").unwrap_or_else(|_| "nodes.db".to_string())),
});
@@ -844,10 +848,34 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
node_id, ip, hostname, os, cores, ram, allocated
);
// Tallennetaan välitetyt mallit muistiin
// Tallennetaan välitetyt mallit muistiin + parsitaan suurin malli
if let Some(models) = json.get("models") {
let mut nm = state.node_models.write().await;
nm.insert(node_id, models.clone());
// Parsitaan suurin mallikoko (B) nimestä: "qwen3:32b" → 32, "qwen2.5-coder:7b" → 7
let max_b = models.get("models").and_then(|v| v.as_array()).map(|arr| {
arr.iter().filter_map(|m| {
let name = m.get("name")?.as_str()?;
// Etsitään :N tai :Nb tai -Nb muoto
let lower = name.to_lowercase();
for part in lower.split(&[':', '-'][..]) {
if let Some(num_str) = part.strip_suffix('b') {
if let Ok(n) = num_str.parse::<f32>() { return Some(n as u32); }
} else if let Ok(n) = part.parse::<f32>() {
if n >= 0.5 && n <= 500.0 { return Some(n as u32); }
}
}
// Fallback: koko tiedostosta (size / ~0.5GB per B param Q4)
let size = m.get("size")?.as_u64()?;
Some((size / 500_000_000) as u32) // karkea arvio
}).max().unwrap_or(0)
}).unwrap_or(0);
if max_b > 0 {
state.node_max_param_b.write().await.insert(node_id, max_b);
tracing::info!("Solmu {} — suurin malli: ~{}B parametria", node_id, max_b);
}
}
if let Some(gpus) = json.get("gpus").and_then(|v| v.as_array()) {
@@ -908,6 +936,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
broadcast_stats(&state).await;
} else if msg_type == "pair_done" {
state.node_busy.lock().unwrap().remove(&node_id);
state.node_active_task.lock().unwrap().remove(&node_id);
{
let mut json = json; // Siirretään omistajuus muokkausta varten
if let Some(obj) = json.as_object_mut() {
@@ -994,6 +1023,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
} else if msg_type == "llm_done" {
// Vapautetaan solmu ja tarkistetaan task_id:n aitous
state.node_busy.lock().unwrap().remove(&node_id);
state.node_active_task.lock().unwrap().remove(&node_id);
let task_id = json.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
let valid_task = if let Some(ref tid) = task_id {
state.pending_task_ids.lock().unwrap().remove(tid.as_str())
@@ -1063,6 +1093,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
}
} else if msg_type == "llm_error" {
state.node_busy.lock().unwrap().remove(&node_id);
state.node_active_task.lock().unwrap().remove(&node_id);
let task_id = json.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string());
if let Some(ref tid) = task_id {
state.pending_task_ids.lock().unwrap().remove(tid.as_str());
@@ -1109,6 +1140,22 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
// Yhteys katkesi — merkitään session päättyneeksi ja siivotaan atomisesti
state.db.close_session(node_id);
// Jos solmulla oli kesken tehtävä, ilmoitetaan odottavalle API-kutsulle
let lost_task_id = state.node_active_task.lock().unwrap().remove(&node_id);
if let Some(tid) = lost_task_id {
tracing::warn!("Solmu {} katosi kesken tehtävän {} — palautetaan virhe API:lle", node_id, tid);
state.pending_task_ids.lock().unwrap().remove(&tid);
if let Some(resp_tx) = state.pending_responses.lock().unwrap().remove(&tid) {
let err = serde_json::json!({
"type": "llm_error",
"error": format!("Solmu #{} katosi kesken laskennan (task {})", node_id, tid),
"task_id": tid
});
let _ = resp_tx.send(err);
}
}
{
// Lukitaan kaikki kerralla, jotta solmu ei ole osittain siivottu
let mut tasks = state.node_tasks.lock().unwrap();
@@ -1128,6 +1175,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
state.node_types.lock().unwrap().remove(&node_id);
state.node_paused.lock().unwrap().remove(&node_id);
state.node_models.write().await.remove(&node_id);
state.node_max_param_b.write().await.remove(&node_id);
tracing::info!("Solmu {} ({}) poistui verkosta.", node_id, ip);
broadcast_stats(&state).await;
sender_task.abort();
@@ -1149,6 +1197,8 @@ struct ChatCompletionRequest {
repeat_penalty: Option<f64>,
#[serde(default)]
stop: Option<Vec<String>>,
#[serde(default)]
capability: Option<String>, // "heavy" → priorisoi isoin malli, "light" → mikä tahansa
}
#[derive(serde::Serialize)]
@@ -1258,15 +1308,26 @@ async fn api_chat_completions(
}
}
// Etsitään vapaa solmu — priorisoidaan natiivisolmut (GPU) selaimen edelle
// Etsitään vapaa solmu — älykäs reititys kyvykkyyden mukaan
let want_heavy = payload.capability.as_deref() == Some("heavy");
// Haetaan param_b-snapshot ennen Mutex-lukituksia (async RwLock ei saa olla Mutex-scopen sisällä)
let param_b_snapshot: HashMap<u64, u32> = state.node_max_param_b.read().await.clone();
let (target_node, _total_matching) = {
let tasks = state.node_tasks.lock().unwrap();
let _busy = state.node_busy.lock().unwrap();
let busy = state.node_busy.lock().unwrap();
let node_types = state.node_types.lock().unwrap();
let paused = state.node_paused.lock().unwrap();
// Debug: logita kaikki solmut ja niiden tilat
let all_nodes: Vec<String> = tasks.iter().map(|(id, task)| {
let ty = node_types.get(id).map(|s| s.as_str()).unwrap_or("?");
let b = if busy.contains(id) { " BUSY" } else { "" };
let p = if paused.contains(id) { " PAUSED" } else { "" };
format!("#{}({}:{}{}{}", id, ty, task, b, p)
}).collect();
tracing::info!("Reititys '{}'{} — solmut: [{}]", payload.model, if want_heavy { " (heavy)" } else { "" }, all_nodes.join(", "));
let matching: Vec<u64> = tasks.iter().filter(|(k, task)| {
if paused.contains(k) { return false; } // Ei sallita tauotettuja
// Eksakti match tai qwen-perheen yhteensopivuus (selain: qwen-coder-05b, natiivi: qwen2.5-coder:7b)
if paused.contains(k) { return false; } // Ei tauotettuja
if busy.contains(k) { return false; } // Ei varattuja
let req_model = payload.model.to_lowercase();
let node_task = task.to_lowercase();
if req_model.starts_with("qwen") {
@@ -1277,11 +1338,32 @@ async fn api_chat_completions(
**task == payload.model
}
}).map(|(k, _)| *k).collect();
// Etsitään mikä tahansa matchaava solmu (natiivi priorisoidaan)
let any = if want_heavy {
// Heavy: priorisoi solmu jolla on suurin malli (B-parametrit)
let mut ranked: Vec<(u64, u32)> = matching.iter().map(|id| {
(*id, param_b_snapshot.get(id).copied().unwrap_or(0))
}).collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1)); // suurin ensin
if let Some((best_id, best_b)) = ranked.first() {
tracing::info!("Heavy-reititys: solmu {} valittu ({}B parametria)", best_id, best_b);
Some(*best_id)
} else {
// Kaikki heavy-solmut busy — fallback mihin tahansa vapaaseen
let all_matching: Vec<u64> = tasks.iter().filter(|(k, task)| {
if paused.contains(k) || busy.contains(k) { return false; }
let req_model = payload.model.to_lowercase();
task.to_lowercase().starts_with(&req_model.split('-').next().unwrap_or(""))
}).map(|(k, _)| *k).collect();
all_matching.first().copied()
}
} else {
// Oletus: vapaa natiivi ensin, sitten mikä tahansa vapaa
let native = matching.iter().find(|id| {
node_types.get(id).map(|t| t == "native").unwrap_or(false)
}).copied();
let any = native.or_else(|| matching.first().copied());
native.or_else(|| matching.first().copied())
};
(any, matching.len())
};
@@ -1308,6 +1390,7 @@ async fn api_chat_completions(
// Merkitään solmu varatuksi ja task_id jaetuksi
state.node_busy.lock().unwrap().insert(target_node_id);
state.node_active_task.lock().unwrap().insert(target_node_id, payload.task_id.clone());
state.pending_task_ids.lock().unwrap().insert(payload.task_id.clone());
let mut msg = serde_json::json!({
@@ -1340,7 +1423,7 @@ async fn api_chat_completions(
}
}
let timeout = tokio::time::timeout(std::time::Duration::from_secs(600), resp_rx).await;
let timeout = tokio::time::timeout(std::time::Duration::from_secs(120), resp_rx).await;
match timeout {
Ok(Ok(v)) => {
@@ -1356,12 +1439,17 @@ async fn api_chat_completions(
}
}
Ok(Err(_)) => {
// Oneshot-kanava sulkeutui (solmu katosi)
// Oneshot-kanava sulkeutui (solmu katosi kesken laskennan)
state.pending_responses.lock().unwrap().remove(&payload.task_id);
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Verkkovirhe: yhteys katkesi").into_response()
state.node_busy.lock().unwrap().remove(&target_node_id);
state.node_active_task.lock().unwrap().remove(&target_node_id);
(axum::http::StatusCode::SERVICE_UNAVAILABLE, "Solmu katosi kesken laskennan — yritä uudelleen").into_response()
}
Err(_) => {
// Timeout — solmu ei vastannut ajoissa
state.pending_responses.lock().unwrap().remove(&payload.task_id);
state.node_busy.lock().unwrap().remove(&target_node_id);
state.node_active_task.lock().unwrap().remove(&target_node_id);
(axum::http::StatusCode::GATEWAY_TIMEOUT, "Aikakatkaisu: solmu ei saanut tehtävää ajoissa valmiiksi").into_response()
}
}

135
network-poc/kipina-node Normal file
View File

@@ -0,0 +1,135 @@
#!/bin/bash
# Kipinä Node — lataa oikea binääri ja käynnistä
set -e
BASE_URL="https://kipina.studio/download"
HUB_URL="${KIPINA_HUB:-wss://kipina.studio/ws}"
OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}"
# Tunnista OS ja arkkitehtuuri
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS-$ARCH" in
darwin-arm64) BINARY="kipina-node-macos-arm64" ;;
darwin-x86_64) BINARY="kipina-node-macos-arm64" ;; # Rosetta
linux-x86_64) BINARY="kipina-node-linux-x86_64" ;;
linux-aarch64) BINARY="kipina-node-linux-arm64" ;;
*) echo "Ei tuettu: $OS-$ARCH"; exit 1 ;;
esac
echo ""
echo " ╔══════════════════════════════════════╗"
echo " ║ Kipinä Agentic Node ║"
echo " ╚══════════════════════════════════════╝"
echo ""
echo " OS: $OS ($ARCH)"
echo ""
# Etsi Ollama-instanssit
CANDIDATES=(
"http://localhost:11434"
"http://127.0.0.1:11434"
"http://ollama:11434"
"http://host.docker.internal:11434"
)
# Lisää OLLAMA_URL listaan jos asetettu ja ei jo mukana
if [ -n "$OLLAMA_URL" ]; then
ALREADY=false
for c in "${CANDIDATES[@]}"; do
[ "$c" = "$OLLAMA_URL" ] && ALREADY=true
done
$ALREADY || CANDIDATES=("$OLLAMA_URL" "${CANDIDATES[@]}")
fi
echo " Etsitään Ollama-instansseja..."
FOUND=()
for url in "${CANDIDATES[@]}"; do
if curl -s --connect-timeout 1 "$url/api/tags" &>/dev/null; then
FOUND+=("$url")
fi
done
if [ ${#FOUND[@]} -eq 0 ]; then
# Ei löytynyt — yritä käynnistää lokaali
if command -v ollama &>/dev/null; then
echo " Käynnistetään Ollama..."
ollama serve &>/dev/null &
sleep 3
if curl -s --connect-timeout 1 "http://localhost:11434/api/tags" &>/dev/null; then
OLLAMA_URL="http://localhost:11434"
echo " ✓ Ollama käynnistetty ($OLLAMA_URL)"
else
echo " ✗ Ollaman käynnistys epäonnistui."
exit 1
fi
else
echo ""
echo " ✗ Ollamaa ei löytynyt."
echo " Kontti/remote: OLLAMA_URL=http://HOST:11434 ./kipina-node"
echo " Asenna: curl -fsSL https://ollama.ai/install.sh | sh"
exit 1
fi
elif [ ${#FOUND[@]} -eq 1 ]; then
OLLAMA_URL="${FOUND[0]}"
echo " ✓ Ollama löytyi: $OLLAMA_URL"
else
echo ""
echo " Löytyi ${#FOUND[@]} Ollama-instanssia:"
echo ""
for i in "${!FOUND[@]}"; do
echo " $((i+1))) ${FOUND[$i]}"
done
echo ""
read -p " Valitse [1-${#FOUND[@]}]: " -r CHOICE
if [[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le ${#FOUND[@]} ]; then
OLLAMA_URL="${FOUND[$((CHOICE-1))]}"
else
OLLAMA_URL="${FOUND[0]}"
echo " Käytetään oletusta: $OLLAMA_URL"
fi
echo " ✓ Valittu: $OLLAMA_URL"
fi
echo ""
echo " Hub: $HUB_URL"
echo " Ollama: $OLLAMA_URL"
if [ -n "$KIPINA_MODEL" ]; then
echo " Malli: $KIPINA_MODEL (Ympäristömuuttujasta)"
fi
# Binäärin automaattinen päivitys — vertaa build-hashia palvelimeen
BIN_PATH="./kipina-node-bin"
HASH_PATH="./kipina-node-bin.hash"
REMOTE_HASH=$(curl -sSL "$BASE_URL/.build-hash?v=$(date +%s)" 2>/dev/null | tr -d '[:space:]')
LOCAL_HASH=""
[ -f "$HASH_PATH" ] && LOCAL_HASH=$(cat "$HASH_PATH" | tr -d '[:space:]')
if [ -f "$BIN_PATH" ] && [ -n "$REMOTE_HASH" ] && [ "$REMOTE_HASH" = "$LOCAL_HASH" ]; then
echo " ✓ Binääri ajan tasalla (versio: $LOCAL_HASH)"
else
if [ -f "$BIN_PATH" ]; then
echo " ↻ Uusi versio saatavilla ($LOCAL_HASH → $REMOTE_HASH)"
else
echo " Ladataan $BINARY..."
fi
rm -f "$BIN_PATH"
curl -sSL "$BASE_URL/$BINARY?v=$(date +%s)" -o "$BIN_PATH"
chmod +x "$BIN_PATH"
echo "$REMOTE_HASH" > "$HASH_PATH"
echo " ✓ Päivitetty versioon $REMOTE_HASH"
fi
echo ""
echo " ✓ Siirrytään Kipinä Noden hallintaan..."
echo " Ctrl+C pysäyttää"
echo ""
if [ -n "$KIPINA_MODEL" ]; then
export OLLAMA_MODEL="$KIPINA_MODEL"
fi
export HUB_URL="$HUB_URL"
export OLLAMA_URL="$OLLAMA_URL"
exec "$BIN_PATH"

View File

@@ -23,3 +23,4 @@ dialoguer = "0.12.0"
ratatui = "0.29.0"
crossterm = { version = "0.28.1", features = ["event-stream"] }
tracing-appender = "0.2.4"
chrono = "0.4"

View File

@@ -69,6 +69,10 @@ impl LlmEngine {
self.model.borrow().clone()
}
pub fn ollama_url(&self) -> &str {
&self.ollama_url
}
pub fn set_model(&self, new_model: String) {
*self.model.borrow_mut() = new_model;
}
@@ -91,6 +95,32 @@ impl LlmEngine {
}
}
/// Hakee käynnissä olevan mallin VRAM-tilan (ollama ps)
pub async fn fetch_ps(&self) -> Result<Option<ModelVramStatus>, String> {
let resp = self.client.get(format!("{}/api/ps", self.ollama_url))
.send()
.await
.map_err(|e| format!("Ollama ps: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Ollama ps HTTP {}", resp.status()));
}
let body: serde_json::Value = resp.json().await
.map_err(|e| format!("Ollama ps json: {}", e))?;
let models = body["models"].as_array();
if let Some(arr) = models {
if let Some(m) = arr.first() {
let name = m["name"].as_str().unwrap_or("?").to_string();
let size = m["size"].as_u64().unwrap_or(0);
let size_vram = m["size_vram"].as_u64().unwrap_or(0);
return Ok(Some(ModelVramStatus { name, size, size_vram }));
}
}
Ok(None) // ei ladattua mallia
}
/// Hakee kaikki Ollamaan asennetut mallit
pub async fn fetch_models(&self) -> Result<serde_json::Value, String> {
let resp = self.client.get(format!("{}/api/tags", self.ollama_url))
@@ -126,6 +156,7 @@ impl LlmEngine {
"messages": messages,
"stream": false,
"options": {
"num_ctx": 16384,
"num_predict": opts.max_tokens,
"temperature": opts.temperature.unwrap_or(0.7),
"top_k": opts.top_k.unwrap_or(40),
@@ -184,3 +215,32 @@ pub struct GenerateResult {
pub duration_ms: f64,
pub tokens_per_sec: f64,
}
pub struct ModelVramStatus {
pub name: String,
pub size: u64, // kokonaiskoko (tavuina)
pub size_vram: u64, // VRAM:ssa oleva osuus (tavuina)
}
impl ModelVramStatus {
pub fn fully_in_vram(&self) -> bool {
self.size > 0 && self.size_vram >= self.size
}
pub fn vram_percent(&self) -> f64 {
if self.size == 0 { return 0.0; }
(self.size_vram as f64 / self.size as f64) * 100.0
}
pub fn display(&self) -> String {
let size_gb = self.size as f64 / 1_073_741_824.0;
let vram_gb = self.size_vram as f64 / 1_073_741_824.0;
if self.fully_in_vram() {
format!("{} ({:.1} GB) — 100% GPU", self.name, size_gb)
} else if self.size_vram == 0 {
format!("{} ({:.1} GB) — 100% CPU", self.name, size_gb)
} else {
format!("{} ({:.1}/{:.1} GB VRAM, {:.0}% GPU)", self.name, vram_gb, size_gb, self.vram_percent())
}
}
}

View File

@@ -363,6 +363,48 @@ async fn main() {
st.push_log("System", format!("Malli valmis: {}", active_model), None);
}
// Lämmittelykutsu: ladataan malli VRAM:iin ja haetaan VRAM-tila
if let Some(ref engine) = llm {
{
let mut st = tui_state.write().await;
st.vram_status = "Ladataan VRAM:iin...".to_string();
st.push_log("System", "Ladataan mallia VRAM:iin...".to_string(), None);
}
// Lyhyt generate-kutsu pakottaa Ollaman lataamaan mallin GPU:lle
let _ = engine.generate("hi", &inference::GenerateOptions {
max_tokens: 1, system_prompt: None, temperature: Some(0.0),
top_k: Some(1), repeat_penalty: None, stop: None,
}).await;
if let Ok(Some(ps)) = engine.fetch_ps().await {
let mut st = tui_state.write().await;
st.vram_status = ps.display();
st.push_log("System", format!("VRAM: {}", ps.display()), None);
}
let vram_engine_url = engine.ollama_url().to_string();
let vram_state = tui_state.clone();
tokio::spawn(async move {
let client = reqwest::Client::new();
loop {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
if let Ok(resp) = client.get(format!("{}/api/ps", vram_engine_url)).send().await {
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(arr) = body["models"].as_array() {
if let Some(m) = arr.first() {
let name = m["name"].as_str().unwrap_or("?").to_string();
let size = m["size"].as_u64().unwrap_or(0);
let size_vram = m["size_vram"].as_u64().unwrap_or(0);
let status = inference::ModelVramStatus { name, size, size_vram };
vram_state.write().await.vram_status = status.display();
} else {
vram_state.write().await.vram_status = "Ei ladattua mallia".to_string();
}
}
}
}
}
});
}
// Käynnistetään graafinen TUI vain jos stdin on terminaali (ei taustaprosessina)
let ui_state = tui_state.clone();
if std::io::stdin().is_terminal() {
@@ -401,6 +443,13 @@ async fn main() {
continue;
}
// Merkitään yhdistetyksi TUI:ssa
{
let mut st = tui_state.write().await;
st.status = "ACTIVE".to_string();
st.push_log("Network", "Yhdistetty hubiin".to_string(), None);
}
loop {
tokio::select! {
cmd = cmd_rx.recv() => {
@@ -497,6 +546,18 @@ async fn main() {
}
}
}
// Node joined → oma node_id
if text.contains(r#""type":"node_joined""#) {
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(nid) = msg.get("node_id").and_then(|v| v.as_u64()) {
let mut st = tui_state.write().await;
if st.node_id.is_none() {
st.node_id = Some(nid);
st.push_log("Network", format!("Node ID: #{}", nid), None);
}
}
}
}
// Verkon globaali tila
if text.contains(r#""type":"network_status""#) {
if let Ok(status) = serde_json::from_str::<serde_json::Value>(&text) {
@@ -615,9 +676,27 @@ async fn main() {
}
}
// Yhteys katkesi — nollataan TUI:n busy-tila
{
let mut st = tui_state.write().await;
let lost_task = st.cur_task_id.clone();
if let Some(tid) = lost_task {
st.push_log("Network", format!("Tehtävä {} keskeytyi yhteyden katketessa", tid), None);
}
st.cur_task_id = None;
st.cur_prompt = None;
st.node_id = None;
st.status = "RECONNECTING".to_string();
st.push_log("Network", "Yhteys hubiin katkesi — yhdistetään uudelleen 5s...".to_string(), None);
}
tracing::warn!("Yhteys hubiin katkesi — yritetään uudelleen 5s...");
}
Err(e) => {
{
let mut st = tui_state.write().await;
st.status = "RECONNECTING".to_string();
st.push_log("Network", format!("Yhdistäminen epäonnistui: {} — yritetään 5s...", e), None);
}
tracing::warn!("Hubiin yhdistäminen epäonnistui: {} — yritetään uudelleen 5s...", e);
}
}

View File

@@ -21,6 +21,7 @@ pub struct LogEntry {
pub ty: String,
pub msg: String,
pub speed: Option<f64>,
pub timestamp: String,
}
pub struct DashboardState {
@@ -35,6 +36,8 @@ pub struct DashboardState {
pub last_tokens_sec: f64,
pub network_active_nodes: usize,
pub network_total_tasks: u64,
// VRAM-tila (ollama ps)
pub vram_status: String,
// Mallivalikko
pub model_picker_open: bool,
pub model_picker_items: Vec<String>,
@@ -55,6 +58,7 @@ impl DashboardState {
last_tokens_sec: 0.0,
network_active_nodes: 1, // oletetaan itsemme
network_total_tasks: 0,
vram_status: "Haetaan...".to_string(),
model_picker_open: false,
model_picker_items: Vec::new(),
model_picker_idx: 0,
@@ -62,7 +66,9 @@ impl DashboardState {
}
pub fn push_log(&mut self, ty: &str, msg: String, speed: Option<f64>) {
let now = chrono::Local::now().format("%H:%M:%S").to_string();
self.logs.push(LogEntry {
timestamp: now,
ty: ty.to_string(),
msg,
speed,
@@ -179,7 +185,7 @@ fn ui(f: &mut ratatui::Frame, st: &DashboardState) {
let body_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7), // Yläosan info ja tehtävä
Constraint::Length(8), // Yläosan info ja tehtävä
Constraint::Min(0), // Lokit / Chat alas
].as_ref())
.split(chunks[1]);
@@ -192,12 +198,38 @@ fn ui(f: &mut ratatui::Frame, st: &DashboardState) {
].as_ref())
.split(body_chunks[0]);
// Vasen paneeli: Laitteisto, Malli & Verkosto
let info_text = format!(
"🚀 Malli: {}\n💻 Järjestelmä: {}\n📊 Tehdyt: {} | Nopeus: {} t/s\n🌐 Verkosto: {} solmua | {} tehtävää",
st.model_name, st.sys_info, st.tasks_completed, st.last_tokens_sec, st.network_active_nodes, st.network_total_tasks
);
let left_panel = Paragraph::new(info_text)
// Vasen paneeli: Laitteisto, Malli & Verkosto — VRAM-rivi värikoodattu
let vram_color = if st.vram_status.starts_with('✓') {
Color::Green
} else if st.vram_status.starts_with('◐') {
Color::Yellow
} else if st.vram_status.starts_with('✗') {
Color::Red
} else {
Color::DarkGray
};
let info_lines = vec![
ratatui::text::Line::from(vec![
ratatui::text::Span::raw("🚀 Malli: "),
ratatui::text::Span::styled(&st.model_name, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
]),
ratatui::text::Line::from(vec![
ratatui::text::Span::raw("🎮 VRAM: "),
ratatui::text::Span::styled(&st.vram_status, Style::default().fg(vram_color)),
]),
ratatui::text::Line::from(vec![
ratatui::text::Span::raw("💻 Järjestelmä: "),
ratatui::text::Span::styled(&st.sys_info, Style::default().fg(Color::White)),
]),
ratatui::text::Line::from(format!(
"📊 Tehdyt: {} | Nopeus: {:.1} t/s", st.tasks_completed, st.last_tokens_sec
)),
ratatui::text::Line::from(format!(
"🌐 Verkosto: {} solmua | {} tehtävää", st.network_active_nodes, st.network_total_tasks
)),
];
let left_panel = Paragraph::new(info_lines)
.block(Block::default().title(" Laitteisto ja AI ").borders(Borders::ALL))
.style(Style::default().fg(Color::White))
.wrap(Wrap { trim: true });
@@ -241,6 +273,8 @@ fn ui(f: &mut ratatui::Frame, st: &DashboardState) {
};
ratatui::text::Line::from(vec![
ratatui::text::Span::styled(&log.timestamp, Style::default().fg(Color::DarkGray)),
ratatui::text::Span::raw(" "),
ratatui::text::Span::styled(format!("{: <8}", log.ty), Style::default().fg(ty_color).add_modifier(Modifier::BOLD)),
ratatui::text::Span::raw(" | "),
ratatui::text::Span::styled(log.msg.clone(), Style::default().fg(Color::White)),

View File

@@ -0,0 +1,513 @@
#!/usr/bin/env node
/**
* Kipinä Model Benchmark
*
* Generoi projekteja eri Ollama-malleilla ja testaa niiden toimivuus.
* Käyttö:
* node model-benchmark.mjs # kaikki mallit, oletusskenaario
* node model-benchmark.mjs --models qwen3:8b,qwen3:30b
* node model-benchmark.mjs --ollama http://host:11434
* node model-benchmark.mjs --scenarios all # kaikki skenaariot
*/
import { execSync } from 'child_process';
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
// === CLI-argumentit ===
const args = process.argv.slice(2);
function arg(name, fallback) {
const i = args.indexOf(`--${name}`);
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
}
const OLLAMA_URL = arg('ollama', process.env.OLLAMA_URL || 'http://localhost:11434');
const HUB_URL = arg('hub', ''); // Vaihtoehto: --hub https://kipina.studio
const FILTER_MODELS = arg('models', '');
const SCENARIO_FILTER = arg('scenarios', 'default');
const OUTPUT_DIR = arg('output', '/tmp/kipina-benchmark');
const MAX_FIX_ROUNDS = 2;
// === Ollama / Hub -client ===
async function ollamaChat(model, prompt, systemPrompt, maxTokens = 2048) {
const start = Date.now();
if (HUB_URL) {
// Hub-reitti: /api/v1/chat/completions
const taskId = `bench-${Date.now()}-${Math.random().toString(36).slice(2,8)}`;
const resp = await fetch(`${HUB_URL}/api/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt, task_id: taskId, system_prompt: systemPrompt, max_tokens: maxTokens }),
});
if (!resp.ok) throw new Error(`Hub HTTP ${resp.status}: ${await resp.text()}`);
const data = await resp.json();
const elapsed = Date.now() - start;
return {
text: (data.response || '').trim(),
tokens: data.tokens_generated || 0,
durationMs: elapsed,
tokPerSec: data.tokens_per_sec || (data.tokens_generated || 0) / (elapsed / 1000),
};
}
// Suora Ollama-reitti: /api/chat
const messages = [];
if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
messages.push({ role: 'user', content: prompt });
const resp = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages,
stream: false,
options: { num_predict: maxTokens, temperature: 0.7, top_k: 40, repeat_penalty: 1.15 },
}),
});
if (!resp.ok) throw new Error(`Ollama HTTP ${resp.status}: ${await resp.text()}`);
const data = await resp.json();
const elapsed = Date.now() - start;
const text = (data.message?.content || '').trim();
const evalCount = data.eval_count || 0;
const evalDurationNs = data.eval_duration || 1;
const tokPerSec = evalCount / (evalDurationNs / 1e9);
return { text, tokens: evalCount, durationMs: elapsed, tokPerSec };
}
async function ollamaListModels() {
const url = HUB_URL ? `${HUB_URL}/api/v1/ollama/tags` : `${OLLAMA_URL}/api/tags`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`Tags: HTTP ${resp.status}`);
const data = await resp.json();
return (data.models || []).map(m => m.name);
}
// === Promptit (kopioitu index.astrosta) ===
const CLIENT_SYSTEM = `You are a product owner who turns vague ideas into clear, actionable software requirements.
GIVEN a short project description from the user, produce a structured brief:
1. PROJECT NAME: a short, descriptive name
2. GOAL: one sentence explaining what the software does and who it's for
3. CORE FEATURES: numbered list of 3-8 concrete features (not vague wishes)
4. DATA MODEL: list the main entities and their key fields (include field types)
5. API ENDPOINTS: list the REST endpoints (method + path + purpose)
6. CONSTRAINTS: any technical constraints (e.g. "must use SQLite", "no auth needed")
RULES:
- Be specific: "User can filter todos by status" not "todo management"
- Use plain English, no code
- Maximum 400 words total`;
const SPEC_SYSTEM = `You are a software architect who designs database schemas for Python web applications.
THINK STEP BY STEP before outputting JSON:
1. What are the main ENTITIES (nouns) in this project?
2. What FIELDS does each entity need? (name, type, required?)
3. Which entities REFERENCE each other? (e.g. "a Book belongs to an Author" → Book has author_id)
4. Are there Date/DateTime fields? → add extra_imports
Then output ONLY valid JSON (no explanations before or after).
SCHEMA:
{"project_name":"short-name","description":"One sentence","entities":[{"name":"EntityName","table_name":"entity_names","fields":[{"name":"field_name","sa_type":"String(255)","py_type":"str","nullable":false,"default":null}]}],"relationships":[{"from":"ChildEntity","field":"parent_id","to":"ParentEntity","type":"many-to-one"}],"extra_imports":[]}
FIELD RULES:
- sa_type: String(N), Text, Integer, Date, DateTime, Boolean, Float
- py_type: str, int, float, bool, date, datetime — append " | None" if nullable
- Status fields: use String(20) with default value, NEVER Enum
- Every entity gets "id" automatically — do NOT add id or redundant ID fields
- Use snake_case for field names
RELATIONSHIP RULES:
- If entity A "belongs to" entity B → A has b_id field (Integer, nullable=false) + relationship entry
- EVERY _id field MUST have a matching relationship entry
- Parent entities must appear BEFORE children in the entities array
- If no relationships, set "relationships": []
AVOID: redundant ID fields, generic names, more than 7 fields or 3 entities, non-English entity/field names (ALWAYS English even if description is Finnish)
EXAMPLES (adapt, don't copy):
Todo app → Todo: title(str), description(Text|None), due_date(Date|None), status(String20="pending")
Blog → Author: name,email,bio(Text|None) / Post: title, content(Text), author_id→Author, published_at(DateTime|None), status(String20="draft")`;
const FIX_SYSTEM = 'You are a Python code fixer. Return ONLY the corrected Python file. No markdown fences, no explanations — just valid Python code.';
// === Template-funktiot (kopioitu korjatusta index.astrosta) ===
function pyLiteral(val) {
if (val === true) return 'True';
if (val === false) return 'False';
if (val === null || val === undefined) return 'None';
if (typeof val === 'string') return `"${val}"`;
return String(val);
}
function pyJsonLiteral(obj) {
const parts = Object.entries(obj).map(([k, v]) => {
let pyVal;
if (v === true) pyVal = 'True'; else if (v === false) pyVal = 'False';
else if (v === null) pyVal = 'None'; else if (typeof v === 'string') pyVal = `"${v}"`;
else pyVal = String(v);
return `"${k}":${pyVal}`;
});
return '{' + parts.join(',') + '}';
}
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 relMap = {};
for (const r of (spec.relationships || [])) {
const target = spec.entities.find(e => e.name === r.to);
if (target) relMap[`${r.from}.${r.field}`] = target.table_name;
}
if (Object.keys(relMap).length > 0) saTypes.add('ForeignKey');
const imports = [...saTypes].sort().join(', ');
let code = `from sqlalchemy import create_engine, Column, ${imports}\nfrom sqlalchemy.orm import declarative_base, sessionmaker\n\nDATABASE_URL = "sqlite:///./app.db"\nengine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})\nSessionLocal = 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) {
const fkTarget = relMap[`${e.name}.${f.name}`];
let parts = fkTarget ? [`Column(${f.sa_type}, ForeignKey("${fkTarget}.id")`] : [`Column(${f.sa_type}`];
if (!f.nullable) parts.push('nullable=False');
if (f.default !== null && f.default !== undefined) parts.push(`default=${pyLiteral(f.default)}`);
code += ` ${f.name} = ${parts.join(', ')})\n`;
}
code += '\n';
}
code += 'Base.metadata.create_all(bind=engine)\n';
return code;
}
function tmplSchemas(spec) {
const dtTypes = new Set();
for (const e of spec.entities) for (const f of e.fields) {
if (/\bdate\b/i.test(f.py_type) && !/datetime/.test(f.py_type)) dtTypes.add('date');
if (/\bdatetime\b/i.test(f.py_type)) dtTypes.add('datetime');
}
let code = 'from pydantic import BaseModel, ConfigDict\n';
if (dtTypes.size > 0) code += `from datetime import ${[...dtTypes].sort().join(', ')}\n`;
for (const imp of (spec.extra_imports || [])) {
if (/^(date|datetime)$/.test(imp.trim())) continue;
if (/^from\s/.test(imp) || /^import\s/.test(imp)) 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} = ${pyLiteral(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 model_config = ConfigDict(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\nfrom models import Base, engine, SessionLocal, ${modelNames}\nfrom schemas import ${createNames}, ${responseNames}\n\napp = 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)\ndef create_${lo}(item: ${e.name}Create, db: Session = Depends(get_db)):\n 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])\ndef 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)\ndef get_${lo}(item_id: int, db: Session = Depends(get_db)):\n item = db.query(${e.name}).filter(${e.name}.id == item_id).first()\n 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)\ndef update_${lo}(item_id: int, item: ${e.name}Create, db: Session = Depends(get_db)):\n db_item = db.query(${e.name}).filter(${e.name}.id == item_id).first()\n if not db_item:\n raise HTTPException(status_code=404, detail="${e.name} not found")\n 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)\ndef delete_${lo}(item_id: int, db: Session = Depends(get_db)):\n db_item = db.query(${e.name}).filter(${e.name}.id == item_id).first()\n 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\nfrom main import app, get_db\nfrom models import Base\n\nTEST_DB = "sqlite:///./test.db"\ntest_engine = create_engine(TEST_DB, connect_args={"check_same_thread": False})\nTestSession = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)\nBase.metadata.create_all(bind=test_engine)\n\ndef override_get_db():\n db = TestSession()\n try:\n yield db\n finally:\n db.close()\n\napp.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 = pyJsonLiteral(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 = pyJsonLiteral(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`;
}
// === Validaattori ===
function validateProjectCode(files) {
const issues = [];
for (const [fname, code] of Object.entries(files)) {
if (!fname.endsWith('.py')) continue;
const lines = code.split('\n');
for (const line of lines) {
const m = line.match(/^from\s+\.(\w*)\s+import/);
if (m) issues.push(`ISSUE: ${fname}: relatiivinen import`);
}
for (const line of lines) {
const m = line.match(/^from\s+(models|schemas|main)\s+import\s+(.+)/);
if (!m) continue;
const srcCode = files[m[1] + '.py'];
if (!srcCode) { issues.push(`ISSUE: ${fname}: ${m[1]}.py 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}: "${name}" puuttuu ${m[1]}.py:stä`);
}
}
if (fname === 'schemas.py') {
if (/:\s*date\b/.test(code) && !/from datetime import/.test(code))
issues.push('ISSUE: schemas.py: date-import puuttuu');
if (/:\s*datetime\b/.test(code) && !/from datetime import/.test(code))
issues.push('ISSUE: schemas.py: datetime-import puuttuu');
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^\s*#/.test(line) || /^\s*$/.test(line)) continue;
if (/(?<!["\w])false(?![\w"])/.test(line)) issues.push(`ISSUE: ${fname}:${i+1}: "false" → "False"`);
if (/(?<!["\w])true(?![\w"])/.test(line)) issues.push(`ISSUE: ${fname}:${i+1}: "true" → "True"`);
}
}
return issues;
}
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;
}
// === Testiskenaariot ===
const SCENARIOS = [
{ id: 'todo', prompt: 'Todo-sovellus: tehtävien hallinta, deadline, prioriteetti ja status' },
{ id: 'users', prompt: 'REST API käyttäjähallinnalle SQLite-tietokannalla' },
{ id: 'blog', prompt: 'Blogi-API: kirjoittajat ja artikkelit, julkaisupäivämäärä ja status' },
];
// === Pipeline: yhdelle mallille ja skenaariolle ===
async function runPipeline(model, scenario) {
const result = {
model, scenario: scenario.id,
reqOk: false, specOk: false, specEntities: 0,
validationIssues: 0, fixRounds: 0,
testsTotal: 0, testsPassed: 0, testsFailed: 0,
totalDurationMs: 0, totalTokens: 0, avgTokPerSec: 0,
error: null,
};
const timings = [];
const dir = `${OUTPUT_DIR}/${model.replace(/[/:]/g, '_')}__${scenario.id}`;
mkdirSync(dir, { recursive: true });
try {
// 1. Vaatimukset
console.log(` [1/5] Vaatimukset...`);
const req = await ollamaChat(model, scenario.prompt, CLIENT_SYSTEM, 1024);
timings.push(req);
if (!req.text || req.text.length < 50) { result.error = 'Vaatimukset liian lyhyet'; return result; }
result.reqOk = true;
writeFileSync(`${dir}/_requirements.txt`, req.text);
// 2. JSON-speksi
console.log(` [2/5] JSON-speksi...`);
const specResp = await ollamaChat(model, `${req.text}\n\nOutput a JSON spec for this project.`, SPEC_SYSTEM, 2048);
timings.push(specResp);
const spec = extractJson(specResp.text);
if (!spec || !spec.entities || spec.entities.length === 0) { result.error = 'JSON-speksi epäonnistui'; writeFileSync(`${dir}/_spec_raw.txt`, specResp.text); return result; }
result.specOk = true;
result.specEntities = spec.entities.length;
writeFileSync(`${dir}/_spec.json`, JSON.stringify(spec, null, 2));
// 3. Template-generointi
console.log(` [3/5] Koodigenerointi...`);
const files = {
'models.py': tmplModels(spec),
'schemas.py': tmplSchemas(spec),
'main.py': tmplMain(spec),
'test_main.py': tmplTests(spec),
'pyproject.toml': tmplPyproject(spec),
};
// 4. Validointi + korjaussilmukka
let issues = validateProjectCode(files);
let fixRound = 0;
while (issues.length > 0 && fixRound < MAX_FIX_ROUNDS) {
fixRound++;
console.log(` [4/5] Korjauskierros ${fixRound} (${issues.length} ongelmaa)...`);
const issuesByFile = {};
for (const issue of issues) {
const m = issue.match(/^ISSUE:\s*(\S+?):/);
const fname = m ? m[1] : 'unknown';
if (!issuesByFile[fname]) issuesByFile[fname] = [];
issuesByFile[fname].push(issue);
}
for (const [fname, fIssues] of Object.entries(issuesByFile)) {
if (!files[fname]) continue;
const fixPrompt = `Fix the following issues in this Python file. Return ONLY the complete corrected file, no explanations.\n\nISSUES:\n${fIssues.join('\n')}\n\nCURRENT FILE (${fname}):\n\`\`\`python\n${files[fname]}\`\`\``;
const fixResp = await ollamaChat(model, fixPrompt, FIX_SYSTEM, 2048);
timings.push(fixResp);
if (fixResp.text) {
files[fname] = fixResp.text.replace(/^```(?:python)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim() + '\n';
}
}
issues = validateProjectCode(files);
}
result.validationIssues = issues.length;
result.fixRounds = fixRound;
// Kirjoita tiedostot levylle
for (const [fn, content] of Object.entries(files)) writeFileSync(`${dir}/${fn}`, content);
// 5. Pytest
console.log(` [5/5] Pytest...`);
try {
const uvPath = process.env.HOME + '/.local/bin/uv';
const uv = existsSync(uvPath) ? uvPath : 'uv';
execSync(`cd "${dir}" && ${uv} sync 2>/dev/null`, { timeout: 60000, stdio: 'pipe' });
execSync(`cd "${dir}" && rm -f app.db test.db`, { stdio: 'pipe' });
const pytestOut = execSync(`cd "${dir}" && ${uv} run pytest test_main.py -v --tb=short 2>&1`, { timeout: 60000, encoding: 'utf-8' });
writeFileSync(`${dir}/_pytest.txt`, pytestOut);
const passedMatch = pytestOut.match(/(\d+) passed/);
const failedMatch = pytestOut.match(/(\d+) failed/);
result.testsPassed = passedMatch ? parseInt(passedMatch[1]) : 0;
result.testsFailed = failedMatch ? parseInt(failedMatch[1]) : 0;
result.testsTotal = result.testsPassed + result.testsFailed;
} catch (e) {
const output = e.stdout || e.stderr || e.message || '';
writeFileSync(`${dir}/_pytest.txt`, output);
const passedMatch = output.match(/(\d+) passed/);
const failedMatch = output.match(/(\d+) failed/);
const errorMatch = output.match(/(\d+) error/);
result.testsPassed = passedMatch ? parseInt(passedMatch[1]) : 0;
result.testsFailed = (failedMatch ? parseInt(failedMatch[1]) : 0) + (errorMatch ? parseInt(errorMatch[1]) : 0);
result.testsTotal = result.testsPassed + result.testsFailed;
if (result.testsTotal === 0) result.error = 'Pytest kaatui';
}
} catch (e) {
result.error = e.message;
}
// Yhteenveto
result.totalDurationMs = timings.reduce((s, t) => s + t.durationMs, 0);
result.totalTokens = timings.reduce((s, t) => s + t.tokens, 0);
result.avgTokPerSec = timings.length > 0 ? timings.reduce((s, t) => s + t.tokPerSec, 0) / timings.length : 0;
return result;
}
// === Main ===
async function main() {
console.log('╔══════════════════════════════════════════════╗');
console.log('║ Kipinä Model Benchmark ║');
console.log('╚══════════════════════════════════════════════╝');
console.log(`Ollama: ${OLLAMA_URL}`);
// Haetaan mallit
let models;
try {
models = await ollamaListModels();
} catch (e) {
console.error(`Ei yhteyttä Ollamaan (${OLLAMA_URL}): ${e.message}`);
process.exit(1);
}
if (FILTER_MODELS) {
const filter = FILTER_MODELS.split(',').map(s => s.trim());
models = models.filter(m => filter.some(f => m.includes(f)));
}
console.log(`Mallit (${models.length}): ${models.join(', ')}`);
const scenarios = SCENARIO_FILTER === 'all' ? SCENARIOS : [SCENARIOS[0]];
console.log(`Skenaariot (${scenarios.length}): ${scenarios.map(s => s.id).join(', ')}`);
console.log(`Tulokset: ${OUTPUT_DIR}/`);
console.log('');
// Puhdista output
rmSync(OUTPUT_DIR, { recursive: true, force: true });
mkdirSync(OUTPUT_DIR, { recursive: true });
const results = [];
for (const model of models) {
for (const scenario of scenarios) {
console.log(`\n━━━ ${model} × ${scenario.id} ━━━`);
const r = await runPipeline(model, scenario);
results.push(r);
const status = r.error ? `${r.error}` :
r.testsPassed === r.testsTotal && r.testsTotal > 0 ? `${r.testsPassed}/${r.testsTotal}` :
`${r.testsPassed}/${r.testsTotal}`;
console.log(`${status} | ${(r.totalDurationMs/1000).toFixed(1)}s | ${r.totalTokens} tok | ${r.avgTokPerSec.toFixed(1)} tok/s`);
}
}
// === Tulostaulu ===
console.log('\n\n╔══════════════════════════════════════════════════════════════════════════════════════════════════╗');
console.log('║ TULOKSET ║');
console.log('╠══════════════════════════════════════════════════════════════════════════════════════════════════╣');
const header = [
'Malli'.padEnd(40),
'Skenaario'.padEnd(10),
'Speksi'.padEnd(8),
'Testit'.padEnd(10),
'Korjaus'.padEnd(8),
'Aika'.padEnd(8),
'tok/s'.padEnd(8),
'Tulos',
].join(' │ ');
console.log(`${header}`);
console.log('╠' + '═'.repeat(header.length + 2) + '╣');
for (const r of results) {
const specStatus = r.specOk ? `${r.specEntities}e` : '✗';
const testStatus = r.testsTotal > 0 ? `${r.testsPassed}/${r.testsTotal}` : '-';
const fixStatus = r.fixRounds > 0 ? `${r.fixRounds}×` : '-';
const time = `${(r.totalDurationMs/1000).toFixed(0)}s`;
const speed = `${r.avgTokPerSec.toFixed(0)}`;
const verdict = r.error ? '✗ FAIL' : r.testsPassed === r.testsTotal && r.testsTotal > 0 ? '✓ PASS' : '◐ PARTIAL';
const row = [
r.model.padEnd(40),
r.scenario.padEnd(10),
specStatus.padEnd(8),
testStatus.padEnd(10),
fixStatus.padEnd(8),
time.padEnd(8),
speed.padEnd(8),
verdict,
].join(' │ ');
console.log(`${row}`);
}
console.log('╚' + '═'.repeat(header.length + 2) + '╝');
// Tallenna JSON
writeFileSync(`${OUTPUT_DIR}/results.json`, JSON.stringify(results, null, 2));
console.log(`\nJSON: ${OUTPUT_DIR}/results.json`);
// Yhteenveto
const passed = results.filter(r => !r.error && r.testsPassed === r.testsTotal && r.testsTotal > 0);
const partial = results.filter(r => !r.error && r.testsPassed < r.testsTotal && r.testsTotal > 0);
const failed = results.filter(r => r.error || r.testsTotal === 0);
console.log(`\n✓ PASS: ${passed.length} | ◐ PARTIAL: ${partial.length} | ✗ FAIL: ${failed.length} | Yhteensä: ${results.length}`);
}
main().catch(e => { console.error(e); process.exit(1); });

Binary file not shown.

Binary file not shown.