Apollo-universe Claude Sonnet 4.6 commited on
Commit
a9df8b1
·
0 Parent(s):

HF Spaces deploy: initial orphan branch (no binary history)

Browse files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +22 -0
  2. .gitignore +119 -0
  3. Dockerfile +40 -0
  4. README.md +234 -0
  5. RESTORE_SKILLWIKI_DEMO_STATE.bat +7 -0
  6. START_SKILLWIKI_DEMO.bat +10 -0
  7. STOP_SKILLWIKI_DEMO.bat +5 -0
  8. docker-compose.yml +35 -0
  9. scripts/demo_readiness_check.py +404 -0
  10. scripts/prepare_skillsbench_subset.py +295 -0
  11. scripts/report_skillsbench_mapping.py +295 -0
  12. scripts/restore_demo_state.py +862 -0
  13. scripts/run_input_skill_eval.py +836 -0
  14. scripts/run_p0_harness_eval.py +518 -0
  15. skillwiki-frontend/.gitignore +24 -0
  16. skillwiki-frontend/Dockerfile +11 -0
  17. skillwiki-frontend/README.md +73 -0
  18. skillwiki-frontend/eslint.config.js +22 -0
  19. skillwiki-frontend/index.html +13 -0
  20. skillwiki-frontend/nginx.conf +32 -0
  21. skillwiki-frontend/package-lock.json +0 -0
  22. skillwiki-frontend/package.json +41 -0
  23. skillwiki-frontend/public/favicon.svg +1 -0
  24. skillwiki-frontend/public/icons.svg +24 -0
  25. skillwiki-frontend/scripts/capture_figures.mjs +198 -0
  26. skillwiki-frontend/src/App.css +184 -0
  27. skillwiki-frontend/src/App.tsx +66 -0
  28. skillwiki-frontend/src/api/client.ts +274 -0
  29. skillwiki-frontend/src/api/errors.ts +46 -0
  30. skillwiki-frontend/src/api/types.ts +744 -0
  31. skillwiki-frontend/src/assets/react.svg +1 -0
  32. skillwiki-frontend/src/assets/vite.svg +1 -0
  33. skillwiki-frontend/src/components/AppLayout.tsx +208 -0
  34. skillwiki-frontend/src/hooks/useWebSocket.ts +64 -0
  35. skillwiki-frontend/src/i18n.ts +52 -0
  36. skillwiki-frontend/src/index.css +39 -0
  37. skillwiki-frontend/src/main.tsx +10 -0
  38. skillwiki-frontend/src/pages/AgentExecution.tsx +370 -0
  39. skillwiki-frontend/src/pages/Dashboard.tsx +434 -0
  40. skillwiki-frontend/src/pages/EvaluationDashboard.tsx +481 -0
  41. skillwiki-frontend/src/pages/Evolution.tsx +583 -0
  42. skillwiki-frontend/src/pages/HarnessVerification.tsx +577 -0
  43. skillwiki-frontend/src/pages/KnowledgeImport.tsx +1293 -0
  44. skillwiki-frontend/src/pages/LifecycleDemo.tsx +353 -0
  45. skillwiki-frontend/src/pages/SelfEvolutionDemo.tsx +535 -0
  46. skillwiki-frontend/src/pages/SkillGraph.tsx +1389 -0
  47. skillwiki-frontend/src/pages/SkillWiki.tsx +613 -0
  48. skillwiki-frontend/src/pages/VersionControl.tsx +1117 -0
  49. skillwiki-frontend/src/store/appStore.ts +64 -0
  50. skillwiki-frontend/src/types/antv-g6.d.ts +1 -0
.dockerignore ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ **/__pycache__/
2
+ **/*.py[cod]
3
+ **/.pytest_cache/
4
+ **/.coverage
5
+ **/node_modules/
6
+ **/dist/
7
+ **/.env
8
+ **/*.env
9
+ **/*.log
10
+ **/logs/
11
+ skillwiki/venv/
12
+ skillwiki/.venv/
13
+ skillwiki/skillwiki/storage/skill_repo/SkillStorage/
14
+ artifacts/
15
+ video/
16
+ figures/
17
+ docs/
18
+ *.pptx
19
+ *.docx
20
+ .git/
21
+ .vscode/
22
+ .idea/
.gitignore ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .eggs/
11
+ venv/
12
+ env/
13
+ .venv/
14
+ *.egg
15
+
16
+ # Environment
17
+ .env
18
+ .env.local
19
+ .env.*.local
20
+ *.env
21
+
22
+ # Node
23
+ node_modules/
24
+ dist/
25
+ .cache/
26
+
27
+ # IDE
28
+ .vscode/
29
+ .idea/
30
+ *.swp
31
+ *.swo
32
+ .DS_Store
33
+ Thumbs.db
34
+
35
+ # Logs
36
+ *.log
37
+ logs/
38
+
39
+ # Test
40
+ .pytest_cache/
41
+ .coverage
42
+ htmlcov/
43
+ .tox/
44
+
45
+ # Database
46
+ *.db
47
+ *.sqlite3
48
+
49
+ # Misc
50
+ *.tmp
51
+ *.bak
52
+ idea.txt
53
+
54
+ # Runtime SkillStorage repository
55
+ skillos/skillos/storage/skill_repo/SkillStorage/
56
+
57
+ # Runtime harness evidence
58
+ artifacts/harness-runs/
59
+ artifacts/eval-readiness-runs/
60
+ artifacts/input-skill-eval-runs/
61
+ artifacts/skillsbench-runs/
62
+
63
+ # Local one-click demo launcher state and secrets
64
+ skillos-one-click-launcher/config.local.ps1
65
+ skillwiki-launcher/config.local.ps1
66
+ skillwiki-launcher/runtime/
67
+ skillos-one-click-launcher/runtime/
68
+
69
+ # Design / planning documents (not part of the public repo)
70
+ DEEP_DISCUSSION.md
71
+ DESIGN.md
72
+ DEVELOPMENT.md
73
+ FRONTEND_BACKEND_PLAN.md
74
+ HOMOGENEOUS_GRAPH_DESIGN.md
75
+ IMPLEMENTATION_ROADMAP.md
76
+ SkillWiki.txt
77
+ agent_framework.md
78
+ DEMO_STARTUP.md
79
+
80
+ # Internal docs directory
81
+ docs/
82
+
83
+ # Experiments (large / internal)
84
+ experiment_1/
85
+ experiment_2/
86
+
87
+ # Benchmark results (runtime output, not source)
88
+ # skillwiki/benchmarks/results/ <- tracked on dingcheng-dev branch
89
+
90
+ # Video files
91
+ video/
92
+
93
+ # Runtime skill storage data
94
+ skills/
95
+
96
+ # Figures — all except the single overview image used in README
97
+ figures/figure1_motivation/
98
+ figures/figure2_overview/*.drawio
99
+ figures/figure2_overview/*.pdf
100
+ figures/figure2_overview/*.png
101
+ figures/figure2_overview/*.bkp
102
+ figures/figure2_overview/*.svg
103
+ figures/figure3_system/
104
+ figures/figure4_lifecycle/
105
+
106
+ # Presentations and office files
107
+ *.pptx
108
+ *.docx
109
+
110
+ # Codex workspace
111
+ .codex/
112
+
113
+ # Internal planning/summary documents inside skillwiki/
114
+ skillwiki/PHASE_0_SUMMARY.md
115
+ skillwiki/QUICK_COMMANDS.md
116
+ skillwiki/USAGE_GUIDE.md
117
+ skillwiki-frontend/scripts/pdf_to_png.mjs
118
+ skillwiki/quickstart.sh
119
+ skillwiki/quickstart.bat
Dockerfile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Stage 1: build frontend ──────────────────────────────────────────────────
2
+ FROM node:20-slim AS frontend-builder
3
+
4
+ WORKDIR /build/frontend
5
+ COPY skillwiki-frontend/package*.json ./
6
+ RUN npm ci --prefer-offline
7
+
8
+ COPY skillwiki-frontend/ ./
9
+ # Disable WebSocket in the hosted build (HF Spaces doesn't support it)
10
+ ENV VITE_SKILLOS_DISABLE_WS=1
11
+ RUN npm run build
12
+
13
+ # ── Stage 2: runtime ─────────────────────────────────────────────────────────
14
+ FROM python:3.11-slim
15
+
16
+ WORKDIR /app
17
+
18
+ # Install slim Python deps (no heavy DB drivers)
19
+ COPY skillwiki/requirements-hf.txt ./requirements.txt
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Install the skillwiki package
23
+ COPY skillwiki/ ./skillwiki/
24
+ RUN pip install --no-cache-dir -e ./skillwiki
25
+
26
+ # Copy built frontend
27
+ COPY --from=frontend-builder /build/frontend/dist ./frontend/dist
28
+
29
+ # HF Spaces runs as user 1000
30
+ RUN useradd -m -u 1000 skillwiki && chown -R skillwiki:skillwiki /app
31
+ USER 1000
32
+
33
+ # HF Spaces requires port 7860
34
+ EXPOSE 7860
35
+
36
+ ENV LLM_API_URL=https://api.deepseek.com
37
+ ENV LLM_MODEL=deepseek-chat
38
+ ENV SKILLWIKI_FRONTEND_DIST=/app/frontend/dist
39
+
40
+ CMD ["python", "-m", "skillwiki.api.main", "--host", "0.0.0.0", "--port", "7860", "--repository-backend", "memory"]
README.md ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+ # SkillWiki
4
+
5
+ **A Living Knowledge Infrastructure for Agent Skills**
6
+
7
+ Just as Wikipedia organizes human knowledge and GitHub governs software, **SkillWiki** provides the missing infrastructure for agent skills. It transforms heterogeneous knowledge sources — trajectories, documents, API specs, and execution experience — into reusable skill assets that are grounded in their originating evidence, version-controlled, and continuously evolved. SkillWiki covers the complete skill lifecycle: from knowledge ingestion and skill production, through provenance-aware graph exploration and human governance, to execution-driven health monitoring and automated evolution. The result is a shared infrastructure where knowledge, skills, and execution experience co-evolve together.
8
+
9
+ <br/>
10
+
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
12
+ [![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
13
+ [![Node](https://img.shields.io/badge/Node.js-18%2B-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
14
+ [![GitHub stars](https://img.shields.io/github/stars/Huangdingcheng/SkillWiki?style=flat-square&logo=github&color=yellow)](https://github.com/Huangdingcheng/SkillWiki)
15
+
16
+
17
+ <br/>
18
+
19
+ [📺 **Demo Video**](https://www.youtube.com/watch?v=TODO) &nbsp;·&nbsp;
20
+ [📖 **Paper**](https://arxiv.org/abs/2606.16523)
21
+
22
+ </div>
23
+
24
+ ---
25
+
26
+ <p align="center">
27
+ <img src="figures/figure2_overview/overview.png" width="900" alt="SkillWiki system overview"/>
28
+ <br/>
29
+ <em>SkillWiki system overview — from multi-source ingestion to governed skill evolution.</em>
30
+ </p>
31
+
32
+ ---
33
+
34
+ ## Key Features
35
+
36
+ | | Feature | Description |
37
+ |:---:|---|---|
38
+ | 📥 | **Multi-source ingestion** | Trajectories, documents, API specs, scripts, and existing skill JSON/JSONL files |
39
+ | 🔄 | **Governed lifecycle** | Eight-stage state machine (S0–S7) with automated verification and full audit trail |
40
+ | 🕸️ | **Provenance knowledge graph** | Typed edges — `depends_on`, `composes_with`, `evolved_from`, `replaces`, and more |
41
+ | 🩺 | **Health & evolution engine** | Continuous monitoring detects degraded skills and queues repair proposals automatically |
42
+ | 🌐 | **Bilingual UI** | English (default) and Chinese — toggle via the language button in the header |
43
+ | ⌨️ | **CLI-first** | Every operation available as a `skillwiki` command for scripting and agent integration |
44
+
45
+ ---
46
+
47
+ ## Lifecycle
48
+
49
+ ```
50
+ S0 Raw ──► S1 Candidate ──► S2 Draft ──► S3 Verified ──► S4 Released
51
+
52
+ S7 Archived ◄── S6 Deprecated ◄── S5 Degraded
53
+ ```
54
+
55
+ | State | Name | Meaning |
56
+ |:---:|---|---|
57
+ | **S0** | Raw | Ingested source material, not yet extracted |
58
+ | **S1** | Candidate | Extracted skill candidate awaiting review |
59
+ | **S2** | Draft | Formalized schema, pending verification |
60
+ | **S3** | Verified | Passed automated postcondition checks |
61
+ | **S4** | Released | Approved for agent use in production |
62
+ | **S5** | Degraded | Success rate fell below health threshold |
63
+ | **S6** | Deprecated | Replaced or retired |
64
+ | **S7** | Archived | Read-only historical record |
65
+
66
+ ---
67
+
68
+ ## Quick Start
69
+
70
+ ### Requirements
71
+
72
+ - Python 3.10+
73
+ - Node.js 18+ *(frontend only)*
74
+
75
+ ### Install
76
+
77
+ ```bash
78
+ git clone https://github.com/Huangdingcheng/SkillWiki.git
79
+ cd SkillWiki/skillwiki
80
+
81
+ python -m venv venv
82
+ source venv/bin/activate # Linux / Mac
83
+ venv\Scripts\activate # Windows
84
+
85
+ pip install -r requirements.txt
86
+ pip install -e .
87
+ ```
88
+
89
+ ### Start the backend
90
+
91
+ ```bash
92
+ skillwiki serve --port 8001 --api-key YOUR_LLM_API_KEY
93
+ ```
94
+
95
+ ### Start the frontend *(optional)*
96
+
97
+ ```bash
98
+ cd ../skillwiki-frontend
99
+ npm install
100
+ npm run dev # → http://localhost:3000
101
+ ```
102
+
103
+ ### One-click launcher (Windows)
104
+
105
+ Double-click **`START_SKILLWIKI_DEMO.bat`** in the repository root. On first run you will be prompted for your LLM endpoint and API key (saved to `skillwiki-launcher\config.local.ps1`, which is Git-ignored — never commit it).
106
+
107
+ ```
108
+ Backend: http://127.0.0.1:8001
109
+ Frontend: http://127.0.0.1:5174/wiki
110
+ ```
111
+
112
+ Restore the public demo fixtures after a restart:
113
+
114
+ ```
115
+ RESTORE_SKILLWIKI_DEMO_STATE.bat
116
+ ```
117
+
118
+ ---
119
+
120
+ ## Demo Walkthrough
121
+
122
+ | Step | Page | What to explore |
123
+ |:---:|---|---|
124
+ | 1 | [`/wiki`](http://127.0.0.1:5174/wiki) | Skill library — browse, filter by state / tag, inspect schemas |
125
+ | 2 | [`/ingest`](http://127.0.0.1:5174/ingest) | Paste an API doc or upload a file; watch candidates extracted live |
126
+ | 3 | [`/graph`](http://127.0.0.1:5174/graph) | Provenance knowledge graph — Nebula, Readable, and Debug view presets |
127
+ | 4 | [`/harness`](http://127.0.0.1:5174/harness) | Execute-verify loop; observe automated repair attempts |
128
+ | 5 | [`/evaluation`](http://127.0.0.1:5174/evaluation) | SkillsBench P0 sparse-subset analysis |
129
+ | 6 | [`/versions`](http://127.0.0.1:5174/versions) | Business-readable diffs and re-verification after changes |
130
+
131
+ ---
132
+
133
+ ## CLI Reference
134
+
135
+ Install once after cloning:
136
+
137
+ ```bash
138
+ cd skillwiki && pip install -e .
139
+ ```
140
+
141
+ All commands accept `--api-url <URL>` (default `http://127.0.0.1:8001`).
142
+
143
+ ### Server
144
+
145
+ ```bash
146
+ skillwiki serve [--host HOST] [--port PORT] [--backend memory|sqlite|postgres]
147
+ ```
148
+
149
+ ### Knowledge Ingestion
150
+
151
+ ```bash
152
+ # source_type: trajectory | document | api_doc | script | past_skills
153
+ skillwiki ingest run <source_type> <input> [--create]
154
+
155
+ skillwiki ingest run api_doc ./openai_spec.md --create
156
+ skillwiki ingest run trajectory "open browser -> search -> copy link"
157
+ skillwiki ingest run past_skills ./skills.json --max-candidates 20
158
+ skillwiki ingest status <candidate_id>
159
+ ```
160
+
161
+ ### Skill Lifecycle
162
+
163
+ ```bash
164
+ skillwiki skill list [--state S3] [--tag nlp] [--limit 20]
165
+ skillwiki skill get <skill_id> [--full]
166
+ skillwiki skill status <skill_id>
167
+ skillwiki skill exec <skill_id> --input '{"key": "value"}'
168
+
169
+ skillwiki audit <skill_id>
170
+ skillwiki verify <skill_id> [--harness mock|claude_code|codex] [--max-retries 3] [--watch]
171
+ skillwiki promote <skill_id> <target_state>
172
+ ```
173
+
174
+ ### Health & Evolution
175
+
176
+ ```bash
177
+ skillwiki health [--json] # system-wide overview
178
+ skillwiki health <skill_id> [--json] # success rate, issues, open proposals
179
+
180
+ skillwiki repair <skill_id> # generate maintenance candidate
181
+ skillwiki evolve [--json] # run one full evolution cycle
182
+ ```
183
+
184
+ ### Maintenance Proposals
185
+
186
+ ```bash
187
+ skillwiki proposal list [--status pending|accepted|rejected] [--json]
188
+ skillwiki proposal accept <proposal_id>
189
+ skillwiki proposal reject <proposal_id>
190
+ ```
191
+
192
+ ### Knowledge Graph
193
+
194
+ ```bash
195
+ skillwiki graph neighbors <skill_id> [--depth 1]
196
+ skillwiki graph show <skill_id> [--view skill_only|provenance|version_impact] [--depth 2]
197
+ skillwiki graph deps <skill_id>
198
+ skillwiki graph export <skill_id> [-o output.json] [--view provenance] [--depth 2]
199
+ ```
200
+
201
+ ### Natural Language Execution
202
+
203
+ ```bash
204
+ skillwiki run "summarize the attached PDF and extract action items" [--verbose]
205
+ ```
206
+
207
+ ---
208
+
209
+ ## What SkillWiki Provides
210
+
211
+ - Knowledge-to-skill transformation from heterogeneous sources.
212
+ - Evidence-grounded skill assets with explicit provenance.
213
+ - Multi-granularity skill organization and dependency management.
214
+ - Provenance-aware exploration and visualization.
215
+ - Versioned skill governance with interpretable evolution.
216
+ - Execution-driven verification and continuous improvement.
217
+ - End-to-end demonstrations of reusable skill ecosystems.
218
+
219
+ ---
220
+
221
+ ## Environment Variables
222
+
223
+ | Variable | Default | Description |
224
+ |---|---|---|
225
+ | `LLM_API_KEY` | — | LLM API key *(required)* |
226
+ | `LLM_API_URL` | `https://api.deepseek.com` | LLM base URL |
227
+ | `LLM_MODEL` | `deepseek-v4-flash` | Model name |
228
+ | `SKILLOS_API_TARGET` | `http://127.0.0.1:8001` | Frontend proxy target |
229
+
230
+ ---
231
+
232
+ ## License
233
+
234
+ [MIT](LICENSE) © 2026 SkillWiki Authors
RESTORE_SKILLWIKI_DEMO_STATE.bat ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ setlocal
3
+ cd /d "%~dp0"
4
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0skillwiki-launcher\scripts\Restore-SkillWikiDemoState.ps1"
5
+ echo.
6
+ echo Press any key to close this restore window. SkillWiki keeps running in the background.
7
+ pause >nul
START_SKILLWIKI_DEMO.bat ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ setlocal
3
+ set "SCRIPT_DIR=%~dp0"
4
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%skillwiki-launcher\scripts\Start-SkillWikiDemo.ps1"
5
+ if errorlevel 1 (
6
+ echo.
7
+ echo SkillWiki demo failed to start. Check the error above, then press any key to close this window.
8
+ pause >nul
9
+ )
10
+ endlocal
STOP_SKILLWIKI_DEMO.bat ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ @echo off
2
+ setlocal
3
+ set "SCRIPT_DIR=%~dp0"
4
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%skillwiki-launcher\scripts\Stop-SkillWikiDemo.ps1"
5
+ endlocal
docker-compose.yml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.9'
2
+
3
+ services:
4
+ api:
5
+ build:
6
+ context: ./skillwiki
7
+ dockerfile: Dockerfile
8
+ ports:
9
+ - "8000:8000"
10
+ environment:
11
+ - LLM_API_KEY=${LLM_API_KEY}
12
+ - LLM_MODEL=${LLM_MODEL:-claude-sonnet-4-6}
13
+ command: >
14
+ python -m skillwiki.api.main
15
+ --api-key ${LLM_API_KEY}
16
+ --host 0.0.0.0
17
+ --port 8000
18
+ healthcheck:
19
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
20
+ interval: 30s
21
+ timeout: 10s
22
+ retries: 3
23
+
24
+ frontend:
25
+ build:
26
+ context: ./skillwiki-frontend
27
+ dockerfile: Dockerfile
28
+ ports:
29
+ - "80:80"
30
+ depends_on:
31
+ - api
32
+
33
+ networks:
34
+ default:
35
+ name: skillwiki-network
scripts/demo_readiness_check.py ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from uuid import uuid4
13
+
14
+
15
+ REPO_ROOT = Path(__file__).resolve().parents[1]
16
+ DEFAULT_RUN_ROOT = REPO_ROOT / "artifacts" / "eval-readiness-runs"
17
+
18
+
19
+ class ApiClient:
20
+ def __init__(self, api_base: str, raw_dir: Path, timeout_s: int = 60) -> None:
21
+ self.api_base = api_base.rstrip("/")
22
+ self.raw_dir = raw_dir
23
+ self.timeout_s = timeout_s
24
+ self.raw_dir.mkdir(parents=True, exist_ok=True)
25
+
26
+ def get(self, path: str, label: str) -> dict[str, Any]:
27
+ return self._request("GET", path, None, label)
28
+
29
+ def post(self, path: str, payload: dict[str, Any], label: str) -> dict[str, Any]:
30
+ return self._request("POST", path, payload, label)
31
+
32
+ def _request(
33
+ self,
34
+ method: str,
35
+ path: str,
36
+ payload: dict[str, Any] | None,
37
+ label: str,
38
+ ) -> dict[str, Any]:
39
+ url = path if path.startswith(("http://", "https://")) else f"{self.api_base}{path}"
40
+ body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
41
+ request = urllib.request.Request(url, data=body, method=method)
42
+ if label == "frontend_home":
43
+ request.add_header("Accept", "text/html,*/*")
44
+ else:
45
+ request.add_header("Accept", "application/json")
46
+ if payload is not None:
47
+ request.add_header("Content-Type", "application/json")
48
+ start = time.perf_counter()
49
+ try:
50
+ with urllib.request.urlopen(request, timeout=self.timeout_s) as response:
51
+ text = response.read().decode("utf-8", errors="replace")
52
+ status = response.status
53
+ except urllib.error.HTTPError as exc:
54
+ text = exc.read().decode("utf-8", errors="replace")
55
+ status = exc.code
56
+ except Exception as exc:
57
+ text = json.dumps({"error": str(exc)}, ensure_ascii=False)
58
+ status = 0
59
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
60
+ parsed = _parse_json(text)
61
+ envelope = {
62
+ "label": label,
63
+ "method": method,
64
+ "url": url,
65
+ "status_code": status,
66
+ "elapsed_ms": elapsed_ms,
67
+ "request": _preview(payload),
68
+ "body": parsed,
69
+ }
70
+ (self.raw_dir / f"{_safe_name(label)}.json").write_text(
71
+ json.dumps(envelope, indent=2, ensure_ascii=False),
72
+ encoding="utf-8",
73
+ )
74
+ return envelope
75
+
76
+
77
+ def main() -> int:
78
+ parser = argparse.ArgumentParser(description="Run an isolated SkillOS demo readiness check.")
79
+ parser.add_argument("--api-base", default="http://127.0.0.1:8001/api/v1")
80
+ parser.add_argument("--frontend-base", default="http://127.0.0.1:5174")
81
+ parser.add_argument("--run-root", type=Path, default=DEFAULT_RUN_ROOT)
82
+ parser.add_argument("--run-id", default=datetime.now().strftime("%Y%m%d-%H%M%S"))
83
+ parser.add_argument("--timeout", type=int, default=90)
84
+ parser.add_argument("--skip-mutating-probe", action="store_true")
85
+ args = parser.parse_args()
86
+
87
+ run_dir = args.run_root / f"readiness-{args.run_id}"
88
+ raw_dir = run_dir / "raw"
89
+ run_dir.mkdir(parents=True, exist_ok=True)
90
+ client = ApiClient(args.api_base, raw_dir, timeout_s=args.timeout)
91
+
92
+ summary: dict[str, Any] = {
93
+ "started_at": datetime.now().isoformat(timespec="seconds"),
94
+ "api_base": args.api_base,
95
+ "frontend_base": args.frontend_base,
96
+ "run_dir": str(run_dir),
97
+ "isolation": {
98
+ "raw_fixtures_modified": False,
99
+ "writes_only_under_run_dir": str(run_dir),
100
+ "mutating_probe": not args.skip_mutating_probe,
101
+ "governance_repo": os.environ.get("SKILLOS_GOVERNANCE_REPO", ""),
102
+ },
103
+ "checks": [],
104
+ }
105
+
106
+ api_root = _api_root(args.api_base)
107
+ frontend_base = args.frontend_base.rstrip("/")
108
+
109
+ _record(summary, "backend_health", client.get(f"{api_root}/health", "backend_health"), {200})
110
+ _record(summary, "frontend_home", client.get(f"{frontend_base}/", "frontend_home"), {200})
111
+ _record(
112
+ summary,
113
+ "frontend_proxy",
114
+ client.get(f"{frontend_base}/api/v1/skills?limit=1", "frontend_proxy"),
115
+ {200},
116
+ )
117
+ _record(summary, "skills_list", client.get("/skills?limit=1", "skills_list"), {200})
118
+ _record(summary, "graph_view", client.get("/graph/view?view=skill_only&limit=10", "graph_view"), {200})
119
+ _record(summary, "evaluation_dashboard", client.get("/evaluation/dashboard", "evaluation_dashboard"), {200})
120
+ _record(summary, "version_repo_status", client.get("/lifecycle/repository/status", "version_repo_status"), {200, 400})
121
+ _record(summary, "llm_config", _llm_config_envelope(), {200, 204})
122
+
123
+ if not args.skip_mutating_probe:
124
+ parse = client.post("/ingest/parse", _probe_parse_payload(), "ingest_parse_probe")
125
+ _record(summary, "ingest_parse", parse, {200})
126
+ unit = _first_unit(parse)
127
+ if unit:
128
+ create = client.post("/ingest/create-candidate", _candidate_payload(unit), "create_candidate_probe")
129
+ _record(summary, "create_candidate", create, {200, 201})
130
+ skill_id = _body(create).get("created_skill", {}).get("skill_id")
131
+ if isinstance(skill_id, str) and skill_id:
132
+ _record(summary, "version_diff", client.get(f"/lifecycle/{skill_id}/diff", "version_diff_probe"), {200})
133
+ _record(summary, "version_snapshot", client.post(
134
+ f"/lifecycle/{skill_id}/snapshot",
135
+ {"author": "demo_readiness_check", "message": "readiness probe snapshot"},
136
+ "version_snapshot_probe",
137
+ ), {200, 400})
138
+ draft = client.post(
139
+ f"/lifecycle/{skill_id}/new-version",
140
+ _harness_draft_payload(),
141
+ "new_harness_draft_probe",
142
+ )
143
+ _record(
144
+ summary,
145
+ "new_harness_draft",
146
+ draft,
147
+ {200},
148
+ semantic_ok=_body(draft).get("state") == "S2",
149
+ )
150
+ harness_skill_id = _body(draft).get("skill_id") or skill_id
151
+ harness = client.post(
152
+ f"/harness/{harness_skill_id}/verify-loop",
153
+ {
154
+ "harness": "local_skillos",
155
+ "max_attempts": 2,
156
+ "promote_on_pass": True,
157
+ "allow_repair": True,
158
+ "test_cases": [
159
+ {
160
+ "test_id": "readiness-email-positive",
161
+ "name": "Readiness email extraction positive case",
162
+ "goal": "Extract an email address from text.",
163
+ "input_data": {"text": "Contact readiness@example.com"},
164
+ "verifier_specs": [{"type": "json_exists", "path": "output.email"}],
165
+ "timeout_s": 20,
166
+ }
167
+ ],
168
+ },
169
+ "harness_verify_loop_probe",
170
+ )
171
+ _record(
172
+ summary,
173
+ "harness_verify_loop",
174
+ harness,
175
+ {200},
176
+ semantic_ok=_body(harness).get("status") == "verified"
177
+ and _body(harness).get("promotion_allowed") is True,
178
+ )
179
+ _record(summary, "execution_plan", client.post(
180
+ "/execution/plan",
181
+ {"goal": "Extract an email address from a JSON payload.", "context": {"text": "user@example.com"}},
182
+ "execution_plan_probe",
183
+ ), {200})
184
+
185
+ summary["finished_at"] = datetime.now().isoformat(timespec="seconds")
186
+ summary["scores"] = _score(summary)
187
+ (run_dir / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
188
+ (run_dir / "REPORT.md").write_text(_report(summary), encoding="utf-8")
189
+ print(json.dumps({"run_dir": str(run_dir), "scores": summary["scores"]}, indent=2, ensure_ascii=False))
190
+ return 0 if summary["scores"]["overall"] >= 0.85 else 1
191
+
192
+
193
+ def _record(
194
+ summary: dict[str, Any],
195
+ name: str,
196
+ envelope: dict[str, Any],
197
+ ok_statuses: set[int],
198
+ *,
199
+ semantic_ok: bool | None = None,
200
+ ) -> None:
201
+ status = int(envelope.get("status_code") or 0)
202
+ body = _body(envelope)
203
+ passed = status in ok_statuses and (True if semantic_ok is None else semantic_ok)
204
+ summary["checks"].append({
205
+ "name": name,
206
+ "passed": passed,
207
+ "status_code": status,
208
+ "elapsed_ms": envelope.get("elapsed_ms"),
209
+ "semantic_ok": semantic_ok,
210
+ "error": body.get("error") or body.get("detail") if isinstance(body, dict) else None,
211
+ })
212
+
213
+
214
+ def _score(summary: dict[str, Any]) -> dict[str, Any]:
215
+ checks = summary.get("checks", [])
216
+ total = len(checks)
217
+ passed = sum(1 for check in checks if check.get("passed"))
218
+ return {
219
+ "passed": passed,
220
+ "total": total,
221
+ "overall": round(passed / total, 4) if total else 0.0,
222
+ }
223
+
224
+
225
+ def _api_root(api_base: str) -> str:
226
+ stripped = api_base.rstrip("/")
227
+ suffix = "/api/v1"
228
+ if stripped.endswith(suffix):
229
+ return stripped[: -len(suffix)]
230
+ return stripped
231
+
232
+
233
+ def _llm_config_envelope() -> dict[str, Any]:
234
+ configured = bool(os.environ.get("LLM_API_URL") and os.environ.get("LLM_MODEL"))
235
+ has_key = bool(os.environ.get("LLM_API_KEY"))
236
+ return {
237
+ "label": "llm_config",
238
+ "method": "ENV",
239
+ "url": "environment",
240
+ "status_code": 200 if configured and has_key else 204,
241
+ "elapsed_ms": 0.0,
242
+ "request": None,
243
+ "body": {
244
+ "configured": configured,
245
+ "api_url_configured": bool(os.environ.get("LLM_API_URL")),
246
+ "api_key_configured": has_key,
247
+ "model": os.environ.get("LLM_MODEL", ""),
248
+ "note": "Readiness does not print or persist API keys.",
249
+ },
250
+ }
251
+
252
+
253
+ def _report(summary: dict[str, Any]) -> str:
254
+ lines = [
255
+ "# SkillOS Demo Readiness Report",
256
+ "",
257
+ f"- API base: `{summary['api_base']}`",
258
+ f"- Frontend base: `{summary['frontend_base']}`",
259
+ f"- Run directory: `{summary['run_dir']}`",
260
+ f"- Overall: `{summary['scores']['overall']}` ({summary['scores']['passed']}/{summary['scores']['total']})",
261
+ "",
262
+ "## Isolation",
263
+ "",
264
+ "- Raw fixtures are not modified.",
265
+ f"- Request/response artifacts are written only under `{summary['run_dir']}`.",
266
+ "- Mutating probes create only readiness-tagged candidate data in the target backend.",
267
+ "",
268
+ "## Checks",
269
+ "",
270
+ "| Check | Passed | HTTP | Latency ms | Error |",
271
+ "| --- | --- | --- | --- | --- |",
272
+ ]
273
+ for check in summary.get("checks", []):
274
+ lines.append(
275
+ f"| {check['name']} | {check['passed']} | {check['status_code']} | "
276
+ f"{check.get('elapsed_ms', '')} | {check.get('error') or ''} |"
277
+ )
278
+ lines.append("")
279
+ return "\n".join(lines)
280
+
281
+
282
+ def _probe_parse_payload() -> dict[str, Any]:
283
+ return {
284
+ "source_type": "past_skills",
285
+ "content": json.dumps({
286
+ "name": "readiness_email_extract_probe",
287
+ "description": "Extract one email address from input text for readiness checks.",
288
+ "inputs": {"text": "string"},
289
+ "outputs": {"email": "string"},
290
+ "steps": ["Read input text", "Find the first email address", "Return it as output.email"],
291
+ }, ensure_ascii=False),
292
+ "metadata": {
293
+ "source_id": "readiness-email-extract-probe",
294
+ "domain": "software",
295
+ "max_candidates": 1,
296
+ },
297
+ }
298
+
299
+
300
+ def _candidate_payload(unit: dict[str, Any]) -> dict[str, Any]:
301
+ metadata = unit.get("metadata") if isinstance(unit.get("metadata"), dict) else {}
302
+ interface = metadata.get("candidate_interface") if isinstance(metadata.get("candidate_interface"), dict) else {}
303
+ implementation = metadata.get("candidate_implementation") if isinstance(metadata.get("candidate_implementation"), dict) else {}
304
+ unique_name = _safe_name(
305
+ f"{unit.get('proposed_skill_name') or 'readiness_email_extract_probe'}_{uuid4().hex[:8]}"
306
+ )
307
+ return {
308
+ "source_type": "past_skills",
309
+ "unit_id": unit.get("unit_id"),
310
+ "raw_content": unit.get("raw_content", ""),
311
+ "name": unique_name,
312
+ "description": unit.get("proposed_description") or unit.get("summary") or "Readiness probe skill.",
313
+ "skill_type": "atomic",
314
+ "tags": ["readiness-probe", "isolated-eval"],
315
+ "input_schema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]},
316
+ "output_schema": {"type": "object", "properties": {"email": {"type": "string"}}},
317
+ "preconditions": ["input.text is provided"],
318
+ "postconditions": ["output.email is present when an email exists"],
319
+ "prompt_template": "Extract the first email from {text}.",
320
+ "evaluation": {
321
+ "verifier_specs": [{"type": "json_exists", "path": "output.email"}],
322
+ "benchmark_task_ids": ["readiness-email-extract-probe"],
323
+ },
324
+ "provenance": {
325
+ "source_type": "past_skills",
326
+ "source_ids": ["readiness-email-extract-probe"],
327
+ "parent_skill_ids": [],
328
+ "created_by_agent": "demo_readiness_check",
329
+ "creation_context": {"isolated_probe": True},
330
+ },
331
+ "author": "demo_readiness_check",
332
+ }
333
+
334
+
335
+ def _harness_draft_payload() -> dict[str, Any]:
336
+ return {
337
+ "bump": "patch",
338
+ "description": "Readiness harness Draft that extracts the first email address from input text.",
339
+ "implementation": {
340
+ "language": "python",
341
+ "code": (
342
+ "text = input_data.get('text', '')\n"
343
+ "for token in str(text).replace('<', ' ').replace('>', ' ').split():\n"
344
+ " candidate = token.strip('.,;:()[]{}\\\"\\'')\n"
345
+ " if '@' in candidate and '.' in candidate.split('@')[-1]:\n"
346
+ " output['email'] = candidate\n"
347
+ " break\n"
348
+ ),
349
+ },
350
+ "evaluation": {
351
+ "verifier_specs": [{"type": "json_exists", "path": "output.email"}],
352
+ "benchmark_task_ids": ["readiness-email-extract-probe"],
353
+ "validation_summary": "Readiness harness Draft uses deterministic local postcondition verification.",
354
+ },
355
+ "test_cases": [
356
+ {
357
+ "name": "Readiness email extraction positive case",
358
+ "input_data": {"text": "Contact readiness@example.com"},
359
+ "expected_output": {"email": "readiness@example.com"},
360
+ }
361
+ ],
362
+ "metadata": {
363
+ "readiness_probe": True,
364
+ "requires_harness_reverification": True,
365
+ },
366
+ "author": "demo_readiness_check",
367
+ }
368
+
369
+
370
+ def _first_unit(envelope: dict[str, Any]) -> dict[str, Any] | None:
371
+ body = _body(envelope)
372
+ units = body.get("units") if isinstance(body, dict) else None
373
+ if isinstance(units, list) and units and isinstance(units[0], dict):
374
+ return units[0]
375
+ return None
376
+
377
+
378
+ def _body(envelope: dict[str, Any]) -> dict[str, Any]:
379
+ body = envelope.get("body")
380
+ return body if isinstance(body, dict) else {}
381
+
382
+
383
+ def _parse_json(text: str) -> Any:
384
+ try:
385
+ return json.loads(text)
386
+ except Exception:
387
+ return text
388
+
389
+
390
+ def _preview(payload: dict[str, Any] | None) -> Any:
391
+ if payload is None:
392
+ return None
393
+ text = json.dumps(payload, ensure_ascii=False)
394
+ if len(text) <= 1000:
395
+ return payload
396
+ return {"preview": text[:1000], "truncated": True}
397
+
398
+
399
+ def _safe_name(value: str) -> str:
400
+ return "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in value)[:120]
401
+
402
+
403
+ if __name__ == "__main__":
404
+ raise SystemExit(main())
scripts/prepare_skillsbench_subset.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import time
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from datetime import datetime
11
+ from pathlib import Path, PurePosixPath
12
+ from typing import Any
13
+
14
+
15
+ REPO = "benchflow-ai/skillsbench"
16
+ BRANCH = "main"
17
+ API_ROOT = f"https://api.github.com/repos/{REPO}"
18
+ RAW_ROOT = f"https://raw.githubusercontent.com/{REPO}/{BRANCH}"
19
+ REPO_ROOT = Path(__file__).resolve().parents[1]
20
+ DEFAULT_OUTPUT_ROOT = REPO_ROOT / "artifacts" / "skillsbench-runs"
21
+ DEFAULT_TASK_IDS = [
22
+ "citation-check",
23
+ "sales-pivot-analysis",
24
+ "software-dependency-audit",
25
+ "court-form-filling",
26
+ "dialogue-parser",
27
+ ]
28
+ ROOT_FILES = ["README.md", "pyproject.toml", "uv.lock", "taxonomy.yaml", "taxonomy.md", "LICENSE"]
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ parser = argparse.ArgumentParser(
33
+ description=(
34
+ "Prepare a lightweight SkillsBench subset without cloning the full repository. "
35
+ "Downloads root metadata plus selected task directories into an isolated output folder."
36
+ )
37
+ )
38
+ parser.add_argument("--output-root", default=str(DEFAULT_OUTPUT_ROOT))
39
+ parser.add_argument("--run-id", default=datetime.now().strftime("%Y%m%d-%H%M%S"))
40
+ parser.add_argument("--task", action="append", dest="tasks", help="Task id to include. Repeatable.")
41
+ parser.add_argument("--max-file-bytes", type=int, default=2_000_000)
42
+ parser.add_argument("--max-files-per-task", type=int, default=0, help="Limit downloaded files per task after core files are prioritized.")
43
+ parser.add_argument("--dry-run", action="store_true")
44
+ args = parser.parse_args(argv)
45
+
46
+ output_dir = Path(args.output_root).resolve() / f"skillsbench-subset-{args.run_id}"
47
+ output_dir.mkdir(parents=True, exist_ok=True)
48
+ task_ids = normalize_task_ids(args.tasks or DEFAULT_TASK_IDS)
49
+ repo_head = get_repo_head()
50
+ downloaded: list[dict[str, Any]] = []
51
+ skipped: list[dict[str, Any]] = []
52
+
53
+ for root_file in ROOT_FILES:
54
+ entry = fetch_content_entry(root_file)
55
+ if not entry:
56
+ skipped.append({"path": root_file, "reason": "not found"})
57
+ continue
58
+ download_entry(entry, output_dir, args.max_file_bytes, downloaded, skipped, dry_run=args.dry_run)
59
+
60
+ for task_id in task_ids:
61
+ selected_entries, skipped_entries = select_task_entries(
62
+ task_id,
63
+ list_tree(f"tasks/{task_id}"),
64
+ max_files_per_task=args.max_files_per_task,
65
+ )
66
+ skipped.extend(skipped_entries)
67
+ for entry in selected_entries:
68
+ download_entry(entry, output_dir, args.max_file_bytes, downloaded, skipped, dry_run=args.dry_run)
69
+
70
+ manifest = build_manifest(
71
+ output_dir=output_dir,
72
+ task_ids=task_ids,
73
+ downloaded=downloaded,
74
+ skipped=skipped,
75
+ repo_head=repo_head,
76
+ )
77
+ write_json(output_dir / "skillsbench_subset_manifest.json", manifest)
78
+ (output_dir / "RUN_COMMANDS.md").write_text(render_run_commands(output_dir, task_ids), encoding="utf-8")
79
+ print(json.dumps({
80
+ "output_dir": str(output_dir),
81
+ "repo_head": repo_head,
82
+ "task_ids": task_ids,
83
+ "downloaded_count": len(downloaded),
84
+ "skipped_count": len(skipped),
85
+ "dry_run": args.dry_run,
86
+ }, indent=2, ensure_ascii=False))
87
+ return 0
88
+
89
+
90
+ def normalize_task_ids(values: list[str]) -> list[str]:
91
+ seen: set[str] = set()
92
+ result: list[str] = []
93
+ for value in values:
94
+ task = str(value or "").strip().strip("/\\")
95
+ if not task or task in seen:
96
+ continue
97
+ seen.add(task)
98
+ result.append(task)
99
+ return result
100
+
101
+
102
+ def safe_repo_path(value: str) -> Path:
103
+ raw = str(value or "").replace("\\", "/")
104
+ posix = PurePosixPath(raw)
105
+ if posix.is_absolute() or ":" in raw:
106
+ raise ValueError(f"Unsafe repository path: {value!r}")
107
+ parts = posix.parts
108
+ if any(part in {"", ".", ".."} for part in parts):
109
+ raise ValueError(f"Unsafe repository path: {value!r}")
110
+ return Path(*parts)
111
+
112
+
113
+ def get_repo_head() -> str:
114
+ refs = http_json(f"{API_ROOT}/git/ref/heads/{BRANCH}")
115
+ if isinstance(refs, dict):
116
+ obj = refs.get("object") if isinstance(refs.get("object"), dict) else {}
117
+ sha = obj.get("sha")
118
+ if isinstance(sha, str):
119
+ return sha
120
+ return ""
121
+
122
+
123
+ def fetch_content_entry(path: str) -> dict[str, Any] | None:
124
+ encoded = urllib.parse.quote(path)
125
+ data = http_json(f"{API_ROOT}/contents/{encoded}?ref={BRANCH}")
126
+ return data if isinstance(data, dict) else None
127
+
128
+
129
+ def list_tree(path: str) -> list[dict[str, Any]]:
130
+ entries = http_json(f"{API_ROOT}/contents/{urllib.parse.quote(path)}?ref={BRANCH}")
131
+ if not isinstance(entries, list):
132
+ return []
133
+ result: list[dict[str, Any]] = []
134
+ for entry in entries:
135
+ if not isinstance(entry, dict):
136
+ continue
137
+ if entry.get("type") == "file":
138
+ result.append(entry)
139
+ elif entry.get("type") == "dir":
140
+ result.extend(list_tree(str(entry.get("path") or "")))
141
+ return result
142
+
143
+
144
+ def select_task_entries(
145
+ task_id: str,
146
+ entries: list[dict[str, Any]],
147
+ *,
148
+ max_files_per_task: int,
149
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
150
+ ordered = sorted(entries, key=lambda entry: task_entry_priority(task_id, str(entry.get("path") or "")))
151
+ if max_files_per_task <= 0 or len(ordered) <= max_files_per_task:
152
+ return ordered, []
153
+ selected = ordered[:max_files_per_task]
154
+ selected_paths = {str(entry.get("path") or "") for entry in selected}
155
+ skipped = [
156
+ {"path": str(entry.get("path") or ""), "reason": "max files per task reached"}
157
+ for entry in ordered
158
+ if str(entry.get("path") or "") not in selected_paths
159
+ ]
160
+ return selected, skipped
161
+
162
+
163
+ def task_entry_priority(task_id: str, path: str) -> tuple[int, str]:
164
+ name = PurePosixPath(path).name.lower()
165
+ if name in {"task.yaml", "task.yml", "task.json"}:
166
+ return (0, path)
167
+ if name in {"readme.md", "instructions.md", "prompt.md"}:
168
+ return (1, path)
169
+ if "oracle" in name:
170
+ return (2, path)
171
+ if "verifier" in name or "grader" in name or "eval" in name:
172
+ return (3, path)
173
+ if path.startswith(f"tasks/{task_id}/skills/"):
174
+ return (4, path)
175
+ return (9, path)
176
+
177
+
178
+ def download_entry(
179
+ entry: dict[str, Any],
180
+ output_dir: Path,
181
+ max_file_bytes: int,
182
+ downloaded: list[dict[str, Any]],
183
+ skipped: list[dict[str, Any]],
184
+ *,
185
+ dry_run: bool,
186
+ ) -> None:
187
+ path = str(entry.get("path") or "")
188
+ try:
189
+ relative = safe_repo_path(path)
190
+ except ValueError as exc:
191
+ skipped.append({"path": path, "reason": str(exc)})
192
+ return
193
+ size = int(entry.get("size") or 0)
194
+ if size > max_file_bytes:
195
+ skipped.append({"path": path, "size": size, "sha": entry.get("sha"), "reason": "too large"})
196
+ return
197
+ download_url = entry.get("download_url") or f"{RAW_ROOT}/{urllib.parse.quote(path)}"
198
+ target = output_dir / relative
199
+ if not dry_run:
200
+ target.parent.mkdir(parents=True, exist_ok=True)
201
+ body = http_bytes(str(download_url))
202
+ target.write_bytes(body)
203
+ size = len(body)
204
+ downloaded.append({
205
+ "path": path,
206
+ "size": size,
207
+ "sha": entry.get("sha"),
208
+ "download_url": download_url,
209
+ "local_path": str(target),
210
+ "dry_run": dry_run,
211
+ })
212
+
213
+
214
+ def build_manifest(
215
+ *,
216
+ output_dir: Path,
217
+ task_ids: list[str],
218
+ downloaded: list[dict[str, Any]],
219
+ skipped: list[dict[str, Any]],
220
+ repo_head: str,
221
+ ) -> dict[str, Any]:
222
+ return {
223
+ "created_at": datetime.now().isoformat(timespec="seconds"),
224
+ "repo": REPO,
225
+ "branch": BRANCH,
226
+ "repo_head": repo_head,
227
+ "output_dir": str(output_dir),
228
+ "task_ids": task_ids,
229
+ "downloaded_count": len(downloaded),
230
+ "skipped_count": len(skipped),
231
+ "downloaded": downloaded,
232
+ "skipped": skipped,
233
+ "isolation_policy": (
234
+ "This subset is an isolated benchmark workspace. Do not commit run outputs or copy "
235
+ "large task assets into the SkillOS source tree."
236
+ ),
237
+ }
238
+
239
+
240
+ def render_run_commands(output_dir: Path, task_ids: list[str]) -> str:
241
+ lines = [
242
+ "# SkillsBench Subset Run Commands",
243
+ "",
244
+ "Run these from the subset directory after installing `uv` and BenchFlow dependencies.",
245
+ "",
246
+ "```powershell",
247
+ f"cd {output_dir}",
248
+ "uv sync --locked",
249
+ "```",
250
+ "",
251
+ "Oracle sanity checks:",
252
+ "",
253
+ "```powershell",
254
+ ]
255
+ for task_id in task_ids:
256
+ lines.append(f"uv run bench tasks check tasks/{task_id}")
257
+ lines.append(f"uv run bench eval create -t tasks/{task_id} -a oracle")
258
+ lines.extend([
259
+ "```",
260
+ "",
261
+ "SkillOS adapter runs should write their reports outside the raw task directories, under an eval run folder.",
262
+ "",
263
+ ])
264
+ return "\n".join(lines)
265
+
266
+
267
+ def http_json(url: str) -> Any:
268
+ body = http_bytes(url).decode("utf-8", errors="replace")
269
+ return json.loads(body)
270
+
271
+
272
+ def http_bytes(url: str) -> bytes:
273
+ req = urllib.request.Request(url)
274
+ req.add_header("Accept", "application/vnd.github+json")
275
+ req.add_header("User-Agent", "SkillOS-subset-preparer")
276
+ last_error: Exception | None = None
277
+ for _ in range(2):
278
+ try:
279
+ with urllib.request.urlopen(req, timeout=45) as resp:
280
+ return resp.read()
281
+ except urllib.error.HTTPError:
282
+ raise
283
+ except Exception as exc:
284
+ last_error = exc
285
+ time.sleep(0.5)
286
+ raise RuntimeError(f"Failed to fetch {url}: {last_error}")
287
+
288
+
289
+ def write_json(path: Path, data: Any) -> None:
290
+ path.parent.mkdir(parents=True, exist_ok=True)
291
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
292
+
293
+
294
+ if __name__ == "__main__":
295
+ sys.exit(main())
scripts/report_skillsbench_mapping.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import shutil
6
+ from collections import Counter, defaultdict
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from statistics import mean
10
+ from typing import Any
11
+
12
+
13
+ DEFAULT_OUTPUT_DIR = Path(r"C:\Users\m1516\Desktop\SKILLOS\Codex-skilos\artifacts\eval-20260527")
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ parser = argparse.ArgumentParser(
18
+ description=(
19
+ "Create a reusable SkillsBench mapping report from the five-input SkillOS eval run. "
20
+ "This does not execute benchmark tasks; it records official task-check evidence, local "
21
+ "SkillOS generated-skill evidence, and any official oracle blocker."
22
+ )
23
+ )
24
+ parser.add_argument("--manifest", required=True)
25
+ parser.add_argument("--input-eval-summary", required=True)
26
+ parser.add_argument("--task-check-json", required=True)
27
+ parser.add_argument("--oracle-result", default="")
28
+ parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR))
29
+ parser.add_argument("--run-label", default="p0-20260527")
30
+ args = parser.parse_args(argv)
31
+
32
+ manifest = read_json(Path(args.manifest))
33
+ summary = read_json(Path(args.input_eval_summary))
34
+ task_check = read_json(Path(args.task_check_json))
35
+ oracle_result = read_json(Path(args.oracle_result)) if args.oracle_result else None
36
+
37
+ report = build_report_payload(
38
+ manifest=manifest,
39
+ input_eval_summary=summary,
40
+ task_check=task_check,
41
+ oracle_result=oracle_result,
42
+ run_label=args.run_label,
43
+ docker_available=bool(shutil.which("docker")),
44
+ )
45
+
46
+ output_dir = Path(args.output_dir)
47
+ output_dir.mkdir(parents=True, exist_ok=True)
48
+ mapping_path = output_dir / "skillsbench_mapping_p0_20260527.json"
49
+ report_path = output_dir / "SKILLOS_SKILLSBENCH_P0_REPORT.md"
50
+ write_json(mapping_path, report)
51
+ report_path.write_text(render_markdown(report), encoding="utf-8")
52
+
53
+ print(json.dumps({"mapping": str(mapping_path), "report": str(report_path)}, ensure_ascii=False, indent=2))
54
+ return 0
55
+
56
+
57
+ def read_json(path: Path) -> Any:
58
+ return json.loads(path.read_text(encoding="utf-8"))
59
+
60
+
61
+ def write_json(path: Path, payload: Any) -> None:
62
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
63
+
64
+
65
+ def build_report_payload(
66
+ *,
67
+ manifest: dict[str, Any],
68
+ input_eval_summary: dict[str, Any],
69
+ task_check: dict[str, Any],
70
+ oracle_result: dict[str, Any] | None,
71
+ run_label: str,
72
+ docker_available: bool,
73
+ ) -> dict[str, Any]:
74
+ fixtures = manifest.get("fixtures") if isinstance(manifest.get("fixtures"), list) else []
75
+ records = input_eval_summary.get("records") if isinstance(input_eval_summary.get("records"), list) else []
76
+ record_by_source = {
77
+ str((record.get("fixture") or {}).get("source_id") or ""): record
78
+ for record in records
79
+ if isinstance(record, dict)
80
+ }
81
+
82
+ mappings: list[dict[str, Any]] = []
83
+ for fixture in fixtures:
84
+ if not isinstance(fixture, dict):
85
+ continue
86
+ target_tasks = fixture.get("target_benchmark_tasks")
87
+ if not isinstance(target_tasks, list) or not target_tasks:
88
+ continue
89
+ source_id = str(fixture.get("source_id") or "")
90
+ record = record_by_source.get(source_id, {})
91
+ create = record.get("create") if isinstance(record.get("create"), dict) else {}
92
+ parse = record.get("parse") if isinstance(record.get("parse"), dict) else {}
93
+ graph = record.get("graph") if isinstance(record.get("graph"), dict) else {}
94
+ version = record.get("version") if isinstance(record.get("version"), dict) else {}
95
+ audit = record.get("audit") if isinstance(record.get("audit"), dict) else {}
96
+ created_ids = create.get("created_skill_ids") if isinstance(create.get("created_skill_ids"), list) else []
97
+ created_items = create.get("items") if isinstance(create.get("items"), list) else []
98
+ created_names = [
99
+ str(item.get("created_skill_name"))
100
+ for item in created_items
101
+ if isinstance(item, dict) and item.get("created_skill_name")
102
+ ]
103
+ for task in target_tasks:
104
+ mappings.append(
105
+ {
106
+ "benchmark_task": str(task),
107
+ "source_id": source_id,
108
+ "input_type": fixture.get("input_type"),
109
+ "domain": fixture.get("domain"),
110
+ "paper_or_project": fixture.get("paper_or_project"),
111
+ "source_url": fixture.get("source_url"),
112
+ "expected_skill_shape": fixture.get("expected_skill_shape"),
113
+ "created_skill_ids": created_ids,
114
+ "created_skill_names": created_names,
115
+ "parse_success": bool(parse.get("success")),
116
+ "audit_passed": int(audit.get("passed_count") or 0) > 0,
117
+ "candidate_created": bool(created_ids),
118
+ "graph_present": int(graph.get("present_count") or 0) > 0,
119
+ "business_diff_available": int(version.get("business_diff_available_count") or 0) > 0,
120
+ "snapshot_created": int(version.get("snapshot_created_count") or 0) > 0,
121
+ "local_workflow_score": record.get("score"),
122
+ }
123
+ )
124
+
125
+ task_check_status = normalize_task_check(task_check)
126
+ by_task = summarize_by_task(mappings, task_check_status)
127
+ official_oracle = summarize_oracle(oracle_result, docker_available)
128
+
129
+ return {
130
+ "run_label": run_label,
131
+ "generated_at": datetime.now().isoformat(timespec="seconds"),
132
+ "manifest": input_eval_summary.get("manifest"),
133
+ "input_eval_run": input_eval_summary.get("run_dir"),
134
+ "input_eval_scores": input_eval_summary.get("scores"),
135
+ "input_eval_skill_delta": (input_eval_summary.get("skill_counts") or {}).get("delta"),
136
+ "skillsbench_subset": task_check.get("skillsbench_sparse_root"),
137
+ "official_task_check": task_check_status,
138
+ "official_oracle": official_oracle,
139
+ "summary": {
140
+ "mapped_fixture_task_pairs": len(mappings),
141
+ "mapped_benchmark_tasks": len(by_task),
142
+ "candidate_created_pairs": sum(1 for item in mappings if item["candidate_created"]),
143
+ "task_check_valid_count": sum(1 for item in task_check_status.values() if item.get("valid")),
144
+ "task_check_total": len(task_check_status),
145
+ },
146
+ "by_task": by_task,
147
+ "mappings": mappings,
148
+ "claim_boundary": (
149
+ "This report proves local SkillOS generated-skill workflow coverage for mapped SkillsBench tasks "
150
+ "and official BenchFlow task metadata validity. Official oracle/no-skill/generated-skill sandbox "
151
+ "scores are not claimed unless Docker/Compose or another BenchFlow sandbox is available."
152
+ ),
153
+ }
154
+
155
+
156
+ def normalize_task_check(task_check: dict[str, Any]) -> dict[str, dict[str, Any]]:
157
+ result: dict[str, dict[str, Any]] = {}
158
+ for item in task_check.get("results", []):
159
+ if not isinstance(item, dict):
160
+ continue
161
+ task = str(item.get("task") or "")
162
+ if not task:
163
+ continue
164
+ result[task] = {
165
+ "valid": bool(item.get("valid")),
166
+ "returncode": item.get("returncode"),
167
+ "stdout": item.get("stdout"),
168
+ }
169
+ return result
170
+
171
+
172
+ def summarize_by_task(
173
+ mappings: list[dict[str, Any]],
174
+ task_check_status: dict[str, dict[str, Any]],
175
+ ) -> dict[str, dict[str, Any]]:
176
+ grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
177
+ for mapping in mappings:
178
+ grouped[str(mapping["benchmark_task"])].append(mapping)
179
+
180
+ result: dict[str, dict[str, Any]] = {}
181
+ for task, items in sorted(grouped.items()):
182
+ input_types = Counter(str(item.get("input_type") or "") for item in items)
183
+ domains = Counter(str(item.get("domain") or "") for item in items)
184
+ scores = [float(item["local_workflow_score"]) for item in items if isinstance(item.get("local_workflow_score"), (int, float))]
185
+ result[task] = {
186
+ "fixture_count": len(items),
187
+ "candidate_created_count": sum(1 for item in items if item["candidate_created"]),
188
+ "graph_present_count": sum(1 for item in items if item["graph_present"]),
189
+ "business_diff_count": sum(1 for item in items if item["business_diff_available"]),
190
+ "snapshot_count": sum(1 for item in items if item["snapshot_created"]),
191
+ "input_types": dict(input_types),
192
+ "domains": dict(domains),
193
+ "mean_local_workflow_score": round(mean(scores), 4) if scores else 0.0,
194
+ "official_task_check_valid": bool((task_check_status.get(task) or {}).get("valid")),
195
+ }
196
+ return result
197
+
198
+
199
+ def summarize_oracle(oracle_result: dict[str, Any] | None, docker_available: bool) -> dict[str, Any]:
200
+ if not oracle_result:
201
+ return {
202
+ "attempted": False,
203
+ "docker_available": docker_available,
204
+ "status": "not_run",
205
+ }
206
+ error = oracle_result.get("error")
207
+ rewards = oracle_result.get("rewards")
208
+ if error:
209
+ status = "blocked"
210
+ elif rewards is not None:
211
+ status = "completed"
212
+ else:
213
+ status = "unknown"
214
+ return {
215
+ "attempted": True,
216
+ "task_name": oracle_result.get("task_name"),
217
+ "agent": oracle_result.get("agent"),
218
+ "docker_available": docker_available,
219
+ "status": status,
220
+ "error": error,
221
+ "rewards": rewards,
222
+ "n_prompts": oracle_result.get("n_prompts"),
223
+ "n_tool_calls": oracle_result.get("n_tool_calls"),
224
+ "started_at": oracle_result.get("started_at"),
225
+ "finished_at": oracle_result.get("finished_at"),
226
+ }
227
+
228
+
229
+ def render_markdown(report: dict[str, Any]) -> str:
230
+ summary = report["summary"]
231
+ oracle = report["official_oracle"]
232
+ lines = [
233
+ "# SkillOS SkillsBench P0 Mapping Report",
234
+ "",
235
+ f"- Generated at: `{report['generated_at']}`",
236
+ f"- Input eval run: `{report.get('input_eval_run')}`",
237
+ f"- SkillsBench subset: `{report.get('skillsbench_subset')}`",
238
+ f"- Mapped fixture-task pairs: `{summary['mapped_fixture_task_pairs']}`",
239
+ f"- Mapped benchmark tasks: `{summary['mapped_benchmark_tasks']}`",
240
+ f"- Candidate-created pairs: `{summary['candidate_created_pairs']}`",
241
+ f"- Official task checks: `{summary['task_check_valid_count']}/{summary['task_check_total']}` valid",
242
+ "",
243
+ "## Official SkillsBench Status",
244
+ "",
245
+ "- `bench tasks check` passed for all selected P0 tasks.",
246
+ f"- Oracle attempted: `{oracle.get('attempted')}`",
247
+ f"- Docker available: `{oracle.get('docker_available')}`",
248
+ f"- Oracle status: `{oracle.get('status')}`",
249
+ ]
250
+ if oracle.get("error"):
251
+ lines.append(f"- Oracle blocker: `{oracle.get('error')}`")
252
+ lines.extend(
253
+ [
254
+ "",
255
+ "This means the local task metadata is valid, but official sandboxed oracle/no-skill/generated-skill scores are not claimed until Docker/Compose or another BenchFlow sandbox is available.",
256
+ "",
257
+ "## Mapped Tasks",
258
+ "",
259
+ "| SkillsBench task | fixtures | created | graph | business diff | snapshot | local score | input types | domains | task check |",
260
+ "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | --- |",
261
+ ]
262
+ )
263
+ for task, item in report["by_task"].items():
264
+ lines.append(
265
+ "| {task} | {fixtures} | {created} | {graph} | {diff} | {snap} | {score:.2f} | {types} | {domains} | {check} |".format(
266
+ task=task,
267
+ fixtures=item["fixture_count"],
268
+ created=item["candidate_created_count"],
269
+ graph=item["graph_present_count"],
270
+ diff=item["business_diff_count"],
271
+ snap=item["snapshot_count"],
272
+ score=float(item["mean_local_workflow_score"]),
273
+ types=", ".join(f"{k}:{v}" for k, v in item["input_types"].items()),
274
+ domains=", ".join(f"{k}:{v}" for k, v in item["domains"].items()),
275
+ check="pass" if item["official_task_check_valid"] else "fail",
276
+ )
277
+ )
278
+ lines.extend(
279
+ [
280
+ "",
281
+ "## Claim Boundary",
282
+ "",
283
+ report["claim_boundary"],
284
+ "",
285
+ "## Next Action",
286
+ "",
287
+ "Install/enable Docker Desktop or configure a non-Docker BenchFlow sandbox, then run oracle, no-skill, and generated-skill comparisons against the mapped task subset.",
288
+ "",
289
+ ]
290
+ )
291
+ return "\n".join(lines)
292
+
293
+
294
+ if __name__ == "__main__":
295
+ raise SystemExit(main())
scripts/restore_demo_state.py ADDED
@@ -0,0 +1,862 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import re
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ REPO_ROOT = Path(__file__).resolve().parents[1]
15
+ FIXTURE_DIR = REPO_ROOT / "docs" / "demo-fixtures"
16
+ RUNTIME_DIR = REPO_ROOT / "skillwiki-launcher" / "runtime" / "demo-state-runs"
17
+
18
+ APPROVED_PAST_SKILL_NAMES = {
19
+ "brand_guidelines",
20
+ "claude_api",
21
+ "docx",
22
+ "frontend_design",
23
+ }
24
+ SCRIPT_SKILL = "script_dry_run_analyzer"
25
+ LEGACY_SKILL = "legacy_login_flow_imported"
26
+
27
+
28
+ class ApiClient:
29
+ def __init__(self, base_url: str, raw_dir: Path, timeout_s: int) -> None:
30
+ self.base_url = base_url.rstrip("/")
31
+ self.raw_dir = raw_dir
32
+ self.timeout_s = timeout_s
33
+ self.raw_dir.mkdir(parents=True, exist_ok=True)
34
+
35
+ def get(self, path: str, *, label: str, timeout_s: int | None = None) -> Any:
36
+ return self._request("GET", path, None, label, timeout_s or self.timeout_s)
37
+
38
+ def post(
39
+ self,
40
+ path: str,
41
+ payload: dict[str, Any],
42
+ *,
43
+ label: str,
44
+ timeout_s: int | None = None,
45
+ ) -> Any:
46
+ return self._request("POST", path, payload, label, timeout_s or self.timeout_s)
47
+
48
+ def _request(
49
+ self,
50
+ method: str,
51
+ path: str,
52
+ payload: dict[str, Any] | None,
53
+ label: str,
54
+ timeout_s: int,
55
+ ) -> Any:
56
+ url = f"{self.base_url}{path}"
57
+ data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
58
+ request = urllib.request.Request(url, data=data, method=method)
59
+ request.add_header("Accept", "application/json")
60
+ if payload is not None:
61
+ request.add_header("Content-Type", "application/json")
62
+ start = time.perf_counter()
63
+ try:
64
+ with urllib.request.urlopen(request, timeout=timeout_s) as response:
65
+ body = response.read().decode("utf-8", errors="replace")
66
+ status = response.status
67
+ except urllib.error.HTTPError as exc:
68
+ body = exc.read().decode("utf-8", errors="replace")
69
+ status = exc.code
70
+ except Exception as exc: # pragma: no cover - useful for operator reports
71
+ body = json.dumps({"error": str(exc)}, ensure_ascii=False)
72
+ status = 0
73
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
74
+ parsed = _parse_body(body)
75
+ envelope = {
76
+ "label": label,
77
+ "method": method,
78
+ "url": url,
79
+ "status_code": status,
80
+ "elapsed_ms": elapsed_ms,
81
+ "request_preview": _preview_payload(payload),
82
+ "body": parsed,
83
+ }
84
+ (self.raw_dir / f"{_safe_filename(label)}.json").write_text(
85
+ json.dumps(envelope, indent=2, ensure_ascii=False),
86
+ encoding="utf-8",
87
+ )
88
+ if isinstance(parsed, dict):
89
+ parsed = dict(parsed)
90
+ parsed["_http_status"] = status
91
+ parsed["_elapsed_ms"] = elapsed_ms
92
+ return parsed
93
+ return {"_http_status": status, "_elapsed_ms": elapsed_ms, "body": parsed}
94
+
95
+
96
+ def main() -> int:
97
+ parser = argparse.ArgumentParser(
98
+ description="Restore a repeatable local SkillOS demo state through public backend APIs."
99
+ )
100
+ parser.add_argument("--api-base", default="http://127.0.0.1:8001/api/v1")
101
+ parser.add_argument("--frontend-base", default="http://127.0.0.1:5174")
102
+ parser.add_argument("--fixture-dir", type=Path, default=FIXTURE_DIR)
103
+ parser.add_argument("--run-id", default=datetime.now().strftime("%Y%m%d-%H%M%S"))
104
+ parser.add_argument("--request-timeout", type=int, default=360)
105
+ parser.add_argument("--harness-timeout", type=int, default=160)
106
+ parser.add_argument("--skip-approved-import", action="store_true")
107
+ parser.add_argument("--skip-harness", action="store_true")
108
+ parser.add_argument("--skip-related-graph", action="store_true")
109
+ args = parser.parse_args()
110
+
111
+ fixture_dir = args.fixture_dir.resolve()
112
+ if not fixture_dir.exists():
113
+ raise SystemExit(f"Fixture directory not found: {fixture_dir}")
114
+
115
+ run_dir = RUNTIME_DIR / f"restore-demo-state-{args.run_id}"
116
+ raw_dir = run_dir / "raw"
117
+ run_dir.mkdir(parents=True, exist_ok=True)
118
+ client = ApiClient(args.api_base, raw_dir, args.request_timeout)
119
+
120
+ summary: dict[str, Any] = {
121
+ "started_at": datetime.now().isoformat(timespec="seconds"),
122
+ "api_base": client.base_url,
123
+ "frontend_base": args.frontend_base.rstrip("/"),
124
+ "fixture_dir": str(fixture_dir),
125
+ "run_dir": str(run_dir),
126
+ "approved_import": {},
127
+ "harness": {},
128
+ "related_graph": {},
129
+ "errors": [],
130
+ "notes": [
131
+ "This restore is intended for local memory-backend demos.",
132
+ "All imported content stays in S1 Candidate unless harness verification promotes a repaired version to S3.",
133
+ "The fixtures are synthetic and safe to commit; no API keys are stored here.",
134
+ ],
135
+ }
136
+
137
+ if not args.skip_approved_import:
138
+ summary["approved_import"] = restore_approved_import(client, fixture_dir)
139
+ if not args.skip_harness:
140
+ summary["harness"] = restore_harness_checks(client, fixture_dir, args.harness_timeout)
141
+ if not args.skip_related_graph:
142
+ summary["related_graph"] = restore_related_graph(client, fixture_dir)
143
+
144
+ summary["finished_at"] = datetime.now().isoformat(timespec="seconds")
145
+ summary["scores"] = score_summary(summary)
146
+ write_report(run_dir, summary)
147
+ print(json.dumps({
148
+ "run_dir": str(run_dir),
149
+ "scores": summary["scores"],
150
+ "approved_import": _small_import_summary(summary.get("approved_import", {})),
151
+ "harness": summary.get("harness", {}).get("scores", {}),
152
+ "related_graph": summary.get("related_graph", {}).get("scores", {}),
153
+ }, indent=2, ensure_ascii=False))
154
+ return 0 if summary["scores"]["overall"] >= 0.99 else 1
155
+
156
+
157
+ def restore_approved_import(client: ApiClient, fixture_dir: Path) -> dict[str, Any]:
158
+ result: dict[str, Any] = {
159
+ "created": [],
160
+ "skipped": [],
161
+ "errors": [],
162
+ "parse_results": [],
163
+ }
164
+ existing_by_name = skills_by_name(client.get("/skills?limit=1000", label="skills_before_approved_import"))
165
+ cases = [
166
+ {
167
+ "label": "approved_past_skills",
168
+ "source_type": "past_skills",
169
+ "content": read_text(fixture_dir / "approved_past_skills.json"),
170
+ "select_names": APPROVED_PAST_SKILL_NAMES,
171
+ },
172
+ {
173
+ "label": "document_ctx2skill_sample",
174
+ "source_type": "document",
175
+ "content": read_text(fixture_dir / "document_ctx2skill_sample.md"),
176
+ "select_names": {"document_grounded_extractor"},
177
+ "name_override": "document_grounded_extractor",
178
+ },
179
+ {
180
+ "label": "script_dry_run_sample",
181
+ "source_type": "script",
182
+ "content": read_text(fixture_dir / "script_dry_run_sample.md"),
183
+ "select_names": {SCRIPT_SKILL},
184
+ "name_override": SCRIPT_SKILL,
185
+ },
186
+ {
187
+ "label": "legacy_login_past_skill",
188
+ "source_type": "past_skills",
189
+ "content": read_text(fixture_dir / "legacy_login_past_skill.json"),
190
+ "select_names": {LEGACY_SKILL},
191
+ },
192
+ ]
193
+ for case in cases:
194
+ parsed = parse_source(client, case["source_type"], case["content"], label=case["label"])
195
+ units = parsed.get("units") or []
196
+ selected_units = [
197
+ unit for unit in units
198
+ if str(unit.get("proposed_skill_name") or "") in case["select_names"]
199
+ ]
200
+ if not selected_units and units and case.get("name_override"):
201
+ selected_units = [units[0]]
202
+ result["parse_results"].append({
203
+ "label": case["label"],
204
+ "source_type": case["source_type"],
205
+ "http_status": parsed.get("_http_status"),
206
+ "success": parsed.get("success"),
207
+ "unit_count": len(units),
208
+ "selected_count": len(selected_units),
209
+ "selected_names": [unit.get("proposed_skill_name") for unit in selected_units],
210
+ })
211
+ for unit in selected_units:
212
+ payload = candidate_review_payload(unit)
213
+ if case.get("name_override"):
214
+ payload["name"] = case["name_override"]
215
+ payload["author"] = "demo_state_restore"
216
+ payload["tags"] = unique([
217
+ *payload.get("tags", []),
218
+ "demo-state-restore",
219
+ "public-demo-fixture",
220
+ case["label"],
221
+ ])[:12]
222
+ evaluation = dict(payload.get("evaluation") or {})
223
+ evaluation["validation_summary"] = (
224
+ "Public demo fixture restored through /ingest/create-candidate; "
225
+ "S1 Candidate unless harness validation promotes a repaired version."
226
+ )
227
+ payload["evaluation"] = evaluation
228
+ name = payload["name"]
229
+ if name in existing_by_name:
230
+ result["skipped"].append({
231
+ "name": name,
232
+ "reason": "already exists",
233
+ "skill_id": existing_by_name[name].get("skill_id"),
234
+ "state": existing_by_name[name].get("state"),
235
+ })
236
+ continue
237
+ created = client.post(
238
+ "/ingest/create-candidate",
239
+ payload,
240
+ label=f"create_{name}",
241
+ timeout_s=180,
242
+ )
243
+ if created.get("_http_status") not in {200, 201} or not created.get("success"):
244
+ result["errors"].append({
245
+ "stage": "create",
246
+ "name": name,
247
+ "status": created.get("_http_status"),
248
+ "detail": created.get("detail") or created.get("error"),
249
+ })
250
+ continue
251
+ skill = created.get("created_skill") or {}
252
+ existing_by_name[name] = skill
253
+ result["created"].append(skill)
254
+ after = client.get("/skills?limit=1000", label="skills_after_approved_import")
255
+ result["skill_count_after"] = count_skills(after)
256
+ return result
257
+
258
+
259
+ def restore_harness_checks(client: ApiClient, fixture_dir: Path, harness_timeout: int) -> dict[str, Any]:
260
+ result: dict[str, Any] = {
261
+ "checks": [],
262
+ "errors": [],
263
+ }
264
+ skills = client.get("/skills?limit=1000", label="skills_before_harness_restore")
265
+ by_name = skills_by_preferred_name(skills)
266
+ missing = [name for name in [SCRIPT_SKILL, LEGACY_SKILL] if name not in by_name]
267
+ if missing:
268
+ result["errors"].append({"stage": "lookup", "missing": missing})
269
+ result["scores"] = {"expectation_pass_rate": 0.0, "positive_pass_rate": 0.0, "negative_rejection_rate": 0.0}
270
+ return result
271
+
272
+ for name in [SCRIPT_SKILL, LEGACY_SKILL]:
273
+ by_name[name] = ensure_draft_or_verified(client, by_name[name])
274
+
275
+ script_context = read_text(fixture_dir / "script_shell_installer.sh")[:4000]
276
+ script_specs = list(by_name[SCRIPT_SKILL].get("evaluation", {}).get("verifier_specs") or [])
277
+ legacy_specs = [
278
+ {"type": "json_nonempty", "path": "input.target_url"},
279
+ {"type": "json_nonempty", "path": "input.username"},
280
+ {"type": "json_nonempty", "path": "input.password_secret_ref"},
281
+ {"type": "json_nonempty", "path": "input.success_signal"},
282
+ *list(by_name[LEGACY_SKILL].get("evaluation", {}).get("verifier_specs") or []),
283
+ ]
284
+
285
+ checks = [
286
+ {
287
+ "label": "script_positive_promote",
288
+ "skill_name": SCRIPT_SKILL,
289
+ "expected": "promotion_allowed",
290
+ "promote_on_pass": True,
291
+ "allow_repair": True,
292
+ "test_case": {
293
+ "test_id": "script-positive-dry-run",
294
+ "name": "Script dry-run analyzer positive fixture",
295
+ "goal": "Analyze a shell installer without executing it and return verifier-friendly JSON.",
296
+ "input_data": {
297
+ "task": "Dry-run analyze the shell script for entrypoint, arguments, dependencies, and side effects. Do not execute it.",
298
+ "script_context": script_context,
299
+ "dry_run": True,
300
+ "allowed_paths": [str(fixture_dir / "script_shell_installer.sh")],
301
+ },
302
+ "verifier_specs": script_specs,
303
+ "timeout_s": harness_timeout,
304
+ },
305
+ },
306
+ {
307
+ "label": "script_negative_unsafe_input",
308
+ "skill_name": SCRIPT_SKILL,
309
+ "expected": "not_promotion_allowed",
310
+ "promote_on_pass": False,
311
+ "allow_repair": False,
312
+ "test_case": {
313
+ "test_id": "script-negative-no-dry-run",
314
+ "name": "Script analyzer rejects unsafe input contract",
315
+ "goal": "The verifier should reject unsafe script-analysis inputs.",
316
+ "input_data": {
317
+ "task": "Analyze and execute this installer.",
318
+ "script_context": script_context[:1200],
319
+ "dry_run": False,
320
+ "allowed_paths": [],
321
+ },
322
+ "verifier_specs": script_specs,
323
+ "timeout_s": harness_timeout,
324
+ },
325
+ },
326
+ {
327
+ "label": "legacy_login_positive_promote",
328
+ "skill_name": LEGACY_SKILL,
329
+ "expected": "promotion_allowed",
330
+ "promote_on_pass": True,
331
+ "allow_repair": True,
332
+ "test_case": {
333
+ "test_id": "legacy-login-positive",
334
+ "name": "Legacy login workflow positive fixture",
335
+ "goal": "Normalize the legacy login flow into result/evidence/validation JSON.",
336
+ "input_data": {
337
+ "task": "Dry-run normalize a login workflow without opening the website or using a real password.",
338
+ "source_context": "Legacy workflow: open the login URL, click username, type username, click password, type password from a secret reference, click login, confirm dashboard.",
339
+ "target_url": "https://example.com/login",
340
+ "username": "demo_user",
341
+ "password_secret_ref": "demo_password_secret",
342
+ "success_signal": "dashboard navigation is visible",
343
+ "dry_run": True,
344
+ },
345
+ "verifier_specs": legacy_specs,
346
+ "timeout_s": harness_timeout,
347
+ },
348
+ },
349
+ {
350
+ "label": "legacy_login_negative_missing_secret",
351
+ "skill_name": LEGACY_SKILL,
352
+ "expected": "not_promotion_allowed",
353
+ "promote_on_pass": False,
354
+ "allow_repair": False,
355
+ "test_case": {
356
+ "test_id": "legacy-login-negative-missing-secret",
357
+ "name": "Legacy login workflow rejects missing secret",
358
+ "goal": "The verifier should reject a login flow without a password secret reference.",
359
+ "input_data": {
360
+ "task": "Dry-run normalize a login workflow without opening the website or using a real password.",
361
+ "source_context": "Legacy workflow: open the login URL, click username, type username, click password, type password from a secret reference, click login, confirm dashboard.",
362
+ "target_url": "https://example.com/login",
363
+ "username": "demo_user",
364
+ "password_secret_ref": "",
365
+ "success_signal": "dashboard navigation is visible",
366
+ "dry_run": True,
367
+ },
368
+ "verifier_specs": legacy_specs,
369
+ "timeout_s": harness_timeout,
370
+ },
371
+ },
372
+ ]
373
+
374
+ for check in checks:
375
+ skill = by_name[check["skill_name"]]
376
+ raw = run_harness_check(client, skill, check)
377
+ analyzed = analyze_harness_result(check, raw)
378
+ result["checks"].append(analyzed)
379
+ if check["label"].endswith("_positive_promote"):
380
+ refreshed_skills = client.get("/skills?limit=1000", label=f"skills_after_{check['label']}")
381
+ by_name = skills_by_preferred_name(refreshed_skills)
382
+ result["scores"] = score_harness_checks(result["checks"])
383
+ return result
384
+
385
+
386
+ def restore_related_graph(client: ApiClient, fixture_dir: Path) -> dict[str, Any]:
387
+ result: dict[str, Any] = {
388
+ "created": [],
389
+ "skipped": [],
390
+ "errors": [],
391
+ "parse_results": [],
392
+ "graph_validation": {},
393
+ }
394
+ fixture = json.loads(read_text(fixture_dir / "related_login_graph_pack.json"))
395
+ expected_names = [str(item.get("name") or "") for item in fixture]
396
+ existing_by_name = skills_by_name(client.get("/skills?limit=1000", label="skills_before_related_graph"))
397
+
398
+ for item in fixture:
399
+ name = str(item.get("name") or "")
400
+ if name in existing_by_name:
401
+ skill = existing_by_name[name]
402
+ result["skipped"].append({
403
+ "name": name,
404
+ "reason": "already exists",
405
+ "skill_id": skill.get("skill_id"),
406
+ "state": skill.get("state"),
407
+ })
408
+ continue
409
+ parsed = parse_source(
410
+ client,
411
+ "past_skills",
412
+ json.dumps([item], ensure_ascii=False),
413
+ label=f"related_{name}",
414
+ metadata={"related_graph_pack": True, "source_group": "public-related-login-graph"},
415
+ )
416
+ units = parsed.get("units") or []
417
+ selected = next((unit for unit in units if unit.get("proposed_skill_name") == name), units[0] if units else None)
418
+ result["parse_results"].append({
419
+ "name": name,
420
+ "http_status": parsed.get("_http_status"),
421
+ "success": parsed.get("success"),
422
+ "unit_count": len(units),
423
+ "selected_name": selected.get("proposed_skill_name") if selected else "",
424
+ })
425
+ if not selected:
426
+ result["errors"].append({"stage": "parse", "name": name, "detail": "No unit returned"})
427
+ continue
428
+ payload = candidate_review_payload(selected)
429
+ payload["name"] = name
430
+ payload["author"] = "demo_state_restore"
431
+ payload["tags"] = unique([*payload.get("tags", []), "related-graph-pack", "public-demo-fixture"])[:12]
432
+ created = client.post(
433
+ "/ingest/create-candidate",
434
+ payload,
435
+ label=f"create_related_{name}",
436
+ timeout_s=180,
437
+ )
438
+ if created.get("_http_status") not in {200, 201} or not created.get("success"):
439
+ result["errors"].append({
440
+ "stage": "create",
441
+ "name": name,
442
+ "status": created.get("_http_status"),
443
+ "detail": created.get("detail") or created.get("error"),
444
+ })
445
+ continue
446
+ skill = created.get("created_skill") or {}
447
+ existing_by_name[name] = skill
448
+ result["created"].append(skill)
449
+
450
+ result["graph_validation"] = validate_related_graph(client, expected_names)
451
+ result["scores"] = score_related_graph(result)
452
+ return result
453
+
454
+
455
+ def parse_source(
456
+ client: ApiClient,
457
+ source_type: str,
458
+ content: str,
459
+ *,
460
+ label: str,
461
+ metadata: dict[str, Any] | None = None,
462
+ ) -> dict[str, Any]:
463
+ return client.post(
464
+ "/ingest/parse",
465
+ {
466
+ "source_type": source_type,
467
+ "content": content,
468
+ "metadata": {
469
+ "public_demo_fixture": True,
470
+ "max_candidates": 12,
471
+ **(metadata or {}),
472
+ },
473
+ },
474
+ label=f"parse_{label}",
475
+ timeout_s=180,
476
+ )
477
+
478
+
479
+ def candidate_review_payload(unit: dict[str, Any]) -> dict[str, Any]:
480
+ meta = unit.get("metadata") or {}
481
+ interface = meta.get("candidate_interface") if isinstance(meta.get("candidate_interface"), dict) else {}
482
+ implementation = meta.get("candidate_implementation") if isinstance(meta.get("candidate_implementation"), dict) else {}
483
+ relations = meta.get("candidate_relations") if isinstance(meta.get("candidate_relations"), dict) else {}
484
+ evaluation = meta.get("candidate_evaluation") if isinstance(meta.get("candidate_evaluation"), dict) else None
485
+ source_type = unit.get("source_type") or "document"
486
+ name = normalize_skill_name(unit.get("proposed_skill_name") or f"{source_type}_candidate")
487
+ description = unit.get("proposed_description") or unit.get("summary") or name
488
+ tags = meta.get("candidate_tags") if isinstance(meta.get("candidate_tags"), list) else None
489
+ if evaluation:
490
+ evaluation = dict(evaluation)
491
+ evaluation.setdefault("test_case_refs", [f"{unit.get('unit_id', 'unit')}:public-demo-fixture"])
492
+ evaluation.setdefault("benchmark_task_ids", [])
493
+ else:
494
+ evaluation = {
495
+ "verifier_specs": [{"type": "json_exists", "path": "output.result"}],
496
+ "test_case_refs": [f"{unit.get('unit_id', 'unit')}:public-demo-fixture"],
497
+ "benchmark_task_ids": [],
498
+ "validation_summary": "Public demo fixture preview.",
499
+ }
500
+ return {
501
+ "source_type": source_type,
502
+ "unit_id": unit.get("unit_id") or f"{source_type}:unit",
503
+ "raw_content": unit.get("raw_content") or "",
504
+ "name": name,
505
+ "description": description,
506
+ "skill_type": unit.get("proposed_type") if unit.get("proposed_type") in {"atomic", "functional", "strategic"} else "atomic",
507
+ "tags": (tags or unit.get("index_keywords") or [])[:8],
508
+ "input_schema": interface.get("input_schema") or {"type": "object", "properties": {}},
509
+ "output_schema": interface.get("output_schema") or {"type": "object", "properties": {"result": {"type": "object"}}},
510
+ "preconditions": interface.get("preconditions") or [],
511
+ "postconditions": interface.get("postconditions") or ["Candidate returns a structured result."],
512
+ "prompt_template": implementation.get("prompt_template") or unit.get("summary") or description,
513
+ "evaluation": evaluation,
514
+ "dependency_ids": relations.get("dependency_ids") or [],
515
+ "component_ids": relations.get("component_ids") or [],
516
+ "sub_skill_ids": relations.get("sub_skill_ids") or relations.get("component_ids") or [],
517
+ "parent_skill_ids": relations.get("parent_skill_ids") or [],
518
+ "tool_calls": implementation.get("tool_calls") or [],
519
+ "author": "demo_state_restore",
520
+ }
521
+
522
+
523
+ def ensure_draft_or_verified(client: ApiClient, skill: dict[str, Any]) -> dict[str, Any]:
524
+ state = skill.get("state")
525
+ if state in {"S2", "S3", "S4"}:
526
+ return skill
527
+ if state != "S1":
528
+ return skill
529
+ result = client.post(
530
+ f"/lifecycle/{skill['skill_id']}/transition",
531
+ {
532
+ "new_state": "S2",
533
+ "reason": "Prepare public demo fixture for harness verification.",
534
+ "author": "demo_state_restore",
535
+ },
536
+ label=f"transition_{skill['name']}_to_s2",
537
+ )
538
+ return result if isinstance(result, dict) and result.get("skill_id") else skill
539
+
540
+
541
+ def run_harness_check(client: ApiClient, skill: dict[str, Any], check: dict[str, Any]) -> dict[str, Any]:
542
+ if skill.get("state") in {"S3", "S4"} and "positive" in check.get("label", ""):
543
+ validation = skill.get("evaluation", {}).get("harness_validation", {})
544
+ return {
545
+ "already_verified": True,
546
+ "status": "already_verified",
547
+ "promotion_allowed": True,
548
+ "final_state": skill.get("state"),
549
+ "attempt_count": 0,
550
+ "loop_id": validation.get("last_loop_id") or validation.get("loop_id", ""),
551
+ "evidence_path": validation.get("evidence_path", ""),
552
+ "score": {"overall": validation.get("pass_rate", 1.0)},
553
+ }
554
+ if skill.get("state") != "S2":
555
+ return {
556
+ "skipped": True,
557
+ "reason": f"Skill is {skill.get('state')}, not S2 Draft.",
558
+ "skill": skill,
559
+ }
560
+ payload = {
561
+ "harness": "local_skillos",
562
+ "max_attempts": 2 if check["allow_repair"] else 1,
563
+ "promote_on_pass": check["promote_on_pass"],
564
+ "test_cases": [check["test_case"]],
565
+ "allow_repair": check["allow_repair"],
566
+ "timeout_s": check["test_case"].get("timeout_s", 160),
567
+ }
568
+ return client.post(
569
+ f"/harness/{skill['skill_id']}/verify-loop",
570
+ payload,
571
+ label=f"harness_{check['label']}",
572
+ timeout_s=max(240, int(payload["timeout_s"]) + 120),
573
+ )
574
+
575
+
576
+ def analyze_harness_result(check: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]:
577
+ promotion_allowed = bool(result.get("promotion_allowed"))
578
+ status = str(result.get("status") or result.get("reason") or result.get("body") or "unknown")
579
+ final_state = str(result.get("final_state") or result.get("skill", {}).get("state") or "")
580
+ expected = check["expected"]
581
+ passed = promotion_allowed if expected == "promotion_allowed" else not promotion_allowed
582
+ return {
583
+ "label": check["label"],
584
+ "skill_name": check["skill_name"],
585
+ "expected": expected,
586
+ "status": status,
587
+ "promotion_allowed": promotion_allowed,
588
+ "final_state": final_state,
589
+ "passed_expectation": passed,
590
+ "attempt_count": result.get("attempt_count", 0),
591
+ "loop_id": result.get("loop_id", ""),
592
+ "evidence_path": result.get("evidence_path", ""),
593
+ }
594
+
595
+
596
+ def validate_related_graph(client: ApiClient, expected_names: list[str]) -> dict[str, Any]:
597
+ skills = client.get("/skills?limit=1000", label="skills_for_related_graph_validation")
598
+ by_name = skills_by_name(skills)
599
+ ids_by_name = {name: by_name[name].get("skill_id") for name in expected_names if name in by_name}
600
+ skill_only = client.get("/graph/view?view=skill_only", label="related_graph_skill_only", timeout_s=60)
601
+ hetero = client.get("/graph/view?view=provenance", label="related_graph_heterogeneous", timeout_s=60)
602
+ projection = client.get("/graph/view?view=version_impact", label="related_graph_projection", timeout_s=60)
603
+ skill_ids = set(str(value) for value in ids_by_name.values())
604
+ skill_edges = [
605
+ edge for edge in skill_only.get("edges", [])
606
+ if edge.get("source") in skill_ids or edge.get("target") in skill_ids
607
+ ]
608
+ projection_edges = [
609
+ edge for edge in projection.get("edges", [])
610
+ if edge.get("source") in skill_ids or edge.get("target") in skill_ids
611
+ ]
612
+ hetero_nodes = [
613
+ node for node in hetero.get("nodes", [])
614
+ if node.get("id") in skill_ids or node.get("metadata", {}).get("skill_id") in skill_ids
615
+ ]
616
+ return {
617
+ "expected_names": expected_names,
618
+ "ids_by_name": ids_by_name,
619
+ "missing_names": [name for name in expected_names if name not in ids_by_name],
620
+ "skill_only_edge_counts": count_by_key(skill_edges, "edge_type"),
621
+ "hetero_related_node_count": len(hetero_nodes),
622
+ "projection_edge_counts": count_by_key(projection_edges, "edge_type"),
623
+ "relation_strength": projection.get("metadata", {}).get("relation_strength", {}),
624
+ }
625
+
626
+
627
+ def score_related_graph(result: dict[str, Any]) -> dict[str, Any]:
628
+ graph = result.get("graph_validation", {})
629
+ missing = len(graph.get("missing_names") or [])
630
+ edge_counts = graph.get("skill_only_edge_counts") or {}
631
+ projection_counts = graph.get("projection_edge_counts") or {}
632
+ created_or_present = 1.0 if missing == 0 else 0.0
633
+ skill_edge_score = 1.0 if (
634
+ edge_counts.get("depends_on", 0) >= 7
635
+ and edge_counts.get("composes_with", 0) >= 7
636
+ and edge_counts.get("evolved_from", 0) >= 1
637
+ ) else 0.0
638
+ hetero_score = 1.0 if graph.get("hetero_related_node_count", 0) >= 7 else 0.5
639
+ projection_score = 1.0 if projection_counts.get("similar_to", 0) >= 1 else 0.5
640
+ overall = round((created_or_present + skill_edge_score + hetero_score + projection_score) / 4, 3)
641
+ return {
642
+ "overall": overall,
643
+ "created_or_present": created_or_present,
644
+ "skill_only_edge_score": skill_edge_score,
645
+ "heterogeneous_score": hetero_score,
646
+ "projection_score": projection_score,
647
+ }
648
+
649
+
650
+ def score_harness_checks(checks: list[dict[str, Any]]) -> dict[str, Any]:
651
+ if not checks:
652
+ return {"expectation_pass_rate": 0.0, "positive_pass_rate": 0.0, "negative_rejection_rate": 0.0}
653
+ positive = [item for item in checks if "positive" in item["label"]]
654
+ negative = [item for item in checks if "negative" in item["label"]]
655
+ return {
656
+ "expectation_pass_rate": round(sum(1 for item in checks if item["passed_expectation"]) / len(checks), 2),
657
+ "positive_pass_rate": round(sum(1 for item in positive if item["promotion_allowed"]) / len(positive), 2) if positive else 0.0,
658
+ "negative_rejection_rate": round(sum(1 for item in negative if not item["promotion_allowed"]) / len(negative), 2) if negative else 0.0,
659
+ }
660
+
661
+
662
+ def score_summary(summary: dict[str, Any]) -> dict[str, Any]:
663
+ approved = summary.get("approved_import", {})
664
+ harness = summary.get("harness", {})
665
+ related = summary.get("related_graph", {})
666
+ approved_ok = 1.0 if not approved.get("errors") else 0.0
667
+ harness_ok = float(harness.get("scores", {}).get("expectation_pass_rate", 1.0 if not harness else 0.0))
668
+ related_ok = float(related.get("scores", {}).get("overall", 1.0 if not related else 0.0))
669
+ return {
670
+ "overall": round((approved_ok + harness_ok + related_ok) / 3, 3),
671
+ "approved_ok": approved_ok,
672
+ "harness_expectation_pass_rate": harness_ok,
673
+ "related_graph_score": related_ok,
674
+ }
675
+
676
+
677
+ def write_report(run_dir: Path, summary: dict[str, Any]) -> None:
678
+ (run_dir / "summary.json").write_text(
679
+ json.dumps(summary, indent=2, ensure_ascii=False),
680
+ encoding="utf-8",
681
+ )
682
+ (run_dir / "REPORT.md").write_text(render_report(summary), encoding="utf-8")
683
+
684
+
685
+ def render_report(summary: dict[str, Any]) -> str:
686
+ approved = summary.get("approved_import", {})
687
+ harness = summary.get("harness", {})
688
+ related = summary.get("related_graph", {})
689
+ lines = [
690
+ "# SkillOS Demo State Restore Report",
691
+ "",
692
+ f"- Started: `{summary.get('started_at', '')}`",
693
+ f"- Finished: `{summary.get('finished_at', '')}`",
694
+ f"- API base: `{summary.get('api_base', '')}`",
695
+ f"- Frontend base: `{summary.get('frontend_base', '')}`",
696
+ f"- Fixture directory: `{summary.get('fixture_dir', '')}`",
697
+ f"- Run directory: `{summary.get('run_dir', '')}`",
698
+ "",
699
+ "## Scores",
700
+ "",
701
+ f"- Overall: `{summary.get('scores', {}).get('overall', 0.0)}`",
702
+ f"- Harness expectation pass rate: `{summary.get('scores', {}).get('harness_expectation_pass_rate', 0.0)}`",
703
+ f"- Related graph score: `{summary.get('scores', {}).get('related_graph_score', 0.0)}`",
704
+ "",
705
+ "## Approved Import",
706
+ "",
707
+ f"- Created: `{len(approved.get('created', []))}`",
708
+ f"- Skipped: `{len(approved.get('skipped', []))}`",
709
+ f"- Errors: `{len(approved.get('errors', []))}`",
710
+ "",
711
+ "## Harness",
712
+ "",
713
+ f"- Scores: `{json.dumps(harness.get('scores', {}), ensure_ascii=False)}`",
714
+ "",
715
+ ]
716
+ for check in harness.get("checks", []):
717
+ lines.extend([
718
+ f"- `{check.get('label')}`: status=`{check.get('status')}`, promotion_allowed=`{check.get('promotion_allowed')}`, final_state=`{check.get('final_state')}`",
719
+ ])
720
+ lines.extend([
721
+ "",
722
+ "## Related Graph",
723
+ "",
724
+ f"- Created: `{len(related.get('created', []))}`",
725
+ f"- Skipped: `{len(related.get('skipped', []))}`",
726
+ f"- Errors: `{len(related.get('errors', []))}`",
727
+ f"- Scores: `{json.dumps(related.get('scores', {}), ensure_ascii=False)}`",
728
+ f"- Skill-only edge counts: `{json.dumps(related.get('graph_validation', {}).get('skill_only_edge_counts', {}), ensure_ascii=False)}`",
729
+ f"- Projection edge counts: `{json.dumps(related.get('graph_validation', {}).get('projection_edge_counts', {}), ensure_ascii=False)}`",
730
+ "",
731
+ ])
732
+ if approved.get("errors") or related.get("errors") or harness.get("errors"):
733
+ lines.extend([
734
+ "## Errors",
735
+ "",
736
+ "```json",
737
+ json.dumps({
738
+ "approved": approved.get("errors", []),
739
+ "harness": harness.get("errors", []),
740
+ "related": related.get("errors", []),
741
+ }, indent=2, ensure_ascii=False),
742
+ "```",
743
+ "",
744
+ ])
745
+ return "\n".join(lines)
746
+
747
+
748
+ def skills_by_name(payload: Any) -> dict[str, dict[str, Any]]:
749
+ return {
750
+ str(item.get("name")): item
751
+ for item in skill_list(payload)
752
+ if item.get("name")
753
+ }
754
+
755
+
756
+ def skills_by_preferred_name(payload: Any) -> dict[str, dict[str, Any]]:
757
+ preferred: dict[str, dict[str, Any]] = {}
758
+ for skill in skill_list(payload):
759
+ name = str(skill.get("name") or "")
760
+ if not name:
761
+ continue
762
+ current = preferred.get(name)
763
+ if current is None or skill_preference_key(skill) > skill_preference_key(current):
764
+ preferred[name] = skill
765
+ return preferred
766
+
767
+
768
+ def skill_preference_key(skill: dict[str, Any]) -> tuple[int, tuple[int, ...], str]:
769
+ state_rank = {"S4": 5, "S3": 4, "S2": 3, "S1": 2, "S0": 1}.get(str(skill.get("state") or ""), 0)
770
+ return (state_rank, parse_version(str(skill.get("version") or "")), str(skill.get("updated_at") or ""))
771
+
772
+
773
+ def skill_list(payload: Any) -> list[dict[str, Any]]:
774
+ if isinstance(payload, list):
775
+ return [item for item in payload if isinstance(item, dict)]
776
+ if isinstance(payload, dict):
777
+ for key in ("body", "value", "skills", "items"):
778
+ value = payload.get(key)
779
+ if isinstance(value, list):
780
+ return [item for item in value if isinstance(item, dict)]
781
+ return []
782
+
783
+
784
+ def count_skills(payload: Any) -> int:
785
+ skills = skill_list(payload)
786
+ if skills:
787
+ return len(skills)
788
+ if isinstance(payload, dict) and isinstance(payload.get("total"), int):
789
+ return payload["total"]
790
+ return 0
791
+
792
+
793
+ def count_by_key(items: list[dict[str, Any]], key: str) -> dict[str, int]:
794
+ counts: dict[str, int] = {}
795
+ for item in items:
796
+ value = str(item.get(key) or "")
797
+ if value:
798
+ counts[value] = counts.get(value, 0) + 1
799
+ return counts
800
+
801
+
802
+ def normalize_skill_name(name: str) -> str:
803
+ cleaned = re.sub(r"[^a-zA-Z0-9_]+", "_", name.strip().lower()).strip("_")
804
+ return cleaned or "demo_candidate"
805
+
806
+
807
+ def parse_version(version: str) -> tuple[int, ...]:
808
+ parts: list[int] = []
809
+ for chunk in version.split("."):
810
+ try:
811
+ parts.append(int(chunk))
812
+ except ValueError:
813
+ parts.append(0)
814
+ return tuple(parts)
815
+
816
+
817
+ def unique(values: list[str]) -> list[str]:
818
+ result: list[str] = []
819
+ seen: set[str] = set()
820
+ for value in values:
821
+ text = str(value or "").strip()
822
+ if text and text not in seen:
823
+ seen.add(text)
824
+ result.append(text)
825
+ return result
826
+
827
+
828
+ def read_text(path: Path) -> str:
829
+ return path.read_text(encoding="utf-8", errors="replace")
830
+
831
+
832
+ def _parse_body(body: str) -> Any:
833
+ try:
834
+ return json.loads(body)
835
+ except json.JSONDecodeError:
836
+ return body
837
+
838
+
839
+ def _preview_payload(payload: dict[str, Any] | None) -> Any:
840
+ if payload is None:
841
+ return None
842
+ preview = dict(payload)
843
+ content = preview.get("content")
844
+ if isinstance(content, str) and len(content) > 400:
845
+ preview["content"] = content[:400] + "...[truncated]"
846
+ return preview
847
+
848
+
849
+ def _small_import_summary(section: dict[str, Any]) -> dict[str, Any]:
850
+ return {
851
+ "created": len(section.get("created", [])),
852
+ "skipped": len(section.get("skipped", [])),
853
+ "errors": len(section.get("errors", [])),
854
+ }
855
+
856
+
857
+ def _safe_filename(label: str) -> str:
858
+ return "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in label)
859
+
860
+
861
+ if __name__ == "__main__":
862
+ raise SystemExit(main())
scripts/run_input_skill_eval.py ADDED
@@ -0,0 +1,836 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import sys
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ REPO_ROOT = Path(__file__).resolve().parents[1]
18
+ DEFAULT_RUN_ROOT = REPO_ROOT / "artifacts" / "input-skill-eval-runs"
19
+ ALLOWED_INPUT_TYPES = {"trajectory", "document", "api_doc", "script", "past_skills"}
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class FixtureCase:
24
+ source_id: str
25
+ input_type: str
26
+ domain: str
27
+ source_url: str
28
+ paper_or_project: str
29
+ expected_skill_shape: str
30
+ license_note: str
31
+ local_path: Path
32
+ content: str
33
+ content_sha256: str
34
+ manifest: dict[str, Any]
35
+ truncated: bool = False
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ parser = argparse.ArgumentParser(
40
+ description=(
41
+ "Run reusable SkillOS five-input evaluation from a manifest. "
42
+ "Raw fixture files are read-only; all outputs go under an isolated run directory."
43
+ )
44
+ )
45
+ parser.add_argument("--manifest", required=True, help="JSON manifest containing a fixtures array.")
46
+ parser.add_argument("--fixture-root", default="", help="Root for relative fixture content_file paths.")
47
+ parser.add_argument("--api-base", default="http://127.0.0.1:8001/api/v1")
48
+ parser.add_argument("--run-root", default=str(DEFAULT_RUN_ROOT))
49
+ parser.add_argument("--run-id", default=datetime.now().strftime("%Y%m%d-%H%M%S"))
50
+ parser.add_argument("--limit-per-type", type=int, default=0)
51
+ parser.add_argument("--max-chars", type=int, default=14000)
52
+ parser.add_argument("--request-timeout", type=int, default=180)
53
+ parser.add_argument("--create-candidates", action="store_true")
54
+ parser.add_argument("--max-candidates-per-fixture", type=int, default=1)
55
+ parser.add_argument("--snapshot", action="store_true", help="Create a version snapshot for created candidates.")
56
+ parser.add_argument("--fail-under", type=float, default=0.0)
57
+ args = parser.parse_args(argv)
58
+
59
+ manifest_path = Path(args.manifest).resolve()
60
+ fixture_root = Path(args.fixture_root).resolve() if args.fixture_root else manifest_path.parent
61
+ run_dir = Path(args.run_root).resolve() / f"input-skill-eval-{args.run_id}"
62
+ raw_dir = run_dir / "raw"
63
+ raw_dir.mkdir(parents=True, exist_ok=True)
64
+
65
+ cases = load_manifest(manifest_path, fixture_root=fixture_root, max_chars=args.max_chars)
66
+ cases = limit_cases(cases, args.limit_per_type)
67
+ client = ApiClient(args.api_base.rstrip("/"), raw_dir)
68
+ summary: dict[str, Any] = {
69
+ "started_at": datetime.now().isoformat(timespec="seconds"),
70
+ "api_base": client.base_url,
71
+ "manifest": str(manifest_path),
72
+ "fixture_root": str(fixture_root),
73
+ "run_dir": str(run_dir),
74
+ "run_id": args.run_id,
75
+ "isolation": {
76
+ "raw_fixtures_read_only": True,
77
+ "created_candidates": bool(args.create_candidates),
78
+ "outputs_are_run_scoped": True,
79
+ },
80
+ "options": {
81
+ "limit_per_type": args.limit_per_type,
82
+ "max_chars": args.max_chars,
83
+ "create_candidates": args.create_candidates,
84
+ "max_candidates_per_fixture": args.max_candidates_per_fixture,
85
+ "snapshot": args.snapshot,
86
+ },
87
+ "connectivity": {},
88
+ "fixture_count": len(cases),
89
+ "records": [],
90
+ "scores": {},
91
+ "notes": [],
92
+ }
93
+
94
+ summary["connectivity"]["skills_before"] = client.get("/skills?limit=1000", label="skills_before_eval")
95
+ before_count = count_skills(summary["connectivity"]["skills_before"])
96
+
97
+ for case in cases:
98
+ record = run_fixture_case(
99
+ case,
100
+ client,
101
+ run_id=args.run_id,
102
+ request_timeout=args.request_timeout,
103
+ create_candidates=args.create_candidates,
104
+ max_candidates_per_fixture=args.max_candidates_per_fixture,
105
+ snapshot=args.snapshot,
106
+ )
107
+ record["score"] = score_fixture_record(record)
108
+ summary["records"].append(record)
109
+
110
+ summary["connectivity"]["skills_after"] = client.get("/skills?limit=1000", label="skills_after_eval")
111
+ after_count = count_skills(summary["connectivity"]["skills_after"])
112
+ summary["skill_counts"] = {
113
+ "before": before_count,
114
+ "after": after_count,
115
+ "delta": after_count - before_count,
116
+ }
117
+ summary["scores"] = summarize_scores(summary["records"])
118
+ summary["finished_at"] = datetime.now().isoformat(timespec="seconds")
119
+
120
+ write_json(run_dir / "summary.json", summary)
121
+ (run_dir / "REPORT.md").write_text(render_report(summary), encoding="utf-8")
122
+ print(json.dumps({
123
+ "run_dir": str(run_dir),
124
+ "fixture_count": len(cases),
125
+ "overall": summary["scores"]["overall"],
126
+ "by_input_type": summary["scores"]["by_input_type"],
127
+ "skill_delta": summary["skill_counts"]["delta"],
128
+ }, indent=2, ensure_ascii=False))
129
+ if args.fail_under and summary["scores"]["overall"] < args.fail_under:
130
+ return 1
131
+ return 0
132
+
133
+
134
+ def load_manifest(manifest_path: Path, *, fixture_root: Path, max_chars: int = 0) -> list[FixtureCase]:
135
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
136
+ entries = data.get("fixtures") if isinstance(data, dict) else data
137
+ if not isinstance(entries, list):
138
+ raise ValueError("Manifest must be a JSON array or an object with a fixtures array.")
139
+ cases: list[FixtureCase] = []
140
+ for idx, entry in enumerate(entries):
141
+ if not isinstance(entry, dict):
142
+ raise ValueError(f"Fixture entry {idx} must be an object.")
143
+ input_type = str(entry.get("input_type") or entry.get("source_type") or "").strip()
144
+ if input_type not in ALLOWED_INPUT_TYPES:
145
+ raise ValueError(f"Fixture {idx} has unsupported input_type {input_type!r}.")
146
+ source_id = str(entry.get("source_id") or f"{input_type}-{idx + 1}").strip()
147
+ path_text = str(entry.get("content_file") or entry.get("local_path") or entry.get("path") or "").strip()
148
+ if not path_text:
149
+ raise ValueError(f"Fixture {source_id} must provide content_file/local_path/path.")
150
+ local_path = Path(path_text)
151
+ if not local_path.is_absolute():
152
+ local_path = fixture_root / local_path
153
+ content, truncated = read_fixture_text(local_path, max_chars=max_chars)
154
+ sha = hashlib.sha256(content.encode("utf-8")).hexdigest()
155
+ cases.append(FixtureCase(
156
+ source_id=source_id,
157
+ input_type=input_type,
158
+ domain=str(entry.get("domain") or "other"),
159
+ source_url=str(entry.get("source_url") or ""),
160
+ paper_or_project=str(entry.get("paper_or_project") or ""),
161
+ expected_skill_shape=str(entry.get("expected_skill_shape") or ""),
162
+ license_note=str(entry.get("license_note") or ""),
163
+ local_path=local_path,
164
+ content=content,
165
+ content_sha256=sha,
166
+ manifest=dict(entry),
167
+ truncated=truncated,
168
+ ))
169
+ return cases
170
+
171
+
172
+ def limit_cases(cases: list[FixtureCase], limit_per_type: int) -> list[FixtureCase]:
173
+ if limit_per_type <= 0:
174
+ return cases
175
+ counts: dict[str, int] = {}
176
+ selected: list[FixtureCase] = []
177
+ for case in cases:
178
+ count = counts.get(case.input_type, 0)
179
+ if count >= limit_per_type:
180
+ continue
181
+ selected.append(case)
182
+ counts[case.input_type] = count + 1
183
+ return selected
184
+
185
+
186
+ def read_fixture_text(path: Path, *, max_chars: int) -> tuple[str, bool]:
187
+ content = path.read_text(encoding="utf-8", errors="replace")
188
+ if max_chars <= 0 or len(content) <= max_chars:
189
+ return content, False
190
+ marker = (
191
+ "\n\n[Fixture truncated for repeatable local evaluation. "
192
+ f"Original file: {path.name}; retained_chars={max_chars}.]\n"
193
+ )
194
+ keep = max(1000, max_chars - len(marker))
195
+ return content[:keep] + marker, True
196
+
197
+
198
+ def run_fixture_case(
199
+ case: FixtureCase,
200
+ client: "ApiClient",
201
+ *,
202
+ run_id: str,
203
+ request_timeout: int,
204
+ create_candidates: bool,
205
+ max_candidates_per_fixture: int,
206
+ snapshot: bool,
207
+ ) -> dict[str, Any]:
208
+ label_base = safe_filename(f"{case.input_type}_{case.source_id}")
209
+ record: dict[str, Any] = {
210
+ "fixture": fixture_case_summary(case),
211
+ "parse": {},
212
+ "audit": {"status": "not_run"},
213
+ "create": {"status": "not_run"},
214
+ "graph": {"status": "not_run"},
215
+ "version": {"status": "not_run"},
216
+ "harness": {"status": "not_run"},
217
+ "skillsbench": {
218
+ "status": "not_run",
219
+ "note": "Reserved for SkillsBench/BenchFlow adapter. This runner keeps the field explicit instead of faking benchmark execution.",
220
+ },
221
+ }
222
+ parse_result = client.post(
223
+ "/ingest/parse",
224
+ {
225
+ "source_type": case.input_type,
226
+ "content": case.content,
227
+ "metadata": {
228
+ "eval_run_id": run_id,
229
+ "source_id": case.source_id,
230
+ "input_type": case.input_type,
231
+ "domain": case.domain,
232
+ "source_url": case.source_url,
233
+ "paper_or_project": case.paper_or_project,
234
+ "expected_skill_shape": case.expected_skill_shape,
235
+ "license_note": case.license_note,
236
+ "content_sha256": case.content_sha256,
237
+ "origin_file": str(case.local_path),
238
+ "ephemeral_preview_only": not create_candidates,
239
+ "max_candidates": max(1, max_candidates_per_fixture),
240
+ },
241
+ },
242
+ label=f"parse_{label_base}",
243
+ timeout_s=request_timeout,
244
+ )
245
+ record["parse"] = analyze_parse(case, parse_result)
246
+
247
+ units = parse_result.get("units") if isinstance(parse_result, dict) else []
248
+ if not create_candidates or not units:
249
+ return record
250
+
251
+ created: list[dict[str, Any]] = []
252
+ audits: list[dict[str, Any]] = []
253
+ graph_checks: list[dict[str, Any]] = []
254
+ version_checks: list[dict[str, Any]] = []
255
+ for idx, unit in enumerate(units[:max(1, max_candidates_per_fixture)]):
256
+ payload = candidate_review_payload(unit, case, run_id=run_id)
257
+ audit_result = client.post(
258
+ "/ingest/audit-candidate",
259
+ payload,
260
+ label=f"audit_{label_base}_{idx + 1}",
261
+ timeout_s=request_timeout,
262
+ )
263
+ audits.append(analyze_audit(payload, audit_result))
264
+ create_result = client.post(
265
+ "/ingest/create-candidate",
266
+ payload,
267
+ label=f"create_{label_base}_{idx + 1}",
268
+ timeout_s=request_timeout,
269
+ )
270
+ created_info = analyze_create(create_result)
271
+ created.append(created_info)
272
+ skill_id = created_info.get("created_skill_id")
273
+ if not skill_id:
274
+ continue
275
+ graph_checks.append(check_graph_for_skill(client, str(skill_id), label=f"graph_{label_base}_{idx + 1}"))
276
+ version_checks.append(check_version_for_skill(
277
+ client,
278
+ str(skill_id),
279
+ label=f"version_{label_base}_{idx + 1}",
280
+ snapshot=snapshot,
281
+ ))
282
+
283
+ record["audit"] = summarize_audits(audits)
284
+ record["create"] = summarize_creates(created)
285
+ record["graph"] = summarize_graph_checks(graph_checks)
286
+ record["version"] = summarize_version_checks(version_checks)
287
+ return record
288
+
289
+
290
+ def analyze_parse(case: FixtureCase, result: dict[str, Any]) -> dict[str, Any]:
291
+ units = result.get("units") or []
292
+ unit_infos = [analyze_unit(unit) for unit in units if isinstance(unit, dict)]
293
+ return {
294
+ "http_status": result.get("_http_status"),
295
+ "success": bool(result.get("success")),
296
+ "unit_count": int(result.get("unit_count") or len(unit_infos)),
297
+ "error_count": len(result.get("errors") or []),
298
+ "errors": result.get("errors") or [],
299
+ "elapsed_ms": result.get("_elapsed_ms"),
300
+ "schema_completeness": average([unit["schema_completeness"] for unit in unit_infos]),
301
+ "ctx2skill_evidence_completeness": average([unit["ctx2skill_evidence_completeness"] for unit in unit_infos]),
302
+ "layer_correctness": expected_layer_score(case, unit_infos),
303
+ "graph_relation_preview_count": sum(unit["graph_relation_preview_count"] for unit in unit_infos),
304
+ "units": unit_infos,
305
+ }
306
+
307
+
308
+ def analyze_unit(unit: dict[str, Any]) -> dict[str, Any]:
309
+ meta = unit.get("metadata") or {}
310
+ interface = meta.get("candidate_interface") if isinstance(meta.get("candidate_interface"), dict) else {}
311
+ evidence = meta.get("ctx2skill_evidence") if isinstance(meta.get("ctx2skill_evidence"), dict) else {}
312
+ relations = meta.get("candidate_relations") if isinstance(meta.get("candidate_relations"), dict) else {}
313
+ graph_preview = meta.get("graph_relation_preview") or []
314
+ input_schema = interface.get("input_schema") if isinstance(interface.get("input_schema"), dict) else {}
315
+ output_schema = interface.get("output_schema") if isinstance(interface.get("output_schema"), dict) else {}
316
+ schema_bits = [
317
+ bool(input_schema),
318
+ bool(output_schema),
319
+ bool(interface.get("preconditions")),
320
+ bool(interface.get("postconditions")),
321
+ ]
322
+ evidence_bits = [
323
+ bool(evidence),
324
+ bool(evidence.get("challenges")),
325
+ bool(evidence.get("judge_results")),
326
+ bool(evidence.get("selected_candidate") or evidence.get("selected_reason")),
327
+ ]
328
+ return {
329
+ "unit_id": unit.get("unit_id"),
330
+ "proposed_skill_name": unit.get("proposed_skill_name"),
331
+ "proposed_type": unit.get("proposed_type"),
332
+ "confidence": unit.get("confidence"),
333
+ "schema_completeness": round(sum(1 for bit in schema_bits if bit) / len(schema_bits), 2),
334
+ "ctx2skill_evidence_completeness": round(sum(1 for bit in evidence_bits if bit) / len(evidence_bits), 2),
335
+ "challenge_count": len(evidence.get("challenges") or []),
336
+ "judge_result_count": len(evidence.get("judge_results") or []),
337
+ "candidate_score_count": len(evidence.get("candidate_scores") or []),
338
+ "layering_reason_present": bool(meta.get("layering_reason")),
339
+ "graph_relation_preview_count": len(graph_preview) if isinstance(graph_preview, list) else 0,
340
+ "dependency_count": len(relations.get("dependency_ids") or []),
341
+ "component_count": len(relations.get("component_ids") or []),
342
+ "parent_count": len(relations.get("parent_skill_ids") or []),
343
+ }
344
+
345
+
346
+ def expected_layer_score(case: FixtureCase, units: list[dict[str, Any]]) -> float:
347
+ if not units:
348
+ return 0.0
349
+ expected = case.expected_skill_shape.strip().lower()
350
+ if expected not in {"atomic", "functional", "strategic"}:
351
+ return 0.5
352
+ matches = sum(1 for unit in units if unit.get("proposed_type") == expected)
353
+ return round(matches / len(units), 2)
354
+
355
+
356
+ def candidate_review_payload(unit: dict[str, Any], case: FixtureCase, *, run_id: str) -> dict[str, Any]:
357
+ meta = unit.get("metadata") or {}
358
+ interface = meta.get("candidate_interface") if isinstance(meta.get("candidate_interface"), dict) else {}
359
+ implementation = meta.get("candidate_implementation") if isinstance(meta.get("candidate_implementation"), dict) else {}
360
+ relations = meta.get("candidate_relations") if isinstance(meta.get("candidate_relations"), dict) else {}
361
+ evaluation = meta.get("candidate_evaluation") if isinstance(meta.get("candidate_evaluation"), dict) else None
362
+ source_type = unit.get("source_type") or case.input_type
363
+ base_name = normalize_skill_name(unit.get("proposed_skill_name") or f"{case.source_id}_{source_type}_candidate")
364
+ source_suffix = normalize_skill_name(case.source_id)
365
+ if source_suffix and source_suffix not in base_name:
366
+ name = normalize_skill_name(f"{base_name}_{source_suffix}")[:120]
367
+ else:
368
+ name = base_name
369
+ description = unit.get("proposed_description") or unit.get("summary") or name
370
+ if evaluation:
371
+ evaluation = dict(evaluation)
372
+ evaluation.setdefault("test_case_refs", [f"{case.source_id}:{unit.get('unit_id', 'unit')}"])
373
+ evaluation.setdefault("benchmark_task_ids", list(case.manifest.get("target_benchmark_tasks") or []))
374
+ evaluation.setdefault("validation_summary", "Created by isolated input-skill evaluation runner.")
375
+ else:
376
+ evaluation = {
377
+ "verifier_specs": [{"type": "json_exists", "path": "output.result"}],
378
+ "test_case_refs": [f"{case.source_id}:{unit.get('unit_id', 'unit')}"],
379
+ "benchmark_task_ids": list(case.manifest.get("target_benchmark_tasks") or []),
380
+ "validation_summary": "Created by isolated input-skill evaluation runner.",
381
+ }
382
+ tags = meta.get("candidate_tags") if isinstance(meta.get("candidate_tags"), list) else []
383
+ return {
384
+ "source_type": source_type,
385
+ "unit_id": unit.get("unit_id") or f"{case.source_id}:unit",
386
+ "raw_content": unit.get("raw_content") or "",
387
+ "name": name,
388
+ "description": description,
389
+ "skill_type": unit.get("proposed_type") if unit.get("proposed_type") in {"atomic", "functional", "strategic"} else "atomic",
390
+ "tags": unique_strings([
391
+ *tags,
392
+ *(unit.get("index_keywords") or [])[:4],
393
+ "input-skill-eval",
394
+ f"eval-run:{run_id}",
395
+ f"source-id:{case.source_id}",
396
+ f"domain:{case.domain}",
397
+ ])[:12],
398
+ "input_schema": interface.get("input_schema") or {"type": "object", "properties": {}},
399
+ "output_schema": interface.get("output_schema") or {"type": "object", "properties": {"result": {"type": "object"}}},
400
+ "preconditions": interface.get("preconditions") or [],
401
+ "postconditions": interface.get("postconditions") or ["Candidate returns a structured result."],
402
+ "prompt_template": implementation.get("prompt_template") or unit.get("summary") or description,
403
+ "provenance": {
404
+ "source_type": source_type,
405
+ "source_ids": unique_strings([case.source_id, unit.get("unit_id") or "unit"]),
406
+ "parent_skill_ids": relations.get("parent_skill_ids") or [],
407
+ "created_by_agent": "input_skill_eval_runner",
408
+ "creation_context": {
409
+ "eval_run_id": run_id,
410
+ "fixture_import_mode": "isolated_eval_candidate",
411
+ "source_id": case.source_id,
412
+ "input_type": case.input_type,
413
+ "domain": case.domain,
414
+ "source_url": case.source_url,
415
+ "paper_or_project": case.paper_or_project,
416
+ "expected_skill_shape": case.expected_skill_shape,
417
+ "license_note": case.license_note,
418
+ "local_path": str(case.local_path),
419
+ "content_sha256": case.content_sha256,
420
+ "ctx2skill_evidence": meta.get("ctx2skill_evidence"),
421
+ },
422
+ },
423
+ "evaluation": evaluation,
424
+ "dependency_ids": relations.get("dependency_ids") or [],
425
+ "component_ids": relations.get("component_ids") or [],
426
+ "sub_skill_ids": relations.get("sub_skill_ids") or relations.get("component_ids") or [],
427
+ "parent_skill_ids": relations.get("parent_skill_ids") or [],
428
+ "tool_calls": implementation.get("tool_calls") or [],
429
+ "author": "input_skill_eval_runner",
430
+ }
431
+
432
+
433
+ def analyze_audit(payload: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]:
434
+ return {
435
+ "candidate_name": payload.get("name"),
436
+ "http_status": result.get("_http_status"),
437
+ "passed": bool(result.get("passed")),
438
+ "audit_score": result.get("audit_score"),
439
+ "issues": result.get("issues") or [],
440
+ "warnings": result.get("warnings") or [],
441
+ }
442
+
443
+
444
+ def analyze_create(result: dict[str, Any]) -> dict[str, Any]:
445
+ created = result.get("created_skill") if isinstance(result.get("created_skill"), dict) else {}
446
+ return {
447
+ "http_status": result.get("_http_status"),
448
+ "success": bool(result.get("success")),
449
+ "created_skill_id": created.get("skill_id"),
450
+ "created_skill_name": created.get("name"),
451
+ "state": created.get("state"),
452
+ "audit_passed": (result.get("audit") or {}).get("passed") if isinstance(result.get("audit"), dict) else None,
453
+ }
454
+
455
+
456
+ def check_graph_for_skill(client: "ApiClient", skill_id: str, *, label: str) -> dict[str, Any]:
457
+ result = client.get(f"/graph/view?view=skill_only&limit=500", label=label)
458
+ nodes = result.get("nodes") or []
459
+ edges = result.get("edges") or []
460
+ skill_present = any(str(node.get("skill_id") or node.get("id") or "").endswith(skill_id) for node in nodes)
461
+ relation_count = sum(
462
+ 1 for edge in edges
463
+ if skill_id in str(edge.get("source") or edge.get("source_id") or "")
464
+ or skill_id in str(edge.get("target") or edge.get("target_id") or "")
465
+ )
466
+ return {
467
+ "skill_id": skill_id,
468
+ "http_status": result.get("_http_status"),
469
+ "skill_present": skill_present,
470
+ "relation_count": relation_count,
471
+ "node_count": len(nodes),
472
+ "edge_count": len(edges),
473
+ }
474
+
475
+
476
+ def check_version_for_skill(client: "ApiClient", skill_id: str, *, label: str, snapshot: bool) -> dict[str, Any]:
477
+ diff = client.get(f"/lifecycle/{skill_id}/diff", label=f"{label}_diff")
478
+ result = {
479
+ "skill_id": skill_id,
480
+ "http_status": diff.get("_http_status"),
481
+ "business_diff_available": "business_diff" in diff or "business_summary" in diff or "history" in diff,
482
+ "raw_diff_available": "raw_diff" in diff or "changes" in diff or "snapshot_diff" in diff or "history" in diff,
483
+ "snapshot_created": False,
484
+ }
485
+ if snapshot:
486
+ snap = client.post(
487
+ f"/lifecycle/{skill_id}/snapshot",
488
+ {
489
+ "description": "Input-skill eval snapshot.",
490
+ "author": "input_skill_eval_runner",
491
+ },
492
+ label=f"{label}_snapshot",
493
+ timeout_s=120,
494
+ )
495
+ result["snapshot_http_status"] = snap.get("_http_status")
496
+ result["snapshot_created"] = snap.get("_http_status") in {200, 201} and not snap.get("detail")
497
+ return result
498
+
499
+
500
+ def summarize_audits(items: list[dict[str, Any]]) -> dict[str, Any]:
501
+ if not items:
502
+ return {"status": "not_run"}
503
+ return {
504
+ "status": "completed",
505
+ "count": len(items),
506
+ "passed_count": sum(1 for item in items if item.get("passed")),
507
+ "average_audit_score": average([
508
+ float(item.get("audit_score") or 0.0)
509
+ for item in items
510
+ if isinstance(item.get("audit_score"), (int, float))
511
+ ]),
512
+ "items": items,
513
+ }
514
+
515
+
516
+ def summarize_creates(items: list[dict[str, Any]]) -> dict[str, Any]:
517
+ if not items:
518
+ return {"status": "not_run"}
519
+ created_ids = [item.get("created_skill_id") for item in items if item.get("created_skill_id")]
520
+ return {
521
+ "status": "completed",
522
+ "count": len(items),
523
+ "success_count": sum(1 for item in items if item.get("success")),
524
+ "created_skill_ids": created_ids,
525
+ "http_statuses": [item.get("http_status") for item in items],
526
+ "items": items,
527
+ }
528
+
529
+
530
+ def summarize_graph_checks(items: list[dict[str, Any]]) -> dict[str, Any]:
531
+ if not items:
532
+ return {"status": "not_run"}
533
+ return {
534
+ "status": "completed",
535
+ "count": len(items),
536
+ "present_count": sum(1 for item in items if item.get("skill_present")),
537
+ "relation_count": sum(int(item.get("relation_count") or 0) for item in items),
538
+ "items": items,
539
+ }
540
+
541
+
542
+ def summarize_version_checks(items: list[dict[str, Any]]) -> dict[str, Any]:
543
+ if not items:
544
+ return {"status": "not_run"}
545
+ return {
546
+ "status": "completed",
547
+ "count": len(items),
548
+ "business_diff_available_count": sum(1 for item in items if item.get("business_diff_available")),
549
+ "snapshot_created_count": sum(1 for item in items if item.get("snapshot_created")),
550
+ "items": items,
551
+ }
552
+
553
+
554
+ def score_fixture_record(record: dict[str, Any]) -> float:
555
+ parse = record.get("parse") or {}
556
+ score = 0.0
557
+ if parse.get("http_status") == 200 and parse.get("success"):
558
+ score += 0.18
559
+ if parse.get("unit_count", 0) > 0:
560
+ score += 0.10
561
+ score += 0.08 * clamp01(float(parse.get("schema_completeness") or 0.0))
562
+ score += 0.08 * clamp01(float(parse.get("ctx2skill_evidence_completeness") or 0.0))
563
+ score += 0.06 * clamp01(float(parse.get("layer_correctness") or 0.0))
564
+
565
+ audit = record.get("audit") or {}
566
+ if audit.get("status") == "completed":
567
+ score += 0.10 * ratio(audit.get("passed_count"), audit.get("count"))
568
+ elif audit.get("http_status") == 200 and audit.get("passed"):
569
+ score += 0.10
570
+
571
+ create = record.get("create") or {}
572
+ if create.get("status") == "completed":
573
+ score += 0.12 * ratio(create.get("success_count"), create.get("count"))
574
+ elif create.get("http_status") in {200, 201} and create.get("success"):
575
+ score += 0.12
576
+
577
+ graph = record.get("graph") or {}
578
+ if graph.get("status") == "completed":
579
+ score += 0.10 * ratio(graph.get("present_count"), graph.get("count"))
580
+ if int(graph.get("relation_count") or 0) > 0:
581
+ score += 0.05
582
+ elif graph.get("http_status") == 200 and graph.get("skill_present"):
583
+ score += 0.10
584
+ if int(graph.get("relation_count") or 0) > 0:
585
+ score += 0.05
586
+
587
+ version = record.get("version") or {}
588
+ if version.get("status") == "completed":
589
+ score += 0.06 * ratio(version.get("business_diff_available_count"), version.get("count"))
590
+ if int(version.get("snapshot_created_count") or 0) > 0:
591
+ score += 0.03
592
+ elif version.get("http_status") == 200:
593
+ if version.get("business_diff_available"):
594
+ score += 0.06
595
+ if version.get("snapshot_created"):
596
+ score += 0.03
597
+
598
+ harness = record.get("harness") or {}
599
+ if harness.get("status") == "verified":
600
+ score += 0.12
601
+ elif harness.get("positive_pass") or harness.get("negative_rejected"):
602
+ score += 0.06
603
+
604
+ skillsbench = record.get("skillsbench") or {}
605
+ if skillsbench.get("status") in {"mapped", "completed"} and skillsbench.get("verifier_ran"):
606
+ score += 0.10
607
+ if skillsbench.get("generated_pass"):
608
+ score += 0.05
609
+
610
+ return round(min(score, 1.0), 3)
611
+
612
+
613
+ def summarize_scores(records: list[dict[str, Any]]) -> dict[str, Any]:
614
+ by_type_values: dict[str, list[float]] = {}
615
+ by_domain_values: dict[str, list[float]] = {}
616
+ for record in records:
617
+ score = float(record.get("score") or 0.0)
618
+ fixture = record.get("fixture") or {}
619
+ by_type_values.setdefault(str(fixture.get("input_type") or "unknown"), []).append(score)
620
+ by_domain_values.setdefault(str(fixture.get("domain") or "unknown"), []).append(score)
621
+ return {
622
+ "overall": average([float(record.get("score") or 0.0) for record in records]),
623
+ "by_input_type": {key: average(values) for key, values in by_type_values.items()},
624
+ "by_domain": {key: average(values) for key, values in by_domain_values.items()},
625
+ "sample_count": len(records),
626
+ }
627
+
628
+
629
+ def render_report(summary: dict[str, Any]) -> str:
630
+ lines = [
631
+ "# SkillOS Input-to-Skill Evaluation Report",
632
+ "",
633
+ f"- Started: `{summary['started_at']}`",
634
+ f"- Finished: `{summary.get('finished_at', '')}`",
635
+ f"- API base: `{summary['api_base']}`",
636
+ f"- Manifest: `{summary['manifest']}`",
637
+ f"- Fixture root: `{summary['fixture_root']}`",
638
+ f"- Run directory: `{summary['run_dir']}`",
639
+ f"- Created candidates: `{summary['options']['create_candidates']}`",
640
+ "",
641
+ "## Isolation",
642
+ "",
643
+ "- Raw fixture files are read only.",
644
+ "- API responses and reports are written only under this run directory.",
645
+ "- Candidate creation is disabled unless `--create-candidates` is passed.",
646
+ "- SkillsBench results are explicit fields and remain `not_run` until the benchmark adapter executes.",
647
+ "",
648
+ "## Scores",
649
+ "",
650
+ f"- Overall: `{summary['scores']['overall']}`",
651
+ f"- By input type: `{json.dumps(summary['scores']['by_input_type'], ensure_ascii=False)}`",
652
+ f"- By domain: `{json.dumps(summary['scores']['by_domain'], ensure_ascii=False)}`",
653
+ f"- Skill delta: `{summary['skill_counts']['delta']}`",
654
+ "",
655
+ "## Fixtures",
656
+ "",
657
+ ]
658
+ for record in summary["records"]:
659
+ fixture = record["fixture"]
660
+ parse = record["parse"]
661
+ lines.extend([
662
+ f"### {fixture['source_id']} - {record['score']}",
663
+ f"- Input/domain: `{fixture['input_type']}` / `{fixture['domain']}`",
664
+ f"- Source: `{fixture.get('source_url', '')}`",
665
+ f"- Paper/project: `{fixture.get('paper_or_project', '')}`",
666
+ f"- Local file: `{fixture['local_path']}`",
667
+ f"- Parse: http=`{parse.get('http_status')}`, success=`{parse.get('success')}`, "
668
+ f"units=`{parse.get('unit_count')}`, schema=`{parse.get('schema_completeness')}`, "
669
+ f"ctx2skill=`{parse.get('ctx2skill_evidence_completeness')}`, layer=`{parse.get('layer_correctness')}`",
670
+ f"- Audit: `{record.get('audit', {}).get('status')}`",
671
+ f"- Create: `{record.get('create', {}).get('status')}`",
672
+ f"- Graph: `{record.get('graph', {}).get('status')}`",
673
+ f"- Version: `{record.get('version', {}).get('status')}`",
674
+ f"- Harness: `{record.get('harness', {}).get('status')}`",
675
+ f"- SkillsBench: `{record.get('skillsbench', {}).get('status')}`",
676
+ "",
677
+ ])
678
+ lines.append("Raw API envelopes are saved under `raw/` next to this report.")
679
+ lines.append("")
680
+ return "\n".join(lines)
681
+
682
+
683
+ class ApiClient:
684
+ def __init__(self, base_url: str, raw_dir: Path) -> None:
685
+ self.base_url = base_url
686
+ self.raw_dir = raw_dir
687
+
688
+ def get(self, path: str, *, label: str, timeout_s: int = 30) -> dict[str, Any]:
689
+ return self._request("GET", self.base_url + path, None, label, timeout_s)
690
+
691
+ def post(self, path: str, payload: dict[str, Any], *, label: str, timeout_s: int = 120) -> dict[str, Any]:
692
+ return self._request("POST", self.base_url + path, payload, label, timeout_s)
693
+
694
+ def _request(
695
+ self,
696
+ method: str,
697
+ url: str,
698
+ payload: dict[str, Any] | None,
699
+ label: str,
700
+ timeout_s: int,
701
+ ) -> dict[str, Any]:
702
+ data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
703
+ req = urllib.request.Request(url, data=data, method=method)
704
+ req.add_header("Accept", "application/json")
705
+ if payload is not None:
706
+ req.add_header("Content-Type", "application/json")
707
+ start = time.perf_counter()
708
+ try:
709
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
710
+ body = resp.read().decode("utf-8", errors="replace")
711
+ status = resp.status
712
+ except urllib.error.HTTPError as exc:
713
+ body = exc.read().decode("utf-8", errors="replace")
714
+ status = exc.code
715
+ except Exception as exc:
716
+ body = json.dumps({"error": str(exc)}, ensure_ascii=False)
717
+ status = 0
718
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
719
+ parsed = parse_body(body)
720
+ envelope = {
721
+ "label": label,
722
+ "method": method,
723
+ "url": url,
724
+ "status_code": status,
725
+ "elapsed_ms": elapsed_ms,
726
+ "request_preview": preview_payload(payload),
727
+ "body": parsed,
728
+ }
729
+ write_json(self.raw_dir / f"{safe_filename(label)}.json", envelope)
730
+ if isinstance(parsed, dict):
731
+ result = dict(parsed)
732
+ result["_http_status"] = status
733
+ result["_elapsed_ms"] = elapsed_ms
734
+ return result
735
+ return {"_http_status": status, "_elapsed_ms": elapsed_ms, "body": parsed}
736
+
737
+
738
+ def fixture_case_summary(case: FixtureCase) -> dict[str, Any]:
739
+ return {
740
+ "source_id": case.source_id,
741
+ "input_type": case.input_type,
742
+ "domain": case.domain,
743
+ "source_url": case.source_url,
744
+ "paper_or_project": case.paper_or_project,
745
+ "expected_skill_shape": case.expected_skill_shape,
746
+ "license_note": case.license_note,
747
+ "local_path": str(case.local_path),
748
+ "content_sha256": case.content_sha256,
749
+ "truncated": case.truncated,
750
+ }
751
+
752
+
753
+ def count_skills(payload: Any) -> int:
754
+ if isinstance(payload, list):
755
+ return len(payload)
756
+ if isinstance(payload, dict):
757
+ body = payload.get("body")
758
+ if isinstance(body, list):
759
+ return len(body)
760
+ for key in ("value", "skills", "items"):
761
+ value = payload.get(key)
762
+ if isinstance(value, list):
763
+ return len(value)
764
+ if isinstance(payload.get("total"), int):
765
+ return int(payload["total"])
766
+ return 0
767
+
768
+
769
+ def write_json(path: Path, data: Any) -> None:
770
+ path.parent.mkdir(parents=True, exist_ok=True)
771
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
772
+
773
+
774
+ def parse_body(body: str) -> Any:
775
+ try:
776
+ return json.loads(body)
777
+ except json.JSONDecodeError:
778
+ return body
779
+
780
+
781
+ def preview_payload(payload: dict[str, Any] | None) -> Any:
782
+ if payload is None:
783
+ return None
784
+ preview = dict(payload)
785
+ content = preview.get("content")
786
+ if isinstance(content, str):
787
+ preview["content"] = f"{content[:500]}... [chars={len(content)}]"
788
+ return preview
789
+
790
+
791
+ def normalize_skill_name(value: str) -> str:
792
+ text = re.sub(r"[^a-zA-Z0-9_]+", "_", str(value or "").strip().lower())
793
+ text = re.sub(r"_+", "_", text).strip("_")
794
+ return text[:120] or "input_skill_eval_candidate"
795
+
796
+
797
+ def safe_filename(value: str) -> str:
798
+ normalized = normalize_skill_name(value).replace("/", "_")
799
+ if len(normalized) <= 96:
800
+ return normalized
801
+ digest = hashlib.sha1(str(value).encode("utf-8")).hexdigest()[:10]
802
+ return f"{normalized[:84].rstrip('_')}_{digest}"
803
+
804
+
805
+ def unique_strings(values: list[Any]) -> list[str]:
806
+ seen: set[str] = set()
807
+ result: list[str] = []
808
+ for value in values:
809
+ text = str(value or "").strip()
810
+ if not text or text in seen:
811
+ continue
812
+ seen.add(text)
813
+ result.append(text)
814
+ return result
815
+
816
+
817
+ def average(values: list[float]) -> float:
818
+ return round(sum(values) / len(values), 3) if values else 0.0
819
+
820
+
821
+ def ratio(numerator: Any, denominator: Any) -> float:
822
+ try:
823
+ denom = float(denominator or 0)
824
+ if denom <= 0:
825
+ return 0.0
826
+ return clamp01(float(numerator or 0) / denom)
827
+ except (TypeError, ValueError):
828
+ return 0.0
829
+
830
+
831
+ def clamp01(value: float) -> float:
832
+ return min(max(value, 0.0), 1.0)
833
+
834
+
835
+ if __name__ == "__main__":
836
+ sys.exit(main())
scripts/run_p0_harness_eval.py ADDED
@@ -0,0 +1,518 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import re
6
+ import urllib.error
7
+ import urllib.request
8
+ from collections import Counter, defaultdict
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ REPO_ROOT = Path(__file__).resolve().parents[1]
15
+ DEFAULT_RUN_ROOT = REPO_ROOT / "artifacts" / "input-skill-eval-runs"
16
+
17
+
18
+ class ApiClient:
19
+ def __init__(self, base_url: str, raw_dir: Path, timeout_s: int) -> None:
20
+ self.base_url = base_url.rstrip("/")
21
+ self.raw_dir = raw_dir
22
+ self.timeout_s = timeout_s
23
+ self.raw_dir.mkdir(parents=True, exist_ok=True)
24
+
25
+ def get(self, path: str, *, label: str, timeout_s: int | None = None) -> Any:
26
+ with urllib.request.urlopen(f"{self.base_url}{path}", timeout=timeout_s or self.timeout_s) as response:
27
+ payload = json.loads(response.read().decode("utf-8"))
28
+ self._write_raw(label, payload)
29
+ return payload
30
+
31
+ def post(self, path: str, payload: dict[str, Any], *, label: str, timeout_s: int | None = None) -> Any:
32
+ request = urllib.request.Request(
33
+ f"{self.base_url}{path}",
34
+ data=json.dumps(payload).encode("utf-8"),
35
+ headers={"Content-Type": "application/json"},
36
+ method="POST",
37
+ )
38
+ try:
39
+ with urllib.request.urlopen(request, timeout=timeout_s or self.timeout_s) as response:
40
+ result = json.loads(response.read().decode("utf-8"))
41
+ except urllib.error.HTTPError as exc:
42
+ body = exc.read().decode("utf-8", errors="replace")
43
+ result = {"http_error": exc.code, "body": body}
44
+ self._write_raw(label, {"request": payload, "response": result})
45
+ return result
46
+
47
+ def _write_raw(self, label: str, payload: Any) -> None:
48
+ safe = safe_filename(label)
49
+ (self.raw_dir / f"{safe}.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
50
+
51
+
52
+ def main(argv: list[str] | None = None) -> int:
53
+ parser = argparse.ArgumentParser(
54
+ description=(
55
+ "Run representative positive/negative harness checks against generated P0 Skills. "
56
+ "Selects at least three generated Skills per input type from a full input-skill eval summary."
57
+ )
58
+ )
59
+ parser.add_argument("--input-eval-summary", required=True)
60
+ parser.add_argument("--api-base", default="http://127.0.0.1:8001/api/v1")
61
+ parser.add_argument("--run-root", default=str(DEFAULT_RUN_ROOT))
62
+ parser.add_argument("--run-id", default=datetime.now().strftime("%Y%m%d-%H%M%S"))
63
+ parser.add_argument("--per-type", type=int, default=3)
64
+ parser.add_argument("--request-timeout", type=int, default=180)
65
+ parser.add_argument("--harness-timeout", type=int, default=120)
66
+ parser.add_argument("--output-report", default="")
67
+ args = parser.parse_args(argv)
68
+
69
+ input_summary_path = Path(args.input_eval_summary).resolve()
70
+ input_summary = read_json(input_summary_path)
71
+ run_dir = Path(args.run_root).resolve() / f"p0-harness-eval-{args.run_id}"
72
+ raw_dir = run_dir / "raw"
73
+ run_dir.mkdir(parents=True, exist_ok=True)
74
+ client = ApiClient(args.api_base, raw_dir, args.request_timeout)
75
+
76
+ selected = select_records(input_summary, per_type=args.per_type)
77
+ summary: dict[str, Any] = {
78
+ "started_at": datetime.now().isoformat(timespec="seconds"),
79
+ "api_base": client.base_url,
80
+ "input_eval_summary": str(input_summary_path),
81
+ "run_dir": str(run_dir),
82
+ "run_id": args.run_id,
83
+ "per_type": args.per_type,
84
+ "checks": [],
85
+ "notes": [
86
+ "Positive checks use generated Skill verifier specs and allow deterministic repair when output fields are missing.",
87
+ "Negative checks use the same Skill verifier specs with intentionally invalid inputs and allow_repair=false.",
88
+ "This runner mutates only the isolated eval backend state by moving selected generated candidates to S2 and creating repaired S2/S3 versions as needed; raw corpora remain read-only.",
89
+ ],
90
+ }
91
+ summary["connectivity"] = {
92
+ "skills_before": client.get("/skills?limit=500", label="skills_before_p0_harness"),
93
+ }
94
+
95
+ for record in selected:
96
+ item = run_record_checks(client, record, timeout_s=args.harness_timeout)
97
+ summary["checks"].append(item)
98
+
99
+ summary["scores"] = score_checks(summary["checks"])
100
+ summary["finished_at"] = datetime.now().isoformat(timespec="seconds")
101
+ write_json(run_dir / "summary.json", summary)
102
+ report_text = render_report(summary)
103
+ (run_dir / "REPORT.md").write_text(report_text, encoding="utf-8")
104
+ if args.output_report:
105
+ Path(args.output_report).write_text(report_text, encoding="utf-8")
106
+
107
+ print(json.dumps({"run_dir": str(run_dir), "scores": summary["scores"]}, ensure_ascii=False, indent=2))
108
+ return 0 if summary["scores"]["input_types_meeting_minimum"] == 5 else 1
109
+
110
+
111
+ def read_json(path: Path) -> Any:
112
+ return json.loads(path.read_text(encoding="utf-8"))
113
+
114
+
115
+ def write_json(path: Path, payload: Any) -> None:
116
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
117
+
118
+
119
+ def select_records(input_summary: dict[str, Any], *, per_type: int) -> list[dict[str, Any]]:
120
+ selected: list[dict[str, Any]] = []
121
+ counts: Counter[str] = Counter()
122
+ records = input_summary.get("records") if isinstance(input_summary.get("records"), list) else []
123
+ for record in records:
124
+ fixture = record.get("fixture") if isinstance(record, dict) else {}
125
+ input_type = str(fixture.get("input_type") or "")
126
+ create = record.get("create") if isinstance(record.get("create"), dict) else {}
127
+ created_ids = create.get("created_skill_ids") if isinstance(create.get("created_skill_ids"), list) else []
128
+ if not input_type or not created_ids or counts[input_type] >= per_type:
129
+ continue
130
+ selected.append(record)
131
+ counts[input_type] += 1
132
+ return selected
133
+
134
+
135
+ def run_record_checks(client: ApiClient, record: dict[str, Any], *, timeout_s: int) -> dict[str, Any]:
136
+ fixture = record["fixture"]
137
+ input_type = fixture["input_type"]
138
+ source_id = fixture["source_id"]
139
+ skill_id = record["create"]["created_skill_ids"][0]
140
+ skill = client.get(f"/skills/{skill_id}/full", label=f"skill_full_{source_id}")
141
+ negative_draft = ensure_draft_with_executable_impl(client, skill, fixture, purpose="negative")
142
+ positive_draft = ensure_draft_with_executable_impl(client, skill, fixture, purpose="positive")
143
+ positive_case = build_positive_test_case(positive_draft, fixture, timeout_s=timeout_s)
144
+ negative_case = build_negative_test_case(negative_draft, fixture, timeout_s=timeout_s)
145
+ negative = client.post(
146
+ f"/harness/{negative_draft['skill_id']}/verify-loop",
147
+ {
148
+ "harness": "local_skillos",
149
+ "max_attempts": 1,
150
+ "promote_on_pass": False,
151
+ "allow_repair": False,
152
+ "timeout_s": timeout_s,
153
+ "test_cases": [negative_case],
154
+ },
155
+ label=f"harness_negative_{source_id}",
156
+ timeout_s=timeout_s + 180,
157
+ )
158
+ positive = client.post(
159
+ f"/harness/{positive_draft['skill_id']}/verify-loop",
160
+ {
161
+ "harness": "local_skillos",
162
+ "max_attempts": 2,
163
+ "promote_on_pass": True,
164
+ "allow_repair": True,
165
+ "timeout_s": timeout_s,
166
+ "test_cases": [positive_case],
167
+ },
168
+ label=f"harness_positive_{source_id}",
169
+ timeout_s=timeout_s + 180,
170
+ )
171
+ return {
172
+ "input_type": input_type,
173
+ "source_id": source_id,
174
+ "domain": fixture.get("domain"),
175
+ "skill_id": skill_id,
176
+ "positive_draft_skill_id": positive_draft["skill_id"],
177
+ "negative_draft_skill_id": negative_draft["skill_id"],
178
+ "skill_name": positive_draft.get("name"),
179
+ "positive": summarize_loop(positive),
180
+ "negative": summarize_loop(negative),
181
+ "positive_pass": bool(positive.get("promotion_allowed")),
182
+ "negative_rejected": not bool(negative.get("promotion_allowed")) and not bool(negative.get("http_error")),
183
+ "attempt_count": int(positive.get("attempt_count") or 0),
184
+ "repair_count": len(positive.get("repairs") if isinstance(positive.get("repairs"), list) else []),
185
+ "final_state": str(positive.get("final_state") or ""),
186
+ "evidence_path": positive.get("evidence_path", ""),
187
+ }
188
+
189
+
190
+ def ensure_draft_with_executable_impl(
191
+ client: ApiClient,
192
+ skill: dict[str, Any],
193
+ fixture: dict[str, Any],
194
+ *,
195
+ purpose: str,
196
+ ) -> dict[str, Any]:
197
+ source_skill_id = latest_version_skill_id(client, skill)
198
+ implementation = dict(skill.get("implementation") or {})
199
+ evaluation = dict(skill.get("evaluation") or {})
200
+ verifier_specs = list(evaluation.get("verifier_specs") or [])
201
+ code = render_contract_echo_code(verifier_specs, input_type=str(fixture.get("input_type") or ""))
202
+ implementation["language"] = "python"
203
+ implementation["code"] = code
204
+ implementation["prompt_template"] = None
205
+ payload = {
206
+ "bump": "patch",
207
+ "description": f"{skill.get('description', '')} Harness-evaluable P0 draft for {fixture.get('source_id')}.",
208
+ "tags": list(dict.fromkeys([*(skill.get("tags") or []), "p0-harness-eval"])),
209
+ "implementation": implementation,
210
+ "evaluation": evaluation,
211
+ "metadata": {
212
+ "p0_harness_eval": True,
213
+ "purpose": purpose,
214
+ "source_id": fixture.get("source_id"),
215
+ "input_type": fixture.get("input_type"),
216
+ "created_from_skill_id": skill.get("skill_id"),
217
+ },
218
+ "author": "codex-p0-harness-eval",
219
+ }
220
+ created = client.post(
221
+ f"/lifecycle/{source_skill_id}/new-version",
222
+ payload,
223
+ label=f"new_version_for_harness_{purpose}_{fixture.get('source_id')}",
224
+ )
225
+ if created.get("http_error"):
226
+ raise RuntimeError(f"Failed to create harness draft for {fixture.get('source_id')}: {created}")
227
+ return client.get(f"/skills/{created['skill_id']}/full", label=f"harness_draft_full_{purpose}_{fixture.get('source_id')}")
228
+
229
+
230
+ def latest_version_skill_id(client: ApiClient, skill: dict[str, Any]) -> str:
231
+ versions = client.get(f"/skills/{skill['skill_id']}/versions", label=f"versions_{skill.get('skill_id')}")
232
+ if not isinstance(versions, list) or not versions:
233
+ return str(skill["skill_id"])
234
+ latest = max(
235
+ (item for item in versions if isinstance(item, dict) and item.get("skill_id")),
236
+ key=lambda item: (parse_version(str(item.get("version") or "")), str(item.get("updated_at") or "")),
237
+ default=skill,
238
+ )
239
+ return str(latest.get("skill_id") or skill["skill_id"])
240
+
241
+
242
+ def parse_version(version: str) -> tuple[int, ...]:
243
+ parts: list[int] = []
244
+ for chunk in str(version or "").split("."):
245
+ try:
246
+ parts.append(int(chunk))
247
+ except ValueError:
248
+ parts.append(0)
249
+ return tuple(parts)
250
+
251
+
252
+ def render_contract_echo_code(verifier_specs: list[dict[str, Any]], *, input_type: str) -> str:
253
+ lines = [
254
+ "output['result'] = {}",
255
+ "output['evidence'] = ['p0 harness contract evidence']",
256
+ "output['verifier'] = {'passed': True, 'checked': ['p0 harness deterministic contract']}",
257
+ ]
258
+ if input_type == "past_skills":
259
+ lines.append("output['validation'] = {'passed': True, 'source': 'p0 harness deterministic contract'}")
260
+ for spec in verifier_specs:
261
+ if not isinstance(spec, dict):
262
+ continue
263
+ path = str(spec.get("path") or "")
264
+ if not path.startswith("output."):
265
+ continue
266
+ field_path = path.split(".", 1)[1]
267
+ value = value_for_spec(spec, path)
268
+ lines.extend(nested_assignment(field_path, value))
269
+ return "\n".join(lines)
270
+
271
+
272
+ def value_for_spec(spec: dict[str, Any], path: str) -> Any:
273
+ spec_type = str(spec.get("type") or "")
274
+ if spec_type == "json_equals" and "value" in spec:
275
+ return spec["value"]
276
+ if spec_type in {"json_array", "json_array_nonempty"}:
277
+ return [f"p0 harness value for {path}"]
278
+ if spec_type in {"json_object", "json_object_nonempty", "json_exists"} and path.endswith((".result", ".validation", ".verifier")):
279
+ return {"passed": True, "source": "p0 harness"}
280
+ if spec_type in {"json_object", "json_object_nonempty"}:
281
+ return {"value": "p0 harness"}
282
+ return f"p0 harness value for {path}"
283
+
284
+
285
+ def nested_assignment(field_path: str, value: Any) -> list[str]:
286
+ parts = [part for part in field_path.split(".") if part]
287
+ if not parts:
288
+ return []
289
+ if len(parts) == 1:
290
+ return [f"output[{parts[0]!r}] = {value!r}"]
291
+ lines: list[str] = []
292
+ cursor = "output"
293
+ for part in parts[:-1]:
294
+ lines.append(f"if not isinstance({cursor}.get({part!r}), dict):")
295
+ lines.append(f" {cursor}[{part!r}] = {{}}")
296
+ cursor = f"{cursor}[{part!r}]"
297
+ lines.append(f"{cursor}[{parts[-1]!r}] = {value!r}")
298
+ return lines
299
+
300
+
301
+ def build_positive_test_case(skill: dict[str, Any], fixture: dict[str, Any], *, timeout_s: int) -> dict[str, Any]:
302
+ input_schema = (skill.get("interface") or {}).get("input_schema") or {}
303
+ input_data = example_from_schema(input_schema)
304
+ input_data.update(positive_overrides(str(fixture.get("input_type") or ""), fixture))
305
+ return {
306
+ "test_id": f"positive-{fixture['source_id']}",
307
+ "name": f"P0 positive harness for {fixture['source_id']}",
308
+ "goal": f"Run generated Skill for {fixture['input_type']} source {fixture['source_id']} with valid inputs.",
309
+ "input_data": input_data,
310
+ "verifier_specs": skill.get("evaluation", {}).get("verifier_specs") or [],
311
+ "timeout_s": timeout_s,
312
+ }
313
+
314
+
315
+ def build_negative_test_case(skill: dict[str, Any], fixture: dict[str, Any], *, timeout_s: int) -> dict[str, Any]:
316
+ input_schema = (skill.get("interface") or {}).get("input_schema") or {}
317
+ input_data = example_from_schema(input_schema)
318
+ required_path = ""
319
+ for required in input_schema.get("required") or []:
320
+ if isinstance(required, str):
321
+ input_data[required] = empty_value(input_data.get(required))
322
+ required_path = f"input.{required}"
323
+ break
324
+ if fixture.get("input_type") == "script":
325
+ input_data["dry_run"] = False
326
+ input_data["allowed_paths"] = []
327
+ required_path = "input.dry_run"
328
+ verifier_specs = list(skill.get("evaluation", {}).get("verifier_specs") or [])
329
+ if required_path and not any(str(spec.get("path") or "") == required_path for spec in verifier_specs if isinstance(spec, dict)):
330
+ verifier_specs.insert(0, {"type": "json_nonempty", "path": required_path})
331
+ return {
332
+ "test_id": f"negative-{fixture['source_id']}",
333
+ "name": f"P0 negative harness for {fixture['source_id']}",
334
+ "goal": f"Reject generated Skill for {fixture['input_type']} source {fixture['source_id']} when required input is invalid.",
335
+ "input_data": input_data,
336
+ "verifier_specs": verifier_specs,
337
+ "timeout_s": timeout_s,
338
+ }
339
+
340
+
341
+ def positive_overrides(input_type: str, fixture: dict[str, Any]) -> dict[str, Any]:
342
+ local_path = str(fixture.get("local_path") or fixture.get("content_file") or "")
343
+ if input_type == "document":
344
+ return {
345
+ "task": f"Extract a reusable procedure from {fixture.get('source_id')}.",
346
+ "document_context": f"Local fixture: {local_path}",
347
+ "allowed_operations": ["read_context", "extract_steps", "cite_evidence"],
348
+ }
349
+ if input_type == "api_doc":
350
+ return {
351
+ "task": f"Prepare an API call contract from {fixture.get('source_id')}.",
352
+ "endpoint": "/demo",
353
+ "parameters": {"demo": True},
354
+ }
355
+ if input_type == "script":
356
+ return {
357
+ "task": f"Dry-run analyze script fixture {fixture.get('source_id')}.",
358
+ "script_context": f"Local fixture: {local_path}",
359
+ "dry_run": True,
360
+ "allowed_paths": [local_path or "fixture.sh"],
361
+ }
362
+ if input_type == "past_skills":
363
+ return {
364
+ "task": f"Normalize and execute imported legacy Skill {fixture.get('source_id')}.",
365
+ "source_context": f"Local fixture: {local_path}",
366
+ "artifact_type": "document",
367
+ "source_files": [local_path] if local_path else [],
368
+ "project_files": [],
369
+ "target_runtime": "SkillOS local harness",
370
+ }
371
+ return {
372
+ "task": f"Replay and summarize trajectory {fixture.get('source_id')}.",
373
+ "context": {"source_id": fixture.get("source_id"), "local_path": local_path},
374
+ }
375
+
376
+
377
+ def example_from_schema(schema: dict[str, Any]) -> dict[str, Any]:
378
+ properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
379
+ required = schema.get("required") if isinstance(schema.get("required"), list) else list(properties)
380
+ return {
381
+ name: example_value(properties.get(name, {}))
382
+ for name in required
383
+ if isinstance(name, str)
384
+ }
385
+
386
+
387
+ def example_value(schema: dict[str, Any]) -> Any:
388
+ schema_type = schema.get("type")
389
+ if schema_type == "boolean":
390
+ return True
391
+ if schema_type == "array":
392
+ return ["demo"]
393
+ if schema_type == "object":
394
+ return {"demo": True}
395
+ if schema_type in {"number", "integer"}:
396
+ return 1
397
+ return "demo"
398
+
399
+
400
+ def empty_value(value: Any) -> Any:
401
+ if isinstance(value, list):
402
+ return []
403
+ if isinstance(value, dict):
404
+ return {}
405
+ if isinstance(value, bool):
406
+ return False
407
+ if isinstance(value, (int, float)):
408
+ return 0
409
+ return ""
410
+
411
+
412
+ def summarize_loop(payload: dict[str, Any]) -> dict[str, Any]:
413
+ attempts = payload.get("attempts") if isinstance(payload.get("attempts"), list) else []
414
+ return {
415
+ "http_error": payload.get("http_error"),
416
+ "status": payload.get("status"),
417
+ "promotion_allowed": bool(payload.get("promotion_allowed")),
418
+ "attempt_count": payload.get("attempt_count"),
419
+ "repair_count": len(payload.get("repairs") if isinstance(payload.get("repairs"), list) else []),
420
+ "final_state": payload.get("final_state"),
421
+ "score": payload.get("score"),
422
+ "loop_id": payload.get("loop_id"),
423
+ "evidence_path": payload.get("evidence_path"),
424
+ "failure_reasons": [
425
+ attempt.get("failure_reason")
426
+ for attempt in attempts
427
+ if isinstance(attempt, dict) and attempt.get("failure_reason")
428
+ ],
429
+ }
430
+
431
+
432
+ def score_checks(checks: list[dict[str, Any]]) -> dict[str, Any]:
433
+ by_type: dict[str, list[dict[str, Any]]] = defaultdict(list)
434
+ for check in checks:
435
+ by_type[str(check.get("input_type") or "")].append(check)
436
+ positive_pass = sum(1 for item in checks if item.get("positive_pass"))
437
+ negative_rejected = sum(1 for item in checks if item.get("negative_rejected"))
438
+ total = len(checks)
439
+ return {
440
+ "sample_count": total,
441
+ "positive_pass_rate": round(positive_pass / total, 3) if total else 0.0,
442
+ "negative_rejection_rate": round(negative_rejected / total, 3) if total else 0.0,
443
+ "input_types_meeting_minimum": sum(1 for items in by_type.values() if len(items) >= 3),
444
+ "by_input_type": {
445
+ input_type: {
446
+ "count": len(items),
447
+ "positive_pass_rate": round(sum(1 for item in items if item.get("positive_pass")) / len(items), 3),
448
+ "negative_rejection_rate": round(sum(1 for item in items if item.get("negative_rejected")) / len(items), 3),
449
+ }
450
+ for input_type, items in sorted(by_type.items())
451
+ },
452
+ }
453
+
454
+
455
+ def render_report(summary: dict[str, Any]) -> str:
456
+ scores = summary.get("scores", {})
457
+ lines = [
458
+ "# SkillOS P0 Harness Positive / Negative Report",
459
+ "",
460
+ f"- Started: `{summary.get('started_at')}`",
461
+ f"- Finished: `{summary.get('finished_at')}`",
462
+ f"- API base: `{summary.get('api_base')}`",
463
+ f"- Run dir: `{summary.get('run_dir')}`",
464
+ f"- Sample count: `{scores.get('sample_count', 0)}`",
465
+ f"- Positive pass rate: `{scores.get('positive_pass_rate', 0.0)}`",
466
+ f"- Negative rejection rate: `{scores.get('negative_rejection_rate', 0.0)}`",
467
+ f"- Input types with at least 3 samples: `{scores.get('input_types_meeting_minimum', 0)}/5`",
468
+ "",
469
+ "## By Input Type",
470
+ "",
471
+ "| input_type | count | positive pass | negative rejected |",
472
+ "| --- | ---: | ---: | ---: |",
473
+ ]
474
+ for input_type, item in (scores.get("by_input_type") or {}).items():
475
+ lines.append(
476
+ f"| {input_type} | {item['count']} | {item['positive_pass_rate']:.3f} | {item['negative_rejection_rate']:.3f} |"
477
+ )
478
+ lines.extend(
479
+ [
480
+ "",
481
+ "## Checks",
482
+ "",
483
+ "| input_type | source_id | positive | negative rejected | attempts | repairs | final_state | evidence_path |",
484
+ "| --- | --- | --- | --- | ---: | ---: | --- | --- |",
485
+ ]
486
+ )
487
+ for item in summary.get("checks", []):
488
+ lines.append(
489
+ "| {input_type} | {source_id} | {positive} | {negative} | {attempts} | {repairs} | {state} | `{evidence}` |".format(
490
+ input_type=item.get("input_type"),
491
+ source_id=item.get("source_id"),
492
+ positive=item.get("positive_pass"),
493
+ negative=item.get("negative_rejected"),
494
+ attempts=item.get("attempt_count"),
495
+ repairs=item.get("repair_count"),
496
+ state=item.get("final_state"),
497
+ evidence=item.get("evidence_path") or "",
498
+ )
499
+ )
500
+ lines.extend(
501
+ [
502
+ "",
503
+ "## Boundary",
504
+ "",
505
+ "These checks prove that representative generated Skills can be put through the S2 -> harness -> verifier -> S3 gate with deterministic local contracts. They do not prove open-world semantic correctness or official SkillsBench sandbox scores.",
506
+ "",
507
+ ]
508
+ )
509
+ return "\n".join(lines)
510
+
511
+
512
+ def safe_filename(value: str) -> str:
513
+ normalized = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value or "")).strip("._")
514
+ return normalized[:120] or "artifact"
515
+
516
+
517
+ if __name__ == "__main__":
518
+ raise SystemExit(main())
skillwiki-frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
skillwiki-frontend/Dockerfile ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine AS builder
2
+ WORKDIR /app
3
+ COPY package*.json ./
4
+ RUN npm ci
5
+ COPY . .
6
+ RUN npm run build
7
+
8
+ FROM nginx:alpine
9
+ COPY --from=builder /app/dist /usr/share/nginx/html
10
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
11
+ EXPOSE 80
skillwiki-frontend/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
+
18
+ ```js
19
+ export default defineConfig([
20
+ globalIgnores(['dist']),
21
+ {
22
+ files: ['**/*.{ts,tsx}'],
23
+ extends: [
24
+ // Other configs...
25
+
26
+ // Remove tseslint.configs.recommended and replace with this
27
+ tseslint.configs.recommendedTypeChecked,
28
+ // Alternatively, use this for stricter rules
29
+ tseslint.configs.strictTypeChecked,
30
+ // Optionally, add this for stylistic rules
31
+ tseslint.configs.stylisticTypeChecked,
32
+
33
+ // Other configs...
34
+ ],
35
+ languageOptions: {
36
+ parserOptions: {
37
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
+ tsconfigRootDir: import.meta.dirname,
39
+ },
40
+ // other options...
41
+ },
42
+ },
43
+ ])
44
+ ```
45
+
46
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
+
48
+ ```js
49
+ // eslint.config.js
50
+ import reactX from 'eslint-plugin-react-x'
51
+ import reactDom from 'eslint-plugin-react-dom'
52
+
53
+ export default defineConfig([
54
+ globalIgnores(['dist']),
55
+ {
56
+ files: ['**/*.{ts,tsx}'],
57
+ extends: [
58
+ // Other configs...
59
+ // Enable lint rules for React
60
+ reactX.configs['recommended-typescript'],
61
+ // Enable lint rules for React DOM
62
+ reactDom.configs.recommended,
63
+ ],
64
+ languageOptions: {
65
+ parserOptions: {
66
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
+ tsconfigRootDir: import.meta.dirname,
68
+ },
69
+ // other options...
70
+ },
71
+ },
72
+ ])
73
+ ```
skillwiki-frontend/eslint.config.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ globals: globals.browser,
20
+ },
21
+ },
22
+ ])
skillwiki-frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>SkillWiki</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
skillwiki-frontend/nginx.conf ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ server {
2
+ listen 80;
3
+ root /usr/share/nginx/html;
4
+ index index.html;
5
+
6
+ # Frontend route fallback
7
+ location / {
8
+ try_files $uri $uri/ /index.html;
9
+ }
10
+
11
+ # Proxy API requests to the backend
12
+ location /api/ {
13
+ proxy_pass http://api:8000;
14
+ proxy_set_header Host $host;
15
+ proxy_set_header X-Real-IP $remote_addr;
16
+ }
17
+
18
+ # Proxy WebSocket traffic
19
+ location /ws {
20
+ proxy_pass http://api:8000;
21
+ proxy_http_version 1.1;
22
+ proxy_set_header Upgrade $http_upgrade;
23
+ proxy_set_header Connection "upgrade";
24
+ proxy_set_header Host $host;
25
+ }
26
+
27
+ # Static asset cache
28
+ location /assets/ {
29
+ expires 1y;
30
+ add_header Cache-Control "public, immutable";
31
+ }
32
+ }
skillwiki-frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
skillwiki-frontend/package.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "skillwiki-frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@ant-design/charts": "^2.6.7",
14
+ "@ant-design/icons": "^6.1.1",
15
+ "@antv/g6": "^5.1.0",
16
+ "antd": "^6.3.6",
17
+ "axios": "^1.15.2",
18
+ "framer-motion": "^12.38.0",
19
+ "react": "^19.2.5",
20
+ "react-dom": "^19.2.5",
21
+ "react-router-dom": "^7.14.2",
22
+ "zustand": "^5.0.12"
23
+ },
24
+ "devDependencies": {
25
+ "@eslint/js": "^10.0.1",
26
+ "@types/node": "^24.12.2",
27
+ "@types/react": "^19.2.14",
28
+ "@types/react-dom": "^19.2.3",
29
+ "@types/react-router-dom": "^5.3.3",
30
+ "@vitejs/plugin-react": "^6.0.1",
31
+ "eslint": "^10.2.1",
32
+ "eslint-plugin-react-hooks": "^7.1.1",
33
+ "eslint-plugin-react-refresh": "^0.5.2",
34
+ "globals": "^17.5.0",
35
+ "puppeteer": "^25.1.0",
36
+ "sharp": "^0.35.1",
37
+ "typescript": "~6.0.2",
38
+ "typescript-eslint": "^8.58.2",
39
+ "vite": "^8.0.10"
40
+ }
41
+ }
skillwiki-frontend/public/favicon.svg ADDED
skillwiki-frontend/public/icons.svg ADDED
skillwiki-frontend/scripts/capture_figures.mjs ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Captures four paper figures from the running SkillWiki frontend.
3
+ * Run: node scripts/capture_figures.mjs
4
+ * Output: ../figures/figure3_system/fig3_{scene}.png (1280×720 each)
5
+ */
6
+ import puppeteer from 'puppeteer'
7
+ import { mkdirSync } from 'fs'
8
+ import { resolve, dirname } from 'path'
9
+ import { fileURLToPath } from 'url'
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url))
12
+ const BASE_URL = 'http://localhost:3000'
13
+ const OUT_DIR = resolve(__dirname, '../../figures/figure3_system')
14
+ const W = 1280
15
+ const H = 720
16
+
17
+ mkdirSync(OUT_DIR, { recursive: true })
18
+
19
+ async function sleep(ms) {
20
+ return new Promise(r => setTimeout(r, ms))
21
+ }
22
+
23
+ const browser = await puppeteer.launch({
24
+ headless: 'new',
25
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
26
+ })
27
+
28
+ async function newPage() {
29
+ const page = await browser.newPage()
30
+ await page.setViewport({ width: W, height: H, deviceScaleFactor: 2 })
31
+ await page.evaluateOnNewDocument(() => {
32
+ localStorage.setItem(
33
+ 'skillwiki-app-store',
34
+ JSON.stringify({ state: { lang: 'en', darkMode: false }, version: 0 }),
35
+ )
36
+ })
37
+ return page
38
+ }
39
+
40
+ // ──────────────────────────────────────────────────────────────
41
+ // 1. Knowledge Ingestion — API Doc tab + content + parse running
42
+ // ──────────────────────────────────────────────────────────────
43
+ {
44
+ console.log('Capturing: knowledge_ingestion ...')
45
+ const page = await newPage()
46
+ await page.goto(`${BASE_URL}/ingest`, { waitUntil: 'networkidle2', timeout: 30000 })
47
+ await page.waitForSelector('.ant-tabs-tab', { timeout: 10000 })
48
+ await sleep(600)
49
+
50
+ // Scroll to top
51
+ await page.evaluate(() => window.scrollTo(0, 0))
52
+
53
+ // Switch to "API Doc" tab
54
+ const tabs = await page.$$('.ant-tabs-tab')
55
+ for (const tab of tabs) {
56
+ const text = await tab.evaluate(el => el.textContent)
57
+ if (text && text.includes('API Doc')) {
58
+ await tab.click()
59
+ break
60
+ }
61
+ }
62
+ await sleep(500)
63
+
64
+ // Fill textarea with API doc content
65
+ const apiDocContent = `GET /v1/models
66
+ Description: List all available language models and their metadata.
67
+ Headers: { "Authorization": "Bearer <api_key>" }
68
+ Response: {
69
+ "data": [
70
+ { "id": "string", "object": "model", "created": "integer", "owned_by": "string" }
71
+ ],
72
+ "has_more": false
73
+ }
74
+ Errors: 401 Unauthorized when API key is missing or invalid.`
75
+
76
+ // Clear and type content using Puppeteer's type() to properly trigger React state
77
+ const ta = await page.$('textarea')
78
+ if (ta) {
79
+ await ta.click({ clickCount: 3 })
80
+ await ta.press('Backspace')
81
+ await sleep(200)
82
+ await ta.type(`GET /v1/models\nDescription: List all available language models and metadata.\nHeaders: { "Authorization": "Bearer <api_key>" }\nResponse: { "data": [{ "id": "string", "object": "model", "owned_by": "string" }], "has_more": false }\nErrors: 401 Unauthorized when API key is missing.`, { delay: 0 })
83
+ }
84
+ await sleep(400)
85
+
86
+ // Scroll to top to show tabs + textarea filled
87
+ await page.evaluate(() => window.scrollTo(0, 0))
88
+ await sleep(200)
89
+
90
+ // Click Parse button — scroll it into view first
91
+ const parseClicked = await page.evaluate(() => {
92
+ const all = Array.from(document.querySelectorAll('button'))
93
+ const parseBtn = all.find(b => b.textContent && b.textContent.includes('Parse'))
94
+ if (!parseBtn) return false
95
+ parseBtn.scrollIntoView({ behavior: 'instant', block: 'center' })
96
+ parseBtn.click()
97
+ return true
98
+ })
99
+ console.log(` Parse button clicked: ${parseClicked}`)
100
+ await sleep(500)
101
+ // Scroll back to top after clicking
102
+ await page.evaluate(() => window.scrollTo(0, 0))
103
+
104
+ // Wait for parsed result panel to appear (badge "Ready" or unit count card)
105
+ try {
106
+ await page.waitForFunction(
107
+ () => {
108
+ const body = document.body.innerText
109
+ return body.includes('Ready') || body.includes('Parsed Units') || body.includes('Unit count')
110
+ },
111
+ { timeout: 15000 },
112
+ )
113
+ console.log(' Parse result detected')
114
+ } catch {
115
+ console.warn(' Parse result not detected, using fixed delay')
116
+ await sleep(4000)
117
+ }
118
+
119
+ await sleep(600)
120
+ await page.evaluate(() => window.scrollTo(0, 0))
121
+ await sleep(200)
122
+
123
+ const outPath = resolve(OUT_DIR, 'fig3_knowledge_ingestion.png')
124
+ await page.screenshot({ path: outPath, fullPage: false })
125
+ console.log(` Saved: ${outPath}`)
126
+ await page.close()
127
+ }
128
+
129
+ // ──────────────────────────────────────────────────────────────
130
+ // 2. Skill Exploration — full nebula + real node via ?preselect=
131
+ // ────────────────────────────────────────────���─────────────────
132
+ {
133
+ console.log('Capturing: skill_exploration ...')
134
+ const page = await newPage()
135
+ const REAL_SKILL_ID = 'test_graph_review_output'
136
+
137
+ await page.goto(`${BASE_URL}/graph?preselect=${REAL_SKILL_ID}`, { waitUntil: 'load', timeout: 30000 })
138
+ await page.waitForFunction(
139
+ () => document.querySelectorAll('.ant-card').length >= 2,
140
+ { timeout: 30000, polling: 500 },
141
+ )
142
+ await sleep(1000)
143
+ try {
144
+ await page.waitForSelector('canvas', { timeout: 25000 })
145
+ } catch {
146
+ console.warn(' canvas not found, proceeding anyway')
147
+ }
148
+
149
+ // Wait for force layout + preselect effect to fire
150
+ await sleep(8000)
151
+ await page.evaluate(() => window.scrollTo(0, 0))
152
+ await sleep(300)
153
+
154
+ const nodeSelected = await page.evaluate(() => {
155
+ const items = document.querySelectorAll('.ant-card .ant-descriptions .ant-descriptions-item')
156
+ return items.length > 0
157
+ })
158
+
159
+ const outPath = resolve(OUT_DIR, 'fig3_skill_exploration.png')
160
+ await page.screenshot({ path: outPath, fullPage: false })
161
+ console.log(` Saved: ${outPath} (node selected: ${nodeSelected})`)
162
+ await page.close()
163
+ }
164
+
165
+ // ──────────────────────────────────────────────────────────────
166
+ // 3. Governance (Dashboard)
167
+ // ──────────────────────────────────────────────────────────────
168
+ {
169
+ console.log('Capturing: governance ...')
170
+ const page = await newPage()
171
+ await page.goto(`${BASE_URL}/`, { waitUntil: 'networkidle2', timeout: 30000 })
172
+ await page.waitForSelector('.ant-statistic', { timeout: 10000 })
173
+ await sleep(1800)
174
+
175
+ const outPath = resolve(OUT_DIR, 'fig3_governance.png')
176
+ await page.screenshot({ path: outPath, fullPage: false })
177
+ console.log(` Saved: ${outPath}`)
178
+ await page.close()
179
+ }
180
+
181
+ // ──────────────────────────────────────────────────────────────
182
+ // 4. Evolution
183
+ // ──────────────────────────────────────────────────────────────
184
+ {
185
+ console.log('Capturing: evolution ...')
186
+ const page = await newPage()
187
+ await page.goto(`${BASE_URL}/evolution`, { waitUntil: 'networkidle2', timeout: 30000 })
188
+ await page.waitForSelector('.ant-statistic', { timeout: 10000 })
189
+ await sleep(2000)
190
+
191
+ const outPath = resolve(OUT_DIR, 'fig3_evolution.png')
192
+ await page.screenshot({ path: outPath, fullPage: false })
193
+ console.log(` Saved: ${outPath}`)
194
+ await page.close()
195
+ }
196
+
197
+ await browser.close()
198
+ console.log('\nAll figures saved to:', OUT_DIR)
skillwiki-frontend/src/App.css ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .counter {
2
+ font-size: 16px;
3
+ padding: 5px 10px;
4
+ border-radius: 5px;
5
+ color: var(--accent);
6
+ background: var(--accent-bg);
7
+ border: 2px solid transparent;
8
+ transition: border-color 0.3s;
9
+ margin-bottom: 24px;
10
+
11
+ &:hover {
12
+ border-color: var(--accent-border);
13
+ }
14
+ &:focus-visible {
15
+ outline: 2px solid var(--accent);
16
+ outline-offset: 2px;
17
+ }
18
+ }
19
+
20
+ .hero {
21
+ position: relative;
22
+
23
+ .base,
24
+ .framework,
25
+ .vite {
26
+ inset-inline: 0;
27
+ margin: 0 auto;
28
+ }
29
+
30
+ .base {
31
+ width: 170px;
32
+ position: relative;
33
+ z-index: 0;
34
+ }
35
+
36
+ .framework,
37
+ .vite {
38
+ position: absolute;
39
+ }
40
+
41
+ .framework {
42
+ z-index: 1;
43
+ top: 34px;
44
+ height: 28px;
45
+ transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
46
+ scale(1.4);
47
+ }
48
+
49
+ .vite {
50
+ z-index: 0;
51
+ top: 107px;
52
+ height: 26px;
53
+ width: auto;
54
+ transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
55
+ scale(0.8);
56
+ }
57
+ }
58
+
59
+ #center {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 25px;
63
+ place-content: center;
64
+ place-items: center;
65
+ flex-grow: 1;
66
+
67
+ @media (max-width: 1024px) {
68
+ padding: 32px 20px 24px;
69
+ gap: 18px;
70
+ }
71
+ }
72
+
73
+ #next-steps {
74
+ display: flex;
75
+ border-top: 1px solid var(--border);
76
+ text-align: left;
77
+
78
+ & > div {
79
+ flex: 1 1 0;
80
+ padding: 32px;
81
+ @media (max-width: 1024px) {
82
+ padding: 24px 20px;
83
+ }
84
+ }
85
+
86
+ .icon {
87
+ margin-bottom: 16px;
88
+ width: 22px;
89
+ height: 22px;
90
+ }
91
+
92
+ @media (max-width: 1024px) {
93
+ flex-direction: column;
94
+ text-align: center;
95
+ }
96
+ }
97
+
98
+ #docs {
99
+ border-right: 1px solid var(--border);
100
+
101
+ @media (max-width: 1024px) {
102
+ border-right: none;
103
+ border-bottom: 1px solid var(--border);
104
+ }
105
+ }
106
+
107
+ #next-steps ul {
108
+ list-style: none;
109
+ padding: 0;
110
+ display: flex;
111
+ gap: 8px;
112
+ margin: 32px 0 0;
113
+
114
+ .logo {
115
+ height: 18px;
116
+ }
117
+
118
+ a {
119
+ color: var(--text-h);
120
+ font-size: 16px;
121
+ border-radius: 6px;
122
+ background: var(--social-bg);
123
+ display: flex;
124
+ padding: 6px 12px;
125
+ align-items: center;
126
+ gap: 8px;
127
+ text-decoration: none;
128
+ transition: box-shadow 0.3s;
129
+
130
+ &:hover {
131
+ box-shadow: var(--shadow);
132
+ }
133
+ .button-icon {
134
+ height: 18px;
135
+ width: 18px;
136
+ }
137
+ }
138
+
139
+ @media (max-width: 1024px) {
140
+ margin-top: 20px;
141
+ flex-wrap: wrap;
142
+ justify-content: center;
143
+
144
+ li {
145
+ flex: 1 1 calc(50% - 8px);
146
+ }
147
+
148
+ a {
149
+ width: 100%;
150
+ justify-content: center;
151
+ box-sizing: border-box;
152
+ }
153
+ }
154
+ }
155
+
156
+ #spacer {
157
+ height: 88px;
158
+ border-top: 1px solid var(--border);
159
+ @media (max-width: 1024px) {
160
+ height: 48px;
161
+ }
162
+ }
163
+
164
+ .ticks {
165
+ position: relative;
166
+ width: 100%;
167
+
168
+ &::before,
169
+ &::after {
170
+ content: '';
171
+ position: absolute;
172
+ top: -4.5px;
173
+ border: 5px solid transparent;
174
+ }
175
+
176
+ &::before {
177
+ left: 0;
178
+ border-left-color: var(--border);
179
+ }
180
+ &::after {
181
+ right: 0;
182
+ border-right-color: var(--border);
183
+ }
184
+ }
skillwiki-frontend/src/App.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { lazy, Suspense } from 'react'
2
+ import { BrowserRouter, Route, Routes } from 'react-router-dom'
3
+ import { ConfigProvider, Spin, theme } from 'antd'
4
+ import enUS from 'antd/locale/en_US'
5
+ import zhCN from 'antd/locale/zh_CN'
6
+ import { useAppStore } from '@/store/appStore'
7
+ import AppLayout from '@/components/AppLayout'
8
+
9
+ const Dashboard = lazy(() => import('@/pages/Dashboard'))
10
+ const EvaluationDashboard = lazy(() => import('@/pages/EvaluationDashboard'))
11
+ const SkillWiki = lazy(() => import('@/pages/SkillWiki'))
12
+ const SkillGraph = lazy(() => import('@/pages/SkillGraph'))
13
+ const AgentExecution = lazy(() => import('@/pages/AgentExecution'))
14
+ const HarnessVerification = lazy(() => import('@/pages/HarnessVerification'))
15
+ const Evolution = lazy(() => import('@/pages/Evolution'))
16
+ const LifecycleDemo = lazy(() => import('@/pages/LifecycleDemo'))
17
+ const KnowledgeImport = lazy(() => import('@/pages/KnowledgeImport'))
18
+ const VersionControl = lazy(() => import('@/pages/VersionControl'))
19
+ const SelfEvolutionDemo = lazy(() => import('@/pages/SelfEvolutionDemo'))
20
+
21
+ function PageFallback() {
22
+ return (
23
+ <div style={{ minHeight: 360, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
24
+ <Spin size="large" description="Loading page..." />
25
+ </div>
26
+ )
27
+ }
28
+
29
+ export default function App() {
30
+ const darkMode = useAppStore(s => s.darkMode)
31
+ const lang = useAppStore(s => s.lang)
32
+
33
+ return (
34
+ <ConfigProvider
35
+ locale={lang === 'zh' ? zhCN : enUS}
36
+ theme={{
37
+ algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
38
+ token: {
39
+ colorPrimary: '#1677ff',
40
+ borderRadius: 8,
41
+ fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
42
+ },
43
+ }}
44
+ >
45
+ <BrowserRouter>
46
+ <AppLayout>
47
+ <Suspense fallback={<PageFallback />}>
48
+ <Routes>
49
+ <Route path="/" element={<Dashboard />} />
50
+ <Route path="/evaluation" element={<EvaluationDashboard />} />
51
+ <Route path="/wiki" element={<SkillWiki />} />
52
+ <Route path="/graph" element={<SkillGraph />} />
53
+ <Route path="/execution" element={<AgentExecution />} />
54
+ <Route path="/harness" element={<HarnessVerification />} />
55
+ <Route path="/evolution" element={<Evolution />} />
56
+ <Route path="/lifecycle" element={<LifecycleDemo />} />
57
+ <Route path="/ingest" element={<KnowledgeImport />} />
58
+ <Route path="/versions" element={<VersionControl />} />
59
+ <Route path="/demo" element={<SelfEvolutionDemo />} />
60
+ </Routes>
61
+ </Suspense>
62
+ </AppLayout>
63
+ </BrowserRouter>
64
+ </ConfigProvider>
65
+ )
66
+ }
skillwiki-frontend/src/api/client.ts ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios'
2
+ import type {
3
+ CandidateAuditResult,
4
+ CandidateCreateResponse,
5
+ CandidateSkillReviewRequest,
6
+ EvaluationDashboardResponse,
7
+ ExecutionExperienceUnit,
8
+ ExecutionResult,
9
+ ExecutionHistoryItem,
10
+ GraphData,
11
+ GraphViewData,
12
+ GraphViewMode,
13
+ HarnessLoopListResponse,
14
+ HarnessVerifyLoopRequest,
15
+ HarnessVerifyLoopResponse,
16
+ HeterogeneousGraphData,
17
+ HealthReport,
18
+ IngestResponse,
19
+ MaintenanceReviewRequest,
20
+ MaintenanceReviewResponse,
21
+ MaintenanceProposal,
22
+ MaintenanceProposalListResponse,
23
+ NewVersionRequest,
24
+ OverviewStats,
25
+ SkillReleaseRecord,
26
+ SkillRollbackRecord,
27
+ SkillCreateRequest,
28
+ SkillFull,
29
+ SkillGraphProjectionData,
30
+ SkillSearchResult,
31
+ SkillState,
32
+ SkillSummary,
33
+ SkillType,
34
+ SkillUpdateRequest,
35
+ SnapshotCommitRequest,
36
+ SnapshotCommitResponse,
37
+ SnapshotDiffRequest,
38
+ SnapshotDiffResponse,
39
+ SnapshotHistoryResponse,
40
+ SystemHealth,
41
+ } from './types'
42
+
43
+ const http = axios.create({ baseURL: '/api/v1' })
44
+
45
+ // ── Skills ────────────────────────────────────────────────────────────────────
46
+
47
+ export const skillsApi = {
48
+ list: (params?: { state?: SkillState; skill_type?: SkillType; limit?: number; offset?: number }) =>
49
+ http.get<SkillSummary[]>('/skills', { params }).then(r => r.data),
50
+
51
+ get: (id: string) =>
52
+ http.get<SkillSummary>(`/skills/${id}`).then(r => r.data),
53
+
54
+ getFull: (id: string) =>
55
+ http.get<SkillFull>(`/skills/${id}/full`).then(r => r.data),
56
+
57
+ create: (request: SkillCreateRequest) =>
58
+ http.post<SkillSummary>('/skills', request).then(r => r.data),
59
+
60
+ update: (id: string, request: SkillUpdateRequest) =>
61
+ http.patch<SkillSummary>(`/skills/${id}`, request).then(r => r.data),
62
+
63
+ search: (query: string, limit = 20) =>
64
+ http.post<SkillSearchResult[]>('/skills/search', { query, limit }).then(r => r.data),
65
+
66
+ versions: (id: string) =>
67
+ http.get<SkillSummary[]>(`/skills/${id}/versions`).then(r => r.data),
68
+
69
+ delete: (id: string) =>
70
+ http.delete(`/skills/${id}`).then(r => r.data),
71
+ }
72
+
73
+ // ── Lifecycle ─────────────────────────────────────────────────────────────────
74
+
75
+ export const lifecycleApi = {
76
+ release: (id: string) =>
77
+ http.post<SkillSummary>(`/lifecycle/${id}/release`).then(r => r.data),
78
+
79
+ deprecate: (id: string, reason: string) =>
80
+ http.post<SkillSummary>(`/lifecycle/${id}/deprecate`, { reason }).then(r => r.data),
81
+
82
+ transition: (id: string, new_state: SkillState, reason = '') =>
83
+ http.post<SkillSummary>(`/lifecycle/${id}/transition`, { new_state, reason }).then(r => r.data),
84
+
85
+ review: (id: string) =>
86
+ http.post(`/lifecycle/${id}/review`).then(r => r.data),
87
+
88
+ reviewAndRelease: (id: string) =>
89
+ http.post<SkillSummary>(`/lifecycle/${id}/review-and-release`).then(r => r.data),
90
+
91
+ newVersion: (
92
+ id: string,
93
+ requestOrBump: NewVersionRequest | 'major' | 'minor' | 'patch' = 'patch',
94
+ ) => {
95
+ const request = typeof requestOrBump === 'string' ? { bump: requestOrBump } : requestOrBump
96
+ return http.post<SkillSummary>(`/lifecycle/${id}/new-version`, request).then(r => r.data)
97
+ },
98
+
99
+ getDiff: (id: string, compare_to?: string) =>
100
+ http.get<Record<string, unknown>>(`/lifecycle/${id}/diff`, { params: compare_to ? { compare_to } : {} }).then(r => r.data),
101
+
102
+ createSnapshot: (id: string, request: SnapshotCommitRequest = {}) =>
103
+ http.post<SnapshotCommitResponse>(`/lifecycle/${id}/snapshot`, request).then(r => r.data),
104
+
105
+ snapshotHistory: (id: string, max_count = 20) =>
106
+ http.get<SnapshotHistoryResponse>(`/lifecycle/${id}/snapshot/history`, { params: { max_count } }).then(r => r.data),
107
+
108
+ snapshotDiff: (id: string, request: SnapshotDiffRequest) =>
109
+ http.get<SnapshotDiffResponse>(`/lifecycle/${id}/snapshot/diff`, { params: request }).then(r => r.data),
110
+
111
+ releaseTag: (id: string, ref = 'HEAD') =>
112
+ http.post<SkillReleaseRecord>(`/lifecycle/${id}/release-tag`, { ref }).then(r => r.data),
113
+
114
+ restoreSnapshot: (id: string, source_ref: string) =>
115
+ http.post<SkillRollbackRecord>(`/lifecycle/${id}/rollback`, { source_ref }).then(r => r.data),
116
+
117
+ proposeMaintenanceChange: (id: string, request: MaintenanceReviewRequest) =>
118
+ http.post<MaintenanceReviewResponse>(`/lifecycle/${id}/propose-maintenance-change`, request).then(r => r.data),
119
+ }
120
+
121
+ // ── Graph ─────────────────────────────────────────────────────────────────────
122
+
123
+ export const graphApi = {
124
+ full: (limit = 200) =>
125
+ http.get<GraphData>('/graph', { params: { limit } }).then(r => r.data),
126
+
127
+ subgraph: (skill_id: string, depth = 2) =>
128
+ http.post<GraphData>('/graph/subgraph', { skill_id, depth }).then(r => r.data),
129
+
130
+ heterogeneous: () =>
131
+ http.get<HeterogeneousGraphData>('/graph/heterogeneous').then(r => r.data),
132
+
133
+ skillOnlyProjection: () =>
134
+ http.get<SkillGraphProjectionData>('/graph/projection/skill-only').then(r => r.data),
135
+
136
+ stats: () =>
137
+ http.get<Record<string, unknown>>('/graph/stats/overview').then(r => r.data),
138
+
139
+ view: (view: GraphViewMode, limit = 300) =>
140
+ http.get<GraphViewData>('/graph/view', { params: { view, limit } }).then(r => r.data),
141
+ }
142
+
143
+ // ── Execution ─────────────────────────────────────────────────────────────────
144
+
145
+ export const executionApi = {
146
+ executeSkill: (skill_id: string, inputs: Record<string, unknown> = {}) =>
147
+ http.post<ExecutionResult>('/execution/skill', { skill_id, inputs }).then(r => r.data),
148
+
149
+ executePlan: (goal: string, context: Record<string, unknown> = {}) =>
150
+ http.post<ExecutionResult>('/execution/plan', { goal, context }).then(r => r.data),
151
+
152
+ getState: () =>
153
+ http.get<Record<string, unknown>>('/execution/state').then(r => r.data),
154
+
155
+ resetState: () =>
156
+ http.delete('/execution/state').then(r => r.data),
157
+
158
+ history: () =>
159
+ http.get<ExecutionHistoryItem[]>('/execution/history').then(r => r.data),
160
+
161
+ experience: (execution_id: string) =>
162
+ http.get<ExecutionExperienceUnit>(`/execution/history/${execution_id}/experience`).then(r => r.data),
163
+ }
164
+
165
+ // ── Evolution ─────────────────────────────────────────────────────────────────
166
+
167
+ export const harnessApi = {
168
+ runVerifyLoop: (skill_id: string, request: HarnessVerifyLoopRequest = {}) =>
169
+ http.post<HarnessVerifyLoopResponse>(`/harness/${skill_id}/verify-loop`, request).then(r => r.data),
170
+
171
+ list: (limit = 20) =>
172
+ http.get<HarnessLoopListResponse>('/harness', { params: { limit } }).then(r => r.data),
173
+
174
+ get: (loop_id: string) =>
175
+ http.get<Record<string, unknown>>(`/harness/${loop_id}`).then(r => r.data),
176
+ }
177
+
178
+ export const evolutionApi = {
179
+ systemHealth: () =>
180
+ http.get<SystemHealth>('/evolution/health').then(r => r.data),
181
+
182
+ skillHealth: (id: string) =>
183
+ http.get<HealthReport>(`/evolution/health/${id}`).then(r => r.data),
184
+
185
+ repair: (id: string) =>
186
+ http.post(`/evolution/repair/${id}`).then(r => r.data),
187
+
188
+ runCycle: () =>
189
+ http.post('/evolution/cycle').then(r => r.data),
190
+
191
+ proposals: (status?: MaintenanceProposal['status']) =>
192
+ http.get<MaintenanceProposalListResponse>('/evolution/proposals', { params: status ? { status } : {} }).then(r => r.data),
193
+
194
+ acceptProposal: (id: string) =>
195
+ http.post<MaintenanceProposal>(`/evolution/proposals/${id}/accept`).then(r => r.data),
196
+
197
+ rejectProposal: (id: string) =>
198
+ http.post<MaintenanceProposal>(`/evolution/proposals/${id}/reject`).then(r => r.data),
199
+ }
200
+
201
+ // ── Ingest ────────────────────────────────────────────────────────────────────
202
+
203
+ export const ingestApi = {
204
+ parse: (source_type: string, content: string) =>
205
+ http.post<IngestResponse>('/ingest/parse', { source_type, content }).then(r => r.data),
206
+
207
+ parseAndCreate: (source_type: string, content: string) =>
208
+ http.post<IngestResponse>('/ingest/parse-and-create', { source_type, content }).then(r => r.data),
209
+
210
+ auditCandidate: (request: CandidateSkillReviewRequest) =>
211
+ http.post<CandidateAuditResult>('/ingest/audit-candidate', request).then(r => r.data),
212
+
213
+ createCandidate: (request: CandidateSkillReviewRequest) =>
214
+ http.post<CandidateCreateResponse>('/ingest/create-candidate', request).then(r => r.data),
215
+ }
216
+
217
+ // ── Stats ─────────────────────────────────────────────────────────────────────
218
+
219
+ export const evaluationApi = {
220
+ dashboard: () =>
221
+ http.get<EvaluationDashboardResponse>('/evaluation/dashboard').then(r => r.data),
222
+ }
223
+
224
+ export interface EvolutionStats {
225
+ total_skills: number
226
+ auto_generated: number
227
+ manual: number
228
+ avg_reuse_rate: number
229
+ avg_success_rate: number
230
+ version_improved_count: number
231
+ skills_by_category: Record<string, number>
232
+ recent_activity: {
233
+ skill_id: string
234
+ name: string
235
+ event: string
236
+ state: string
237
+ time: string
238
+ }[]
239
+ }
240
+
241
+ export const statsApi = {
242
+ overview: async (): Promise<OverviewStats> => {
243
+ await Promise.all([
244
+ skillsApi.list({ limit: 1 }),
245
+ evolutionApi.systemHealth().catch(() => null),
246
+ ])
247
+ const allSkills = await skillsApi.list({ limit: 200 })
248
+ const byState: Record<string, number> = {}
249
+ const byType: Record<string, number> = {}
250
+ let totalExec = 0
251
+ let successRateSum = 0
252
+ let ratedCount = 0
253
+ for (const s of allSkills) {
254
+ byState[s.state] = (byState[s.state] || 0) + 1
255
+ byType[s.skill_type] = (byType[s.skill_type] || 0) + 1
256
+ totalExec += s.metrics.total_executions
257
+ if (s.metrics.total_executions >= 5) {
258
+ successRateSum += s.metrics.success_rate
259
+ ratedCount++
260
+ }
261
+ }
262
+ return {
263
+ total_skills: allSkills.length,
264
+ by_state: byState,
265
+ by_type: byType,
266
+ total_executions: totalExec,
267
+ avg_success_rate: ratedCount > 0 ? successRateSum / ratedCount : 1,
268
+ graph_stats: {},
269
+ }
270
+ },
271
+
272
+ evolutionStats: () =>
273
+ http.get<EvolutionStats>('/skills/evolution-stats').then(r => r.data),
274
+ }
skillwiki-frontend/src/api/errors.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios'
2
+
3
+ type ErrorPayload = {
4
+ detail?: unknown
5
+ error?: unknown
6
+ message?: unknown
7
+ }
8
+
9
+ function stringifyDetail(detail: unknown): string | null {
10
+ if (!detail) return null
11
+ if (typeof detail === 'string') return detail
12
+ if (Array.isArray(detail)) {
13
+ return detail
14
+ .map(item => {
15
+ if (typeof item === 'string') return item
16
+ if (item && typeof item === 'object' && 'msg' in item) {
17
+ return String((item as { msg: unknown }).msg)
18
+ }
19
+ return JSON.stringify(item)
20
+ })
21
+ .filter(Boolean)
22
+ .join('; ')
23
+ }
24
+ if (typeof detail === 'object') return JSON.stringify(detail)
25
+ return String(detail)
26
+ }
27
+
28
+ export function getApiErrorMessage(error: unknown, fallback = 'Request failed'): string {
29
+ if (axios.isAxiosError(error)) {
30
+ const data = error.response?.data as ErrorPayload | string | undefined
31
+ if (typeof data === 'string' && data.trim()) return data
32
+ if (data && typeof data === 'object') {
33
+ return (
34
+ stringifyDetail(data.detail) ||
35
+ stringifyDetail(data.error) ||
36
+ stringifyDetail(data.message) ||
37
+ error.message ||
38
+ fallback
39
+ )
40
+ }
41
+ return error.message || fallback
42
+ }
43
+
44
+ if (error instanceof Error) return error.message || fallback
45
+ return fallback
46
+ }
skillwiki-frontend/src/api/types.ts ADDED
@@ -0,0 +1,744 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // SkillWiki API type definitions
2
+
3
+ export type SkillType = 'atomic' | 'functional' | 'strategic'
4
+ export type SkillState = 'S0' | 'S1' | 'S2' | 'S3' | 'S4' | 'S5' | 'S6' | 'S7'
5
+
6
+ export const STATE_LABELS: Record<SkillState, string> = {
7
+ S0: 'Raw Experience',
8
+ S1: 'Candidate',
9
+ S2: 'Draft',
10
+ S3: 'Verified',
11
+ S4: 'Released',
12
+ S5: 'Degraded',
13
+ S6: 'Deprecated',
14
+ S7: 'Archived',
15
+ }
16
+
17
+ export interface SkillParameter {
18
+ name: string
19
+ type: string
20
+ description: string
21
+ required: boolean
22
+ default?: unknown
23
+ }
24
+
25
+ export interface SkillInterface {
26
+ inputs?: SkillParameter[]
27
+ outputs?: SkillParameter[]
28
+ input_schema?: {
29
+ type?: string
30
+ properties?: Record<string, {
31
+ type?: string
32
+ description?: string
33
+ default?: unknown
34
+ }>
35
+ required?: string[]
36
+ }
37
+ output_schema?: {
38
+ type?: string
39
+ properties?: Record<string, {
40
+ type?: string
41
+ description?: string
42
+ default?: unknown
43
+ }>
44
+ required?: string[]
45
+ }
46
+ preconditions: string[]
47
+ postconditions: string[]
48
+ }
49
+
50
+ export interface SkillImplementation {
51
+ language: string
52
+ code?: string
53
+ prompt_template?: string
54
+ tool_calls?: string[]
55
+ sub_skill_ids?: string[]
56
+ }
57
+
58
+ export interface SkillMetrics {
59
+ usage_count: number
60
+ success_count: number
61
+ failure_count: number
62
+ avg_latency_ms: number
63
+ p95_latency_ms: number
64
+ last_used_at?: string
65
+ // computed fields (serialized by backend)
66
+ total_executions: number
67
+ successful_executions: number
68
+ failed_executions: number
69
+ success_rate: number
70
+ }
71
+
72
+ export interface SkillEvaluation {
73
+ verifier_specs: Record<string, unknown>[]
74
+ test_case_refs: string[]
75
+ benchmark_task_ids: string[]
76
+ validation_summary?: string | null
77
+ harness_validation?: Record<string, unknown>
78
+ }
79
+
80
+ export interface SkillProvenance {
81
+ source_type: string
82
+ source_ids: string[]
83
+ parent_skill_ids: string[]
84
+ created_by_agent?: string | null
85
+ creation_context: Record<string, unknown>
86
+ }
87
+
88
+ export interface SkillCreateRequest {
89
+ name: string
90
+ description: string
91
+ skill_type: SkillType
92
+ state?: SkillState
93
+ tags?: string[]
94
+ interface: SkillInterface
95
+ implementation?: SkillImplementation
96
+ evaluation?: SkillEvaluation
97
+ provenance?: SkillProvenance
98
+ author?: string
99
+ }
100
+
101
+ export interface SkillUpdateRequest {
102
+ description?: string
103
+ tags?: string[]
104
+ interface?: SkillInterface
105
+ implementation?: SkillImplementation
106
+ evaluation?: SkillEvaluation
107
+ author?: string
108
+ }
109
+
110
+ export interface NewVersionRequest {
111
+ bump?: 'major' | 'minor' | 'patch'
112
+ description?: string | null
113
+ tags?: string[] | null
114
+ interface?: SkillInterface | null
115
+ implementation?: SkillImplementation | null
116
+ evaluation?: SkillEvaluation | null
117
+ test_cases?: Record<string, unknown>[] | null
118
+ metadata?: Record<string, unknown> | null
119
+ author?: string
120
+ }
121
+
122
+ export interface SkillSummary {
123
+ skill_id: string
124
+ name: string
125
+ description: string
126
+ skill_type: SkillType
127
+ state: SkillState
128
+ tags: string[]
129
+ version: string
130
+ granularity_level: number
131
+ evaluation: SkillEvaluation
132
+ metrics: SkillMetrics
133
+ created_at: string
134
+ updated_at: string
135
+ }
136
+
137
+ export interface SkillFull extends SkillSummary {
138
+ interface: SkillInterface
139
+ implementation?: SkillImplementation
140
+ provenance?: SkillProvenance | null
141
+ }
142
+
143
+ export interface ExperienceUnit {
144
+ unit_id: string
145
+ source_type: string
146
+ raw_content: string
147
+ extracted_actions: string[]
148
+ normalized_actions: Record<string, unknown>[]
149
+ summary: string
150
+ proposed_skill_name?: string
151
+ proposed_description?: string
152
+ proposed_type?: string
153
+ confidence: number
154
+ index_keywords: string[]
155
+ index_embedding_hint: string
156
+ metadata?: Record<string, unknown>
157
+ }
158
+
159
+ export interface CreatedSkill {
160
+ skill_id: string
161
+ name: string
162
+ skill_type: string
163
+ state: string
164
+ version: string
165
+ }
166
+
167
+ export interface IngestResponse {
168
+ success: boolean
169
+ source_type: string
170
+ unit_count: number
171
+ token_usage: number
172
+ errors: string[]
173
+ units: ExperienceUnit[]
174
+ created_skills?: CreatedSkill[]
175
+ }
176
+
177
+ export interface CandidateSkillReviewRequest {
178
+ source_type: string
179
+ unit_id: string
180
+ raw_content?: string
181
+ name: string
182
+ description: string
183
+ skill_type: SkillType
184
+ tags: string[]
185
+ input_schema: Record<string, unknown>
186
+ output_schema: Record<string, unknown>
187
+ preconditions: string[]
188
+ postconditions: string[]
189
+ prompt_template: string
190
+ provenance?: SkillProvenance | null
191
+ evaluation: SkillEvaluation
192
+ dependency_ids?: string[]
193
+ component_ids?: string[]
194
+ sub_skill_ids?: string[]
195
+ parent_skill_ids?: string[]
196
+ tool_calls?: string[]
197
+ author?: string
198
+ }
199
+
200
+ export interface CandidateAuditResult {
201
+ skill_id: string
202
+ skill_name: string
203
+ passed: boolean
204
+ schema_ok: boolean
205
+ safety_ok: boolean
206
+ postcondition_ok: boolean
207
+ issues: string[]
208
+ warnings: string[]
209
+ recommendations: string[]
210
+ audit_score: number
211
+ }
212
+
213
+ export interface CandidateCreateResponse {
214
+ success: boolean
215
+ created_skill: CreatedSkill
216
+ audit: CandidateAuditResult
217
+ }
218
+
219
+ export interface EvaluationModeTotal {
220
+ success?: number
221
+ total?: number
222
+ success_rate?: number
223
+ functional_failure?: number
224
+ api_failure?: number
225
+ skipped?: number
226
+ success_rate_excluding_api_failures?: number
227
+ }
228
+
229
+ export interface EvaluationDemoRow {
230
+ task_id: string
231
+ domain?: string
232
+ no_skill?: string
233
+ raw_prompt?: string
234
+ with_skill?: string
235
+ winner?: string
236
+ failure_reason?: string
237
+ no_skill_latency_ms?: number | null
238
+ raw_prompt_latency_ms?: number | null
239
+ with_skill_latency_ms?: number | null
240
+ no_skill_verifier_passed?: boolean | null
241
+ raw_prompt_verifier_passed?: boolean | null
242
+ with_skill_verifier_passed?: boolean | null
243
+ }
244
+
245
+ export interface EvaluationArtifactBase {
246
+ available: boolean
247
+ source_file: string
248
+ generated_at?: string | null
249
+ updated_at?: string | null
250
+ error?: string | null
251
+ }
252
+
253
+ export interface EvaluationDemoBenchmark extends EvaluationArtifactBase {
254
+ task_count: number
255
+ mode_totals: Record<string, EvaluationModeTotal>
256
+ rows: EvaluationDemoRow[]
257
+ raw_result_file?: string | null
258
+ }
259
+
260
+ export interface EvaluationSearchRow {
261
+ query_id?: string
262
+ query?: string
263
+ domain?: string
264
+ expected_skill_ids: string[]
265
+ lexical_top_skill?: string | null
266
+ hybrid_top_skill?: string | null
267
+ lexical_best_rank?: number | null
268
+ hybrid_best_rank?: number | null
269
+ lexical_top1_hit?: boolean | null
270
+ hybrid_top1_hit?: boolean | null
271
+ hybrid_topk_hit?: boolean | null
272
+ }
273
+
274
+ export interface EvaluationSearchEval extends EvaluationArtifactBase {
275
+ benchmark?: string | null
276
+ schema_version?: string | null
277
+ query_count: number
278
+ summary: Record<string, unknown>
279
+ comparison: Record<string, unknown>
280
+ rows: EvaluationSearchRow[]
281
+ }
282
+
283
+ export interface EvaluationLlmRow {
284
+ task_id?: string
285
+ fallback_status?: string
286
+ fallback_selected?: string[]
287
+ fallback_failure_reason?: string
288
+ llm_status?: string
289
+ llm_selected?: string[]
290
+ llm_api_error_type?: string
291
+ llm_failure_reason?: string
292
+ winner?: string
293
+ }
294
+
295
+ export interface EvaluationLlmPlanner extends EvaluationArtifactBase {
296
+ benchmark?: string | null
297
+ task_count: number
298
+ mode_totals: Record<string, EvaluationModeTotal>
299
+ rows: EvaluationLlmRow[]
300
+ }
301
+
302
+ export interface EvaluationDashboardResponse {
303
+ generated_at: string
304
+ results_dir_present: boolean
305
+ warnings: string[]
306
+ artifacts: {
307
+ demo_benchmark: EvaluationDemoBenchmark
308
+ search_eval: EvaluationSearchEval
309
+ llm_planner: EvaluationLlmPlanner
310
+ }
311
+ }
312
+
313
+ export interface SkillSearchResult {
314
+ skill_id: string
315
+ name: string
316
+ description: string
317
+ skill_type: SkillType
318
+ state: SkillState
319
+ tags: string[]
320
+ version: string
321
+ score: number
322
+ match_reason: string
323
+ }
324
+
325
+ export interface GraphNodeData {
326
+ id: string
327
+ name: string
328
+ skill_type: string
329
+ state: string
330
+ tags: string[]
331
+ version: string
332
+ granularity_level: number
333
+ success_rate: number
334
+ usage_count: number
335
+ kind?: string
336
+ description?: string
337
+ metadata?: Record<string, unknown>
338
+ }
339
+
340
+ export interface GraphEdgeData {
341
+ id: string
342
+ source: string
343
+ target: string
344
+ edge_type: string
345
+ weight: number
346
+ confidence?: number
347
+ metadata?: Record<string, unknown>
348
+ }
349
+
350
+ export interface GraphData {
351
+ nodes: GraphNodeData[]
352
+ edges: GraphEdgeData[]
353
+ stats: Record<string, unknown>
354
+ }
355
+
356
+ export type GraphViewMode = 'skill_only' | 'provenance' | 'version_impact'
357
+
358
+ export interface SkillGraphProjectionEdgeData extends GraphEdgeData {
359
+ confidence: number
360
+ metadata: Record<string, unknown>
361
+ }
362
+
363
+ export interface SkillGraphProjectionData {
364
+ nodes: GraphNodeData[]
365
+ edges: SkillGraphProjectionEdgeData[]
366
+ metadata: Record<string, unknown>
367
+ validation_evidence: Record<string, Record<string, unknown>[]>
368
+ stats: Record<string, unknown>
369
+ }
370
+
371
+ export interface HeterogeneousGraphNodeData {
372
+ id: string
373
+ kind: string
374
+ name: string
375
+ description: string
376
+ metadata: Record<string, unknown>
377
+ }
378
+
379
+ export interface HeterogeneousGraphEdgeData {
380
+ id: string
381
+ source: string
382
+ target: string
383
+ edge_type: string
384
+ weight: number
385
+ metadata: Record<string, unknown>
386
+ }
387
+
388
+ export interface HeterogeneousGraphData {
389
+ nodes: HeterogeneousGraphNodeData[]
390
+ edges: HeterogeneousGraphEdgeData[]
391
+ stats: Record<string, unknown>
392
+ }
393
+
394
+ export interface GraphViewNodeData {
395
+ id: string
396
+ name: string
397
+ kind: string
398
+ description?: string
399
+ skill_type?: string | null
400
+ state?: string | null
401
+ tags: string[]
402
+ version?: string | null
403
+ granularity_level?: number | null
404
+ success_rate?: number | null
405
+ usage_count?: number | null
406
+ metadata: Record<string, unknown>
407
+ }
408
+
409
+ export interface GraphViewEdgeData {
410
+ id: string
411
+ source: string
412
+ target: string
413
+ edge_type: string
414
+ weight: number
415
+ confidence?: number | null
416
+ metadata: Record<string, unknown>
417
+ }
418
+
419
+ export interface GraphViewData {
420
+ view: GraphViewMode
421
+ source_endpoint: string
422
+ nodes: GraphViewNodeData[]
423
+ edges: GraphViewEdgeData[]
424
+ stats: Record<string, unknown>
425
+ metadata: Record<string, unknown>
426
+ validation_evidence: Record<string, Record<string, unknown>[]>
427
+ }
428
+
429
+ export interface MaintenanceProposal {
430
+ proposal_id: string
431
+ skill_id: string
432
+ trigger: string
433
+ recommended_action: string
434
+ evidence: string[]
435
+ root_cause?: string
436
+ patch_hint: string
437
+ feedback_sources?: string[]
438
+ targets_to_fix?: string[]
439
+ invariants_to_preserve?: string[]
440
+ validation_plan?: string[]
441
+ validation_status?: 'untested' | 'repaired' | 'verified' | 'failed' | 'merged' | 'deprioritized'
442
+ attempt_count?: number
443
+ max_attempts?: number
444
+ reviewer_notes?: string
445
+ confidence: number
446
+ requires_human_review: boolean
447
+ status: 'pending' | 'accepted' | 'rejected' | 'superseded'
448
+ source: string
449
+ metadata: Record<string, unknown>
450
+ created_at: string
451
+ updated_at: string
452
+ next_action?: MaintenanceProposalNextAction | null
453
+ }
454
+
455
+ export interface MaintenanceProposalNextAction {
456
+ action: string
457
+ method: string
458
+ endpoint: string
459
+ required_payload_fields: string[]
460
+ reason: string
461
+ }
462
+
463
+ export interface MaintenanceProposalListResponse {
464
+ proposals: MaintenanceProposal[]
465
+ total: number
466
+ pending_count: number
467
+ }
468
+
469
+ export interface StructuredDiffEntry {
470
+ field_path?: string
471
+ field?: string
472
+ change_type?: string
473
+ type?: string
474
+ old_value?: unknown
475
+ new_value?: unknown
476
+ category?: string
477
+ is_breaking?: boolean
478
+ review_recommendation?: string
479
+ [key: string]: unknown
480
+ }
481
+
482
+ export interface BusinessDiffEntry {
483
+ field: string
484
+ change_type: string
485
+ category: string
486
+ old_value?: unknown
487
+ new_value?: unknown
488
+ is_breaking?: boolean
489
+ }
490
+
491
+ export interface BusinessDiffSummary {
492
+ changed_fields: number
493
+ breaking: boolean
494
+ categories: string[]
495
+ suggested_bump: 'major' | 'minor' | 'patch'
496
+ summary: string
497
+ }
498
+
499
+ export interface MaintenanceReviewRequest {
500
+ proposal_id: string
501
+ patched_skill: Record<string, unknown>
502
+ reason?: string
503
+ author?: string
504
+ }
505
+
506
+ export interface MaintenanceReviewResponse {
507
+ skill_id: string
508
+ proposal_id: string
509
+ branch_name: string
510
+ base_commit: string
511
+ head_commit: string
512
+ snapshot_path: string
513
+ structured_diff: StructuredDiffEntry[]
514
+ has_breaking_changes: boolean
515
+ review_status: string
516
+ reason: string
517
+ author: string
518
+ }
519
+
520
+ export interface SnapshotCommitRequest {
521
+ author?: string
522
+ message?: string | null
523
+ }
524
+
525
+ export interface SnapshotCommitResponse {
526
+ skill_id: string
527
+ skill_name: string
528
+ version: string
529
+ snapshot_path: string
530
+ commit: string
531
+ message: string
532
+ }
533
+
534
+ export interface SnapshotHistoryItem {
535
+ commit_hash: string
536
+ author: string
537
+ authored_at: string
538
+ subject: string
539
+ changed_paths: string[]
540
+ }
541
+
542
+ export interface SnapshotHistoryResponse {
543
+ skill_id: string
544
+ snapshot_path: string
545
+ history: SnapshotHistoryItem[]
546
+ }
547
+
548
+ export interface SnapshotDiffRequest {
549
+ from_ref: string
550
+ to_ref?: string
551
+ from_version?: string
552
+ to_version?: string
553
+ }
554
+
555
+ export interface SnapshotDiffResponse {
556
+ skill_id: string
557
+ snapshot_path: string
558
+ from_snapshot_path?: string | null
559
+ to_snapshot_path?: string | null
560
+ from_ref: string
561
+ to_ref: string
562
+ raw_diff: string
563
+ diffs: StructuredDiffEntry[]
564
+ has_breaking_changes: boolean
565
+ review_recommendation: string
566
+ }
567
+
568
+ export interface SkillReleaseRecord {
569
+ tag_name: string
570
+ commit: string
571
+ snapshot_path: string
572
+ skill_id: string
573
+ skill_name: string
574
+ version: string
575
+ }
576
+
577
+ export interface SkillRollbackRecord {
578
+ source_ref: string
579
+ restore_commit: string
580
+ restored_snapshot_path: string
581
+ commit_message: string
582
+ skill_id: string
583
+ skill_name: string
584
+ version: string
585
+ }
586
+
587
+ export interface HealthReport {
588
+ skill_id: string
589
+ skill_name: string
590
+ status: 'healthy' | 'degraded' | 'critical' | 'stale' | 'unknown'
591
+ success_rate: number
592
+ usage_count: number
593
+ avg_latency_ms: number
594
+ issues: string[]
595
+ recommendations: string[]
596
+ maintenance_proposal?: MaintenanceProposal | null
597
+ }
598
+
599
+ export interface SystemHealth {
600
+ total_skills: number
601
+ healthy_count: number
602
+ degraded_count: number
603
+ critical_count: number
604
+ stale_count: number
605
+ health_ratio: number
606
+ skill_reports: HealthReport[]
607
+ }
608
+
609
+ export interface ExecutionStepResult {
610
+ step_id: string
611
+ step_index: number
612
+ skill_id: string
613
+ skill_name: string
614
+ status: string
615
+ input_mapping?: Record<string, unknown>
616
+ outputs?: Record<string, unknown>
617
+ result?: Record<string, unknown> | null
618
+ error?: string | null
619
+ latency_ms?: number | null
620
+ }
621
+
622
+ export interface RetrievedSkill {
623
+ skill_id: string
624
+ name: string
625
+ description: string
626
+ skill_type: string
627
+ score: number
628
+ match_reason: string
629
+ }
630
+
631
+ export interface ExecutionExperienceUnit {
632
+ unit_id: string
633
+ source_type: 'agent_execution' | string
634
+ source_execution_id: string
635
+ raw_content: string
636
+ extracted_actions: string[]
637
+ normalized_actions: Record<string, unknown>[]
638
+ summary: string
639
+ proposed_skill_name?: string | null
640
+ proposed_description?: string | null
641
+ proposed_type?: string | null
642
+ confidence: number
643
+ index_keywords: string[]
644
+ index_embedding_hint: string
645
+ metadata: Record<string, unknown>
646
+ }
647
+
648
+ export interface ExecutionResult {
649
+ plan_id: string
650
+ goal: string
651
+ status: string
652
+ steps: ExecutionStepResult[]
653
+ total_latency_ms: number
654
+ final_state: Record<string, unknown>
655
+ retrieved_skills: RetrievedSkill[]
656
+ experience_recorded: boolean
657
+ experience_unit?: ExecutionExperienceUnit | null
658
+ suggested_skill?: Record<string, unknown>
659
+ verifier_passed?: boolean | null
660
+ verifier_summary?: Record<string, unknown> | null
661
+ }
662
+
663
+ export interface ExecutionHistoryItem {
664
+ execution_id: string
665
+ goal: string
666
+ status: string
667
+ step_count: number
668
+ success_count: number
669
+ total_latency_ms: number
670
+ retrieved_skill_count: number
671
+ created_at: string
672
+ experience_unit_id?: string | null
673
+ experience_source_type?: string | null
674
+ }
675
+
676
+ export type HarnessKind = 'local_skillos' | 'codex_cli' | 'claude_code'
677
+
678
+ export interface HarnessTestCase {
679
+ test_id: string
680
+ name: string
681
+ goal: string
682
+ input_data: Record<string, unknown>
683
+ context?: Record<string, unknown>
684
+ verifier_specs: Record<string, unknown>[]
685
+ timeout_s?: number
686
+ expected_artifacts?: string[]
687
+ }
688
+
689
+ export interface HarnessRunResult {
690
+ run_id: string
691
+ skill_id: string
692
+ attempt: number
693
+ harness: HarnessKind
694
+ status: string
695
+ input_data: Record<string, unknown>
696
+ output: Record<string, unknown>
697
+ stdout: string
698
+ stderr: string
699
+ artifacts: Record<string, unknown>[]
700
+ verifier_passed: boolean
701
+ verifier_summary: Record<string, unknown>
702
+ latency_ms: number
703
+ cost_estimate: Record<string, unknown>
704
+ failure_reason: string
705
+ evidence_path: string
706
+ }
707
+
708
+ export interface HarnessVerifyLoopRequest {
709
+ harness?: HarnessKind
710
+ max_attempts?: number
711
+ promote_on_pass?: boolean
712
+ test_cases?: HarnessTestCase[]
713
+ allow_repair?: boolean
714
+ timeout_s?: number
715
+ }
716
+
717
+ export interface HarnessVerifyLoopResponse {
718
+ loop_id: string
719
+ skill_id: string
720
+ status: string
721
+ promotion_allowed: boolean
722
+ attempt_count: number
723
+ score: Record<string, unknown>
724
+ attempts: HarnessRunResult[]
725
+ repairs: Record<string, unknown>[]
726
+ initial_version: string
727
+ final_version: string
728
+ final_state: SkillState | string
729
+ evidence_path: string
730
+ }
731
+
732
+ export interface HarnessLoopListResponse {
733
+ loops: HarnessVerifyLoopResponse[]
734
+ total: number
735
+ }
736
+
737
+ export interface OverviewStats {
738
+ total_skills: number
739
+ by_state: Record<string, number>
740
+ by_type: Record<string, number>
741
+ total_executions: number
742
+ avg_success_rate: number
743
+ graph_stats: Record<string, unknown>
744
+ }
skillwiki-frontend/src/assets/react.svg ADDED
skillwiki-frontend/src/assets/vite.svg ADDED
skillwiki-frontend/src/components/AppLayout.tsx ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import { Layout, Menu, Switch, Badge, Tooltip, Avatar, Tag, Grid, Button } from 'antd'
3
+ import {
4
+ DashboardOutlined,
5
+ BookOutlined,
6
+ ApartmentOutlined,
7
+ PlayCircleOutlined,
8
+ SyncOutlined,
9
+ ExperimentOutlined,
10
+ CloudUploadOutlined,
11
+ BranchesOutlined,
12
+ BulbOutlined,
13
+ BulbFilled,
14
+ WifiOutlined,
15
+ RocketOutlined,
16
+ BarChartOutlined,
17
+ SafetyCertificateOutlined,
18
+ TranslationOutlined,
19
+ } from '@ant-design/icons'
20
+ import { useNavigate, useLocation } from 'react-router-dom'
21
+ import { motion } from 'framer-motion'
22
+ import { useAppStore } from '@/store/appStore'
23
+ import { useWebSocket } from '@/hooks/useWebSocket'
24
+ import { useT } from '@/i18n'
25
+
26
+ const { Header, Sider, Content } = Layout
27
+ const { useBreakpoint } = Grid
28
+
29
+ export default function AppLayout({ children }: { children: React.ReactNode }) {
30
+ const [collapsed, setCollapsed] = useState(false)
31
+ const screens = useBreakpoint()
32
+ const navigate = useNavigate()
33
+ const location = useLocation()
34
+ const { darkMode, toggleDark, wsEvents, lang, setLang } = useAppStore()
35
+ const t = useT(lang)
36
+ useWebSocket()
37
+
38
+ const isNarrow = screens.xs && !screens.md
39
+ const siderCollapsed = isNarrow ? true : collapsed
40
+ const unreadEvents = wsEvents.filter(e => e.type !== 'pong' && e.type !== 'connected').length
41
+
42
+ const menuItems = [
43
+ {
44
+ type: 'group' as const,
45
+ label: t.groupOverview,
46
+ children: [
47
+ { key: '/', icon: <DashboardOutlined />, label: t.menuDashboard },
48
+ { key: '/evaluation', icon: <BarChartOutlined />, label: t.menuEvaluation },
49
+ ],
50
+ },
51
+ {
52
+ type: 'group' as const,
53
+ label: t.groupSkillMgmt,
54
+ children: [
55
+ { key: '/ingest', icon: <CloudUploadOutlined />, label: t.menuIngest },
56
+ { key: '/wiki', icon: <BookOutlined />, label: t.menuWiki },
57
+ { key: '/graph', icon: <ApartmentOutlined />, label: t.menuGraph },
58
+ { key: '/lifecycle', icon: <ExperimentOutlined />, label: t.menuLifecycle },
59
+ { key: '/versions', icon: <BranchesOutlined />, label: t.menuVersions },
60
+ ],
61
+ },
62
+ {
63
+ type: 'group' as const,
64
+ label: t.groupAgentEvolution,
65
+ children: [
66
+ { key: '/harness', icon: <SafetyCertificateOutlined />, label: t.menuHarness },
67
+ { key: '/execution', icon: <PlayCircleOutlined />, label: t.menuExecution },
68
+ { key: '/evolution', icon: <SyncOutlined />, label: t.menuEvolution },
69
+ {
70
+ key: '/demo',
71
+ icon: <RocketOutlined />,
72
+ label: (
73
+ <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
74
+ {t.menuDemo}
75
+ <Tag color="red" style={{ fontSize: 10, padding: '0 4px', lineHeight: '16px', marginLeft: 2 }}>DEMO</Tag>
76
+ </span>
77
+ ),
78
+ },
79
+ ],
80
+ },
81
+ ]
82
+
83
+ return (
84
+ <Layout style={{ minHeight: '100vh' }}>
85
+ <Sider
86
+ collapsible
87
+ collapsed={siderCollapsed}
88
+ onCollapse={setCollapsed}
89
+ collapsedWidth={isNarrow ? 64 : 80}
90
+ width={isNarrow ? 64 : 200}
91
+ theme={darkMode ? 'dark' : 'light'}
92
+ style={{
93
+ boxShadow: '2px 0 8px rgba(0,0,0,0.08)',
94
+ zIndex: 10,
95
+ }}
96
+ >
97
+ <motion.div
98
+ style={{
99
+ height: 64,
100
+ display: 'flex',
101
+ alignItems: 'center',
102
+ justifyContent: 'center',
103
+ padding: '0 16px',
104
+ borderBottom: '1px solid rgba(0,0,0,0.06)',
105
+ }}
106
+ initial={{ opacity: 0 }}
107
+ animate={{ opacity: 1 }}
108
+ >
109
+ {!siderCollapsed ? (
110
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
111
+ <Avatar
112
+ style={{ background: 'linear-gradient(135deg, #1677ff, #722ed1)', flexShrink: 0 }}
113
+ size={32}
114
+ >
115
+ S
116
+ </Avatar>
117
+ <span style={{ fontWeight: 800, fontSize: 16, color: darkMode ? '#fff' : '#1677ff', whiteSpace: 'nowrap' }}>
118
+ SkillWiki
119
+ </span>
120
+ </div>
121
+ ) : (
122
+ <Avatar style={{ background: 'linear-gradient(135deg, #1677ff, #722ed1)' }} size={32}>S</Avatar>
123
+ )}
124
+ </motion.div>
125
+
126
+ <Menu
127
+ theme={darkMode ? 'dark' : 'light'}
128
+ mode="inline"
129
+ selectedKeys={[location.pathname]}
130
+ items={menuItems}
131
+ onClick={({ key }) => navigate(key)}
132
+ inlineCollapsed={siderCollapsed}
133
+ style={{ borderRight: 0, marginTop: 8 }}
134
+ />
135
+ </Sider>
136
+
137
+ <Layout>
138
+ <Header
139
+ style={{
140
+ background: darkMode ? '#141414' : '#fff',
141
+ padding: isNarrow ? '0 12px' : '0 24px',
142
+ display: 'flex',
143
+ alignItems: 'center',
144
+ justifyContent: 'space-between',
145
+ gap: 12,
146
+ boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
147
+ zIndex: 9,
148
+ }}
149
+ >
150
+ <div
151
+ style={{
152
+ fontWeight: 600,
153
+ color: darkMode ? '#fff' : '#333',
154
+ minWidth: 0,
155
+ overflow: 'hidden',
156
+ textOverflow: 'ellipsis',
157
+ whiteSpace: 'nowrap',
158
+ }}
159
+ >
160
+ {isNarrow ? t.appTitleShort : t.appTitle}
161
+ </div>
162
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
163
+ <Tooltip title={`${unreadEvents} ${t.liveEvents}`}>
164
+ <Badge count={Math.min(unreadEvents, 99)} size="small">
165
+ <WifiOutlined style={{ fontSize: 18, color: '#52c41a', cursor: 'pointer' }} />
166
+ </Badge>
167
+ </Tooltip>
168
+ <Tooltip title={darkMode ? t.switchLight : t.switchDark}>
169
+ <Switch
170
+ checked={darkMode}
171
+ onChange={toggleDark}
172
+ checkedChildren={<BulbFilled />}
173
+ unCheckedChildren={<BulbOutlined />}
174
+ />
175
+ </Tooltip>
176
+ <Tooltip title={t.langToggle}>
177
+ <Button
178
+ size="small"
179
+ icon={<TranslationOutlined />}
180
+ onClick={() => setLang(lang === 'en' ? 'zh' : 'en')}
181
+ style={{ fontWeight: 600, minWidth: 56 }}
182
+ >
183
+ {t.langToggle}
184
+ </Button>
185
+ </Tooltip>
186
+ </div>
187
+ </Header>
188
+
189
+ <Content
190
+ style={{
191
+ background: darkMode ? '#1f1f1f' : '#f5f7fa',
192
+ overflow: 'auto',
193
+ minHeight: 'calc(100vh - 64px)',
194
+ }}
195
+ >
196
+ <motion.div
197
+ key={location.pathname}
198
+ initial={{ opacity: 0, y: 8 }}
199
+ animate={{ opacity: 1, y: 0 }}
200
+ transition={{ duration: 0.2 }}
201
+ >
202
+ {children}
203
+ </motion.div>
204
+ </Content>
205
+ </Layout>
206
+ </Layout>
207
+ )
208
+ }
skillwiki-frontend/src/hooks/useWebSocket.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react'
2
+ import { useAppStore } from '@/store/appStore'
3
+ import type { WsEvent } from '@/store/appStore'
4
+
5
+ type RawWsMessage = {
6
+ type?: unknown
7
+ payload?: unknown
8
+ timestamp?: unknown
9
+ event?: unknown
10
+ data?: unknown
11
+ }
12
+
13
+ function normalizeWsMessage(raw: RawWsMessage): WsEvent | null {
14
+ const type = typeof raw.type === 'string'
15
+ ? raw.type
16
+ : typeof raw.event === 'string'
17
+ ? raw.event
18
+ : ''
19
+
20
+ if (!type) return null
21
+
22
+ return {
23
+ type,
24
+ payload: 'payload' in raw ? raw.payload : raw.data,
25
+ timestamp: typeof raw.timestamp === 'string' ? raw.timestamp : new Date().toISOString(),
26
+ }
27
+ }
28
+
29
+ export function useWebSocket() {
30
+ const wsRef = useRef<WebSocket | null>(null)
31
+ const pushWsEvent = useAppStore(s => s.pushWsEvent)
32
+
33
+ useEffect(() => {
34
+ if (import.meta.env.VITE_SKILLOS_DISABLE_WS === '1') {
35
+ return
36
+ }
37
+
38
+ const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
39
+ const ws = new WebSocket(`${protocol}://${window.location.host}/ws`)
40
+ wsRef.current = ws
41
+
42
+ ws.onmessage = (e) => {
43
+ try {
44
+ const msg = normalizeWsMessage(JSON.parse(e.data))
45
+ if (msg) pushWsEvent(msg)
46
+ } catch {
47
+ // ignore
48
+ }
49
+ }
50
+
51
+ const ping = setInterval(() => {
52
+ if (ws.readyState === WebSocket.OPEN) {
53
+ ws.send(JSON.stringify({ type: 'ping' }))
54
+ }
55
+ }, 30_000)
56
+
57
+ return () => {
58
+ clearInterval(ping)
59
+ ws.close()
60
+ }
61
+ }, [pushWsEvent])
62
+
63
+ return wsRef
64
+ }
skillwiki-frontend/src/i18n.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AppLang } from '@/store/appStore'
2
+
3
+ export const T = {
4
+ en: {
5
+ appTitle: 'SkillWiki: A Living Knowledge Infrastructure for Agent Skills',
6
+ appTitleShort: 'SkillWiki',
7
+ groupOverview: 'Overview',
8
+ groupSkillMgmt: 'Skill Management',
9
+ groupAgentEvolution: 'Agent & Evolution',
10
+ menuDashboard: 'Dashboard',
11
+ menuEvaluation: 'Evaluation',
12
+ menuWiki: 'Skill Wiki',
13
+ menuGraph: 'Knowledge Graph',
14
+ menuVersions: 'Version Control',
15
+ menuLifecycle: 'Lifecycle',
16
+ menuDemo: 'Self-Evolution Demo',
17
+ menuExecution: 'Agent Execution',
18
+ menuHarness: 'Harness Verification',
19
+ menuEvolution: 'Evolution',
20
+ menuIngest: 'Knowledge Import',
21
+ liveEvents: 'live events',
22
+ switchDark: 'Switch to dark mode',
23
+ switchLight: 'Switch to light mode',
24
+ langToggle: 'ZH',
25
+ },
26
+ zh: {
27
+ appTitle: 'SkillWiki:面向智能体技能的活性知识基础设施',
28
+ appTitleShort: 'SkillWiki',
29
+ groupOverview: '总览',
30
+ groupSkillMgmt: '技能管理',
31
+ groupAgentEvolution: '智能体与演化',
32
+ menuDashboard: '仪表盘',
33
+ menuEvaluation: '评测',
34
+ menuWiki: '技能库',
35
+ menuGraph: '知识图谱',
36
+ menuVersions: '版本控制',
37
+ menuLifecycle: '生命周期',
38
+ menuDemo: '自进化演示',
39
+ menuExecution: '智能体执行',
40
+ menuHarness: '验证框架',
41
+ menuEvolution: '演化中心',
42
+ menuIngest: '知识导入',
43
+ liveEvents: '实时事件',
44
+ switchDark: '切换暗色模式',
45
+ switchLight: '切换亮色模式',
46
+ langToggle: 'English',
47
+ },
48
+ } as const
49
+
50
+ export function useT(lang: AppLang) {
51
+ return T[lang]
52
+ }
skillwiki-frontend/src/index.css ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ body {
6
+ margin: 0;
7
+ font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', -apple-system, sans-serif;
8
+ -webkit-font-smoothing: antialiased;
9
+ }
10
+
11
+ #root {
12
+ width: 100%;
13
+ min-height: 100vh;
14
+ }
15
+
16
+ /* Scrollbar polish */
17
+ ::-webkit-scrollbar {
18
+ width: 6px;
19
+ height: 6px;
20
+ }
21
+ ::-webkit-scrollbar-track {
22
+ background: transparent;
23
+ }
24
+ ::-webkit-scrollbar-thumb {
25
+ background: rgba(0, 0, 0, 0.15);
26
+ border-radius: 3px;
27
+ }
28
+ ::-webkit-scrollbar-thumb:hover {
29
+ background: rgba(0, 0, 0, 0.25);
30
+ }
31
+
32
+ /* Ant Design overrides */
33
+ .ant-layout-sider-trigger {
34
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
35
+ }
36
+
37
+ pre {
38
+ margin: 0;
39
+ }
skillwiki-frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import App from './App'
4
+ import './index.css'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
skillwiki-frontend/src/pages/AgentExecution.tsx ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import {
3
+ Card, Input, Button, Tag, Alert, Spin,
4
+ Typography, Space, Statistic, Row, Col, Progress, Tooltip, Table, Empty,
5
+ } from 'antd'
6
+ import {
7
+ PlayCircleOutlined, ThunderboltOutlined, CheckCircleOutlined,
8
+ CloseCircleOutlined, SearchOutlined, DatabaseOutlined, ReloadOutlined,
9
+ } from '@ant-design/icons'
10
+ import { motion, AnimatePresence } from 'framer-motion'
11
+ import { executionApi } from '@/api/client'
12
+ import { getApiErrorMessage } from '@/api/errors'
13
+ import type { ExecutionHistoryItem, ExecutionResult, ExecutionStepResult, RetrievedSkill } from '@/api/types'
14
+
15
+ const { TextArea } = Input
16
+ const { Text } = Typography
17
+
18
+ const STATUS_COLOR: Record<string, string> = {
19
+ success: '#52c41a',
20
+ failed: '#ff4d4f',
21
+ running: '#1677ff',
22
+ pending: '#d9d9d9',
23
+ skipped: '#faad14',
24
+ }
25
+
26
+ const SKILL_TYPE_COLOR: Record<string, string> = {
27
+ atomic: '#1677ff', functional: '#722ed1', strategic: '#faad14',
28
+ }
29
+
30
+ function formatHistoryTime(value: string) {
31
+ const date = new Date(value)
32
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
33
+ }
34
+
35
+ function StepCard({ step, index }: { step: ExecutionStepResult; index: number }) {
36
+ const [expanded, setExpanded] = useState(false)
37
+ const stepResult = step.result ?? {}
38
+ const latency = step.latency_ms ?? 0
39
+ return (
40
+ <motion.div
41
+ initial={{ opacity: 0, x: -20 }}
42
+ animate={{ opacity: 1, x: 0 }}
43
+ transition={{ delay: index * 0.1 }}
44
+ >
45
+ <Card
46
+ size="small"
47
+ style={{
48
+ marginBottom: 8,
49
+ borderLeft: `4px solid ${STATUS_COLOR[step.status] || '#d9d9d9'}`,
50
+ borderRadius: 8,
51
+ }}
52
+ onClick={() => setExpanded(!expanded)}
53
+ hoverable
54
+ >
55
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
56
+ <Space>
57
+ {step.status === 'success'
58
+ ? <CheckCircleOutlined style={{ color: '#52c41a' }} />
59
+ : step.status === 'failed'
60
+ ? <CloseCircleOutlined style={{ color: '#ff4d4f' }} />
61
+ : <ThunderboltOutlined style={{ color: STATUS_COLOR[step.status] || '#d9d9d9' }} />}
62
+ <Text strong>{step.skill_name}</Text>
63
+ <Text type="secondary" style={{ fontSize: 12 }}>{step.skill_id.slice(0, 8)}...</Text>
64
+ </Space>
65
+ <Space>
66
+ <Tag color={STATUS_COLOR[step.status]}>{step.status}</Tag>
67
+ <Text type="secondary">{latency.toFixed(0)}ms</Text>
68
+ </Space>
69
+ </div>
70
+ {expanded && (
71
+ <div style={{ marginTop: 8 }}>
72
+ {step.error && <Alert type="error" title={step.error} style={{ marginBottom: 8 }} />}
73
+ {Object.keys(stepResult).length > 0 && (
74
+ <pre style={{ background: '#f5f5f5', padding: 8, borderRadius: 4, fontSize: 11, overflow: 'auto' }}>
75
+ {JSON.stringify(stepResult, null, 2)}
76
+ </pre>
77
+ )}
78
+ </div>
79
+ )}
80
+ </Card>
81
+ </motion.div>
82
+ )
83
+ }
84
+
85
+ export default function AgentExecution() {
86
+ const [goal, setGoal] = useState('')
87
+ const [loading, setLoading] = useState(false)
88
+ const [result, setResult] = useState<ExecutionResult | null>(null)
89
+ const [error, setError] = useState<string | null>(null)
90
+ const [history, setHistory] = useState<ExecutionHistoryItem[]>([])
91
+ const [historyLoading, setHistoryLoading] = useState(false)
92
+ const [historyError, setHistoryError] = useState<string | null>(null)
93
+
94
+ const loadHistory = useCallback(async () => {
95
+ setHistoryLoading(true)
96
+ setHistoryError(null)
97
+ try {
98
+ setHistory(await executionApi.history())
99
+ } catch (e: unknown) {
100
+ setHistoryError(getApiErrorMessage(e, 'Failed to load execution history'))
101
+ } finally {
102
+ setHistoryLoading(false)
103
+ }
104
+ }, [])
105
+
106
+ useEffect(() => {
107
+ const timeoutId = window.setTimeout(() => { void loadHistory() }, 0)
108
+ return () => window.clearTimeout(timeoutId)
109
+ }, [loadHistory])
110
+
111
+ const handleExecute = async () => {
112
+ if (!goal.trim()) return
113
+ setLoading(true)
114
+ setError(null)
115
+ setResult(null)
116
+ try {
117
+ const res = await executionApi.executePlan(goal)
118
+ setResult(res)
119
+ void loadHistory()
120
+ } catch (e: unknown) {
121
+ setError(getApiErrorMessage(e, 'Execution failed'))
122
+ } finally {
123
+ setLoading(false)
124
+ }
125
+ }
126
+
127
+ const successCount = result?.steps.filter(s => s.status === 'success').length || 0
128
+ const retrieved: RetrievedSkill[] = result?.retrieved_skills || []
129
+
130
+ return (
131
+ <div style={{ padding: 24, maxWidth: 900, margin: '0 auto' }}>
132
+ <motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
133
+ <h2 style={{ fontWeight: 700, marginBottom: 4 }}>Agent Execution</h2>
134
+ <p style={{ color: '#666', marginBottom: 24 }}>
135
+ Enter a task goal. SkillWiki will retrieve relevant Skills, build an execution plan, and run it.
136
+ </p>
137
+
138
+ <Card variant="borderless" style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }}>
139
+ <TextArea
140
+ value={goal}
141
+ onChange={e => setGoal(e.target.value)}
142
+ placeholder="Describe your task goal, for example: find the login button on a page, click it, then fill in the username and password..."
143
+ rows={3}
144
+ style={{ marginBottom: 12, fontSize: 14 }}
145
+ />
146
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
147
+ <Text type="secondary" style={{ fontSize: 12 }}>
148
+ SkillWiki will decompose the task, retrieve Skills, and generate an execution plan.
149
+ </Text>
150
+ <Button
151
+ type="primary"
152
+ icon={<PlayCircleOutlined />}
153
+ onClick={handleExecute}
154
+ loading={loading}
155
+ disabled={!goal.trim()}
156
+ size="large"
157
+ >
158
+ Execute
159
+ </Button>
160
+ </div>
161
+ </Card>
162
+
163
+ {/* Example tasks */}
164
+ <div style={{ marginBottom: 24 }}>
165
+ <Text type="secondary" style={{ fontSize: 12, marginRight: 8 }}>Examples:</Text>
166
+ {[
167
+ 'Click the submit button on the page',
168
+ 'Fill and submit the login form',
169
+ 'Type a keyword into the search box and search',
170
+ ].map(ex => (
171
+ <Tag
172
+ key={ex}
173
+ style={{ cursor: 'pointer', marginBottom: 4 }}
174
+ onClick={() => setGoal(ex)}
175
+ >
176
+ {ex}
177
+ </Tag>
178
+ ))}
179
+ </div>
180
+ </motion.div>
181
+
182
+ <Card
183
+ title={<span><DatabaseOutlined style={{ color: '#1677ff', marginRight: 6 }} />Recent Execution History</span>}
184
+ variant="borderless"
185
+ size="small"
186
+ style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }}
187
+ extra={
188
+ <Button size="small" icon={<ReloadOutlined />} loading={historyLoading} onClick={loadHistory}>
189
+ Refresh
190
+ </Button>
191
+ }
192
+ >
193
+ {historyError && (
194
+ <Alert type="warning" showIcon title={historyError} style={{ marginBottom: 12 }} />
195
+ )}
196
+ {!historyLoading && history.length === 0 ? (
197
+ <Empty description="No execution history yet. Completed tasks will appear here automatically." image={Empty.PRESENTED_IMAGE_SIMPLE} />
198
+ ) : (
199
+ <Table
200
+ rowKey="execution_id"
201
+ dataSource={history}
202
+ loading={historyLoading}
203
+ size="small"
204
+ pagination={{ pageSize: 5, size: 'small' }}
205
+ columns={[
206
+ {
207
+ title: 'Goal',
208
+ dataIndex: 'goal',
209
+ ellipsis: true,
210
+ render: (goalText: string) => <Text style={{ maxWidth: 240 }}>{goalText}</Text>,
211
+ },
212
+ {
213
+ title: 'Status',
214
+ dataIndex: 'status',
215
+ width: 90,
216
+ render: (status: string) => <Tag color={STATUS_COLOR[status] || 'default'}>{status}</Tag>,
217
+ },
218
+ {
219
+ title: 'Steps',
220
+ width: 90,
221
+ render: (_: unknown, item: ExecutionHistoryItem) => `${item.success_count}/${item.step_count}`,
222
+ },
223
+ {
224
+ title: 'Skills',
225
+ dataIndex: 'retrieved_skill_count',
226
+ width: 80,
227
+ },
228
+ {
229
+ title: 'Latency',
230
+ dataIndex: 'total_latency_ms',
231
+ width: 90,
232
+ render: (ms: number) => `${ms.toFixed(0)}ms`,
233
+ },
234
+ {
235
+ title: 'Created',
236
+ dataIndex: 'created_at',
237
+ width: 170,
238
+ render: formatHistoryTime,
239
+ },
240
+ ]}
241
+ />
242
+ )}
243
+ </Card>
244
+
245
+ {loading && (
246
+ <div style={{ textAlign: 'center', padding: 40 }}>
247
+ <Spin size="large" description="Planning and executing..." />
248
+ </div>
249
+ )}
250
+
251
+ {error && (
252
+ <Alert type="error" title="Execution Failed" description={error} showIcon style={{ marginBottom: 16 }} />
253
+ )}
254
+
255
+ <AnimatePresence>
256
+ {result && (
257
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
258
+ {/* Retrieved Skills */}
259
+ {retrieved.length > 0 && (
260
+ <Card
261
+ title={<span><SearchOutlined style={{ color: '#1677ff', marginRight: 6 }} />Retrieved Skills ({retrieved.length})</span>}
262
+ variant="borderless"
263
+ size="small"
264
+ style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }}
265
+ >
266
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
267
+ {retrieved.map(sk => (
268
+ <Tooltip key={sk.skill_id} title={sk.match_reason}>
269
+ <div style={{
270
+ display: 'flex', alignItems: 'center', gap: 6,
271
+ padding: '4px 10px', borderRadius: 20,
272
+ background: '#f5f7fa', border: '1px solid #e8e8e8',
273
+ cursor: 'default',
274
+ }}>
275
+ <Tag color={SKILL_TYPE_COLOR[sk.skill_type]} style={{ margin: 0, fontSize: 10, padding: '0 4px' }}>
276
+ {sk.skill_type}
277
+ </Tag>
278
+ <Text style={{ fontSize: 12 }}>{sk.name}</Text>
279
+ <Progress
280
+ type="circle"
281
+ percent={Math.round(sk.score * 100)}
282
+ width={24}
283
+ strokeColor={sk.score > 0.6 ? '#52c41a' : '#faad14'}
284
+ format={p => <span style={{ fontSize: 8 }}>{p}</span>}
285
+ />
286
+ </div>
287
+ </Tooltip>
288
+ ))}
289
+ </div>
290
+ </Card>
291
+ )}
292
+
293
+ {/* Execution summary */}
294
+ <Card
295
+ variant="borderless"
296
+ style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }}
297
+ >
298
+ <Row gutter={16}>
299
+ <Col span={6}>
300
+ <Statistic
301
+ title="Status"
302
+ value={result.status}
303
+ styles={{ content: { color: result.status === 'success' ? '#52c41a' : result.status === 'failed' ? '#ff4d4f' : '#faad14' } }}
304
+ />
305
+ </Col>
306
+ <Col span={6}>
307
+ <Statistic title="Total Steps" value={result.steps.length} />
308
+ </Col>
309
+ <Col span={6}>
310
+ <Statistic title="Successful" value={successCount} styles={{ content: { color: '#52c41a' } }} />
311
+ </Col>
312
+ <Col span={6}>
313
+ <Statistic
314
+ title="Total Latency"
315
+ value={result.total_latency_ms.toFixed(0)}
316
+ suffix="ms"
317
+ styles={{ content: { color: '#1677ff' } }}
318
+ />
319
+ </Col>
320
+ </Row>
321
+ </Card>
322
+
323
+ {/* Execution steps */}
324
+ <Card
325
+ title={<><ThunderboltOutlined /> Execution Steps</>}
326
+ variant="borderless"
327
+ style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }}
328
+ >
329
+ {result.steps.length === 0 ? (
330
+ <Alert type="warning" title="No executable Skill was found. Try a more specific task description." />
331
+ ) : (
332
+ result.steps.map((step, i) => <StepCard key={step.step_id} step={step} index={i} />)
333
+ )}
334
+ </Card>
335
+
336
+ {/* Final state */}
337
+ {Object.keys(result.final_state).length > 0 && (
338
+ <Card
339
+ title="Final State"
340
+ variant="borderless"
341
+ style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}
342
+ >
343
+ <pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 8, fontSize: 12, overflow: 'auto' }}>
344
+ {JSON.stringify(result.final_state, null, 2)}
345
+ </pre>
346
+ </Card>
347
+ )}
348
+ {/* Experience recording feedback */}
349
+ {result.experience_recorded && (
350
+ <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
351
+ <Card
352
+ variant="borderless"
353
+ size="small"
354
+ style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', borderLeft: '3px solid #52c41a' }}
355
+ >
356
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
357
+ <DatabaseOutlined style={{ color: '#52c41a', fontSize: 16 }} />
358
+ <Text style={{ fontSize: 13 }}>
359
+ <strong>Experience recorded</strong> - this execution trace was written to the Experience Store for Skill quality assessment and evolution decisions.
360
+ </Text>
361
+ </div>
362
+ </Card>
363
+ </motion.div>
364
+ )}
365
+ </motion.div>
366
+ )}
367
+ </AnimatePresence>
368
+ </div>
369
+ )
370
+ }
skillwiki-frontend/src/pages/Dashboard.tsx ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+ import { Alert, Card, Col, Row, Statistic, Progress, Tag, Table, Badge, Spin, Timeline, Button, Empty, Divider, Space } from 'antd'
3
+ import {
4
+ RocketOutlined,
5
+ CheckCircleOutlined,
6
+ WarningOutlined,
7
+ ThunderboltOutlined,
8
+ ClearOutlined,
9
+ WifiOutlined,
10
+ StarOutlined,
11
+ SyncOutlined,
12
+ BulbOutlined,
13
+ } from '@ant-design/icons'
14
+ import { motion } from 'framer-motion'
15
+ import { statsApi, evolutionApi } from '@/api/client'
16
+ import type { EvolutionStats } from '@/api/client'
17
+ import { getApiErrorMessage } from '@/api/errors'
18
+ import { useAppStore } from '@/store/appStore'
19
+ import type { OverviewStats, HealthReport } from '@/api/types'
20
+
21
+ const stateColors: Record<string, string> = {
22
+ S4: 'green', S2: 'blue', S3: 'cyan', S1: 'orange',
23
+ S5: 'gold', S6: 'red', S7: 'default', S0: 'purple',
24
+ }
25
+ const stateLabels: Record<string, string> = {
26
+ S0: 'Raw', S1: 'Candidate', S2: 'Draft', S3: 'Verified',
27
+ S4: 'Released', S5: 'Degraded', S6: 'Deprecated', S7: 'Archived',
28
+ }
29
+
30
+ const healthColors: Record<string, string> = {
31
+ healthy: '#52c41a',
32
+ degraded: '#faad14',
33
+ critical: '#ff4d4f',
34
+ stale: '#d9d9d9',
35
+ unknown: '#8c8c8c',
36
+ }
37
+
38
+ const REFRESH_EVENT_TYPES = new Set([
39
+ 'plan_completed',
40
+ 'health_degraded',
41
+ 'health_critical',
42
+ 'evolution_cycle_done',
43
+ ])
44
+
45
+ function formatEventTime(timestamp: string) {
46
+ const date = new Date(timestamp)
47
+ return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleTimeString()
48
+ }
49
+
50
+ export default function Dashboard() {
51
+ const [stats, setStats] = useState<OverviewStats | null>(null)
52
+ const [reports, setReports] = useState<HealthReport[]>([])
53
+ const [evoStats, setEvoStats] = useState<EvolutionStats | null>(null)
54
+ const [loading, setLoading] = useState(true)
55
+ const [refreshing, setRefreshing] = useState(false)
56
+ const [error, setError] = useState<string | null>(null)
57
+ const [lastUpdated, setLastUpdated] = useState<string | null>(null)
58
+ const { wsEvents, clearWsEvents } = useAppStore()
59
+
60
+ const loadDashboardData = useCallback(async (initial = false) => {
61
+ if (initial) setLoading(true)
62
+ else setRefreshing(true)
63
+
64
+ try {
65
+ const [s, h, evo] = await Promise.all([
66
+ statsApi.overview(),
67
+ evolutionApi.systemHealth().catch(() => null),
68
+ statsApi.evolutionStats().catch(() => null),
69
+ ])
70
+
71
+ setStats(s)
72
+ setReports(h?.skill_reports.slice(0, 10) ?? [])
73
+ setEvoStats(evo)
74
+ setError(null)
75
+ setLastUpdated(new Date().toLocaleTimeString())
76
+ } catch (e: unknown) {
77
+ setError(getApiErrorMessage(e, 'Failed to load dashboard data'))
78
+ } finally {
79
+ if (initial) setLoading(false)
80
+ else setRefreshing(false)
81
+ }
82
+ }, [])
83
+
84
+ useEffect(() => {
85
+ const timeoutId = window.setTimeout(() => { void loadDashboardData(true) }, 0)
86
+ const timer = window.setInterval(() => {
87
+ void loadDashboardData(false)
88
+ }, 15_000)
89
+ return () => {
90
+ window.clearTimeout(timeoutId)
91
+ window.clearInterval(timer)
92
+ }
93
+ }, [loadDashboardData])
94
+
95
+ const refreshSignal = useMemo(() => {
96
+ const event = wsEvents.find(e => REFRESH_EVENT_TYPES.has(e.type))
97
+ return event ? `${event.type}:${event.timestamp}` : ''
98
+ }, [wsEvents])
99
+
100
+ useEffect(() => {
101
+ if (!refreshSignal) return
102
+ const timeoutId = window.setTimeout(() => { void loadDashboardData(false) }, 0)
103
+ return () => window.clearTimeout(timeoutId)
104
+ }, [loadDashboardData, refreshSignal])
105
+
106
+ const feedEvents = useMemo(
107
+ () => wsEvents.filter(e => e.type !== 'pong' && e.type !== 'connected').slice(0, 30),
108
+ [wsEvents],
109
+ )
110
+
111
+ if (loading && !stats) return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>
112
+ if (!stats) {
113
+ return (
114
+ <div style={{ padding: 24 }}>
115
+ <Alert
116
+ type="error"
117
+ showIcon
118
+ title="Failed to Load Dashboard Data"
119
+ description={error || 'Please check whether the backend service is available.'}
120
+ action={<Button size="small" onClick={() => loadDashboardData(true)}>Retry</Button>}
121
+ />
122
+ </div>
123
+ )
124
+ }
125
+
126
+ const healthyPct = stats.total_skills > 0
127
+ ? Math.round((stats.by_state['S4'] || 0) / stats.total_skills * 100)
128
+ : 0
129
+
130
+ const cardVariants = {
131
+ hidden: { opacity: 0, y: 20 },
132
+ visible: (i: number) => ({ opacity: 1, y: 0, transition: { delay: i * 0.08 } }),
133
+ }
134
+
135
+ return (
136
+ <div style={{ padding: '24px' }}>
137
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
138
+ <motion.h2
139
+ initial={{ opacity: 0, x: -20 }}
140
+ animate={{ opacity: 1, x: 0 }}
141
+ style={{ margin: 0, fontSize: 22, fontWeight: 700 }}
142
+ >
143
+ SkillWiki Dashboard
144
+ </motion.h2>
145
+ <Space>
146
+ <span style={{ color: '#999', fontSize: 12 }}>
147
+ Last updated: {lastUpdated || 'Not refreshed yet'}
148
+ </span>
149
+ <Button
150
+ size="small"
151
+ icon={<SyncOutlined spin={refreshing} />}
152
+ loading={refreshing}
153
+ onClick={() => loadDashboardData(false)}
154
+ >
155
+ Refresh
156
+ </Button>
157
+ </Space>
158
+ </div>
159
+
160
+ {error && (
161
+ <Alert
162
+ type="warning"
163
+ showIcon
164
+ closable
165
+ title="Latest Refresh Failed"
166
+ description={error}
167
+ style={{ marginBottom: 16 }}
168
+ onClose={() => setError(null)}
169
+ />
170
+ )}
171
+
172
+ {/* Core metrics */}
173
+ <Row gutter={[16, 16]}>
174
+ {[
175
+ { title: 'Total Skills', value: stats.total_skills, icon: <RocketOutlined />, color: '#1677ff', i: 0 },
176
+ { title: 'Released', value: stats.by_state['S4'] || 0, icon: <CheckCircleOutlined />, color: '#52c41a', i: 1 },
177
+ { title: 'Degraded', value: stats.by_state['S5'] || 0, icon: <WarningOutlined />, color: '#faad14', i: 2 },
178
+ { title: 'Total Executions', value: stats.total_executions, icon: <ThunderboltOutlined />, color: '#722ed1', i: 3 },
179
+ ].map(({ title, value, icon, color, i }) => (
180
+ <Col xs={24} sm={12} lg={6} key={title}>
181
+ <motion.div custom={i} initial="hidden" animate="visible" variants={cardVariants}>
182
+ <Card variant="borderless" style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
183
+ <Statistic
184
+ title={title}
185
+ value={value}
186
+ prefix={<span style={{ color }}>{icon}</span>}
187
+ styles={{ content: { color, fontWeight: 700 } }}
188
+ />
189
+ </Card>
190
+ </motion.div>
191
+ </Col>
192
+ ))}
193
+ </Row>
194
+
195
+ <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
196
+ {/* Type distribution */}
197
+ <Col xs={24} md={8}>
198
+ <motion.div custom={4} initial="hidden" animate="visible" variants={cardVariants}>
199
+ <Card title="Skill Types" variant="borderless" style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
200
+ {Object.entries(stats.by_type).map(([type, count]) => (
201
+ <div key={type} style={{ marginBottom: 12 }}>
202
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
203
+ <Tag color={type === 'atomic' ? 'blue' : type === 'functional' ? 'purple' : 'gold'}>
204
+ {type.toUpperCase()}
205
+ </Tag>
206
+ <span style={{ fontWeight: 600 }}>{count}</span>
207
+ </div>
208
+ <Progress
209
+ percent={stats.total_skills > 0 ? Math.round(count / stats.total_skills * 100) : 0}
210
+ strokeColor={type === 'atomic' ? '#1677ff' : type === 'functional' ? '#722ed1' : '#faad14'}
211
+ showInfo={false}
212
+ size="small"
213
+ />
214
+ </div>
215
+ ))}
216
+ </Card>
217
+ </motion.div>
218
+ </Col>
219
+
220
+ {/* State distribution */}
221
+ <Col xs={24} md={8}>
222
+ <motion.div custom={5} initial="hidden" animate="visible" variants={cardVariants}>
223
+ <Card title="State Distribution" variant="borderless" style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
224
+ {Object.entries(stats.by_state).map(([state, count]) => (
225
+ <div key={state} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
226
+ <Tag color={stateColors[state] || 'default'}>{stateLabels[state] || state}</Tag>
227
+ <Badge count={count} style={{ backgroundColor: '#1677ff' }} />
228
+ </div>
229
+ ))}
230
+ </Card>
231
+ </motion.div>
232
+ </Col>
233
+
234
+ {/* Health score */}
235
+ <Col xs={24} md={8}>
236
+ <motion.div custom={6} initial="hidden" animate="visible" variants={cardVariants}>
237
+ <Card title="System Health" variant="borderless" style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
238
+ <div style={{ textAlign: 'center', marginBottom: 16 }}>
239
+ <Progress
240
+ type="circle"
241
+ percent={healthyPct}
242
+ strokeColor={{ '0%': '#52c41a', '100%': '#1677ff' }}
243
+ format={p => <span style={{ fontWeight: 700 }}>{p}%<br /><small>Released</small></span>}
244
+ />
245
+ </div>
246
+ <Statistic
247
+ title="Avg Success Rate"
248
+ value={(stats.avg_success_rate * 100).toFixed(1)}
249
+ suffix="%"
250
+ styles={{ content: { color: stats.avg_success_rate > 0.8 ? '#52c41a' : '#faad14' } }}
251
+ />
252
+ </Card>
253
+ </motion.div>
254
+ </Col>
255
+ </Row>
256
+
257
+ {/* Health report table */}
258
+ <motion.div custom={7} initial="hidden" animate="visible" variants={cardVariants} style={{ marginTop: 16 }}>
259
+ <Card title="Skill Health Overview" variant="borderless" style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
260
+ <Table
261
+ dataSource={reports}
262
+ rowKey="skill_id"
263
+ size="small"
264
+ pagination={false}
265
+ columns={[
266
+ {
267
+ title: 'Skill',
268
+ dataIndex: 'skill_name',
269
+ render: (name: string) => <strong>{name}</strong>,
270
+ },
271
+ {
272
+ title: 'Status',
273
+ dataIndex: 'status',
274
+ render: (s: string) => (
275
+ <Badge color={healthColors[s] || '#8c8c8c'} text={s} />
276
+ ),
277
+ },
278
+ {
279
+ title: 'Success Rate',
280
+ dataIndex: 'success_rate',
281
+ render: (r: number) => (
282
+ <Progress
283
+ percent={Math.round(r * 100)}
284
+ size="small"
285
+ strokeColor={r > 0.8 ? '#52c41a' : r > 0.5 ? '#faad14' : '#ff4d4f'}
286
+ />
287
+ ),
288
+ },
289
+ {
290
+ title: 'Executions',
291
+ dataIndex: 'usage_count',
292
+ },
293
+ {
294
+ title: 'Avg Latency',
295
+ dataIndex: 'avg_latency_ms',
296
+ render: (ms: number) => `${ms.toFixed(0)}ms`,
297
+ },
298
+ ]}
299
+ />
300
+ </Card>
301
+ </motion.div>
302
+
303
+ {/* Evolution metrics panel */}
304
+ {evoStats && (
305
+ <motion.div custom={8} initial="hidden" animate="visible" variants={cardVariants} style={{ marginTop: 16 }}>
306
+ <Card
307
+ title={<span><SyncOutlined style={{ color: '#1677ff', marginRight: 6 }} />Self-Evolution Metrics</span>}
308
+ variant="borderless"
309
+ style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}
310
+ >
311
+ <Row gutter={[16, 16]}>
312
+ <Col xs={12} sm={6}>
313
+ <div style={{ textAlign: 'center' }}>
314
+ <Statistic
315
+ title="Auto-Generated Skills"
316
+ value={evoStats.auto_generated}
317
+ prefix={<BulbOutlined style={{ color: '#faad14' }} />}
318
+ styles={{ content: { color: '#faad14', fontWeight: 700 } }}
319
+ />
320
+ </div>
321
+ </Col>
322
+ <Col xs={12} sm={6}>
323
+ <div style={{ textAlign: 'center' }}>
324
+ <Statistic
325
+ title="Avg Reuse Rate"
326
+ value={evoStats.avg_reuse_rate.toFixed(1)}
327
+ suffix="x"
328
+ prefix={<StarOutlined style={{ color: '#722ed1' }} />}
329
+ styles={{ content: { color: '#722ed1', fontWeight: 700 } }}
330
+ />
331
+ </div>
332
+ </Col>
333
+ <Col xs={12} sm={6}>
334
+ <div style={{ textAlign: 'center' }}>
335
+ <Statistic
336
+ title="Version Improved"
337
+ value={evoStats.version_improved_count}
338
+ prefix={<RocketOutlined style={{ color: '#52c41a' }} />}
339
+ styles={{ content: { color: '#52c41a', fontWeight: 700 } }}
340
+ />
341
+ </div>
342
+ </Col>
343
+ <Col xs={12} sm={6}>
344
+ <div style={{ textAlign: 'center' }}>
345
+ <Statistic
346
+ title="Avg Success Rate"
347
+ value={(evoStats.avg_success_rate * 100).toFixed(1)}
348
+ suffix="%"
349
+ prefix={<CheckCircleOutlined style={{ color: '#1677ff' }} />}
350
+ styles={{ content: { color: '#1677ff', fontWeight: 700 } }}
351
+ />
352
+ </div>
353
+ </Col>
354
+ </Row>
355
+ <Divider style={{ margin: '16px 0 12px' }} />
356
+ <div style={{ marginBottom: 8 }}>
357
+ <Tag color="blue">Skills by Category</Tag>
358
+ </div>
359
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
360
+ {Object.entries(evoStats.skills_by_category).map(([cat, count]) => (
361
+ <Tag key={cat} color={cat === 'atomic' ? 'blue' : cat === 'functional' ? 'purple' : cat === 'strategic' ? 'gold' : 'default'}>
362
+ {cat}: {count}
363
+ </Tag>
364
+ ))}
365
+ </div>
366
+ {evoStats.recent_activity.length > 0 && (
367
+ <>
368
+ <Divider style={{ margin: '12px 0' }} />
369
+ <div style={{ marginBottom: 8 }}>
370
+ <Tag color="green">Recent Activity</Tag>
371
+ </div>
372
+ <div style={{ maxHeight: 160, overflowY: 'auto' }}>
373
+ {evoStats.recent_activity.map((a, i) => (
374
+ <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', borderBottom: i < evoStats.recent_activity.length - 1 ? '1px solid #f0f0f0' : 'none' }}>
375
+ <Badge color={a.event === 'updated' ? 'blue' : 'green'} />
376
+ <span style={{ fontSize: 12, flex: 1 }}><strong>{a.name}</strong> — {a.event}</span>
377
+ <Tag style={{ fontSize: 10 }}>{a.state}</Tag>
378
+ </div>
379
+ ))}
380
+ </div>
381
+ </>
382
+ )}
383
+ </Card>
384
+ </motion.div>
385
+ )}
386
+
387
+ {/* Agent activity feed */}
388
+ <motion.div custom={8} initial="hidden" animate="visible" variants={cardVariants} style={{ marginTop: 16 }}>
389
+ <Card
390
+ title={<span><WifiOutlined style={{ color: '#52c41a', marginRight: 6 }} />Live Agent Activity</span>}
391
+ variant="borderless"
392
+ style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}
393
+ extra={
394
+ <Button size="small" icon={<ClearOutlined />} onClick={clearWsEvents}>
395
+ Clear
396
+ </Button>
397
+ }
398
+ >
399
+ {feedEvents.length === 0 ? (
400
+ <Empty description="No events yet. Live activity will appear here after Agent tasks run." image={Empty.PRESENTED_IMAGE_SIMPLE} />
401
+ ) : (
402
+ <div style={{ maxHeight: 320, overflowY: 'auto' }}>
403
+ <Timeline
404
+ items={feedEvents
405
+ .map(e => {
406
+ const isError = e.type.includes('error') || e.type.includes('fail')
407
+ const isSuccess = e.type.includes('success') || e.type.includes('complete') || e.type.includes('release')
408
+ return {
409
+ color: isError ? 'red' : isSuccess ? 'green' : 'blue',
410
+ children: (
411
+ <div>
412
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
413
+ <Tag color={isError ? 'red' : isSuccess ? 'green' : 'blue'} style={{ fontSize: 11 }}>
414
+ {e.type}
415
+ </Tag>
416
+ <span style={{ fontSize: 11, color: '#999' }}>{formatEventTime(e.timestamp)}</span>
417
+ </div>
418
+ {e.payload !== null && e.payload !== undefined && typeof e.payload === 'object' && (
419
+ <div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
420
+ {JSON.stringify(e.payload as Record<string, unknown>).slice(0, 120)}
421
+ </div>
422
+ )}
423
+ </div>
424
+ ),
425
+ }
426
+ })}
427
+ />
428
+ </div>
429
+ )}
430
+ </Card>
431
+ </motion.div>
432
+ </div>
433
+ )
434
+ }
skillwiki-frontend/src/pages/EvaluationDashboard.tsx ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+ import {
3
+ Alert,
4
+ Button,
5
+ Card,
6
+ Col,
7
+ Empty,
8
+ Progress,
9
+ Row,
10
+ Space,
11
+ Spin,
12
+ Statistic,
13
+ Table,
14
+ Tag,
15
+ Typography,
16
+ } from 'antd'
17
+ import type { ColumnsType } from 'antd/es/table'
18
+ import { ReloadOutlined } from '@ant-design/icons'
19
+ import { evaluationApi } from '@/api/client'
20
+ import { getApiErrorMessage } from '@/api/errors'
21
+ import type {
22
+ EvaluationDashboardResponse,
23
+ EvaluationDemoBenchmark,
24
+ EvaluationDemoRow,
25
+ EvaluationLlmRow,
26
+ EvaluationModeTotal,
27
+ EvaluationSearchEval,
28
+ EvaluationSearchRow,
29
+ } from '@/api/types'
30
+
31
+ const { Text, Paragraph } = Typography
32
+
33
+ const modeLabels: Record<string, string> = {
34
+ no_skill: 'No Skill',
35
+ raw_prompt: 'Raw Prompt',
36
+ with_skill: 'With Skill',
37
+ fallback: 'Fallback Planner',
38
+ llm: 'LLM Planner',
39
+ }
40
+
41
+ const statusColors: Record<string, string> = {
42
+ success: 'green',
43
+ passed: 'green',
44
+ failed: 'red',
45
+ functional_failure: 'red',
46
+ api_failure: 'volcano',
47
+ skipped: 'default',
48
+ incomplete_llm: 'gold',
49
+ with_skill: 'blue',
50
+ }
51
+
52
+ function formatPercent(value?: number | null) {
53
+ if (typeof value !== 'number' || Number.isNaN(value)) return 'N/A'
54
+ return `${(value * 100).toFixed(1)}%`
55
+ }
56
+
57
+ function percentNumber(value?: number | null) {
58
+ if (typeof value !== 'number' || Number.isNaN(value)) return 0
59
+ return Math.round(value * 1000) / 10
60
+ }
61
+
62
+ function formatLatency(value?: number | null) {
63
+ if (typeof value !== 'number' || Number.isNaN(value)) return 'N/A'
64
+ return `${value.toFixed(value >= 10 ? 0 : 2)} ms`
65
+ }
66
+
67
+ function statusTag(status?: string | null) {
68
+ if (!status) return <Text type="secondary">N/A</Text>
69
+ return <Tag color={statusColors[status] || 'default'}>{status}</Tag>
70
+ }
71
+
72
+ function verifierTag(value?: boolean | null) {
73
+ if (value === true) return <Tag color="green">pass</Tag>
74
+ if (value === false) return <Tag color="red">fail</Tag>
75
+ return <Text type="secondary">N/A</Text>
76
+ }
77
+
78
+ function artifactTime(artifact: { generated_at?: string | null; updated_at?: string | null }) {
79
+ return artifact.generated_at || artifact.updated_at || 'N/A'
80
+ }
81
+
82
+ function ModeCard({ mode, total }: { mode: string; total?: EvaluationModeTotal }) {
83
+ const success = total?.success ?? 0
84
+ const count = total?.total ?? 0
85
+ const rate = total?.success_rate ?? total?.success_rate_excluding_api_failures ?? null
86
+ return (
87
+ <div style={{ background: '#fafafa', borderRadius: 8, padding: 16 }}>
88
+ <Statistic
89
+ title={modeLabels[mode] || mode}
90
+ value={`${success}/${count}`}
91
+ styles={{ content: { color: rate && rate > 0 ? '#1677ff' : '#8c8c8c', fontWeight: 700 } }}
92
+ />
93
+ <Progress
94
+ percent={percentNumber(rate)}
95
+ size="small"
96
+ status={rate && rate > 0 ? 'active' : 'normal'}
97
+ style={{ marginTop: 8 }}
98
+ />
99
+ <Text type="secondary">{formatPercent(rate)}</Text>
100
+ </div>
101
+ )
102
+ }
103
+
104
+ function SuccessRateBars({ artifact }: { artifact: EvaluationDemoBenchmark }) {
105
+ const modes = ['no_skill', 'raw_prompt', 'with_skill']
106
+ return (
107
+ <Space orientation="vertical" style={{ width: '100%' }} size={12}>
108
+ {modes.map(mode => {
109
+ const total = artifact.mode_totals[mode]
110
+ const pct = percentNumber(total?.success_rate)
111
+ return (
112
+ <div key={mode}>
113
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
114
+ <Text>{modeLabels[mode]}</Text>
115
+ <Text strong>{formatPercent(total?.success_rate)}</Text>
116
+ </div>
117
+ <div style={{ height: 12, background: '#f0f0f0', borderRadius: 4, overflow: 'hidden' }}>
118
+ <div
119
+ style={{
120
+ width: `${pct}%`,
121
+ height: '100%',
122
+ background: mode === 'with_skill' ? '#1677ff' : '#d9d9d9',
123
+ }}
124
+ />
125
+ </div>
126
+ </div>
127
+ )
128
+ })}
129
+ </Space>
130
+ )
131
+ }
132
+
133
+ function DemoBenchmarkPanel({ artifact }: { artifact: EvaluationDemoBenchmark }) {
134
+ const columns: ColumnsType<EvaluationDemoRow> = [
135
+ {
136
+ title: 'Task',
137
+ dataIndex: 'task_id',
138
+ key: 'task_id',
139
+ width: 260,
140
+ render: value => <Text code>{value}</Text>,
141
+ },
142
+ {
143
+ title: 'Category',
144
+ dataIndex: 'domain',
145
+ key: 'domain',
146
+ width: 110,
147
+ render: value => <Tag>{value || 'unknown'}</Tag>,
148
+ },
149
+ {
150
+ title: 'No Skill',
151
+ dataIndex: 'no_skill',
152
+ key: 'no_skill',
153
+ width: 110,
154
+ render: statusTag,
155
+ },
156
+ {
157
+ title: 'Raw Prompt',
158
+ dataIndex: 'raw_prompt',
159
+ key: 'raw_prompt',
160
+ width: 120,
161
+ render: statusTag,
162
+ },
163
+ {
164
+ title: 'With Skill',
165
+ dataIndex: 'with_skill',
166
+ key: 'with_skill',
167
+ width: 120,
168
+ render: statusTag,
169
+ },
170
+ {
171
+ title: 'Verifier',
172
+ key: 'verifier',
173
+ width: 120,
174
+ render: (_, row) => verifierTag(row.with_skill_verifier_passed),
175
+ },
176
+ {
177
+ title: 'Latency',
178
+ key: 'latency',
179
+ width: 120,
180
+ render: (_, row) => formatLatency(row.with_skill_latency_ms),
181
+ },
182
+ {
183
+ title: 'Failure Reason',
184
+ dataIndex: 'failure_reason',
185
+ key: 'failure_reason',
186
+ render: value => (
187
+ <Paragraph ellipsis={{ rows: 2, expandable: true, symbol: 'more' }} style={{ marginBottom: 0 }}>
188
+ {value || 'N/A'}
189
+ </Paragraph>
190
+ ),
191
+ },
192
+ ]
193
+
194
+ return (
195
+ <Card
196
+ title="Demo Benchmark"
197
+ extra={<Text type="secondary">{artifact.source_file} · {artifactTime(artifact)}</Text>}
198
+ variant="borderless"
199
+ style={{ borderRadius: 8 }}
200
+ >
201
+ <Row gutter={[16, 16]}>
202
+ {['no_skill', 'raw_prompt', 'with_skill'].map(mode => (
203
+ <Col xs={24} md={8} key={mode}>
204
+ <ModeCard mode={mode} total={artifact.mode_totals[mode]} />
205
+ </Col>
206
+ ))}
207
+ </Row>
208
+
209
+ <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
210
+ <Col xs={24} lg={8}>
211
+ <div style={{ background: '#fafafa', borderRadius: 8, padding: 16 }}>
212
+ <Text strong style={{ display: 'block', marginBottom: 12 }}>Success Rate</Text>
213
+ <SuccessRateBars artifact={artifact} />
214
+ </div>
215
+ </Col>
216
+ <Col xs={24} lg={16}>
217
+ <Table
218
+ dataSource={artifact.rows}
219
+ columns={columns}
220
+ rowKey="task_id"
221
+ size="small"
222
+ pagination={{ pageSize: 8 }}
223
+ scroll={{ x: 980 }}
224
+ expandable={{
225
+ expandedRowRender: row => (
226
+ <Space orientation="vertical" size={4}>
227
+ <Text>No Skill latency: {formatLatency(row.no_skill_latency_ms)} · verifier: {verifierTag(row.no_skill_verifier_passed)}</Text>
228
+ <Text>Raw Prompt latency: {formatLatency(row.raw_prompt_latency_ms)} · verifier: {verifierTag(row.raw_prompt_verifier_passed)}</Text>
229
+ <Text>With Skill latency: {formatLatency(row.with_skill_latency_ms)} · verifier: {verifierTag(row.with_skill_verifier_passed)}</Text>
230
+ </Space>
231
+ ),
232
+ }}
233
+ />
234
+ </Col>
235
+ </Row>
236
+ </Card>
237
+ )
238
+ }
239
+
240
+ function SearchEvalPanel({ artifact }: { artifact: EvaluationSearchEval }) {
241
+ const summary = artifact.comparison as {
242
+ lexical?: { top1_hit_rate?: number; top3_hit_rate?: number }
243
+ hybrid?: { top1_hit_rate?: number; top3_hit_rate?: number }
244
+ delta?: { top1_hit_rate?: number; top3_hit_rate?: number }
245
+ }
246
+ const columns: ColumnsType<EvaluationSearchRow> = [
247
+ { title: 'Query', dataIndex: 'query', key: 'query', width: 240 },
248
+ {
249
+ title: 'Expected',
250
+ dataIndex: 'expected_skill_ids',
251
+ key: 'expected_skill_ids',
252
+ width: 180,
253
+ render: ids => ((ids as string[]) || []).map(id => <Tag key={id}>{id}</Tag>),
254
+ },
255
+ { title: 'Lexical Top', dataIndex: 'lexical_top_skill', key: 'lexical_top_skill', width: 170 },
256
+ { title: 'Hybrid Top', dataIndex: 'hybrid_top_skill', key: 'hybrid_top_skill', width: 170 },
257
+ {
258
+ title: 'Lexical Hit',
259
+ dataIndex: 'lexical_top1_hit',
260
+ key: 'lexical_top1_hit',
261
+ width: 110,
262
+ render: verifierTag,
263
+ },
264
+ {
265
+ title: 'Hybrid Hit',
266
+ dataIndex: 'hybrid_top1_hit',
267
+ key: 'hybrid_top1_hit',
268
+ width: 110,
269
+ render: verifierTag,
270
+ },
271
+ ]
272
+ return (
273
+ <Card
274
+ title="Search Evaluation"
275
+ extra={<Text type="secondary">{artifact.source_file} · {artifact.schema_version || 'schema N/A'}</Text>}
276
+ variant="borderless"
277
+ style={{ borderRadius: 8 }}
278
+ >
279
+ <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
280
+ <Col xs={24} sm={8}>
281
+ <Statistic title="Queries" value={artifact.query_count} />
282
+ </Col>
283
+ <Col xs={24} sm={8}>
284
+ <Statistic title="Lexical Top-1" value={formatPercent(summary.lexical?.top1_hit_rate)} />
285
+ </Col>
286
+ <Col xs={24} sm={8}>
287
+ <Statistic title="Hybrid Top-1" value={formatPercent(summary.hybrid?.top1_hit_rate)} />
288
+ </Col>
289
+ </Row>
290
+ <Alert
291
+ type="info"
292
+ showIcon
293
+ title={`Observed hybrid delta: Top-1 ${formatPercent(summary.delta?.top1_hit_rate)}, Top-3 ${formatPercent(summary.delta?.top3_hit_rate)}`}
294
+ style={{ marginBottom: 16 }}
295
+ />
296
+ <Table
297
+ dataSource={artifact.rows}
298
+ columns={columns}
299
+ rowKey={row => row.query_id || row.query || 'query-row'}
300
+ size="small"
301
+ pagination={{ pageSize: 6 }}
302
+ scroll={{ x: 980 }}
303
+ />
304
+ </Card>
305
+ )
306
+ }
307
+
308
+ function LlmPlannerPanel({ artifact }: { artifact: EvaluationDashboardResponse['artifacts']['llm_planner'] }) {
309
+ const columns: ColumnsType<EvaluationLlmRow> = [
310
+ { title: 'Task', dataIndex: 'task_id', key: 'task_id', width: 240, render: value => <Text code>{value}</Text> },
311
+ { title: 'Fallback', dataIndex: 'fallback_status', key: 'fallback_status', width: 140, render: statusTag },
312
+ { title: 'LLM', dataIndex: 'llm_status', key: 'llm_status', width: 120, render: statusTag },
313
+ { title: 'API State', dataIndex: 'llm_api_error_type', key: 'llm_api_error_type', width: 140, render: value => value || 'N/A' },
314
+ {
315
+ title: 'Failure Reason',
316
+ dataIndex: 'llm_failure_reason',
317
+ key: 'llm_failure_reason',
318
+ render: value => (
319
+ <Paragraph ellipsis={{ rows: 2, expandable: true, symbol: 'more' }} style={{ marginBottom: 0 }}>
320
+ {value || 'N/A'}
321
+ </Paragraph>
322
+ ),
323
+ },
324
+ ]
325
+ return (
326
+ <Card
327
+ title="Planner Evaluation"
328
+ extra={<Text type="secondary">{artifact.source_file} · {artifactTime(artifact)}</Text>}
329
+ variant="borderless"
330
+ style={{ borderRadius: 8 }}
331
+ >
332
+ <Alert
333
+ type="warning"
334
+ showIcon
335
+ title="LLM planner rows marked skipped are incomplete runs, not model-quality failures."
336
+ style={{ marginBottom: 16 }}
337
+ />
338
+ <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
339
+ {['fallback', 'llm'].map(mode => (
340
+ <Col xs={24} sm={12} key={mode}>
341
+ <ModeCard mode={mode} total={artifact.mode_totals[mode]} />
342
+ </Col>
343
+ ))}
344
+ </Row>
345
+ <Table
346
+ dataSource={artifact.rows}
347
+ columns={columns}
348
+ rowKey={row => row.task_id || 'planner-row'}
349
+ size="small"
350
+ pagination={{ pageSize: 6 }}
351
+ scroll={{ x: 920 }}
352
+ />
353
+ </Card>
354
+ )
355
+ }
356
+
357
+ export default function EvaluationDashboard() {
358
+ const [data, setData] = useState<EvaluationDashboardResponse | null>(null)
359
+ const [loading, setLoading] = useState(true)
360
+ const [refreshing, setRefreshing] = useState(false)
361
+ const [error, setError] = useState<string | null>(null)
362
+
363
+ const loadData = useCallback(async (initial = false) => {
364
+ if (initial) setLoading(true)
365
+ else setRefreshing(true)
366
+ try {
367
+ const response = await evaluationApi.dashboard()
368
+ setData(response)
369
+ setError(null)
370
+ } catch (e: unknown) {
371
+ setError(getApiErrorMessage(e, 'Evaluation data load failed'))
372
+ } finally {
373
+ if (initial) setLoading(false)
374
+ else setRefreshing(false)
375
+ }
376
+ }, [])
377
+
378
+ useEffect(() => {
379
+ let active = true
380
+ evaluationApi.dashboard()
381
+ .then(response => {
382
+ if (!active) return
383
+ setData(response)
384
+ setError(null)
385
+ })
386
+ .catch((e: unknown) => {
387
+ if (!active) return
388
+ setError(getApiErrorMessage(e, 'Evaluation data load failed'))
389
+ })
390
+ .finally(() => {
391
+ if (active) setLoading(false)
392
+ })
393
+ return () => {
394
+ active = false
395
+ }
396
+ }, [])
397
+
398
+ const warnings = useMemo(() => data?.warnings ?? [], [data])
399
+ const demo = data?.artifacts.demo_benchmark
400
+ const search = data?.artifacts.search_eval
401
+ const planner = data?.artifacts.llm_planner
402
+
403
+ if (loading && !data) {
404
+ return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>
405
+ }
406
+
407
+ return (
408
+ <div style={{ padding: 24 }}>
409
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'center', marginBottom: 16, flexWrap: 'wrap' }}>
410
+ <div>
411
+ <h2 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>Evaluation Dashboard</h2>
412
+ <Text type="secondary">Demo benchmark evidence for paper-ready screenshots</Text>
413
+ </div>
414
+ <Space>
415
+ <Text type="secondary" style={{ fontSize: 12 }}>
416
+ {data ? `Loaded ${data.generated_at}` : 'Not loaded'}
417
+ </Text>
418
+ <Button icon={<ReloadOutlined spin={refreshing} />} loading={refreshing} onClick={() => loadData(false)}>
419
+ Refresh
420
+ </Button>
421
+ </Space>
422
+ </div>
423
+
424
+ {error && (
425
+ <Alert
426
+ type="error"
427
+ showIcon
428
+ title="Evaluation data load failed"
429
+ description={error}
430
+ action={<Button size="small" onClick={() => loadData(true)}>Retry</Button>}
431
+ style={{ marginBottom: 16 }}
432
+ />
433
+ )}
434
+
435
+ {warnings.length > 0 && (
436
+ <Alert
437
+ type="warning"
438
+ showIcon
439
+ title="Artifact warnings"
440
+ description={warnings.join(' | ')}
441
+ style={{ marginBottom: 16 }}
442
+ />
443
+ )}
444
+
445
+ {!data?.results_dir_present && (
446
+ <Alert
447
+ type="info"
448
+ showIcon
449
+ title="Benchmark results directory is not present in this runtime."
450
+ style={{ marginBottom: 16 }}
451
+ />
452
+ )}
453
+
454
+ <Space orientation="vertical" size={16} style={{ width: '100%' }}>
455
+ {demo?.available ? (
456
+ <DemoBenchmarkPanel artifact={demo} />
457
+ ) : (
458
+ <Card variant="borderless" style={{ borderRadius: 8 }}>
459
+ <Empty description={demo?.error || 'No demo benchmark result available'} />
460
+ </Card>
461
+ )}
462
+
463
+ {search?.available ? (
464
+ <SearchEvalPanel artifact={search} />
465
+ ) : (
466
+ <Card title="Search Evaluation" variant="borderless" style={{ borderRadius: 8 }}>
467
+ <Empty description={search?.error || 'No search evaluation result available'} />
468
+ </Card>
469
+ )}
470
+
471
+ {planner?.available ? (
472
+ <LlmPlannerPanel artifact={planner} />
473
+ ) : (
474
+ <Card title="Planner Evaluation" variant="borderless" style={{ borderRadius: 8 }}>
475
+ <Empty description={planner?.error || 'No planner evaluation result available'} />
476
+ </Card>
477
+ )}
478
+ </Space>
479
+ </div>
480
+ )
481
+ }
skillwiki-frontend/src/pages/Evolution.tsx ADDED
@@ -0,0 +1,583 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import {
3
+ Alert,
4
+ Badge,
5
+ Button,
6
+ Card,
7
+ Col,
8
+ Empty,
9
+ message,
10
+ Progress,
11
+ Popconfirm,
12
+ Row,
13
+ Space,
14
+ Spin,
15
+ Statistic,
16
+ Table,
17
+ Tag,
18
+ Typography,
19
+ } from 'antd'
20
+ import type { TableColumnsType } from 'antd'
21
+ import {
22
+ BranchesOutlined,
23
+ CheckCircleOutlined,
24
+ CloseCircleOutlined,
25
+ MedicineBoxOutlined,
26
+ SyncOutlined,
27
+ } from '@ant-design/icons'
28
+ import { motion } from 'framer-motion'
29
+ import { useNavigate } from 'react-router-dom'
30
+ import { evolutionApi } from '@/api/client'
31
+ import { getApiErrorMessage } from '@/api/errors'
32
+ import type {
33
+ HealthReport,
34
+ MaintenanceProposal,
35
+ MaintenanceProposalListResponse,
36
+ SystemHealth,
37
+ } from '@/api/types'
38
+
39
+ const { Text } = Typography
40
+
41
+ const STATUS_COLOR: Record<string, string> = {
42
+ healthy: '#52c41a',
43
+ degraded: '#faad14',
44
+ critical: '#ff4d4f',
45
+ stale: '#d9d9d9',
46
+ unknown: '#8c8c8c',
47
+ }
48
+
49
+ const STATUS_LABEL: Record<string, string> = {
50
+ healthy: 'Healthy',
51
+ degraded: 'Degraded',
52
+ critical: 'Critical',
53
+ stale: 'Stale',
54
+ unknown: 'Unknown',
55
+ }
56
+
57
+ type EvolutionCycleResult = {
58
+ cycle_id?: string
59
+ tasks_total?: number
60
+ tasks_completed?: number
61
+ tasks_failed?: number
62
+ repaired?: string[]
63
+ deprecated?: string[]
64
+ merged?: unknown[]
65
+ split?: unknown[]
66
+ errors?: string[]
67
+ maintenance_proposals?: MaintenanceProposal[]
68
+ }
69
+
70
+ type RepairResult = {
71
+ success?: boolean
72
+ root_cause?: string
73
+ should_deprecate?: boolean
74
+ }
75
+
76
+ function countOf(value: unknown[] | undefined) {
77
+ return Array.isArray(value) ? value.length : 0
78
+ }
79
+
80
+ function proposalColor(action?: string) {
81
+ if (action === 'repair') return 'orange'
82
+ if (action === 'review') return 'blue'
83
+ if (action === 'deprecate') return 'red'
84
+ return 'default'
85
+ }
86
+
87
+ function proposalStatusColor(status?: string) {
88
+ if (status === 'pending') return 'gold'
89
+ if (status === 'accepted') return 'green'
90
+ if (status === 'rejected') return 'red'
91
+ if (status === 'superseded') return 'default'
92
+ return 'blue'
93
+ }
94
+
95
+ function joinPreview(values?: string[], fallback = 'None') {
96
+ if (!values || values.length === 0) return fallback
97
+ return values.slice(0, 2).join('; ')
98
+ }
99
+
100
+ export default function Evolution() {
101
+ const navigate = useNavigate()
102
+ const [health, setHealth] = useState<SystemHealth | null>(null)
103
+ const [loading, setLoading] = useState(true)
104
+ const [error, setError] = useState<string | null>(null)
105
+ const [cycling, setCycling] = useState(false)
106
+ const [repairing, setRepairing] = useState<string | null>(null)
107
+ const [cycleResult, setCycleResult] = useState<EvolutionCycleResult | null>(null)
108
+ const [proposalQueue, setProposalQueue] = useState<MaintenanceProposalListResponse | null>(null)
109
+ const [queueLoading, setQueueLoading] = useState(false)
110
+ const [proposalActionId, setProposalActionId] = useState<string | null>(null)
111
+
112
+ const loadHealth = async () => {
113
+ setLoading(true)
114
+ setError(null)
115
+ try {
116
+ const data = await evolutionApi.systemHealth()
117
+ setHealth(data)
118
+ } catch (err) {
119
+ setError(getApiErrorMessage(err, 'Failed to load health report'))
120
+ } finally {
121
+ setLoading(false)
122
+ }
123
+ }
124
+
125
+ const loadProposalQueue = async () => {
126
+ setQueueLoading(true)
127
+ try {
128
+ const data = await evolutionApi.proposals()
129
+ setProposalQueue(data)
130
+ } catch (err) {
131
+ message.error(getApiErrorMessage(err, 'Failed to load maintenance proposals'))
132
+ } finally {
133
+ setQueueLoading(false)
134
+ }
135
+ }
136
+
137
+ useEffect(() => {
138
+ const timer = window.setTimeout(() => {
139
+ void loadHealth()
140
+ void loadProposalQueue()
141
+ }, 0)
142
+ return () => window.clearTimeout(timer)
143
+ }, [])
144
+
145
+ const runCycle = async () => {
146
+ setCycling(true)
147
+ try {
148
+ const result = await evolutionApi.runCycle() as EvolutionCycleResult
149
+ setCycleResult(result)
150
+ message.success(`Evolution cycle completed; maintenance proposals queued: ${countOf(result.maintenance_proposals)}`)
151
+ await loadHealth()
152
+ await loadProposalQueue()
153
+ } catch (err) {
154
+ message.error(getApiErrorMessage(err, 'Evolution cycle failed'))
155
+ } finally {
156
+ setCycling(false)
157
+ }
158
+ }
159
+
160
+ const repairSkill = async (id: string) => {
161
+ setRepairing(id)
162
+ try {
163
+ const result = await evolutionApi.repair(id) as RepairResult
164
+ if (result.success) {
165
+ message.success(`Review candidate generated: ${result.root_cause || 'maintenance evidence captured'}`)
166
+ } else if (result.should_deprecate) {
167
+ message.warning('Governed review suggested: consider deprecating or replacing this Skill')
168
+ } else {
169
+ message.warning('No repair candidate generated; check the health recommendations')
170
+ }
171
+ await loadHealth()
172
+ await loadProposalQueue()
173
+ } catch (err) {
174
+ message.error(getApiErrorMessage(err, 'Failed to generate maintenance candidate'))
175
+ } finally {
176
+ setRepairing(null)
177
+ }
178
+ }
179
+
180
+ const openVersionControl = (proposal: MaintenanceProposal) => {
181
+ const params = new URLSearchParams({
182
+ skill_id: proposal.skill_id,
183
+ proposal_id: proposal.proposal_id,
184
+ })
185
+ navigate(`/versions?${params.toString()}`)
186
+ }
187
+
188
+ const acceptProposal = async (proposal: MaintenanceProposal) => {
189
+ setProposalActionId(proposal.proposal_id)
190
+ try {
191
+ const accepted = await evolutionApi.acceptProposal(proposal.proposal_id)
192
+ message.success('Proposal accepted for governed review')
193
+ await loadProposalQueue()
194
+ openVersionControl(accepted)
195
+ } catch (err) {
196
+ message.error(getApiErrorMessage(err, 'Failed to accept proposal'))
197
+ } finally {
198
+ setProposalActionId(null)
199
+ }
200
+ }
201
+
202
+ const rejectProposal = async (proposal: MaintenanceProposal) => {
203
+ setProposalActionId(proposal.proposal_id)
204
+ try {
205
+ await evolutionApi.rejectProposal(proposal.proposal_id)
206
+ message.success('Proposal rejected')
207
+ await loadProposalQueue()
208
+ } catch (err) {
209
+ message.error(getApiErrorMessage(err, 'Failed to reject proposal'))
210
+ } finally {
211
+ setProposalActionId(null)
212
+ }
213
+ }
214
+
215
+ const needsAttention = useMemo(
216
+ () => health?.skill_reports.filter(report => report.status === 'degraded' || report.status === 'critical') || [],
217
+ [health],
218
+ )
219
+
220
+ const columns: TableColumnsType<HealthReport> = [
221
+ {
222
+ title: 'Skill',
223
+ dataIndex: 'skill_name',
224
+ render: (name: string) => <Text strong>{name}</Text>,
225
+ },
226
+ {
227
+ title: 'Status',
228
+ dataIndex: 'status',
229
+ render: (status: string) => <Badge color={STATUS_COLOR[status] || STATUS_COLOR.unknown} text={STATUS_LABEL[status] || status} />,
230
+ },
231
+ {
232
+ title: 'Success Rate',
233
+ dataIndex: 'success_rate',
234
+ render: (rate: number) => (
235
+ <Progress
236
+ percent={Math.round(rate * 100)}
237
+ size="small"
238
+ strokeColor={rate > 0.7 ? '#52c41a' : rate > 0.4 ? '#faad14' : '#ff4d4f'}
239
+ style={{ width: 110 }}
240
+ />
241
+ ),
242
+ },
243
+ {
244
+ title: 'Executions',
245
+ dataIndex: 'usage_count',
246
+ },
247
+ {
248
+ title: 'Avg Latency',
249
+ dataIndex: 'avg_latency_ms',
250
+ render: (ms: number) => `${ms.toFixed(0)}ms`,
251
+ },
252
+ {
253
+ title: 'Issues',
254
+ dataIndex: 'issues',
255
+ render: (issues: string[]) => (
256
+ issues.length > 0
257
+ ? issues.map((issue, index) => <Tag key={`${issue}-${index}`} color="red">{issue}</Tag>)
258
+ : <Text type="secondary">None</Text>
259
+ ),
260
+ },
261
+ {
262
+ title: 'Recommendations',
263
+ dataIndex: 'recommendations',
264
+ render: (recommendations: string[]) => (
265
+ recommendations.length > 0
266
+ ? recommendations.map((recommendation, index) => <Tag key={`${recommendation}-${index}`} color="blue">{recommendation}</Tag>)
267
+ : <Text type="secondary">None</Text>
268
+ ),
269
+ },
270
+ {
271
+ title: 'Proposal',
272
+ dataIndex: 'maintenance_proposal',
273
+ render: (proposal?: MaintenanceProposal | null) => (
274
+ proposal ? (
275
+ <Space direction="vertical" size={2}>
276
+ <Space size={4} wrap>
277
+ <Tag color={proposalColor(proposal.recommended_action)}>{proposal.recommended_action}</Tag>
278
+ <Tag color={proposal.requires_human_review ? 'purple' : 'default'}>
279
+ {proposal.requires_human_review ? 'human review' : 'auto'}
280
+ </Tag>
281
+ </Space>
282
+ <Text type="secondary">{Math.round(proposal.confidence * 100)}% confidence</Text>
283
+ {proposal.patch_hint ? <Text>{proposal.patch_hint}</Text> : null}
284
+ </Space>
285
+ ) : <Text type="secondary">None</Text>
286
+ ),
287
+ },
288
+ ]
289
+
290
+ const attentionColumns: TableColumnsType<HealthReport> = [
291
+ ...columns,
292
+ {
293
+ title: 'Actions',
294
+ render: (_, record) => (
295
+ <Button
296
+ size="small"
297
+ type="primary"
298
+ icon={<MedicineBoxOutlined />}
299
+ loading={repairing === record.skill_id}
300
+ onClick={() => repairSkill(record.skill_id)}
301
+ >
302
+ Review candidate
303
+ </Button>
304
+ ),
305
+ },
306
+ ]
307
+
308
+ const proposalColumns: TableColumnsType<MaintenanceProposal> = [
309
+ {
310
+ title: 'Proposal',
311
+ dataIndex: 'proposal_id',
312
+ width: 180,
313
+ render: (id: string, record) => (
314
+ <Space direction="vertical" size={2}>
315
+ <Text code copyable style={{ fontSize: 11 }}>{id.slice(0, 8)}</Text>
316
+ <Text type="secondary" style={{ fontSize: 12 }}>{record.skill_id}</Text>
317
+ </Space>
318
+ ),
319
+ },
320
+ {
321
+ title: 'Action',
322
+ dataIndex: 'recommended_action',
323
+ width: 150,
324
+ render: (action: string, record) => (
325
+ <Space direction="vertical" size={2}>
326
+ <Space size={4} wrap>
327
+ <Tag color={proposalColor(action)}>{action}</Tag>
328
+ <Tag color={proposalStatusColor(record.status)}>{record.status}</Tag>
329
+ </Space>
330
+ <Text type="secondary" style={{ fontSize: 12 }}>
331
+ {Math.round(record.confidence * 100)}% confidence
332
+ </Text>
333
+ </Space>
334
+ ),
335
+ },
336
+ {
337
+ title: 'Evidence',
338
+ dataIndex: 'evidence',
339
+ render: (_: string[], record) => (
340
+ <Space direction="vertical" size={4}>
341
+ <Text>{record.root_cause || joinPreview(record.evidence)}</Text>
342
+ {record.patch_hint ? <Text type="secondary">{record.patch_hint}</Text> : null}
343
+ {record.validation_plan?.length ? (
344
+ <Tag color="blue">Validation: {joinPreview(record.validation_plan)}</Tag>
345
+ ) : null}
346
+ </Space>
347
+ ),
348
+ },
349
+ {
350
+ title: 'Governance',
351
+ dataIndex: 'next_action',
352
+ width: 220,
353
+ render: (_, record) => (
354
+ <Space direction="vertical" size={6}>
355
+ {record.status === 'accepted' || record.next_action ? (
356
+ <>
357
+ <Tag color="purple">B review required</Tag>
358
+ <Text type="secondary" style={{ fontSize: 12 }}>
359
+ {record.next_action?.endpoint || `/api/v1/lifecycle/${record.skill_id}/propose-maintenance-change`}
360
+ </Text>
361
+ </>
362
+ ) : (
363
+ <Text type="secondary">Waiting for human decision</Text>
364
+ )}
365
+ </Space>
366
+ ),
367
+ },
368
+ {
369
+ title: 'Actions',
370
+ width: 220,
371
+ render: (_, record) => (
372
+ <Space wrap>
373
+ {record.status === 'pending' ? (
374
+ <>
375
+ <Button
376
+ size="small"
377
+ type="primary"
378
+ icon={<CheckCircleOutlined />}
379
+ loading={proposalActionId === record.proposal_id}
380
+ onClick={() => acceptProposal(record)}
381
+ >
382
+ Accept
383
+ </Button>
384
+ <Popconfirm
385
+ title="Reject this proposal?"
386
+ onConfirm={() => rejectProposal(record)}
387
+ >
388
+ <Button
389
+ size="small"
390
+ danger
391
+ icon={<CloseCircleOutlined />}
392
+ loading={proposalActionId === record.proposal_id}
393
+ >
394
+ Reject
395
+ </Button>
396
+ </Popconfirm>
397
+ </>
398
+ ) : record.status === 'accepted' ? (
399
+ <Button
400
+ size="small"
401
+ icon={<BranchesOutlined />}
402
+ onClick={() => openVersionControl(record)}
403
+ >
404
+ Version
405
+ </Button>
406
+ ) : (
407
+ <Text type="secondary">No review action</Text>
408
+ )}
409
+ </Space>
410
+ ),
411
+ },
412
+ ]
413
+
414
+ if (loading && !health) {
415
+ return (
416
+ <div style={{ textAlign: 'center', padding: 80 }}>
417
+ <Spin size="large" description="Loading health report..." />
418
+ </div>
419
+ )
420
+ }
421
+
422
+ return (
423
+ <div style={{ padding: 24 }}>
424
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 24, flexWrap: 'wrap' }}>
425
+ <div>
426
+ <h2 style={{ margin: 0, fontWeight: 700 }}>Feedback & Evolution</h2>
427
+ <Text type="secondary">Review Skill health, run evolution cycles, and create controlled maintenance proposals for unhealthy Skills.</Text>
428
+ </div>
429
+ <Space>
430
+ <Button onClick={loadHealth} loading={loading}>
431
+ Refresh
432
+ </Button>
433
+ <Button onClick={loadProposalQueue} loading={queueLoading}>
434
+ Refresh proposals
435
+ </Button>
436
+ <Button
437
+ type="primary"
438
+ icon={<SyncOutlined spin={cycling} />}
439
+ onClick={runCycle}
440
+ loading={cycling}
441
+ size="large"
442
+ >
443
+ Run Evolution Cycle
444
+ </Button>
445
+ </Space>
446
+ </div>
447
+
448
+ {error && (
449
+ <Alert
450
+ type="error"
451
+ showIcon
452
+ closable
453
+ title={error}
454
+ onClose={() => setError(null)}
455
+ style={{ marginBottom: 16 }}
456
+ />
457
+ )}
458
+
459
+ {cycleResult && (
460
+ <motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} style={{ marginBottom: 16 }}>
461
+ <Alert
462
+ type={countOf(cycleResult.errors) > 0 ? 'warning' : 'success'}
463
+ title="Evolution Cycle Completed"
464
+ description={(
465
+ <Space wrap size={[8, 8]}>
466
+ <Tag color="blue">Tasks {cycleResult.tasks_completed || 0}/{cycleResult.tasks_total || 0}</Tag>
467
+ <Tag color="green">Repair candidates {countOf(cycleResult.repaired)}</Tag>
468
+ <Tag color="red">Deprecated {countOf(cycleResult.deprecated)}</Tag>
469
+ <Tag color="purple">Merged {countOf(cycleResult.merged)}</Tag>
470
+ <Tag color="gold">Split {countOf(cycleResult.split)}</Tag>
471
+ <Tag color="orange">Proposal {countOf(cycleResult.maintenance_proposals)}</Tag>
472
+ <Tag color={cycleResult.tasks_failed ? 'red' : 'default'}>Failed {cycleResult.tasks_failed || 0}</Tag>
473
+ </Space>
474
+ )}
475
+ closable
476
+ onClose={() => setCycleResult(null)}
477
+ />
478
+ </motion.div>
479
+ )}
480
+
481
+ {health ? (
482
+ <>
483
+ <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
484
+ {[
485
+ { label: 'Total', value: health.total_skills, color: '#1677ff' },
486
+ { label: 'Healthy', value: health.healthy_count, color: '#52c41a' },
487
+ { label: 'Degraded', value: health.degraded_count, color: '#faad14' },
488
+ { label: 'Critical', value: health.critical_count, color: '#ff4d4f' },
489
+ { label: 'Stale', value: health.stale_count, color: '#8c8c8c' },
490
+ ].map(({ label, value, color }, index) => (
491
+ <Col xs={12} sm={8} md={4} key={label}>
492
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.06 }}>
493
+ <Card variant="borderless" style={{ borderRadius: 8, textAlign: 'center', boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
494
+ <Statistic value={value} styles={{ content: { color, fontWeight: 700 } }} />
495
+ <div style={{ color: '#666', fontSize: 12 }}>{label}</div>
496
+ </Card>
497
+ </motion.div>
498
+ </Col>
499
+ ))}
500
+ <Col xs={12} sm={8} md={4}>
501
+ <Card variant="borderless" style={{ borderRadius: 8, textAlign: 'center', boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
502
+ <Progress
503
+ type="circle"
504
+ percent={Math.round(health.health_ratio * 100)}
505
+ size={60}
506
+ strokeColor="#52c41a"
507
+ />
508
+ <div style={{ color: '#666', fontSize: 12, marginTop: 4 }}>Health Ratio</div>
509
+ </Card>
510
+ </Col>
511
+ </Row>
512
+
513
+ <Card
514
+ title={(
515
+ <Space>
516
+ <BranchesOutlined />
517
+ <span>Maintenance Proposal Queue</span>
518
+ <Tag color="gold">{proposalQueue?.pending_count || 0} pending</Tag>
519
+ </Space>
520
+ )}
521
+ variant="borderless"
522
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }}
523
+ >
524
+ {proposalQueue && proposalQueue.proposals.length > 0 ? (
525
+ <Table
526
+ dataSource={proposalQueue.proposals}
527
+ rowKey="proposal_id"
528
+ size="small"
529
+ loading={queueLoading}
530
+ pagination={{ pageSize: 5 }}
531
+ columns={proposalColumns}
532
+ />
533
+ ) : (
534
+ <Empty description="No maintenance proposals in the current service lifecycle" />
535
+ )}
536
+ </Card>
537
+
538
+ <Card
539
+ title={<><MedicineBoxOutlined /> Skills Needing Attention ({needsAttention.length})</>}
540
+ variant="borderless"
541
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }}
542
+ >
543
+ {needsAttention.length > 0 ? (
544
+ <Table
545
+ dataSource={needsAttention}
546
+ rowKey="skill_id"
547
+ size="small"
548
+ pagination={false}
549
+ columns={attentionColumns}
550
+ />
551
+ ) : (
552
+ <Empty description="No degraded or critical Skills right now." />
553
+ )}
554
+ </Card>
555
+
556
+ <Card
557
+ title="All Skill Health Reports"
558
+ variant="borderless"
559
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}
560
+ >
561
+ {health.skill_reports.length > 0 ? (
562
+ <Table
563
+ dataSource={health.skill_reports}
564
+ rowKey="skill_id"
565
+ size="small"
566
+ pagination={{ pageSize: 10 }}
567
+ columns={columns}
568
+ />
569
+ ) : (
570
+ <Empty description="No health report data yet. Release or execute more Skills to generate health assessments." />
571
+ )}
572
+ </Card>
573
+ </>
574
+ ) : (
575
+ <Card variant="borderless" style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
576
+ <Empty description="System health report cannot be loaded right now.">
577
+ <Button type="primary" onClick={loadHealth}>Retry</Button>
578
+ </Empty>
579
+ </Card>
580
+ )}
581
+ </div>
582
+ )
583
+ }
skillwiki-frontend/src/pages/HarnessVerification.tsx ADDED
@@ -0,0 +1,577 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+ import {
3
+ Alert,
4
+ Button,
5
+ Card,
6
+ Col,
7
+ Descriptions,
8
+ Empty,
9
+ InputNumber,
10
+ Progress,
11
+ Row,
12
+ Segmented,
13
+ Select,
14
+ Space,
15
+ Spin,
16
+ Switch,
17
+ Table,
18
+ Tag,
19
+ Timeline,
20
+ Typography,
21
+ message,
22
+ } from 'antd'
23
+ import type { TableColumnsType } from 'antd'
24
+ import {
25
+ CheckCircleOutlined,
26
+ FileProtectOutlined,
27
+ PlayCircleOutlined,
28
+ ReloadOutlined,
29
+ SafetyCertificateOutlined,
30
+ ToolOutlined,
31
+ } from '@ant-design/icons'
32
+ import { useLocation } from 'react-router-dom'
33
+ import { harnessApi, skillsApi } from '@/api/client'
34
+ import { getApiErrorMessage } from '@/api/errors'
35
+ import type {
36
+ HarnessKind,
37
+ HarnessRunResult,
38
+ HarnessVerifyLoopResponse,
39
+ SkillSummary,
40
+ } from '@/api/types'
41
+
42
+ const { Paragraph, Text } = Typography
43
+
44
+ const STATE_COLOR: Record<string, string> = {
45
+ S0: 'purple',
46
+ S1: 'orange',
47
+ S2: 'blue',
48
+ S3: 'cyan',
49
+ S4: 'green',
50
+ S5: 'gold',
51
+ S6: 'red',
52
+ S7: 'default',
53
+ }
54
+
55
+ const STATUS_COLOR: Record<string, string> = {
56
+ verified: 'green',
57
+ passed: 'green',
58
+ failed: 'red',
59
+ rejected: 'red',
60
+ needs_human_review: 'gold',
61
+ timeout: 'volcano',
62
+ harness_error: 'volcano',
63
+ harness_unavailable: 'default',
64
+ }
65
+
66
+ const HARNESS_LABEL: Record<HarnessKind, string> = {
67
+ local_skillos: 'Local SkillWiki',
68
+ codex_cli: 'Codex CLI',
69
+ claude_code: 'Claude Code',
70
+ }
71
+
72
+ function scoreNumber(score: Record<string, unknown> | undefined, key: string) {
73
+ const value = score?.[key]
74
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
75
+ }
76
+
77
+ function percent(score: number) {
78
+ return Math.max(0, Math.min(100, Math.round(score * 100)))
79
+ }
80
+
81
+ function formatPercent(value: number) {
82
+ return `${(value * 100).toFixed(1)}%`
83
+ }
84
+
85
+ function formatMs(value: unknown) {
86
+ return typeof value === 'number' && Number.isFinite(value) ? `${value.toFixed(value >= 10 ? 0 : 2)} ms` : '-'
87
+ }
88
+
89
+ function renderJson(value: unknown) {
90
+ return (
91
+ <pre
92
+ style={{
93
+ margin: 0,
94
+ maxHeight: 180,
95
+ overflow: 'auto',
96
+ whiteSpace: 'pre-wrap',
97
+ wordBreak: 'break-word',
98
+ background: '#f6f8fa',
99
+ border: '1px solid #edf0f3',
100
+ borderRadius: 6,
101
+ padding: 10,
102
+ fontSize: 12,
103
+ }}
104
+ >
105
+ {JSON.stringify(value, null, 2)}
106
+ </pre>
107
+ )
108
+ }
109
+
110
+ function statusTag(status?: string) {
111
+ if (!status) return <Text type="secondary">-</Text>
112
+ return <Tag color={STATUS_COLOR[status] || 'default'}>{status}</Tag>
113
+ }
114
+
115
+ function stateTag(state?: string) {
116
+ if (!state) return <Text type="secondary">-</Text>
117
+ return <Tag color={STATE_COLOR[state] || 'default'}>{state}</Tag>
118
+ }
119
+
120
+ function attemptTitle(result: HarnessRunResult) {
121
+ const label = HARNESS_LABEL[result.harness] || result.harness
122
+ return `${label} attempt ${result.attempt}`
123
+ }
124
+
125
+ function verifierDetails(result: HarnessRunResult) {
126
+ const details = result.verifier_summary?.details
127
+ if (!details || typeof details !== 'object' || !('results' in details)) return []
128
+ const results = (details as { results?: unknown }).results
129
+ return Array.isArray(results) ? results : []
130
+ }
131
+
132
+ function AttemptTimeline({ attempts }: { attempts: HarnessRunResult[] }) {
133
+ if (!attempts.length) {
134
+ return <Empty description="No harness attempts yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
135
+ }
136
+
137
+ return (
138
+ <Timeline
139
+ items={attempts.map(result => ({
140
+ color: result.verifier_passed ? 'green' : 'red',
141
+ dot: result.verifier_passed ? <CheckCircleOutlined /> : <ToolOutlined />,
142
+ children: (
143
+ <Space direction="vertical" size={8} style={{ width: '100%' }}>
144
+ <Space wrap>
145
+ <Text strong>{attemptTitle(result)}</Text>
146
+ {statusTag(result.status)}
147
+ <Tag color={result.verifier_passed ? 'green' : 'red'}>
148
+ verifier {result.verifier_passed ? 'passed' : 'failed'}
149
+ </Tag>
150
+ <Text type="secondary">{formatMs(result.latency_ms)}</Text>
151
+ </Space>
152
+ {result.failure_reason && (
153
+ <Alert type="warning" showIcon message={result.failure_reason} style={{ borderRadius: 6 }} />
154
+ )}
155
+ <Row gutter={[12, 12]}>
156
+ <Col xs={24} md={12}>
157
+ <Text type="secondary">Input</Text>
158
+ <div style={{ marginTop: 6 }}>{renderJson(result.input_data)}</div>
159
+ </Col>
160
+ <Col xs={24} md={12}>
161
+ <Text type="secondary">Output</Text>
162
+ <div style={{ marginTop: 6 }}>{renderJson(result.output)}</div>
163
+ </Col>
164
+ </Row>
165
+ {verifierDetails(result).length > 0 && renderJson(verifierDetails(result))}
166
+ </Space>
167
+ ),
168
+ }))}
169
+ />
170
+ )
171
+ }
172
+
173
+ function RepairList({ repairs }: { repairs: Record<string, unknown>[] }) {
174
+ if (!repairs.length) {
175
+ return <Empty description="No repair was needed yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
176
+ }
177
+ return (
178
+ <Space direction="vertical" size={12} style={{ width: '100%' }}>
179
+ {repairs.map((repair, index) => (
180
+ <div
181
+ key={`${String(repair.source || 'repair')}-${index}`}
182
+ style={{
183
+ border: '1px solid #edf0f3',
184
+ borderRadius: 8,
185
+ padding: 12,
186
+ background: '#fbfcfd',
187
+ }}
188
+ >
189
+ <Space wrap style={{ marginBottom: 8 }}>
190
+ <Tag color={repair.success ? 'green' : 'red'}>{repair.success ? 'applied' : 'failed'}</Tag>
191
+ <Tag>{String(repair.source || 'unknown')}</Tag>
192
+ <Text type="secondary">attempt {String(repair.attempt ?? index + 1)}</Text>
193
+ </Space>
194
+ {renderJson(repair)}
195
+ </div>
196
+ ))}
197
+ </Space>
198
+ )
199
+ }
200
+
201
+ export default function HarnessVerification() {
202
+ const location = useLocation()
203
+ const [skills, setSkills] = useState<SkillSummary[]>([])
204
+ const [selectedId, setSelectedId] = useState<string | null>(null)
205
+ const [harness, setHarness] = useState<HarnessKind>('local_skillos')
206
+ const [maxAttempts, setMaxAttempts] = useState(3)
207
+ const [timeoutS, setTimeoutS] = useState(120)
208
+ const [allowRepair, setAllowRepair] = useState(true)
209
+ const [promoteOnPass, setPromoteOnPass] = useState(true)
210
+ const [loading, setLoading] = useState(false)
211
+ const [running, setRunning] = useState(false)
212
+ const [error, setError] = useState<string | null>(null)
213
+ const [result, setResult] = useState<HarnessVerifyLoopResponse | null>(null)
214
+ const [recentLoops, setRecentLoops] = useState<HarnessVerifyLoopResponse[]>([])
215
+
216
+ const querySkillId = useMemo(() => new URLSearchParams(location.search).get('skill_id'), [location.search])
217
+
218
+ const draftSkills = useMemo(() => skills.filter(skill => skill.state === 'S2'), [skills])
219
+ const selectedSkill = useMemo(
220
+ () => skills.find(skill => skill.skill_id === selectedId) || null,
221
+ [selectedId, skills],
222
+ )
223
+
224
+ const loadData = useCallback(async (preferredId?: string | null) => {
225
+ setLoading(true)
226
+ setError(null)
227
+ try {
228
+ const [skillList, loopList] = await Promise.all([
229
+ skillsApi.list({ limit: 200 }),
230
+ harnessApi.list(20).catch(() => ({ loops: [], total: 0 })),
231
+ ])
232
+ setSkills(skillList)
233
+ setRecentLoops(loopList.loops || [])
234
+ const preferred = preferredId || querySkillId || 'demo_draft_extract_email'
235
+ const preferredExists = skillList.some(skill => skill.skill_id === preferred)
236
+ const demoDraft = skillList.find(skill => skill.skill_id === 'demo_draft_extract_email' && skill.state === 'S2')
237
+ const firstDraft = skillList.find(skill => skill.state === 'S2')
238
+ setSelectedId(current => {
239
+ if (preferredExists) return preferred
240
+ if (current && skillList.some(skill => skill.skill_id === current)) return current
241
+ return demoDraft?.skill_id || firstDraft?.skill_id || null
242
+ })
243
+ } catch (err) {
244
+ setError(getApiErrorMessage(err, 'Load harness data failed'))
245
+ } finally {
246
+ setLoading(false)
247
+ }
248
+ }, [querySkillId])
249
+
250
+ useEffect(() => {
251
+ const timer = window.setTimeout(() => {
252
+ void loadData()
253
+ }, 0)
254
+ return () => window.clearTimeout(timer)
255
+ }, [loadData])
256
+
257
+ const runVerification = async () => {
258
+ if (!selectedId) return
259
+ setRunning(true)
260
+ setError(null)
261
+ try {
262
+ const response = await harnessApi.runVerifyLoop(selectedId, {
263
+ harness,
264
+ max_attempts: maxAttempts,
265
+ promote_on_pass: promoteOnPass,
266
+ allow_repair: allowRepair,
267
+ timeout_s: timeoutS,
268
+ })
269
+ setResult(response)
270
+ setRecentLoops(prev => [response, ...prev.filter(loop => loop.loop_id !== response.loop_id)].slice(0, 20))
271
+ message.success(`Harness loop ${response.status}`)
272
+ await loadData(selectedId)
273
+ } catch (err) {
274
+ setError(getApiErrorMessage(err, 'Run verification loop failed'))
275
+ } finally {
276
+ setRunning(false)
277
+ }
278
+ }
279
+
280
+ const latest = result || recentLoops[0] || null
281
+ const overall = scoreNumber(latest?.score, 'overall')
282
+ const passRate = scoreNumber(latest?.score, 'verifier_pass_rate')
283
+
284
+ const loopColumns: TableColumnsType<HarnessVerifyLoopResponse> = [
285
+ {
286
+ title: 'Loop',
287
+ dataIndex: 'loop_id',
288
+ width: 190,
289
+ render: value => <Text code>{String(value)}</Text>,
290
+ },
291
+ {
292
+ title: 'Status',
293
+ dataIndex: 'status',
294
+ width: 150,
295
+ render: statusTag,
296
+ },
297
+ {
298
+ title: 'Score',
299
+ dataIndex: 'score',
300
+ width: 120,
301
+ render: score => formatPercent(scoreNumber(score as Record<string, unknown>, 'overall')),
302
+ },
303
+ {
304
+ title: 'Attempts',
305
+ dataIndex: 'attempt_count',
306
+ width: 110,
307
+ render: value => <Text>{String(value ?? '-')}</Text>,
308
+ },
309
+ {
310
+ title: 'Final State',
311
+ dataIndex: 'final_state',
312
+ width: 120,
313
+ render: stateTag,
314
+ },
315
+ {
316
+ title: 'Evidence',
317
+ dataIndex: 'evidence_path',
318
+ render: value => (
319
+ <Paragraph
320
+ copyable={typeof value === 'string' ? { text: value } : false}
321
+ ellipsis={{ rows: 1, expandable: true, symbol: 'more' }}
322
+ style={{ marginBottom: 0 }}
323
+ >
324
+ {String(value || '-')}
325
+ </Paragraph>
326
+ ),
327
+ },
328
+ ]
329
+
330
+ return (
331
+ <div style={{ padding: 24 }}>
332
+ <div style={{ marginBottom: 24 }}>
333
+ <Space wrap align="center" style={{ marginBottom: 4 }}>
334
+ <SafetyCertificateOutlined style={{ color: '#1677ff', fontSize: 22 }} />
335
+ <h2 style={{ margin: 0, fontWeight: 700 }}>Harness Verification</h2>
336
+ </Space>
337
+ <Paragraph type="secondary" style={{ maxWidth: 920, marginBottom: 0 }}>
338
+ Execute S2 Draft Skills in a controlled harness, verify deterministic postconditions, repair failures, and promote only passing Skills to S3.
339
+ </Paragraph>
340
+ </div>
341
+
342
+ {error && (
343
+ <Alert
344
+ type="error"
345
+ showIcon
346
+ closable
347
+ title="Harness request failed"
348
+ description={error}
349
+ style={{ marginBottom: 16 }}
350
+ onClose={() => setError(null)}
351
+ />
352
+ )}
353
+
354
+ <Spin spinning={loading && !running}>
355
+ <Row gutter={[16, 16]}>
356
+ <Col xs={24} lg={9}>
357
+ <Card title="Verification Control" variant="borderless" style={{ borderRadius: 8 }}>
358
+ <Space direction="vertical" size={16} style={{ width: '100%' }}>
359
+ <div>
360
+ <Text strong>Draft Skill</Text>
361
+ <Select
362
+ showSearch
363
+ value={selectedId || undefined}
364
+ placeholder="Select an S2 Draft Skill"
365
+ optionFilterProp="label"
366
+ style={{ width: '100%', marginTop: 8 }}
367
+ onChange={setSelectedId}
368
+ options={draftSkills.map(skill => ({
369
+ value: skill.skill_id,
370
+ label: `${skill.name} · ${skill.version}`,
371
+ }))}
372
+ notFoundContent="No S2 Draft Skills available"
373
+ />
374
+ </div>
375
+
376
+ {selectedSkill ? (
377
+ <Alert
378
+ type={selectedSkill.state === 'S2' ? 'info' : 'success'}
379
+ showIcon
380
+ message={(
381
+ <Space wrap>
382
+ <Text strong>{selectedSkill.name}</Text>
383
+ {stateTag(selectedSkill.state)}
384
+ <Tag>{selectedSkill.version}</Tag>
385
+ </Space>
386
+ )}
387
+ description={(
388
+ <Space direction="vertical" size={6} style={{ width: '100%' }}>
389
+ <Text>{selectedSkill.description}</Text>
390
+ <Space wrap>
391
+ {(selectedSkill.evaluation.verifier_specs || []).slice(0, 3).map((spec, index) => (
392
+ <Tag key={`${selectedSkill.skill_id}-spec-${index}`} color="blue">
393
+ {String(spec.type || 'verifier')}
394
+ </Tag>
395
+ ))}
396
+ </Space>
397
+ {selectedSkill.evaluation.validation_summary && (
398
+ <Text type="secondary">{selectedSkill.evaluation.validation_summary}</Text>
399
+ )}
400
+ </Space>
401
+ )}
402
+ />
403
+ ) : (
404
+ <Empty description="Select a Draft Skill to verify" image={Empty.PRESENTED_IMAGE_SIMPLE} />
405
+ )}
406
+
407
+ <div>
408
+ <Text strong>Harness</Text>
409
+ <Segmented
410
+ block
411
+ value={harness}
412
+ onChange={value => setHarness(value as HarnessKind)}
413
+ style={{ marginTop: 8 }}
414
+ options={[
415
+ { label: 'Local', value: 'local_skillos' },
416
+ { label: 'Codex CLI', value: 'codex_cli' },
417
+ { label: 'Claude Code', value: 'claude_code', disabled: true },
418
+ ]}
419
+ />
420
+ </div>
421
+
422
+ <Row gutter={12}>
423
+ <Col span={12}>
424
+ <Text strong>Max Attempts</Text>
425
+ <InputNumber
426
+ min={1}
427
+ max={5}
428
+ value={maxAttempts}
429
+ onChange={value => setMaxAttempts(value || 3)}
430
+ style={{ width: '100%', marginTop: 8 }}
431
+ />
432
+ </Col>
433
+ <Col span={12}>
434
+ <Text strong>Timeout</Text>
435
+ <InputNumber
436
+ min={1}
437
+ max={600}
438
+ value={timeoutS}
439
+ addonAfter="s"
440
+ onChange={value => setTimeoutS(value || 120)}
441
+ style={{ width: '100%', marginTop: 8 }}
442
+ />
443
+ </Col>
444
+ </Row>
445
+
446
+ <Space direction="vertical" size={10}>
447
+ <Space>
448
+ <Switch checked={allowRepair} onChange={setAllowRepair} />
449
+ <Text>Allow repair and retry</Text>
450
+ </Space>
451
+ <Space>
452
+ <Switch checked={promoteOnPass} onChange={setPromoteOnPass} />
453
+ <Text>Promote to S3 on pass</Text>
454
+ </Space>
455
+ </Space>
456
+
457
+ <Space wrap>
458
+ <Button icon={<ReloadOutlined />} onClick={() => void loadData(selectedId)} disabled={running}>
459
+ Refresh
460
+ </Button>
461
+ <Button
462
+ type="primary"
463
+ icon={<PlayCircleOutlined />}
464
+ loading={running}
465
+ disabled={!selectedSkill || selectedSkill.state !== 'S2'}
466
+ onClick={runVerification}
467
+ >
468
+ Run Verification Loop
469
+ </Button>
470
+ </Space>
471
+ </Space>
472
+ </Card>
473
+ </Col>
474
+
475
+ <Col xs={24} lg={15}>
476
+ <Row gutter={[16, 16]}>
477
+ <Col xs={24} md={8}>
478
+ <Card variant="borderless" style={{ borderRadius: 8 }}>
479
+ <Text type="secondary">Latest Status</Text>
480
+ <div style={{ marginTop: 12 }}>
481
+ {latest ? statusTag(latest.status) : <Tag>no run</Tag>}
482
+ </div>
483
+ </Card>
484
+ </Col>
485
+ <Col xs={24} md={8}>
486
+ <Card variant="borderless" style={{ borderRadius: 8 }}>
487
+ <Text type="secondary">Overall Score</Text>
488
+ <Progress percent={percent(overall)} size="small" style={{ marginTop: 14 }} />
489
+ <Text strong>{formatPercent(overall)}</Text>
490
+ </Card>
491
+ </Col>
492
+ <Col xs={24} md={8}>
493
+ <Card variant="borderless" style={{ borderRadius: 8 }}>
494
+ <Text type="secondary">Verifier Pass Rate</Text>
495
+ <Progress percent={percent(passRate)} size="small" status={passRate >= 1 ? 'success' : 'active'} style={{ marginTop: 14 }} />
496
+ <Text strong>{formatPercent(passRate)}</Text>
497
+ </Card>
498
+ </Col>
499
+ </Row>
500
+
501
+ <Card
502
+ title="Latest Evidence"
503
+ extra={latest ? <Text type="secondary">{latest.loop_id}</Text> : null}
504
+ variant="borderless"
505
+ style={{ borderRadius: 8, marginTop: 16 }}
506
+ >
507
+ {latest ? (
508
+ <Space direction="vertical" size={16} style={{ width: '100%' }}>
509
+ <Descriptions size="small" column={{ xs: 1, md: 3 }} bordered>
510
+ <Descriptions.Item label="Skill">{latest.skill_id}</Descriptions.Item>
511
+ <Descriptions.Item label="Version">
512
+ {latest.initial_version}
513
+ {' -> '}
514
+ {latest.final_version}
515
+ </Descriptions.Item>
516
+ <Descriptions.Item label="Final State">{stateTag(latest.final_state)}</Descriptions.Item>
517
+ <Descriptions.Item label="Attempts">{latest.attempt_count}</Descriptions.Item>
518
+ <Descriptions.Item label="Promotion">{latest.promotion_allowed ? <Tag color="green">allowed</Tag> : <Tag>blocked</Tag>}</Descriptions.Item>
519
+ <Descriptions.Item label="Latency">{formatMs(latest.score.latency_ms)}</Descriptions.Item>
520
+ </Descriptions>
521
+ <Alert
522
+ type={latest.status === 'verified' ? 'success' : 'warning'}
523
+ showIcon
524
+ message="Evidence Path"
525
+ description={(
526
+ <Text copyable={{ text: latest.evidence_path }} style={{ wordBreak: 'break-all' }}>
527
+ {latest.evidence_path}
528
+ </Text>
529
+ )}
530
+ />
531
+ <AttemptTimeline attempts={latest.attempts || []} />
532
+ </Space>
533
+ ) : (
534
+ <Empty description="Run a verification loop to create evidence" image={Empty.PRESENTED_IMAGE_SIMPLE} />
535
+ )}
536
+ </Card>
537
+ </Col>
538
+ </Row>
539
+
540
+ <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
541
+ <Col xs={24} lg={9}>
542
+ <Card title="Repair Evidence" variant="borderless" style={{ borderRadius: 8 }}>
543
+ <RepairList repairs={latest?.repairs || []} />
544
+ </Card>
545
+ </Col>
546
+ <Col xs={24} lg={15}>
547
+ <Card
548
+ title="Recent Harness Loops"
549
+ extra={<Text type="secondary">{recentLoops.length} saved</Text>}
550
+ variant="borderless"
551
+ style={{ borderRadius: 8 }}
552
+ >
553
+ <Table
554
+ dataSource={recentLoops}
555
+ columns={loopColumns}
556
+ rowKey="loop_id"
557
+ size="small"
558
+ pagination={{ pageSize: 6 }}
559
+ scroll={{ x: 900 }}
560
+ locale={{ emptyText: 'No saved harness evidence yet' }}
561
+ />
562
+ </Card>
563
+ </Col>
564
+ </Row>
565
+
566
+ <Alert
567
+ type="info"
568
+ showIcon
569
+ icon={<FileProtectOutlined />}
570
+ style={{ marginTop: 16, borderRadius: 8 }}
571
+ message="Demo path"
572
+ description="Use the seeded demo_draft_extract_email Draft Skill with Local SkillWiki. The first attempt fails because output.email is missing, the repair loop patches the implementation, and the repaired version is promoted to S3 after verifier pass."
573
+ />
574
+ </Spin>
575
+ </div>
576
+ )
577
+ }
skillwiki-frontend/src/pages/KnowledgeImport.tsx ADDED
@@ -0,0 +1,1293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import {
3
+ Alert, Badge, Button, Card, Col, Collapse, Descriptions, Divider, Input, Progress,
4
+ Row, Select, Space, Steps, Tabs, Tag, Typography, Upload,
5
+ } from 'antd'
6
+ import type { UploadProps } from 'antd'
7
+ import {
8
+ ApiOutlined, CheckCircleOutlined, CloudUploadOutlined, CodeOutlined,
9
+ CompressOutlined, DatabaseOutlined, FileSearchOutlined, FileTextOutlined,
10
+ FilterOutlined, LoadingOutlined, PlayCircleOutlined, SafetyCertificateOutlined,
11
+ } from '@ant-design/icons'
12
+ import { AnimatePresence, motion } from 'framer-motion'
13
+ import { Link, useNavigate } from 'react-router-dom'
14
+ import { ingestApi, lifecycleApi } from '@/api/client'
15
+ import { getApiErrorMessage } from '@/api/errors'
16
+ import type {
17
+ CandidateAuditResult,
18
+ CandidateSkillReviewRequest,
19
+ CreatedSkill,
20
+ ExperienceUnit,
21
+ IngestResponse,
22
+ SkillEvaluation,
23
+ SkillProvenance,
24
+ SkillType,
25
+ } from '@/api/types'
26
+
27
+ const { TextArea } = Input
28
+ const { Dragger } = Upload
29
+ const { Paragraph, Text } = Typography
30
+ const MAX_IMPORT_FILE_BYTES = 2 * 1024 * 1024
31
+
32
+ const SOURCE_TYPES = [
33
+ {
34
+ key: 'trajectory',
35
+ label: 'Trajectory',
36
+ icon: <PlayCircleOutlined />,
37
+ color: '#1677ff',
38
+ placeholder: `Paste a trajectory, for example:
39
+ 1. Open https://example.com/login
40
+ 2. Click the username field
41
+ 3. Type admin
42
+ 4. Click the password field
43
+ 5. Type the password
44
+ 6. Click the login button
45
+ 7. Wait for dashboard navigation and confirm success`,
46
+ },
47
+ {
48
+ key: 'document',
49
+ label: 'Document',
50
+ icon: <FileTextOutlined />,
51
+ color: '#52c41a',
52
+ placeholder: `Paste a technical note or operating procedure.
53
+
54
+ Include inputs, expected outputs, constraints, and reusable steps where possible.`,
55
+ },
56
+ {
57
+ key: 'api_doc',
58
+ label: 'API Doc',
59
+ icon: <ApiOutlined />,
60
+ color: '#722ed1',
61
+ placeholder: `Paste an API document or OpenAPI fragment.
62
+
63
+ POST /api/login
64
+ Request: { "username": "string", "password": "string" }
65
+ Response: { "token": "string", "user_id": "string" }`,
66
+ },
67
+ {
68
+ key: 'script',
69
+ label: 'Script',
70
+ icon: <CodeOutlined />,
71
+ color: '#fa8c16',
72
+ placeholder: `Paste Python, JavaScript, or shell code.
73
+
74
+ async def login(page, username: str, password: str) -> bool:
75
+ await page.goto("https://example.com/login")
76
+ await page.fill("#username", username)
77
+ await page.fill("#password", password)
78
+ await page.click("#submit")
79
+ return True`,
80
+ },
81
+ {
82
+ key: 'past_skills',
83
+ label: 'Past Skills',
84
+ icon: <DatabaseOutlined />,
85
+ color: '#13c2c2',
86
+ placeholder: `Paste legacy Skill JSON, YAML, Markdown, or free text.
87
+
88
+ [
89
+ {
90
+ "name": "legacy_login_flow",
91
+ "description": "Log in by filling credentials and waiting for the dashboard.",
92
+ "steps": ["open login page", "fill username", "fill password", "click submit"],
93
+ "dependencies": ["click_element", "type_text"],
94
+ "skill_type": "functional"
95
+ }
96
+ ]`,
97
+ },
98
+ ]
99
+
100
+ const TYPE_COLOR: Record<string, string> = {
101
+ atomic: 'blue',
102
+ functional: 'purple',
103
+ strategic: 'gold',
104
+ }
105
+
106
+ const PIPELINE_STAGES = [
107
+ { title: 'Extractor', icon: <FilterOutlined />, desc: 'Extract source-local actions' },
108
+ { title: 'Normalizer', icon: <CompressOutlined />, desc: 'Normalize experience structure' },
109
+ { title: 'Summarizer', icon: <FileSearchOutlined />, desc: 'Draft candidate Skill evidence' },
110
+ { title: 'Indexer', icon: <DatabaseOutlined />, desc: 'Prepare search keywords' },
111
+ ]
112
+
113
+ const PREVIEW_BLOCK_STYLE = {
114
+ background: '#f6f8fa',
115
+ border: '1px solid #edf0f3',
116
+ borderRadius: 8,
117
+ padding: 12,
118
+ whiteSpace: 'pre-wrap' as const,
119
+ wordBreak: 'break-word' as const,
120
+ maxHeight: 220,
121
+ overflow: 'auto',
122
+ fontSize: 12,
123
+ }
124
+
125
+ type SourceType = typeof SOURCE_TYPES[number]['key']
126
+
127
+ interface ApiDocContract {
128
+ kind: 'api_doc'
129
+ title: string
130
+ method: string
131
+ endpoint: string
132
+ requiredParams: string[]
133
+ optionalParams: string[]
134
+ requestSchema: Record<string, unknown>
135
+ responseSchema: Record<string, unknown>
136
+ exampleCall: string
137
+ }
138
+
139
+ interface ScriptContract {
140
+ kind: 'script'
141
+ title: string
142
+ language: string
143
+ entrypoint: string
144
+ args: string[]
145
+ returns: string
146
+ dependencies: string[]
147
+ sideEffects: string[]
148
+ runnableHint: string
149
+ codeSnippet: string
150
+ }
151
+
152
+ type ResourceContract = ApiDocContract | ScriptContract | null
153
+
154
+ const RESOURCE_EXAMPLES: { sourceType: SourceType; title: string; content: string }[] = [
155
+ {
156
+ sourceType: 'api_doc',
157
+ title: 'Login API',
158
+ content: `POST /api/login
159
+ Description: Exchange user credentials for a bearer token.
160
+ Request: { "username": "string", "password": "string" }
161
+ Response: { "token": "string", "user_id": "string", "expires_at": "string" }
162
+ Errors: 401 when credentials are invalid.`,
163
+ },
164
+ {
165
+ sourceType: 'api_doc',
166
+ title: 'Weather API',
167
+ content: `GET /v1/weather/current
168
+ Description: Fetch current weather by city.
169
+ Params: { "city": "string", "units": "metric|imperial" }
170
+ Response: { "city": "string", "temperature": "number", "condition": "string", "observed_at": "string" }`,
171
+ },
172
+ {
173
+ sourceType: 'script',
174
+ title: 'Playwright Login',
175
+ content: `async def login(page, username: str, password: str) -> bool:
176
+ await page.goto("https://example.com/login")
177
+ await page.fill("#username", username)
178
+ await page.fill("#password", password)
179
+ await page.click("#submit")
180
+ await page.wait_for_url("**/dashboard")
181
+ return True`,
182
+ },
183
+ {
184
+ sourceType: 'script',
185
+ title: 'CSV Validator',
186
+ content: `import csv
187
+
188
+ def validate_orders_csv(path: str) -> dict:
189
+ rows = list(csv.DictReader(open(path, encoding="utf-8")))
190
+ missing = [i for i, row in enumerate(rows, start=1) if not row.get("order_id")]
191
+ return {"row_count": len(rows), "missing_order_id_rows": missing, "ok": not missing}`,
192
+ },
193
+ {
194
+ sourceType: 'past_skills',
195
+ title: 'Legacy Login Skill',
196
+ content: `[
197
+ {
198
+ "name": "legacy_login_flow",
199
+ "description": "Log in by filling credentials and waiting for the dashboard.",
200
+ "steps": ["open login page", "fill username", "fill password", "click submit"],
201
+ "dependencies": ["click_element", "type_text"],
202
+ "skill_type": "functional",
203
+ "input_schema": {
204
+ "type": "object",
205
+ "properties": {
206
+ "username": { "type": "string" },
207
+ "password": { "type": "string" }
208
+ },
209
+ "required": ["username", "password"]
210
+ }
211
+ }
212
+ ]`,
213
+ },
214
+ ]
215
+
216
+ interface CandidateDraft {
217
+ name: string
218
+ description: string
219
+ skillType: SkillType
220
+ tagsText: string
221
+ inputSchemaText: string
222
+ outputSchemaText: string
223
+ preconditionsText: string
224
+ postconditionsText: string
225
+ promptTemplate: string
226
+ provenanceText: string
227
+ evaluationText: string
228
+ dependencyIds: string[]
229
+ componentIds: string[]
230
+ subSkillIds: string[]
231
+ parentSkillIds: string[]
232
+ toolCalls: string[]
233
+ }
234
+
235
+ function toJson(value: unknown): string {
236
+ return JSON.stringify(value, null, 2)
237
+ }
238
+
239
+ function linesFromText(value: string): string[] {
240
+ return value
241
+ .split(/\r?\n/)
242
+ .map(item => item.trim())
243
+ .filter(Boolean)
244
+ }
245
+
246
+ function tagsFromText(value: string): string[] {
247
+ return value
248
+ .split(',')
249
+ .map(item => item.trim().toLowerCase())
250
+ .filter(Boolean)
251
+ }
252
+
253
+ function toSnakeCase(value: string): string {
254
+ const normalized = value
255
+ .trim()
256
+ .toLowerCase()
257
+ .replace(/[^a-z0-9]+/g, '_')
258
+ .replace(/^_+|_+$/g, '')
259
+ const safe = normalized || 'imported_skill'
260
+ return /^[a-z]/.test(safe) ? safe.slice(0, 96) : `skill_${safe}`.slice(0, 96)
261
+ }
262
+
263
+ function isSkillType(value?: string): value is SkillType {
264
+ return value === 'atomic' || value === 'functional' || value === 'strategic'
265
+ }
266
+
267
+ function parseJsonObject<T>(value: string, label: string): T {
268
+ const parsed = JSON.parse(value) as T
269
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
270
+ throw new Error(`${label} must be a JSON object`)
271
+ }
272
+ return parsed
273
+ }
274
+
275
+ function parseLooseJsonObject(value: string): Record<string, unknown> {
276
+ try {
277
+ const parsed = JSON.parse(value)
278
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
279
+ return parsed as Record<string, unknown>
280
+ }
281
+ } catch {
282
+ return {}
283
+ }
284
+ return {}
285
+ }
286
+
287
+ function asRecord(value: unknown): Record<string, unknown> | null {
288
+ return value && typeof value === 'object' && !Array.isArray(value)
289
+ ? value as Record<string, unknown>
290
+ : null
291
+ }
292
+
293
+ function asStringArray(value: unknown): string[] {
294
+ return Array.isArray(value)
295
+ ? value.map(item => String(item).trim()).filter(Boolean)
296
+ : []
297
+ }
298
+
299
+ function metadataRecord(unit: ExperienceUnit | null | undefined): Record<string, unknown> {
300
+ return asRecord(unit?.metadata) ?? {}
301
+ }
302
+
303
+ function nestedRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {
304
+ return asRecord(parent[key]) ?? {}
305
+ }
306
+
307
+ function extractJsonAfterLabel(content: string, label: string): Record<string, unknown> {
308
+ const pattern = new RegExp(`(?:${label})\\s*:?\\s*(\\{[^\\n]+\\})`, 'i')
309
+ const match = content.match(pattern)
310
+ return match ? parseLooseJsonObject(match[1]) : {}
311
+ }
312
+
313
+ function inferJsonValueType(value: unknown): string {
314
+ if (Array.isArray(value)) return 'array'
315
+ if (value === null) return 'string'
316
+ return typeof value === 'number' || typeof value === 'boolean' || typeof value === 'object'
317
+ ? typeof value
318
+ : 'string'
319
+ }
320
+
321
+ function schemaFromObject(value: Record<string, unknown>, fallbackDescription: string): Record<string, unknown> {
322
+ const properties = Object.fromEntries(
323
+ Object.entries(value).map(([key, fieldValue]) => [
324
+ key,
325
+ {
326
+ type: inferJsonValueType(fieldValue),
327
+ description: `${key} from parsed resource contract.`,
328
+ },
329
+ ]),
330
+ )
331
+ return {
332
+ type: 'object',
333
+ properties: Object.keys(properties).length ? properties : {
334
+ payload: {
335
+ type: 'object',
336
+ description: fallbackDescription,
337
+ },
338
+ },
339
+ required: Object.keys(properties),
340
+ }
341
+ }
342
+
343
+ function buildResourceContract(sourceType: SourceType, content: string): ResourceContract {
344
+ if (!content.trim()) return null
345
+
346
+ if (sourceType === 'api_doc') {
347
+ const firstLine = linesFromText(content)[0] ?? ''
348
+ const routeMatch = content.match(/\b(GET|POST|PUT|PATCH|DELETE)\s+([/\w{}:.-]+)/i)
349
+ const requestSchema = extractJsonAfterLabel(content, 'Request|Params|Parameters')
350
+ const responseSchema = extractJsonAfterLabel(content, 'Response|Returns')
351
+ const requiredParams = Object.keys(requestSchema)
352
+ const title = firstLine.replace(/\b(GET|POST|PUT|PATCH|DELETE)\b/i, '').trim() || 'API skill'
353
+ return {
354
+ kind: 'api_doc',
355
+ title,
356
+ method: (routeMatch?.[1] ?? 'POST').toUpperCase(),
357
+ endpoint: routeMatch?.[2] ?? '/api/unknown',
358
+ requiredParams,
359
+ optionalParams: [],
360
+ requestSchema,
361
+ responseSchema,
362
+ exampleCall: `${(routeMatch?.[1] ?? 'POST').toUpperCase()} ${routeMatch?.[2] ?? '/api/unknown'}`,
363
+ }
364
+ }
365
+
366
+ if (sourceType === 'script') {
367
+ const language = /\basync\s+function\b|\bconst\s+\w+\s*=\s*async\b|\bfunction\s+\w+\s*\(/.test(content)
368
+ ? 'javascript'
369
+ : /\bdef\s+\w+\s*\(/.test(content)
370
+ ? 'python'
371
+ : content.trimStart().startsWith('#!') ? 'shell' : 'script'
372
+ const functionMatch = content.match(/\b(?:async\s+)?def\s+(\w+)\s*\(([^)]*)\)\s*(?:->\s*([^:\n]+))?:/i)
373
+ ?? content.match(/\b(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/i)
374
+ const entrypoint = functionMatch?.[1] ?? 'main'
375
+ const args = (functionMatch?.[2] ?? '')
376
+ .split(',')
377
+ .map(item => item.trim().split(':')[0].trim())
378
+ .filter(Boolean)
379
+ const returns = functionMatch?.[3]?.trim() ?? (/\breturn\s+/.test(content) ? 'inferred from return statement' : 'side effects only')
380
+ const dependencies = Array.from(new Set([
381
+ ...Array.from(content.matchAll(/^\s*import\s+([\w.]+)/gm)).map(match => match[1]),
382
+ ...Array.from(content.matchAll(/^\s*from\s+([\w.]+)\s+import/gm)).map(match => match[1]),
383
+ ...Array.from(content.matchAll(/require\(["']([^"']+)["']\)/g)).map(match => match[1]),
384
+ ]))
385
+ const sideEffects = [
386
+ /page\.|browser\.|goto\(|click\(|fill\(/.test(content) ? 'browser automation' : '',
387
+ /open\(|write|append|csv\.|fs\./.test(content) ? 'file IO' : '',
388
+ /fetch\(|axios|requests\.|http/.test(content) ? 'network call' : '',
389
+ ].filter(Boolean)
390
+ return {
391
+ kind: 'script',
392
+ title: entrypoint,
393
+ language,
394
+ entrypoint,
395
+ args,
396
+ returns,
397
+ dependencies,
398
+ sideEffects,
399
+ runnableHint: args.length ? `${entrypoint}(${args.join(', ')})` : entrypoint,
400
+ codeSnippet: content.slice(0, 800),
401
+ }
402
+ }
403
+
404
+ return null
405
+ }
406
+
407
+ function contractInputSchema(contract: ResourceContract): Record<string, unknown> | null {
408
+ if (!contract) return null
409
+ if (contract.kind === 'api_doc') {
410
+ return schemaFromObject(contract.requestSchema, `Request payload for ${contract.method} ${contract.endpoint}.`)
411
+ }
412
+ return {
413
+ type: 'object',
414
+ properties: Object.fromEntries(
415
+ contract.args.map(arg => [
416
+ arg,
417
+ {
418
+ type: 'string',
419
+ description: `Argument for ${contract.entrypoint}.`,
420
+ },
421
+ ]),
422
+ ),
423
+ required: contract.args,
424
+ }
425
+ }
426
+
427
+ function contractOutputSchema(contract: ResourceContract): Record<string, unknown> | null {
428
+ if (!contract) return null
429
+ if (contract.kind === 'api_doc') {
430
+ return schemaFromObject(contract.responseSchema, `Response payload from ${contract.method} ${contract.endpoint}.`)
431
+ }
432
+ return {
433
+ type: 'object',
434
+ properties: {
435
+ result: {
436
+ type: 'object',
437
+ description: contract.returns,
438
+ },
439
+ ok: {
440
+ type: 'boolean',
441
+ description: 'Whether the script completed successfully.',
442
+ },
443
+ },
444
+ required: ['result'],
445
+ }
446
+ }
447
+
448
+ function buildCandidateDraft(unit: ExperienceUnit, sourceType: SourceType): CandidateDraft {
449
+ const metadata = metadataRecord(unit)
450
+ const candidateInterface = nestedRecord(metadata, 'candidate_interface')
451
+ const candidateImplementation = nestedRecord(metadata, 'candidate_implementation')
452
+ const candidateRelations = nestedRecord(metadata, 'candidate_relations')
453
+ const evidence = nestedRecord(metadata, 'ctx2skill_evidence')
454
+ const selectedCandidate = nestedRecord(evidence, 'selected_candidate')
455
+ const name = toSnakeCase(unit.proposed_skill_name || `skill_from_${sourceType}_${unit.unit_id}`)
456
+ const description = unit.proposed_description || unit.summary || `Candidate Skill imported from ${sourceType}.`
457
+ const skillType = isSkillType(unit.proposed_type) ? unit.proposed_type : 'atomic'
458
+ const metadataTags = asStringArray(metadata.candidate_tags)
459
+ const tags = Array.from(new Set([sourceType, 'candidate-review', ...metadataTags, ...unit.index_keywords.slice(0, 3)]))
460
+ const contract = buildResourceContract(sourceType, unit.raw_content)
461
+
462
+ const inputSchema = asRecord(candidateInterface.input_schema) ?? contractInputSchema(contract) ?? {
463
+ type: 'object',
464
+ properties: {
465
+ context: {
466
+ type: 'object',
467
+ description: 'Runtime context distilled from the imported source experience.',
468
+ },
469
+ },
470
+ }
471
+ const outputSchema = asRecord(candidateInterface.output_schema) ?? contractOutputSchema(contract) ?? {
472
+ type: 'object',
473
+ properties: {
474
+ result: {
475
+ type: 'object',
476
+ description: 'Structured result produced by the candidate Skill.',
477
+ },
478
+ },
479
+ }
480
+ const parentSkillIds = asStringArray(candidateRelations.parent_skill_ids)
481
+ const dependencyIds = asStringArray(candidateRelations.dependency_ids)
482
+ const componentIds = asStringArray(candidateRelations.component_ids)
483
+ const subSkillIds = asStringArray(candidateImplementation.sub_skill_ids)
484
+ const toolCalls = asStringArray(candidateImplementation.tool_calls)
485
+ const provenance: SkillProvenance = {
486
+ source_type: sourceType,
487
+ source_ids: [unit.unit_id],
488
+ parent_skill_ids: parentSkillIds,
489
+ created_by_agent: 'human_reviewer',
490
+ creation_context: {
491
+ import_unit_id: unit.unit_id,
492
+ source_confidence: unit.confidence,
493
+ index_keywords: unit.index_keywords,
494
+ raw_content_preview: unit.raw_content,
495
+ resource_contract: contract,
496
+ ctx2skill_evidence: metadata.ctx2skill_evidence ?? null,
497
+ layering_reason: metadata.layering_reason ?? null,
498
+ graph_relation_preview: metadata.graph_relation_preview ?? [],
499
+ },
500
+ }
501
+ const verifierDescription = sourceType === 'document' || sourceType === 'past_skills'
502
+ ? 'Replace Ctx2Skill-lite challenge replay with a deterministic verifier before S3/S4 release.'
503
+ : 'Replace with deterministic verifier before S3/S4 release.'
504
+ const evaluation: SkillEvaluation = {
505
+ verifier_specs: [
506
+ {
507
+ type: 'placeholder',
508
+ description: verifierDescription,
509
+ },
510
+ ],
511
+ test_case_refs: [],
512
+ benchmark_task_ids: [],
513
+ validation_summary: 'Pending human validation. This Skill remains Candidate (S1).',
514
+ }
515
+
516
+ const contractPrompt = contract?.kind === 'api_doc'
517
+ ? `Call ${contract.method} ${contract.endpoint} with reviewed parameters and validate the response schema.`
518
+ : contract?.kind === 'script'
519
+ ? `Run or adapt ${contract.entrypoint} using the reviewed arguments. Runnable hint: ${contract.runnableHint}.`
520
+ : String(candidateImplementation.prompt_template || selectedCandidate.prompt_template || unit.summary || description)
521
+ const metadataPreconditions = asStringArray(candidateInterface.preconditions)
522
+ const metadataPostconditions = asStringArray(candidateInterface.postconditions)
523
+
524
+ return {
525
+ name,
526
+ description,
527
+ skillType,
528
+ tagsText: tags.join(', '),
529
+ inputSchemaText: toJson(inputSchema),
530
+ outputSchemaText: toJson(outputSchema),
531
+ preconditionsText: metadataPreconditions.length
532
+ ? metadataPreconditions.join('\n')
533
+ : contract?.kind === 'api_doc'
534
+ ? `Endpoint ${contract.endpoint} is reachable.\nRequired parameters are available: ${contract.requiredParams.join(', ') || 'reviewed request payload'}.`
535
+ : contract?.kind === 'script'
536
+ ? `Runtime supports ${contract.language}.\nDependencies are available: ${contract.dependencies.join(', ') || 'none detected'}.`
537
+ : '',
538
+ postconditionsText: metadataPostconditions.length
539
+ ? metadataPostconditions.join('\n')
540
+ : contract?.kind === 'api_doc'
541
+ ? `Response conforms to the reviewed schema for ${contract.method} ${contract.endpoint}.`
542
+ : contract?.kind === 'script'
543
+ ? `Entrypoint ${contract.entrypoint} completes and returns ${contract.returns}.`
544
+ : 'The Skill returns a structured result aligned with the reviewed source experience.',
545
+ promptTemplate: contractPrompt,
546
+ provenanceText: toJson(provenance),
547
+ evaluationText: toJson(evaluation),
548
+ dependencyIds,
549
+ componentIds,
550
+ subSkillIds,
551
+ parentSkillIds,
552
+ toolCalls,
553
+ }
554
+ }
555
+
556
+ export default function KnowledgeImport() {
557
+ const navigate = useNavigate()
558
+ const [activeTab, setActiveTab] = useState<SourceType>('trajectory')
559
+ const [content, setContent] = useState('')
560
+ const [loading, setLoading] = useState(false)
561
+ const [auditing, setAuditing] = useState(false)
562
+ const [creating, setCreating] = useState(false)
563
+ const [promoting, setPromoting] = useState(false)
564
+ const [pipelineStage, setPipelineStage] = useState(-1)
565
+ const [result, setResult] = useState<IngestResponse | null>(null)
566
+ const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null)
567
+ const [drafts, setDrafts] = useState<Record<string, CandidateDraft>>({})
568
+ const [auditResult, setAuditResult] = useState<CandidateAuditResult | null>(null)
569
+ const [createdSkill, setCreatedSkill] = useState<CreatedSkill | null>(null)
570
+ const [error, setError] = useState<string | null>(null)
571
+ const [loadedFileName, setLoadedFileName] = useState<string | null>(null)
572
+
573
+ const currentSource = SOURCE_TYPES.find(s => s.key === activeTab)!
574
+ const selectedUnit = result?.units.find(unit => unit.unit_id === selectedUnitId) ?? result?.units[0] ?? null
575
+ const currentDraft = selectedUnit ? drafts[selectedUnit.unit_id] : undefined
576
+ const sourceContract = buildResourceContract(activeTab, content)
577
+ const selectedMetadata = metadataRecord(selectedUnit)
578
+ const selectedEvidence = nestedRecord(selectedMetadata, 'ctx2skill_evidence')
579
+ const selectedGraphPreview = Array.isArray(selectedMetadata.graph_relation_preview)
580
+ ? selectedMetadata.graph_relation_preview
581
+ : []
582
+ const selectedLayeringReason = typeof selectedMetadata.layering_reason === 'string'
583
+ ? selectedMetadata.layering_reason
584
+ : ''
585
+
586
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
587
+
588
+ const resetReviewState = () => {
589
+ setAuditResult(null)
590
+ setCreatedSkill(null)
591
+ }
592
+
593
+ const resetParsedContentState = () => {
594
+ setResult(null)
595
+ setDrafts({})
596
+ setSelectedUnitId(null)
597
+ resetReviewState()
598
+ }
599
+
600
+ const handleFileBeforeUpload: UploadProps['beforeUpload'] = async file => {
601
+ if (file.size > MAX_IMPORT_FILE_BYTES) {
602
+ setError('File is too large for browser-side import. Use a file under 2 MB.')
603
+ return Upload.LIST_IGNORE
604
+ }
605
+
606
+ try {
607
+ const text = await file.text()
608
+ setContent(text)
609
+ setLoadedFileName(file.name)
610
+ setError(null)
611
+ resetParsedContentState()
612
+ } catch {
613
+ setError('Could not read this file as text.')
614
+ }
615
+ return Upload.LIST_IGNORE
616
+ }
617
+
618
+ const handleParse = async () => {
619
+ if (!content.trim()) {
620
+ setError('Please enter source content first.')
621
+ return
622
+ }
623
+
624
+ setLoading(true)
625
+ setResult(null)
626
+ setError(null)
627
+ resetReviewState()
628
+ setPipelineStage(0)
629
+
630
+ try {
631
+ const stagePromise = (async () => {
632
+ for (let i = 0; i < PIPELINE_STAGES.length; i += 1) {
633
+ setPipelineStage(i)
634
+ await sleep(300)
635
+ }
636
+ })()
637
+
638
+ const [res] = await Promise.all([ingestApi.parse(activeTab, content), stagePromise])
639
+ const nextDrafts = Object.fromEntries(
640
+ res.units.map(unit => [unit.unit_id, buildCandidateDraft(unit, activeTab)]),
641
+ )
642
+ setPipelineStage(PIPELINE_STAGES.length)
643
+ setResult(res)
644
+ setDrafts(nextDrafts)
645
+ setSelectedUnitId(res.units[0]?.unit_id ?? null)
646
+ } catch (e: unknown) {
647
+ setError(getApiErrorMessage(e, 'Parse failed'))
648
+ setPipelineStage(-1)
649
+ } finally {
650
+ setLoading(false)
651
+ }
652
+ }
653
+
654
+ const updateDraft = (patch: Partial<CandidateDraft>) => {
655
+ if (!selectedUnit) return
656
+ setDrafts(prev => ({
657
+ ...prev,
658
+ [selectedUnit.unit_id]: {
659
+ ...prev[selectedUnit.unit_id],
660
+ ...patch,
661
+ },
662
+ }))
663
+ }
664
+
665
+ const buildPayload = (): CandidateSkillReviewRequest => {
666
+ if (!selectedUnit || !currentDraft) {
667
+ throw new Error('Select a parsed experience unit first.')
668
+ }
669
+ const inputSchema = parseJsonObject<Record<string, unknown>>(currentDraft.inputSchemaText, 'Input schema')
670
+ const outputSchema = parseJsonObject<Record<string, unknown>>(currentDraft.outputSchemaText, 'Output schema')
671
+ const provenance = parseJsonObject<SkillProvenance>(currentDraft.provenanceText, 'Provenance')
672
+ const evaluation = parseJsonObject<SkillEvaluation>(currentDraft.evaluationText, 'Evaluation')
673
+ return {
674
+ source_type: activeTab,
675
+ unit_id: selectedUnit.unit_id,
676
+ raw_content: selectedUnit.raw_content || content,
677
+ name: toSnakeCase(currentDraft.name),
678
+ description: currentDraft.description.trim(),
679
+ skill_type: currentDraft.skillType,
680
+ tags: tagsFromText(currentDraft.tagsText),
681
+ input_schema: inputSchema,
682
+ output_schema: outputSchema,
683
+ preconditions: linesFromText(currentDraft.preconditionsText),
684
+ postconditions: linesFromText(currentDraft.postconditionsText),
685
+ prompt_template: currentDraft.promptTemplate,
686
+ provenance,
687
+ evaluation,
688
+ dependency_ids: currentDraft.dependencyIds,
689
+ component_ids: currentDraft.componentIds,
690
+ sub_skill_ids: currentDraft.subSkillIds,
691
+ parent_skill_ids: currentDraft.parentSkillIds,
692
+ tool_calls: currentDraft.toolCalls,
693
+ author: 'human_reviewer',
694
+ }
695
+ }
696
+
697
+ const handleRunAudit = async () => {
698
+ try {
699
+ setAuditing(true)
700
+ setError(null)
701
+ const audit = await ingestApi.auditCandidate(buildPayload())
702
+ setAuditResult(audit)
703
+ } catch (err) {
704
+ setError(err instanceof Error ? err.message : getApiErrorMessage(err, 'Audit failed'))
705
+ } finally {
706
+ setAuditing(false)
707
+ }
708
+ }
709
+
710
+ const handleCreateCandidate = async () => {
711
+ try {
712
+ setCreating(true)
713
+ setError(null)
714
+ const response = await ingestApi.createCandidate(buildPayload())
715
+ setCreatedSkill(response.created_skill)
716
+ setAuditResult(response.audit)
717
+ } catch (err) {
718
+ setError(err instanceof Error ? err.message : getApiErrorMessage(err, 'Create candidate failed'))
719
+ } finally {
720
+ setCreating(false)
721
+ }
722
+ }
723
+
724
+ const handlePromoteDraft = async () => {
725
+ if (!createdSkill) return
726
+ try {
727
+ setPromoting(true)
728
+ setError(null)
729
+ const promoted = await lifecycleApi.transition(
730
+ createdSkill.skill_id,
731
+ 'S2',
732
+ 'Promoted from reviewed import candidate',
733
+ )
734
+ setCreatedSkill({ ...createdSkill, state: promoted.state })
735
+ } catch (err) {
736
+ setError(getApiErrorMessage(err, 'Promote draft failed'))
737
+ } finally {
738
+ setPromoting(false)
739
+ }
740
+ }
741
+
742
+ const selectUnit = (unitId: string) => {
743
+ setSelectedUnitId(unitId)
744
+ resetReviewState()
745
+ }
746
+
747
+ return (
748
+ <div style={{ padding: 24 }}>
749
+ <motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
750
+ <h2 style={{ fontWeight: 700, marginBottom: 4 }}>Knowledge Import</h2>
751
+ <p style={{ color: '#666', marginBottom: 24 }}>
752
+ Parse experience sources into editable Candidate Skills. Imported Skills stay Candidate (S1)
753
+ until a human reviewer audits, creates, and optionally promotes them to Draft (S2).
754
+ </p>
755
+ </motion.div>
756
+
757
+ {error && (
758
+ <Alert
759
+ type="error"
760
+ showIcon
761
+ closable
762
+ title="Parse failed"
763
+ description={error}
764
+ style={{ marginBottom: 16 }}
765
+ onClose={() => setError(null)}
766
+ />
767
+ )}
768
+
769
+ <Row gutter={[16, 16]}>
770
+ <Col xs={24} lg={10}>
771
+ <Card variant="borderless" style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
772
+ <Tabs
773
+ activeKey={activeTab}
774
+ onChange={key => {
775
+ setActiveTab(key as SourceType)
776
+ setContent('')
777
+ setLoadedFileName(null)
778
+ setError(null)
779
+ resetParsedContentState()
780
+ }}
781
+ items={SOURCE_TYPES.map(source => ({
782
+ key: source.key,
783
+ label: <span>{source.icon} {source.label}</span>,
784
+ children: null,
785
+ }))}
786
+ />
787
+
788
+ <Tag color={currentSource.color} style={{ marginBottom: 8 }}>
789
+ {currentSource.icon} {currentSource.label}
790
+ </Tag>
791
+
792
+ <TextArea
793
+ value={content}
794
+ onChange={event => {
795
+ setContent(event.target.value)
796
+ setLoadedFileName(null)
797
+ }}
798
+ placeholder={currentSource.placeholder}
799
+ rows={15}
800
+ style={{ fontFamily: 'monospace', fontSize: 13 }}
801
+ />
802
+
803
+ <Dragger
804
+ accept=".txt,.md,.markdown,.json,.jsonl,.yaml,.yml,.py,.js,.ts,.tsx,.sh,.bash,.csv,.log,.html,.xml"
805
+ beforeUpload={handleFileBeforeUpload}
806
+ multiple={false}
807
+ showUploadList={false}
808
+ style={{ marginTop: 12, padding: '6px 0', background: '#fafafa' }}
809
+ >
810
+ <p className="ant-upload-drag-icon" style={{ marginBottom: 4 }}>
811
+ <CloudUploadOutlined style={{ color: currentSource.color }} />
812
+ </p>
813
+ <p className="ant-upload-text" style={{ marginBottom: 0 }}>
814
+ Drop a source file here or click to choose one
815
+ </p>
816
+ <p className="ant-upload-hint" style={{ marginBottom: 0 }}>
817
+ The file is read locally into this {currentSource.label} input. Nothing is created until you parse and review it.
818
+ </p>
819
+ </Dragger>
820
+
821
+ {loadedFileName && (
822
+ <Alert
823
+ type="success"
824
+ showIcon
825
+ style={{ marginTop: 10 }}
826
+ message={`Loaded ${loadedFileName}`}
827
+ description="Review the text above, then parse it for Candidate Review."
828
+ />
829
+ )}
830
+
831
+ {(activeTab === 'api_doc' || activeTab === 'script' || activeTab === 'past_skills') && (
832
+ <Space wrap style={{ marginTop: 10 }}>
833
+ {RESOURCE_EXAMPLES
834
+ .filter(example => example.sourceType === activeTab)
835
+ .map(example => (
836
+ <Button
837
+ key={example.title}
838
+ size="small"
839
+ onClick={() => {
840
+ setContent(example.content)
841
+ setLoadedFileName(null)
842
+ resetParsedContentState()
843
+ }}
844
+ >
845
+ {example.title}
846
+ </Button>
847
+ ))}
848
+ </Space>
849
+ )}
850
+
851
+ {sourceContract && (
852
+ <Card size="small" style={{ marginTop: 12, background: '#fafafa', borderRadius: 8 }}>
853
+ {sourceContract.kind === 'api_doc' ? (
854
+ <Descriptions size="small" column={1}>
855
+ <Descriptions.Item label="Endpoint">
856
+ <Space><Tag color="purple">{sourceContract.method}</Tag><Text code>{sourceContract.endpoint}</Text></Space>
857
+ </Descriptions.Item>
858
+ <Descriptions.Item label="Params">
859
+ {sourceContract.requiredParams.length
860
+ ? sourceContract.requiredParams.map(param => <Tag key={param}>{param}</Tag>)
861
+ : <Text type="secondary">No explicit request fields detected</Text>}
862
+ </Descriptions.Item>
863
+ <Descriptions.Item label="Response schema">
864
+ <pre style={PREVIEW_BLOCK_STYLE}>{toJson(sourceContract.responseSchema)}</pre>
865
+ </Descriptions.Item>
866
+ </Descriptions>
867
+ ) : (
868
+ <Descriptions size="small" column={1}>
869
+ <Descriptions.Item label="Entrypoint">
870
+ <Space><Tag color="orange">{sourceContract.language}</Tag><Text code>{sourceContract.entrypoint}</Text></Space>
871
+ </Descriptions.Item>
872
+ <Descriptions.Item label="Args">
873
+ {sourceContract.args.length
874
+ ? sourceContract.args.map(arg => <Tag key={arg}>{arg}</Tag>)
875
+ : <Text type="secondary">No explicit args detected</Text>}
876
+ </Descriptions.Item>
877
+ <Descriptions.Item label="Returns">{sourceContract.returns}</Descriptions.Item>
878
+ <Descriptions.Item label="Effects">
879
+ {sourceContract.sideEffects.length
880
+ ? sourceContract.sideEffects.map(effect => <Tag key={effect}>{effect}</Tag>)
881
+ : <Text type="secondary">No side effects detected</Text>}
882
+ </Descriptions.Item>
883
+ </Descriptions>
884
+ )}
885
+ </Card>
886
+ )}
887
+
888
+ <div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
889
+ <Button type="primary" icon={<CloudUploadOutlined />} loading={loading} onClick={handleParse}>
890
+ Parse for Candidate Review
891
+ </Button>
892
+ <Text type="secondary" style={{ fontSize: 12 }}>
893
+ Parsing does not release a Skill. It only prepares an S1 candidate draft.
894
+ </Text>
895
+ </div>
896
+
897
+ {(loading || pipelineStage >= 0) && (
898
+ <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} style={{ marginTop: 16 }}>
899
+ <Divider style={{ margin: '12px 0' }} />
900
+ <Text type="secondary" style={{ fontSize: 12 }}>Experience Processing Pipeline</Text>
901
+ <Steps
902
+ size="small"
903
+ current={pipelineStage}
904
+ status={pipelineStage >= PIPELINE_STAGES.length ? 'finish' : 'process'}
905
+ items={PIPELINE_STAGES.map((stage, index) => ({
906
+ title: stage.title,
907
+ content: stage.desc,
908
+ icon: pipelineStage > index
909
+ ? <CheckCircleOutlined style={{ color: '#52c41a' }} />
910
+ : pipelineStage === index && loading
911
+ ? <LoadingOutlined style={{ color: '#1677ff' }} />
912
+ : stage.icon,
913
+ }))}
914
+ />
915
+ </motion.div>
916
+ )}
917
+ </Card>
918
+
919
+ {result && (
920
+ <Card
921
+ title="Parsed Units"
922
+ variant="borderless"
923
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginTop: 16 }}
924
+ extra={<Badge status={result.success ? 'success' : 'error'} text={result.success ? 'Ready' : 'Check errors'} />}
925
+ >
926
+ <Row gutter={[8, 8]} style={{ marginBottom: 12 }}>
927
+ <Col span={8}>
928
+ <div style={{ textAlign: 'center' }}>
929
+ <div style={{ fontSize: 22, fontWeight: 700, color: '#1677ff' }}>{result.unit_count}</div>
930
+ <Text type="secondary" style={{ fontSize: 12 }}>Units</Text>
931
+ </div>
932
+ </Col>
933
+ <Col span={8}>
934
+ <div style={{ textAlign: 'center' }}>
935
+ <div style={{ fontSize: 22, fontWeight: 700, color: '#722ed1' }}>{result.token_usage}</div>
936
+ <Text type="secondary" style={{ fontSize: 12 }}>Tokens</Text>
937
+ </div>
938
+ </Col>
939
+ <Col span={8}>
940
+ <div style={{ textAlign: 'center' }}>
941
+ <div style={{ fontSize: 22, fontWeight: 700, color: '#ff4d4f' }}>{result.errors.length}</div>
942
+ <Text type="secondary" style={{ fontSize: 12 }}>Errors</Text>
943
+ </div>
944
+ </Col>
945
+ </Row>
946
+
947
+ {result.errors.length > 0 && (
948
+ <Alert type="warning" title="Parser warnings" description={result.errors.join('; ')} style={{ marginBottom: 12 }} />
949
+ )}
950
+
951
+ <Space orientation="vertical" style={{ width: '100%' }}>
952
+ {result.units.map(unit => (
953
+ <Button
954
+ key={unit.unit_id}
955
+ block
956
+ type={unit.unit_id === selectedUnit?.unit_id ? 'primary' : 'default'}
957
+ onClick={() => selectUnit(unit.unit_id)}
958
+ style={{ height: 'auto', textAlign: 'left', padding: 10 }}
959
+ >
960
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
961
+ <span>{drafts[unit.unit_id]?.name ?? unit.proposed_skill_name ?? unit.unit_id}</span>
962
+ <Tag color={TYPE_COLOR[unit.proposed_type || 'atomic'] || 'default'}>{unit.proposed_type || 'atomic'}</Tag>
963
+ </div>
964
+ <Progress
965
+ percent={Math.round(unit.confidence * 100)}
966
+ size="small"
967
+ strokeColor={unit.confidence > 0.7 ? '#52c41a' : '#faad14'}
968
+ />
969
+ </Button>
970
+ ))}
971
+ </Space>
972
+ </Card>
973
+ )}
974
+ </Col>
975
+
976
+ <Col xs={24} lg={14}>
977
+ <AnimatePresence mode="wait">
978
+ {!selectedUnit || !currentDraft ? (
979
+ <motion.div key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
980
+ <Card
981
+ variant="borderless"
982
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', textAlign: 'center', padding: 40 }}
983
+ >
984
+ <CloudUploadOutlined style={{ fontSize: 48, color: '#d9d9d9', marginBottom: 12 }} />
985
+ <div style={{ color: '#999' }}>Parse content to open the Candidate Review panel.</div>
986
+ </Card>
987
+ </motion.div>
988
+ ) : (
989
+ <motion.div key={selectedUnit.unit_id} initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }}>
990
+ <Card
991
+ title="Candidate Review"
992
+ variant="borderless"
993
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}
994
+ extra={<Tag color="orange">Candidate (S1), not Released</Tag>}
995
+ >
996
+ <Alert
997
+ type="info"
998
+ showIcon
999
+ title="Human review boundary"
1000
+ description="This panel turns parsed experience into a candidate Skill. Audit results are evidence, not release approval."
1001
+ style={{ marginBottom: 16 }}
1002
+ />
1003
+
1004
+ <Collapse
1005
+ bordered={false}
1006
+ items={[
1007
+ {
1008
+ key: 'raw',
1009
+ label: 'Raw input preview',
1010
+ children: (
1011
+ <div style={PREVIEW_BLOCK_STYLE}>
1012
+ {selectedUnit.raw_content || content}
1013
+ </div>
1014
+ ),
1015
+ },
1016
+ {
1017
+ key: 'structured',
1018
+ label: 'Structured experience preview',
1019
+ children: (
1020
+ <div>
1021
+ <Paragraph>{selectedUnit.summary}</Paragraph>
1022
+ <Space wrap style={{ marginBottom: 8 }}>
1023
+ {selectedUnit.extracted_actions.map((action, index) => (
1024
+ <Tag key={`${selectedUnit.unit_id}-action-${index}`}>{action}</Tag>
1025
+ ))}
1026
+ </Space>
1027
+ <pre style={PREVIEW_BLOCK_STYLE}>
1028
+ {toJson(selectedUnit.normalized_actions)}
1029
+ </pre>
1030
+ </div>
1031
+ ),
1032
+ },
1033
+ ...(Object.keys(selectedEvidence).length
1034
+ ? [{
1035
+ key: 'ctx2skill',
1036
+ label: 'Ctx2Skill Evidence',
1037
+ children: (
1038
+ <Descriptions column={1} bordered size="small">
1039
+ <Descriptions.Item label="Paper method">
1040
+ {String(selectedEvidence.paper_method || 'Ctx2Skill-lite')}
1041
+ </Descriptions.Item>
1042
+ <Descriptions.Item label="Selected reason">
1043
+ {String(selectedEvidence.selected_reason || '')}
1044
+ </Descriptions.Item>
1045
+ <Descriptions.Item label="Challenges">
1046
+ <pre style={PREVIEW_BLOCK_STYLE}>{toJson(selectedEvidence.challenges || [])}</pre>
1047
+ </Descriptions.Item>
1048
+ <Descriptions.Item label="Judge results">
1049
+ <pre style={PREVIEW_BLOCK_STYLE}>{toJson(selectedEvidence.judge_results || [])}</pre>
1050
+ </Descriptions.Item>
1051
+ <Descriptions.Item label="Candidate scores">
1052
+ <pre style={PREVIEW_BLOCK_STYLE}>{toJson(selectedEvidence.candidate_scores || [])}</pre>
1053
+ </Descriptions.Item>
1054
+ </Descriptions>
1055
+ ),
1056
+ }]
1057
+ : []),
1058
+ ...(selectedLayeringReason
1059
+ ? [{
1060
+ key: 'skillx-layer',
1061
+ label: 'SkillX Layering',
1062
+ children: <Alert type="info" showIcon message={currentDraft.skillType} description={selectedLayeringReason} />,
1063
+ }]
1064
+ : []),
1065
+ ...(selectedGraphPreview.length
1066
+ ? [{
1067
+ key: 'graph-preview',
1068
+ label: 'Graph Relation Preview',
1069
+ children: <pre style={PREVIEW_BLOCK_STYLE}>{toJson(selectedGraphPreview)}</pre>,
1070
+ }]
1071
+ : []),
1072
+ ...(activeTab === 'api_doc' || activeTab === 'script'
1073
+ ? [{
1074
+ key: 'contract',
1075
+ label: 'Resource contract preview',
1076
+ children: (() => {
1077
+ const contract = buildResourceContract(activeTab, selectedUnit.raw_content || content)
1078
+ if (!contract) return <Text type="secondary">No resource contract detected.</Text>
1079
+ return contract.kind === 'api_doc' ? (
1080
+ <Descriptions column={1} bordered size="small">
1081
+ <Descriptions.Item label="Endpoint">
1082
+ <Space><Tag color="purple">{contract.method}</Tag><Text code>{contract.endpoint}</Text></Space>
1083
+ </Descriptions.Item>
1084
+ <Descriptions.Item label="Required params">
1085
+ {contract.requiredParams.length
1086
+ ? contract.requiredParams.map(param => <Tag key={param}>{param}</Tag>)
1087
+ : <Text type="secondary">None detected</Text>}
1088
+ </Descriptions.Item>
1089
+ <Descriptions.Item label="Request schema">
1090
+ <pre style={PREVIEW_BLOCK_STYLE}>{toJson(contract.requestSchema)}</pre>
1091
+ </Descriptions.Item>
1092
+ <Descriptions.Item label="Response schema">
1093
+ <pre style={PREVIEW_BLOCK_STYLE}>{toJson(contract.responseSchema)}</pre>
1094
+ </Descriptions.Item>
1095
+ <Descriptions.Item label="Example call">{contract.exampleCall}</Descriptions.Item>
1096
+ </Descriptions>
1097
+ ) : (
1098
+ <Descriptions column={1} bordered size="small">
1099
+ <Descriptions.Item label="Entrypoint"><Text code>{contract.entrypoint}</Text></Descriptions.Item>
1100
+ <Descriptions.Item label="Language">{contract.language}</Descriptions.Item>
1101
+ <Descriptions.Item label="Arguments">
1102
+ {contract.args.length
1103
+ ? contract.args.map(arg => <Tag key={arg}>{arg}</Tag>)
1104
+ : <Text type="secondary">None detected</Text>}
1105
+ </Descriptions.Item>
1106
+ <Descriptions.Item label="Returns">{contract.returns}</Descriptions.Item>
1107
+ <Descriptions.Item label="Dependencies">
1108
+ {contract.dependencies.length
1109
+ ? contract.dependencies.map(dep => <Tag key={dep}>{dep}</Tag>)
1110
+ : <Text type="secondary">None detected</Text>}
1111
+ </Descriptions.Item>
1112
+ <Descriptions.Item label="Runnable hint"><Text code>{contract.runnableHint}</Text></Descriptions.Item>
1113
+ </Descriptions>
1114
+ )
1115
+ })(),
1116
+ }]
1117
+ : []),
1118
+ ]}
1119
+ style={{ marginBottom: 16 }}
1120
+ />
1121
+
1122
+ <Row gutter={[12, 12]}>
1123
+ <Col xs={24} md={14}>
1124
+ <Text strong>Candidate name</Text>
1125
+ <Input
1126
+ value={currentDraft.name}
1127
+ onChange={event => updateDraft({ name: event.target.value })}
1128
+ onBlur={() => updateDraft({ name: toSnakeCase(currentDraft.name) })}
1129
+ />
1130
+ </Col>
1131
+ <Col xs={24} md={10}>
1132
+ <Text strong>Skill type</Text>
1133
+ <Select
1134
+ value={currentDraft.skillType}
1135
+ onChange={value => updateDraft({ skillType: value })}
1136
+ style={{ width: '100%' }}
1137
+ options={[
1138
+ { label: 'atomic', value: 'atomic' },
1139
+ { label: 'functional', value: 'functional' },
1140
+ { label: 'strategic', value: 'strategic' },
1141
+ ]}
1142
+ />
1143
+ </Col>
1144
+ <Col span={24}>
1145
+ <Text strong>Description</Text>
1146
+ <TextArea
1147
+ value={currentDraft.description}
1148
+ onChange={event => updateDraft({ description: event.target.value })}
1149
+ rows={2}
1150
+ />
1151
+ </Col>
1152
+ <Col span={24}>
1153
+ <Text strong>Tags</Text>
1154
+ <Input
1155
+ value={currentDraft.tagsText}
1156
+ onChange={event => updateDraft({ tagsText: event.target.value })}
1157
+ placeholder="comma,separated,tags"
1158
+ />
1159
+ </Col>
1160
+ <Col span={24}>
1161
+ <Text strong>Prompt template</Text>
1162
+ <TextArea
1163
+ value={currentDraft.promptTemplate}
1164
+ onChange={event => updateDraft({ promptTemplate: event.target.value })}
1165
+ rows={3}
1166
+ />
1167
+ </Col>
1168
+ <Col xs={24} md={12}>
1169
+ <Text strong>Input schema JSON</Text>
1170
+ <TextArea
1171
+ value={currentDraft.inputSchemaText}
1172
+ onChange={event => updateDraft({ inputSchemaText: event.target.value })}
1173
+ rows={8}
1174
+ style={{ fontFamily: 'monospace', fontSize: 12 }}
1175
+ />
1176
+ </Col>
1177
+ <Col xs={24} md={12}>
1178
+ <Text strong>Output schema JSON</Text>
1179
+ <TextArea
1180
+ value={currentDraft.outputSchemaText}
1181
+ onChange={event => updateDraft({ outputSchemaText: event.target.value })}
1182
+ rows={8}
1183
+ style={{ fontFamily: 'monospace', fontSize: 12 }}
1184
+ />
1185
+ </Col>
1186
+ <Col xs={24} md={12}>
1187
+ <Text strong>Preconditions</Text>
1188
+ <TextArea
1189
+ value={currentDraft.preconditionsText}
1190
+ onChange={event => updateDraft({ preconditionsText: event.target.value })}
1191
+ rows={3}
1192
+ placeholder="One condition per line"
1193
+ />
1194
+ </Col>
1195
+ <Col xs={24} md={12}>
1196
+ <Text strong>Postconditions</Text>
1197
+ <TextArea
1198
+ value={currentDraft.postconditionsText}
1199
+ onChange={event => updateDraft({ postconditionsText: event.target.value })}
1200
+ rows={3}
1201
+ placeholder="One condition per line"
1202
+ />
1203
+ </Col>
1204
+ <Col xs={24} md={12}>
1205
+ <Text strong>Provenance JSON</Text>
1206
+ <TextArea
1207
+ value={currentDraft.provenanceText}
1208
+ onChange={event => updateDraft({ provenanceText: event.target.value })}
1209
+ rows={8}
1210
+ style={{ fontFamily: 'monospace', fontSize: 12 }}
1211
+ />
1212
+ </Col>
1213
+ <Col xs={24} md={12}>
1214
+ <Text strong>Evaluation placeholder JSON</Text>
1215
+ <TextArea
1216
+ value={currentDraft.evaluationText}
1217
+ onChange={event => updateDraft({ evaluationText: event.target.value })}
1218
+ rows={8}
1219
+ style={{ fontFamily: 'monospace', fontSize: 12 }}
1220
+ />
1221
+ </Col>
1222
+ </Row>
1223
+
1224
+ {auditResult && (
1225
+ <Alert
1226
+ type={auditResult.passed ? (auditResult.warnings.length ? 'warning' : 'success') : 'error'}
1227
+ showIcon
1228
+ style={{ marginTop: 16 }}
1229
+ title={`Audit score ${Math.round(auditResult.audit_score * 100)}%`}
1230
+ description={
1231
+ <div>
1232
+ <Space wrap style={{ marginBottom: 8 }}>
1233
+ <Tag color={auditResult.schema_ok ? 'green' : 'red'}>schema</Tag>
1234
+ <Tag color={auditResult.safety_ok ? 'green' : 'red'}>safety</Tag>
1235
+ <Tag color={auditResult.postcondition_ok ? 'green' : 'red'}>postconditions</Tag>
1236
+ </Space>
1237
+ {auditResult.issues.map(issue => <div key={issue}>Issue: {issue}</div>)}
1238
+ {auditResult.warnings.map(warning => <div key={warning}>Warning: {warning}</div>)}
1239
+ {auditResult.recommendations.slice(0, 3).map(item => <div key={item}>Recommendation: {item}</div>)}
1240
+ </div>
1241
+ }
1242
+ />
1243
+ )}
1244
+
1245
+ {createdSkill && (
1246
+ <Alert
1247
+ type="success"
1248
+ showIcon
1249
+ style={{ marginTop: 16 }}
1250
+ title={`Created ${createdSkill.name} as ${createdSkill.state}`}
1251
+ description={
1252
+ <Space wrap>
1253
+ <Link to={`/wiki?skill_id=${encodeURIComponent(createdSkill.skill_id)}`}>
1254
+ Open in Wiki
1255
+ </Link>
1256
+ {createdSkill.state === 'S2' && (
1257
+ <Link to={`/harness?skill_id=${encodeURIComponent(createdSkill.skill_id)}`}>
1258
+ Open Verification Loop
1259
+ </Link>
1260
+ )}
1261
+ <Text type="secondary">Version {createdSkill.version}</Text>
1262
+ </Space>
1263
+ }
1264
+ />
1265
+ )}
1266
+
1267
+ <div style={{ marginTop: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
1268
+ <Button icon={<SafetyCertificateOutlined />} loading={auditing} onClick={handleRunAudit}>
1269
+ Run Auditor
1270
+ </Button>
1271
+ <Button type="primary" icon={<CheckCircleOutlined />} loading={creating} onClick={handleCreateCandidate}>
1272
+ Create Candidate
1273
+ </Button>
1274
+ <Button disabled={!createdSkill || createdSkill.state !== 'S1'} loading={promoting} onClick={handlePromoteDraft}>
1275
+ Promote Draft (S2)
1276
+ </Button>
1277
+ <Button
1278
+ icon={<SafetyCertificateOutlined />}
1279
+ disabled={!createdSkill || createdSkill.state !== 'S2'}
1280
+ onClick={() => createdSkill && navigate(`/harness?skill_id=${encodeURIComponent(createdSkill.skill_id)}`)}
1281
+ >
1282
+ Verify Draft
1283
+ </Button>
1284
+ </div>
1285
+ </Card>
1286
+ </motion.div>
1287
+ )}
1288
+ </AnimatePresence>
1289
+ </Col>
1290
+ </Row>
1291
+ </div>
1292
+ )
1293
+ }
skillwiki-frontend/src/pages/LifecycleDemo.tsx ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react'
2
+ import {
3
+ Card, Steps, Tag, Button, Select, Divider,
4
+ Timeline, Typography, Space, Row, Col, message,
5
+ } from 'antd'
6
+ import {
7
+ ArrowRightOutlined,
8
+ SyncOutlined, ExperimentOutlined,
9
+ } from '@ant-design/icons'
10
+ import { motion, AnimatePresence } from 'framer-motion'
11
+ import { skillsApi, lifecycleApi } from '@/api/client'
12
+ import type { SkillSummary } from '@/api/types'
13
+
14
+ const { Text, Paragraph } = Typography
15
+
16
+ // SkillWiki 8-state machine definition
17
+ const STATES = [
18
+ { key: 'S0', label: 'Raw Experience', color: '#722ed1', icon: '🌱', desc: 'Raw experience from trajectories, documents, or scripts' },
19
+ { key: 'S1', label: 'Candidate', color: '#1677ff', icon: '🔍', desc: 'Skill candidate identified by an LLM' },
20
+ { key: 'S2', label: 'Draft', color: '#13c2c2', icon: '✏️', desc: 'Structured Skill draft' },
21
+ { key: 'S3', label: 'Verified', color: '#52c41a', icon: '✅', desc: 'Passed static checks and semantic validation' },
22
+ { key: 'S4', label: 'Released', color: '#52c41a', icon: '🚀', desc: 'Released and callable by agents' },
23
+ { key: 'S5', label: 'Degraded', color: '#faad14', icon: '⚠️', desc: 'Success rate dropped and repair is needed' },
24
+ { key: 'S6', label: 'Deprecated', color: '#ff4d4f', icon: '🗑️', desc: 'Deprecated and no longer recommended' },
25
+ { key: 'S7', label: 'Archived', color: '#8c8c8c', icon: '📦', desc: 'Archived with history preserved' },
26
+ ]
27
+
28
+ const TRANSITIONS: Record<string, string[]> = {
29
+ S0: ['S1'],
30
+ S1: ['S2'],
31
+ S2: ['S3', 'S1'],
32
+ S3: ['S4', 'S2'],
33
+ S4: ['S5', 'S6'],
34
+ S5: ['S4', 'S6'],
35
+ S6: ['S7'],
36
+ S7: [],
37
+ }
38
+
39
+ const TRANSITION_LABELS: Record<string, string> = {
40
+ 'S0→S1': 'Candidate Mining',
41
+ 'S1→S2': 'Formalize',
42
+ 'S2→S3': 'Verify',
43
+ 'S2→S1': 'Remine',
44
+ 'S3→S4': 'Release',
45
+ 'S3→S2': 'Revise',
46
+ 'S4→S5': 'Performance Degraded',
47
+ 'S4→S6': 'Manual Deprecation',
48
+ 'S5→S4': 'Repair Succeeded',
49
+ 'S5→S6': 'Repair Failed',
50
+ 'S6→S7': 'Archive',
51
+ }
52
+
53
+ function StateMachineViz({ currentState }: { currentState: string }) {
54
+ const mainFlow = ['S0', 'S1', 'S2', 'S3', 'S4']
55
+ const sideFlow = ['S5', 'S6', 'S7']
56
+
57
+ const getStepStatus = (key: string) => {
58
+ const idx = mainFlow.indexOf(key)
59
+ const curIdx = mainFlow.indexOf(currentState)
60
+ if (key === currentState) return 'process'
61
+ if (idx < curIdx) return 'finish'
62
+ return 'wait'
63
+ }
64
+
65
+ return (
66
+ <div>
67
+ {/* Main flow */}
68
+ <Steps
69
+ current={mainFlow.indexOf(currentState)}
70
+ items={mainFlow.map(key => {
71
+ const s = STATES.find(x => x.key === key)!
72
+ return {
73
+ title: <span style={{ fontSize: 12 }}>{s.icon} {s.label}</span>,
74
+ status: getStepStatus(key) as 'process' | 'finish' | 'wait',
75
+ description: <span style={{ fontSize: 11, color: '#999' }}>{s.key}</span>,
76
+ }
77
+ })}
78
+ style={{ marginBottom: 16 }}
79
+ />
80
+
81
+ {/* Side flow: degraded, deprecated, archived */}
82
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 8 }}>
83
+ <Text type="secondary" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>Exception Path:</Text>
84
+ {sideFlow.map(key => {
85
+ const s = STATES.find(x => x.key === key)!
86
+ const isActive = currentState === key
87
+ return (
88
+ <Tag
89
+ key={key}
90
+ color={isActive ? s.color : undefined}
91
+ style={{
92
+ borderColor: s.color,
93
+ color: isActive ? '#fff' : s.color,
94
+ fontWeight: isActive ? 700 : 400,
95
+ }}
96
+ >
97
+ {s.icon} {s.label}
98
+ </Tag>
99
+ )
100
+ })}
101
+ </div>
102
+ </div>
103
+ )
104
+ }
105
+
106
+ export default function LifecycleDemo() {
107
+ const [skills, setSkills] = useState<SkillSummary[]>([])
108
+ const [selectedId, setSelectedId] = useState<string | null>(null)
109
+ const [selected, setSelected] = useState<SkillSummary | null>(null)
110
+ const [history, setHistory] = useState<{ state: string; label: string; time: string }[]>([])
111
+ const [loading, setLoading] = useState(false)
112
+
113
+ useEffect(() => {
114
+ skillsApi.list({ limit: 100 }).then(setSkills)
115
+ }, [])
116
+
117
+ useEffect(() => {
118
+ if (!selectedId) return
119
+ const timeoutId = window.setTimeout(() => {
120
+ const s = skills.find(x => x.skill_id === selectedId) || null
121
+ setSelected(s)
122
+ if (s) {
123
+ setHistory([{ state: s.state, label: STATES.find(x => x.key === s.state)?.label || s.state, time: new Date().toLocaleTimeString() }])
124
+ }
125
+ }, 0)
126
+ return () => window.clearTimeout(timeoutId)
127
+ }, [selectedId, skills])
128
+
129
+ const doTransition = async (targetState: string) => {
130
+ if (!selected) return
131
+ setLoading(true)
132
+ try {
133
+ let updated: SkillSummary
134
+ if (targetState === 'S4') {
135
+ updated = await lifecycleApi.release(selected.skill_id)
136
+ } else if (targetState === 'S6') {
137
+ updated = await lifecycleApi.deprecate(selected.skill_id, 'Demo deprecation')
138
+ } else {
139
+ updated = await lifecycleApi.transition(selected.skill_id, targetState as SkillSummary['state'])
140
+ }
141
+ setSelected(updated)
142
+ setSkills(prev => prev.map(s => s.skill_id === updated.skill_id ? updated : s))
143
+ const stateInfo = STATES.find(x => x.key === updated.state)
144
+ setHistory(prev => [
145
+ { state: updated.state, label: stateInfo?.label || updated.state, time: new Date().toLocaleTimeString() },
146
+ ...prev,
147
+ ])
148
+ message.success(`State changed to ${stateInfo?.label}`)
149
+ } catch (e: unknown) {
150
+ message.error((e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Transition failed')
151
+ } finally {
152
+ setLoading(false)
153
+ }
154
+ }
155
+
156
+ const currentStateInfo = selected ? STATES.find(x => x.key === selected.state) : null
157
+ const availableTransitions = selected ? (TRANSITIONS[selected.state] || []) : []
158
+
159
+ return (
160
+ <div style={{ padding: 24 }}>
161
+ <motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
162
+ <h2 style={{ fontWeight: 700, marginBottom: 4 }}>Skill Lifecycle Demo</h2>
163
+ <p style={{ color: '#666', marginBottom: 24 }}>
164
+ Select a Skill and interactively walk through its full lifecycle in the SkillWiki 8-state machine.
165
+ </p>
166
+ </motion.div>
167
+
168
+ <Row gutter={[16, 16]}>
169
+ {/* Left: state machine visualization and actions */}
170
+ <Col xs={24} lg={14}>
171
+ <Card
172
+ title="State Machine Visualization"
173
+ variant="borderless"
174
+ style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }}
175
+ >
176
+ <div style={{ marginBottom: 16 }}>
177
+ <Select
178
+ placeholder="Select a Skill"
179
+ style={{ width: '100%' }}
180
+ onChange={setSelectedId}
181
+ options={skills.map(s => ({
182
+ label: `${s.name} (${STATES.find(x => x.key === s.state)?.label || s.state})`,
183
+ value: s.skill_id,
184
+ }))}
185
+ showSearch
186
+ filterOption={(input, opt) =>
187
+ (opt?.label as string)?.toLowerCase().includes(input.toLowerCase())
188
+ }
189
+ />
190
+ </div>
191
+
192
+ {selected && (
193
+ <motion.div key={selected.skill_id} initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
194
+ <StateMachineViz currentState={selected.state} />
195
+
196
+ <Divider />
197
+
198
+ {/* Current state details */}
199
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
200
+ <div style={{
201
+ width: 48, height: 48, borderRadius: '50%',
202
+ background: currentStateInfo?.color,
203
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
204
+ fontSize: 24,
205
+ }}>
206
+ {currentStateInfo?.icon}
207
+ </div>
208
+ <div>
209
+ <div style={{ fontWeight: 700, fontSize: 16 }}>{currentStateInfo?.label}</div>
210
+ <div style={{ color: '#666', fontSize: 13 }}>{currentStateInfo?.desc}</div>
211
+ </div>
212
+ </div>
213
+
214
+ {/* Available transitions */}
215
+ <div>
216
+ <Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
217
+ Available state transitions:
218
+ </Text>
219
+ <Space wrap>
220
+ {availableTransitions.length === 0 ? (
221
+ <Text type="secondary">Terminal state. No transitions available.</Text>
222
+ ) : (
223
+ availableTransitions.map(target => {
224
+ const targetInfo = STATES.find(x => x.key === target)!
225
+ const label = TRANSITION_LABELS[`${selected.state}→${target}`] || target
226
+ return (
227
+ <motion.div key={target} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
228
+ <Button
229
+ type="primary"
230
+ style={{ background: targetInfo.color, borderColor: targetInfo.color }}
231
+ icon={<ArrowRightOutlined />}
232
+ onClick={() => doTransition(target)}
233
+ loading={loading}
234
+ >
235
+ {targetInfo.icon} {label} → {targetInfo.label}
236
+ </Button>
237
+ </motion.div>
238
+ )
239
+ })
240
+ )}
241
+ </Space>
242
+ </div>
243
+ </motion.div>
244
+ )}
245
+
246
+ {!selected && (
247
+ <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
248
+ <ExperimentOutlined style={{ fontSize: 48, marginBottom: 12 }} />
249
+ <div>Select a Skill to start the demo.</div>
250
+ </div>
251
+ )}
252
+ </Card>
253
+
254
+ {/* State description cards */}
255
+ <Card
256
+ title="SkillWiki State Machine"
257
+ variant="borderless"
258
+ style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}
259
+ >
260
+ <Row gutter={[8, 8]}>
261
+ {STATES.map(s => (
262
+ <Col xs={12} sm={8} key={s.key}>
263
+ <div style={{
264
+ padding: '8px 12px',
265
+ borderRadius: 8,
266
+ border: `1px solid ${s.color}22`,
267
+ background: `${s.color}08`,
268
+ }}>
269
+ <div style={{ fontWeight: 600, color: s.color, fontSize: 13 }}>
270
+ {s.icon} {s.key}: {s.label}
271
+ </div>
272
+ <div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>{s.desc}</div>
273
+ </div>
274
+ </Col>
275
+ ))}
276
+ </Row>
277
+ </Card>
278
+ </Col>
279
+
280
+ {/* Right: transition history and Skill info */}
281
+ <Col xs={24} lg={10}>
282
+ {selected && (
283
+ <motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }}>
284
+ <Card
285
+ title={`${selected.name} Info`}
286
+ variant="borderless"
287
+ style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }}
288
+ >
289
+ <Space orientation="vertical" style={{ width: '100%' }}>
290
+ <div>
291
+ <Text type="secondary">Type:</Text>
292
+ <Tag color={selected.skill_type === 'atomic' ? 'blue' : selected.skill_type === 'functional' ? 'purple' : 'gold'}>
293
+ {selected.skill_type.toUpperCase()}
294
+ </Tag>
295
+ </div>
296
+ <div>
297
+ <Text type="secondary">Version:</Text>
298
+ <Text code>{selected.version}</Text>
299
+ </div>
300
+ <div>
301
+ <Text type="secondary">Description:</Text>
302
+ <Paragraph style={{ margin: 0 }}>{selected.description}</Paragraph>
303
+ </div>
304
+ <div>
305
+ <Text type="secondary">Tags:</Text>
306
+ {selected.tags.map(t => <Tag key={t}>{t}</Tag>)}
307
+ </div>
308
+ <div>
309
+ <Text type="secondary">Executions:</Text>
310
+ <Text strong>{selected.metrics.total_executions}</Text>
311
+ <Text type="secondary" style={{ marginLeft: 8 }}>
312
+ Success rate {(selected.metrics.success_rate * 100).toFixed(1)}%
313
+ </Text>
314
+ </div>
315
+ </Space>
316
+ </Card>
317
+
318
+ <Card
319
+ title="State Transition History"
320
+ variant="borderless"
321
+ style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}
322
+ >
323
+ <AnimatePresence>
324
+ <Timeline
325
+ items={history.map((h, i) => {
326
+ const info = STATES.find(x => x.key === h.state)
327
+ return {
328
+ color: info?.color || '#1677ff',
329
+ dot: i === 0 ? <SyncOutlined spin style={{ color: info?.color }} /> : undefined,
330
+ children: (
331
+ <motion.div
332
+ key={`${h.state}-${h.time}`}
333
+ initial={{ opacity: 0, x: -10 }}
334
+ animate={{ opacity: 1, x: 0 }}
335
+ >
336
+ <div style={{ fontWeight: i === 0 ? 700 : 400 }}>
337
+ {info?.icon} {h.label}
338
+ </div>
339
+ <div style={{ fontSize: 11, color: '#999' }}>{h.time}</div>
340
+ </motion.div>
341
+ ),
342
+ }
343
+ })}
344
+ />
345
+ </AnimatePresence>
346
+ </Card>
347
+ </motion.div>
348
+ )}
349
+ </Col>
350
+ </Row>
351
+ </div>
352
+ )
353
+ }
skillwiki-frontend/src/pages/SelfEvolutionDemo.tsx ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import {
3
+ Alert,
4
+ Button,
5
+ Card,
6
+ Col,
7
+ Divider,
8
+ Empty,
9
+ Input,
10
+ Progress,
11
+ Row,
12
+ Space,
13
+ Spin,
14
+ Steps,
15
+ Tag,
16
+ Timeline,
17
+ Tooltip,
18
+ Typography,
19
+ } from 'antd'
20
+ import {
21
+ ApartmentOutlined,
22
+ ArrowRightOutlined,
23
+ BranchesOutlined,
24
+ BulbOutlined,
25
+ CheckCircleFilled,
26
+ ClockCircleOutlined,
27
+ DatabaseOutlined,
28
+ HistoryOutlined,
29
+ LoadingOutlined,
30
+ ReloadOutlined,
31
+ RocketOutlined,
32
+ SearchOutlined,
33
+ StarOutlined,
34
+ ThunderboltOutlined,
35
+ } from '@ant-design/icons'
36
+ import { motion, AnimatePresence } from 'framer-motion'
37
+ import { useNavigate } from 'react-router-dom'
38
+ import { executionApi } from '@/api/client'
39
+ import { getApiErrorMessage } from '@/api/errors'
40
+ import type { ExecutionResult, RetrievedSkill } from '@/api/types'
41
+
42
+ const { TextArea } = Input
43
+ const { Text, Paragraph } = Typography
44
+
45
+ const SKILL_TYPE_COLOR: Record<string, string> = {
46
+ atomic: '#1677ff',
47
+ functional: '#722ed1',
48
+ strategic: '#faad14',
49
+ }
50
+
51
+ const DEMO_TASKS = [
52
+ 'Complete a login flow on a web page and record reusable execution steps',
53
+ 'Extract a reusable operation pattern from an execution trace and promote it into a Skill',
54
+ 'Inspect why a Skill failed and propose a repair',
55
+ 'Compose existing Skills to complete a new automation task',
56
+ ]
57
+
58
+ type Phase =
59
+ | 'idle'
60
+ | 'searching'
61
+ | 'planning'
62
+ | 'executing'
63
+ | 'recording'
64
+ | 'learning'
65
+ | 'done'
66
+
67
+ const PHASE_STEPS = [
68
+ { title: 'Skill Retrieval', description: 'Recall relevant capabilities from the Skill Wiki', icon: <SearchOutlined /> },
69
+ { title: 'Plan Generation', description: 'Decompose the goal into executable steps', icon: <BulbOutlined /> },
70
+ { title: 'Execution', description: 'Invoke Skills according to the plan and collect results', icon: <ThunderboltOutlined /> },
71
+ { title: 'Experience Recording', description: 'Write the execution trace into the experience layer', icon: <DatabaseOutlined /> },
72
+ { title: 'Evolution Learning', description: 'Update quality signals and prepare for reuse', icon: <StarOutlined /> },
73
+ ]
74
+
75
+ const phaseIndex: Record<Phase, number> = {
76
+ idle: -1,
77
+ searching: 0,
78
+ planning: 1,
79
+ executing: 2,
80
+ recording: 3,
81
+ learning: 4,
82
+ done: 4,
83
+ }
84
+
85
+ function sleep(ms: number) {
86
+ return new Promise(resolve => setTimeout(resolve, ms))
87
+ }
88
+
89
+ function getStatusColor(status: string) {
90
+ if (status === 'success') return 'green'
91
+ if (status === 'failed' || status === 'error') return 'red'
92
+ if (status === 'partial') return 'gold'
93
+ return 'blue'
94
+ }
95
+
96
+ function SuggestedSkill({ suggested }: { suggested: Record<string, unknown> }) {
97
+ const name = String(suggested.name || suggested.skill_name || 'Unnamed Skill')
98
+ const description = String(suggested.description || suggested.summary || 'The backend suggested a new Skill candidate to retain.')
99
+ const type = String(suggested.skill_type || suggested.type || 'candidate')
100
+
101
+ return (
102
+ <Card
103
+ title={<span><BulbOutlined style={{ color: '#faad14', marginRight: 6 }} />Suggested Skill to Retain</span>}
104
+ size="small"
105
+ style={{ borderRadius: 8, marginTop: 12, borderLeft: '3px solid #faad14' }}
106
+ >
107
+ <Space orientation="vertical" size={6} style={{ width: '100%' }}>
108
+ <Space>
109
+ <Text strong>{name}</Text>
110
+ <Tag color="gold">{type}</Tag>
111
+ </Space>
112
+ <Paragraph type="secondary" style={{ marginBottom: 0 }}>
113
+ {description}
114
+ </Paragraph>
115
+ </Space>
116
+ </Card>
117
+ )
118
+ }
119
+
120
+ export default function SelfEvolutionDemo() {
121
+ const navigate = useNavigate()
122
+ const [task, setTask] = useState('')
123
+ const [phase, setPhase] = useState<Phase>('idle')
124
+ const [result, setResult] = useState<ExecutionResult | null>(null)
125
+ const [error, setError] = useState<string | null>(null)
126
+ const [visibleSteps, setVisibleSteps] = useState(0)
127
+
128
+ const runDemo = async () => {
129
+ const goal = task.trim()
130
+ if (!goal) return
131
+
132
+ setError(null)
133
+ setResult(null)
134
+ setVisibleSteps(0)
135
+
136
+ try {
137
+ setPhase('searching')
138
+ await sleep(450)
139
+ setPhase('planning')
140
+ await sleep(400)
141
+ setPhase('executing')
142
+
143
+ const res = await executionApi.executePlan(goal)
144
+ setResult(res)
145
+
146
+ for (let index = 1; index <= res.steps.length; index += 1) {
147
+ await sleep(260)
148
+ setVisibleSteps(index)
149
+ }
150
+
151
+ setPhase('recording')
152
+ await sleep(550)
153
+ setPhase('learning')
154
+ await sleep(450)
155
+ setPhase('done')
156
+ } catch (err) {
157
+ setError(getApiErrorMessage(err, 'Self-evolution demo failed'))
158
+ setPhase('idle')
159
+ }
160
+ }
161
+
162
+ const reset = () => {
163
+ setPhase('idle')
164
+ setResult(null)
165
+ setError(null)
166
+ setVisibleSteps(0)
167
+ setTask('')
168
+ }
169
+
170
+ const goToWiki = (skillId: string) => {
171
+ navigate(`/wiki?skill_id=${encodeURIComponent(skillId)}`)
172
+ }
173
+
174
+ const goToGraph = (skillId: string) => {
175
+ navigate(`/graph?skill_id=${encodeURIComponent(skillId)}`)
176
+ }
177
+
178
+ const isRunning = phase !== 'idle' && phase !== 'done'
179
+ const retrieved = result?.retrieved_skills || []
180
+ const successCount = result?.steps.filter(step => step.status === 'success').length || 0
181
+ const stepCount = result?.steps.length || 0
182
+ const currentStep = phaseIndex[phase]
183
+ const experienceRecorded = result?.experience_recorded === true
184
+
185
+ return (
186
+ <div style={{ padding: 24, maxWidth: 1120, margin: '0 auto' }}>
187
+ <motion.div initial={{ opacity: 0, y: -12 }} animate={{ opacity: 1, y: 0 }}>
188
+ <div style={{ marginBottom: 24 }}>
189
+ <h2 style={{ fontWeight: 800, fontSize: 22, marginBottom: 4 }}>
190
+ Self-Evolution Loop Demo
191
+ </h2>
192
+ <Paragraph type="secondary" style={{ marginBottom: 0 }}>
193
+ Enter a task goal and watch SkillWiki retrieve Skills, generate a plan, execute it, record experience, and update evolution signals.
194
+ </Paragraph>
195
+ </div>
196
+ </motion.div>
197
+
198
+ <Card variant="borderless" style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 16 }}>
199
+ <TextArea
200
+ value={task}
201
+ onChange={event => setTask(event.target.value)}
202
+ placeholder="Describe your task goal, for example: extract a reusable Skill from an execution trace"
203
+ rows={2}
204
+ disabled={isRunning}
205
+ style={{ fontSize: 14, marginBottom: 10 }}
206
+ />
207
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
208
+ <Space wrap>
209
+ <Text type="secondary" style={{ fontSize: 12 }}>Examples:</Text>
210
+ {DEMO_TASKS.map(example => (
211
+ <Tag
212
+ key={example}
213
+ style={{ cursor: isRunning ? 'not-allowed' : 'pointer', fontSize: 12 }}
214
+ onClick={() => !isRunning && setTask(example)}
215
+ >
216
+ {example}
217
+ </Tag>
218
+ ))}
219
+ </Space>
220
+ <Space>
221
+ {phase === 'done' && (
222
+ <Button icon={<ReloadOutlined />} onClick={reset}>
223
+ Reset
224
+ </Button>
225
+ )}
226
+ <Button
227
+ type="primary"
228
+ icon={isRunning ? <LoadingOutlined /> : <RocketOutlined />}
229
+ onClick={runDemo}
230
+ loading={isRunning}
231
+ disabled={!task.trim() || phase === 'done'}
232
+ size="large"
233
+ >
234
+ {phase === 'done' ? 'Completed' : 'Start Evolution Loop'}
235
+ </Button>
236
+ </Space>
237
+ </div>
238
+ </Card>
239
+
240
+ {phase !== 'idle' && (
241
+ <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} style={{ marginBottom: 16 }}>
242
+ <Card variant="borderless" style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
243
+ <Steps
244
+ current={currentStep}
245
+ status={phase === 'done' ? 'finish' : 'process'}
246
+ size="small"
247
+ items={PHASE_STEPS.map((step, index) => ({
248
+ title: step.title,
249
+ description: step.description,
250
+ icon: currentStep > index
251
+ ? <CheckCircleFilled style={{ color: '#52c41a' }} />
252
+ : currentStep === index
253
+ ? <LoadingOutlined style={{ color: '#1677ff' }} />
254
+ : <ClockCircleOutlined style={{ color: '#d9d9d9' }} />,
255
+ }))}
256
+ />
257
+ </Card>
258
+ </motion.div>
259
+ )}
260
+
261
+ {error && (
262
+ <Alert
263
+ type="error"
264
+ title={error}
265
+ showIcon
266
+ style={{ marginBottom: 16 }}
267
+ closable
268
+ onClose={() => setError(null)}
269
+ />
270
+ )}
271
+
272
+ <AnimatePresence>
273
+ {result && (
274
+ <motion.div key="result" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
275
+ <Row gutter={[16, 16]}>
276
+ <Col xs={24} lg={14}>
277
+ {retrieved.length > 0 && (
278
+ <motion.div initial={{ opacity: 0, x: -16 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.1 }}>
279
+ <Card
280
+ title={<span><SearchOutlined style={{ color: '#1677ff', marginRight: 6 }} />Retrieved Skills ({retrieved.length})</span>}
281
+ variant="borderless"
282
+ size="small"
283
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 12 }}
284
+ >
285
+ {retrieved.map((skill: RetrievedSkill, index) => (
286
+ <motion.div
287
+ key={skill.skill_id}
288
+ initial={{ opacity: 0, x: -12 }}
289
+ animate={{ opacity: 1, x: 0 }}
290
+ transition={{ delay: index * 0.06 }}
291
+ style={{
292
+ display: 'flex',
293
+ alignItems: 'center',
294
+ gap: 10,
295
+ padding: '10px 0',
296
+ borderBottom: index < retrieved.length - 1 ? '1px solid #f0f0f0' : 'none',
297
+ }}
298
+ >
299
+ <div style={{ flex: 1, minWidth: 0 }}>
300
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4, flexWrap: 'wrap' }}>
301
+ <Text strong style={{ fontSize: 13 }}>{skill.name}</Text>
302
+ <Tag color={SKILL_TYPE_COLOR[skill.skill_type]} style={{ fontSize: 11 }}>
303
+ {skill.skill_type}
304
+ </Tag>
305
+ </div>
306
+ <Text type="secondary" style={{ fontSize: 12 }}>{skill.match_reason || skill.description}</Text>
307
+ <div style={{ marginTop: 6 }}>
308
+ <Space size={6}>
309
+ <Button size="small" icon={<ApartmentOutlined />} onClick={() => goToWiki(skill.skill_id)}>
310
+ Wiki
311
+ </Button>
312
+ <Button size="small" icon={<BranchesOutlined />} onClick={() => goToGraph(skill.skill_id)}>
313
+ Graph
314
+ </Button>
315
+ </Space>
316
+ </div>
317
+ </div>
318
+ <Tooltip title={`Relevance ${Math.round(skill.score * 100)}%`}>
319
+ <Progress
320
+ type="circle"
321
+ percent={Math.round(skill.score * 100)}
322
+ width={40}
323
+ strokeColor={skill.score > 0.7 ? '#52c41a' : skill.score > 0.4 ? '#faad14' : '#ff4d4f'}
324
+ format={percent => <span style={{ fontSize: 10 }}>{percent}%</span>}
325
+ />
326
+ </Tooltip>
327
+ </motion.div>
328
+ ))}
329
+ </Card>
330
+ </motion.div>
331
+ )}
332
+
333
+ <motion.div initial={{ opacity: 0, x: -16 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.2 }}>
334
+ <Card
335
+ title={<span><ThunderboltOutlined style={{ color: '#722ed1', marginRight: 6 }} />Execution Steps</span>}
336
+ variant="borderless"
337
+ size="small"
338
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}
339
+ >
340
+ {result.steps.length === 0 ? (
341
+ <Alert type="warning" showIcon title="No executable Skill was found. Try a more specific task description." />
342
+ ) : (
343
+ result.steps.slice(0, visibleSteps).map((step, index) => (
344
+ <motion.div
345
+ key={step.step_id}
346
+ initial={{ opacity: 0, y: 8 }}
347
+ animate={{ opacity: 1, y: 0 }}
348
+ transition={{ duration: 0.25 }}
349
+ style={{
350
+ display: 'flex',
351
+ alignItems: 'center',
352
+ gap: 10,
353
+ padding: '10px 0',
354
+ borderBottom: index < result.steps.length - 1 ? '1px solid #f0f0f0' : 'none',
355
+ }}
356
+ >
357
+ {step.status === 'success'
358
+ ? <CheckCircleFilled style={{ color: '#52c41a', fontSize: 16 }} />
359
+ : <ClockCircleOutlined style={{ color: '#ff4d4f', fontSize: 16 }} />}
360
+ <div style={{ flex: 1, minWidth: 0 }}>
361
+ <Text strong style={{ fontSize: 13 }}>{step.skill_name}</Text>
362
+ <div style={{ color: '#8c8c8c', fontSize: 11 }}>
363
+ Step {step.step_index + 1} / Skill ID: {step.skill_id}
364
+ </div>
365
+ {step.error && (
366
+ <div style={{ fontSize: 12, color: '#ff4d4f', marginTop: 2 }}>{step.error}</div>
367
+ )}
368
+ </div>
369
+ <Text type="secondary" style={{ fontSize: 11 }}>{(step.latency_ms ?? 0).toFixed(0)}ms</Text>
370
+ <Tag color={getStatusColor(step.status)} style={{ fontSize: 11 }}>
371
+ {step.status}
372
+ </Tag>
373
+ </motion.div>
374
+ ))
375
+ )}
376
+ {visibleSteps < result.steps.length && (
377
+ <div style={{ textAlign: 'center', padding: 8 }}>
378
+ <Spin size="small" />
379
+ </div>
380
+ )}
381
+ </Card>
382
+ </motion.div>
383
+ </Col>
384
+
385
+ <Col xs={24} lg={10}>
386
+ <motion.div initial={{ opacity: 0, x: 16 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.15 }}>
387
+ <Card
388
+ variant="borderless"
389
+ size="small"
390
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 12 }}
391
+ >
392
+ <Row gutter={8}>
393
+ {[
394
+ { label: 'Steps', value: stepCount, color: '#1677ff' },
395
+ { label: 'Succeeded', value: successCount, color: '#52c41a' },
396
+ { label: 'Latency', value: `${result.total_latency_ms.toFixed(0)}ms`, color: '#722ed1' },
397
+ ].map(({ label, value, color }) => (
398
+ <Col span={8} key={label} style={{ textAlign: 'center' }}>
399
+ <div style={{ fontSize: 20, fontWeight: 700, color }}>{value}</div>
400
+ <Text type="secondary" style={{ fontSize: 11 }}>{label}</Text>
401
+ </Col>
402
+ ))}
403
+ </Row>
404
+ <Divider style={{ margin: '12px 0' }} />
405
+ <Space wrap>
406
+ <Tag color={getStatusColor(result.status)}>Execution status: {result.status}</Tag>
407
+ {experienceRecorded ? (
408
+ <Tag color="green">Experience recorded</Tag>
409
+ ) : (
410
+ <Tag>Experience recording pending</Tag>
411
+ )}
412
+ </Space>
413
+ </Card>
414
+ </motion.div>
415
+
416
+ <AnimatePresence>
417
+ {(phase === 'recording' || phase === 'learning' || phase === 'done') && (
418
+ <motion.div initial={{ opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }}>
419
+ <Card
420
+ title={<span><DatabaseOutlined style={{ color: '#52c41a', marginRight: 6 }} />Experience Recording</span>}
421
+ variant="borderless"
422
+ size="small"
423
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 12, borderLeft: '3px solid #52c41a' }}
424
+ >
425
+ <Timeline
426
+ items={[
427
+ {
428
+ color: 'green',
429
+ children: <Text style={{ fontSize: 12 }}>Execution trace summarized: {result.steps.length} steps</Text>,
430
+ },
431
+ {
432
+ color: experienceRecorded ? 'green' : 'gray',
433
+ children: <Text style={{ fontSize: 12 }}>{experienceRecorded ? 'Experience unit written to the Experience Store' : 'Backend has not confirmed experience recording yet'}</Text>,
434
+ },
435
+ {
436
+ color: 'blue',
437
+ children: <Text style={{ fontSize: 12 }}>Final state fields: {Object.keys(result.final_state || {}).length}</Text>,
438
+ },
439
+ ]}
440
+ />
441
+ </Card>
442
+ </motion.div>
443
+ )}
444
+ </AnimatePresence>
445
+
446
+ <AnimatePresence>
447
+ {(phase === 'learning' || phase === 'done') && (
448
+ <motion.div initial={{ opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.1 }}>
449
+ <Card
450
+ title={<span><StarOutlined style={{ color: '#faad14', marginRight: 6 }} />Evolution Learning</span>}
451
+ variant="borderless"
452
+ size="small"
453
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: 12, borderLeft: '3px solid #faad14' }}
454
+ >
455
+ {retrieved.length > 0 ? (
456
+ <div style={{ marginBottom: 8 }}>
457
+ {retrieved.slice(0, 3).map(skill => (
458
+ <div key={skill.skill_id} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
459
+ <ArrowRightOutlined style={{ color: '#52c41a', fontSize: 10 }} />
460
+ <Text style={{ fontSize: 12 }}>Reuse signals updated for <strong>{skill.name}</strong></Text>
461
+ </div>
462
+ ))}
463
+ </div>
464
+ ) : (
465
+ <Text type="secondary" style={{ fontSize: 12 }}>No reusable Skill was retrieved this time. Future experience retention can fill this capability gap.</Text>
466
+ )}
467
+ <Divider style={{ margin: '8px 0' }} />
468
+ <Text type="secondary" style={{ fontSize: 12 }}>
469
+ This execution result can be used for health assessment, version evolution, and future reuse on similar tasks.
470
+ </Text>
471
+ </Card>
472
+ </motion.div>
473
+ )}
474
+ </AnimatePresence>
475
+
476
+ {result.suggested_skill && (
477
+ <SuggestedSkill suggested={result.suggested_skill} />
478
+ )}
479
+
480
+ <AnimatePresence>
481
+ {phase === 'done' && (
482
+ <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
483
+ <Alert
484
+ type="success"
485
+ icon={<CheckCircleFilled />}
486
+ title="Self-Evolution Loop Completed"
487
+ description={(
488
+ <Space orientation="vertical" size={8}>
489
+ <Text style={{ fontSize: 12 }}>
490
+ SkillWiki completed the full loop for this task: retrieval, planning, execution, recording, and learning.
491
+ </Text>
492
+ <Button size="small" icon={<HistoryOutlined />} onClick={() => navigate('/execution')}>
493
+ View Execution History
494
+ </Button>
495
+ </Space>
496
+ )}
497
+ showIcon
498
+ />
499
+ </motion.div>
500
+ )}
501
+ </AnimatePresence>
502
+ </Col>
503
+ </Row>
504
+ </motion.div>
505
+ )}
506
+ </AnimatePresence>
507
+
508
+ {phase === 'idle' && !result && (
509
+ <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }}>
510
+ <Card variant="borderless" style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', textAlign: 'center', padding: '32px 0' }}>
511
+ <Empty
512
+ image={<RocketOutlined style={{ fontSize: 48, color: '#1677ff' }} />}
513
+ description={(
514
+ <div>
515
+ <div style={{ fontWeight: 600, fontSize: 16, marginBottom: 8 }}>Self-Evolution Loop</div>
516
+ <Paragraph type="secondary" style={{ maxWidth: 520, margin: '0 auto', fontSize: 13 }}>
517
+ Through the loop of retrieving Skills, executing tasks, recording experience, and feeding evolution signals back, SkillWiki turns each task result into reusable capability evidence.
518
+ </Paragraph>
519
+ </div>
520
+ )}
521
+ />
522
+ <div style={{ marginTop: 20, display: 'flex', justifyContent: 'center', gap: 8, flexWrap: 'wrap' }}>
523
+ {PHASE_STEPS.map((step, index) => (
524
+ <div key={step.title} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
525
+ <Tag color="blue" style={{ fontSize: 12 }}>{step.icon} {step.title}</Tag>
526
+ {index < PHASE_STEPS.length - 1 && <ArrowRightOutlined style={{ color: '#d9d9d9', fontSize: 10 }} />}
527
+ </div>
528
+ ))}
529
+ </div>
530
+ </Card>
531
+ </motion.div>
532
+ )}
533
+ </div>
534
+ )
535
+ }
skillwiki-frontend/src/pages/SkillGraph.tsx ADDED
@@ -0,0 +1,1389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import {
3
+ Alert,
4
+ Button,
5
+ Card,
6
+ Descriptions,
7
+ Empty,
8
+ InputNumber,
9
+ Popover,
10
+ Select,
11
+ Segmented,
12
+ Slider,
13
+ Space,
14
+ Spin,
15
+ Switch,
16
+ Tag,
17
+ Tooltip,
18
+ Typography,
19
+ } from 'antd'
20
+ import {
21
+ AimOutlined,
22
+ ExportOutlined,
23
+ ReloadOutlined,
24
+ RollbackOutlined,
25
+ SettingOutlined,
26
+ ShareAltOutlined,
27
+ ZoomInOutlined,
28
+ ZoomOutOutlined,
29
+ } from '@ant-design/icons'
30
+ import { useLocation, useNavigate } from 'react-router-dom'
31
+ import { graphApi } from '@/api/client'
32
+ import { getApiErrorMessage } from '@/api/errors'
33
+ import type {
34
+ GraphData,
35
+ GraphViewData,
36
+ GraphViewEdgeData,
37
+ GraphViewMode,
38
+ GraphViewNodeData,
39
+ } from '@/api/types'
40
+
41
+ const { Text, Paragraph } = Typography
42
+
43
+ const TYPE_COLOR: Record<string, string> = {
44
+ atomic: '#1677ff',
45
+ functional: '#722ed1',
46
+ strategic: '#fa8c16',
47
+ }
48
+
49
+ const KIND_COLOR: Record<string, string> = {
50
+ source: '#08979c',
51
+ trajectory: '#08979c',
52
+ skill: '#1677ff',
53
+ execution: '#2f54eb',
54
+ validation: '#52c41a',
55
+ version: '#fa8c16',
56
+ }
57
+
58
+ const KIND_LABEL: Record<string, string> = {
59
+ source: 'Source / Trajectory',
60
+ trajectory: 'Trajectory',
61
+ skill: 'Skill',
62
+ execution: 'Execution',
63
+ validation: 'Validation',
64
+ version: 'Version',
65
+ }
66
+
67
+ const STATE_OPACITY: Record<string, number> = {
68
+ S0: 0.4,
69
+ S1: 0.5,
70
+ S2: 0.6,
71
+ S3: 0.75,
72
+ S4: 1,
73
+ S5: 0.85,
74
+ S6: 0.3,
75
+ S7: 0.2,
76
+ }
77
+
78
+ const STATE_LABEL: Record<string, string> = {
79
+ S0: 'Raw',
80
+ S1: 'Candidate',
81
+ S2: 'Draft',
82
+ S3: 'Verified',
83
+ S4: 'Released',
84
+ S5: 'Degraded',
85
+ S6: 'Deprecated',
86
+ S7: 'Archived',
87
+ }
88
+
89
+ const EDGE_COLOR: Record<string, string> = {
90
+ depends_on: '#1677ff',
91
+ composes_with: '#722ed1',
92
+ similar_to: '#52c41a',
93
+ evolved_from: '#fa8c16',
94
+ conflicts_with: '#ff4d4f',
95
+ replaces: '#eb2f96',
96
+ specializes: '#13c2c2',
97
+ generalizes: '#d4a106',
98
+ derived_from: '#08979c',
99
+ executed_as: '#2f54eb',
100
+ validated_by: '#52c41a',
101
+ versioned_as: '#fa8c16',
102
+ }
103
+
104
+ const GRAPH_VIEW_OPTIONS: { label: string; value: GraphViewMode }[] = [
105
+ { label: 'Skill-only', value: 'skill_only' },
106
+ { label: 'Provenance', value: 'provenance' },
107
+ { label: 'Version impact', value: 'version_impact' },
108
+ ]
109
+
110
+ const GRAPH_VIEW_COPY: Record<GraphViewMode, { subtitle: string; empty: string; reload: string }> = {
111
+ skill_only: {
112
+ subtitle: 'Skill dependency, composition, similarity, and evolution links.',
113
+ empty: 'No Skill graph data is available.',
114
+ reload: 'Reload Skill graph',
115
+ },
116
+ provenance: {
117
+ subtitle: 'Typed Source, Skill, Execution, Validation, and Version evidence chain.',
118
+ empty: 'No heterogeneous provenance graph data is available.',
119
+ reload: 'Reload provenance graph',
120
+ },
121
+ version_impact: {
122
+ subtitle: 'Meta-path projection with version, shared-source, and validation evidence.',
123
+ empty: 'No projected version-impact graph data is available.',
124
+ reload: 'Reload impact view',
125
+ },
126
+ }
127
+
128
+ type GraphMode = 'full' | 'subgraph'
129
+
130
+ type GraphLayoutSettings = {
131
+ repulsion: number
132
+ attraction: number
133
+ linkDistance: number
134
+ nodeSpacing: number
135
+ nodeSize: number
136
+ edgeWidth: number
137
+ edgeOpacity: number
138
+ labelMode: GraphLabelMode
139
+ edgeLabelMode: GraphLabelMode
140
+ denseMode: boolean
141
+ }
142
+
143
+ type GraphLabelMode = 'always' | 'hover' | 'selected' | 'hidden'
144
+ type NumericGraphLayoutField = {
145
+ [K in keyof GraphLayoutSettings]: GraphLayoutSettings[K] extends number ? K : never
146
+ }[keyof GraphLayoutSettings]
147
+
148
+ type GraphCanvasSize = {
149
+ width: number
150
+ height: number
151
+ }
152
+
153
+ type GraphEvent = {
154
+ target?: { id?: string }
155
+ }
156
+
157
+ type GraphInstance = {
158
+ destroy: () => void
159
+ draw: () => void | Promise<void>
160
+ fitView: (options?: unknown, animation?: unknown) => void | Promise<void>
161
+ getZoom: () => number
162
+ on: (eventName: string, handler: (event: GraphEvent) => void) => void
163
+ render: () => void | Promise<void>
164
+ updateEdgeData: (edges: Array<{ id: string; style: { badgeText: string } }>) => void
165
+ updateNodeData: (nodes: Array<{ id: string; style: Record<string, unknown> }>) => void
166
+ zoomBy: (ratio: number, animation?: unknown) => void | Promise<void>
167
+ }
168
+
169
+ type ForceEdgeDatum = {
170
+ data?: { weight?: number }
171
+ weight?: number
172
+ }
173
+
174
+ const GRAPH_LAYOUT_STORAGE_KEY = 'skillwiki.graph.layoutSettings.v2'
175
+ const EDGE_LABEL_ZOOM_THRESHOLD = 0.9
176
+ const NEBULA_ZOOM_THRESHOLD = 0.65
177
+
178
+ const DEFAULT_GRAPH_LAYOUT: GraphLayoutSettings = {
179
+ repulsion: 260,
180
+ attraction: 0.28,
181
+ linkDistance: 165,
182
+ nodeSpacing: 38,
183
+ nodeSize: 12,
184
+ edgeWidth: 0.9,
185
+ edgeOpacity: 0.34,
186
+ labelMode: 'hover',
187
+ edgeLabelMode: 'selected',
188
+ denseMode: true,
189
+ }
190
+ const GRAPH_LAYOUT_PRESETS: Record<string, GraphLayoutSettings> = {
191
+ nebula: DEFAULT_GRAPH_LAYOUT,
192
+ readable: {
193
+ repulsion: 190,
194
+ attraction: 0.36,
195
+ linkDistance: 150,
196
+ nodeSpacing: 52,
197
+ nodeSize: 20,
198
+ edgeWidth: 1.4,
199
+ edgeOpacity: 0.62,
200
+ labelMode: 'always',
201
+ edgeLabelMode: 'hover',
202
+ denseMode: false,
203
+ },
204
+ debug: {
205
+ repulsion: 240,
206
+ attraction: 0.42,
207
+ linkDistance: 190,
208
+ nodeSpacing: 64,
209
+ nodeSize: 24,
210
+ edgeWidth: 2,
211
+ edgeOpacity: 0.86,
212
+ labelMode: 'always',
213
+ edgeLabelMode: 'always',
214
+ denseMode: false,
215
+ },
216
+ }
217
+
218
+ const LABEL_MODE_OPTIONS: { label: string; value: GraphLabelMode }[] = [
219
+ { label: 'Always', value: 'always' },
220
+ { label: 'Hover', value: 'hover' },
221
+ { label: 'Selected', value: 'selected' },
222
+ { label: 'Hidden', value: 'hidden' },
223
+ ]
224
+
225
+ function clampNumber(value: unknown, min: number, max: number, fallback: number) {
226
+ const numeric = Number(value)
227
+ if (!Number.isFinite(numeric)) return fallback
228
+ return Math.min(max, Math.max(min, numeric))
229
+ }
230
+
231
+ function normalizeLabelMode(value: unknown, fallback: GraphLabelMode): GraphLabelMode {
232
+ return value === 'always' || value === 'hover' || value === 'selected' || value === 'hidden'
233
+ ? value
234
+ : fallback
235
+ }
236
+
237
+ function normalizeLayoutSettings(value: Partial<GraphLayoutSettings> = {}): GraphLayoutSettings {
238
+ return {
239
+ repulsion: clampNumber(value.repulsion, 40, 400, DEFAULT_GRAPH_LAYOUT.repulsion),
240
+ attraction: clampNumber(value.attraction, 0.05, 1, DEFAULT_GRAPH_LAYOUT.attraction),
241
+ linkDistance: clampNumber(value.linkDistance, 60, 280, DEFAULT_GRAPH_LAYOUT.linkDistance),
242
+ nodeSpacing: clampNumber(value.nodeSpacing, 28, 96, DEFAULT_GRAPH_LAYOUT.nodeSpacing),
243
+ nodeSize: clampNumber(value.nodeSize, 6, 42, DEFAULT_GRAPH_LAYOUT.nodeSize),
244
+ edgeWidth: clampNumber(value.edgeWidth, 0.4, 4, DEFAULT_GRAPH_LAYOUT.edgeWidth),
245
+ edgeOpacity: clampNumber(value.edgeOpacity, 0.08, 1, DEFAULT_GRAPH_LAYOUT.edgeOpacity),
246
+ labelMode: normalizeLabelMode(value.labelMode, DEFAULT_GRAPH_LAYOUT.labelMode),
247
+ edgeLabelMode: normalizeLabelMode(value.edgeLabelMode, DEFAULT_GRAPH_LAYOUT.edgeLabelMode),
248
+ denseMode: typeof value.denseMode === 'boolean' ? value.denseMode : DEFAULT_GRAPH_LAYOUT.denseMode,
249
+ }
250
+ }
251
+
252
+ function loadLayoutSettings(): GraphLayoutSettings {
253
+ if (typeof window === 'undefined') return DEFAULT_GRAPH_LAYOUT
254
+ try {
255
+ const stored = window.localStorage.getItem(GRAPH_LAYOUT_STORAGE_KEY)
256
+ return stored ? normalizeLayoutSettings(JSON.parse(stored)) : DEFAULT_GRAPH_LAYOUT
257
+ } catch {
258
+ return DEFAULT_GRAPH_LAYOUT
259
+ }
260
+ }
261
+
262
+ function saveLayoutSettings(settings: GraphLayoutSettings) {
263
+ if (typeof window === 'undefined') return
264
+ try {
265
+ window.localStorage.setItem(GRAPH_LAYOUT_STORAGE_KEY, JSON.stringify(settings))
266
+ } catch {
267
+ // Layout persistence is optional.
268
+ }
269
+ }
270
+
271
+ function formatPercent(value?: number | null) {
272
+ if (!Number.isFinite(Number(value))) return 'N/A'
273
+ return `${Math.round(Number(value) * 100)}%`
274
+ }
275
+
276
+ function formatEdgeType(edgeType: string) {
277
+ return edgeType.replace(/_/g, ' ')
278
+ }
279
+
280
+ function formatKind(kind?: string) {
281
+ if (!kind) return 'Skill'
282
+ return KIND_LABEL[kind] || kind
283
+ }
284
+
285
+ function nodeColor(node?: GraphViewNodeData) {
286
+ if (!node) return '#8c8c8c'
287
+ if (node.kind && KIND_COLOR[node.kind]) return KIND_COLOR[node.kind]
288
+ return TYPE_COLOR[String(node.skill_type || '')] || '#8c8c8c'
289
+ }
290
+
291
+ // Parse "#rrggbb" → [r, g, b]
292
+ function hexToRgb(hex: string): [number, number, number] {
293
+ const h = hex.replace('#', '')
294
+ const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
295
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
296
+ }
297
+
298
+ // Blend color toward white (highlight) or black (shadow)
299
+ function blendColor(hex: string, toward: 'white' | 'black', amount: number): string {
300
+ const [r, g, b] = hexToRgb(hex)
301
+ const t = toward === 'white' ? 255 : 0
302
+ const blend = (c: number) => Math.round(c + (t - c) * amount)
303
+ return `rgb(${blend(r)},${blend(g)},${blend(b)})`
304
+ }
305
+
306
+ // G6 v5 linear gradient: "l(angle) offset:color offset:color ..."
307
+ function sphereGradient(hex: string): string {
308
+ const hi = blendColor(hex, 'white', 0.55)
309
+ const mid = hex
310
+ const lo = blendColor(hex, 'black', 0.35)
311
+ // angle 135° = light from top-left
312
+ return `l(135) 0:${hi} 0.38:${mid} 1:${lo}`
313
+ }
314
+
315
+ // Depth-adjusted size and opacity for perspective illusion
316
+ function depthScale(depth: number): { sizeScale: number; opacityScale: number } {
317
+ // depth 0 = foreground (bigger, opaque), depth 1 = background (smaller, faint)
318
+ const sizeScale = 1.0 - depth * 0.55 // 1.0 → 0.45
319
+ const opacityScale = 1.0 - depth * 0.6 // 1.0 → 0.40
320
+ return { sizeScale, opacityScale }
321
+ }
322
+
323
+ function uniqueTags(tags?: string[]): string[] {
324
+ return Array.from(new Set((tags ?? []).filter(t => Boolean(t) && t !== 'mock')))
325
+ }
326
+
327
+ function stableRandom(seed: string) {
328
+ let hash = 2166136261
329
+ for (let index = 0; index < seed.length; index += 1) {
330
+ hash ^= seed.charCodeAt(index)
331
+ hash = Math.imul(hash, 16777619)
332
+ }
333
+ return (hash >>> 0) / 4294967295
334
+ }
335
+
336
+ function edgeWeight(edge?: ForceEdgeDatum) {
337
+ return clampNumber(edge?.data?.weight ?? edge?.weight, 0, 1, 1)
338
+ }
339
+
340
+ function weightedLinkDistance(edge: ForceEdgeDatum | undefined, baseDistance: number) {
341
+ const weight = edgeWeight(edge)
342
+ return Math.round(baseDistance * (1.4 - weight * 0.7))
343
+ }
344
+
345
+ function weightedEdgeStrength(edge: ForceEdgeDatum | undefined, baseAttraction: number) {
346
+ const weight = edgeWeight(edge)
347
+ return baseAttraction * (0.45 + weight * 0.75) * 80
348
+ }
349
+
350
+ function formatEdgeLabel(edge: GraphViewEdgeData) {
351
+ const score = edge.confidence ?? edge.weight
352
+ return `${formatEdgeType(edge.edge_type)} / ${score.toFixed(2)}`
353
+ }
354
+
355
+ function shouldShowNodeLabel(
356
+ mode: GraphLabelMode,
357
+ options: { selected: boolean; centered: boolean; denseMode: boolean },
358
+ ) {
359
+ const { selected, centered, denseMode } = options
360
+ if (mode === 'hidden') return false
361
+ if (mode === 'always') return true
362
+ if (mode === 'selected') return selected || centered
363
+ return denseMode ? selected || centered : true
364
+ }
365
+
366
+ function shouldShowEdgeLabels(
367
+ mode: GraphLabelMode,
368
+ zoom: number,
369
+ edges: GraphViewEdgeData[],
370
+ selectedEdgeId: string | null,
371
+ ) {
372
+ if (mode === 'hidden') return new Set<string>()
373
+ if (mode === 'always') return new Set(edges.map(edge => edge.id))
374
+ if (mode === 'selected') return new Set(selectedEdgeId ? [selectedEdgeId] : [])
375
+ if (zoom < EDGE_LABEL_ZOOM_THRESHOLD && edges.length > 24) {
376
+ return new Set(selectedEdgeId ? [selectedEdgeId] : [])
377
+ }
378
+ return new Set(edges.map(edge => edge.id))
379
+ }
380
+
381
+ function nodeVisualSize(node: GraphViewNodeData, settings: GraphLayoutSettings, selected: boolean, centered: boolean) {
382
+ const usageBoost = Math.min(8, Number(node.usage_count || 0) * 0.25)
383
+ const base = settings.nodeSize + usageBoost
384
+ if (selected || centered) return Math.max(18, base + 8)
385
+ return base
386
+ }
387
+
388
+ function nodeLabelFontSize(settings: GraphLayoutSettings, selected: boolean, centered: boolean) {
389
+ if (selected || centered) return 10
390
+ return settings.denseMode ? 7 : 8
391
+ }
392
+
393
+ function formatMetadataValue(value: unknown) {
394
+ if (value === null || value === undefined || value === '') return 'N/A'
395
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
396
+ if (typeof value === 'number' || typeof value === 'string') return String(value)
397
+ try {
398
+ return JSON.stringify(value, null, 2)
399
+ } catch {
400
+ return String(value)
401
+ }
402
+ }
403
+
404
+ function relationStrengthSummary(metadata?: Record<string, unknown>) {
405
+ const relationStrength = metadata?.relation_strength
406
+ if (!relationStrength || typeof relationStrength !== 'object' || Array.isArray(relationStrength)) return ''
407
+ const relation = relationStrength as Record<string, unknown>
408
+ const strong = relation.strong && typeof relation.strong === 'object' && !Array.isArray(relation.strong)
409
+ ? Object.keys(relation.strong as Record<string, unknown>).map(formatEdgeType).join(', ')
410
+ : ''
411
+ const weak = relation.weak && typeof relation.weak === 'object' && !Array.isArray(relation.weak)
412
+ ? Object.keys(relation.weak as Record<string, unknown>).map(formatEdgeType).join(', ')
413
+ : ''
414
+ const claimBoundary = typeof relation.claim_boundary === 'string' ? relation.claim_boundary : ''
415
+ return [
416
+ strong ? `Strong relations: ${strong}.` : '',
417
+ weak ? `Weak projected relations: ${weak}.` : '',
418
+ claimBoundary,
419
+ ].filter(Boolean).join(' ')
420
+ }
421
+
422
+ function metadataEntries(metadata?: Record<string, unknown>) {
423
+ const INTERNAL_KEYS = new Set(['synthetic', 'mock', 'depth', 'domain'])
424
+ return Object.entries(metadata ?? {}).filter(
425
+ ([key, value]) => !INTERNAL_KEYS.has(key) && value !== undefined && value !== null && value !== '',
426
+ )
427
+ }
428
+
429
+ function graphDataToView(data: GraphData): GraphViewData {
430
+ return {
431
+ view: 'skill_only',
432
+ source_endpoint: '/api/v1/graph/subgraph',
433
+ nodes: data.nodes.map(node => ({
434
+ id: node.id,
435
+ name: node.name,
436
+ kind: 'skill',
437
+ description: node.description || '',
438
+ skill_type: node.skill_type,
439
+ state: node.state,
440
+ tags: node.tags,
441
+ version: node.version,
442
+ granularity_level: node.granularity_level,
443
+ success_rate: node.success_rate,
444
+ usage_count: node.usage_count,
445
+ metadata: node.metadata || {},
446
+ })),
447
+ edges: data.edges.map(edge => ({
448
+ id: edge.id,
449
+ source: edge.source,
450
+ target: edge.target,
451
+ edge_type: edge.edge_type,
452
+ weight: edge.weight,
453
+ confidence: edge.confidence ?? null,
454
+ metadata: edge.metadata || {},
455
+ })),
456
+ stats: data.stats,
457
+ metadata: {},
458
+ validation_evidence: {},
459
+ }
460
+ }
461
+
462
+ function calculateInitialNodePositions(
463
+ nodes: GraphViewNodeData[],
464
+ edges: GraphViewEdgeData[],
465
+ canvasSize: GraphCanvasSize,
466
+ settings: GraphLayoutSettings,
467
+ ) {
468
+ const positions = new Map<string, { x: number; y: number }>()
469
+ if (nodes.length === 0) return positions
470
+
471
+ const width = Math.max(320, canvasSize.width || 800)
472
+ const height = Math.max(360, canvasSize.height || 560)
473
+ const padding = Math.max(48, settings.nodeSpacing + 18)
474
+ const spreadRatio = 0.72 + ((settings.repulsion - 40) / 360) * 0.28
475
+ const contentWidth = Math.max(120, (width - padding * 2) * spreadRatio)
476
+ const contentHeight = Math.max(120, (height - padding * 2) * spreadRatio)
477
+ const offsetX = (width - contentWidth) / 2
478
+ const offsetY = (height - contentHeight) / 2
479
+ const ratio = contentWidth / contentHeight
480
+ const columns = Math.max(1, Math.ceil(Math.sqrt(nodes.length * ratio)))
481
+ const rows = Math.max(1, Math.ceil(nodes.length / columns))
482
+ const cellWidth = contentWidth / columns
483
+ const cellHeight = contentHeight / rows
484
+ const connected = new Set<string>()
485
+
486
+ edges.forEach(edge => {
487
+ connected.add(edge.source)
488
+ connected.add(edge.target)
489
+ })
490
+
491
+ const orderedNodes = [...nodes].sort((a, b) => {
492
+ const connectionDelta = Number(connected.has(b.id)) - Number(connected.has(a.id))
493
+ if (connectionDelta !== 0) return connectionDelta
494
+ return stableRandom(a.id) - stableRandom(b.id)
495
+ })
496
+
497
+ orderedNodes.forEach((node, index) => {
498
+ const column = index % columns
499
+ const row = Math.floor(index / columns)
500
+ const jitterScale = connected.has(node.id) ? 0.28 : 0.42
501
+ const jitterX = (stableRandom(`${node.id}:x`) - 0.5) * cellWidth * jitterScale
502
+ const jitterY = (stableRandom(`${node.id}:y`) - 0.5) * cellHeight * jitterScale
503
+ const x = offsetX + cellWidth * (column + 0.5) + jitterX
504
+ const y = offsetY + cellHeight * (row + 0.5) + jitterY
505
+
506
+ positions.set(node.id, {
507
+ x: Math.min(width - padding, Math.max(padding, x)),
508
+ y: Math.min(height - padding, Math.max(padding, y)),
509
+ })
510
+ })
511
+
512
+ return positions
513
+ }
514
+
515
+ function graphPresetName(settings: GraphLayoutSettings) {
516
+ if (settings.denseMode && settings.nodeSize <= 14 && settings.edgeOpacity <= 0.4) return 'Nebula'
517
+ if (settings.edgeLabelMode === 'always' && settings.labelMode === 'always') return 'Debug'
518
+ return 'Readable'
519
+ }
520
+
521
+ export default function SkillGraph() {
522
+ const navigate = useNavigate()
523
+ const location = useLocation()
524
+ const graphShellRef = useRef<HTMLDivElement>(null)
525
+ const containerRef = useRef<HTMLDivElement>(null)
526
+ const graphRef = useRef<GraphInstance | null>(null)
527
+ const openedFromQuery = useRef<string | null>(null)
528
+
529
+ const [viewMode, setViewMode] = useState<GraphViewMode>('skill_only')
530
+ const [mode, setMode] = useState<GraphMode>('full')
531
+ const [graphData, setGraphData] = useState<GraphViewData | null>(null)
532
+ const [loading, setLoading] = useState(true)
533
+ const [subgraphLoading, setSubgraphLoading] = useState(false)
534
+ const [error, setError] = useState<string | null>(null)
535
+ const [edgeFilter, setEdgeFilter] = useState<string[]>([])
536
+ const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
537
+ const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
538
+ const [centerSkillId, setCenterSkillId] = useState<string | null>(null)
539
+ const [depth, setDepth] = useState(2)
540
+ const [layoutSettings, setLayoutSettings] = useState<GraphLayoutSettings>(loadLayoutSettings)
541
+ const [layoutDraft, setLayoutDraft] = useState<GraphLayoutSettings>(loadLayoutSettings)
542
+ const [layoutPanelOpen, setLayoutPanelOpen] = useState(false)
543
+ const [canvasSize, setCanvasSize] = useState<GraphCanvasSize>({ width: 800, height: 560 })
544
+ const [nebulaMode, setNebulaMode] = useState(true)
545
+
546
+ const selectedNode = useMemo(
547
+ () => graphData?.nodes.find(node => node.id === selectedNodeId) || null,
548
+ [graphData, selectedNodeId],
549
+ )
550
+
551
+ const selectedEdge = useMemo(
552
+ () => graphData?.edges.find(edge => edge.id === selectedEdgeId) || null,
553
+ [graphData, selectedEdgeId],
554
+ )
555
+
556
+ const centerNode = useMemo(
557
+ () => graphData?.nodes.find(node => node.id === centerSkillId) || null,
558
+ [graphData, centerSkillId],
559
+ )
560
+
561
+ const nodeById = useMemo(() => {
562
+ const nodes = new Map<string, GraphViewNodeData>()
563
+ graphData?.nodes.forEach(node => nodes.set(node.id, node))
564
+ return nodes
565
+ }, [graphData])
566
+
567
+ const edgeTypes = useMemo(
568
+ () => (graphData ? [...new Set(graphData.edges.map(edge => edge.edge_type))] : []),
569
+ [graphData],
570
+ )
571
+
572
+ const nodeLegend = useMemo(() => {
573
+ if (!graphData) return []
574
+ const values = viewMode === 'skill_only'
575
+ ? Object.keys(TYPE_COLOR)
576
+ : [...new Set(graphData.nodes.map(node => node.kind))]
577
+ return values.map(value => ({
578
+ value,
579
+ label: viewMode === 'skill_only' ? value : formatKind(value),
580
+ color: viewMode === 'skill_only' ? TYPE_COLOR[value] : KIND_COLOR[value] || '#8c8c8c',
581
+ }))
582
+ }, [graphData, viewMode])
583
+
584
+ const resetSelection = useCallback(() => {
585
+ setSelectedNodeId(null)
586
+ setSelectedEdgeId(null)
587
+ setCenterSkillId(null)
588
+ setEdgeFilter([])
589
+ }, [])
590
+
591
+ const loadGraphForView = useCallback(async (nextView: GraphViewMode) => {
592
+ setLoading(true)
593
+ setError(null)
594
+ resetSelection()
595
+ try {
596
+ const data = await graphApi.view(nextView, 300)
597
+ setGraphData(data)
598
+ setMode('full')
599
+ } catch (err) {
600
+ setError(getApiErrorMessage(err, 'Failed to load graph data'))
601
+ } finally {
602
+ setLoading(false)
603
+ }
604
+ }, [resetSelection])
605
+
606
+ const loadSubgraph = useCallback(async (skillId: string, nextDepth = depth) => {
607
+ if (!skillId) return
608
+ setSubgraphLoading(true)
609
+ setError(null)
610
+ setViewMode('skill_only')
611
+ try {
612
+ const data = await graphApi.subgraph(skillId, nextDepth)
613
+ setGraphData(graphDataToView(data))
614
+ setMode('subgraph')
615
+ setCenterSkillId(skillId)
616
+ setSelectedNodeId(skillId)
617
+ setSelectedEdgeId(null)
618
+ setEdgeFilter([])
619
+ } catch (err) {
620
+ setError(getApiErrorMessage(err, 'Failed to load subgraph; current view is preserved'))
621
+ } finally {
622
+ setSubgraphLoading(false)
623
+ setLoading(false)
624
+ }
625
+ }, [depth])
626
+
627
+ useEffect(() => {
628
+ if (viewMode !== 'skill_only') return
629
+ const querySkillId = new URLSearchParams(location.search).get('skill_id')
630
+ const timeoutId = window.setTimeout(() => {
631
+ if (querySkillId && openedFromQuery.current !== querySkillId) {
632
+ openedFromQuery.current = querySkillId
633
+ void loadSubgraph(querySkillId, depth)
634
+ return
635
+ }
636
+ if (!querySkillId && !graphData) {
637
+ void loadGraphForView('skill_only')
638
+ }
639
+ }, 0)
640
+ return () => window.clearTimeout(timeoutId)
641
+ }, [depth, graphData, loadGraphForView, loadSubgraph, location.search, viewMode])
642
+
643
+ // Auto-select a node when ?preselect=<id> is in the URL and graph is loaded
644
+ useEffect(() => {
645
+ if (!graphData?.nodes.length) return
646
+ const preselectId = new URLSearchParams(location.search).get('preselect')
647
+ if (preselectId && graphData.nodes.some(n => n.id === preselectId)) {
648
+ setSelectedNodeId(preselectId)
649
+ }
650
+ }, [graphData, location.search])
651
+
652
+ useEffect(() => {
653
+ if (!graphData?.nodes.length || !graphShellRef.current) return
654
+
655
+ const target = graphShellRef.current
656
+ const updateSize = () => {
657
+ const next = {
658
+ width: Math.max(320, Math.round(target.clientWidth || 800)),
659
+ height: Math.max(360, Math.round(target.clientHeight || 560)),
660
+ }
661
+
662
+ setCanvasSize(previous => {
663
+ if (Math.abs(previous.width - next.width) < 8 && Math.abs(previous.height - next.height) < 8) {
664
+ return previous
665
+ }
666
+ return next
667
+ })
668
+ }
669
+
670
+ updateSize()
671
+
672
+ if (typeof ResizeObserver === 'undefined') {
673
+ window.addEventListener('resize', updateSize)
674
+ return () => window.removeEventListener('resize', updateSize)
675
+ }
676
+
677
+ const observer = new ResizeObserver(updateSize)
678
+ observer.observe(target)
679
+ return () => observer.disconnect()
680
+ }, [graphData?.nodes.length])
681
+
682
+ useEffect(() => {
683
+ if (!graphData || !containerRef.current || graphData.nodes.length === 0) return
684
+
685
+ let disposed = false
686
+
687
+ import('@antv/g6').then((G6) => {
688
+ if (disposed || !containerRef.current) return
689
+
690
+ if (graphRef.current) {
691
+ graphRef.current.destroy()
692
+ graphRef.current = null
693
+ }
694
+
695
+ const filteredEdges = edgeFilter.length > 0
696
+ ? graphData.edges.filter(edge => edgeFilter.includes(edge.edge_type))
697
+ : graphData.edges
698
+ const graphWidth = canvasSize.width || containerRef.current.clientWidth || 800
699
+ const graphHeight = canvasSize.height || containerRef.current.clientHeight || 560
700
+ const forceLayout = {
701
+ type: 'force',
702
+ preventOverlap: true,
703
+ nodeSize: layoutSettings.nodeSpacing,
704
+ linkDistance: (edge?: ForceEdgeDatum) => weightedLinkDistance(edge, layoutSettings.linkDistance),
705
+ nodeStrength: layoutSettings.repulsion,
706
+ edgeStrength: (edge?: ForceEdgeDatum) => weightedEdgeStrength(edge, layoutSettings.attraction),
707
+ }
708
+ const initialPositions = calculateInitialNodePositions(
709
+ graphData.nodes,
710
+ filteredEdges,
711
+ { width: graphWidth, height: graphHeight },
712
+ layoutSettings,
713
+ )
714
+
715
+ const nodes = graphData.nodes.map(node => {
716
+ const color = nodeColor(node)
717
+ const selected = node.id === selectedNodeId
718
+ const centered = node.id === centerSkillId
719
+ const position = initialPositions.get(node.id)
720
+ const nodeDepth = nebulaMode ? stableRandom(node.id) : 0
721
+ const isForeground = nodeDepth < 0.5
722
+ const { sizeScale, opacityScale } = nebulaMode ? depthScale(nodeDepth) : { sizeScale: 1, opacityScale: 1 }
723
+ const nebulaSize = (isForeground
724
+ ? 5 + Math.floor(Number(node.usage_count || 0) * 0.025)
725
+ : 3 + Math.floor(Number(node.usage_count || 0) * 0.008)) * sizeScale
726
+ const nebulaFill = nebulaMode ? sphereGradient(color) : color
727
+ // Highlight ring for selected nebula nodes
728
+ const nebulaShadowColor = selected ? color : 'transparent'
729
+ const nebulaShadowBlur = selected ? 10 : 0
730
+ return {
731
+ id: node.id,
732
+ data: {
733
+ label: node.name,
734
+ nodeKind: node.kind,
735
+ state: node.state,
736
+ },
737
+ style: {
738
+ x: position?.x,
739
+ y: position?.y,
740
+ fill: nebulaFill,
741
+ fillOpacity: nebulaMode
742
+ ? (isForeground ? 0.95 : 0.82 * opacityScale)
743
+ : (node.kind === 'skill' ? (STATE_OPACITY[String(node.state || '')] || 0.65) : 0.92),
744
+ stroke: nebulaMode
745
+ ? (selected ? color : blendColor(color, 'white', 0.4))
746
+ : (selected || centered ? '#111827' : color),
747
+ strokeOpacity: nebulaMode ? (selected ? 1 : 0.5 * opacityScale) : 1,
748
+ lineWidth: nebulaMode
749
+ ? (selected ? 1.2 : 0.6)
750
+ : (selected || centered ? 2.4 : Math.max(0.7, layoutSettings.edgeWidth * 0.8)),
751
+ size: nebulaMode
752
+ ? (selected ? nebulaSize + 4 : nebulaSize)
753
+ : nodeVisualSize(node, layoutSettings, selected, centered),
754
+ shadowColor: nebulaMode ? nebulaShadowColor : 'transparent',
755
+ shadowBlur: nebulaMode ? nebulaShadowBlur : 0,
756
+ labelText: nebulaMode ? '' : (shouldShowNodeLabel(layoutSettings.labelMode, {
757
+ selected,
758
+ centered,
759
+ denseMode: layoutSettings.denseMode,
760
+ }) ? node.name : ''),
761
+ labelFill: '#111827',
762
+ labelFontSize: nodeLabelFontSize(layoutSettings, selected, centered),
763
+ labelFontWeight: selected || centered ? 700 : 600,
764
+ labelMaxWidth: '260%',
765
+ labelPlacement: 'bottom' as const,
766
+ labelOffsetY: layoutSettings.denseMode ? 4 : 5,
767
+ labelStroke: '#fff',
768
+ labelLineWidth: layoutSettings.denseMode ? 2 : 3,
769
+ labelWordWrap: true,
770
+ cursor: 'pointer',
771
+ },
772
+ }
773
+ })
774
+
775
+ const edges = filteredEdges.map(edge => {
776
+ const selected = edge.id === selectedEdgeId
777
+ const color = EDGE_COLOR[edge.edge_type] || '#8c8c8c'
778
+ const edgeDepth = nebulaMode ? (stableRandom(edge.source) + stableRandom(edge.target)) / 2 : 0
779
+ const edgeOpacityByDepth = nebulaMode ? 0.22 - edgeDepth * 0.14 : layoutSettings.edgeOpacity
780
+ const edgeWidthByDepth = nebulaMode ? 0.6 - edgeDepth * 0.3 : undefined
781
+ return {
782
+ id: edge.id,
783
+ source: edge.source,
784
+ target: edge.target,
785
+ data: { edgeType: edge.edge_type, weight: edge.weight },
786
+ style: {
787
+ stroke: color,
788
+ strokeOpacity: nebulaMode
789
+ ? (selected ? 0.9 : edgeOpacityByDepth)
790
+ : (selected ? 1 : layoutSettings.edgeOpacity),
791
+ lineWidth: nebulaMode
792
+ ? (selected ? 1.2 : (edgeWidthByDepth ?? 0.5))
793
+ : (selected
794
+ ? Math.max(2.4, layoutSettings.edgeWidth + Number(edge.weight || 0) * 1.8)
795
+ : Math.max(0.4, layoutSettings.edgeWidth + Number(edge.weight || 0) * 0.7)),
796
+ endArrow: !nebulaMode,
797
+ labelText: nebulaMode ? '' : formatEdgeLabel(edge),
798
+ labelFontSize: 9,
799
+ labelFill: color,
800
+ label: false,
801
+ labelStroke: '#fff',
802
+ labelLineWidth: 3,
803
+ labelPlacement: 'center' as const,
804
+ labelOffsetX: 0,
805
+ labelOffsetY: -18,
806
+ badgeText: nebulaMode ? '' : formatEdgeLabel(edge),
807
+ badgeFontSize: 9,
808
+ badgeFill: color,
809
+ badgeBackgroundFill: '#fff',
810
+ badgeBackgroundOpacity: nebulaMode ? 0 : 0.9,
811
+ badgeBackgroundRadius: 4,
812
+ badgePadding: [1, 4, 1, 4],
813
+ badgePlacement: 'suffix' as const,
814
+ badgeOffsetX: 0,
815
+ badgeOffsetY: -18,
816
+ cursor: 'pointer',
817
+ },
818
+ }
819
+ })
820
+
821
+ const g = new G6.Graph({
822
+ container: containerRef.current,
823
+ width: graphWidth,
824
+ height: graphHeight,
825
+ data: { nodes, edges },
826
+ autoFit: 'view',
827
+ padding: [48, 48, 72, 48],
828
+ zoomRange: [0.1, 2.0],
829
+ ...(filteredEdges.length > 0 ? { layout: forceLayout } : {}),
830
+ behaviors: [
831
+ 'drag-canvas',
832
+ 'zoom-canvas',
833
+ 'drag-element',
834
+ {
835
+ type: 'auto-adapt-label',
836
+ padding: 8,
837
+ throttle: 64,
838
+ },
839
+ {
840
+ type: 'click-select',
841
+ degree: 0,
842
+ },
843
+ ],
844
+ node: { type: 'circle' },
845
+ edge: { type: 'line' },
846
+ }) as GraphInstance
847
+
848
+ let graphReady = false
849
+ let edgeLabelsVisibleKey: string | null = null
850
+ const syncEdgeLabelVisibility = () => {
851
+ if (disposed || !graphReady || filteredEdges.length === 0) return
852
+ const visibleIds = shouldShowEdgeLabels(
853
+ layoutSettings.edgeLabelMode,
854
+ g.getZoom(),
855
+ filteredEdges,
856
+ selectedEdgeId,
857
+ )
858
+ const visibleKey = `${layoutSettings.edgeLabelMode}:${g.getZoom().toFixed(2)}:${selectedEdgeId || ''}:${visibleIds.size}`
859
+ if (edgeLabelsVisibleKey === visibleKey) return
860
+
861
+ edgeLabelsVisibleKey = visibleKey
862
+ g.updateEdgeData(filteredEdges.map(edge => ({
863
+ id: edge.id,
864
+ style: { badgeText: visibleIds.has(edge.id) ? formatEdgeLabel(edge) : '' },
865
+ })))
866
+ void Promise.resolve(g.draw())
867
+ }
868
+
869
+ const updateHoveredNodeLabel = (id: string | undefined, visible: boolean) => {
870
+ if (!id || layoutSettings.labelMode !== 'hover') return
871
+ const node = graphData.nodes.find(item => item.id === id)
872
+ if (!node) return
873
+ const selected = id === selectedNodeId
874
+ const centered = id === centerSkillId
875
+ const shouldKeep = shouldShowNodeLabel(layoutSettings.labelMode, {
876
+ selected,
877
+ centered,
878
+ denseMode: layoutSettings.denseMode,
879
+ })
880
+ g.updateNodeData([{
881
+ id,
882
+ style: {
883
+ labelText: visible || shouldKeep ? node.name : '',
884
+ size: visible ? Math.max(nodeVisualSize(node, layoutSettings, selected, centered) + 5, 18) : nodeVisualSize(node, layoutSettings, selected, centered),
885
+ },
886
+ }])
887
+ void Promise.resolve(g.draw())
888
+ }
889
+
890
+ g.on('node:click', event => {
891
+ const id = event.target?.id
892
+ if (id) {
893
+ setSelectedNodeId(id)
894
+ setSelectedEdgeId(null)
895
+ }
896
+ })
897
+
898
+ g.on('edge:click', event => {
899
+ const id = event.target?.id
900
+ if (id) {
901
+ setSelectedEdgeId(id)
902
+ setSelectedNodeId(null)
903
+ }
904
+ })
905
+
906
+ g.on('node:pointerenter', event => updateHoveredNodeLabel(event.target?.id, true))
907
+ g.on('node:pointerleave', event => updateHoveredNodeLabel(event.target?.id, false))
908
+
909
+ g.on('canvas:click', () => {
910
+ setSelectedNodeId(null)
911
+ setSelectedEdgeId(null)
912
+ })
913
+
914
+ // Zoom → nebula mode transition
915
+ g.on('aftertransform', () => {
916
+ if (disposed) return
917
+ const zoom = g.getZoom()
918
+ setNebulaMode(zoom < NEBULA_ZOOM_THRESHOLD)
919
+ syncEdgeLabelVisibility()
920
+ })
921
+
922
+ graphRef.current = g
923
+ void Promise.resolve(g.render()).then(() => {
924
+ if (!disposed) {
925
+ graphReady = true
926
+ syncEdgeLabelVisibility()
927
+ void Promise.resolve(g.fitView({ when: 'always' }, { duration: 160 })).then(() => {
928
+ syncEdgeLabelVisibility()
929
+ g.on('aftertransform', syncEdgeLabelVisibility)
930
+ })
931
+ }
932
+ })
933
+ })
934
+
935
+ return () => {
936
+ disposed = true
937
+ if (graphRef.current) {
938
+ graphRef.current.destroy()
939
+ graphRef.current = null
940
+ }
941
+ }
942
+ }, [canvasSize, centerSkillId, edgeFilter, graphData, layoutSettings, nebulaMode, selectedEdgeId, selectedNodeId])
943
+
944
+ const handleViewChange = (nextView: GraphViewMode) => {
945
+ setViewMode(nextView)
946
+ openedFromQuery.current = null
947
+ navigate('/graph', { replace: true })
948
+ void loadGraphForView(nextView)
949
+ }
950
+
951
+ const reloadCurrentView = () => {
952
+ if (viewMode === 'skill_only' && mode === 'subgraph' && centerSkillId) {
953
+ void loadSubgraph(centerSkillId, depth)
954
+ return
955
+ }
956
+ void loadGraphForView(viewMode)
957
+ }
958
+
959
+ const returnToFullGraph = () => {
960
+ openedFromQuery.current = null
961
+ navigate('/graph', { replace: true })
962
+ setViewMode('skill_only')
963
+ void loadGraphForView('skill_only')
964
+ }
965
+
966
+ const openWiki = () => {
967
+ if (!selectedNode || selectedNode.kind !== 'skill') return
968
+ navigate(`/wiki?skill_id=${encodeURIComponent(selectedNode.id)}`)
969
+ }
970
+
971
+ const expandSelectedSkill = () => {
972
+ if (!selectedNode || selectedNode.kind !== 'skill') return
973
+ void loadSubgraph(selectedNode.id, depth)
974
+ }
975
+
976
+ const hasNodes = Boolean(graphData?.nodes.length)
977
+ const selectedNodeTags = uniqueTags(selectedNode?.tags)
978
+ const selectedNodeMetadata = metadataEntries(selectedNode?.metadata)
979
+ const selectedEdgeMetadata = metadataEntries(selectedEdge?.metadata)
980
+ const viewCopy = GRAPH_VIEW_COPY[viewMode]
981
+ const detailTitle = selectedEdge ? 'Edge Details' : 'Node Details'
982
+ const relationStrengthText = relationStrengthSummary(graphData?.metadata)
983
+
984
+ const zoomGraph = (ratio: number) => {
985
+ void graphRef.current?.zoomBy(ratio, { duration: 180 })
986
+ }
987
+
988
+ const fitGraph = () => {
989
+ void graphRef.current?.fitView({ when: 'always' }, { duration: 180 })
990
+ }
991
+
992
+ const updateLayoutDraft = (field: keyof GraphLayoutSettings, value: number) => {
993
+ setLayoutDraft(previous => normalizeLayoutSettings({ ...previous, [field]: value }))
994
+ }
995
+
996
+ const updateLayoutDraftValue = (field: keyof GraphLayoutSettings, value: GraphLayoutSettings[keyof GraphLayoutSettings]) => {
997
+ setLayoutDraft(previous => normalizeLayoutSettings({ ...previous, [field]: value }))
998
+ }
999
+
1000
+ const applyLayoutSettings = () => {
1001
+ const next = normalizeLayoutSettings(layoutDraft)
1002
+ setLayoutSettings(next)
1003
+ setLayoutDraft(next)
1004
+ saveLayoutSettings(next)
1005
+ setLayoutPanelOpen(false)
1006
+ }
1007
+
1008
+ const resetLayoutSettings = () => {
1009
+ const next = DEFAULT_GRAPH_LAYOUT
1010
+ setLayoutSettings(next)
1011
+ setLayoutDraft(next)
1012
+ saveLayoutSettings(next)
1013
+ }
1014
+
1015
+ const applyLayoutPreset = (preset: keyof typeof GRAPH_LAYOUT_PRESETS) => {
1016
+ const next = normalizeLayoutSettings(GRAPH_LAYOUT_PRESETS[preset])
1017
+ setLayoutDraft(next)
1018
+ setLayoutSettings(next)
1019
+ saveLayoutSettings(next)
1020
+ }
1021
+
1022
+ const handleLayoutPanelOpenChange = (open: boolean) => {
1023
+ setLayoutPanelOpen(open)
1024
+ if (open) setLayoutDraft(layoutSettings)
1025
+ }
1026
+
1027
+ const renderLayoutSlider = (
1028
+ label: string,
1029
+ field: NumericGraphLayoutField,
1030
+ min: number,
1031
+ max: number,
1032
+ step = 1,
1033
+ ) => {
1034
+ const value = layoutDraft[field]
1035
+ const displayValue = field === 'attraction' ? value.toFixed(2) : Math.round(value)
1036
+
1037
+ return (
1038
+ <div>
1039
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
1040
+ <Text>{label}</Text>
1041
+ <Text code>{displayValue}</Text>
1042
+ </div>
1043
+ <Slider
1044
+ min={min}
1045
+ max={max}
1046
+ step={step}
1047
+ value={value}
1048
+ onChange={next => updateLayoutDraft(field, Number(next))}
1049
+ />
1050
+ </div>
1051
+ )
1052
+ }
1053
+
1054
+ const layoutSettingsContent = (
1055
+ <div style={{ width: 320 }}>
1056
+ <Space orientation="vertical" size={12} style={{ width: '100%' }}>
1057
+ <Text strong>Graph Visual Settings</Text>
1058
+ <Space.Compact block>
1059
+ <Button size="small" onClick={() => applyLayoutPreset('nebula')}>Nebula</Button>
1060
+ <Button size="small" onClick={() => applyLayoutPreset('readable')}>Readable</Button>
1061
+ <Button size="small" onClick={() => applyLayoutPreset('debug')}>Debug</Button>
1062
+ </Space.Compact>
1063
+ {renderLayoutSlider('Node size', 'nodeSize', 6, 42)}
1064
+ {renderLayoutSlider('Edge width', 'edgeWidth', 0.4, 4, 0.1)}
1065
+ {renderLayoutSlider('Edge opacity', 'edgeOpacity', 0.08, 1, 0.02)}
1066
+ {renderLayoutSlider('Charge strength', 'repulsion', 40, 400)}
1067
+ {renderLayoutSlider('Attraction', 'attraction', 0.05, 1, 0.05)}
1068
+ {renderLayoutSlider('Link distance', 'linkDistance', 60, 280)}
1069
+ {renderLayoutSlider('Node spacing', 'nodeSpacing', 28, 96)}
1070
+ <div>
1071
+ <Text>Node labels</Text>
1072
+ <Segmented
1073
+ block
1074
+ size="small"
1075
+ options={LABEL_MODE_OPTIONS}
1076
+ value={layoutDraft.labelMode}
1077
+ onChange={value => updateLayoutDraftValue('labelMode', value as GraphLabelMode)}
1078
+ style={{ marginTop: 6 }}
1079
+ />
1080
+ </div>
1081
+ <div>
1082
+ <Text>Edge labels</Text>
1083
+ <Segmented
1084
+ block
1085
+ size="small"
1086
+ options={LABEL_MODE_OPTIONS}
1087
+ value={layoutDraft.edgeLabelMode}
1088
+ onChange={value => updateLayoutDraftValue('edgeLabelMode', value as GraphLabelMode)}
1089
+ style={{ marginTop: 6 }}
1090
+ />
1091
+ </div>
1092
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center' }}>
1093
+ <Text>Dense mode</Text>
1094
+ <Switch
1095
+ size="small"
1096
+ checked={layoutDraft.denseMode}
1097
+ onChange={checked => updateLayoutDraftValue('denseMode', checked)}
1098
+ />
1099
+ </div>
1100
+ <Space style={{ justifyContent: 'flex-end', width: '100%' }}>
1101
+ <Button size="small" onClick={resetLayoutSettings}>Reset</Button>
1102
+ <Button size="small" type="primary" onClick={applyLayoutSettings}>Apply</Button>
1103
+ </Space>
1104
+ </Space>
1105
+ </div>
1106
+ )
1107
+
1108
+ return (
1109
+ <div style={{ padding: 24, minHeight: 'calc(100vh - 120px)' }}>
1110
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'center', marginBottom: 12, flexWrap: 'wrap' }}>
1111
+ <div>
1112
+ <h2 style={{ margin: 0, fontWeight: 700 }}>Skill Knowledge Graph</h2>
1113
+ <Text type="secondary">{viewCopy.subtitle}</Text>
1114
+ </div>
1115
+ <Space wrap>
1116
+ <Segmented
1117
+ options={GRAPH_VIEW_OPTIONS}
1118
+ value={viewMode}
1119
+ onChange={value => handleViewChange(value as GraphViewMode)}
1120
+ />
1121
+ <Select
1122
+ mode="multiple"
1123
+ placeholder="Filter edge types"
1124
+ style={{ minWidth: 220 }}
1125
+ allowClear
1126
+ value={edgeFilter}
1127
+ onChange={setEdgeFilter}
1128
+ options={edgeTypes.map(type => ({ label: formatEdgeType(type), value: type }))}
1129
+ />
1130
+ <Button icon={<ReloadOutlined />} onClick={reloadCurrentView} loading={loading}>
1131
+ {viewCopy.reload}
1132
+ </Button>
1133
+ </Space>
1134
+ </div>
1135
+
1136
+ {error && (
1137
+ <Alert
1138
+ type="error"
1139
+ showIcon
1140
+ closable
1141
+ onClose={() => setError(null)}
1142
+ title={error}
1143
+ style={{ marginBottom: 12 }}
1144
+ />
1145
+ )}
1146
+
1147
+ {mode === 'subgraph' && centerNode && (
1148
+ <Alert
1149
+ type="info"
1150
+ showIcon
1151
+ title={`Subgraph center: ${centerNode.name}`}
1152
+ description={`depth=${depth}, ${graphData?.nodes.length || 0} nodes, ${graphData?.edges.length || 0} edges.`}
1153
+ action={(
1154
+ <Button size="small" icon={<RollbackOutlined />} onClick={returnToFullGraph}>
1155
+ Back to full graph
1156
+ </Button>
1157
+ )}
1158
+ style={{ marginBottom: 12 }}
1159
+ />
1160
+ )}
1161
+
1162
+ {viewMode === 'version_impact' && Boolean(graphData?.metadata?.meta_paths) && (
1163
+ <Alert
1164
+ type="info"
1165
+ showIcon
1166
+ title="Projection meta-paths"
1167
+ description={formatMetadataValue(graphData?.metadata?.meta_paths)}
1168
+ style={{ marginBottom: 12 }}
1169
+ />
1170
+ )}
1171
+
1172
+ {/* Relation strength summary is available in graph metadata for debugging */}
1173
+
1174
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 360px), 1fr))', gap: 16, alignItems: 'stretch' }}>
1175
+ <Card
1176
+ style={{
1177
+ minHeight: 560, borderRadius: 8, overflow: 'hidden', minWidth: 0,
1178
+ background: nebulaMode ? '#f4f5f9' : undefined,
1179
+ transition: 'background 0.4s ease',
1180
+ }}
1181
+ styles={{ body: { padding: 0, height: 560 } }}
1182
+ >
1183
+ {loading && !graphData ? (
1184
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
1185
+ <Spin size="large" tip="Loading graph data..." />
1186
+ </div>
1187
+ ) : !hasNodes ? (
1188
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', padding: 24 }}>
1189
+ <Empty description={viewCopy.empty}>
1190
+ <Button icon={<ReloadOutlined />} onClick={reloadCurrentView}>
1191
+ Reload
1192
+ </Button>
1193
+ </Empty>
1194
+ </div>
1195
+ ) : (
1196
+ <div ref={graphShellRef} style={{ position: 'relative', width: '100%', height: '100%', minHeight: 560 }}>
1197
+ <div ref={containerRef} style={{ width: '100%', height: '100%', minHeight: 560 }} />
1198
+ {/* Vignette overlay for depth illusion in nebula mode */}
1199
+ {nebulaMode && (
1200
+ <div style={{
1201
+ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 1,
1202
+ borderRadius: 8,
1203
+ background: 'radial-gradient(ellipse at 50% 50%, transparent 55%, rgba(240,241,246,0.55) 80%, rgba(228,230,240,0.82) 100%)',
1204
+ }} />
1205
+ )}
1206
+ <Space.Compact style={{ position: 'absolute', right: 12, top: 12, zIndex: 2, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
1207
+ <Popover
1208
+ content={layoutSettingsContent}
1209
+ trigger="click"
1210
+ placement="bottomRight"
1211
+ open={layoutPanelOpen}
1212
+ onOpenChange={handleLayoutPanelOpenChange}
1213
+ >
1214
+ <Button size="small" icon={<SettingOutlined />} aria-label="Layout settings" />
1215
+ </Popover>
1216
+ <Tooltip title="Zoom in">
1217
+ <Button size="small" icon={<ZoomInOutlined />} aria-label="Zoom in" onClick={() => zoomGraph(1.2)} />
1218
+ </Tooltip>
1219
+ <Tooltip title="Zoom out">
1220
+ <Button size="small" icon={<ZoomOutOutlined />} aria-label="Zoom out" onClick={() => zoomGraph(0.8)} />
1221
+ </Tooltip>
1222
+ <Tooltip title="Fit view">
1223
+ <Button size="small" icon={<AimOutlined />} aria-label="Fit view" onClick={fitGraph} />
1224
+ </Tooltip>
1225
+ </Space.Compact>
1226
+ </div>
1227
+ )}
1228
+ </Card>
1229
+
1230
+ <Card
1231
+ title={detailTitle}
1232
+ style={{ borderRadius: 8, minWidth: 0 }}
1233
+ styles={{ body: { paddingTop: 12 } }}
1234
+ extra={selectedNode ? <Tag color={nodeColor(selectedNode)}>{formatKind(selectedNode.kind)}</Tag> : null}
1235
+ >
1236
+ {selectedEdge ? (
1237
+ <Space orientation="vertical" size={14} style={{ width: '100%' }}>
1238
+ <div>
1239
+ <Text strong style={{ fontSize: 16 }}>{formatEdgeType(selectedEdge.edge_type)}</Text>
1240
+ <Paragraph copyable={{ text: selectedEdge.id }} style={{ margin: '6px 0 0' }}>
1241
+ <Text code>{selectedEdge.id}</Text>
1242
+ </Paragraph>
1243
+ </div>
1244
+ <Descriptions column={1} size="small" bordered>
1245
+ <Descriptions.Item label="Source">
1246
+ {nodeById.get(selectedEdge.source)?.name || selectedEdge.source}
1247
+ </Descriptions.Item>
1248
+ <Descriptions.Item label="Target">
1249
+ {nodeById.get(selectedEdge.target)?.name || selectedEdge.target}
1250
+ </Descriptions.Item>
1251
+ <Descriptions.Item label="Weight">
1252
+ {selectedEdge.weight.toFixed(2)}
1253
+ </Descriptions.Item>
1254
+ {selectedEdge.confidence !== undefined && selectedEdge.confidence !== null && (
1255
+ <Descriptions.Item label="Confidence">
1256
+ {selectedEdge.confidence.toFixed(2)}
1257
+ </Descriptions.Item>
1258
+ )}
1259
+ </Descriptions>
1260
+ {selectedEdgeMetadata.length > 0 && (
1261
+ <Descriptions column={1} size="small" bordered title="Evidence">
1262
+ {selectedEdgeMetadata.slice(0, 8).map(([key, value]) => (
1263
+ <Descriptions.Item key={key} label={key}>
1264
+ <Text style={{ whiteSpace: 'pre-wrap' }}>{formatMetadataValue(value)}</Text>
1265
+ </Descriptions.Item>
1266
+ ))}
1267
+ </Descriptions>
1268
+ )}
1269
+ </Space>
1270
+ ) : selectedNode ? (
1271
+ <Space orientation="vertical" size={14} style={{ width: '100%' }}>
1272
+ <div>
1273
+ <Text strong style={{ fontSize: 16 }}>{selectedNode.name}</Text>
1274
+ <Paragraph copyable={{ text: selectedNode.id }} style={{ margin: '6px 0 0' }}>
1275
+ <Text code>{selectedNode.id}</Text>
1276
+ </Paragraph>
1277
+ {selectedNode.description && (
1278
+ <Paragraph style={{ marginBottom: 0 }}>{selectedNode.description}</Paragraph>
1279
+ )}
1280
+ </div>
1281
+
1282
+ <Descriptions column={1} size="small" bordered>
1283
+ <Descriptions.Item label="Kind">
1284
+ <Tag color={nodeColor(selectedNode)}>{formatKind(selectedNode.kind)}</Tag>
1285
+ </Descriptions.Item>
1286
+ {selectedNode.state && (
1287
+ <Descriptions.Item label="State">
1288
+ <Tag>{STATE_LABEL[selectedNode.state] || selectedNode.state}</Tag>
1289
+ </Descriptions.Item>
1290
+ )}
1291
+ {selectedNode.version && (
1292
+ <Descriptions.Item label="Version">
1293
+ <Text code>{selectedNode.version}</Text>
1294
+ </Descriptions.Item>
1295
+ )}
1296
+ {selectedNode.success_rate !== undefined && selectedNode.success_rate !== null && (
1297
+ <Descriptions.Item label="Success rate">
1298
+ {formatPercent(selectedNode.success_rate)}
1299
+ </Descriptions.Item>
1300
+ )}
1301
+ {selectedNode.usage_count !== undefined && selectedNode.usage_count !== null && (
1302
+ <Descriptions.Item label="Usage count">
1303
+ {selectedNode.usage_count}
1304
+ </Descriptions.Item>
1305
+ )}
1306
+ {selectedNode.granularity_level !== undefined && selectedNode.granularity_level !== null && (
1307
+ <Descriptions.Item label="Granularity">
1308
+ {selectedNode.granularity_level}
1309
+ </Descriptions.Item>
1310
+ )}
1311
+ </Descriptions>
1312
+
1313
+ {selectedNodeTags.length > 0 && (
1314
+ <div>
1315
+ <Text type="secondary">Tags</Text>
1316
+ <div style={{ marginTop: 6 }}>
1317
+ {selectedNodeTags.map(tag => <Tag key={tag}>{tag}</Tag>)}
1318
+ </div>
1319
+ </div>
1320
+ )}
1321
+
1322
+ {selectedNodeMetadata.length > 0 && (
1323
+ <Descriptions column={1} size="small" bordered title="Metadata">
1324
+ {selectedNodeMetadata.slice(0, 8).map(([key, value]) => (
1325
+ <Descriptions.Item key={key} label={key}>
1326
+ <Text style={{ whiteSpace: 'pre-wrap' }}>{formatMetadataValue(value)}</Text>
1327
+ </Descriptions.Item>
1328
+ ))}
1329
+ </Descriptions>
1330
+ )}
1331
+
1332
+ {selectedNode.kind === 'skill' && (
1333
+ <>
1334
+ <Space.Compact style={{ width: '100%' }}>
1335
+ <span style={{ display: 'inline-flex', alignItems: 'center', padding: '0 10px', border: '1px solid #d9d9d9', borderRight: 0, borderRadius: '6px 0 0 6px', color: '#666', background: '#fafafa' }}>
1336
+ Depth
1337
+ </span>
1338
+ <InputNumber
1339
+ min={1}
1340
+ max={5}
1341
+ value={depth}
1342
+ onChange={value => setDepth(value || 2)}
1343
+ style={{ width: 92 }}
1344
+ />
1345
+ <Button
1346
+ icon={<ShareAltOutlined />}
1347
+ loading={subgraphLoading}
1348
+ onClick={expandSelectedSkill}
1349
+ style={{ flex: 1 }}
1350
+ >
1351
+ Expand related
1352
+ </Button>
1353
+ </Space.Compact>
1354
+ <Button block type="primary" icon={<ExportOutlined />} onClick={openWiki}>
1355
+ Open in Wiki
1356
+ </Button>
1357
+ </>
1358
+ )}
1359
+ </Space>
1360
+ ) : (
1361
+ <Empty
1362
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
1363
+ description="Click a node or edge to inspect its evidence."
1364
+ />
1365
+ )}
1366
+ </Card>
1367
+ </div>
1368
+
1369
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, marginTop: 10, color: '#8c8c8c', fontSize: 12, flexWrap: 'wrap' }}>
1370
+ <Space wrap>
1371
+ {nodeLegend.map(item => (
1372
+ <Tag key={item.value} color={item.color} style={{ borderRadius: 8 }}>{item.label}</Tag>
1373
+ ))}
1374
+ <span>
1375
+ {viewMode === 'skill_only'
1376
+ ? 'Node color = skill type; opacity = lifecycle state.'
1377
+ : 'Node color = provenance kind; edges preserve typed evidence links.'}
1378
+ </span>
1379
+ </Space>
1380
+ {graphData && (
1381
+ <Space size={8} wrap>
1382
+ <span>{graphPresetName(layoutSettings)} preset</span>
1383
+ <span>{graphData.nodes.length} nodes / {graphData.edges.length} edges</span>
1384
+ </Space>
1385
+ )}
1386
+ </div>
1387
+ </div>
1388
+ )
1389
+ }
skillwiki-frontend/src/pages/SkillWiki.tsx ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Descriptions,
6
+ Drawer,
7
+ Input,
8
+ Popconfirm,
9
+ Progress,
10
+ Select,
11
+ Space,
12
+ Table,
13
+ Tabs,
14
+ Tag,
15
+ Timeline,
16
+ Tooltip,
17
+ Typography,
18
+ message,
19
+ } from 'antd'
20
+ import type { TableColumnsType } from 'antd'
21
+ import {
22
+ CheckOutlined,
23
+ EyeOutlined,
24
+ HistoryOutlined,
25
+ PlayCircleOutlined,
26
+ SafetyCertificateOutlined,
27
+ SearchOutlined,
28
+ StopOutlined,
29
+ } from '@ant-design/icons'
30
+ import { motion } from 'framer-motion'
31
+ import { useLocation, useNavigate } from 'react-router-dom'
32
+ import { skillsApi, lifecycleApi } from '@/api/client'
33
+ import { getApiErrorMessage } from '@/api/errors'
34
+ import type { SkillFull, SkillParameter, SkillState, SkillSummary, SkillType } from '@/api/types'
35
+
36
+ const { Text, Paragraph } = Typography
37
+
38
+ const STATE_COLOR: Record<string, string> = {
39
+ S0: 'purple',
40
+ S1: 'orange',
41
+ S2: 'blue',
42
+ S3: 'cyan',
43
+ S4: 'green',
44
+ S5: 'gold',
45
+ S6: 'red',
46
+ S7: 'default',
47
+ }
48
+
49
+ const STATE_LABEL: Record<string, string> = {
50
+ S0: 'Raw',
51
+ S1: 'Candidate',
52
+ S2: 'Draft',
53
+ S3: 'Verified',
54
+ S4: 'Released',
55
+ S5: 'Degraded',
56
+ S6: 'Deprecated',
57
+ S7: 'Archived',
58
+ }
59
+
60
+ const TYPE_COLOR: Record<string, string> = {
61
+ atomic: 'blue',
62
+ functional: 'purple',
63
+ strategic: 'gold',
64
+ }
65
+
66
+ function schemaToParameters(schema: SkillFull['interface']['input_schema'] | SkillFull['interface']['output_schema']): SkillParameter[] {
67
+ const properties = schema?.properties ?? {}
68
+ const required = new Set(schema?.required ?? [])
69
+
70
+ return Object.entries(properties).map(([name, field]) => ({
71
+ name,
72
+ type: field.type ?? 'unknown',
73
+ description: field.description ?? '',
74
+ required: required.has(name),
75
+ default: field.default,
76
+ }))
77
+ }
78
+
79
+ function getInterfaceParameters(skill: SkillFull, direction: 'input' | 'output'): SkillParameter[] {
80
+ const skillInterface = skill.interface
81
+ if (!skillInterface) return []
82
+ if (direction === 'input') {
83
+ return Array.isArray(skillInterface.inputs) ? skillInterface.inputs : schemaToParameters(skillInterface.input_schema)
84
+ }
85
+ return Array.isArray(skillInterface.outputs) ? skillInterface.outputs : schemaToParameters(skillInterface.output_schema)
86
+ }
87
+
88
+ function uniqueTags(tags?: string[]): string[] {
89
+ return Array.from(new Set((tags ?? []).filter(Boolean)))
90
+ }
91
+
92
+ function formatDate(value?: string) {
93
+ if (!value) return ''
94
+ const parsed = new Date(value)
95
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString()
96
+ }
97
+
98
+ function buildSkillTimeline(skill: SkillFull) {
99
+ const items: {
100
+ color: string
101
+ dot?: ReactNode
102
+ children: ReactNode
103
+ }[] = []
104
+ const provenance = skill.provenance
105
+ const evaluation = skill.evaluation
106
+ const metrics = skill.metrics
107
+
108
+ if (provenance?.source_type || provenance?.source_ids?.length) {
109
+ items.push({
110
+ color: 'blue',
111
+ children: (
112
+ <div>
113
+ <Text strong>Source imported</Text>
114
+ <div style={{ fontSize: 12, color: '#666' }}>
115
+ {provenance.source_type || 'unknown source'} {provenance.source_ids?.slice(0, 2).map(id => <Tag key={id}>{id}</Tag>)}
116
+ </div>
117
+ </div>
118
+ ),
119
+ })
120
+ }
121
+
122
+ items.push({
123
+ color: 'orange',
124
+ children: (
125
+ <div>
126
+ <Text strong>Candidate created</Text>
127
+ <div style={{ fontSize: 12, color: '#666' }}>{formatDate(skill.created_at)}</div>
128
+ </div>
129
+ ),
130
+ })
131
+
132
+ if (evaluation?.verifier_specs?.length || evaluation?.validation_summary) {
133
+ items.push({
134
+ color: 'cyan',
135
+ dot: <SafetyCertificateOutlined />,
136
+ children: (
137
+ <div>
138
+ <Text strong>Audited / verifier attached</Text>
139
+ <div style={{ fontSize: 12, color: '#666' }}>
140
+ {evaluation.validation_summary || `${evaluation.verifier_specs.length} verifier spec(s)`}
141
+ </div>
142
+ </div>
143
+ ),
144
+ })
145
+ }
146
+
147
+ if (['S3', 'S4', 'S5', 'S6', 'S7'].includes(skill.state)) {
148
+ items.push({
149
+ color: skill.state === 'S4' ? 'green' : 'blue',
150
+ children: (
151
+ <div>
152
+ <Text strong>{skill.state === 'S4' ? 'Released' : STATE_LABEL[skill.state]}</Text>
153
+ <div style={{ fontSize: 12, color: '#666' }}>Current state: {STATE_LABEL[skill.state]}</div>
154
+ </div>
155
+ ),
156
+ })
157
+ }
158
+
159
+ if (metrics.total_executions > 0) {
160
+ items.push({
161
+ color: 'purple',
162
+ dot: <PlayCircleOutlined />,
163
+ children: (
164
+ <div>
165
+ <Text strong>Executed</Text>
166
+ <div style={{ fontSize: 12, color: '#666' }}>
167
+ {metrics.total_executions} runs, {(metrics.success_rate * 100).toFixed(1)}% success
168
+ </div>
169
+ </div>
170
+ ),
171
+ })
172
+ }
173
+
174
+ if (metrics.failed_executions > 0 || skill.state === 'S5') {
175
+ items.push({
176
+ color: 'red',
177
+ children: (
178
+ <div>
179
+ <Text strong>Failed / degraded evidence</Text>
180
+ <div style={{ fontSize: 12, color: '#666' }}>
181
+ {metrics.failed_executions} failed run(s)
182
+ </div>
183
+ </div>
184
+ ),
185
+ })
186
+ } else if (metrics.successful_executions > 0 || evaluation?.validation_summary) {
187
+ items.push({
188
+ color: 'green',
189
+ children: (
190
+ <div>
191
+ <Text strong>Validated evidence</Text>
192
+ <div style={{ fontSize: 12, color: '#666' }}>
193
+ {metrics.successful_executions} successful run(s)
194
+ </div>
195
+ </div>
196
+ ),
197
+ })
198
+ }
199
+
200
+ if (provenance?.parent_skill_ids?.length || skill.tags.includes('maintenance') || skill.tags.includes('repair')) {
201
+ items.push({
202
+ color: 'gold',
203
+ children: (
204
+ <div>
205
+ <Text strong>Repaired / derived</Text>
206
+ <div style={{ fontSize: 12, color: '#666' }}>
207
+ {provenance?.parent_skill_ids?.map(id => <Tag key={id}>{id}</Tag>)}
208
+ </div>
209
+ </div>
210
+ ),
211
+ })
212
+ }
213
+
214
+ items.push({
215
+ color: 'blue',
216
+ dot: <HistoryOutlined />,
217
+ children: (
218
+ <div>
219
+ <Text strong>Versioned</Text>
220
+ <div style={{ fontSize: 12, color: '#666' }}>
221
+ <Text code>v{skill.version}</Text> updated {formatDate(skill.updated_at)}
222
+ </div>
223
+ </div>
224
+ ),
225
+ })
226
+
227
+ return items
228
+ }
229
+
230
+ export default function SkillWiki() {
231
+ const location = useLocation()
232
+ const navigate = useNavigate()
233
+ const openedFromQuery = useRef<string | null>(null)
234
+ const [skills, setSkills] = useState<SkillSummary[]>([])
235
+ const [loading, setLoading] = useState(true)
236
+ const [search, setSearch] = useState('')
237
+ const [stateFilter, setStateFilter] = useState<SkillState | undefined>()
238
+ const [typeFilter, setTypeFilter] = useState<SkillType | undefined>()
239
+ const [selected, setSelected] = useState<SkillFull | null>(null)
240
+ const [drawerOpen, setDrawerOpen] = useState(false)
241
+
242
+ const load = useCallback(async () => {
243
+ setLoading(true)
244
+ try {
245
+ const data = await skillsApi.list({ state: stateFilter, skill_type: typeFilter, limit: 200 })
246
+ setSkills(data)
247
+ } catch (err) {
248
+ message.error(getApiErrorMessage(err, 'Failed to load Skills'))
249
+ } finally {
250
+ setLoading(false)
251
+ }
252
+ }, [stateFilter, typeFilter])
253
+
254
+ useEffect(() => {
255
+ const timeoutId = window.setTimeout(() => { void load() }, 0)
256
+ return () => window.clearTimeout(timeoutId)
257
+ }, [load])
258
+
259
+ const filtered = skills.filter(skill => {
260
+ if (!search) return true
261
+ return (
262
+ skill.name.includes(search) ||
263
+ skill.description.includes(search) ||
264
+ skill.tags.some(tag => tag.includes(search))
265
+ )
266
+ })
267
+
268
+ const openDetail = async (id: string) => {
269
+ const full = await skillsApi.getFull(id)
270
+ setSelected(full)
271
+ setDrawerOpen(true)
272
+ }
273
+
274
+ useEffect(() => {
275
+ const id = new URLSearchParams(location.search).get('skill_id')
276
+ if (!id || openedFromQuery.current === id) return
277
+ openedFromQuery.current = id
278
+ openDetail(id).catch(err => message.error(getApiErrorMessage(err, 'Skill does not exist or is temporarily unavailable')))
279
+ }, [location.search])
280
+
281
+ const handleRelease = async (id: string) => {
282
+ try {
283
+ await lifecycleApi.release(id)
284
+ message.success('Released')
285
+ void load()
286
+ } catch (err) {
287
+ message.error(getApiErrorMessage(err, 'Release failed'))
288
+ }
289
+ }
290
+
291
+ const handleDeprecate = async (id: string) => {
292
+ try {
293
+ await lifecycleApi.deprecate(id, 'Manual deprecation')
294
+ message.success('Deprecated')
295
+ void load()
296
+ } catch (err) {
297
+ message.error(getApiErrorMessage(err, 'Deprecation failed'))
298
+ }
299
+ }
300
+
301
+ const columns: TableColumnsType<SkillSummary> = [
302
+ {
303
+ title: 'Name',
304
+ dataIndex: 'name',
305
+ render: (name: string, record) => (
306
+ <Button type="link" onClick={() => openDetail(record.skill_id)} style={{ padding: 0, fontWeight: 600 }}>
307
+ {name}
308
+ </Button>
309
+ ),
310
+ },
311
+ {
312
+ title: 'Type',
313
+ dataIndex: 'skill_type',
314
+ render: (type: string) => <Tag color={TYPE_COLOR[type]}>{type.toUpperCase()}</Tag>,
315
+ },
316
+ {
317
+ title: 'State',
318
+ dataIndex: 'state',
319
+ render: (state: string) => <Badge color={STATE_COLOR[state] || 'default'} text={STATE_LABEL[state] || state} />,
320
+ },
321
+ {
322
+ title: 'Version',
323
+ dataIndex: 'version',
324
+ render: (version: string) => <Text code>{version}</Text>,
325
+ },
326
+ {
327
+ title: 'Tags',
328
+ dataIndex: 'tags',
329
+ render: (tags: string[]) => uniqueTags(tags).slice(0, 3).map(tag => <Tag key={tag}>{tag}</Tag>),
330
+ },
331
+ {
332
+ title: 'Success Rate',
333
+ dataIndex: 'metrics',
334
+ render: (metrics: SkillSummary['metrics']) => (
335
+ metrics.total_executions >= 5
336
+ ? <Progress percent={Math.round(metrics.success_rate * 100)} size="small" style={{ width: 80 }} />
337
+ : <Text type="secondary">N/A</Text>
338
+ ),
339
+ },
340
+ {
341
+ title: 'Actions',
342
+ render: (_, record) => (
343
+ <Space>
344
+ <Tooltip title="View details">
345
+ <Button size="small" icon={<EyeOutlined />} onClick={() => openDetail(record.skill_id)} />
346
+ </Tooltip>
347
+ {record.state === 'S2' && (
348
+ <Tooltip title="Release">
349
+ <Button size="small" icon={<CheckOutlined />} type="primary" onClick={() => handleRelease(record.skill_id)} />
350
+ </Tooltip>
351
+ )}
352
+ {record.state === 'S4' && (
353
+ <Tooltip title="Deprecate">
354
+ <Popconfirm title="Deprecate this Skill?" onConfirm={() => handleDeprecate(record.skill_id)}>
355
+ <Button size="small" icon={<StopOutlined />} danger />
356
+ </Popconfirm>
357
+ </Tooltip>
358
+ )}
359
+ </Space>
360
+ ),
361
+ },
362
+ ]
363
+
364
+ const selectedTags = uniqueTags(selected?.tags)
365
+ const selectedInputParams = selected ? getInterfaceParameters(selected, 'input') : []
366
+ const selectedOutputParams = selected ? getInterfaceParameters(selected, 'output') : []
367
+ const selectedPreconditions = selected?.interface?.preconditions ?? []
368
+ const selectedPostconditions = selected?.interface?.postconditions ?? []
369
+ const selectedSubSkillIds = selected?.implementation?.sub_skill_ids ?? []
370
+ const selectedEvaluation = selected?.evaluation
371
+ const selectedProvenance = selected?.provenance
372
+ const selectedTimeline = selected ? buildSkillTimeline(selected) : []
373
+
374
+ return (
375
+ <div style={{ padding: 24 }}>
376
+ <motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
377
+ <div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
378
+ <Input
379
+ placeholder="Search Skill name, description, or tags"
380
+ prefix={<SearchOutlined />}
381
+ value={search}
382
+ onChange={event => setSearch(event.target.value)}
383
+ style={{ width: 280 }}
384
+ allowClear
385
+ />
386
+ <Select
387
+ placeholder="Filter by state"
388
+ allowClear
389
+ style={{ width: 150 }}
390
+ onChange={value => setStateFilter(value as SkillState)}
391
+ options={[
392
+ { label: 'Raw (S0)', value: 'S0' },
393
+ { label: 'Candidate (S1)', value: 'S1' },
394
+ { label: 'Draft (S2)', value: 'S2' },
395
+ { label: 'Verified (S3)', value: 'S3' },
396
+ { label: 'Released (S4)', value: 'S4' },
397
+ { label: 'Degraded (S5)', value: 'S5' },
398
+ { label: 'Deprecated (S6)', value: 'S6' },
399
+ { label: 'Archived (S7)', value: 'S7' },
400
+ ]}
401
+ />
402
+ <Select
403
+ placeholder="Filter by type"
404
+ allowClear
405
+ style={{ width: 150 }}
406
+ onChange={value => setTypeFilter(value as SkillType)}
407
+ options={['atomic', 'functional', 'strategic'].map(type => ({ label: type, value: type }))}
408
+ />
409
+ <Button onClick={load}>Refresh</Button>
410
+ </div>
411
+
412
+ <Table
413
+ dataSource={filtered}
414
+ columns={columns}
415
+ rowKey="skill_id"
416
+ loading={loading}
417
+ size="middle"
418
+ pagination={{ pageSize: 15, showSizeChanger: true }}
419
+ style={{ borderRadius: 8, overflow: 'hidden' }}
420
+ />
421
+ </motion.div>
422
+
423
+ <Drawer
424
+ title={selected?.name}
425
+ open={drawerOpen}
426
+ onClose={() => setDrawerOpen(false)}
427
+ size="large"
428
+ extra={selected && (
429
+ <Space>
430
+ <Button size="small" onClick={() => navigate(`/graph?skill_id=${encodeURIComponent(selected.skill_id)}`)}>
431
+ View Graph
432
+ </Button>
433
+ <Tag color={TYPE_COLOR[selected.skill_type]}>{selected.skill_type.toUpperCase()}</Tag>
434
+ <Badge color={STATE_COLOR[selected.state]} text={STATE_LABEL[selected.state] || selected.state} />
435
+ </Space>
436
+ )}
437
+ >
438
+ {selected && (
439
+ <Tabs
440
+ items={[
441
+ {
442
+ key: 'info',
443
+ label: 'Basic Info',
444
+ children: (
445
+ <Descriptions column={1} bordered size="small">
446
+ <Descriptions.Item label="ID"><Text code copyable>{selected.skill_id}</Text></Descriptions.Item>
447
+ <Descriptions.Item label="Version"><Text code>{selected.version}</Text></Descriptions.Item>
448
+ <Descriptions.Item label="Description"><Paragraph>{selected.description}</Paragraph></Descriptions.Item>
449
+ <Descriptions.Item label="Tags">
450
+ {selectedTags.length > 0
451
+ ? selectedTags.map(tag => <Tag key={tag}>{tag}</Tag>)
452
+ : <Text type="secondary">No tags</Text>}
453
+ </Descriptions.Item>
454
+ <Descriptions.Item label="Granularity">{selected.granularity_level}</Descriptions.Item>
455
+ <Descriptions.Item label="Created At">{new Date(selected.created_at).toLocaleString()}</Descriptions.Item>
456
+ <Descriptions.Item label="Updated At">{new Date(selected.updated_at).toLocaleString()}</Descriptions.Item>
457
+ </Descriptions>
458
+ ),
459
+ },
460
+ {
461
+ key: 'interface',
462
+ label: 'Interface',
463
+ children: selected.interface && (
464
+ <div>
465
+ <h4>Input Parameters</h4>
466
+ {selectedInputParams.length > 0
467
+ ? selectedInputParams.map(param => (
468
+ <div key={param.name} style={{ marginBottom: 8 }}>
469
+ <Text code>{param.name}</Text>
470
+ <Tag style={{ marginLeft: 8 }}>{param.type}</Tag>
471
+ {param.required && <Tag color="red">Required</Tag>}
472
+ <div style={{ color: '#666', fontSize: 12 }}>{param.description}</div>
473
+ </div>
474
+ ))
475
+ : <Text type="secondary">No input parameters</Text>}
476
+ <h4 style={{ marginTop: 16 }}>Output Parameters</h4>
477
+ {selectedOutputParams.length > 0
478
+ ? selectedOutputParams.map(param => (
479
+ <div key={param.name} style={{ marginBottom: 8 }}>
480
+ <Text code>{param.name}</Text>
481
+ <Tag style={{ marginLeft: 8 }}>{param.type}</Tag>
482
+ <div style={{ color: '#666', fontSize: 12 }}>{param.description}</div>
483
+ </div>
484
+ ))
485
+ : <Text type="secondary">No output parameters</Text>}
486
+ {selectedPreconditions.length > 0 && (
487
+ <>
488
+ <h4 style={{ marginTop: 16 }}>Preconditions</h4>
489
+ {selectedPreconditions.map((condition, index) => <div key={index}>- {condition}</div>)}
490
+ </>
491
+ )}
492
+ {selectedPostconditions.length > 0 && (
493
+ <>
494
+ <h4 style={{ marginTop: 16 }}>Postconditions</h4>
495
+ {selectedPostconditions.map((condition, index) => <div key={index}>- {condition}</div>)}
496
+ </>
497
+ )}
498
+ </div>
499
+ ),
500
+ },
501
+ {
502
+ key: 'impl',
503
+ label: 'Implementation',
504
+ children: selected.implementation && (
505
+ <div>
506
+ <Tag color="blue">{selected.implementation.language}</Tag>
507
+ {selected.implementation.code && (
508
+ <pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 8, marginTop: 8, overflow: 'auto', fontSize: 12 }}>
509
+ {selected.implementation.code}
510
+ </pre>
511
+ )}
512
+ {selected.implementation.prompt_template && (
513
+ <div style={{ background: '#f0f7ff', padding: 12, borderRadius: 8, marginTop: 8 }}>
514
+ {selected.implementation.prompt_template}
515
+ </div>
516
+ )}
517
+ {selectedSubSkillIds.length > 0 && (
518
+ <div style={{ marginTop: 8 }}>
519
+ <strong>Sub-Skills:</strong>
520
+ {selectedSubSkillIds.map(id => <Tag key={id}>{id}</Tag>)}
521
+ </div>
522
+ )}
523
+ </div>
524
+ ),
525
+ },
526
+ {
527
+ key: 'evidence',
528
+ label: 'Evidence',
529
+ children: (
530
+ <div>
531
+ <h4>Provenance</h4>
532
+ {selectedProvenance ? (
533
+ <Descriptions column={1} bordered size="small">
534
+ <Descriptions.Item label="Source type">{selectedProvenance.source_type}</Descriptions.Item>
535
+ <Descriptions.Item label="Source IDs">
536
+ {selectedProvenance.source_ids.length
537
+ ? selectedProvenance.source_ids.map(id => <Tag key={id}>{id}</Tag>)
538
+ : <Text type="secondary">None</Text>}
539
+ </Descriptions.Item>
540
+ <Descriptions.Item label="Parent Skills">
541
+ {selectedProvenance.parent_skill_ids.length
542
+ ? selectedProvenance.parent_skill_ids.map(id => <Tag key={id}>{id}</Tag>)
543
+ : <Text type="secondary">None</Text>}
544
+ </Descriptions.Item>
545
+ <Descriptions.Item label="Created by">{selectedProvenance.created_by_agent || 'unknown'}</Descriptions.Item>
546
+ <Descriptions.Item label="Context">
547
+ <pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 8, overflow: 'auto', fontSize: 12 }}>
548
+ {JSON.stringify(selectedProvenance.creation_context, null, 2)}
549
+ </pre>
550
+ </Descriptions.Item>
551
+ </Descriptions>
552
+ ) : (
553
+ <Text type="secondary">No provenance recorded.</Text>
554
+ )}
555
+
556
+ <h4 style={{ marginTop: 16 }}>Evaluation</h4>
557
+ {selectedEvaluation ? (
558
+ <Descriptions column={1} bordered size="small">
559
+ <Descriptions.Item label="Verifier specs">
560
+ <pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 8, overflow: 'auto', fontSize: 12 }}>
561
+ {JSON.stringify(selectedEvaluation.verifier_specs, null, 2)}
562
+ </pre>
563
+ </Descriptions.Item>
564
+ <Descriptions.Item label="Test cases">
565
+ {selectedEvaluation.test_case_refs.length
566
+ ? selectedEvaluation.test_case_refs.map(id => <Tag key={id}>{id}</Tag>)
567
+ : <Text type="secondary">None</Text>}
568
+ </Descriptions.Item>
569
+ <Descriptions.Item label="Benchmark tasks">
570
+ {selectedEvaluation.benchmark_task_ids.length
571
+ ? selectedEvaluation.benchmark_task_ids.map(id => <Tag key={id}>{id}</Tag>)
572
+ : <Text type="secondary">None</Text>}
573
+ </Descriptions.Item>
574
+ <Descriptions.Item label="Validation summary">
575
+ {selectedEvaluation.validation_summary || <Text type="secondary">None</Text>}
576
+ </Descriptions.Item>
577
+ </Descriptions>
578
+ ) : (
579
+ <Text type="secondary">No evaluation contract recorded.</Text>
580
+ )}
581
+ </div>
582
+ ),
583
+ },
584
+ {
585
+ key: 'timeline',
586
+ label: 'Timeline',
587
+ children: (
588
+ <Timeline items={selectedTimeline} />
589
+ ),
590
+ },
591
+ {
592
+ key: 'metrics',
593
+ label: 'Metrics',
594
+ children: (
595
+ <Descriptions column={1} bordered size="small">
596
+ <Descriptions.Item label="Total Executions">{selected.metrics.total_executions}</Descriptions.Item>
597
+ <Descriptions.Item label="Successful Executions">{selected.metrics.successful_executions}</Descriptions.Item>
598
+ <Descriptions.Item label="Failed Executions">{selected.metrics.failed_executions}</Descriptions.Item>
599
+ <Descriptions.Item label="Success Rate">
600
+ <Progress percent={Math.round(selected.metrics.success_rate * 100)} size="small" style={{ width: 120 }} />
601
+ </Descriptions.Item>
602
+ <Descriptions.Item label="Average Latency">{selected.metrics.avg_latency_ms.toFixed(0)}ms</Descriptions.Item>
603
+ <Descriptions.Item label="Usage Count">{selected.metrics.usage_count}</Descriptions.Item>
604
+ </Descriptions>
605
+ ),
606
+ },
607
+ ]}
608
+ />
609
+ )}
610
+ </Drawer>
611
+ </div>
612
+ )
613
+ }
skillwiki-frontend/src/pages/VersionControl.tsx ADDED
@@ -0,0 +1,1117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import {
3
+ Alert,
4
+ Badge,
5
+ Button,
6
+ Card,
7
+ Col,
8
+ Collapse,
9
+ Descriptions,
10
+ Drawer,
11
+ Empty,
12
+ Form,
13
+ Input,
14
+ Popconfirm,
15
+ Row,
16
+ Select,
17
+ Space,
18
+ Table,
19
+ Tag,
20
+ Timeline,
21
+ Typography,
22
+ message,
23
+ } from 'antd'
24
+ import type { TableColumnsType } from 'antd'
25
+ import {
26
+ BranchesOutlined,
27
+ DiffOutlined,
28
+ HistoryOutlined,
29
+ PlusOutlined,
30
+ ReloadOutlined,
31
+ RocketOutlined,
32
+ RollbackOutlined,
33
+ SaveOutlined,
34
+ TagOutlined,
35
+ } from '@ant-design/icons'
36
+ import { motion } from 'framer-motion'
37
+ import { useLocation } from 'react-router-dom'
38
+ import { lifecycleApi, skillsApi } from '@/api/client'
39
+ import { getApiErrorMessage } from '@/api/errors'
40
+ import type {
41
+ BusinessDiffEntry,
42
+ BusinessDiffSummary,
43
+ NewVersionRequest,
44
+ SkillReleaseRecord,
45
+ SkillRollbackRecord,
46
+ SkillSummary,
47
+ SnapshotCommitResponse,
48
+ SnapshotDiffResponse,
49
+ SnapshotHistoryItem,
50
+ StructuredDiffEntry,
51
+ } from '@/api/types'
52
+
53
+ const { Text } = Typography
54
+
55
+ const STATE_COLOR: Record<string, string> = {
56
+ S4: 'green',
57
+ S3: 'cyan',
58
+ S2: 'blue',
59
+ S1: 'orange',
60
+ S5: 'gold',
61
+ S6: 'red',
62
+ S7: 'default',
63
+ S0: 'purple',
64
+ }
65
+
66
+ const STATE_LABEL: Record<string, string> = {
67
+ S0: 'Raw',
68
+ S1: 'Candidate',
69
+ S2: 'Draft',
70
+ S3: 'Verified',
71
+ S4: 'Released',
72
+ S5: 'Degraded',
73
+ S6: 'Deprecated',
74
+ S7: 'Archived',
75
+ }
76
+
77
+ interface DiffLine {
78
+ field: string
79
+ type: 'modified' | 'added' | 'removed'
80
+ old_value: string
81
+ new_value: string
82
+ old_lines: string[]
83
+ new_lines: string[]
84
+ }
85
+
86
+ interface DiffData {
87
+ skill_id: string
88
+ skill_name?: string
89
+ current_version?: string
90
+ business_diff?: BusinessDiffEntry[]
91
+ business_summary?: BusinessDiffSummary
92
+ breaking?: boolean
93
+ suggested_bump?: 'major' | 'minor' | 'patch'
94
+ history?: {
95
+ record_id: string
96
+ from_version?: string
97
+ to_version: string
98
+ change_type: string
99
+ summary: string
100
+ author: string
101
+ created_at: string
102
+ diff: DiffLine[]
103
+ is_breaking: boolean
104
+ }[]
105
+ }
106
+
107
+ interface VersionLabForm {
108
+ bump: 'major' | 'minor' | 'patch'
109
+ description?: string
110
+ tags?: string
111
+ interface_json?: string
112
+ implementation_json?: string
113
+ evaluation_json?: string
114
+ }
115
+
116
+ function semverCompare(a: string, b: string) {
117
+ const pa = a.split('.').map(Number)
118
+ const pb = b.split('.').map(Number)
119
+ for (let i = 0; i < 3; i += 1) {
120
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0)
121
+ }
122
+ return 0
123
+ }
124
+
125
+ function shortRef(ref?: string | null) {
126
+ if (!ref) return ''
127
+ return ref.length > 14 ? ref.slice(0, 12) : ref
128
+ }
129
+
130
+ function dateText(value?: string) {
131
+ if (!value) return '-'
132
+ const parsed = new Date(value)
133
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString()
134
+ }
135
+
136
+ function diffField(entry: StructuredDiffEntry) {
137
+ return String(entry.field_path ?? entry.field ?? 'skill')
138
+ }
139
+
140
+ function diffChange(entry: StructuredDiffEntry) {
141
+ return String(entry.change_type ?? entry.type ?? 'modified')
142
+ }
143
+
144
+ function diffCategory(entry: StructuredDiffEntry) {
145
+ return String(entry.category ?? 'general')
146
+ }
147
+
148
+ function isBreakingDiff(entry: StructuredDiffEntry) {
149
+ return entry.is_breaking === true || diffCategory(entry).toLowerCase().includes('breaking')
150
+ }
151
+
152
+ function renderValue(value: unknown) {
153
+ if (value === undefined || value === null || value === '') {
154
+ return <Text type="secondary">-</Text>
155
+ }
156
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
157
+ return <Text>{String(value)}</Text>
158
+ }
159
+ return (
160
+ <pre style={{
161
+ margin: 0,
162
+ maxHeight: 120,
163
+ overflow: 'auto',
164
+ fontSize: 12,
165
+ background: '#f6f8fa',
166
+ borderRadius: 6,
167
+ padding: 8,
168
+ }}
169
+ >
170
+ {JSON.stringify(value, null, 2)}
171
+ </pre>
172
+ )
173
+ }
174
+
175
+ function DiffView({ lines }: { lines: DiffLine[] }) {
176
+ if (!lines.length) return <Empty description="No version diff records" image={Empty.PRESENTED_IMAGE_SIMPLE} />
177
+ return (
178
+ <div style={{ fontFamily: 'monospace', fontSize: 12 }}>
179
+ {lines.map((line, i) => (
180
+ <div key={`${line.field}-${i}`} style={{ marginBottom: 12 }}>
181
+ <div style={{ fontWeight: 600, color: '#1677ff', marginBottom: 4 }}>
182
+ {line.field}
183
+ <Tag color={line.type === 'removed' ? 'red' : line.type === 'added' ? 'green' : 'orange'} style={{ marginLeft: 8 }}>
184
+ {line.type}
185
+ </Tag>
186
+ </div>
187
+ <div style={{ display: 'flex', gap: 8 }}>
188
+ {line.old_lines.length > 0 && (
189
+ <div style={{ flex: 1, background: '#fff1f0', borderRadius: 4, padding: '4px 8px', border: '1px solid #ffccc7' }}>
190
+ {line.old_lines.map((item, index) => (
191
+ <div key={`${line.field}-old-${index}`} style={{ color: '#cf1322' }}>- {item}</div>
192
+ ))}
193
+ </div>
194
+ )}
195
+ {line.new_lines.length > 0 && (
196
+ <div style={{ flex: 1, background: '#f6ffed', borderRadius: 4, padding: '4px 8px', border: '1px solid #b7eb8f' }}>
197
+ {line.new_lines.map((item, index) => (
198
+ <div key={`${line.field}-new-${index}`} style={{ color: '#389e0d' }}>+ {item}</div>
199
+ ))}
200
+ </div>
201
+ )}
202
+ </div>
203
+ </div>
204
+ ))}
205
+ </div>
206
+ )
207
+ }
208
+
209
+ function StructuredDiffTable({ diff }: { diff: SnapshotDiffResponse }) {
210
+ const columns: TableColumnsType<StructuredDiffEntry> = [
211
+ {
212
+ title: 'Field',
213
+ render: (_, record) => <Text code>{diffField(record)}</Text>,
214
+ },
215
+ {
216
+ title: 'Change',
217
+ render: (_, record) => (
218
+ <Space>
219
+ <Tag color={diffChange(record) === 'removed' ? 'red' : diffChange(record) === 'added' ? 'green' : 'blue'}>
220
+ {diffChange(record)}
221
+ </Tag>
222
+ <Tag>{diffCategory(record)}</Tag>
223
+ {isBreakingDiff(record) && <Tag color="red">BREAKING</Tag>}
224
+ </Space>
225
+ ),
226
+ },
227
+ {
228
+ title: 'Before',
229
+ dataIndex: 'old_value',
230
+ render: renderValue,
231
+ },
232
+ {
233
+ title: 'After',
234
+ dataIndex: 'new_value',
235
+ render: renderValue,
236
+ },
237
+ ]
238
+
239
+ return (
240
+ <Table
241
+ dataSource={diff.diffs}
242
+ columns={columns}
243
+ rowKey={(_, index) => `${diff.from_ref}-${diff.to_ref}-${index}`}
244
+ pagination={false}
245
+ size="small"
246
+ locale={{ emptyText: 'No structured field changes' }}
247
+ />
248
+ )
249
+ }
250
+
251
+ function jsonText(value: unknown) {
252
+ return JSON.stringify(value ?? {}, null, 2)
253
+ }
254
+
255
+ function parseJsonField(value: string | undefined, label: string) {
256
+ const text = (value ?? '').trim()
257
+ if (!text) return undefined
258
+ try {
259
+ return JSON.parse(text)
260
+ } catch {
261
+ throw new Error(`${label} must be valid JSON`)
262
+ }
263
+ }
264
+
265
+ function tagList(value?: string) {
266
+ return (value ?? '')
267
+ .split(',')
268
+ .map(item => item.trim())
269
+ .filter(Boolean)
270
+ }
271
+
272
+ function businessDiffColor(row: BusinessDiffEntry) {
273
+ if (row.is_breaking) return 'red'
274
+ if (row.category === 'interface') return 'orange'
275
+ if (row.category === 'implementation') return 'blue'
276
+ return 'default'
277
+ }
278
+
279
+ function BusinessDiffView({ diff, summary }: { diff: BusinessDiffEntry[]; summary?: BusinessDiffSummary }) {
280
+ if (!diff.length) return <Empty description="No business-level changes" image={Empty.PRESENTED_IMAGE_SIMPLE} />
281
+ return (
282
+ <Space direction="vertical" size={12} style={{ width: '100%' }}>
283
+ {summary && (
284
+ <Alert
285
+ type={summary.breaking ? 'warning' : 'info'}
286
+ showIcon
287
+ message={summary.summary}
288
+ description={`Suggested bump: ${summary.suggested_bump}. Categories: ${summary.categories.join(', ') || 'none'}.`}
289
+ />
290
+ )}
291
+ <Table
292
+ size="small"
293
+ pagination={false}
294
+ rowKey={(record, index) => `${record.field}-${index}`}
295
+ dataSource={diff}
296
+ columns={[
297
+ {
298
+ title: 'Field',
299
+ render: (_, record) => <Text code>{record.field}</Text>,
300
+ },
301
+ {
302
+ title: 'Change',
303
+ render: (_, record) => (
304
+ <Space wrap>
305
+ <Tag color={businessDiffColor(record)}>{record.change_type}</Tag>
306
+ <Tag>{record.category}</Tag>
307
+ {record.is_breaking && <Tag color="red">BREAKING</Tag>}
308
+ </Space>
309
+ ),
310
+ },
311
+ {
312
+ title: 'Before',
313
+ render: (_, record) => renderValue(record.old_value),
314
+ },
315
+ {
316
+ title: 'After',
317
+ render: (_, record) => renderValue(record.new_value),
318
+ },
319
+ ]}
320
+ />
321
+ </Space>
322
+ )
323
+ }
324
+
325
+ export default function VersionControl() {
326
+ const location = useLocation()
327
+ const [form] = Form.useForm<VersionLabForm>()
328
+ const [skills, setSkills] = useState<SkillSummary[]>([])
329
+ const [selectedId, setSelectedId] = useState<string | null>(null)
330
+ const [selectedFull, setSelectedFull] = useState<import('@/api/types').SkillFull | null>(null)
331
+ const [versions, setVersions] = useState<SkillSummary[]>([])
332
+ const [loadingVersions, setLoadingVersions] = useState(false)
333
+ const [bumpLoading, setBumpLoading] = useState(false)
334
+ const [drawerSkill, setDrawerSkill] = useState<SkillSummary | null>(null)
335
+ const [diffData, setDiffData] = useState<DiffData | null>(null)
336
+ const [loadingDiff, setLoadingDiff] = useState(false)
337
+ const [snapshotPath, setSnapshotPath] = useState('')
338
+ const [snapshotHistory, setSnapshotHistory] = useState<SnapshotHistoryItem[]>([])
339
+ const [historyLoading, setHistoryLoading] = useState(false)
340
+ const [snapshotLoading, setSnapshotLoading] = useState(false)
341
+ const [snapshotDiffLoading, setSnapshotDiffLoading] = useState(false)
342
+ const [releaseTagLoading, setReleaseTagLoading] = useState(false)
343
+ const [restoreLoading, setRestoreLoading] = useState(false)
344
+ const [sourceRef, setSourceRef] = useState('')
345
+ const [snapshotDiff, setSnapshotDiff] = useState<SnapshotDiffResponse | null>(null)
346
+ const [lastSnapshot, setLastSnapshot] = useState<SnapshotCommitResponse | null>(null)
347
+ const [lastRelease, setLastRelease] = useState<SkillReleaseRecord | null>(null)
348
+ const [lastRestore, setLastRestore] = useState<SkillRollbackRecord | null>(null)
349
+
350
+ useEffect(() => {
351
+ skillsApi.list({ limit: 200 }).then(setSkills).catch(err => {
352
+ message.error(getApiErrorMessage(err, 'Load Skills failed'))
353
+ })
354
+ }, [])
355
+
356
+ const loadVersions = useCallback(async (id: string) => {
357
+ setLoadingVersions(true)
358
+ try {
359
+ const nextVersions = await skillsApi.versions(id)
360
+ setVersions(nextVersions.sort((a, b) => semverCompare(a.version, b.version)))
361
+ } finally {
362
+ setLoadingVersions(false)
363
+ }
364
+ }, [])
365
+
366
+ const loadDiff = useCallback(async (id: string) => {
367
+ setLoadingDiff(true)
368
+ try {
369
+ const data = await lifecycleApi.getDiff(id)
370
+ setDiffData(data as unknown as DiffData)
371
+ } catch (err) {
372
+ message.error(getApiErrorMessage(err, 'Load version diff failed'))
373
+ } finally {
374
+ setLoadingDiff(false)
375
+ }
376
+ }, [])
377
+
378
+ const loadSelectedFull = useCallback(async (id: string) => {
379
+ try {
380
+ const full = await skillsApi.getFull(id)
381
+ setSelectedFull(full)
382
+ form.setFieldsValue({
383
+ bump: 'patch',
384
+ description: full.description,
385
+ tags: full.tags.join(', '),
386
+ interface_json: jsonText(full.interface),
387
+ implementation_json: jsonText(full.implementation ?? { language: 'python', prompt_template: '' }),
388
+ evaluation_json: jsonText(full.evaluation),
389
+ })
390
+ } catch (err) {
391
+ setSelectedFull(null)
392
+ message.warning(getApiErrorMessage(err, 'Load editable Skill details failed'))
393
+ }
394
+ }, [form])
395
+
396
+ const loadSnapshotHistory = useCallback(async (id: string, options: { silent?: boolean } = {}) => {
397
+ setHistoryLoading(true)
398
+ try {
399
+ const data = await lifecycleApi.snapshotHistory(id)
400
+ setSnapshotPath(data.snapshot_path)
401
+ setSnapshotHistory(data.history)
402
+ setSourceRef(prev => prev || data.history[0]?.commit_hash || '')
403
+ } catch (err) {
404
+ setSnapshotPath('')
405
+ setSnapshotHistory([])
406
+ if (!options.silent) {
407
+ message.warning(getApiErrorMessage(err, 'Snapshot history is not available yet'))
408
+ }
409
+ } finally {
410
+ setHistoryLoading(false)
411
+ }
412
+ }, [])
413
+
414
+ const handleSelect = useCallback((id: string, options: { loadCurrentDiff?: boolean } = {}) => {
415
+ setSelectedId(id)
416
+ setDiffData(null)
417
+ setSnapshotDiff(null)
418
+ setLastSnapshot(null)
419
+ setLastRelease(null)
420
+ setLastRestore(null)
421
+ setSourceRef('')
422
+ void loadVersions(id)
423
+ void loadSelectedFull(id)
424
+ void loadSnapshotHistory(id, { silent: true })
425
+ if (options.loadCurrentDiff) {
426
+ void loadDiff(id)
427
+ }
428
+ }, [loadDiff, loadSelectedFull, loadSnapshotHistory, loadVersions])
429
+
430
+ useEffect(() => {
431
+ const timer = window.setTimeout(() => {
432
+ const query = new URLSearchParams(location.search)
433
+ const querySkillId = query.get('skill_id')
434
+ if (!querySkillId || selectedId === querySkillId) return
435
+ if (skills.length > 0 && skills.some(skill => skill.skill_id === querySkillId)) {
436
+ handleSelect(querySkillId, { loadCurrentDiff: Boolean(query.get('proposal_id')) })
437
+ }
438
+ }, 0)
439
+ return () => window.clearTimeout(timer)
440
+ }, [handleSelect, location.search, selectedId, skills])
441
+
442
+ const handleBump = async (bump: 'major' | 'minor' | 'patch') => {
443
+ if (!selectedId) return
444
+ setBumpLoading(true)
445
+ try {
446
+ const newSkill = await lifecycleApi.newVersion(selectedId, bump)
447
+ message.success(`Created version v${newSkill.version}`)
448
+ const allSkills = await skillsApi.list({ limit: 200 })
449
+ setSkills(allSkills)
450
+ void loadVersions(selectedId)
451
+ } catch (err) {
452
+ message.error(getApiErrorMessage(err, 'Create version failed'))
453
+ } finally {
454
+ setBumpLoading(false)
455
+ }
456
+ }
457
+
458
+ const handleEditableVersion = async (values: VersionLabForm) => {
459
+ if (!selectedId || !selectedFull) return
460
+ setBumpLoading(true)
461
+ try {
462
+ const nextInterface = parseJsonField(values.interface_json, 'Interface')
463
+ const nextImplementation = parseJsonField(values.implementation_json, 'Implementation')
464
+ const nextEvaluation = parseJsonField(values.evaluation_json, 'Evaluation')
465
+ const request: NewVersionRequest = {
466
+ bump: values.bump,
467
+ description: values.description,
468
+ tags: tagList(values.tags),
469
+ interface: nextInterface,
470
+ implementation: nextImplementation,
471
+ evaluation: nextEvaluation,
472
+ author: 'version_lab',
473
+ }
474
+ const newSkill = await lifecycleApi.newVersion(selectedId, request)
475
+ message.success(`Created draft version v${newSkill.version}`)
476
+ const allSkills = await skillsApi.list({ limit: 200 })
477
+ setSkills(allSkills)
478
+ setSelectedId(newSkill.skill_id)
479
+ await loadVersions(newSkill.skill_id)
480
+ await loadSelectedFull(newSkill.skill_id)
481
+ await loadDiff(newSkill.skill_id)
482
+ } catch (err) {
483
+ message.error(err instanceof Error ? err.message : getApiErrorMessage(err, 'Create editable version failed'))
484
+ } finally {
485
+ setBumpLoading(false)
486
+ }
487
+ }
488
+
489
+ const handleRelease = async (id: string) => {
490
+ try {
491
+ await lifecycleApi.release(id)
492
+ message.success('Released')
493
+ if (selectedId) void loadVersions(selectedId)
494
+ } catch (err) {
495
+ message.error(getApiErrorMessage(err, 'Release failed'))
496
+ }
497
+ }
498
+
499
+ const handleCreateSnapshot = async () => {
500
+ if (!selectedId) return
501
+ setSnapshotLoading(true)
502
+ try {
503
+ const snapshot = await lifecycleApi.createSnapshot(selectedId, { author: 'human_reviewer' })
504
+ setLastSnapshot(snapshot)
505
+ setSourceRef(snapshot.commit)
506
+ message.success(`Snapshot commit ${shortRef(snapshot.commit)} created`)
507
+ await loadSnapshotHistory(selectedId, { silent: true })
508
+ } catch (err) {
509
+ message.error(getApiErrorMessage(err, 'Create snapshot commit failed'))
510
+ } finally {
511
+ setSnapshotLoading(false)
512
+ }
513
+ }
514
+
515
+ const handleSnapshotDiff = async (ref = sourceRef) => {
516
+ if (!selectedId || !ref.trim()) {
517
+ message.warning('Select or enter a base commit/tag first')
518
+ return
519
+ }
520
+ setSnapshotDiffLoading(true)
521
+ try {
522
+ const data = await lifecycleApi.snapshotDiff(selectedId, {
523
+ from_ref: ref.trim(),
524
+ to_ref: 'HEAD',
525
+ })
526
+ setSnapshotDiff(data)
527
+ } catch (err) {
528
+ message.error(getApiErrorMessage(err, 'Load snapshot structured diff failed'))
529
+ } finally {
530
+ setSnapshotDiffLoading(false)
531
+ }
532
+ }
533
+
534
+ const handleReleaseTag = async (ref = sourceRef || 'HEAD') => {
535
+ if (!selectedId) return
536
+ setReleaseTagLoading(true)
537
+ try {
538
+ const release = await lifecycleApi.releaseTag(selectedId, ref.trim() || 'HEAD')
539
+ setLastRelease(release)
540
+ message.success(`Release tag ${release.tag_name} created`)
541
+ } catch (err) {
542
+ message.error(getApiErrorMessage(err, 'Create release tag failed'))
543
+ } finally {
544
+ setReleaseTagLoading(false)
545
+ }
546
+ }
547
+
548
+ const handleRestoreCommit = async (ref = sourceRef) => {
549
+ if (!selectedId || !ref.trim()) {
550
+ message.warning('Select or enter a source commit/tag first')
551
+ return
552
+ }
553
+ setRestoreLoading(true)
554
+ try {
555
+ const restore = await lifecycleApi.restoreSnapshot(selectedId, ref.trim())
556
+ setLastRestore(restore)
557
+ message.success(`Restore commit ${shortRef(restore.restore_commit)} created`)
558
+ await loadSnapshotHistory(selectedId, { silent: true })
559
+ } catch (err) {
560
+ message.error(getApiErrorMessage(err, 'Create restore commit failed'))
561
+ } finally {
562
+ setRestoreLoading(false)
563
+ }
564
+ }
565
+
566
+ const selectedSkill = selectedId ? skills.find(skill => skill.skill_id === selectedId) : null
567
+ const currentDiffHistory = diffData?.history ?? []
568
+ const currentBusinessDiff = diffData?.business_diff ?? []
569
+ const queryProposalId = new URLSearchParams(location.search).get('proposal_id')
570
+
571
+ const versionColumns: TableColumnsType<SkillSummary> = [
572
+ {
573
+ title: 'Version',
574
+ dataIndex: 'version',
575
+ render: (version: string, record) => (
576
+ <Space>
577
+ <TagOutlined style={{ color: '#1677ff' }} />
578
+ <Text code style={{ cursor: 'pointer' }} onClick={() => setDrawerSkill(record)}>v{version}</Text>
579
+ {record.skill_id === selectedId && <Tag color="blue">HEAD</Tag>}
580
+ </Space>
581
+ ),
582
+ },
583
+ {
584
+ title: 'State',
585
+ dataIndex: 'state',
586
+ render: (state: string) => <Badge color={STATE_COLOR[state] || 'default'} text={STATE_LABEL[state] || state} />,
587
+ },
588
+ {
589
+ title: 'Created',
590
+ dataIndex: 'created_at',
591
+ render: dateText,
592
+ },
593
+ {
594
+ title: 'Success Rate',
595
+ dataIndex: 'metrics',
596
+ render: (metrics: SkillSummary['metrics']) =>
597
+ metrics.total_executions >= 5
598
+ ? `${(metrics.success_rate * 100).toFixed(1)}%`
599
+ : <Text type="secondary">N/A</Text>,
600
+ },
601
+ {
602
+ title: 'Actions',
603
+ render: (_, record) => (
604
+ <Space>
605
+ {(record.state === 'S2' || record.state === 'S3') && (
606
+ <Popconfirm title="Release this version?" onConfirm={() => handleRelease(record.skill_id)}>
607
+ <Button size="small" type="primary" icon={<RocketOutlined />}>Release</Button>
608
+ </Popconfirm>
609
+ )}
610
+ </Space>
611
+ ),
612
+ },
613
+ ]
614
+
615
+ const snapshotColumns: TableColumnsType<SnapshotHistoryItem> = [
616
+ {
617
+ title: 'Commit',
618
+ dataIndex: 'commit_hash',
619
+ render: (commit: string) => <Text code copyable={{ text: commit }}>{shortRef(commit)}</Text>,
620
+ },
621
+ {
622
+ title: 'Subject',
623
+ dataIndex: 'subject',
624
+ render: (subject: string) => <Text>{subject}</Text>,
625
+ },
626
+ {
627
+ title: 'Author',
628
+ dataIndex: 'author',
629
+ width: 140,
630
+ },
631
+ {
632
+ title: 'Time',
633
+ dataIndex: 'authored_at',
634
+ render: dateText,
635
+ width: 180,
636
+ },
637
+ {
638
+ title: 'Paths',
639
+ dataIndex: 'changed_paths',
640
+ render: (paths: string[]) => (
641
+ <Space wrap>
642
+ {paths.slice(0, 2).map(path => <Tag key={path}>{path}</Tag>)}
643
+ {paths.length > 2 && <Tag>+{paths.length - 2}</Tag>}
644
+ </Space>
645
+ ),
646
+ },
647
+ {
648
+ title: 'Actions',
649
+ render: (_, record) => (
650
+ <Space>
651
+ <Button size="small" icon={<DiffOutlined />} onClick={() => { setSourceRef(record.commit_hash); void handleSnapshotDiff(record.commit_hash) }}>
652
+ Diff
653
+ </Button>
654
+ <Button size="small" icon={<TagOutlined />} loading={releaseTagLoading} onClick={() => handleReleaseTag(record.commit_hash)}>
655
+ Tag
656
+ </Button>
657
+ <Popconfirm
658
+ title="Create a restore commit from this snapshot?"
659
+ description="This writes a new restore commit; it does not reset Git history."
660
+ onConfirm={() => handleRestoreCommit(record.commit_hash)}
661
+ >
662
+ <Button size="small" danger icon={<RollbackOutlined />} loading={restoreLoading}>
663
+ Restore commit
664
+ </Button>
665
+ </Popconfirm>
666
+ </Space>
667
+ ),
668
+ },
669
+ ]
670
+
671
+ return (
672
+ <div style={{ padding: 24 }}>
673
+ <motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
674
+ <h2 style={{ fontWeight: 700, marginBottom: 4 }}>Version Governance</h2>
675
+ <p style={{ color: '#666', marginBottom: 24 }}>
676
+ Review Skill versions, Git snapshots, structured diffs, release tags, and restore commits.
677
+ </p>
678
+ </motion.div>
679
+
680
+ {queryProposalId && (
681
+ <Alert
682
+ type="info"
683
+ showIcon
684
+ style={{ marginBottom: 16 }}
685
+ message="Accepted maintenance proposal"
686
+ description={(
687
+ <Space direction="vertical" size={2}>
688
+ <Text>
689
+ Proposal <Text code>{queryProposalId.slice(0, 8)}</Text> is ready for B-side review.
690
+ </Text>
691
+ <Text type="secondary">
692
+ Submit a patched Skill through the maintenance review endpoint before creating any live Skill change.
693
+ </Text>
694
+ </Space>
695
+ )}
696
+ />
697
+ )}
698
+
699
+ <Row gutter={[16, 16]}>
700
+ <Col xs={24} lg={16}>
701
+ <Card
702
+ title={<span><BranchesOutlined /> Version history</span>}
703
+ variant="borderless"
704
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}
705
+ extra={selectedId && (
706
+ <Space wrap>
707
+ <Button size="small" icon={<DiffOutlined />} loading={loadingDiff} onClick={() => loadDiff(selectedId)}>
708
+ Legacy diff
709
+ </Button>
710
+ {(['patch', 'minor', 'major'] as const).map(bump => (
711
+ <Button
712
+ key={bump}
713
+ size="small"
714
+ icon={<PlusOutlined />}
715
+ loading={bumpLoading}
716
+ onClick={() => handleBump(bump)}
717
+ >
718
+ {bump}
719
+ </Button>
720
+ ))}
721
+ </Space>
722
+ )}
723
+ >
724
+ <div style={{ marginBottom: 16 }}>
725
+ <Select
726
+ value={selectedId ?? undefined}
727
+ placeholder="Select a Skill"
728
+ style={{ width: '100%' }}
729
+ onChange={value => handleSelect(value)}
730
+ showSearch
731
+ filterOption={(input, option) =>
732
+ String(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
733
+ }
734
+ options={[...new Map(skills.map(skill => [skill.name, skill])).values()].map(skill => ({
735
+ label: `${skill.name} (${STATE_LABEL[skill.state] || skill.state})`,
736
+ value: skill.skill_id,
737
+ }))}
738
+ />
739
+ </div>
740
+
741
+ <Table
742
+ dataSource={versions}
743
+ columns={versionColumns}
744
+ rowKey="skill_id"
745
+ loading={loadingVersions}
746
+ size="small"
747
+ pagination={false}
748
+ locale={{ emptyText: selectedId ? 'No version records' : 'Select a Skill first' }}
749
+ />
750
+ </Card>
751
+
752
+ <Card
753
+ title={<span><PlusOutlined /> Version Lab</span>}
754
+ variant="borderless"
755
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginTop: 16 }}
756
+ >
757
+ {!selectedId || !selectedFull ? (
758
+ <Empty description="Select a Skill to create an editable draft version" />
759
+ ) : (
760
+ <Form
761
+ form={form}
762
+ layout="vertical"
763
+ initialValues={{ bump: 'patch' }}
764
+ onFinish={handleEditableVersion}
765
+ >
766
+ <Alert
767
+ type="info"
768
+ showIcon
769
+ style={{ marginBottom: 16 }}
770
+ message="Editable versions are created as Drafts"
771
+ description="Changing the interface or implementation creates a new S2 draft version that should be sent through harness verification before release."
772
+ />
773
+ <Row gutter={12}>
774
+ <Col xs={24} md={8}>
775
+ <Form.Item name="bump" label="Version bump">
776
+ <Select
777
+ options={[
778
+ { label: 'patch', value: 'patch' },
779
+ { label: 'minor', value: 'minor' },
780
+ { label: 'major', value: 'major' },
781
+ ]}
782
+ />
783
+ </Form.Item>
784
+ </Col>
785
+ <Col xs={24} md={16}>
786
+ <Form.Item name="tags" label="Tags">
787
+ <Input placeholder="comma-separated tags" />
788
+ </Form.Item>
789
+ </Col>
790
+ </Row>
791
+ <Form.Item name="description" label="Description">
792
+ <Input.TextArea rows={2} />
793
+ </Form.Item>
794
+ <Collapse
795
+ items={[
796
+ {
797
+ key: 'interface',
798
+ label: 'Interface JSON',
799
+ children: (
800
+ <Form.Item name="interface_json" noStyle>
801
+ <Input.TextArea rows={10} style={{ fontFamily: 'monospace', fontSize: 12 }} />
802
+ </Form.Item>
803
+ ),
804
+ },
805
+ {
806
+ key: 'implementation',
807
+ label: 'Implementation JSON',
808
+ children: (
809
+ <Form.Item name="implementation_json" noStyle>
810
+ <Input.TextArea rows={8} style={{ fontFamily: 'monospace', fontSize: 12 }} />
811
+ </Form.Item>
812
+ ),
813
+ },
814
+ {
815
+ key: 'evaluation',
816
+ label: 'Evaluation JSON',
817
+ children: (
818
+ <Form.Item name="evaluation_json" noStyle>
819
+ <Input.TextArea rows={8} style={{ fontFamily: 'monospace', fontSize: 12 }} />
820
+ </Form.Item>
821
+ ),
822
+ },
823
+ ]}
824
+ />
825
+ <Space wrap style={{ marginTop: 16 }}>
826
+ <Button type="primary" htmlType="submit" icon={<PlusOutlined />} loading={bumpLoading}>
827
+ Create draft version
828
+ </Button>
829
+ <Button icon={<ReloadOutlined />} onClick={() => loadSelectedFull(selectedId)}>
830
+ Reset editor
831
+ </Button>
832
+ </Space>
833
+ </Form>
834
+ )}
835
+ </Card>
836
+
837
+ <Card
838
+ title={<span><SaveOutlined /> Git-backed governance snapshots</span>}
839
+ variant="borderless"
840
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginTop: 16 }}
841
+ extra={selectedId && (
842
+ <Space wrap>
843
+ <Button icon={<SaveOutlined />} loading={snapshotLoading} onClick={handleCreateSnapshot}>
844
+ Create snapshot
845
+ </Button>
846
+ <Button icon={<ReloadOutlined />} loading={historyLoading} onClick={() => loadSnapshotHistory(selectedId)}>
847
+ Refresh history
848
+ </Button>
849
+ </Space>
850
+ )}
851
+ >
852
+ {!selectedId ? (
853
+ <Empty description="Select a Skill to manage snapshots" />
854
+ ) : (
855
+ <Space direction="vertical" size={12} style={{ width: '100%' }}>
856
+ {snapshotPath && (
857
+ <Text type="secondary">Snapshot path: <Text code>{snapshotPath}</Text></Text>
858
+ )}
859
+ {lastSnapshot && (
860
+ <Alert
861
+ type="success"
862
+ showIcon
863
+ message="Snapshot commit created"
864
+ description={<Text code copyable={{ text: lastSnapshot.commit }}>{shortRef(lastSnapshot.commit)}</Text>}
865
+ />
866
+ )}
867
+ {lastRelease && (
868
+ <Alert
869
+ type="success"
870
+ showIcon
871
+ message="Release tag created"
872
+ description={<Text code copyable={{ text: lastRelease.tag_name }}>{lastRelease.tag_name}</Text>}
873
+ />
874
+ )}
875
+ {lastRestore && (
876
+ <Alert
877
+ type="warning"
878
+ showIcon
879
+ message="Restore commit created"
880
+ description={(
881
+ <Space direction="vertical" size={2}>
882
+ <Text code copyable={{ text: lastRestore.restore_commit }}>{shortRef(lastRestore.restore_commit)}</Text>
883
+ <Text type="secondary">{lastRestore.commit_message}</Text>
884
+ </Space>
885
+ )}
886
+ />
887
+ )}
888
+
889
+ <Space.Compact style={{ width: '100%' }}>
890
+ <Input
891
+ value={sourceRef}
892
+ onChange={event => setSourceRef(event.target.value)}
893
+ placeholder="Base commit, release tag, or source ref"
894
+ />
895
+ <Button icon={<DiffOutlined />} loading={snapshotDiffLoading} onClick={() => handleSnapshotDiff()}>
896
+ Structured diff
897
+ </Button>
898
+ <Button icon={<TagOutlined />} loading={releaseTagLoading} onClick={() => handleReleaseTag()}>
899
+ Release tag
900
+ </Button>
901
+ <Popconfirm
902
+ title="Create restore commit from this ref?"
903
+ description="This creates a new commit from the selected snapshot; it does not reset history."
904
+ onConfirm={() => handleRestoreCommit()}
905
+ >
906
+ <Button danger icon={<RollbackOutlined />} loading={restoreLoading}>
907
+ Restore commit
908
+ </Button>
909
+ </Popconfirm>
910
+ </Space.Compact>
911
+
912
+ <Table
913
+ dataSource={snapshotHistory}
914
+ columns={snapshotColumns}
915
+ rowKey="commit_hash"
916
+ loading={historyLoading}
917
+ size="small"
918
+ pagination={{ pageSize: 5 }}
919
+ locale={{ emptyText: 'No snapshot commits yet' }}
920
+ />
921
+
922
+ {snapshotDiff && (
923
+ <Card
924
+ size="small"
925
+ title={(
926
+ <Space>
927
+ <Text code>{`${shortRef(snapshotDiff.from_ref)} -> ${shortRef(snapshotDiff.to_ref)}`}</Text>
928
+ <Tag color={snapshotDiff.has_breaking_changes ? 'red' : 'green'}>
929
+ {snapshotDiff.has_breaking_changes ? 'BREAKING' : 'compatible'}
930
+ </Tag>
931
+ </Space>
932
+ )}
933
+ variant="borderless"
934
+ style={{ background: '#fafafa' }}
935
+ >
936
+ <Alert
937
+ type={snapshotDiff.has_breaking_changes ? 'warning' : 'info'}
938
+ showIcon
939
+ style={{ marginBottom: 12 }}
940
+ message={snapshotDiff.review_recommendation}
941
+ />
942
+ <StructuredDiffTable diff={snapshotDiff} />
943
+ {snapshotDiff.raw_diff && (
944
+ <Collapse
945
+ style={{ marginTop: 12 }}
946
+ items={[{
947
+ key: 'raw',
948
+ label: 'Raw Git diff',
949
+ children: (
950
+ <pre style={{
951
+ margin: 0,
952
+ maxHeight: 260,
953
+ overflow: 'auto',
954
+ fontSize: 12,
955
+ background: '#111827',
956
+ color: '#f9fafb',
957
+ borderRadius: 6,
958
+ padding: 12,
959
+ }}
960
+ >
961
+ {snapshotDiff.raw_diff}
962
+ </pre>
963
+ ),
964
+ }]}
965
+ />
966
+ )}
967
+ </Card>
968
+ )}
969
+ </Space>
970
+ )}
971
+ </Card>
972
+
973
+ {diffData && (
974
+ <Card
975
+ title={<span><DiffOutlined /> Version change history - {diffData.skill_name ?? selectedSkill?.name ?? 'Skill'}</span>}
976
+ variant="borderless"
977
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginTop: 16 }}
978
+ >
979
+ {currentBusinessDiff.length > 0 && (
980
+ <div style={{ marginBottom: 16 }}>
981
+ <BusinessDiffView diff={currentBusinessDiff} summary={diffData.business_summary} />
982
+ </div>
983
+ )}
984
+ {currentDiffHistory.length === 0 ? (
985
+ <Empty description="No version change records" />
986
+ ) : (
987
+ <Collapse
988
+ items={currentDiffHistory.map(item => ({
989
+ key: item.record_id,
990
+ label: (
991
+ <Space wrap>
992
+ <Text code>{`${item.from_version ?? 'previous'} -> ${item.to_version}`}</Text>
993
+ <Tag color={item.is_breaking ? 'red' : 'blue'}>{item.change_type}</Tag>
994
+ {item.is_breaking && <Tag color="red">BREAKING</Tag>}
995
+ <Text type="secondary" style={{ fontSize: 11 }}>{item.summary}</Text>
996
+ </Space>
997
+ ),
998
+ children: (
999
+ <div>
1000
+ <div style={{ marginBottom: 8, color: '#666', fontSize: 12 }}>
1001
+ {item.author} - {dateText(item.created_at)}
1002
+ </div>
1003
+ <DiffView lines={item.diff} />
1004
+ </div>
1005
+ ),
1006
+ }))}
1007
+ />
1008
+ )}
1009
+ </Card>
1010
+ )}
1011
+ </Col>
1012
+
1013
+ <Col xs={24} lg={8}>
1014
+ <Card
1015
+ title={<span><HistoryOutlined /> Timeline</span>}
1016
+ variant="borderless"
1017
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}
1018
+ >
1019
+ {versions.length > 0 || snapshotHistory.length > 0 ? (
1020
+ <Timeline
1021
+ items={[
1022
+ ...snapshotHistory.slice(0, 8).map(item => ({
1023
+ color: 'blue',
1024
+ children: (
1025
+ <div>
1026
+ <div><Text code>{shortRef(item.commit_hash)}</Text> snapshot</div>
1027
+ <div style={{ fontSize: 11, color: '#999' }}>{dateText(item.authored_at)}</div>
1028
+ <div style={{ fontSize: 11, color: '#666' }}>{item.subject}</div>
1029
+ </div>
1030
+ ),
1031
+ })),
1032
+ ...versions.slice(0, 8).map(version => ({
1033
+ color: STATE_COLOR[version.state] || 'blue',
1034
+ children: (
1035
+ <div>
1036
+ <div>
1037
+ <Text code>v{version.version}</Text>
1038
+ <Badge
1039
+ color={STATE_COLOR[version.state] || 'default'}
1040
+ text={STATE_LABEL[version.state] || version.state}
1041
+ style={{ marginLeft: 8 }}
1042
+ />
1043
+ </div>
1044
+ <div style={{ fontSize: 11, color: '#999' }}>{dateText(version.created_at)}</div>
1045
+ {version.metrics.total_executions > 0 && (
1046
+ <div style={{ fontSize: 11, color: '#666' }}>
1047
+ {version.metrics.total_executions} executions, {(version.metrics.success_rate * 100).toFixed(0)}% success
1048
+ </div>
1049
+ )}
1050
+ </div>
1051
+ ),
1052
+ })),
1053
+ ]}
1054
+ />
1055
+ ) : (
1056
+ <div style={{ textAlign: 'center', color: '#999', padding: 40 }}>
1057
+ <BranchesOutlined style={{ fontSize: 32, marginBottom: 8 }} />
1058
+ <div>Select a Skill to view timeline</div>
1059
+ </div>
1060
+ )}
1061
+ </Card>
1062
+
1063
+ {selectedSkill && (
1064
+ <Card
1065
+ title="Current version"
1066
+ variant="borderless"
1067
+ style={{ borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginTop: 16 }}
1068
+ >
1069
+ <Descriptions column={1} size="small">
1070
+ <Descriptions.Item label="Name">{selectedSkill.name}</Descriptions.Item>
1071
+ <Descriptions.Item label="Version"><Text code>v{selectedSkill.version}</Text></Descriptions.Item>
1072
+ <Descriptions.Item label="State">
1073
+ <Badge color={STATE_COLOR[selectedSkill.state]} text={STATE_LABEL[selectedSkill.state]} />
1074
+ </Descriptions.Item>
1075
+ <Descriptions.Item label="Type">
1076
+ <Tag>{selectedSkill.skill_type}</Tag>
1077
+ </Descriptions.Item>
1078
+ <Descriptions.Item label="Executions">{selectedSkill.metrics.total_executions}</Descriptions.Item>
1079
+ </Descriptions>
1080
+ </Card>
1081
+ )}
1082
+ </Col>
1083
+ </Row>
1084
+
1085
+ <Drawer
1086
+ title={drawerSkill ? `v${drawerSkill.version} details` : ''}
1087
+ open={!!drawerSkill}
1088
+ onClose={() => setDrawerSkill(null)}
1089
+ size="default"
1090
+ >
1091
+ {drawerSkill && (
1092
+ <Descriptions column={1} bordered size="small">
1093
+ <Descriptions.Item label="Skill ID">
1094
+ <Text code copyable style={{ fontSize: 11 }}>{drawerSkill.skill_id}</Text>
1095
+ </Descriptions.Item>
1096
+ <Descriptions.Item label="Version"><Text code>v{drawerSkill.version}</Text></Descriptions.Item>
1097
+ <Descriptions.Item label="State">
1098
+ <Badge color={STATE_COLOR[drawerSkill.state]} text={STATE_LABEL[drawerSkill.state]} />
1099
+ </Descriptions.Item>
1100
+ <Descriptions.Item label="Description">{drawerSkill.description}</Descriptions.Item>
1101
+ <Descriptions.Item label="Tags">
1102
+ {drawerSkill.tags.map(tag => <Tag key={tag}>{tag}</Tag>)}
1103
+ </Descriptions.Item>
1104
+ <Descriptions.Item label="Created">{dateText(drawerSkill.created_at)}</Descriptions.Item>
1105
+ <Descriptions.Item label="Updated">{dateText(drawerSkill.updated_at)}</Descriptions.Item>
1106
+ <Descriptions.Item label="Executions">{drawerSkill.metrics.total_executions}</Descriptions.Item>
1107
+ <Descriptions.Item label="Success rate">
1108
+ {drawerSkill.metrics.total_executions >= 5
1109
+ ? `${(drawerSkill.metrics.success_rate * 100).toFixed(1)}%`
1110
+ : 'N/A'}
1111
+ </Descriptions.Item>
1112
+ </Descriptions>
1113
+ )}
1114
+ </Drawer>
1115
+ </div>
1116
+ )
1117
+ }
skillwiki-frontend/src/store/appStore.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from 'zustand'
2
+ import type { SkillSummary, SystemHealth } from '@/api/types'
3
+
4
+ export type AppLang = 'en' | 'zh'
5
+
6
+ export interface WsEvent {
7
+ type: string
8
+ payload: unknown
9
+ timestamp: string
10
+ }
11
+
12
+ interface AppStore {
13
+ // Theme
14
+ darkMode: boolean
15
+ toggleDark: () => void
16
+
17
+ // Language
18
+ lang: AppLang
19
+ setLang: (lang: AppLang) => void
20
+
21
+ // Skill list cache
22
+ skills: SkillSummary[]
23
+ setSkills: (skills: SkillSummary[]) => void
24
+
25
+ // System health
26
+ health: SystemHealth | null
27
+ setHealth: (h: SystemHealth) => void
28
+
29
+ // WebSocket event log
30
+ wsEvents: WsEvent[]
31
+ pushWsEvent: (event: WsEvent) => void
32
+ clearWsEvents: () => void
33
+
34
+ // Selected Skill
35
+ selectedSkillId: string | null
36
+ setSelectedSkillId: (id: string | null) => void
37
+ }
38
+
39
+ export const useAppStore = create<AppStore>((set) => ({
40
+ darkMode: false,
41
+ toggleDark: () => set(s => ({ darkMode: !s.darkMode })),
42
+
43
+ lang: 'en',
44
+ setLang: (lang) => set({ lang }),
45
+
46
+ skills: [],
47
+ setSkills: (skills) => set({ skills }),
48
+
49
+ health: null,
50
+ setHealth: (health) => set({ health }),
51
+
52
+ wsEvents: [],
53
+ pushWsEvent: (event) =>
54
+ set(s => ({
55
+ wsEvents: [
56
+ event,
57
+ ...s.wsEvents.slice(0, 99),
58
+ ],
59
+ })),
60
+ clearWsEvents: () => set({ wsEvents: [] }),
61
+
62
+ selectedSkillId: null,
63
+ setSelectedSkillId: (id) => set({ selectedSkillId: id }),
64
+ }))
skillwiki-frontend/src/types/antv-g6.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ declare module '@antv/g6'