Compare commits
25 Commits
8468724a4c
...
projekti1
| Author | SHA1 | Date | |
|---|---|---|---|
| 20cea8f268 | |||
| 38a18c555b | |||
| 8138e41aa1 | |||
| 6ee5bdf960 | |||
| cf3bf54bf8 | |||
| 56f21a96c9 | |||
| 763b93396c | |||
| e09962940a | |||
| 5e44b63b0c | |||
| 0f3881aa02 | |||
| fa85dcc5b3 | |||
| 58d93613f0 | |||
| 66b4435362 | |||
| 3a00de9b8e | |||
| 670141c8c3 | |||
| 59daebbd38 | |||
| 42b71dbf77 | |||
| b88a741f85 | |||
|
|
68c7195d54 | ||
|
|
3d20238eef | ||
|
|
8b8ba01af3 | ||
|
|
a3b95a56e8 | ||
|
|
5b20ebe800 | ||
|
|
ffe9bd6902 | ||
|
|
d27068b11a |
157
TEMPLATING.md
Normal file
157
TEMPLATING.md
Normal 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.5B–7B) 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ö, ~200–400 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).
|
||||
@@ -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 | 7–14 | **1** |
|
||||
| Aika | 80–120s | **~20s** |
|
||||
| LLM-kutsuja | 7–14 | **3** (speksi + requirements + README) |
|
||||
| Aika | 80–120s | **~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
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
dirty-3e9cdd70c60dadfb970cee47ebbd912c
|
||||
cf3bf54
|
||||
|
||||
Binary file not shown.
BIN
network-poc/frontend/public/forge_hero.webp
Normal file
BIN
network-poc/frontend/public/forge_hero.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -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 ""
|
||||
|
||||
BIN
network-poc/frontend/public/serpent_hero.webp
Normal file
BIN
network-poc/frontend/public/serpent_hero.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -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>
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
135
network-poc/kipina-node
Normal 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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
513
network-poc/tests/model-benchmark.mjs
Normal file
513
network-poc/tests/model-benchmark.mjs
Normal 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); });
|
||||
BIN
projektit/luodut/rest-api-kyttjhallinnalle (1).zip
Normal file
BIN
projektit/luodut/rest-api-kyttjhallinnalle (1).zip
Normal file
Binary file not shown.
BIN
projektit/luodut/rest-api-kyttjhallinnalle.zip
Normal file
BIN
projektit/luodut/rest-api-kyttjhallinnalle.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user