Spaces:
Sleeping
Sleeping
Apollo-universe Claude Sonnet 4.6 commited on
Commit ·
a9df8b1
0
Parent(s):
HF Spaces deploy: initial orphan branch (no binary history)
Browse filesCo-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
- .dockerignore +22 -0
- .gitignore +119 -0
- Dockerfile +40 -0
- README.md +234 -0
- RESTORE_SKILLWIKI_DEMO_STATE.bat +7 -0
- START_SKILLWIKI_DEMO.bat +10 -0
- STOP_SKILLWIKI_DEMO.bat +5 -0
- docker-compose.yml +35 -0
- scripts/demo_readiness_check.py +404 -0
- scripts/prepare_skillsbench_subset.py +295 -0
- scripts/report_skillsbench_mapping.py +295 -0
- scripts/restore_demo_state.py +862 -0
- scripts/run_input_skill_eval.py +836 -0
- scripts/run_p0_harness_eval.py +518 -0
- skillwiki-frontend/.gitignore +24 -0
- skillwiki-frontend/Dockerfile +11 -0
- skillwiki-frontend/README.md +73 -0
- skillwiki-frontend/eslint.config.js +22 -0
- skillwiki-frontend/index.html +13 -0
- skillwiki-frontend/nginx.conf +32 -0
- skillwiki-frontend/package-lock.json +0 -0
- skillwiki-frontend/package.json +41 -0
- skillwiki-frontend/public/favicon.svg +1 -0
- skillwiki-frontend/public/icons.svg +24 -0
- skillwiki-frontend/scripts/capture_figures.mjs +198 -0
- skillwiki-frontend/src/App.css +184 -0
- skillwiki-frontend/src/App.tsx +66 -0
- skillwiki-frontend/src/api/client.ts +274 -0
- skillwiki-frontend/src/api/errors.ts +46 -0
- skillwiki-frontend/src/api/types.ts +744 -0
- skillwiki-frontend/src/assets/react.svg +1 -0
- skillwiki-frontend/src/assets/vite.svg +1 -0
- skillwiki-frontend/src/components/AppLayout.tsx +208 -0
- skillwiki-frontend/src/hooks/useWebSocket.ts +64 -0
- skillwiki-frontend/src/i18n.ts +52 -0
- skillwiki-frontend/src/index.css +39 -0
- skillwiki-frontend/src/main.tsx +10 -0
- skillwiki-frontend/src/pages/AgentExecution.tsx +370 -0
- skillwiki-frontend/src/pages/Dashboard.tsx +434 -0
- skillwiki-frontend/src/pages/EvaluationDashboard.tsx +481 -0
- skillwiki-frontend/src/pages/Evolution.tsx +583 -0
- skillwiki-frontend/src/pages/HarnessVerification.tsx +577 -0
- skillwiki-frontend/src/pages/KnowledgeImport.tsx +1293 -0
- skillwiki-frontend/src/pages/LifecycleDemo.tsx +353 -0
- skillwiki-frontend/src/pages/SelfEvolutionDemo.tsx +535 -0
- skillwiki-frontend/src/pages/SkillGraph.tsx +1389 -0
- skillwiki-frontend/src/pages/SkillWiki.tsx +613 -0
- skillwiki-frontend/src/pages/VersionControl.tsx +1117 -0
- skillwiki-frontend/src/store/appStore.ts +64 -0
- 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)
|
| 12 |
+
[](https://www.python.org/)
|
| 13 |
+
[](https://nodejs.org/)
|
| 14 |
+
[](https://github.com/Huangdingcheng/SkillWiki)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
<br/>
|
| 18 |
+
|
| 19 |
+
[📺 **Demo Video**](https://www.youtube.com/watch?v=TODO) ·
|
| 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'
|