Add interactive Web UI (Flask + SSE workbench) with full design doc; fix rollback PURPOSE.md bug; add test-split evaluation with paired bootstrap; fix RunManager reentrant-lock deadlock

This commit is contained in:
DistributedApps.AI
2026-08-31 11:10:33 -04:00
parent 9c70a406b3
commit 4bb6ce97c8
21 changed files with 1721 additions and 29 deletions
+31 -7
View File
@@ -53,11 +53,35 @@ this work:
- **Paired bootstrap significance testing** (App. C) in `metrics.py`.
- Early stop when `R_best == 1.0`.
## Quickstart
## Web UI
An interactive workbench for running and inspecting the evolution loop:
```bash
pip install -e ".[ui]" # installs Flask
wikiskill serve # http://127.0.0.1:5000
# or: python -m wikiskill.cli serve --port 5000
```
Tabs (see `ui_design.md` for the full design):
- **Run** — configure backend (offline Mock or any OpenAI-compatible endpoint),
iterations, ReAct turns; start/cancel; live SSE console including the Skill
Proposer's ReAct `read_file` steps.
- **Dashboard** — R_best curve, accepted/rejected validation-gating bars, history table.
- **Evaluation** — final skills vs no-skill baseline on the test split with paired
bootstrap p-value (paper §4 + App. C); re-evaluate any workspace.
- **Workspace** — browse the three-layer workspace (`raw/`, `wiki/`, `skills/`) and
view any artifact (JSON pretty-printed, traces, diffs).
- **Knowledge** — evolved skills (SKILL.md + PURPOSE.md), wiki pattern pages, index,
evolution log, skill-impact audit trail.
- **How it works** — the pipeline, Algorithm 1 pseudocode, and glossary mapped to the
paper.
## Quickstart (CLI)
```bash
pip install -e ".[dev]"
pytest # 14 tests incl. end-to-end loop
pytest # 30 tests incl. UI + end-to-end loop
python -m wikiskill.demo # offline demo with the deterministic MockLLM
```
@@ -76,20 +100,20 @@ API key is read from `--api-key`, `WIKISKILL_API_KEY`, or `OPENAI_API_KEY`.
Any OpenAI-compatible `/chat/completions` endpoint works (including local vLLM, as in
the paper). `tasks.jsonl` lines: `{"id", "x", "y", "meta?"}`.
## Layout
Layout
```
src/wikiskill/
├── workspace.py # three layers + patch engine + scoped read_file
├── skills.py # skills layer, apply_proposal, skills-only snapshot/rollback
├── agents.py # InferenceAgent, WikiMaintainer, ReAct SkillProposer, stratified_sample
├── orchestrator.py # Algorithm 1 with gating/rollback + audit trail
├── orchestrator.py # Algorithm 1 with gating/rollback + audit trail + test evaluation
├── prompts.py # agent system prompts (adapted from paper Appendix E)
├── llm.py # LLM protocol, OpenAI-compatible client, MockLLM, extract_json
├── metrics.py # accuracy + paired bootstrap (1,000 iterations)
├── datasets.py # dataclasses + synthetic demo dataset
├── demo.py / cli.py
└── tests/… # unit + end-to-end tests
├── webapp/ # Flask UI (runner + api + SPA: see ui_design.md)
├── demo.py / cli.py # `wikiskill demo | evolve | serve`
└── tests/… # unit + end-to-end + UI tests
```
## Notes & limitations (mirroring the paper)
+5 -6
View File
@@ -11,17 +11,16 @@ wikiskill/
│ ├── __init__.py
│ ├── llm.py # LLM provider abstraction: MockLLM, OpenAICompatLLM
│ ├── workspace.py # Three-layer workspace: raw/, wiki/, skills/ + patch engine
│ ├── skills.py # Skills layer: SKILL.md + PURPOSE.md, snapshot/rollback
│ ├── prompts.py # System prompts (Inference / Wiki Maintainer / Skill Proposer)
│ ├── agents.py # InferenceAgent, WikiMaintainer, SkillProposer (ReAct)
│ ├── orchestrator.py # Algorithm 1: evolution loop, gating, rollback, logging
│ ├── orchestrator.py # Algorithm 1: loop, gating, rollback, test evaluation (§4)
│ ├── metrics.py # accuracy metric + paired bootstrap significance test
│ ├── datasets.py # Task/Trace dataclasses + demo dataset
│ ├── webapp/ # Web UI (see ui_design.md): runner + Flask api + SPA
│ ├── demo.py # End-to-end demo run with mock LLM
│ └── cli.py # `wikiskill evolve` / `wikiskill demo` entry point
└── tests/
├── test_workspace.py
├── test_gating.py
└── test_e2e.py
│ └── cli.py # `wikiskill demo | evolve | serve` entry point
└── tests/ # unit, end-to-end, and UI tests
```
## 2. Core data model (`datasets.py`)
+6
View File
@@ -91,3 +91,9 @@ experience into the wiki so that subsequent skill updates build on it.
workspace with all three layers populated, and accepts/rolls back proposals correctly.
3. Code mirrors paper notation (S_k, W_k, P_k, T_sample, R_best) and cites Algorithm 1 steps
in docstrings.
4. **Web UI (ui_design.md)**: `wikiskill serve` boots an interactive workbench that can
start/cancel evolution runs, stream progress live, browse the three-layer workspace,
inspect skills/wiki artifacts, and report test-split evaluation with paired bootstrap.
UI covered by Flask test-client tests plus a live-server smoke test.
5. **Post-run evaluation**: every run reports test-split accuracy for evolved skills vs
no-skill baseline with paired-bootstrap significance (paper §4 + App. C).
+1
View File
@@ -11,6 +11,7 @@ dependencies = []
[project.optional-dependencies]
dev = ["pytest>=7"]
ui = ["flask>=3.0"]
[project.scripts]
wikiskill = "wikiskill.cli:main"
+1
View File
@@ -0,0 +1 @@
flask>=3.0
+87
View File
@@ -0,0 +1,87 @@
"""Live smoke test: boots the real wikiskill serve stack and exercises it over HTTP."""
import json
import subprocess
import sys
import time
import urllib.request
PORT = 8765
BASE = f"http://127.0.0.1:{PORT}"
ok = True
def check(name, cond, extra=""):
global ok
print(f"[{'PASS' if cond else 'FAIL'}] {name} {extra}")
ok = ok and bool(cond)
def get(path, timeout=20):
with urllib.request.urlopen(BASE + path, timeout=timeout) as r:
return r.status, r.read().decode("utf-8")
proc = subprocess.Popen(
[sys.executable, "-m", "wikiskill.cli", "serve", "--host", "127.0.0.1",
"--port", str(PORT)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
try:
# wait for server
for _ in range(100):
try:
st, body = get("/api/health", timeout=5)
break
except Exception:
time.sleep(0.3)
else:
raise RuntimeError("server did not start")
check("health", st == 200 and '"ok":true' in body.replace(" ", ""))
st, body = get("/")
check("index served", st == 200 and 'WikiSkill' in body and 'run-form' in body)
req = urllib.request.Request(BASE + "/api/run",
data=json.dumps({"llm_backend": "mock",
"iterations": 2,
"workspace_root": "smoke_run"}).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as r:
st, body = r.status, r.read().decode()
check("run started", st == 202 and '"run_id"' in body)
done = False
for _ in range(200):
st, body = get("/api/status")
state = json.loads(body)["state"]
if state and state["status"] in ("done", "error"):
done = state["status"] == "done"
break
time.sleep(0.2)
check("run completed (mock, live server)", done)
st, body = get("/api/workspace?root=smoke_run")
data = json.loads(body)
check("workspace browsable", st == 200 and "wiki/index.md" in data["files"],
f"({sorted(data['files'])[:3]})")
req = urllib.request.Request(BASE + "/api/evaluate",
data=json.dumps({"llm_backend": "mock",
"workspace_root": "smoke_run"}).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
ev = json.loads(r.read().decode())["evaluation"]
check("evaluation end-to-end", ev["significant"] is True and
ev["skilled_accuracy"] == 1.0,
f"skilled={ev['skilled_accuracy']}")
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
print("SMOKE TEST", "PASSED" if ok else "FAILED")
sys.exit(0 if ok else 1)
+19 -3
View File
@@ -112,9 +112,18 @@ class SkillProposer:
"""§3.2.3: P_k = M_P(W'_k, S_{k-1}, T_train,k). Multi-turn ReAct agent that
reads the wiki index, skill-impact tracker, and traces on demand via read_file."""
def __init__(self, llm: LLM, ws: Workspace, skills: SkillsLayer, max_turns: int = 20):
def __init__(self, llm: LLM, ws: Workspace, skills: SkillsLayer, max_turns: int = 20,
on_step=None):
self.llm, self.ws, self.skills = llm, ws, skills
self.max_turns = max_turns
self.on_step = on_step # callable(step: dict) for UI transparency
def _step(self, **payload):
if self.on_step is not None:
try:
self.on_step({"proposer_step": True, **payload})
except Exception: # pragma: no cover - never break evolution on UI hooks
pass
def propose(self, outcome_summary: str, iteration: int) -> Optional[dict]:
index = self.ws.wiki_index() or "(empty wiki)"
@@ -126,19 +135,26 @@ class SkillProposer:
"(raw/iter_.../...) as needed, then finish with your atomic proposal JSON.")
messages = [{"role": "system", "content": SKILL_PROPOSER_SYSTEM},
{"role": "user", "content": user}]
for _ in range(self.max_turns):
for turn in range(1, self.max_turns + 1):
text = self.llm.chat(messages, temperature=0.2)
parsed = extract_json(text)
if parsed and parsed.get("action") in ("create", "patch"):
self._step(turn=turn, kind="proposal", skill=parsed.get("skill"),
action=parsed.get("action"))
return parsed
fm = re.search(r'"path"\s*:\s*"([^"]+)"', text)
if fm:
obs = self.ws.read_file(fm.group(1))
path = fm.group(1)
obs = self.ws.read_file(path)
self._step(turn=turn, kind="read", path=path,
found=not obs.startswith("ERROR"),
snippet=obs[:200])
messages.append({"role": "assistant", "content": text})
messages.append({"role": "user",
"content": f"read_file observation:\n{obs}"})
continue
messages.append({"role": "assistant", "content": text})
self._step(turn=turn, kind="no_tool", snippet=text[:200])
messages.append({"role": "user", "content":
"Reply with a read_file tool call or the finish JSON."})
return None
+16
View File
@@ -25,6 +25,11 @@ def main(argv=None) -> int:
e.add_argument("--base-url", default=None)
e.add_argument("--api-key", default=None)
e.add_argument("--iterations", type=int, default=8)
s = sub.add_parser("serve", help="run the WikiSkill web UI (Flask)")
s.add_argument("--host", default="127.0.0.1")
s.add_argument("--port", type=int, default=5000)
s.add_argument("--debug", action="store_true")
args = p.parse_args(argv)
if args.cmd == "demo":
@@ -32,6 +37,17 @@ def main(argv=None) -> int:
run_demo(args.root, args.iterations)
return 0
if args.cmd == "serve":
try:
from .webapp import create_app
except ImportError as exc:
print("The UI requires Flask. Install it with: "
"pip install -e '.[ui]' (or: pip install flask)", file=sys.stderr)
return 2
app = create_app()
app.run(host=args.host, port=args.port, debug=args.debug, threaded=True)
return 0
from .datasets import Dataset, Task, ToolSpec, load_jsonl
from .llm import OpenAICompatLLM
from .orchestrator import EvolutionOrchestrator
+7 -2
View File
@@ -12,16 +12,21 @@ from .workspace import Workspace
def run_demo(root: str = "wikiskill_demo_workspace", K: int = 3) -> dict:
ws = Workspace(root)
skills_logger = []
orch = EvolutionOrchestrator(
llm=MockLLM(), dataset=make_demo_dataset(), ws=ws, tools=_demo_tools(), K=K)
result = orch.run()
eval_res = orch.evaluate_test()
print("\nTest-set evaluation (final skills vs no-skill baseline):")
print(f" skilled accuracy : {eval_res['skilled_accuracy']:.4f}")
print(f" baseline accuracy: {eval_res['baseline_accuracy']:.4f}")
print(f" bootstrap p : {eval_res['bootstrap_p_value']:.4f} "
f"({'significant' if eval_res['significant'] else 'not significant'})")
print("\nWorkspace layout:")
for dirpath, _, files in os.walk(root):
rel = os.path.relpath(dirpath, root)
for fn in sorted(files):
print(f" {os.path.join(rel, fn)}")
return result
return {**result, "evaluation": eval_res}
if __name__ == "__main__":
+56 -4
View File
@@ -31,7 +31,9 @@ class EvolutionOrchestrator:
def __init__(self, llm: LLM, dataset: Dataset, ws: Workspace, tools: list,
metric: Callable[[List[bool]], float] = None, K: int = 8,
max_react_turns: int = 20, log: Optional[Callable[[str], None]] = None):
max_react_turns: int = 20, log: Optional[Callable[[str], None]] = None,
proposer_on_step=None,
cancel_event=None):
from .metrics import accuracy
self.llm = llm
self.dataset = dataset
@@ -41,17 +43,20 @@ class EvolutionOrchestrator:
self.skills = SkillsLayer(ws)
self.inference = InferenceAgent(llm, tools, ws, self.skills)
self.maintainer = WikiMaintainer(llm, ws)
self.proposer = SkillProposer(llm, ws, self.skills, max_turns=max_react_turns)
self.proposer = SkillProposer(llm, ws, self.skills, max_turns=max_react_turns,
on_step=proposer_on_step)
self.log = log or (lambda s: print(s, flush=True))
self.cancel_event = cancel_event # optional threading.Event for mid-run stop
self.history: List[dict] = []
self.r_best = -1.0
# ------------------------------------------------------------------
def _rollout(self, tasks, iteration: int, split: str) -> List[Trajectory]:
def _rollout(self, tasks, iteration, split: str) -> List[Trajectory]:
trajs = [self.inference.run(t) for t in tasks]
for t in trajs:
self.ws.add_raw_trace(iteration, t.task_id,
{**t.to_dict(), "split": split, "iteration": iteration})
{**t.to_dict(), "split": split, "iteration":
str(iteration)})
return trajs
def _score(self, trajs: List[Trajectory]) -> float:
@@ -76,6 +81,9 @@ class EvolutionOrchestrator:
def run(self) -> dict:
self._baseline_validation()
for k in range(1, self.K + 1):
if self.cancel_event is not None and self.cancel_event.is_set():
self.log("Run cancelled by user.")
break
if self.r_best >= 1.0:
self.log("R_best reached 1.0; early stop.")
break
@@ -150,3 +158,47 @@ class EvolutionOrchestrator:
self.log(f"\nEvolution complete. R_best={self.r_best:.4f}, skills={final_skills}")
return result
# ------------------------------------------------------------------
def evaluate_test(self) -> dict:
"""Post-evolution evaluation on the test split (paper §4).
Compares the final evolved skills against a no-skill baseline on D_test and
reports both accuracies plus a paired bootstrap significance p-value
(paper Appendix C, 1,000 iterations).
"""
from .metrics import paired_bootstrap
# Eval traces are auxiliary (not part of the immutable evolution history);
# clear any previous eval traces so re-evaluation works on the same workspace.
for tag in ("eval_skilled", "eval_baseline"):
d = os.path.join(self.ws.root, "raw", tag)
if os.path.isdir(d):
for fn in os.listdir(d):
os.remove(os.path.join(d, fn))
os.rmdir(d)
skilled_trajs = self._rollout(self.dataset.test, "eval_skilled", "test")
skilled = [t.correct for t in skilled_trajs]
skilled_acc = self.metric(skilled)
snap = self.skills.snapshot()
try:
for name in self.skills.list_skills():
d = os.path.join(self.ws.root, "skills", name)
for fn in os.listdir(d):
os.remove(os.path.join(d, fn))
os.rmdir(d)
base_trajs = self._rollout(self.dataset.test, "eval_baseline", "test")
finally:
self.skills.restore(snap)
base = [t.correct for t in base_trajs]
base_acc = self.metric(base)
p = paired_bootstrap(skilled, base, n=1000, seed=42)
self.log(f"Test evaluation: skilled={skilled_acc:.4f} "
f"baseline={base_acc:.4f} bootstrap_p={p:.4f}")
return {"skilled_accuracy": skilled_acc,
"baseline_accuracy": base_acc,
"bootstrap_p_value": p,
"significant": p < 0.05,
"n_test": len(skilled)}
+14 -5
View File
@@ -76,15 +76,24 @@ class SkillsLayer:
raise ValueError(f"unknown proposal action: {action!r}")
# ---------- gating rollback (§3.2.4): skills only, wiki untouched ----------
def snapshot(self) -> Dict[str, str]:
return {name: self.skill_content(name) or "" for name in self.list_skills()}
def snapshot(self) -> Dict[str, dict]:
"""Snapshot the active skill state (SKILL.md + PURPOSE.md) so a rejected
proposal can be rolled back exactly to S_{k-1}. The wiki is never included."""
return {
name: {"SKILL.md": self.skill_content(name) or "",
"PURPOSE.md": self.purpose(name) or ""}
for name in self.list_skills()
}
def restore(self, snap: Dict[str, str]) -> None:
def restore(self, snap: Dict[str, dict]) -> None:
for name in self.list_skills():
if name not in snap:
d = os.path.join(self.ws.root, "skills", name)
for fn in os.listdir(d):
os.remove(os.path.join(d, fn))
os.rmdir(d)
for name, content in snap.items():
self.ws.write_file(os.path.join("skills", name, "SKILL.md"), content)
for name, files in snap.items():
self.ws.write_file(os.path.join("skills", name, "SKILL.md"),
files["SKILL.md"])
self.ws.write_file(os.path.join("skills", name, "PURPOSE.md"),
files["PURPOSE.md"])
+4
View File
@@ -0,0 +1,4 @@
"""WikiSkill web UI package (ui_design.md)."""
from .api import create_app
__all__ = ["create_app"]
+183
View File
@@ -0,0 +1,183 @@
"""Flask application factory + REST/SSE endpoints for the WikiSkill UI."""
from __future__ import annotations
import json
import os
from flask import Blueprint, Flask, Response, jsonify, render_template, request
from .. import __version__
from .runner import RunManager
bp = Blueprint("wikiskill_ui", __name__)
_manager: RunManager = None # module-level singleton
def get_manager() -> RunManager:
global _manager
if _manager is None:
_manager = RunManager()
return _manager
def create_app(test_config=None) -> Flask:
app = Flask(__name__)
app.config.from_mapping(SECRET_KEY="wikiskill-local-dev")
if test_config:
app.config.update(test_config)
app.register_blueprint(bp)
return app
def _safe_root(root: str) -> str:
"""Reject absolute paths and path traversal; normalize to a relative name."""
root = (root or "").strip().replace("\\", "/").strip("/")
if not root:
raise ValueError("workspace root must not be empty")
if os.path.isabs(root):
raise ValueError("workspace root must be a relative path")
if any(part == ".." for part in root.split("/")):
raise ValueError("workspace root must not contain '..'")
return root
# ---------------------------------------------------------------------------
@bp.get("/")
def index():
return render_template("index.html")
@bp.get("/api/health")
def health():
return jsonify({"ok": True, "version": __version__})
@bp.post("/api/run")
def start_run():
config = request.get_json(silent=True) or {}
backend = config.get("llm_backend", "mock")
if backend not in ("mock", "openai"):
return jsonify({"error": "llm_backend must be 'mock' or 'openai'"}), 400
if backend == "openai" and not config.get("model"):
return jsonify({"error": "'model' is required when llm_backend=openai"}), 400
root = config.get("workspace_root")
if root:
try:
_safe_root(root)
except ValueError as e:
return jsonify({"error": str(e)}), 400
try:
info = get_manager().start(config)
except PermissionError as e:
return jsonify({"error": str(e)}), 409
return jsonify(info), 202
@bp.get("/api/status")
def status():
return jsonify(get_manager().status())
@bp.get("/api/stream")
def stream():
def gen():
yield from get_manager().stream()
return Response(gen(), mimetype="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
@bp.post("/api/cancel")
def cancel():
return jsonify({"cancelled": get_manager().cancel()})
# ---------------------------------------------------------------------------
_TREE_SKIP = {".pytest_cache", "__pycache__", ".git"}
_TEXT_EXT = {".md", ".json", ".txt", ".py", ".toml", ".diff", ".log"}
@bp.get("/api/workspace")
def workspace():
try:
root = _safe_root(request.args.get("root", ""))
except ValueError as e:
return jsonify({"error": str(e)}), 400
if not os.path.isdir(root):
return jsonify({"error": f"workspace '{root}' does not exist",
"root": root}), 404
return jsonify({"root": root, "tree": _build_tree(root),
"files": _read_text_files(root)})
def _build_tree(root: str) -> dict:
def build(path: str) -> dict:
name = os.path.basename(path) or root
rel = os.path.relpath(path, root).replace("\\", "/")
node = {"name": name, "path": rel, "type": "dir", "children": []}
for entry in sorted(os.listdir(path)):
if entry in _TREE_SKIP:
continue
full = os.path.join(path, entry)
if os.path.isdir(full):
node["children"].append(build(full))
else:
node["children"].append({
"name": entry,
"path": os.path.relpath(full, root).replace("\\", "/"),
"type": "file"})
return node
return build(root)
def _read_text_files(root: str) -> dict:
"""Collect text-file contents (bounded) for the UI viewer."""
out = {}
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in _TREE_SKIP]
for fn in filenames:
if os.path.splitext(fn)[1] not in _TEXT_EXT:
continue
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, root).replace("\\", "/")
try:
with open(full, "r", encoding="utf-8") as f:
out[rel] = f.read(200_000)
except (OSError, UnicodeDecodeError):
out[rel] = "(unreadable/binary)"
return out
# ---------------------------------------------------------------------------
@bp.post("/api/evaluate")
def evaluate():
"""Re-evaluate final skills vs baseline on the test split (paper §4)."""
config = request.get_json(silent=True) or {}
backend = config.get("llm_backend", "mock")
if backend not in ("mock", "openai"):
return jsonify({"error": "llm_backend must be 'mock' or 'openai'"}), 400
try:
root = _safe_root(config.get("workspace_root", ""))
except ValueError as e:
return jsonify({"error": str(e)}), 400
if not os.path.isdir(root):
return jsonify({"error": f"workspace '{root}' does not exist"}), 404
from ..datasets import _demo_tools, make_demo_dataset
from ..llm import MockLLM, OpenAICompatLLM
from ..orchestrator import EvolutionOrchestrator
from ..workspace import Workspace
if backend == "openai":
llm = OpenAICompatLLM(model=config.get("model", ""),
base_url=config.get("base_url") or None,
api_key=config.get("api_key") or None)
else:
llm = MockLLM()
orch = EvolutionOrchestrator(llm=llm, dataset=make_demo_dataset(),
ws=Workspace(root), tools=_demo_tools(),
K=int(config.get("iterations", 8)),
log=lambda msg: None)
res = orch.evaluate_test()
return jsonify({"evaluation": res, "workspace_root": root})
+166
View File
@@ -0,0 +1,166 @@
"""RunManager: executes the WikiSkill evolution loop in a background thread and
streams progress to the browser via Server-Sent Events (ui_design.md §6–§7)."""
from __future__ import annotations
import datetime as _dt
import json
import os
import queue
import threading
import traceback as _tb
import uuid
from typing import Optional
from ..datasets import _demo_tools, make_demo_dataset
from ..llm import MockLLM, OpenAICompatLLM
from ..orchestrator import EvolutionOrchestrator
from ..workspace import Workspace
def _is_fresh_root(path: str) -> bool:
"""True if the workspace root has no prior evolution history (raw/iter_*)."""
raw = os.path.join(path, "raw")
if not os.path.isdir(raw):
return True
return not any(d.startswith("iter_") for d in os.listdir(raw))
class RunManager:
"""Single-flight run manager. `start` spawns a daemon thread; `stream` yields SSE."""
def __init__(self):
self._lock = threading.Lock()
self._state: Optional[dict] = None
self._events: "queue.Queue[dict]" = queue.Queue()
self._cancel_event = threading.Event()
# ---- public API ----------------------------------------------------
@property
def active(self) -> bool:
with self._lock:
return bool(self._state and self._state.get("status")
in ("starting", "running", "evaluating"))
def status(self) -> dict:
# Read without holding the lock while computing `active` — the property
# also acquires the (non-reentrant) lock, which would deadlock.
state = self._state
return {"active": bool(state and state.get("status")
in ("starting", "running", "evaluating")),
"state": state}
def start(self, config: dict) -> dict:
with self._lock:
if self._state and self._state.get("status") in (
"starting", "running", "evaluating"):
raise PermissionError("another evolution run is already active")
run_id = uuid.uuid4().hex[:12]
self._state = {"run_id": run_id, "config": dict(config), "status":
"starting", "events": [], "result": None, "error": None}
self._cancel_event = threading.Event()
threading.Thread(target=self._execute, args=(run_id, dict(config)),
daemon=True).start()
return {"run_id": run_id, "active": True}
def cancel(self) -> bool:
self._cancel_event.set()
return True
def _reset_for_tests(self) -> None:
"""Test-only: drop state and queue so a fresh run can start."""
with self._lock:
self._state = None
self._cancel_event = threading.Event()
while True:
try:
self._events.get_nowait()
except queue.Empty:
break
# ---- streaming -----------------------------------------------------
def _emit(self, **payload) -> None:
self._events.put(dict(payload))
with self._lock:
if self._state is not None:
self._state["events"].append(dict(payload))
self._state["events"] = self._state["events"][-500:]
def stream(self):
"""SSE generator: emits events; closes after done/error."""
while True:
try:
payload = self._events.get(timeout=15)
except queue.Empty:
yield ":\n\n" # heartbeat
continue
data = json.dumps(payload)
yield f"data: {data}\n\n"
if payload.get("type") in ("done", "error"):
break
# ---- execution -----------------------------------------------------
def _build_llm(self, config: dict):
backend = config.get("llm_backend", "mock")
if backend == "openai":
return OpenAICompatLLM(
model=config.get("model", ""),
base_url=config.get("base_url") or None,
api_key=config.get("api_key") or None,
temperature=float(config.get("temperature", 0.7)))
return MockLLM()
def _root_for(self, config: dict) -> str:
root = config.get("workspace_root") or ""
if root:
return str(root).strip().strip("/").replace("\\", "/").strip("/")
stamp = _dt.datetime.now().strftime("%Y%m%d_%H%M%S")
return f"runs/run_{stamp}"
def _execute(self, run_id: str, config: dict) -> None:
try:
self._emit(type="status", phase="starting",
message="Preparing run...")
root = self._root_for(config)
if not _is_fresh_root(root):
raise ValueError(
f"workspace root '{root}' already contains evolution history; "
"choose a fresh root (the raw layer is immutable)")
llm = self._build_llm(config)
ws = Workspace(root)
orch = EvolutionOrchestrator(
llm=llm, dataset=make_demo_dataset(), ws=ws, tools=_demo_tools(),
K=int(config.get("iterations", 8)),
max_react_turns=int(config.get("max_react_turns", 20)),
cancel_event=self._cancel_event,
log=lambda msg: self._emit(type="log", message=msg),
proposer_on_step=lambda ev: self._emit(type="react", **ev))
self._emit(type="status", phase="running", message=f"root={root}")
result = orch.run()
self._emit(type="status", phase="evaluating",
message="Evaluating evolved skills on the test split...")
evaluation = orch.evaluate_test()
with self._lock:
if self._state is not None:
self._state["status"] = "done"
self._state["result"] = {"evolution": result,
"evaluation": evaluation,
"workspace_root": root}
self._emit(type="done", result=self._state["result"] if self._state else
{"evolution": result, "evaluation": evaluation,
"workspace_root": root})
except Exception as exc: # noqa: BLE001 - surface any failure to the UI
err = f"{type(exc).__name__}: {exc}"
print(err, flush=True)
print(_tb.format_exc(), flush=True)
with self._lock:
if self._state is not None:
self._state["status"] = "error"
self._state["error"] = err + "\n" + _tb.format_exc()
self._emit(type="error", error=str(exc))
self._state = None
self._cancel_event = threading.Event()
while True:
try:
self._events.get_nowait()
except queue.Empty:
break
+437
View File
@@ -0,0 +1,437 @@
"use strict";
/* WikiSkill UI client — vanilla JS, no dependencies (ui_design.md §6–§7). */
const $ = (id) => document.getElementById(id);
const S = {
ws: null, // workspace data {root, tree, files}
lastResult: null, // {evolution, evaluation, workspace_root}
activeRoot: null,
};
/* ---------- utils ---------- */
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
async function api(path, opts = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
method: opts.method || (opts.body ? "POST" : "GET"),
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
let data = null;
try { data = await res.json(); } catch (_) { /* empty */ }
if (!res.ok) {
const msg = (data && data.error) || `HTTP ${res.status}`;
throw new Error(msg);
}
return data;
}
function fmtPct(v) { return `${(v * 100).toFixed(1)}%`; }
/* ---------- tabs ---------- */
function switchTab(name) {
document.querySelectorAll(".tab").forEach((b) =>
b.classList.toggle("is-active", b.dataset.tab === name));
document.querySelectorAll(".tab-panel").forEach((p) =>
p.classList.toggle("is-active", p.id === `tab-${name}`));
}
document.querySelectorAll(".tab").forEach((b) =>
b.addEventListener("click", () => switchTab(b.dataset.tab)));
/* ---------- status pill ---------- */
function setStatus(status, label) {
const pill = $("status-pill");
pill.textContent = label || status;
const cls = { idle: "pill-idle", starting: "pill-running", running: "pill-running",
evaluating: "pill-evaluating", done: "pill-done", error: "pill-error" };
pill.className = `pill ${cls[status] || "pill-idle"}`;
$("btn-cancel").classList.toggle("hidden",
!["starting", "running", "evaluating"].includes(status));
$("btn-start").disabled = ["starting", "running", "evaluating"].includes(status);
}
/* ---------- console ---------- */
function logLine(text, cls) {
const con = $("console");
const div = document.createElement("span");
div.className = `ln ${cls || "ln-tail"}`;
div.textContent = text;
con.appendChild(div);
con.scrollTop = con.scrollHeight;
}
function renderEvent(ev) {
if (ev.type === "log") logLine(ev.message, "ln-tail");
else if (ev.type === "status") {
logLine(`—— ${ev.message || ev.phase} ——`, "ln-cmd");
if (ev.phase) setStatus(ev.phase === "evaluating" ? "evaluating" :
["starting", "running"].includes(ev.phase) ? "running" : ev.phase);
} else if (ev.type === "react") {
if (ev.kind === "read") {
logLine(` [proposer] read ${ev.path} ${ev.found ? "" : "(MISSING)"}`, "ln-react");
} else if (ev.kind === "proposal") {
logLine(` [proposer] → proposal ${ev.action} '${ev.skill}'`, "ln-react");
} else {
logLine(` [proposer] ${esc(ev.kind)}`, "ln-react");
}
} else if (ev.type === "done") {
S.lastResult = ev.result;
setStatus("done", "done");
if (ev.result) {
showDashboard(ev.result.evolution);
showEval(ev.result.evaluation);
S.activeRoot = ev.result.workspace_root;
$("log-root-label").textContent = `→ ${S.activeRoot}`;
}
logLine("✔ run finished", "ln-accept");
} else if (ev.type === "error") {
logLine(`✖ error: ${ev.error}`, "ln-reject");
setStatus("error", "error");
}
}
/* ---------- run form ---------- */
$("cfg-backend").addEventListener("change", () => {
$("openai-fields").classList.toggle("hidden", $("cfg-backend").value !== "openai");
});
$("run-form").addEventListener("submit", async (e) => {
/* ---------- SSE ---------- */
let esSource = null;
function connectStream() {
if (esSource) esSource.close();
setStatus("running", "running…");
esSource = new EventSource("/api/stream");
esSource.onmessage = (m) => {
let ev;
try { ev = JSON.parse(m.data); } catch (_) { return; }
renderEvent(ev);
if (ev.type === "done" || ev.type === "error") esSource.close();
};
esSource.onerror = () => { if (esSource) esSource.close(); };
}
/* ---------- dashboard ---------- */
function showDashboard(evo) {
$("dash-emptystate").classList.add("hidden");
$("dash-charts").classList.remove("hidden");
const history = evo.history || [];
drawRbest(history);
drawGate(history);
const tbody = document.querySelector("#dash-table tbody");
tbody.innerHTML = "";
if (!history.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">No proposals evaluated.</td></tr>';
return;
}
for (const h of history) {
const tr = document.createElement("tr");
const outcome = h.accepted ? "accepted" : (h.proposal ? "rejected" : "none");
tr.innerHTML = `<td>${h.iteration}</td><td>${esc(h.proposal || "—")}</td>
<td>${esc(h.action || "—")}</td>
<td>${h.val_score == null ? "—" : h.val_score.toFixed(4)}</td>
<td><span class="badge ${outcome}">${outcome}</span></td>`;
tbody.appendChild(tr);
}
}
function drawRbest(history) {
const svg = $("chart-rbest");
// Reconstruct R_best trajectory: starts at baseline (0 by convention here, or the
// first accepted score), then holds between accepted proposals.
const accepted = history.filter((h) => h.accepted && h.val_score != null);
const start = accepted.length ? firstVal(history) : 0;
if (!accepted.length) {
svg.innerHTML = `<text x="12" y="70" fill="#999">no accepted proposals (R_best = baseline)</text>`;
return;
}
const pts = [{ lab: "base", v: start }];
let cur = start;
for (const h of history) {
if (h.accepted) cur = h.val_score;
pts.push({ lab: `k${h.iteration}`, v: cur });
}
renderLine(svg, pts);
}
function firstVal(history) {
for (const h of history) if (h.accepted) return h.val_score;
return 0;
}
function renderLine(svg, pts) {
const W = 400, H = 180, pad = { l: 34, r: 10, t: 12, b: 24 };
const minV = Math.min(...pts.map((p) => p.v), 0);
const maxV = Math.max(...pts.map((p) => p.v), 1);
const span = Math.max(0.2, maxV - minV);
const x = (i) => pad.l + (i / Math.max(1, pts.length - 1)) * (W - pad.l - pad.r);
const y = (v) => H - pad.b - ((v - minV) / span) * (H - pad.t - pad.b);
let html = "";
for (let g = 0; g <= 4; g++) {
const gv = minV + (span * g) / 4;
const gy = y(gv);
html += `<line x1="${pad.l}" y1="${gy}" x2="${W - pad.r}" y2="${gy}" stroke="#efefea"/>`;
html += `<text x="2" y="${gy + 3}" fill="#999">${gv.toFixed(2)}</text>`;
}
const px = pts.map((p, i) => `${x(i).toFixed(1)},${y(p.v).toFixed(1)}`).join(" ");
html += `<polyline points="${px}" fill="none" stroke="#3b5bdb" stroke-width="2"/>`;
pts.forEach((p, i) => {
html += `<circle cx="${x(i)}" cy="${y(p.v)}" r="3" fill="#3b5bdb">`;
html += `<title>${p.lab}: ${p.v.toFixed(3)}</title></circle>`;
});
html += `<text x="${pad.l}" y="${H - 6}" fill="#999">baseline</text>`;
svg.innerHTML = html;
}
function drawGate(history) {
const svg = $("chart-gate");
const W = 400, H = 180, pad = { l: 34, r: 10, t: 12, b: 24 };
const vals = history.filter((h) => h.val_score != null);
const maxV = Math.max(1, ...vals.map((v) => v.val_score));
let html = "";
for (let g = 0; g <= 4; g++) {
const gy = H - pad.b - (g / 4) * (H - pad.t - pad.b);
html += `<line x1="${pad.l}" y1="${gy}" x2="${W - pad.r}" y2="${gy}" stroke="#efefea"/>`;
}
if (!vals.length) {
html += `<text x="${pad.l}" y="60" fill="#999">no proposals evaluated yet</text>`;
svg.innerHTML = html; return;
}
const bw = Math.min(46, (W - pad.l - pad.r) / vals.length - 8);
vals.forEach((h, i) => {
const bh = (h.val_score / maxV) * (H - pad.t - pad.b);
const bx = pad.l + i * ((W - pad.l - pad.r) / vals.length) + 4;
const by = H - pad.b - bh;
const color = h.accepted ? "#099268" : "#e03131";
html += `<rect x="${bx}" y="${by}" width="${bw}" height="${Math.max(2, bh)}" fill="${color}">`;
html += `<title>iter ${h.iteration}: ${h.val_score.toFixed(3)} ${h.accepted ? "accepted" : "rejected"}</title></rect>`;
html += `<text x="${bx + bw / 2 - 8}" y="${H - 8}" fill="#999">k${h.iteration}</text>`;
});
svg.innerHTML = html;
}
e.preventDefault();
$("form-error").textContent = "";
const cfg = {
llm_backend: $("cfg-backend").value,
iterations: parseInt($("cfg-iterations").value, 10),
max_react_turns: parseInt($("cfg-react").value, 10),
workspace_root: $("cfg-root").value.trim() || undefined,
};
if (cfg.llm_backend === "openai") {
cfg.model = $("cfg-model").value.trim();
cfg.base_url = $("cfg-base-url").value.trim() || undefined;
cfg.api_key = $("cfg-api-key").value.trim() || undefined;
cfg.temperature = parseFloat($("cfg-temperature").value);
}
try {
await api("/api/run", { body: cfg });
$("console").textContent = "";
connectStream();
} catch (err) {
/* ---------- evaluation ---------- */
function showEval(ev) {
if (!ev) return;
$("eval-emptystate").classList.add("hidden");
$("eval-cards").classList.remove("hidden");
$("eval-bars").classList.remove("hidden");
$("eval-verdict").classList.remove("hidden");
const sk = ev.skilled_accuracy, base = ev.baseline_accuracy;
const d = sk - base;
$("eval-cards").innerHTML = `
<div class="kpi"><div class="v">${fmtPct(sk)}</div><div class="l">with evolved skills</div></div>
<div class="kpi"><div class="v">${fmtPct(base)}</div><div class="l">no skills (baseline)</div></div>
<div class="kpi"><div class="v">${d >= 0 ? "+" : ""}${fmtPct(d)}</div><div class="l">Δ</div></div>
<div class="kpi"><div class="v">${ev.bootstrap_p_value.toFixed(4)}</div><div class="l">bootstrap p (1000)</div></div>`;
const W = 420, H = 90;
const maxV = Math.max(sk, base, 0.01);
const bar = (label, v, color) => `
<text x="4" y="26" fill="#666">${label}</text>
<rect x="4" y="36" width="${Math.max(4, (v / maxV) * (W - 8))}" height="18" fill="${color}" rx="4">
<title>${fmtPct(v)}</title></rect>
<text x="6" y="76" fill="#444" font-size="11">${fmtPct(v)}</text>`;
$("eval-bars").innerHTML = `
<svg viewBox="0 0 430 92" class="chart">${bar("evolved skills", sk, "#099268")}
${bar("no-skill baseline", base, "#adb5bd")}</svg>`;
const verdict = $("eval-verdict");
if (ev.significant && d > 0) {
verdict.className = "verdict ok";
verdict.textContent = `✔ Evolved skills are significantly better than the no-skill baseline (p = ${ev.bootstrap_p_value.toFixed(4)} < 0.05).`;
} else if (d < 0) {
verdict.className = "verdict bad";
verdict.textContent = `✖ Skills underperform the baseline on the test split.`;
} else {
verdict.className = "verdict neutral";
verdict.textContent = `∽ No significant difference from the baseline (p = ${ev.bootstrap_p_value.toFixed(4)}).`;
}
}
$("btn-evaluate").addEventListener("click", async () => {
$("eval-error").textContent = "";
const root = $("eval-root").value.trim() || S.activeRoot;
if (!root) {
$("eval-error").textContent = "No workspace yet — run an evolution first, or enter a root.";
return;
}
const btn = $("btn-evaluate");
btn.disabled = true; btn.textContent = "Evaluating…";
try {
$("eval-load-form").classList.remove("hidden");
const res = await api("/api/evaluate", { body: { llm_backend: "mock",
workspace_root: root, iterations: 3 } });
showEval(res.evaluation);
S.activeRoot = root;
} catch (err) {
$("eval-error").textContent = err.message;
} finally {
btn.disabled = false; btn.textContent = "Re-evaluate current workspace";
}
});
$("btn-eval-load").addEventListener("click", async () => {
const root = $("eval-root").value.trim();
if (!root) { $("eval-error").textContent = "Enter a workspace root."; return; }
try {
const res = await api("/api/evaluate", { body: { llm_backend: "mock",
workspace_root: root, iterations: 3 } });
showEval(res.evaluation);
} catch (err) { $("eval-error").textContent = err.message; }
});
/* ---------- workspace explorer ---------- */
async function loadWorkspace(root) {
$("ws-error").textContent = "";
try {
const data = await api(`/api/workspace?root=${encodeURIComponent(root)}`);
S.ws = data;
S.activeRoot = root;
$("ws-split").classList.remove("hidden");
renderTree(data.tree);
showFile("run_state.json");
renderKnowledge(data.files);
$("eval-root").value = root;
$("ws-root").value = root;
} catch (err) {
$("ws-error").textContent = err.message;
}
}
$("btn-ws-load").addEventListener("click", () =>
loadWorkspace($("ws-root").value.trim()));
$("btn-ws-last").addEventListener("click", () => {
if (S.activeRoot) { $("ws-root").value = S.activeRoot; loadWorkspace(S.activeRoot); }
else $("ws-error").textContent = "No last run yet.";
});
function renderTree(node) {
const ul = document.createElement("ul");
node.children.forEach((ch) => ul.appendChild(renderNode(ch)));
const tree = $("ws-tree");
tree.innerHTML = "";
tree.appendChild(ul);
}
function renderNode(node) {
const li = document.createElement("li");
if (node.type === "dir") {
const label = document.createElement("span");
label.className = "node-dir";
label.innerHTML = `<span class="toggle">▾</span>📁 ${esc(node.name)}`;
li.appendChild(label);
const childUl = document.createElement("ul");
node.children.forEach((ch) => childUl.appendChild(renderNode(ch)));
li.appendChild(childUl);
label.addEventListener("click", () => {
childUl.classList.toggle("hidden");
label.querySelector(".toggle").textContent =
childUl.classList.contains("hidden") ? "▸" : "▾";
});
} else {
const f = document.createElement("span");
f.className = "node-file";
const icon = node.name.endsWith(".json") ? "🧾" :
(node.name.endsWith(".md") ? "📝" : "📄");
f.innerHTML = `${icon} ${esc(node.name)}`;
f.addEventListener("click", () => showFile(node.path));
li.appendChild(f);
}
return li;
}
function showFile(path) {
if (!S.ws || !S.ws.files[path]) { $("ws-content").textContent = "(not available)"; return; }
$("ws-file-path").textContent = path;
let content = S.ws.files[path];
const mode = $("ws-view-mode").value;
if (path.endsWith(".json") && (mode === "auto" || mode === "json")) {
try { content = JSON.stringify(JSON.parse(content), null, 2); }
catch (_) { /* keep raw */ }
}
$("ws-content").textContent = content;
}
$("ws-view-mode").addEventListener("change", () => {
if (S.ws) showFile($("ws-file-path").textContent);
});
/* ---------- knowledge ---------- */
let ktab = "skills";
document.querySelectorAll(".ktab").forEach((b) =>
b.addEventListener("click", () => {
ktab = b.dataset.ktab;
document.querySelectorAll(".ktab").forEach((x) =>
x.classList.toggle("is-active", x === b));
if (S.ws) renderKnowledge(S.ws.files);
}));
function renderKnowledge(files) {
if (!files) return;
$("know-emptystate").classList.add("hidden");
const out = $("know-content");
if (ktab === "skills") {
const names = Object.keys(files).filter((p) => p.endsWith("/SKILL.md"));
if (!names.length) { out.innerHTML = '<p class="muted">No evolved skills in this workspace.</p>'; return; }
out.innerHTML = "";
names.forEach((skillPath) => {
const purposePath = skillPath.replace("/SKILL.md", "/PURPOSE.md");
const card = document.createElement("div");
card.className = "skill-card";
const name = skillPath.split("/")[1];
card.innerHTML = `<h4>📦 ${esc(name)}</h4><h5>SKILL.md</h5>
<pre>${esc(files[skillPath])}</pre><h5>PURPOSE.md</h5>
<pre>${esc(files[purposePath] || "(none)")}</pre>`;
out.appendChild(card);
});
} else {
const map = {
patterns: (p) => p.startsWith("wiki/patterns/"),
index: (p) => p === "wiki/index.md",
logs: (p) => p === "wiki/logs.md",
impact: (p) => p === "wiki/skill-impact.md",
};
const keep = map[ktab];
const paths = Object.keys(files).filter(keep).sort();
if (!paths.length) { out.innerHTML = '<p class="muted">Nothing here yet.</p>'; return; }
out.innerHTML = "";
paths.forEach((p) => {
const box = document.createElement("div");
box.className = "kdoc";
box.innerHTML = `<b>${esc(p)}</b>\n\n${esc(files[p])}`;
out.appendChild(box);
});
}
}
/* ---------- restore state on load ---------- */
(async function init() {
try {
const st = await api("/api/status");
const state = st.state;
if (state) {
(state.events || []).forEach(renderEvent);
if (state.result) {
S.lastResult = state.result;
showDashboard(state.result.evolution);
showEval(state.result.evaluation);
S.activeRoot = state.result.workspace_root;
$("log-root-label").textContent = `→ ${S.activeRoot}`;
$("eval-root").value = S.activeRoot || "";
}
if (state.status === "done") setStatus("done", "done");
if (st.active) connectStream();
}
} catch (_) { /* server unreachable; keep idle state */ }
})();
$("form-error").textContent = err.message;
}
});
$("btn-cancel").addEventListener("click", async () => {
try { await api("/api/cancel", { body: {} }); logLine("—— cancel requested ——", "ln-cmd"); }
catch (_) { /* ignore */ }
});
+141
View File
@@ -0,0 +1,141 @@
:root {
--bg: #f7f7f5; --panel: #ffffff; --ink: #1a1a1a; --muted: #6b6b6b;
--accent: #3b5bdb; --accent-soft: #e7ebfc; --ok: #099268; --ok-soft: #e6f7f1;
--bad: #e03131; --bad-soft: #fdeaea; --warn: #f59f00; --border: #e3e1dc;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.5; }
.hidden { display: none !important; }
.muted { color: var(--muted); font-weight: 400; }
.small { font-size: 12px; }
a { color: var(--accent); }
/* ---------- header / tabs ---------- */
.topbar { display: flex; justify-content: space-between; align-items: center;
padding: 14px 22px; background: #fff; border-bottom: 1px solid var(--border); }
.brand-kicker { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); display: block; }
.brand h1 { margin: 0; font-size: 20px; }
.brand .sub { color: var(--muted); font-weight: 400; font-size: 14px; }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.small-link { font-size: 12px; }
.pill { border-radius: 999px; padding: 3px 12px; font-size: 12px; font-weight: 600; }
.pill-idle { background: #eee; color: #555; }
.pill-running { background: var(--accent-soft); color: var(--accent); }
.pill-evaluating { background: #f3ecfb; color: #7b2cbf; }
.pill-done { background: var(--ok-soft); color: var(--ok); }
.pill-error { background: var(--bad-soft); color: var(--bad); }
.tabs { display: flex; gap: 4px; padding: 10px 22px 0; background: #fff;
border-bottom: 1px solid var(--border); }
.tab { border: 0; background: transparent; padding: 8px 14px; font-size: 13px;
font-weight: 600; color: #555; cursor: pointer; border-radius: 8px 8px 0 0; }
.tab:hover { background: #f2f2f0; }
.tab.is-active { color: var(--accent); background: var(--accent-soft); }
/* ---------- layout ---------- */
main { max-width: 1180px; margin: 18px auto; padding: 0 18px; }
.tab-panel { display: none; }
.tab-panel.is-active { display: block; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
@media (max-width: 860px) { .grid-2 { grid-template-columns: 1fr; } }
.grid-2-inner { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
padding: 18px 20px; margin-bottom: 16px; }
.card h2 { margin: 0 0 10px; font-size: 16px; }
.card h3 { font-size: 14px; margin: 14px 0 6px; }
/* ---------- forms ---------- */
form label { display: block; font-weight: 600; font-size: 13px; margin: 10px 0 4px; }
form input, form select { width: 100%; padding: 7px 9px; font-size: 13px;
border: 1px solid var(--border); border-radius: 8px; background: #fff; }
.actions { display: flex; gap: 10px; margin-top: 14px; }
.btn { background: #fff; color: var(--ink); border: 1px solid var(--border);
border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 600; cursor: pointer; }
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { opacity: .9; color: #fff; }
.btn-danger { background: var(--bad); color: #fff; border-color: var(--bad); }
.btn-danger:hover { opacity: .9; color: #fff; }
.form-error { color: var(--bad); font-size: 12px; min-height: 16px; margin: 8px 0 0; }
.load-row { display: flex; gap: 10px; align-items: end; margin-bottom: 12px; }
.load-row label { flex: 1; display: block; font-size: 12px; font-weight: 600; }
.load-row input { width: 100%; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px; }
/* ---------- console ---------- */
.console { background: #15181d; color: #d7e0f0; font-family: var(--mono);
font-size: 12px; padding: 12px; border-radius: 10px; height: 460px; overflow: auto; white-space: pre-wrap; }
.console .ln { display: block; }
.console .ln-tail { color: #8a93a6; }
.console .ln-cmd { color: #7cc4ff; }
.console .ln-react { color: #f2c55c; }
.console .ln-accept { color: #5ee6a7; }
.console .ln-reject { color: #ff8d8d; }
/* ---------- charts ---------- */
.chart { width: 100%; height: auto; background: #fff; border: 1px solid var(--border); border-radius: 10px; }
.chart text { font-size: 10px; fill: #666; }
/* ---------- tables ---------- */
.table-wrap { overflow: auto; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); }
th { font-size: 12px; color: var(--muted); }
.badge { border-radius: 999px; padding: 2px 10px; font-size: 11px; font-weight: 700; }
.badge.accepted { background: var(--ok-soft); color: var(--ok); }
.badge.rejected { background: var(--bad-soft); color: var(--bad); }
.badge.none { background: #eee; color: #555; }
/* ---------- KPI / verdict ---------- */
.kpi-row { display: flex; gap: 14px; margin: 12px 0; flex-wrap: wrap; }
.kpi { flex: 1; min-width: 130px; background: #fff; border: 1px solid var(--border);
border-radius: 12px; padding: 14px 16px; text-align: center; }
.kpi .v { font-size: 26px; font-weight: 700; }
.kpi .l { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.verdict { font-weight: 600; padding: 10px 14px; border-radius: 10px; display: inline-block; }
.verdict.ok { background: var(--ok-soft); color: var(--ok); }
.verdict.bad { background: var(--bad-soft); color: var(--bad); }
.verdict.neutral { background: #f1f1ee; color: #555; }
/* ---------- workspace tree / viewer ---------- */
.ws-split { display: grid; grid-template-columns: 300px 1fr; gap: 14px; align-items: start; }
@media (max-width: 860px) { .ws-split { grid-template-columns: 1fr; } }
.tree { border: 1px solid var(--border); border-radius: 10px; padding: 8px; max-height: 560px; overflow: auto; font-size: 13px; }
.tree ul { list-style: none; margin: 0; padding-left: 16px; }
.tree .node-file { cursor: pointer; padding: 1px 6px; border-radius: 6px; white-space: nowrap; }
.tree .node-file:hover { background: var(--accent-soft); }
.tree .node-dir { font-weight: 600; }
.tree .toggle { cursor: pointer; color: var(--muted); margin-right: 2px; }
.viewer { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.viewer-head { display: flex; justify-content: space-between; gap: 8px; padding: 8px 10px;
/* ---------- knowledge ---------- */
.ktabs { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; }
.ktab { border: 1px solid var(--border); background: #fff; border-radius: 8px;
padding: 5px 12px; font-size: 12px; font-weight: 600; color: #555; cursor: pointer; }
.ktab.is-active { background: var(--accent); border-color: var(--accent); color: #fff; }
.kdoc { background: #fdfdfb; border: 1px solid var(--border); border-radius: 10px;
padding: 14px 16px; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; max-height: 480px; overflow: auto; }
.skill-card { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; margin-bottom: 12px; }
.skill-card h4 { margin: 0 0 6px; color: var(--accent); }
.skill-card h5 { margin: 8px 0 4px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.skill-card pre { margin: 0; font-family: var(--mono); font-size: 12px; white-space: pre-wrap;
background: #f8f8f6; border-radius: 8px; padding: 8px 10px; max-height: 220px; overflow: auto; }
/* ---------- how-it-works ---------- */
.pipeline { display: flex; flex-wrap: wrap; gap: 8px; align-items: stretch; margin: 12px 0; }
.layer { flex: 1; min-width: 160px; background: var(--accent-soft); border: 1px solid #cfd7f8;
border-radius: 10px; padding: 10px 12px; font-size: 13px; }
.layer span { display: block; font-weight: 400; font-size: 12px; color: var(--muted); margin-top: 2px; }
.arrow { align-self: center; font-size: 20px; color: var(--accent); }
.alg { background: #15181d; color: #cfe3d8; font-family: var(--mono); font-size: 12px;
border-radius: 10px; padding: 14px 16px; overflow: auto; }
.glossary td:first-child { font-family: var(--mono); font-weight: 700; }
.foot { text-align: center; padding: 18px; color: var(--muted); font-size: 12px; }
background: #fafaf8; border-bottom: 1px solid var(--border); align-items: center; }
.viewer-head select { font-size: 12px; }
.viewer-content { margin: 0; padding: 12px; font-family: var(--mono); font-size: 12px;
max-height: 560px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
+202
View File
@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WikiSkill — Skill Evolution Workbench</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>📚</text></svg>">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-kicker">arXiv:2608.27454 · Google Research</span>
<h1>WikiSkill <span class="sub">Skill Evolution Workbench</span></h1>
</div>
<div class="topbar-right">
<span id="status-pill" class="pill pill-idle">idle</span>
<a class="small-link" href="https://arxiv.org/abs/2608.27454" target="_blank" rel="noopener">Paper ↗</a>
</div>
</header>
<nav class="tabs" role="tablist" aria-label="Sections">
<button class="tab is-active" data-tab="run" role="tab">▶ Run</button>
<button class="tab" data-tab="dashboard" role="tab">Dashboard</button>
<button class="tab" data-tab="eval" role="tab">Evaluation</button>
<button class="tab" data-tab="workspace" role="tab">Workspace</button>
<button class="tab" data-tab="knowledge" role="tab">Knowledge</button>
<button class="tab" data-tab="how" role="tab">How it works</button>
</nav>
<main>
<!-- ============================ RUN ============================ -->
<section id="tab-run" class="tab-panel is-active">
<div class="grid-2">
<div class="card">
<h2>Configuration</h2>
<form id="run-form" autocomplete="off">
<label>LLM backend
<select id="cfg-backend">
<option value="mock">Mock (offline, deterministic)</option>
<option value="openai">OpenAI-compatible endpoint</option>
</select>
</label>
<div id="openai-fields" class="hidden">
<label>Model<input id="cfg-model" placeholder="e.g. qwen3-27b-instruct"></label>
<label>Base URL<input id="cfg-base-url" placeholder="https://api.openai.com/v1"></label>
<label>API key<input id="cfg-api-key" type="password" placeholder="sk-... (or env WIKISKILL_API_KEY)"></label>
<label>Temperature<input id="cfg-temperature" type="number" step="0.1" min="0" max="2" value="0.7"></label>
</div>
<div class="grid-2-inner">
<label>Max iterations (K)<input id="cfg-iterations" type="number" min="1" max="50" value="8"></label>
<label>Max ReAct turns<input id="cfg-react" type="number" min="1" max="40" value="20"></label>
</div>
<label>Workspace root (blank = auto <code>runs/run_&lt;ts&gt;</code>)
<input id="cfg-root" placeholder="runs/my-experiment"></label>
<div class="actions">
<button id="btn-start" type="submit" class="btn btn-primary">Start evolution</button>
<button id="btn-cancel" type="button" class="btn btn-danger hidden">Cancel</button>
<!-- ========================= DASHBOARD ========================= -->
<section id="tab-dashboard" class="tab-panel">
<div class="card">
<h2>Evolution overview</h2>
<p class="muted" id="dash-emptystate">No run yet — start one on the <b>Run</b> tab.</p>
<div class="grid-2 hidden" id="dash-charts">
<div><h3>R_best over iterations</h3><svg id="chart-rbest" viewBox="0 0 400 180" class="chart"></svg></div>
<div><h3>Validation gating (proposals)</h3><svg id="chart-gate" viewBox="0 0 400 180" class="chart"></svg></div>
</div>
<h3>History</h3>
<div class="table-wrap">
<table id="dash-table">
<thead><tr><th>Iter</th><th>Proposal</th><th>Action</th><th>Val score</th><th>Outcome</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
<!-- ========================= EVAL ========================= -->
<section id="tab-eval" class="tab-panel">
<div class="card">
<h2>Test-set evaluation <span class="muted">— final skills vs no-skill baseline (paper §4)</span></h2>
<p class="muted" id="eval-emptystate">Evaluation is computed automatically at the end
of every run. You can also re-run it for the current workspace below.</p>
<div id="eval-cards" class="kpi-row hidden"></div>
<div id="eval-bars" class="hidden"></div>
<p id="eval-verdict" class="verdict hidden"></p>
<div class="actions">
<button id="btn-evaluate" class="btn btn-primary">Re-evaluate current workspace</button>
</div>
<div class="load-row hidden" id="eval-load-form">
<label>Workspace root
<input id="eval-root" placeholder="runs/run_20260830_120000"></label>
<button id="btn-eval-load" class="btn">Load result</button>
</div>
<p id="eval-error" class="form-error" role="alert"></p>
</div>
</section>
<!-- ========================= WORKSPACE ========================= -->
<section id="tab-workspace" class="tab-panel">
<div class="card">
<h2>Workspace explorer</h2>
<div class="load-row">
<label>Root
<input id="ws-root" placeholder="runs/run_20260830_120000"></label>
<button id="btn-ws-load" class="btn">Browse</button>
<button id="btn-ws-last" class="btn">Last run</button>
</div>
<p id="ws-error" class="form-error"></p>
<div class="ws-split hidden" id="ws-split">
<!-- ========================= KNOWLEDGE ========================= -->
<section id="tab-knowledge" class="tab-panel">
<div class="card">
<h2>Knowledge &amp; skills</h2>
<div class="ktabs">
<button class="ktab is-active" data-ktab="skills">Skills</button>
<button class="ktab" data-ktab="patterns">Wiki patterns</button>
<button class="ktab" data-ktab="index">Index</button>
<button class="ktab" data-ktab="logs">Evolution log</button>
<button class="ktab" data-ktab="impact">Skill impact</button>
</div>
<p class="muted" id="know-emptystate">Load a workspace in the Workspace tab to inspect its knowledge and skills.</p>
<div id="know-content"></div>
</div>
</section>
<!-- ========================= HOW ========================= -->
<section id="tab-how" class="tab-panel">
<div class="card">
<h2>How WikiSkill works</h2>
<p><b>WikiSkill</b> co-evolves agent <i>skills</i> with a persistent <i>knowledge base
(wiki)</i>. Each evolution iteration runs the loop below; the wiki accumulates
across all iterations and is never rolled back.</p>
<div class="pipeline">
<div class="layer"><b>Raw layer <span>immutable traces τ ~ π(x; S)</span></b></div>
<div class="arrow">→</div>
<div class="layer"><b>Wiki Maintainer <span>root-cause analysis → patterns</span></b></div>
<div class="arrow">→</div>
<div class="layer"><b>Skill Proposer <span>ReAct over wiki + traces → P</span></b></div>
<div class="arrow">→</div>
<div class="layer"><b>Gating <span>accept iff R(T_val) &gt; R_best</span></b></div>
</div>
<h3>Algorithm 1 (per iteration k)</h3>
<pre class="alg">S₀ = ∅, W₀ = ∅; R_best ← R_baseline(D_val, S₀)
for k = 1..K:
if R_best == 1.0: break
T_train,k ← rollouts of π with S_{k-1} # skills injected, wiki hidden
T_sample,k ⊂ T_train,k # ≤5 fails + ≤3 passes (App. C)
W'_k ← M_WM(W_{k-1}, T_sample,k) # wiki maintenance (patch edits)
P_k ← M_P(W'_k, S_{k-1}, T_train,k) # ReAct proposer
S'_k ← Apply(S_{k-1}, P_k)
if R(T_val,k) > R_best: accept; S_k ← S'_k; R_best ← R(T_val,k)
else: roll back skills; S_k ← S_{k-1}
W_k ← Update(W'_k, P_k, R(T_val,k), a_k) # harness writes skill-impact.md
return S_K, W_K</pre>
<h3>Glossary</h3>
<table class="glossary">
<tr><td>S_k</td><td>active skill set (skills/)</td></tr>
<tr><td>W_k</td><td>persistent wiki (patterns, index, logs, skill-impact)</td></tr>
<tr><td>P_k</td><td>single atomic skill proposal (create or patch)</td></tr>
<tr><td>M_WM</td><td>Wiki Maintainer agent (consolidates traces into patterns)</td></tr>
<tr><td>M_P</td><td>Wiki-informed Skill Proposer agent (multi-turn ReAct)</td></tr>
<tr><td>R_best</td><td>best validation score; gating threshold (strict &gt;)</td></tr>
<tr><td>R(·)</td><td>accuracy metric on a trace set</td></tr>
</table>
<p class="muted small">Full detail in <code>design.md</code>, <code>prd.md</code>,
<code>ui_design.md</code>, and the paper itself.</p>
</div>
</section>
</main>
<footer class="foot"><span>Independent reimplementation for study · <a href="https://arxiv.org/abs/2608.27454" target="_blank" rel="noopener">Tang et al., Google Research (CC BY 4.0)</a></span></footer>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>
<div id="ws-tree" class="tree" role="tree"></div>
<div class="viewer">
<div class="viewer-head"><span id="ws-file-path" class="muted"></span>
<select id="ws-view-mode">
<option value="auto">auto</option><option value="json">JSON</option>
<option value="raw">Raw</option>
</select>
</div>
<pre id="ws-content" class="viewer-content"></pre>
</div>
</div>
</div>
</section>
</div>
<p id="form-error" class="form-error" role="alert"></p>
</form>
</div>
<div class="card">
<h2>Live console <span class="muted" id="log-root-label"></span></h2>
<div id="console" class="console" aria-live="polite"></div>
<p class="muted small">Logs stream live; writer events (Wiki Maintainer / Skill
Proposer) and validation gating decisions appear as they happen.</p>
</div>
</div>
</section>
+8 -2
View File
@@ -73,8 +73,14 @@ class Workspace:
f.write(content)
# ---------- Raw layer: immutable ----------
def add_raw_trace(self, iteration: int, task_id: str, trace: dict) -> str:
rel = os.path.join("raw", f"iter_{iteration:03d}", f"{task_id}.json")
def add_raw_trace(self, iteration, task_id: str, trace: dict) -> str:
"""Write an immutable raw-layer trace under raw/<subdir>/.
`iteration` may be an int (formatted `iter_NNN`) or any string tag (e.g.
`eval_skilled` for post-hoc test evaluations). Existing files are never
overwritten — the raw layer preserves the complete execution history."""
subdir = f"iter_{iteration:03d}" if isinstance(iteration, int) else str(iteration)
rel = os.path.join("raw", subdir, f"{task_id}.json")
p = self._abs(rel)
if os.path.exists(p):
raise FileExistsError(f"raw layer is immutable: {rel}")
+16
View File
@@ -32,6 +32,22 @@ def test_skills_create_snapshot_restore(tmp_path):
assert skills.list_skills() == ["s1"]
def test_skills_restore_reverts_purpose_md(tmp_path):
"""A rejected patch must roll back BOTH SKILL.md and PURPOSE.md exactly
to S_{k-1} (skills-only rollback, §3.2.4)."""
skills = SkillsLayer(_ws(tmp_path))
skills.apply_proposal({"action": "create", "skill": "s",
"content": "body", "purpose": "v1"})
snap = skills.snapshot()
skills.apply_proposal({"action": "patch", "skill": "s",
"edits": [{"op": "append", "content": "extra"}],
"purpose": "v2 (append)"})
assert "v2" in skills.purpose("s")
skills.restore(snap)
assert skills.purpose("s") == "v1\n"
assert skills.skill_content("s") == "body"
def test_skill_injection_context(tmp_path):
skills = SkillsLayer(_ws(tmp_path))
assert "(No active skills.)" in skills.active_skills_context()
+174
View File
@@ -0,0 +1,174 @@
import os
import time
import types
import pytest
from wikiskill import __version__
from wikiskill.webapp import create_app
from wikiskill.webapp.api import get_manager
from wikiskill.webapp.runner import RunManager, _is_fresh_root
def _wait_status(client, timeout=20.0):
deadline = time.time() + timeout
while time.time() < deadline:
r = client.get("/api/status")
assert r.status_code == 200
st = r.get_json()
if st["state"] and st["state"]["status"] in ("done", "error"):
return st
time.sleep(0.05)
raise AssertionError("run did not finish in time")
@pytest.fixture()
def client(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) # relative workspace roots land under tmp_path
app = create_app({"TESTING": True})
get_manager()._reset_for_tests()
with app.test_client() as c:
yield c
# ---------------------------------------------------------------- basics
def test_index_serves_spa(client):
r = client.get("/")
assert r.status_code == 200
html = r.get_data(as_text=True)
assert "WikiSkill" in html
assert 'id="run-form"' in html
assert 'id="console"' in html
assert 'id="chart-rbest"' in html
def test_health(client):
r = client.get("/api/health")
# ---------------------------------------------------------------- lifecycle
def test_full_run_lifecycle(client, tmp_path):
root = "ui_test_run"
r = client.post("/api/run", json={"llm_backend": "mock", "iterations": 3,
"workspace_root": root})
assert r.status_code == 202
assert r.get_json()["active"] is True
# single-flight: second start while active must be rejected
r2 = client.post("/api/run", json={"llm_backend": "mock", "iterations": 3,
"workspace_root": root + "_2"})
assert r2.status_code == 409
st = _wait_status(client)
assert st["state"]["status"] == "done"
result = st["state"]["result"]
assert "unit-conversion" in result["evolution"]["final_skills"]
assert result["evaluation"]["significant"] is True
# workspace artifacts exist on disk
assert (tmp_path / root / "wiki" / "index.md").is_file()
assert (tmp_path / root / "skills" / "unit-conversion" / "SKILL.md").is_file()
# string-tag subdirs for post-hoc test evaluation
assert (tmp_path / root / "raw" / "eval_skilled").is_dir()
def test_status_empty(client):
st = client.get("/api/status").get_json()
assert st["active"] is False
assert st["state"] is None
# ---------------------------------------------------------------- workspace & eval
def test_workspace_endpoint(client, tmp_path):
root = "ui_ws_test"
client.post("/api/run", json={"llm_backend": "mock", "iterations": 2,
"workspace_root": root})
_wait_status(client)
r = client.get(f"/api/workspace?root={root}")
assert r.status_code == 200
data = r.get_json()
assert data["root"] == root
names = {c["name"] for c in data["tree"]["children"]}
assert {"raw", "wiki", "skills"} <= names
assert "wiki/index.md" in data["files"]
assert "wiki/skill-impact.md" in data["files"]
assert any(k.endswith("SKILL.md") for k in data["files"])
assert client.get("/api/workspace?root=ui_does_not_exist").status_code == 404
assert client.get("/api/workspace?root=../x").status_code == 400
def test_evaluate_endpoint(client, tmp_path):
root = "ui_eval_test"
client.post("/api/run", json={"llm_backend": "mock", "iterations": 2,
"workspace_root": root})
_wait_status(client)
r = client.post("/api/evaluate", json={"llm_backend": "mock",
"workspace_root": root})
assert r.status_code == 200
ev = r.get_json()["evaluation"]
assert ev["skilled_accuracy"] == 1.0
assert ev["baseline_accuracy"] == 0.0
assert ev["significant"] is True
assert client.post("/api/evaluate", json={"llm_backend": "mock",
"workspace_root": "missing"}).status_code == 404
# ---------------------------------------------------------------- SSE
def test_sse_stream(client):
r = client.post("/api/run", json={"llm_backend": "mock", "iterations": 1,
"workspace_root": "ui_sse_test"})
assert r.status_code == 202
stream = client.get("/api/stream")
body = b""
for chunk in stream.response:
body += chunk
if b'"type": "done"' in body:
break
assert b"data:" in body
assert b'"done"' in body
# ---------------------------------------------------------------- RunManager units
def test_runmanager_double_start_rejected():
mgr = RunManager()
mgr._reset_for_tests()
mgr._execute = types.MethodType(lambda self, rid, cfg: time.sleep(2), mgr)
a = mgr.start({"llm_backend": "mock"})
assert a["active"] is True
with pytest.raises(PermissionError):
mgr.start({"llm_backend": "mock"})
mgr.cancel()
mgr._reset_for_tests()
def test_runmanager_fresh_root_guard(tmp_path):
assert _is_fresh_root(str(tmp_path / "new")) is True
raw = tmp_path / "used" / "raw"
raw.mkdir(parents=True)
(raw / "iter_000").mkdir()
assert _is_fresh_root(str(tmp_path / "used")) is False
def test_cancel(client, tmp_path, monkeypatch):
# Slow the orchestrator so cancel lands mid-run (cooperative stop).
import wikiskill.orchestrator as orch_mod
original_run = orch_mod.EvolutionOrchestrator.run
def slow_run(self):
time.sleep(1.5)
return original_run(self)
monkeypatch.setattr(orch_mod.EvolutionOrchestrator, "run", slow_run)
client.post("/api/run", json={"llm_backend": "mock", "iterations": 4,
"workspace_root": "ui_cancel_run"})
assert client.post("/api/cancel", json={}).status_code == 200
st = _wait_status(client, timeout=20)
assert st["state"]["status"] in ("done", "error")
def test_run_validation_400s(client):
assert client.post("/api/run", json={"llm_backend": "bogus"}).status_code == 400
assert client.post("/api/run", json={"llm_backend": "openai"}).status_code == 400 # no model
assert client.post("/api/run", json={"llm_backend": "mock",
"workspace_root": "../escape"}).status_code == 400
assert client.post("/api/run", json={"llm_backend": "mock",
"workspace_root": "C:\\abs"}).status_code == 400
+147
View File
@@ -0,0 +1,147 @@
# WikiSkill — Web UI Design Document
Companion to `design.md`. Describes the interactive web application that lets users
run the WikiSkill evolution loop, watch it live, inspect every produced artifact, and
evaluate the evolved skills — a visual, testable harness for the paper's Algorithm 1.
## 1. Goals
- **Run**: configure a WikiSkill evolution run (dataset, iterations, LLM backend) and
start/stop it from the browser.
- **Watch**: stream the evolution live (logs, ReAct proposer steps, gating decisions).
- **Inspect**: browse the complete three-layer workspace (`raw/`, `wiki/`, `skills/`)
produced by the run.
- **Evaluate**: compare final evolved skills vs. no-skill baseline on the test split
with paired-bootstrap significance.
- **Understand**: an "How it works" view that maps the UI to the paper's architecture
(Figure 2 / Algorithm 1).
Non-goals: skill editing, wiki pruning, multi-user auth. This is a local study tool.
## 2. User workflows
1. **Explore** — open `http://127.0.0.1:5000`, read How-it-works, browse an existing
workspace from disk.
2. **Run** — Configure → Start → watch live events → switch to Dashboard/Workspace.
3. **Analyze** — inspect the evolution curve, accepted/rejected proposals, wiki
patterns, raw traces, then Evaluate on the test split.
4. **Repeat** — tweak parameters (iterations, ReAct turns), start a new run in a fresh
workspace, compare.
## 3. Information architecture (single page, six tabs)
| Tab | Purpose |
|-----|---------|
| **Run** | Configuration form + live console + status pill; start/cancel |
| **Dashboard** | R_best curve, per-iteration acceptance bars, history table |
| **Evaluation** | Test-split comparison (skilled vs baseline), bootstrap p-value |
| **Workspace** | File tree + viewer for raw/wiki/skills/run_state.json |
| **Knowledge** | Wiki index, pattern pages, evolution log, skill-impact tracker, skills |
| **How it works** | Layer diagram + Algorithm 1 pseudocode + glossary |
## 4. Screen details
### 4.1 Run tab
- **Config form**: LLM backend (`mock`|`openai`), model, base URL, API key (password
field), temperature, max iterations (K), max ReAct turns, workspace root (empty =
auto `runs/run_<timestamp>`).
### 4.3 Evaluation tab
- Big-number cards: Skilled accuracy, Baseline accuracy, Δ, bootstrap p-value.
- Horizontal bar comparison + verdict ("Skilled is significantly better", etc.).
- Re-evaluate button (re-runs `evaluate_test()` for the current workspace).
### 4.4 Workspace tab
- Left: collapsible file tree starting at the workspace root.
- Right: content viewer — JSON pretty-printed, markdown shown in raw + rendered form,
diffs monospace.
### 4.5 Knowledge tab
- Sub-tabs: Skills (cards with SKILL.md + PURPOSE.md), Wiki patterns (list + pages),
Evolution log, Skill-impact history.
### 4.6 How-it-works tab
- Static annotated pipeline of the three layers and four components, Algorithm 1
pseudocode, and definitions table (S_k, W_k, P_k, T_train, R_best…).
## 5. Design system
- Dark-on-light ("paper" feel): background `#f7f7f5`, panels white `#fff`, ink `#1a1a1a`.
- Accent: indigo `#3b5bdb` (brand), success `#099268`, danger `#e03131`, warn `#f59f00`.
- Type: system stack; headings 600, mono stack for code/traces.
- Badges/pills for Accept/Reject and status; cards with 1px border, 10px radius.
- Charts rendered as inline SVG (zero JS chart libraries → works offline).
## 6. Backend contract
| Method/Path | Body/Query | Returns |
|---|---|---|
| `GET /` | — | SPA HTML |
| `GET /api/health` | — | `{ok, version}` |
| `POST /api/run` | config JSON | `202 {run_id}`; `400` validation; `409` busy |
| `GET /api/status` | — | `{active, state:{status, events[], result, error}}` |
| `GET /api/stream` | — | Server-Sent Events (`log`/`status`/`react`/`done`/`error`) |
| `POST /api/cancel` | — | `200 {cancelled}` |
| `GET /api/workspace?root=` | query | `{root, tree, files}` (text files) |
| `POST /api/evaluate` | `{workspace_root, llm_backend, …}` | evaluation report JSON |
Validation rules: workspace_root must be a *relative* path (no abs, no `..`); run
rejected with 409 if another run is active; run rejected if root already contains
evolution history (choose a fresh root to avoid clobbering the immutable raw layer).
### SSE protocol
Events: `{type:"log", message}`, `{type:"react", turn, kind, path?, found?}`,
`{type:"status", phase}`, `{type:"done", result}`, `{type:"error", error}`.
Heartbeat comments every 15 s; stream closes after `done`/`error`.
## 7. State model
Server (single flask app):
- `RunManager` — one active run; holds `RunState {run_id, status, events, result, error}`
and an SSE queue; background daemon thread executes the `EvolutionOrchestrator`
(MockLLM or OpenAICompatLLM) and wires `log` + proposer `on_step` to the queue;
`cancel_event` lets the orchestrator stop between iterations.
Client (vanilla JS):
- `state = {config, status, events[], result, evaluation}`; tab rendering is
pure functions over `state`; `EventSource('/api/stream')` drives updates.
## 8. Error handling & resilience
- JSON payloads only; unknown/invalid fields → 400 with message.
- Proposal failures / malformed LLM JSON are absorbed by the orchestrator (logged).
- Run exceptions → `type:"error"` event + status `error` (traceback stored, exposed
via status).
- EventSource auto-reconnect on transient drops.
- All file reads limited to 200 kB per file and whitelisted extension set.
## 9. Accessibility & usability
- Semantic HTML (tabs as buttons with aria), keyboard navigation for tree, focus
states, sufficient contrast, no motion-only information.
## 10. Testing strategy
- Backend: Flask test client (mock backend) — run lifecycle, status, workspace tree,
evaluation, single-flight 409, validation 400s, cancel.
- Runner: unit test RunManager concurrency (double-start raises).
- E2E smoke: boot `wikiskill serve` on a port; fetch `/`, `/api/health`, run
`/api/run` with mock, poll status to completion, verify workspace artifacts.
- Regression: full `pytest` suite (core + UI) must stay green.
## 11. Non-functional targets
- Zero CDN dependencies (offline usable).
- Startup < 1 s; mock run completes in seconds; UI remains responsive during runs
(threaded Flask).
- Flask is the single new runtime dependency, grouped under `[project.optional-dependencies] ui`.
- **Actions**: Start, Cancel (appears while running). Status pill: idle | starting |
running | evaluating | done | error.
- **Live console**: appended log lines; SSE-driven; auto-scrolls; monospace.
- **ReAct event log**: marks `read_file` calls (path + found/not-found) and final
proposal, rendered inline in the console with icons.
### 4.2 Dashboard tab
- **R_best curve**: SVG polyline (x = iteration incl. baseline, y = R_best value).
- **Validation bars**: one bar per evaluated proposal, colored green=Accepted /
red=Rejected; dashed baseline marker.
- **History table**: iteration, action, target skill, validation score, outcome.