Compare commits
10 Commits
52b8dbb835
...
feature/ss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ecf4e903a | ||
|
|
9fccb0a473 | ||
|
|
38d6875ab8 | ||
|
|
ea88e1dc18 | ||
|
|
60195f00a0 | ||
| 33c357a1de | |||
| 8dc5354fa4 | |||
| 0e1a800646 | |||
| 49f6297631 | |||
| bd41f48971 |
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@@ -6,7 +6,6 @@
|
|||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
"preLaunchTask": "Kill Stale LaodingBot Debug Processes",
|
|
||||||
"program": "${workspaceFolder}/cmd/bot",
|
"program": "${workspaceFolder}/cmd/bot",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"envFile": "${workspaceFolder}/configs/env"
|
"envFile": "${workspaceFolder}/configs/env"
|
||||||
@@ -16,7 +15,6 @@
|
|||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
"preLaunchTask": "Kill Stale LaodingBot Debug Processes",
|
|
||||||
"program": "${workspaceFolder}/cmd/bot",
|
"program": "${workspaceFolder}/cmd/bot",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"envFile": "${workspaceFolder}/configs/env",
|
"envFile": "${workspaceFolder}/configs/env",
|
||||||
@@ -29,7 +27,6 @@
|
|||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
"preLaunchTask": "Kill Stale LaodingBot Debug Processes",
|
|
||||||
"program": "${workspaceFolder}/cmd/bot",
|
"program": "${workspaceFolder}/cmd/bot",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"envFile": "${workspaceFolder}/configs/env",
|
"envFile": "${workspaceFolder}/configs/env",
|
||||||
|
|||||||
11
.vscode/tasks.json
vendored
11
.vscode/tasks.json
vendored
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "2.0.0",
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"label": "Kill Stale LaodingBot Debug Processes",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "pkill -f '/LaodingBot/cmd/bot/__debug_bin|dlv.*LaodingBot/cmd/bot|dlv dap' || true",
|
|
||||||
"problemMatcher": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Pull modules first for better layer cache.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/laodingbot ./cmd/bot
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
COPY --from=builder /out/laodingbot /app/laodingbot
|
||||||
|
|
||||||
|
# Keep default runtime assets in image; can be overridden by bind mounts.
|
||||||
|
COPY bot_context /app/bot_context
|
||||||
|
COPY skills /app/skills
|
||||||
|
COPY workspace/agent_runtime /app/workspace/agent_runtime
|
||||||
|
|
||||||
|
ENV MESSAGE_CHANNEL=webui
|
||||||
|
ENV WEBUI_LISTEN_ADDR=:8090
|
||||||
|
ENV AGENT_WORKSPACE_DIR=/app/workspace/agent_runtime
|
||||||
|
|
||||||
|
# Host can bind mount these directories for persistent/external runtime data.
|
||||||
|
VOLUME ["/app/workspace/agent_runtime"]
|
||||||
|
|
||||||
|
EXPOSE 8090
|
||||||
|
|
||||||
|
CMD ["/app/laodingbot"]
|
||||||
82
README.md
82
README.md
@@ -15,6 +15,7 @@ Now supports mutually exclusive message channels:
|
|||||||
|
|
||||||
- `telegram` (long polling)
|
- `telegram` (long polling)
|
||||||
- `feishu` (official SDK websocket long connection)
|
- `feishu` (official SDK websocket long connection)
|
||||||
|
- `webui` (HTTP + SSE, default `:8090`)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -22,9 +23,11 @@ Now supports mutually exclusive message channels:
|
|||||||
- The app auto-loads `configs/env` (or `.env`) if present.
|
- The app auto-loads `configs/env` (or `.env`) if present.
|
||||||
- You can also set `CONFIG_ENV_FILE=/path/to/env`.
|
- You can also set `CONFIG_ENV_FILE=/path/to/env`.
|
||||||
- Process environment variables override file values.
|
- Process environment variables override file values.
|
||||||
2. Choose exactly one channel with `MESSAGE_CHANNEL=telegram|feishu`.
|
2. Choose exactly one channel with `MESSAGE_CHANNEL=telegram|feishu|webui`.
|
||||||
- If `telegram`: set `TELEGRAM_BOT_TOKEN`, keep `FEISHU_*` empty.
|
- If `telegram`: set `TELEGRAM_BOT_TOKEN`, keep `FEISHU_*` empty.
|
||||||
- If `feishu`: set `FEISHU_APP_ID` and `FEISHU_APP_SECRET`, keep `TELEGRAM_BOT_TOKEN` empty.
|
- If `feishu`: set `FEISHU_APP_ID` and `FEISHU_APP_SECRET`, keep `TELEGRAM_BOT_TOKEN` empty.
|
||||||
|
- If `webui`: set `WEBUI_LISTEN_ADDR` (default `:8090`).
|
||||||
|
- Optional for `webui`: set `WEBUI_EXPOSE_REASONING=true` to expose `thought/tool_call/tool_result` events to frontend.
|
||||||
3. Set log level with `LOG_LEVEL=debug|info|warn|error`.
|
3. Set log level with `LOG_LEVEL=debug|info|warn|error`.
|
||||||
- To inspect full skill/tool execution content and detailed ReAct step traces, use `LOG_LEVEL=debug`.
|
- To inspect full skill/tool execution content and detailed ReAct step traces, use `LOG_LEVEL=debug`.
|
||||||
4. Configure knowledge and reasoning:
|
4. Configure knowledge and reasoning:
|
||||||
@@ -65,6 +68,83 @@ go run ./cmd/bot
|
|||||||
- Skills directory default path: `skills/`
|
- Skills directory default path: `skills/`
|
||||||
- Skill format uses subdirectories: `skills/<skill_name>/skill.md`
|
- Skill format uses subdirectories: `skills/<skill_name>/skill.md`
|
||||||
|
|
||||||
|
## Docker Build And Run
|
||||||
|
|
||||||
|
### Required Files And Folders
|
||||||
|
|
||||||
|
Before building/running with Docker, ensure these exist:
|
||||||
|
|
||||||
|
- `Dockerfile`
|
||||||
|
- `docker-compose.yaml`
|
||||||
|
- `workspace/agent_runtime/configs/env`
|
||||||
|
- `workspace/agent_runtime/bot_context/soul.md`
|
||||||
|
- `workspace/agent_runtime/skills/`
|
||||||
|
- `workspace/agent_runtime/data/`
|
||||||
|
- `workspace/agent_runtime/workspace/`
|
||||||
|
|
||||||
|
Quick bootstrap command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p workspace/agent_runtime/{configs,bot_context,skills,data,workspace}
|
||||||
|
cp -n configs/env.sample workspace/agent_runtime/configs/env
|
||||||
|
cp -n bot_context/soul.md workspace/agent_runtime/bot_context/soul.md
|
||||||
|
cp -rn skills/* workspace/agent_runtime/skills/
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimum env config in `workspace/agent_runtime/configs/env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
MESSAGE_CHANNEL=webui
|
||||||
|
WEBUI_LISTEN_ADDR=:8090
|
||||||
|
WEBUI_EXPOSE_REASONING=false
|
||||||
|
LLM_API_KEY=your_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.yaml` already mounts `./workspace/agent_runtime` into the container, so host-side data is persistent.
|
||||||
|
|
||||||
|
### Build Docker Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t laodingbot:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run With Docker (Single Container)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -d \
|
||||||
|
--name laodingbot \
|
||||||
|
-p 8090:8090 \
|
||||||
|
--env-file ./workspace/agent_runtime/configs/env \
|
||||||
|
-e MESSAGE_CHANNEL=webui \
|
||||||
|
-e WEBUI_LISTEN_ADDR=:8090 \
|
||||||
|
-e WEBUI_EXPOSE_REASONING=false \
|
||||||
|
-e AGENT_WORKSPACE_DIR=/app/workspace/agent_runtime \
|
||||||
|
-v "$(pwd)/workspace/agent_runtime:/app/workspace/agent_runtime" \
|
||||||
|
laodingbot:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run With Docker Compose
|
||||||
|
|
||||||
|
Build and start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
View logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f laodingbot
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop and remove:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
After startup, open WebUI at `http://localhost:8090`.
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
- `shell` only allows commands listed in `ALLOWED_COMMANDS`.
|
- `shell` only allows commands listed in `ALLOWED_COMMANDS`.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"laodingbot/internal/tools"
|
"laodingbot/internal/tools"
|
||||||
"laodingbot/internal/transport/feishu"
|
"laodingbot/internal/transport/feishu"
|
||||||
"laodingbot/internal/transport/telegram"
|
"laodingbot/internal/transport/telegram"
|
||||||
|
"laodingbot/internal/transport/webui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// main 是程序的入口点。它负责初始化环境、加载配置、注册工具并启动消息通道。
|
// main 是程序的入口点。它负责初始化环境、加载配置、注册工具并启动消息通道。
|
||||||
@@ -125,9 +126,19 @@ func main() {
|
|||||||
// 实例化 LLM 客户端
|
// 实例化 LLM 客户端
|
||||||
llmClient := llm.NewOpenAICompatibleClient(cfg.LLM, appLogger.WithComponent("llm"))
|
llmClient := llm.NewOpenAICompatibleClient(cfg.LLM, appLogger.WithComponent("llm"))
|
||||||
|
|
||||||
|
// 实例化路由 LLM 客户端(如果配置了独立的路由模型)
|
||||||
|
var routerLLMClient llm.Client
|
||||||
|
if cfg.LLM.RouterModel != "" {
|
||||||
|
routerCfg := cfg.LLM
|
||||||
|
routerCfg.Model = cfg.LLM.RouterModel
|
||||||
|
routerLLMClient = llm.NewOpenAICompatibleClient(routerCfg, appLogger.WithComponent("llm.router"))
|
||||||
|
appLogger.Infof("skill router enabled, model=%s", cfg.LLM.RouterModel)
|
||||||
|
}
|
||||||
|
|
||||||
// 创建编排器,整合 LLM、记忆系统、知识技能库与各种工具
|
// 创建编排器,整合 LLM、记忆系统、知识技能库与各种工具
|
||||||
engine := agent.NewOrchestrator(
|
engine := agent.NewOrchestrator(
|
||||||
llmClient,
|
llmClient,
|
||||||
|
routerLLMClient,
|
||||||
store,
|
store,
|
||||||
toolRegistry,
|
toolRegistry,
|
||||||
soul,
|
soul,
|
||||||
@@ -197,7 +208,91 @@ func runMessageChannel(ctx context.Context, cfg config.Config, engine *agent.Orc
|
|||||||
}
|
}
|
||||||
return engine.HandleMessage(ctx, msg.ChatID, msg.UserID, msg.Text)
|
return engine.HandleMessage(ctx, msg.ChatID, msg.UserID, msg.Text)
|
||||||
})
|
})
|
||||||
|
case "webui":
|
||||||
|
wb, err := webui.NewBot(cfg.WebUI, lg.WithComponent("transport.webui"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init webui bot failed: %w", err)
|
||||||
|
}
|
||||||
|
lg.Infof("starting webui transport listen_addr=%s", cfg.WebUI.ListenAddr)
|
||||||
|
return wb.Run(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, msg webui.IncomingMessage) (string, error) {
|
||||||
|
return engine.HandleMessage(ctx, msg.ChatID, msg.UserID, msg.Text)
|
||||||
|
},
|
||||||
|
func(ctx context.Context, msg webui.IncomingMessage, callback webui.StreamEventCallback) (string, error) {
|
||||||
|
return engine.HandleMessageStream(ctx, msg.ChatID, msg.UserID, msg.Text, buildWebUIStreamForwarder(callback, cfg.WebUI.ExposeReasoning))
|
||||||
|
},
|
||||||
|
func(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) {
|
||||||
|
return engine.UploadAndCacheFiles(ctx, chatID, userID, files)
|
||||||
|
},
|
||||||
|
func(ctx context.Context, chatID string, limit int) ([]memory.Message, error) {
|
||||||
|
return engine.GetHistory(chatID, limit)
|
||||||
|
},
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported message channel: %s", cfg.MessageChannel)
|
return fmt.Errorf("unsupported message channel: %s", cfg.MessageChannel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildWebUIStreamForwarder(callback webui.StreamEventCallback, exposeReasoning bool) agent.StreamEventCallback {
|
||||||
|
const finalChunkRunes = 12
|
||||||
|
const finalChunkInterval = 25 * time.Millisecond
|
||||||
|
|
||||||
|
return func(event agent.StreamEvent) error {
|
||||||
|
if callback == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch event.Type {
|
||||||
|
case agent.StreamEventTypeThought, agent.StreamEventTypeToolCall, agent.StreamEventTypeToolResult:
|
||||||
|
if !exposeReasoning {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return callback(webui.StreamEvent{
|
||||||
|
Type: webui.StreamEventType(event.Type),
|
||||||
|
Content: event.Content,
|
||||||
|
Step: event.Step,
|
||||||
|
ToolName: event.ToolName,
|
||||||
|
})
|
||||||
|
case agent.StreamEventTypeFinal:
|
||||||
|
runes := []rune(event.Content)
|
||||||
|
if len(runes) == 0 {
|
||||||
|
return callback(webui.StreamEvent{Type: webui.StreamEventTypeFinal, Content: "", Step: event.Step})
|
||||||
|
}
|
||||||
|
start := 0
|
||||||
|
for start < len(runes) {
|
||||||
|
end := start + finalChunkRunes
|
||||||
|
if end > len(runes) {
|
||||||
|
end = len(runes)
|
||||||
|
}
|
||||||
|
if err := callback(webui.StreamEvent{
|
||||||
|
Type: webui.StreamEventTypeFinal,
|
||||||
|
Content: string(runes[start:end]),
|
||||||
|
Step: event.Step,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
start = end
|
||||||
|
if start < len(runes) {
|
||||||
|
time.Sleep(finalChunkInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case agent.StreamEventTypeError:
|
||||||
|
return callback(webui.StreamEvent{
|
||||||
|
Type: webui.StreamEventTypeError,
|
||||||
|
Content: event.Content,
|
||||||
|
Step: event.Step,
|
||||||
|
})
|
||||||
|
case agent.StreamEventTypeWorkspaceStart, agent.StreamEventTypeWorkspaceDelta, agent.StreamEventTypeWorkspaceEnd:
|
||||||
|
return callback(webui.StreamEvent{
|
||||||
|
Type: webui.StreamEventType(event.Type),
|
||||||
|
Content: event.Content,
|
||||||
|
Step: event.Step,
|
||||||
|
ToolName: event.ToolName,
|
||||||
|
WorkspaceTitle: event.WorkspaceTitle,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
43
cmd/bot/main_test.go
Normal file
43
cmd/bot/main_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"laodingbot/internal/agent"
|
||||||
|
"laodingbot/internal/transport/webui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildWebUIStreamForwarderFiltersTraceEvents(t *testing.T) {
|
||||||
|
var got []webui.StreamEvent
|
||||||
|
forwarder := buildWebUIStreamForwarder(func(event webui.StreamEvent) error {
|
||||||
|
got = append(got, event)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
events := []agent.StreamEvent{
|
||||||
|
{Type: agent.StreamEventTypeThought, Content: "thinking", Step: 1},
|
||||||
|
{Type: agent.StreamEventTypeToolCall, Content: "pwd", Step: 1, ToolName: "shell"},
|
||||||
|
{Type: agent.StreamEventTypeToolResult, Content: "C:/Project", Step: 1, ToolName: "shell"},
|
||||||
|
{Type: agent.StreamEventTypeFinal, Content: "done", Step: 2},
|
||||||
|
{Type: agent.StreamEventTypeError, Content: "boom", Step: 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, event := range events {
|
||||||
|
if err := forwarder(event); err != nil {
|
||||||
|
t.Fatalf("forwarder returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected 2 forwarded events, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Type != webui.StreamEventTypeFinal || got[0].Content != "done" {
|
||||||
|
t.Fatalf("unexpected final event: %+v", got[0])
|
||||||
|
}
|
||||||
|
if got[0].Step != 0 || got[0].ToolName != "" {
|
||||||
|
t.Fatalf("expected final event without trace fields, got %+v", got[0])
|
||||||
|
}
|
||||||
|
if got[1].Type != webui.StreamEventTypeError || got[1].Content != "boom" {
|
||||||
|
t.Fatalf("unexpected error event: %+v", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ SKILLS_DIR=./skills
|
|||||||
REACT_MAX_STEPS=4
|
REACT_MAX_STEPS=4
|
||||||
TOOL_CALL_TIMEOUT_SEC=15
|
TOOL_CALL_TIMEOUT_SEC=15
|
||||||
TOOL_OUTPUT_MAX_CHARS=4000
|
TOOL_OUTPUT_MAX_CHARS=4000
|
||||||
|
PI_PLAN_MAX_CHARS=40000
|
||||||
ENABLE_CAPABILITY_GAP=true
|
ENABLE_CAPABILITY_GAP=true
|
||||||
AUTO_SKILL_DIR=./skills
|
AUTO_SKILL_DIR=./skills
|
||||||
GAP_DRAFT_TRIGGER_COUNT=3
|
GAP_DRAFT_TRIGGER_COUNT=3
|
||||||
@@ -17,10 +18,25 @@ TELEGRAM_POLL_TIMEOUT_SECONDS=30
|
|||||||
FEISHU_APP_ID=
|
FEISHU_APP_ID=
|
||||||
FEISHU_APP_SECRET=
|
FEISHU_APP_SECRET=
|
||||||
FEISHU_VERIFY_TOKEN=
|
FEISHU_VERIFY_TOKEN=
|
||||||
|
FEISHU_LISTEN_ADDR=:8080
|
||||||
|
FEISHU_EVENT_PATH=/feishu/events
|
||||||
|
WEBUI_LISTEN_ADDR=:8090
|
||||||
|
WEBUI_MAX_UPLOAD_MB=20
|
||||||
|
WEBUI_EXPOSE_REASONING=false
|
||||||
|
|
||||||
LLM_BASE_URL=https://api.openai.com/v1
|
LLM_BASE_URL=https://api.openai.com/v1
|
||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
LLM_MODEL=gpt-4o-mini
|
LLM_MODEL=gpt-4o-mini
|
||||||
|
LLM_FILE_MODEL=gpt-4o-mini
|
||||||
|
LLM_ROUTER_MODEL=
|
||||||
|
|
||||||
|
WEB_SEARCH_ENGINE=duckduckgo
|
||||||
|
WEB_SEARCH_API_KEY=
|
||||||
|
|
||||||
|
GITEA_BASE_URL=
|
||||||
|
GITEA_TOKEN=
|
||||||
|
GITEA_OWNER=
|
||||||
|
GITEA_REPO=
|
||||||
|
|
||||||
SQLITE_PATH=./data/laodingbot.db
|
SQLITE_PATH=./data/laodingbot.db
|
||||||
ALLOWED_DIRS=./workspace,./data,./skills
|
ALLOWED_DIRS=./workspace,./data,./skills
|
||||||
|
|||||||
34
deploy.sh
Executable file
34
deploy.sh
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
REGISTRY_URL="dcr-by1jwyxk44.71826370.xyz"
|
||||||
|
IMAGE_NAME="laodingbot"
|
||||||
|
TAG=$(date +%Y%m%d%H%M%S)
|
||||||
|
FULL_IMAGE_NAME="${REGISTRY_URL}/${IMAGE_NAME}:${TAG}"
|
||||||
|
LATEST_IMAGE_NAME="${REGISTRY_URL}/${IMAGE_NAME}:latest"
|
||||||
|
|
||||||
|
echo "Step 1: Building Docker image..."
|
||||||
|
docker build -t ${IMAGE_NAME}:latest .
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Docker build failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Step 2: Tagging image..."
|
||||||
|
docker tag ${IMAGE_NAME}:latest ${FULL_IMAGE_NAME}
|
||||||
|
docker tag ${IMAGE_NAME}:latest ${LATEST_IMAGE_NAME}
|
||||||
|
|
||||||
|
echo "Step 3: Pushing image to ${REGISTRY_URL}..."
|
||||||
|
# Note: You might need to run 'docker login ${REGISTRY_URL}' once before running this script
|
||||||
|
docker push ${FULL_IMAGE_NAME}
|
||||||
|
docker push ${LATEST_IMAGE_NAME}
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Docker push failed. Make sure you are logged in to the registry."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Successfully built and pushed:"
|
||||||
|
echo " - ${FULL_IMAGE_NAME}"
|
||||||
|
echo " - ${LATEST_IMAGE_NAME}"
|
||||||
140
doc/Artifact_SplitScreen_Design.md
Normal file
140
doc/Artifact_SplitScreen_Design.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Vibe Coding Design Docs: Workspace/Artifact Split-Screen Pattern
|
||||||
|
|
||||||
|
## 1. Context & Objective
|
||||||
|
The goal is to implement a UI/UX pattern similar to **Claude Artifacts** or **Gemini Deep Research**. When a specific complex task is triggered (e.g., "Project Planning Skill"), the single-column chat interface should smoothly transition into a split-screen layout:
|
||||||
|
- **Left Panel (35%)**: Conversational context, CoT (Chain of Thought) traces, tool calls, and user input.
|
||||||
|
- **Right Panel (65%)**: A dedicated "Workspace" or "Artifact" rendering area to display long-form content (Markdown, code, diagrams) generated by the Agent's skills.
|
||||||
|
|
||||||
|
Crucially, this system must support **Reflexion/Iterative generation**. The user can comment on the generated artifact in the left panel, and the agent should update the artifact in the right panel based on the feedback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Frontend Implementation Guide (React + Vite + Tailwind)
|
||||||
|
|
||||||
|
### 2.1 State Management (State & Types)
|
||||||
|
Extend the existing frontend state to track the workspace status and content.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Extend the StreamEvent type to support UI control and artifact streaming
|
||||||
|
type StreamEvent = {
|
||||||
|
type:
|
||||||
|
| "thought"
|
||||||
|
| "tool_call"
|
||||||
|
| "tool_result"
|
||||||
|
| "message" // Standard chat message
|
||||||
|
| "error"
|
||||||
|
| "workspace_start" // Trigger right panel open
|
||||||
|
| "workspace_delta" // Streaming text for the right panel
|
||||||
|
| "workspace_end"; // Streaming completed
|
||||||
|
content: string;
|
||||||
|
step?: number;
|
||||||
|
tool_name?: string;
|
||||||
|
workspace_title?: string; // Optional title for the artifact
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Add Workspace State (Can be added to useReducer or a separate useState)
|
||||||
|
type WorkspaceState = {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
isGenerating: boolean;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 SSE Parsing Logic
|
||||||
|
Modify the `onEvent` handler inside `streamChat` to intercept `workspace_*` events.
|
||||||
|
- When `workspace_start` arrives: Set `workspace.isOpen = true`, clear previous content, set `isGenerating = true`.
|
||||||
|
- When `workspace_delta` arrives: Append text to `workspace.content`. Do **not** append this text to the left-panel chat history to avoid redundancy.
|
||||||
|
- When `workspace_end` arrives: Set `isGenerating = false`.
|
||||||
|
|
||||||
|
### 2.3 Layout & UI Re-architecture
|
||||||
|
Refactor the root `<div>` of `PlanningAgent.tsx` to handle dynamic flex layouts. Use Tailwind's transition utilities for smooth scaling.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex h-full w-full overflow-hidden bg-surface">
|
||||||
|
|
||||||
|
{/* Left Panel: Chat & Controls */}
|
||||||
|
<div
|
||||||
|
className={`flex flex-col h-full transition-all duration-300 ease-in-out ${
|
||||||
|
workspace.isOpen ? 'w-[35%] border-r border-border min-w-[350px]' : 'w-full max-w-5xl mx-auto'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Existing Message List & Input Area */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel: Workspace / Deep Research Output */}
|
||||||
|
{workspace.isOpen && (
|
||||||
|
<div className="w-[65%] h-full flex flex-col bg-surface-muted transition-opacity duration-300 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="h-14 border-b border-border flex items-center px-6 justify-between bg-white">
|
||||||
|
<h3 className="font-semibold text-txt flex items-center gap-2">
|
||||||
|
<IconDocument /> {workspace.title || 'Project Planning Document'}
|
||||||
|
</h3>
|
||||||
|
{workspace.isGenerating && (
|
||||||
|
<span className="text-sm text-magenta animate-pulse flex items-center gap-1">
|
||||||
|
<Spinner /> Generating...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Markdown Content Area */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-8 prose prose-slate max-w-none">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||||
|
{workspace.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Backend Implementation Guide (Agent / ReAct Loop)
|
||||||
|
|
||||||
|
The backend agent requires structural changes to understand the "Artifact" concept, emit correct SSE events, and maintain the artifact in its memory for iterative edits.
|
||||||
|
|
||||||
|
### 3.1 Tool / Skill Definition
|
||||||
|
When defining the `Project Planning Skill` for the LLM, clearly state its output behavior so the LLM knows *when* to use it.
|
||||||
|
- **Tool Description**: `use_planning_workspace`: "Invoke this tool to generate, structure, or update a major project planning document. The output will be rendered in a dedicated UI workspace."
|
||||||
|
|
||||||
|
### 3.2 Context Injection (Memory for Reflexion)
|
||||||
|
To allow the user to say *"extend the testing phase to 2 weeks"*, the LLM **must know what is currently in the right panel**.
|
||||||
|
- **Before sending the prompt to the LLM**, query the database/session for the current Artifact state.
|
||||||
|
- **Prompt Assembly**:
|
||||||
|
```text
|
||||||
|
[System Prompt / ReAct Instructions]
|
||||||
|
...
|
||||||
|
|
||||||
|
[Current Workspace Artifact (if exists)]
|
||||||
|
<workspace>
|
||||||
|
# Project Plan
|
||||||
|
1. Dev Phase: 1 week
|
||||||
|
2. Testing Phase: 1 week
|
||||||
|
</workspace>
|
||||||
|
|
||||||
|
[Chat History]
|
||||||
|
User: extend the testing phase to 2 weeks.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Streaming Control (Hijacking the Stream)
|
||||||
|
Within the ReAct execution loop, when the Agent decides to execute the `Project Planning Skill`:
|
||||||
|
1. The Backend normally streams `thought` or `tool_call` events.
|
||||||
|
2. Upon entering the specific Skill execution, the backend emits `{"type": "workspace_start", "workspace_title": "Update: Project Plan"}`.
|
||||||
|
3. As the LLM (or a sub-agent) generates the Markdown schema, the backend maps these tokens to `workspace_delta` events and flushes them to the frontend.
|
||||||
|
4. (CRITICAL) Do **not** send these tokens as `message` or `final` chat events. The chat bubble should only say something like: *"I have updated the project plan in the workspace area."*
|
||||||
|
5. Save the final generated Markdown text into the session memory as the `Current Artifact` for future context injection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Work Flow Summary (For LLM context generation)
|
||||||
|
|
||||||
|
1. `User` sends prompt: "Plan the new feature".
|
||||||
|
2. `Agent` thinks (`type: thought`), decides to use `Project Planning Skill` (`type: tool_call`).
|
||||||
|
3. `Agent` emits `{"type": "workspace_start"}`.
|
||||||
|
4. `Frontend` expands right panel (65% width).
|
||||||
|
5. `Agent` streams `{"type": "workspace_delta", "content": "..."}`.
|
||||||
|
6. `Frontend` live-renders Markdown in the right panel.
|
||||||
|
7. `Agent` finishes, saves artifact to backend session.
|
||||||
|
8. `User` reads right panel, types in left panel: "Change point 2".
|
||||||
|
9. `Agent` receives Left Panel history + Right Panel Artifact Content.
|
||||||
|
10. `Agent` updates document, streaming new `workspace_delta`. Frontend live-updates the right panel.
|
||||||
197
doc/WebUI_Stream_API_前端对接说明.md
Normal file
197
doc/WebUI_Stream_API_前端对接说明.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# WebUI `/api/chat/stream` 前端对接说明
|
||||||
|
|
||||||
|
本文档用于指导前端项目接入 LaodingBot 的流式聊天接口,并可直接作为提示词输入给 LLM,批量改造其他前端代码。
|
||||||
|
|
||||||
|
## 1. 接口总览
|
||||||
|
|
||||||
|
- 方法: `POST`
|
||||||
|
- 路径: `/api/chat/stream`
|
||||||
|
- 请求头: `Content-Type: application/json`
|
||||||
|
- 响应类型: `text/event-stream`
|
||||||
|
- 协议: SSE (Server-Sent Events)
|
||||||
|
|
||||||
|
说明: 该接口为单次请求、多次推送。后端会持续推送 `data: <json>\n\n` 格式的事件。
|
||||||
|
|
||||||
|
## 2. 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "请帮我分析当前目录",
|
||||||
|
"session_id": "sess_abc",
|
||||||
|
"user_id": "user_001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `text` (string, required): 用户输入文本,去除空白后不能为空。
|
||||||
|
- `session_id` (string, optional): 会话 ID,不传时后端自动生成。
|
||||||
|
- `user_id` (string, optional): 用户 ID,不传时后端自动生成。
|
||||||
|
|
||||||
|
兼容字段:
|
||||||
|
|
||||||
|
- `sessionId` 等价于 `session_id`
|
||||||
|
- `userId` 等价于 `user_id`
|
||||||
|
|
||||||
|
## 3. SSE 事件格式
|
||||||
|
|
||||||
|
每条 SSE 消息只包含 `data` 字段,内容是 JSON:
|
||||||
|
|
||||||
|
```text
|
||||||
|
data: {"type":"thought","content":"我先判断是否需要调用工具","step":1}
|
||||||
|
|
||||||
|
data: {"type":"tool_call","content":"{\"input\":\"pwd\"}","step":1,"tool_name":"shell"}
|
||||||
|
|
||||||
|
data: {"type":"tool_result","content":"C:/Project/MyProject","step":1,"tool_name":"shell"}
|
||||||
|
|
||||||
|
data: {"type":"final","content":"当前目录是 C:/Project/MyProject","step":2}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
事件字段:
|
||||||
|
|
||||||
|
- `type` (string): 事件类型
|
||||||
|
- `content` (string): 事件文本内容
|
||||||
|
- `step` (number, optional): ReAct 步骤编号
|
||||||
|
- `tool_name` (string, optional): 工具名
|
||||||
|
|
||||||
|
事件类型:
|
||||||
|
|
||||||
|
- `thought`: LLM 思考片段(可选透传)
|
||||||
|
- `tool_call`: 工具调用请求(可选透传)
|
||||||
|
- `tool_result`: 工具执行结果(可选透传)
|
||||||
|
- `final`: 最终回答
|
||||||
|
- `error`: 错误信息
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 默认情况下(`WEBUI_EXPOSE_REASONING=false`),WebUI 只向前端返回 `final` 和 `error`。
|
||||||
|
- 当设置 `WEBUI_EXPOSE_REASONING=true` 时,WebUI 会额外透传 `thought`、`tool_call`、`tool_result` 事件。
|
||||||
|
- 无论是否透传推理事件,`final` 都会分段累计推送,以便前端实现打字机效果。
|
||||||
|
|
||||||
|
## 4. 连接生命周期
|
||||||
|
|
||||||
|
- 正常结束: 收到 `type=final` 后结束渲染,连接可由浏览器自然关闭。
|
||||||
|
- 异常结束: 收到 `type=error`,前端应显示错误并结束当前轮次。
|
||||||
|
- 网络中断: 前端应允许用户重试,并保留已收到的事件记录。
|
||||||
|
|
||||||
|
## 5. 前端渲染建议
|
||||||
|
|
||||||
|
推荐前端仅处理两类结果:
|
||||||
|
|
||||||
|
- 答案区: 显示最后一个 `final`
|
||||||
|
- 错误区: 显示 `error`
|
||||||
|
|
||||||
|
如果开启了 `WEBUI_EXPOSE_REASONING=true`,建议额外提供“思考面板”:
|
||||||
|
|
||||||
|
- 思考区: 渲染 `thought`
|
||||||
|
- 工具区: 渲染 `tool_call` / `tool_result`
|
||||||
|
|
||||||
|
建议状态机:
|
||||||
|
|
||||||
|
- `idle`: 初始状态
|
||||||
|
- `streaming`: 请求中且持续接收事件
|
||||||
|
- `done`: 收到 `final`
|
||||||
|
- `failed`: 收到 `error` 或请求异常
|
||||||
|
|
||||||
|
## 6. TypeScript 对接示例 (fetch + ReadableStream)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type StreamEventType = 'thought' | 'tool_call' | 'tool_result' | 'final' | 'error';
|
||||||
|
|
||||||
|
interface StreamEvent {
|
||||||
|
type: StreamEventType;
|
||||||
|
content: string;
|
||||||
|
step?: number;
|
||||||
|
tool_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamChat(
|
||||||
|
payload: { text: string; session_id?: string; user_id?: string },
|
||||||
|
onEvent: (event: StreamEvent) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const resp = await fetch('/api/chat/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
if (!resp.body) {
|
||||||
|
throw new Error('ReadableStream is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// SSE message delimiter: blank line
|
||||||
|
let idx = buffer.indexOf('\n\n');
|
||||||
|
while (idx >= 0) {
|
||||||
|
const chunk = buffer.slice(0, idx).trim();
|
||||||
|
buffer = buffer.slice(idx + 2);
|
||||||
|
|
||||||
|
for (const line of chunk.split('\n')) {
|
||||||
|
const text = line.trim();
|
||||||
|
if (!text.startsWith('data:')) continue;
|
||||||
|
const raw = text.slice(5).trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
|
||||||
|
const event = JSON.parse(raw) as StreamEvent;
|
||||||
|
onEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
idx = buffer.indexOf('\n\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 给 LLM 的改造任务提示词模板
|
||||||
|
|
||||||
|
将下面模板发给 LLM,可用于自动改造其他前端项目:
|
||||||
|
|
||||||
|
```text
|
||||||
|
你要改造一个前端项目的聊天页面,把非流式接口 `/api/chat` 改为流式接口 `/api/chat/stream`。
|
||||||
|
|
||||||
|
后端协议约束:
|
||||||
|
1) 请求方法 POST,Content-Type=application/json
|
||||||
|
2) 请求体: { text, session_id?, user_id? }
|
||||||
|
3) 响应是 SSE 文本流,事件格式为 `data: <json>\n\n`
|
||||||
|
4) JSON 结构:
|
||||||
|
- type: thought | tool_call | tool_result | final | error
|
||||||
|
- content: string
|
||||||
|
- step?: number
|
||||||
|
- tool_name?: string
|
||||||
|
5) 收到 final 视为本轮完成;收到 error 视为失败
|
||||||
|
6) 默认不要假设前端一定会收到 thought/tool_call/tool_result;仅当 `WEBUI_EXPOSE_REASONING=true` 才会透传
|
||||||
|
|
||||||
|
你的改造要求:
|
||||||
|
1) 保留现有 UI 风格和组件结构,不做无关重构
|
||||||
|
2) 新增流式读取逻辑,支持中途取消 (AbortController)
|
||||||
|
3) 将事件按 step 渲染到消息区
|
||||||
|
4) 兼容旧会话字段命名 (session_id / sessionId, user_id / userId)
|
||||||
|
5) 增加错误态与重试按钮
|
||||||
|
6) 不破坏原有上传、历史消息和输入框行为
|
||||||
|
7) 输出改动文件列表 + 每个文件的关键变更说明
|
||||||
|
|
||||||
|
请直接给出可运行代码补丁。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 调试清单
|
||||||
|
|
||||||
|
- 检查响应头是否为 `text/event-stream`
|
||||||
|
- 检查每条事件是否以 `data:` 开头并以空行结尾
|
||||||
|
- 确认 `final` 和 `error` 都能正确结束当前轮次
|
||||||
|
- 验证弱网下不会丢失已收到的事件
|
||||||
|
- 验证用户快速连续提问时,旧流可被取消
|
||||||
305
doc/WebUI开发技术与需求说明.md
Normal file
305
doc/WebUI开发技术与需求说明.md
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
# LaodingBot WebUI 开发技术与需求说明
|
||||||
|
|
||||||
|
> 版本: v1.0
|
||||||
|
> 目标读者: 下一个执行 WebUI 开发任务的 LLM / 前端工程师
|
||||||
|
> 依据: 当前后端已落地代码(`internal/transport/webui/bot.go`、`cmd/bot/main.go`、`internal/agent/orchestrator.go`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 文档目标与范围
|
||||||
|
|
||||||
|
本说明用于指导“仅 WebUI 页面层/前端层”的开发,确保严格对接当前 LaodingBot 已开放的 WebUI 后端能力。
|
||||||
|
|
||||||
|
本期范围:
|
||||||
|
- 支持纯文本对话
|
||||||
|
- 支持独立文档上传(上传后立即返回 `file_id`)
|
||||||
|
- 支持“先上传文档,再发送文本提问”的两阶段交互
|
||||||
|
|
||||||
|
非本期范围:
|
||||||
|
- 不改动后端接口语义
|
||||||
|
- 不引入新的后端路由
|
||||||
|
- 不实现复杂权限系统(当前后端无鉴权)
|
||||||
|
- 不实现流式输出(当前为一次性响应)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 当前后端能力总览
|
||||||
|
|
||||||
|
### 2.1 消息通道
|
||||||
|
后端已支持 `MESSAGE_CHANNEL=webui`,入口在 `cmd/bot/main.go`。
|
||||||
|
|
||||||
|
当通道为 `webui` 时:
|
||||||
|
- 启动 HTTP 服务
|
||||||
|
- 暴露两个接口:
|
||||||
|
- `POST /api/upload`
|
||||||
|
- `POST /api/chat`
|
||||||
|
|
||||||
|
### 2.2 运行配置
|
||||||
|
关键环境变量(`configs/env.sample`):
|
||||||
|
- `MESSAGE_CHANNEL=webui`
|
||||||
|
- `WEBUI_LISTEN_ADDR=:8090`
|
||||||
|
- `WEBUI_MAX_UPLOAD_MB=20`
|
||||||
|
- `LLM_*` 相关配置(含文件模型切换)
|
||||||
|
|
||||||
|
### 2.3 文件上下文机制(非常关键)
|
||||||
|
当前后端使用 `chat_id + user_id` 作为会话键缓存 `file_id`:
|
||||||
|
- 上传接口会把文件上传到 LLM,并把 `file_id` 缓存到该键
|
||||||
|
- 后续 `chat` 接口只要带同一组 `session_id`/`user_id`,就会自动消费缓存文件上下文
|
||||||
|
|
||||||
|
结论:
|
||||||
|
- 前端必须稳定维护并复用 `session_id` 与 `user_id`
|
||||||
|
- 两者变化会导致上传文件上下文丢失(从后端视角是新会话)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 后端 API 契约(以代码为准)
|
||||||
|
|
||||||
|
## 3.1 `POST /api/upload`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
- 独立上传文档
|
||||||
|
- 后端立即调用 LLM 文件上传并返回 `file_id`
|
||||||
|
- 同时缓存到当前会话,供下一次聊天使用
|
||||||
|
|
||||||
|
请求:
|
||||||
|
- Method: `POST`
|
||||||
|
- Content-Type: `multipart/form-data`
|
||||||
|
- Form 字段:
|
||||||
|
- `file` (required)
|
||||||
|
- `session_id` (optional,建议前端始终传)
|
||||||
|
- `user_id` (optional,建议前端始终传)
|
||||||
|
|
||||||
|
响应 `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": "file-xxx",
|
||||||
|
"file_ids": ["file-xxx"],
|
||||||
|
"file_name": "report.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 12345,
|
||||||
|
"session_id": "sess_xxx",
|
||||||
|
"user_id": "user_xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
错误响应:
|
||||||
|
- `400`:
|
||||||
|
- `{"error":"file is required"}`
|
||||||
|
- `{"error":"invalid multipart form"}`
|
||||||
|
- `{"error":"invalid file name"}`
|
||||||
|
- `{"error":"empty file"}`
|
||||||
|
- `{"error":"read file failed"}`
|
||||||
|
- `413`:
|
||||||
|
- `{"error":"file too large"}`
|
||||||
|
- `500`:
|
||||||
|
- `{"error":"upload failed"}`
|
||||||
|
- `{"error":"upload succeeded but file_id is empty"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.2 `POST /api/chat`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
- 发送纯文本消息
|
||||||
|
- 若该 `session_id + user_id` 下已有上传文件,会自动携带文件上下文进行问答
|
||||||
|
|
||||||
|
请求:
|
||||||
|
- Method: `POST`
|
||||||
|
- Content-Type: `application/json`
|
||||||
|
- Body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "请总结我上传的文档",
|
||||||
|
"session_id": "sess_xxx",
|
||||||
|
"user_id": "user_xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应 `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reply": "...",
|
||||||
|
"session_id": "sess_xxx",
|
||||||
|
"user_id": "user_xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
错误响应:
|
||||||
|
- `400`:
|
||||||
|
- `{"error":"content-type must be application/json"}`
|
||||||
|
- `{"error":"invalid json body"}`
|
||||||
|
- `{"error":"text is required"}`
|
||||||
|
- `405`:
|
||||||
|
- `{"error":"method not allowed"}`
|
||||||
|
- `500`:
|
||||||
|
- `{"error":"chat failed"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. WebUI 功能需求(给前端实现)
|
||||||
|
|
||||||
|
### 4.1 页面最小能力
|
||||||
|
必须实现:
|
||||||
|
1. 聊天消息列表
|
||||||
|
2. 文本输入框 + 发送按钮
|
||||||
|
3. 文件选择器 + 上传按钮
|
||||||
|
4. 当前会话标识展示(`session_id`、`user_id`)
|
||||||
|
5. 上传结果区(展示 `file_id`、文件名、大小、时间)
|
||||||
|
6. 错误提示区(接口失败、文件超限、网络错误)
|
||||||
|
|
||||||
|
### 4.2 会话策略
|
||||||
|
必须:
|
||||||
|
- 首次进入页面时生成并持久化 `session_id`、`user_id`(例如 LocalStorage)
|
||||||
|
- 每次请求都携带这两个字段
|
||||||
|
- 提供“重置会话”操作(清空本地 session/user 并重新生成)
|
||||||
|
|
||||||
|
建议 ID 规则:
|
||||||
|
- `session_id`: `sess_<uuid>`
|
||||||
|
- `user_id`: `user_<uuid>`
|
||||||
|
|
||||||
|
### 4.3 文件上传交互
|
||||||
|
必须:
|
||||||
|
- 文件选择后显示基本信息(名称、大小、类型)
|
||||||
|
- 点击上传后调用 `/api/upload`
|
||||||
|
- 上传成功后将返回的 `file_id` 加入“已上传文件列表”
|
||||||
|
- 上传失败时给出明确错误提示
|
||||||
|
|
||||||
|
建议:
|
||||||
|
- 前端先做大小校验(默认 20MB,可配置)
|
||||||
|
- 上传中禁用重复点击
|
||||||
|
|
||||||
|
### 4.4 聊天交互
|
||||||
|
必须:
|
||||||
|
- 发送前校验 `text` 非空
|
||||||
|
- 调用 `/api/chat`
|
||||||
|
- 成功后追加机器人消息
|
||||||
|
- 失败后展示错误并保留用户输入
|
||||||
|
|
||||||
|
建议:
|
||||||
|
- 支持回车发送(Shift+Enter 换行)
|
||||||
|
- 增加“请求中”状态,避免并发多发
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 前端状态模型(建议)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type UploadedFile = {
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
uploadedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatMessage = {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
content: string;
|
||||||
|
createdAt: number;
|
||||||
|
status?: "sending" | "sent" | "failed";
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppState = {
|
||||||
|
sessionId: string;
|
||||||
|
userId: string;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
uploadedFiles: UploadedFile[];
|
||||||
|
uploading: boolean;
|
||||||
|
chatting: boolean;
|
||||||
|
lastError?: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
状态约束:
|
||||||
|
- `uploading=true` 时不应再次触发上传
|
||||||
|
- `chatting=true` 时可选禁用发送(或允许排队)
|
||||||
|
- 任何失败都要更新 `lastError`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 接口调用示例
|
||||||
|
|
||||||
|
### 6.1 上传
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8090/api/upload \
|
||||||
|
-F "session_id=sess_demo" \
|
||||||
|
-F "user_id=user_demo" \
|
||||||
|
-F "file=@./demo.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 聊天
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8090/api/chat \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"text":"这份文档主要讲了什么?","session_id":"sess_demo","user_id":"user_demo"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 开发任务拆解(给下一个 LLM)
|
||||||
|
|
||||||
|
1. 初始化 WebUI 工程(技术栈自选,建议 React + TypeScript)。
|
||||||
|
2. 实现全局状态管理(至少管理 session/user/messages/uploads)。
|
||||||
|
3. 实现 API Client 层:
|
||||||
|
- `uploadFile(formData)`
|
||||||
|
- `sendChat(payload)`
|
||||||
|
- 统一错误解析
|
||||||
|
4. 实现页面组件:
|
||||||
|
- `ChatPanel`
|
||||||
|
- `Composer`
|
||||||
|
- `UploadPanel`
|
||||||
|
- `SessionBadge`
|
||||||
|
5. 实现本地持久化:
|
||||||
|
- 启动时恢复 `session_id`、`user_id`
|
||||||
|
- 重置会话功能
|
||||||
|
6. 完成联调与自测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 验收标准
|
||||||
|
|
||||||
|
必须全部满足:
|
||||||
|
1. 纯文本聊天可用(无文件也能正常回复)。
|
||||||
|
2. 文档上传可用,返回并展示 `file_id`。
|
||||||
|
3. 上传后同会话提问能基于文档回答。
|
||||||
|
4. 刷新页面后会话 ID 不丢失(可继续提问)。
|
||||||
|
5. 文件超限、空消息、网络失败均有可见错误反馈。
|
||||||
|
6. 同一会话多文件上传后,后续问答仍可使用文件上下文。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 已知限制与后续建议
|
||||||
|
|
||||||
|
当前后端限制(前端需知晓):
|
||||||
|
- 无鉴权机制
|
||||||
|
- 无流式输出
|
||||||
|
- 上传 MIME 未做白名单限制
|
||||||
|
- 无会话文件列表接口(前端只能依赖本地记录)
|
||||||
|
|
||||||
|
后续建议(非本期必做):
|
||||||
|
1. 新增后端 `GET /api/session/files` 以便页面恢复历史上传列表。
|
||||||
|
2. 增加鉴权中间件(Bearer Token/JWT)。
|
||||||
|
3. 增加流式聊天接口(SSE/WebSocket)。
|
||||||
|
4. 增加上传文件类型白名单和病毒扫描。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 对下一个 LLM 的执行指令模板
|
||||||
|
|
||||||
|
可直接复制给下一个 LLM:
|
||||||
|
|
||||||
|
```text
|
||||||
|
请基于 `doc/WebUI开发技术与需求说明.md` 实现 WebUI 前端,严格对接现有后端接口:
|
||||||
|
- POST /api/upload
|
||||||
|
- POST /api/chat
|
||||||
|
|
||||||
|
必须满足:
|
||||||
|
1) 纯文本聊天
|
||||||
|
2) 独立文档上传
|
||||||
|
3) 上传后同会话提问可利用文档上下文
|
||||||
|
4) session_id/user_id 持久化
|
||||||
|
5) 完整错误处理和可视化反馈
|
||||||
|
|
||||||
|
不允许修改后端接口语义;若需新增接口,请先输出变更提案,不直接改。
|
||||||
|
```
|
||||||
256
doc/技术说明文档.md
256
doc/技术说明文档.md
@@ -1,73 +1,80 @@
|
|||||||
# LaodingBot 技术说明文档(2026-02-28 最新实现)
|
# LaodingBot 技术说明文档(2026-03-09)
|
||||||
|
|
||||||
> 本文档基于当前代码状态,描述真实可运行架构与能力边界。
|
> 本文档基于当前代码状态(含本次“文档问答 + 模型切换”改造)整理,描述真实可运行架构、能力边界与配置方式。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 项目定位
|
## 1. 项目定位
|
||||||
|
|
||||||
LaodingBot 当前已从“单进程工具调用 MVP”演进为:
|
LaodingBot 当前架构为:
|
||||||
- **父进程 Agent 编排**(技能路由 + ReAct + 记忆)
|
- 父进程 Agent 编排(技能路由 + 统一 ReAct + 记忆)
|
||||||
- **子进程 ToolHost 执行**(JSON-RPC)
|
- 子进程 ToolHost 执行(JSON-RPC)
|
||||||
- **workspace 隔离运行空间**(配置与工具权限收敛)
|
- runtime workspace 隔离(配置、数据、技能、工具权限收敛)
|
||||||
- **能力缺口闭环**(落库、聚类、自动生成技能并热加载)
|
- 能力缺口闭环(落库、聚类、自动生成技能、热加载)
|
||||||
|
- 文档问答链路(飞书文件下载 -> 上传 LLM -> 缓存 file_id -> 下一轮文本问答使用)
|
||||||
|
|
||||||
核心目标:让 Agent 在安全边界内持续补全能力,而不是仅做静态问答。
|
核心目标:在安全边界内持续扩展能力,并兼容文本问答与文档长上下文问答的混合场景。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 目录与模块
|
## 2. 关键模块
|
||||||
|
|
||||||
- `cmd/bot/main.go`:应用入口、workspace 引导、toolhost 启动、通道分发
|
- `cmd/bot/main.go`:应用入口、workspace 引导、toolhost 启动、消息通道分发
|
||||||
- `internal/config/config.go`:配置加载、workspace 路径解析、安全策略归一化
|
- `internal/config/config.go`:配置加载、workspace 解析、安全策略归一化、LLM 模型策略
|
||||||
- `internal/runtimews/bootstrap.go`:运行时 workspace 准备与种子目录复制
|
- `internal/runtimews/bootstrap.go`:runtime workspace 初始化与种子复制
|
||||||
- `internal/agent/orchestrator.go`:主编排器(技能匹配、ReAct、能力缺口闭环)
|
- `internal/agent/orchestrator.go`:主编排(路由、ReAct、文件上下文、能力缺口)
|
||||||
- `internal/toolhost/*`:工具子进程协议、服务端、客户端、远程工具适配
|
- `internal/llm/client.go`:OpenAI 兼容客户端(聊天、文件上传、文件注入模式)
|
||||||
- `internal/tools/filetool/filetool.go`:文件工具(`read/list/write`)
|
- `internal/transport/feishu/bot.go`:飞书事件接入、文件下载与本地落盘
|
||||||
- `internal/tools/shelltool/shelltool.go`:命令工具(白名单 + 超时 + 输出限制)
|
- `internal/toolhost/*`:工具子进程协议、客户端/服务端、远程工具适配
|
||||||
- `internal/memory/store_sqlite.go`:消息与能力缺口存储、聚类查询
|
- `internal/memory/store_sqlite.go`:消息与能力缺口存储
|
||||||
- `internal/knowledge/loader.go`:skill/soul 加载
|
- `internal/knowledge/*`:soul/skills 加载与技能草稿生成
|
||||||
- `internal/knowledge/drafts.go`:能力缺口驱动的 skill 自动生成
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 启动链路(当前)
|
## 3. 启动链路
|
||||||
|
|
||||||
`main()` 执行顺序:
|
`main()` 执行顺序:
|
||||||
1. 建立可取消上下文(SIGINT/SIGTERM)。
|
1. 创建 SIGINT/SIGTERM 可取消上下文。
|
||||||
2. 调用 `runtimews.PrepareFromEnv()`:
|
2. 调用 `runtimews.PrepareFromEnv()`:
|
||||||
- 解析 `AGENT_WORKSPACE_DIR`(默认 `./workspace/agent_runtime`)
|
- 解析 `AGENT_WORKSPACE_DIR`(默认 `./workspace/agent_runtime`)
|
||||||
- 将 `configs/data/skills/bot_context` 种子复制到 runtime workspace(缺失才复制)
|
- 复制 `configs/data/skills/bot_context` 种子到 runtime workspace(缺失才复制)
|
||||||
- 设定 `CONFIG_ENV_FILE=<workspace>/configs/env`
|
- 注入 `CONFIG_ENV_FILE=<workspace>/configs/env`
|
||||||
3. 调用 `config.Load()`,优先读取 workspace env。
|
3. 调用 `config.Load()`,按优先级读取 env。
|
||||||
4. 若 `--toolhost` 模式,进入子进程服务。
|
4. `--toolhost` 模式下进入子进程工具服务。
|
||||||
5. 正常父进程:初始化日志、SQLite、ToolHost Client、知识、Orchestrator。
|
5. 父进程初始化日志、SQLite、ToolHost Client、知识、Orchestrator。
|
||||||
6. 根据 `MESSAGE_CHANNEL` 启动 Telegram 或 Feishu transport。
|
6. 按 `MESSAGE_CHANNEL` 启动 Telegram 或 Feishu。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 配置加载与优先级(关键变更)
|
## 4. 配置优先级与关键配置
|
||||||
|
|
||||||
`config.Load()` 的 env 读取优先级:
|
`config.Load()` 的 env 优先级:
|
||||||
1. `CONFIG_ENV_FILE`(强覆盖)
|
1. `CONFIG_ENV_FILE`(强覆盖)
|
||||||
2. `<workspace>/configs/env` 与 `<workspace>/.env`(强覆盖)
|
2. `<workspace>/configs/env`、`<workspace>/.env`(强覆盖)
|
||||||
3. 根目录 `configs/env` 与 `.env`(仅兜底,不覆盖已有值)
|
3. 根目录 `configs/env`、`.env`(仅兜底)
|
||||||
|
|
||||||
这保证 VS Code Debug 场景下,**workspace 配置优先于根目录配置**。
|
关键配置:
|
||||||
|
- `REACT_MAX_STEPS`:必须配置(1~8)
|
||||||
### 关键配置
|
- `AGENT_WORKSPACE_DIR`:运行空间根目录
|
||||||
- `REACT_MAX_STEPS`:必须来自 env(无代码默认值)
|
|
||||||
- `AGENT_WORKSPACE_DIR`:agent 运行空间根目录
|
|
||||||
- `ALLOWED_DIRS` / `ALLOWED_COMMANDS` / `WORK_DIR`:工具安全边界
|
- `ALLOWED_DIRS` / `ALLOWED_COMMANDS` / `WORK_DIR`:工具安全边界
|
||||||
- `AUTO_SKILL_DIR`:自动生成 skill 的目标目录(默认 workspace/skills)
|
- `AUTO_SKILL_DIR`:自动生成 skill 目录
|
||||||
- `GAP_DRAFT_TRIGGER_COUNT` / `GAP_CLUSTER_LOOKBACK_HOURS`:缺口聚类触发参数
|
- `GAP_DRAFT_TRIGGER_COUNT` / `GAP_CLUSTER_LOOKBACK_HOURS`:缺口聚类参数
|
||||||
|
|
||||||
|
### 新增(文档问答)
|
||||||
|
- `LLM_MODEL`:常规文本问答模型(路由 + ReAct 默认模型)
|
||||||
|
- `LLM_FILE_MODEL`:携带 `file_id` 时使用的模型(未设置时回退 `LLM_MODEL`)
|
||||||
|
- `LLM_FILE_PROMPT_MODE`:文件注入模式
|
||||||
|
- `user_content_file_parts`(默认):`messages.user.content=[{type:text},{type:file,file_id}]`
|
||||||
|
- `system_fileid_uri`:按 provider 要求注入 `system: fileid://<id>`
|
||||||
|
|
||||||
|
说明:`LLM_FILE_PROMPT_MODE=system_fileid_uri` 可对齐 Qwen/DashScope 兼容模式中常见的 `fileid://` 用法。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. workspace 隔离策略
|
## 5. workspace 隔离策略
|
||||||
|
|
||||||
当前实现中,Agent 与工具默认都在 workspace 内高权限运行:
|
当前实现中,Agent 与工具默认都在 workspace 内运行:
|
||||||
- 相对路径统一按 `AGENT_WORKSPACE_DIR` 解析
|
- 相对路径按 `AGENT_WORKSPACE_DIR` 解析
|
||||||
- `ALLOWED_DIRS` 强制补齐:
|
- `ALLOWED_DIRS` 强制补齐:
|
||||||
- workspace 根
|
- workspace 根
|
||||||
- `workspace/skills`
|
- `workspace/skills`
|
||||||
@@ -75,122 +82,147 @@ LaodingBot 当前已从“单进程工具调用 MVP”演进为:
|
|||||||
- `workspace/workspace`
|
- `workspace/workspace`
|
||||||
- `ALLOWED_COMMANDS` 自动补齐:`go`、`curl`、`curl.exe`
|
- `ALLOWED_COMMANDS` 自动补齐:`go`、`curl`、`curl.exe`
|
||||||
|
|
||||||
`filetool` 对相对路径优先按 workspace 根解析,避免写到代码仓库根目录。
|
`filetool` 对相对路径优先解析到 workspace 根,避免写到仓库根。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. ToolHost 子进程架构
|
## 6. ToolHost 子进程架构
|
||||||
|
|
||||||
当前工具调用已迁移到 JSON-RPC 子进程:
|
工具调用通过 JSON-RPC 子进程完成:
|
||||||
- 协议方法:`ping`、`tool.list`、`tool.call`
|
- 协议:`ping`、`tool.list`、`tool.call`
|
||||||
- 父进程 `Client` 能力:
|
- 父进程客户端能力:
|
||||||
- 调用超时
|
- 调用超时
|
||||||
- 心跳检测
|
- 心跳检测
|
||||||
- 失败重启与重试
|
- 失败重启与重试
|
||||||
- 并发限制(信号量)
|
- 并发限制(信号量)
|
||||||
- 子进程 stdout 仅承载协议数据(避免日志污染 RPC)
|
- 子进程 stdout 仅输出协议内容,避免日志污染
|
||||||
|
|
||||||
效果:工具崩溃不会直接拖垮 Agent 主编排逻辑。
|
结果:工具崩溃不会直接拖垮 Agent 主编排。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. ReAct 与技能路由
|
## 7. 文本问答主流程(统一 ReAct)
|
||||||
|
|
||||||
`Orchestrator` 流程:
|
`Orchestrator.HandleMessage*()` 流程:
|
||||||
1. 保存用户消息到 SQLite
|
1. 保存用户消息
|
||||||
2. 读取最近对话并压缩
|
2. 加载最近消息并压缩
|
||||||
3. LLM 进行技能路由(最多命中 2 个)
|
3. 执行能力路由(Router)
|
||||||
4. 若无技能命中:尝试回退到 `创建skill` 技能
|
4. 进入统一 ReAct 循环
|
||||||
5. 进入 ReAct 多轮决策(`action/final`)
|
5. 按决策调用工具并将 Observation 写入 scratchpad
|
||||||
6. 工具调用观察写入 scratchpad
|
6. 直到 `is_final_answer=true`
|
||||||
7. 保存 assistant 回复
|
7. 保存 assistant 回复
|
||||||
|
|
||||||
工具错误会结构化为:
|
注:当前循环有固定安全上限 20 步(代码内硬上限)。
|
||||||
- `ERROR_CODE=...; TOOL=...; REASON=...`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 能力缺口闭环(已落地)
|
## 8. 文档问答链路(飞书)
|
||||||
|
|
||||||
当出现“不会做”信号(如无 skill、解析失败、工具失败)时:
|
### 8.1 接收与下载
|
||||||
1. 写入 `capability_gaps` 表
|
`internal/transport/feishu/bot.go` 在 `msg_type=file` 时:
|
||||||
2. 进行意图归一化聚类(按 `intent_key + reason`)
|
1. 从事件中解析 `file_key`、`file_name`
|
||||||
3. 高频达到阈值后自动生成 skill 文件
|
2. 调用飞书 `message resource` 下载二进制
|
||||||
4. 自动调用 `ReloadSkills()` 热加载
|
3. 校验大小(默认上限 20MB)
|
||||||
|
4. 保存到本地 `files/` 目录
|
||||||
|
5. 组装 `IncomingMessage{FileBytes, FileMime, FilePath}` 交给主流程
|
||||||
|
|
||||||
可通过消息命令查看与控制:
|
### 8.2 上传与缓存 file_id
|
||||||
- `/capability_gaps`:输出当前高频缺口清单
|
`cmd/bot/main.go` 在 Feishu 文件消息分支:
|
||||||
- `/reload_skills`:手动热加载 skills
|
- 调用 `engine.HandleMessageWithFiles(..., text="", files=[...])`
|
||||||
|
- Orchestrator 识别为 `isFileOnly`:
|
||||||
|
- 上传文件到 LLM(`UploadFile(..., purpose=file-extract)`)
|
||||||
|
- 缓存 `pendingFiles[chat_id::user_id]`
|
||||||
|
- 回复“文件上传完成,等待下一次提问”
|
||||||
|
|
||||||
|
### 8.3 下一轮文本提问使用 file_id
|
||||||
|
当用户随后发送文本:
|
||||||
|
- Orchestrator 取出 `pendingFiles`
|
||||||
|
- 构建 `fileCtx.FileIDs`
|
||||||
|
- 在 ReAct 内通过 `generateWithOptionalFiles()` 调用 LLM
|
||||||
|
- 回答成功后清空该用户待消费文件缓存
|
||||||
|
|
||||||
|
该行为与需求一致:文件上传与提问分离,且 `file_id` 在下一轮问答自动注入。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 自动生成 skill 的当前行为
|
## 9. LLM 文件能力实现细节
|
||||||
|
|
||||||
自动生成由 `internal/knowledge/drafts.go` 执行:
|
`internal/llm/client.go` 当前能力:
|
||||||
- 目标目录:`AUTO_SKILL_DIR`(默认 workspace/skills)
|
|
||||||
- 命名:`auto_<intent_key>/skill.md`
|
|
||||||
- 仅在文件不存在时创建,避免重复覆盖
|
|
||||||
- 模板内包含:触发背景、执行流程、工具建议、测试建议
|
|
||||||
|
|
||||||
并额外提供基础引导技能:
|
### 9.1 文件上传
|
||||||
- `skills/skill_builder/skill.md`
|
- 接口:`POST /files`
|
||||||
|
- multipart 字段:`purpose` + `file`
|
||||||
|
- 目的值尝试顺序:调用方指定 -> `file-extract` -> `batch`
|
||||||
|
- 兼容返回:`id` 或 `data.id`
|
||||||
|
|
||||||
|
### 9.2 模型切换策略
|
||||||
|
- 无文件:使用 `LLM_MODEL`
|
||||||
|
- 有文件:优先使用 `LLM_FILE_MODEL`
|
||||||
|
|
||||||
|
### 9.3 文件注入策略
|
||||||
|
- `user_content_file_parts`:`user.content` 使用 text/file parts
|
||||||
|
- `system_fileid_uri`:将每个 `file_id` 注入为一条 `system: fileid://<id>` 消息
|
||||||
|
|
||||||
|
这使同一套 ReAct 编排可适配不同 provider 的文件上下文协议差异。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. file/shell 工具现状
|
## 10. 为什么不会破坏 ReAct / tools / skills
|
||||||
|
|
||||||
### file tool
|
本次改造仅发生在 LLM I/O 层,不改变编排核心:
|
||||||
支持:
|
- ReAct 决策协议(JSON 输出格式)不变
|
||||||
- `read <path>`
|
- ToolHost、工具注册与调用链路不变
|
||||||
- `list <path>`
|
- 技能加载、路由与能力缺口闭环不变
|
||||||
- `write <path>\n<content>`
|
- 仅在 `GenerateWithFiles()` 分支切换模型与消息格式
|
||||||
|
|
||||||
特性:
|
因此:文本问答仍走原有路径,文档问答只在“带 file_id 的 LLM 调用”处差异化。
|
||||||
- 白名单路径检查
|
|
||||||
- 目录误读防护:`read` 目录返回 `PATH_IS_DIRECTORY`
|
|
||||||
- 输出长度限制
|
|
||||||
|
|
||||||
### shell tool
|
|
||||||
特性:
|
|
||||||
- 命令白名单(首 token)
|
|
||||||
- 超时中断
|
|
||||||
- 固定工作目录
|
|
||||||
- 输出截断
|
|
||||||
- Windows 不可执行命令友好报错
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. 数据存储
|
## 11. 数据存储
|
||||||
|
|
||||||
SQLite 表:
|
SQLite 表:
|
||||||
1. `messages`:对话消息
|
1. `messages`:用户与 assistant 对话
|
||||||
2. `capability_gaps`:能力缺口事件
|
2. `capability_gaps`:能力缺口事件
|
||||||
|
|
||||||
提供查询:
|
支持查询:最近消息、最近缺口、高频缺口聚类。
|
||||||
- 最近消息
|
|
||||||
- 最近缺口事件
|
|
||||||
- 高频缺口聚类(含计数与最近出现时间)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. 与最初文档相比的变化
|
## 12. 与旧文档相比的更新点
|
||||||
|
|
||||||
当前代码已经完成并替代旧文档中的以下“待实现项”:
|
已补充并对齐代码现状:
|
||||||
- ToolHost 子进程隔离(已实现)
|
- 飞书文件事件下载与本地保存流程
|
||||||
- 能力缺口闭环(已实现)
|
- 文件上传到 LLM 并缓存 `file_id` 的两阶段问答流程
|
||||||
- 自动 skill 生成与热加载(已实现)
|
- 双模型配置:`LLM_MODEL` + `LLM_FILE_MODEL`
|
||||||
- workspace 配置优先与运行空间隔离(已实现)
|
- 文件注入模式:`LLM_FILE_PROMPT_MODE`
|
||||||
|
- “不影响 ReAct/tools/skills”的边界说明
|
||||||
仍属于持续演进项:
|
|
||||||
- 新工具代码自动注册与生效的全自动化流水线
|
|
||||||
- 更细粒度权限域(按 skill/tool 分级)
|
|
||||||
- 更强的自动化验收(e2e + 故障注入)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. 下一步建议
|
## 13. 推荐配置(Qwen Long 场景)
|
||||||
|
|
||||||
1. 为 `toolhost client/server` 增加专项故障单测(心跳失败、子进程崩溃、并发压力)。
|
示例:
|
||||||
2. 增加“自动生成 tool 后自动接线注册”的流水线模块。
|
|
||||||
3. 为 skill 自动生成增加结构门禁(frontmatter/章节完整性校验)。
|
```env
|
||||||
4. 引入操作审计视图,串联 trace_id 与 capability_gap。
|
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
LLM_API_KEY=<your_key>
|
||||||
|
|
||||||
|
LLM_MODEL=qwen-plus
|
||||||
|
LLM_FILE_MODEL=qwen-long
|
||||||
|
LLM_FILE_PROMPT_MODE=system_fileid_uri
|
||||||
|
```
|
||||||
|
|
||||||
|
解释:
|
||||||
|
- 普通对话用 `qwen-plus`
|
||||||
|
- 文档问答自动切到 `qwen-long`
|
||||||
|
- 文件上下文注入采用 `fileid://` 形式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 后续建议
|
||||||
|
|
||||||
|
1. 为 `internal/llm/client.go` 增加表驱动单测,覆盖两种 `LLM_FILE_PROMPT_MODE`。
|
||||||
|
2. 在能力路由中加入“文档问答意图”标记,优化带文件时的提示词压缩策略。
|
||||||
|
3. 为 pending file_id 增加 TTL 清理,避免长期未消费堆积。
|
||||||
|
4. 增加 e2e 用例:飞书文件消息 -> 下一轮提问 -> 产出稳定答案。
|
||||||
|
|||||||
18
docker-compose.yaml
Normal file
18
docker-compose.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
laodingbot:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: laodingbot:latest
|
||||||
|
container_name: laodingbot
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
|
env_file:
|
||||||
|
- ./workspace/agent_runtime/configs/env
|
||||||
|
environment:
|
||||||
|
MESSAGE_CHANNEL: webui
|
||||||
|
WEBUI_LISTEN_ADDR: ":8090"
|
||||||
|
AGENT_WORKSPACE_DIR: /app/workspace/agent_runtime
|
||||||
|
volumes:
|
||||||
|
- ./workspace/agent_runtime:/app/workspace/agent_runtime
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,250 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
Sub-Goal 5: User’s experience is
|
|
||||||
enhanced by the provision of Use Cases: 3 Requirements: 8
|
|
||||||
value-added services
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Figure 2 Structure of this document.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1.6 Product Scope and Perspective
|
|
||||||
The EIP Project’s urban platform is an open common architecture which serves for city data
|
|
||||||
collection, management and distribution. An urban platform is intended to support the widespread
|
|
||||||
exploitation of city data by humans and machines in the urban environment. Figure 3 illustrates a
|
|
||||||
holistic high level overview of the urban platform the EIP project intends to deliver.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 6
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
The reference architecture for urban platforms should:
|
|
||||||
|
|
||||||
• Cater for interoperability between urban infrastructures
|
|
||||||
• Enable replicability of the solutions/platforms city to city
|
|
||||||
• Scale without technical constraints and excessive cost increase
|
|
||||||
• Provide open APIs and SDKs
|
|
||||||
• Enable Real Time capabilities
|
|
||||||
• Support implementation of functional and technical capabilities
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Figure 3. High level overview of the urban platform (currently approved EC DG CNECT).
|
|
||||||
|
|
||||||
|
|
||||||
The current urban platform market is nascent. Many software vendors offer such a platform, though
|
|
||||||
many requirements and expectations of the stakeholders of city data are not (fully) addressed. As
|
|
||||||
a result, current urban platforms are often more costly to design and maintain, less reusable and
|
|
||||||
often not interoperable platform-to-platform, and susceptible to information fragmentation and
|
|
||||||
overload.
|
|
||||||
|
|
||||||
The urban platform which the EIP initiative intends to design takes a step beyond the platforms
|
|
||||||
currently on the market by ensuring the requirements are fully founded on a co-created and
|
|
||||||
common set of representative city needs, from which it solicits suitable industry input, and an open
|
|
||||||
and managed collaboration between industry, cities and communities, and others, in order to take
|
|
||||||
into account their needs and concerns. To do this, it is necessary to take a technology agnostic
|
|
||||||
approach to design an open and common reference architecture for urban platforms. This platform
|
|
||||||
must ensure data is collected and sustained in accordance with well-stablished standards,
|
|
||||||
managed in a robust manner so that it can handle high level supply and demand of data, and
|
|
||||||
distributed across different value chains, systems and stakeholders. The ability to handle high level
|
|
||||||
of city data supply and demand while being user secure and accessible enough for city-wide
|
|
||||||
exploitation of data is one of many keys to the success of urban platforms. This is central to the
|
|
||||||
design and implementation of urban platforms.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 7
|
|
||||||
|
|
||||||
|
|
||||||
1.7 The Urban Platform Development Stack
|
|
||||||
The requirements specification of this document is based on the Urban Platform Development
|
|
||||||
Stack illustrated in Figure 4. The stack is composed by five domains (represented as layers in the
|
|
||||||
stack) necessary to fully implement an urban platform software suite as shown in Table 1. Each
|
|
||||||
domain comprehends a set of requirements necessary to the design of a common and open urban
|
|
||||||
data service platform. The elicited requirements are used to define a technical architecture which is
|
|
||||||
simple enough to be comprehensible at least at a high level of abstraction. The platform should be
|
|
||||||
conceptually decomposable into its major subsystems, the platform’s functionality reused by many
|
|
||||||
services and external applications should be identifiable, and interactions between the platform
|
|
||||||
and services, data providers and data consumers should be well defined and explicit.
|
|
||||||
|
|
||||||
The first layer of the stack “Societal needs” concerns to outcomes we strive for within a portfolio of
|
|
||||||
city service domains. An urban platform should recognise societal needs and wants as the starting
|
|
||||||
point for city data service offering. Ultimately, an urban platform aims to provide tailor made and
|
|
||||||
compelling engaging services for the users. The Services and Business models layers concerns
|
|
||||||
with delivering data services which carefully targets the needs and expectations of the different
|
|
||||||
users of the urban platform, and explore use cases and commercial models where data is used to
|
|
||||||
deliver different forms of value. The city data layer concerns with the mechanisms necessary to
|
|
||||||
transform urban platforms into a foundation for widespread exploitation of data, including handling
|
|
||||||
data architectural features, data usability, semantics and quality aspects. The urban platform layer
|
|
||||||
concerns to the technology foundation to configure, share, and interpret exponentially increasing
|
|
||||||
volumes city data and services. Finally, the Infrastructure layer concerns with the base level
|
|
||||||
connectivity that supports the platform to be scalable and reliable in the long run.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
STACK OUTPUT
|
|
||||||
|
|
||||||
Requirements to deliver new digital services that will address the
|
|
||||||
Societal Needs societal needs of cities in a positive manner that relates to political
|
|
||||||
narratives.
|
|
||||||
|
|
||||||
|
|
||||||
Services & Requirements to new profitable business models and the development
|
|
||||||
Business Models of an increase range of new and engaging services in the smart cities.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
City Data Requirements to provide all city data stakeholders ready access and
|
|
||||||
delivery of all city data that unpins the decision making process in smart
|
|
||||||
cities.
|
|
||||||
|
|
||||||
|
|
||||||
Urban Platform Requirements to put in place applications together to build a foundation
|
|
||||||
for the widespread exploitation of data.
|
|
||||||
|
|
||||||
|
|
||||||
Infrastructure Requirements to deliver the backbone infrastructure that will be used to
|
|
||||||
capture the opportunities of digital technology and data to enable
|
|
||||||
transformation.
|
|
||||||
|
|
||||||
|
|
||||||
Figure 4. Urban Platform Development Stack.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 8
|
|
||||||
|
|
||||||
|
|
||||||
Table 1. Urban Platform Development Stack
|
|
||||||
Layer Rationale
|
|
||||||
Societal Needs - Accessible services and data necessary to solve social problems
|
|
||||||
and drive innovation;
|
|
||||||
- Parameters that influence user’s experience while interacting with
|
|
||||||
services (e.g. usability, feeling of security and trust);
|
|
||||||
Services and Business - Tailor-made data services which careful targets the needs of users
|
|
||||||
Models and businesses;
|
|
||||||
- New potential and cost-effective beneficial services that could be
|
|
||||||
rolled out across cities of different sizes;
|
|
||||||
- Use cases where data is used to deliver different forms of value.
|
|
||||||
City Data - Data architectural features (e.g. volume, variety, temporal factors
|
|
||||||
and sensitivity);
|
|
||||||
- Data licensing, policies and regulations to exploit data to full effect;
|
|
||||||
- Minimum metadata requirements;
|
|
||||||
- Data usability and reusability aspects of humans and machines.
|
|
||||||
Urban Platform - Holistic and interoperable solutions;
|
|
||||||
- Integrated approaches which ensures that services fit together and
|
|
||||||
that synergies can be exploited;
|
|
||||||
- Data management mechanisms to ensure data integrity and
|
|
||||||
compliance with data protection regulations
|
|
||||||
- Extension capabilities to accommodate additional functionality at
|
|
||||||
later stage at a fair and transparent cost.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1.8 User Classes, Characteristics, and User Access
|
|
||||||
The users of the Urban Platform include end-users, such as the general Public, public and private
|
|
||||||
organisations; data providers; service providers; and the platform providers who will be
|
|
||||||
working with the providers of city data and services, and managing the content, defining policies
|
|
||||||
and regulations of the platform. A crucial feature of an urban platform is the provision of the
|
|
||||||
various access levels required by the different types of users. Particular uses need different
|
|
||||||
access levels to some data than the general public. Data publishers will require access to the
|
|
||||||
Urban Platform in order to ingest, administer, manage, preserve and access their resources. This
|
|
||||||
will require multiple levels of access to city data and its respective metadata. Table 2 provides a
|
|
||||||
description of each class of users.
|
|
||||||
|
|
||||||
|
|
||||||
Table 2. Actors
|
|
||||||
User Class Rationale
|
|
||||||
Platform - Maintains the ecosystem of data, services and users;
|
|
||||||
Provider - Defines standards, licenses and regulations and provides terms and conditions
|
|
||||||
for platform usage and the commercial exploitation of data and services;
|
|
||||||
- Decides who are allowed to join the value network of data and services
|
|
||||||
providers;
|
|
||||||
City Data - Publishes open and proprietary data into the platform;
|
|
||||||
Publisher - Manages and maintain resources in the platform accordingly to terms and
|
|
||||||
conditions.
|
|
||||||
Data - Deploys open and commercial data services into the platform (e.g. data
|
|
||||||
Services visualisation, data cleansing, data integration tools);
|
|
||||||
Provider - Manages and maintain resources in the platform accordingly to terms and
|
|
||||||
conditions.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 9
|
|
||||||
|
|
||||||
|
|
||||||
City Data - Consumes open and proprietary data provided in the platform;
|
|
||||||
Consumer - Uses open and commercial data services provided in the platform;
|
|
||||||
- Provides feedback on data and services provision;
|
|
||||||
|
|
||||||
|
|
||||||
1.8.1 End-User Access
|
|
||||||
|
|
||||||
City data consumers will need to access and use the city data residing in the Urban Platform. End-
|
|
||||||
users will be able to search metadata and full text within datasets (when available), and obtain city
|
|
||||||
data in open formats readily available to both humans and machines such as CSV, XML, JSON.
|
|
||||||
Some end-users may require different access rights to city data. The 2 major end-user groups that
|
|
||||||
have been identified are:
|
|
||||||
|
|
||||||
• Open data users, including both national and international users (humans and machines).
|
|
||||||
Open access to some city data may be restricted by licensing terms (e.g. commercial data),
|
|
||||||
embargo periods, copyright, etc.
|
|
||||||
|
|
||||||
• Private data users, which need to use the Urban Platform to obtain commercial city data. Data
|
|
||||||
access is available via data subscriptions or when purchase requirements and licenses are
|
|
||||||
waived by the data provider.
|
|
||||||
|
|
||||||
|
|
||||||
1.8.2 City Data Publisher Access
|
|
||||||
|
|
||||||
A broad data provider level access is needed for stakeholders (humans and machines) working
|
|
||||||
with the urban platform and their respective data in it. Basically, data publishers will carry out the
|
|
||||||
following activities:
|
|
||||||
• Data publication access, available to publishers adding new data and metadata, checking the
|
|
||||||
quality of datasets, manipulating data, performing format conversions, defining data-access
|
|
||||||
level, tariff for consumption when applicable, and licences.
|
|
||||||
|
|
||||||
• Data maintenance access, for publishers reviewing or editing appropriate data and metadata in
|
|
||||||
the urban platform. Data publishers can view data and add to or edit metadata without
|
|
||||||
changing the data itself. They should be provided with access to feedback from users to
|
|
||||||
investigate problem in their resources (e.g. missing data, inconsistent metadata), and statistical
|
|
||||||
information about how their resources are used by users.
|
|
||||||
|
|
||||||
|
|
||||||
1.8.3 Data Services Provider Access
|
|
||||||
|
|
||||||
This is the second most restrictive access level providing rights to deploy services in the platform.
|
|
||||||
Basically, data service providers will carry out the following activities:
|
|
||||||
|
|
||||||
• Data services deployment access, available to service providers adding new mechanisms or
|
|
||||||
integrating new applications, testing and validating integration, defining data-access level and
|
|
||||||
tariff for service usage.
|
|
||||||
|
|
||||||
• Data services maintenance access, for services providers reviewing, extending or editing
|
|
||||||
applications in the urban platform. Data services providers can view their services deployed
|
|
||||||
and add to or edit access level and tariff without having to deploy the services again. They
|
|
||||||
should be provided with access to feedback from users to investigate problem in their services
|
|
||||||
(e.g. bugs, scalability issues), and statistical information about how their services are used by
|
|
||||||
users.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 10
|
|
||||||
|
|
||||||
|
|
||||||
1.8.4 Platform Provider Access
|
|
||||||
|
|
||||||
This is the most restrictive access level providing ultimate rights to the system and is required for
|
|
||||||
its management, development, and assigning appropriate rights to data and services providers.
|
|
||||||
Policies and regulations, license agreements are also defined by the provider of the urban
|
|
||||||
platform. Platform providers should be provided with the means to follow up on civic engagement
|
|
||||||
(e.g. feedback, request for city data) and on the provision of city data and services.
|
|
||||||
|
|
||||||
|
|
||||||
1.9 User Documentation
|
|
||||||
• City Data Consumers: Provide license terms and conditions associated to consuming
|
|
||||||
data and services provided in the platform, documentation of APIs and guide to
|
|
||||||
discover city data in the platform.
|
|
||||||
• City Data Providers: Provide data publication documentation describing the minimum
|
|
||||||
metadata requirements, formats accepted, step-by-step guide to publish accurate city
|
|
||||||
data.
|
|
||||||
• Data Service Providers: Disclosure technical and architecture blueprint details in
|
|
||||||
order share and outsource expertise, and partnerships, and integrate supporting
|
|
||||||
partners’ solutions into the platform itself.
|
|
||||||
|
|
||||||
|
|
||||||
1.10 Design and Implementation Constraints
|
|
||||||
1.10.1 Design Constraints
|
|
||||||
|
|
||||||
• Lack of standards agreement for metadata representation.
|
|
||||||
@@ -1,600 +0,0 @@
|
|||||||
• City data found in existing data catalogues may require special consideration concerning the
|
|
||||||
type of formats and datasets that must be stored within the platform.
|
|
||||||
• Requirements mismatch due to increased number of stakeholders involved in the design
|
|
||||||
|
|
||||||
|
|
||||||
1.10.2 Implementation Constraints
|
|
||||||
|
|
||||||
• Evaluation and testing of software options is expected to occur prior to selection and
|
|
||||||
implementation of a production urban platform.
|
|
||||||
• Budget costs are unknown until evaluation of software options is completed.
|
|
||||||
|
|
||||||
|
|
||||||
1.11 Assumptions, Alignment with other Action Clusters and Policies
|
|
||||||
1.11.1 Assumptions
|
|
||||||
|
|
||||||
The assumptions in Table 3 have been identified by the Demand Side Engagement Stream as
|
|
||||||
relevant to this Requirements Specification.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 11
|
|
||||||
|
|
||||||
|
|
||||||
Table 3. Assumptions
|
|
||||||
|
|
||||||
# ASSUMPTION
|
|
||||||
|
|
||||||
1 The providers of city data and services will be responsible to maintain their resources in the platform.
|
|
||||||
|
|
||||||
2 All city data must meet the minimum metadata requirements and use the standards adopted by the platform.
|
|
||||||
|
|
||||||
3 The platform shall consider open Source as an optional commercial model, with open standards as a principle
|
|
||||||
|
|
||||||
4 The system design and architecture should minimize fragmentation of city data in the urban platform.
|
|
||||||
|
|
||||||
5 To the extent possible, automation should be used for the extraction of descriptive and technical metadata.
|
|
||||||
|
|
||||||
6 The platform must be designed in a way it accommodates additional functionality at later stage at a fair and transparent cost.
|
|
||||||
|
|
||||||
The platform must be a modular based architecture which relies on stable and well-defined open interfaces to ensure
|
|
||||||
7
|
|
||||||
interoperability between the platform, services and the applications provided by service providers.
|
|
||||||
|
|
||||||
The platform will offer open and well-documented API’s and clear service descriptions and contracts that is offered for reuse by
|
|
||||||
8 another party to foster open innovation in the city, which means that developers and interested individuals openly utilize the
|
|
||||||
resources provided.
|
|
||||||
|
|
||||||
9 Adopt open and published European and International standards where possible.
|
|
||||||
|
|
||||||
The platform must be flexible enough to accommodate different local, National and International data protection, licensing and
|
|
||||||
10
|
|
||||||
commercialization regulations.
|
|
||||||
|
|
||||||
11 Platform providers will monitor emerging technologies in order to maintain and improve the architecture.
|
|
||||||
|
|
||||||
|
|
||||||
12 Platform providers will monitor emerging information standards, including metadata standards and data interface standards.
|
|
||||||
|
|
||||||
|
|
||||||
13 Platform providers will monitor new commercial models for city data exploitation
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1.11.2 Alignment with Citizen’s Focus Action Cluster
|
|
||||||
This specification document is aligned with the principles defined in the Citizen Focus5 Action
|
|
||||||
Cluster of the European Innovation Partnership on Smart Cities and Communities. Citizen Focus’ is
|
|
||||||
about “working together with citizens to realize public interests at the intersection of ICT, mobility
|
|
||||||
and energy in an urban environment”. We recognize citizens as owners of and participants in
|
|
||||||
the creation and delivery of city data and digital services, and we specify requirements to deliver
|
|
||||||
new digital services that will address the societal needs of cities in a positive manner that relates to
|
|
||||||
political narratives. Societal needs and wants are considered the starting point for city data service
|
|
||||||
offering by the urban platform. The requirements in this document were elicited considering
|
|
||||||
|
|
||||||
• Human behaviour and needs as important as technology;
|
|
||||||
• The services and data that solve social problems and drive innovation;
|
|
||||||
• The mechanisms that make data and services more accessible to users;
|
|
||||||
• The factors that influences user’s experience while interacting with services provided (e.g.
|
|
||||||
usability, feeling of security and trust);
|
|
||||||
|
|
||||||
|
|
||||||
5
|
|
||||||
https://eu-smartcities.eu/node/1333
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 12
|
|
||||||
|
|
||||||
|
|
||||||
1.11.3 Policies to be developed
|
|
||||||
|
|
||||||
The following policies have been identified by the Demand Side Engagement Stream as relevant to
|
|
||||||
this Requirements Specification.
|
|
||||||
|
|
||||||
• Data formatting and Metadata Schemes: Urban platforms will require more expansive, robust
|
|
||||||
and useful data encoding and conversion that what is available in existing data catalogues.
|
|
||||||
Data preservation policies should be developed to allow data to be stored in formats that can
|
|
||||||
be migrated, associated with metadata and ontologies to become both humans and machines
|
|
||||||
readable and understandable, ongoing monitoring for data obsolescence, and migrating data to
|
|
||||||
systems environments as needed to ensure their continued availability. Current Metadata
|
|
||||||
schemes (e.g. open data, sensory data ontologies) should be reviewed to see if it meets
|
|
||||||
current needs for city data management. It is possible that the needs of the urban platform
|
|
||||||
designed here may require new or additional schemas.
|
|
||||||
|
|
||||||
• Data commercialisation: The commercial exploitation of city data and their funding models
|
|
||||||
are unexplored concepts that we are committed to address. There is an urgent need to define
|
|
||||||
license agreements and fair commercial and subscription models to allow interoperable open
|
|
||||||
and proprietary data to co-exist in the platform.
|
|
||||||
|
|
||||||
• Data publication and services deployment: Policy development will be needed regarding
|
|
||||||
ingesting data and deploying data services into the platform, including which users/machines
|
|
||||||
will be authorized to submit data for publication, the minimum requirements for data submitted
|
|
||||||
by open and proprietary data publishers, and the removal of resources and services from the
|
|
||||||
platform.
|
|
||||||
|
|
||||||
|
|
||||||
2. Urban Platform Value Proposition, Use Cases and
|
|
||||||
Functional Requirements
|
|
||||||
2.1 From Value Proposition to Platform Specifications
|
|
||||||
This document uses goal-oriented modelling for eliciting, elaborating, structuring, specifying,
|
|
||||||
documenting, and modifying requirements. Goals represent the objectives which the urban
|
|
||||||
platform should achieve through cooperation of actors in the intended system and in the
|
|
||||||
environment. They capture, at different levels of abstraction, the various objectives the urban
|
|
||||||
platform under design should achieve. Through goals modelling we consider how the value
|
|
||||||
proposition and intended solutions connects across the stack, how the urban platform meets city
|
|
||||||
goals, why the system and its functionality are needed, and how the stakeholders’ interests may be
|
|
||||||
addressed.
|
|
||||||
|
|
||||||
In our specification, we present the overall goal (the value proposition) that the urban platform
|
|
||||||
should aim to achieve in order to be considered as a viable final product, and a set of sub-goals
|
|
||||||
(intended solutions) it should maintain in the long run so that the overall goal can be unceasingly
|
|
||||||
achieved. By using this approach, the low-level technical requirements can be traced back to high-
|
|
||||||
level strategic objectives of the urban platform. The formal notations used in this document are:
|
|
||||||
Achieve [“Name of Overall Goal”] and Maintain [“Name of Sub-Goal”]. The requirements of the
|
|
||||||
Urban Platform are noted for each of the sub-goals, and are presented as a series of statements
|
|
||||||
regarding the capabilities needed in to achieve the overall goal of the Urban Platform.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 13
|
|
||||||
|
|
||||||
|
|
||||||
2.2 Overall Goal: “City data is exploited to its full potential”
|
|
||||||
An urban platform is an organization of people and systems, which has accepted the responsibility
|
|
||||||
to preserve city data and make it available for all the stakeholders of smart cities. Ultimately, an
|
|
||||||
urban platform is a foundation for the full exploitation of city data. Hence, the major goal an Urban
|
|
||||||
Platform must achieve is “city data is exploited to its full potential”. To achieve this high level goal,
|
|
||||||
the urban platform must maintain in the long run the five sub-goals illustrated in Figure 3.
|
|
||||||
|
|
||||||
Each one of the defined sub-goals co-enables the achievement of the specified high-level (overall)
|
|
||||||
goal of the urban platform. The sub-goals include the ingestion of city data, metadata generation,
|
|
||||||
data management, data storage, access, preservation, and administration, provision of engaging
|
|
||||||
services in the smart cities. These sub-goals are discussed in details in the following sections.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Achieve [City data is exploited How we achieve the platform overall goal?
|
|
||||||
Why do we need the sub-goals?
|
|
||||||
to its full effect]
|
|
||||||
Goal
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Maintain [User s experience is
|
|
||||||
Maintain [City data is provided
|
|
||||||
enhanced by the provision of
|
|
||||||
in a harmonised way]
|
|
||||||
S ub-Goal 1 S ub-Goal 5 value-added services]
|
|
||||||
co-ena bles
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Maintain [Resources are managed in Maintain [City data is offered
|
|
||||||
a safe and intelligent manner] in an accessible manner]
|
|
||||||
S ub-Goal 2 S ub-Goal 4
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Maintain [City data is orchestrated
|
|
||||||
in a market place]
|
|
||||||
S ub-Goal 3
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Figure 3. Platform High-Level Goal and its respective sub-goals.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.2.1 Urban Platform Boundary
|
|
||||||
|
|
||||||
The use case diagram illustrated in Figure 4 identifies the boundaries between the actors (either
|
|
||||||
automated or human) and the urban platform. We have arrived at the urban platform boundary by
|
|
||||||
inspecting each business use case and determining, in conjunction with the stakeholders needs,
|
|
||||||
which part of the business use case should be implemented and what part should be done by an
|
|
||||||
outsourced product (e.g. Billing System) using the framework 4.
|
|
||||||
|
|
||||||
This task is technology agnostic and takes into account the abilities of the users/actors, the
|
|
||||||
constraints, the goals of the urban platform. Table 4 maps out the use cases with their respective
|
|
||||||
sub-goals and actors.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 14
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Manage Services
|
|
||||||
|
|
||||||
S ub-Goal 5
|
|
||||||
|
|
||||||
|
|
||||||
Data Service
|
|
||||||
Publish City Data
|
|
||||||
Provider
|
|
||||||
S ub-Goal 1
|
|
||||||
|
|
||||||
|
|
||||||
<<include>> Deploy Data Services
|
|
||||||
rules
|
|
||||||
S ub-Goal 5
|
|
||||||
|
|
||||||
Authenticate in the
|
|
||||||
Manage Resources <<include>>
|
|
||||||
Platform Platform
|
|
||||||
S ub-Goal 1 Provider Database
|
|
||||||
<<include>> Manage Infrastructure System
|
|
||||||
S ub-Goal 2
|
|
||||||
|
|
||||||
Utlise Data Services
|
|
||||||
City Data <<include>>
|
|
||||||
Publisher S ub-Goal 5
|
|
||||||
|
|
||||||
Store City Data Management
|
|
||||||
<<include>>
|
|
||||||
S ub-Goal 2
|
|
||||||
Systems Services
|
|
||||||
System
|
|
||||||
|
|
||||||
<<include>>
|
|
||||||
City Data
|
|
||||||
Consumer
|
|
||||||
QoS Monitoring
|
|
||||||
Transmit Data
|
|
||||||
System
|
|
||||||
Register in the Platform S ub-Goal 2
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<<include>>
|
|
||||||
rules
|
|
||||||
|
|
||||||
<<extend>>
|
|
||||||
rules
|
|
||||||
Platform
|
|
||||||
Provider
|
|
||||||
rules
|
|
||||||
|
|
||||||
Commercialise Data
|
|
||||||
Consume City Data
|
|
||||||
Services City Data
|
|
||||||
S ub-Goal 3 S ub-Goal 4
|
|
||||||
Consumer
|
|
||||||
<<extend>>
|
|
||||||
|
|
||||||
|
|
||||||
Commercialise
|
|
||||||
City Data Discover City Data
|
|
||||||
S ub-Goal 3
|
|
||||||
S ub-Goal 4
|
|
||||||
|
|
||||||
Billing
|
|
||||||
Management
|
|
||||||
System
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Authenticate in the Register in the
|
|
||||||
Platform Platform
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Authenticate Authenticate
|
|
||||||
Register Consumers Register Publishers
|
|
||||||
Consumers Publishers
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Figure 4. Simplistic overview of the use cases identified in the early stages of the platform design.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Table 4. Use Cases Mapping with Sub-Goals and Actors
|
|
||||||
Sub-Goals Use Cases ID Specialised Use Cases Actors
|
|
||||||
HIGH-LEVEL GOAL: City data is exploited to its
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Publish City Data UC1 User publishes city data via data API’s
|
|
||||||
City Data Publisher
|
|
||||||
1. City data is Register as a publisher
|
|
||||||
UC2 User manually uploads datasets
|
|
||||||
provided in a in the Platform
|
|
||||||
harmonised way
|
|
||||||
full effect
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
User manages resources
|
|
||||||
Manage Resources UC3 City Data Publisher
|
|
||||||
User tracks resources usage
|
|
||||||
|
|
||||||
Store City Data UC4
|
|
||||||
2. City data is
|
|
||||||
managed in a safe Transmit Data UC5 - Management Systems
|
|
||||||
and intelligent
|
|
||||||
manner
|
|
||||||
Manage Infrastructure UC6
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 15
|
|
||||||
|
|
||||||
|
|
||||||
City Data Publisher
|
|
||||||
Set commercial city data
|
|
||||||
Platform Provider
|
|
||||||
Subscribe to proprietary data City Data Consumer
|
|
||||||
Commercialise City
|
|
||||||
UC7
|
|
||||||
Data
|
|
||||||
Manage commercial data City Data Publisher
|
|
||||||
|
|
||||||
3. City data is Manage data subscription City Data Consumer
|
|
||||||
orchestrated in a
|
|
||||||
Data Services Provider
|
|
||||||
market place Set commercial data services
|
|
||||||
Platform Provider
|
|
||||||
Subscribe to commercial services Data Services Consumer
|
|
||||||
Commercialise Data
|
|
||||||
UC8
|
|
||||||
Services
|
|
||||||
Manage commercial services Data Services Provider
|
|
||||||
|
|
||||||
Manage services subscription Data Services Consumer
|
|
||||||
UC9
|
|
||||||
Register in the Platform City Data Consumer
|
|
||||||
-
|
|
||||||
4. City data is Discover City Data City Data Consumer
|
|
||||||
UC10
|
|
||||||
offered in an
|
|
||||||
User consumes city data via data
|
|
||||||
accessible manner
|
|
||||||
API’s
|
|
||||||
Consume City Data UC11 City Data Consumer
|
|
||||||
User downloads datasets
|
|
||||||
5. User’s Data Services Provider
|
|
||||||
Deploy Data Services UC12
|
|
||||||
experience is Platform Provider
|
|
||||||
enhanced by the
|
|
||||||
Manage Services UC13 - Data Services Provider
|
|
||||||
provision of value-
|
|
||||||
added services
|
|
||||||
Utilise Data Services UC14 City Data Consumer
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.3 SUB-GOAL 1: City data is collected in an intelligent manner
|
|
||||||
Description: The urban platform enables the owners of city data to easily publish both historic and
|
|
||||||
data streams in the platform, as well as their associated metadata.
|
|
||||||
Rationale: This sub-goal is maintained by the services and functions to accept the publication of
|
|
||||||
city data from data providers (of both open and proprietary data) and prepare the contents for
|
|
||||||
storage and management within the urban platform. Functions include receiving data, performing
|
|
||||||
quality assurance on data, verifying data formatting and document standards, associating meta-
|
|
||||||
data information, and coordinating updates to databases and resources management.
|
|
||||||
|
|
||||||
Achieve [City data is exploited
|
|
||||||
to its full effect]
|
|
||||||
Goal
|
|
||||||
co-ena bles
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
How we achieve the platform overall goal?
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Maintain [City data is provided in
|
|
||||||
a harmonised manner]
|
|
||||||
S ub-Goal 1
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
What actions can maintain the sub-goal?
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Achieve [Register Publisher] Achieve [Publish City Data] Achieve [Manage Resources]
|
|
||||||
UC1 UC2 UC3
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Figure 5. Sub-Goal 1 “City data is collected in an intelligent manner” refinement.
|
|
||||||
|
|
||||||
|
|
||||||
Drivers: Ensure data is published in an easy and uniform way.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 16
|
|
||||||
|
|
||||||
|
|
||||||
Actions: For Sub-Goal 1 to be maintained in the long-run it requires the efficient realisation of use
|
|
||||||
cases: “Publish City Data” and “Manage Resources”, as shown in Figure 5.
|
|
||||||
|
|
||||||
|
|
||||||
2.3.1 Use Case: Register Publisher
|
|
||||||
ID: UC1
|
|
||||||
Refines: SUB-GOAL 1: City data is collected in an intelligent manner
|
|
||||||
Pre-condition: Data Publisher is not logged in the system
|
|
||||||
Actors: Data Publishers
|
|
||||||
Rationale: Data Publishers can register in the platform and request approval to submit city data.
|
|
||||||
They provide valid registration details (to be defined) and wait for registration confirmation.
|
|
||||||
Platform Providers may authorise or not data publishers to offer both open and proprietary city data
|
|
||||||
in the platform. Data submission agreement is a formal agreement between the Data Provider and
|
|
||||||
the Urban Platform defining the terms of the content, standards, metadata creation, and license
|
|
||||||
agreement. The Urban Platform will proactively work with Data Providers to agree on the content,
|
|
||||||
quality and format of city data. Agreements between Platform and Data Providers may be
|
|
||||||
renegotiated on a periodic or ad-hoc basis.
|
|
||||||
Refines into requirements: FREQ.1 to FREQ.5.
|
|
||||||
|
|
||||||
Use Case Basic Stimulus and Responses
|
|
||||||
1. The platform prompts the user for a username and password or
|
|
||||||
register new account.
|
|
||||||
2. The user selects registration option.
|
|
||||||
3. The platform prompts user for publisher registration information (e.g.
|
|
||||||
username, password, organisation)
|
|
||||||
4. The user enters in their information.
|
|
||||||
UC1. Register 5. Platform verifies information and creates account.
|
|
||||||
Publisher o If non-valid information, platform shows error message and
|
|
||||||
returns to step 1.
|
|
||||||
6. Platform provider is requested to approve the account
|
|
||||||
o Platform acknowledges registration has been successful
|
|
||||||
o If non approved, platform shows error message and returns to
|
|
||||||
step 1.
|
|
||||||
7. End of registration
|
|
||||||
|
|
||||||
|
|
||||||
2.3.2 Use Case: Publish City Data
|
|
||||||
ID: UC2
|
|
||||||
Refines: SUB-GOAL 1 - City data is collected in an intelligent manner
|
|
||||||
Pre-condition: User is authenticated in the platform
|
|
||||||
Actors: City data publisher
|
|
||||||
Rationale: The Publish City Data function provides the appropriate mechanisms to receive city data from
|
|
||||||
authorized data providers. Data may be manually uploaded or submitted via APIs. In general, data providers
|
|
||||||
with whom the Urban Platform negotiates submission agreements are the providers of proprietary city data
|
|
||||||
(those producing published material, i.e. publishers) and open data, and they can be either humans or
|
|
||||||
machines. The providers of the Urban Platform will provide data providers with specifications on the content,
|
|
||||||
quality and format of data, and publication terms and conditions.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 17
|
|
||||||
|
|
||||||
|
|
||||||
The Publish City Data function may represent a legal transfer of custody for the data in the urban platform,
|
|
||||||
and may require that special access controls be placed on the contents. This function provides a
|
|
||||||
confirmation of receipt of data publication to the Producer, which may include a request to resubmit data in
|
|
||||||
the case of errors resulting from the submission.
|
|
||||||
Once data has arrived, it must undergo several reviews, including virus checking, format compliance,
|
|
||||||
metadata minimum requirement agreement, quality and anticipated content and data formatting. The
|
|
||||||
platform must include the ability to record all actions and decisions made concerning the publication of city
|
|
||||||
data. The reasons for publication failure (e.g. missing metadata information, non-valid dataset) will be
|
|
||||||
provided back to the city data publisher. In some cases, the provider can then resubmit corrected data and
|
|
||||||
metadata information, while in other instances data publication refusal criteria should prevent the publisher
|
|
||||||
from submitting the same dataset at a later time period (e.g. in cases of suspicious datasets – copyrights
|
|
||||||
violation, viruses). When data is successfully submitted (either via APIs or manual upload), it will be
|
|
||||||
processed/prepared for storage into the platform’s database.
|
|
||||||
Specialised Use Cases: The Use Case Publish City Data data is distinguished into two
|
|
||||||
specialised Use Cases: “User publishes city data via data API’s (UC2.1)” and “User manually
|
|
||||||
uploads datasets (UC2.2)”.
|
|
||||||
Subordinated Use Cases: “Store City Data (UC4)”
|
|
||||||
Refines into requirements: FREQ 6 to FREQ.26.
|
|
||||||
|
|
||||||
|
|
||||||
Specialised Use Cases Basic Stimulus and Responses
|
|
||||||
1. Platform provides user with an interface for static data
|
|
||||||
publication
|
|
||||||
2. User selects datasets to be uploaded
|
|
||||||
3. User provides metadata associated with the data (license,
|
|
||||||
provenance, ownership, semantics) in accordance with defined
|
|
||||||
standards
|
|
||||||
UC2.1. User manually 4. User requests data publication
|
|
||||||
uploads datasets 5. Platform quickly process user’s request for data publication
|
|
||||||
6. Platform validates data submitted
|
|
||||||
o If valid data, platform acknowledges data publication has
|
|
||||||
been successful
|
|
||||||
o If non-valid data, platform shows error message and returns
|
|
||||||
to step 1.
|
|
||||||
7. End of data publication
|
|
||||||
1. Platform provides user with an interface for real-time data
|
|
||||||
publication
|
|
||||||
2. User input data API information
|
|
||||||
3. User provides metadata associated with the data (license,
|
|
||||||
provenance, ownership, semantics) in accordance with defined
|
|
||||||
standards
|
|
||||||
UC2.2. User publishes 4. User confirm information and request data publication
|
|
||||||
city data via data API’s 5. Platform quickly process user’s request for data publication
|
|
||||||
6. Platform validates data submitted
|
|
||||||
o If valid data, platform acknowledges data publication has
|
|
||||||
been successful
|
|
||||||
o If non-valid data, platform shows error message and returns
|
|
||||||
to step 1
|
|
||||||
End of data publication
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 18
|
|
||||||
|
|
||||||
|
|
||||||
2.3.3 Use Case: Manage Resources
|
|
||||||
ID: UC3
|
|
||||||
Refines: GOAL 1: City data is collected in an intelligent manner
|
|
||||||
Pre-condition: User successfully authenticates in the platform
|
|
||||||
Actors: City data publisher
|
|
||||||
Rationale: Manage resources provides the services and functions for updating, maintaining and
|
|
||||||
accessing both data and metadata, as well as tracking the usage of resources by users. Ideally
|
|
||||||
the owners of the resources should be the only authorised user to manage resources, and other
|
|
||||||
authorised users can track the usage of the resources in the platform. The platform must provide a
|
|
||||||
database update response indicating the status of the update, avoid update errors to be
|
|
||||||
propagated in the platform, and should keep an audit trail of all actions to enable rollback. Data
|
|
||||||
usage tracking includes performing queries on the data management data to generate result sets,
|
|
||||||
and producing reports from these result sets.
|
|
||||||
Specialised Use Cases: The Use Case Manage Resources data is distinguished into two
|
|
||||||
specialised Use Cases: “User manages resources (UC3.1)” and “User tracks resources usage
|
|
||||||
(UC3.2)”.
|
|
||||||
Subordinated Use Cases: “Transmit Data (UC5)”
|
|
||||||
Refines into requirements: FREQ.27 to FREQ.25.
|
|
||||||
|
|
||||||
|
|
||||||
Specialised Use Cases Basic Interactions and Responses
|
|
||||||
1. Platform provides user with an interface for resources
|
|
||||||
management (e.g. data and metadata, data usage)
|
|
||||||
2. User chooses to edit or delete data
|
|
||||||
3. If edit, user revise metadata associated with the data (license,
|
|
||||||
provenance, ownership, access-control, semantics);
|
|
||||||
UC3.1. User manages 4. If delete, user selects dataset(s) to be removed
|
|
||||||
resources 5. User confirms action
|
|
||||||
6. Platform quickly process user’s request
|
|
||||||
7. Platform confirms execution of request
|
|
||||||
o If valid request, platform acknowledges request has been
|
|
||||||
processed successfully
|
|
||||||
o If non-valid request, platform returns to step 1.
|
|
||||||
8. End of resources management
|
|
||||||
1. Platform provides user with an interface for resources
|
|
||||||
management (e.g. data and metadata, data usage)
|
|
||||||
2. User chooses to visualise usage information of a dataset
|
|
||||||
UC3.2. User tracks 3. Platform quickly process user’s request for data usage
|
|
||||||
resources usage information
|
|
||||||
4. Platform provides user with statistical information about data
|
|
||||||
usage and data users anonymised information
|
|
||||||
5. End of data usage tracking.
|
|
||||||
|
|
||||||
|
|
||||||
2.3.4 Functional Requirements
|
|
||||||
|
|
||||||
Req. ID UC. ID Description Priority Domain
|
|
||||||
Societal Needs,
|
|
||||||
FREQ.1 UC1 Allow data publishers to register to submit data for publication Must
|
|
||||||
Platform
|
|
||||||
Tracks data publication agreements between Data and Platform Business Needs,
|
|
||||||
FREQ.2 UC1 Must
|
|
||||||
Providers Platform
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 19
|
|
||||||
|
|
||||||
|
|
||||||
Store terms of agreements, and use them to monitor/review/process
|
|
||||||
FREQ.3 UC1 Must City Data, Platform
|
|
||||||
data submissions.
|
|
||||||
Able to add and edit terms of agreement, based on access of level of Business Needs,
|
|
||||||
FREQ.4 UC1 Must
|
|
||||||
user. Platform
|
|
||||||
|
|
||||||
FREQ.5 UC1 Data publications are managed and monitored Must City Data, Platform
|
|
||||||
|
|
||||||
Allow authenticated users from across different organisations to City Data, Platform,
|
|
||||||
FREQ.6 UC2 Must
|
|
||||||
publish city data Business Needs
|
|
||||||
Provide authorization mechanisms for users and sensors to publish
|
|
||||||
FREQ.7 UC2 Must City Data, Platform
|
|
||||||
city data
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.9 UC2 Provide mechanisms for static data publication Must
|
|
||||||
Business Needs
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.10 UC2 Provide mechanisms for real-time data publication Must
|
|
||||||
Business Needs
|
|
||||||
|
|
||||||
FREQ.11 UC2 Enable the publication of metadata Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.12 UC2 Maintain temporal information about the data Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.13 UC2 Support sensory data collection Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.14 UC2 Accepts content in numerous file types/formats Must City Data, Platform
|
|
||||||
|
|
||||||
Prompts a request for resubmission to the data provider if an error of
|
|
||||||
@@ -1,600 +0,0 @@
|
|||||||
harmonised way
|
|
||||||
full effect
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
User manages resources
|
|
||||||
Manage Resources UC3 City Data Publisher
|
|
||||||
User tracks resources usage
|
|
||||||
|
|
||||||
Store City Data UC4
|
|
||||||
2. City data is
|
|
||||||
managed in a safe Transmit Data UC5 - Management Systems
|
|
||||||
and intelligent
|
|
||||||
manner
|
|
||||||
Manage Infrastructure UC6
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 15
|
|
||||||
|
|
||||||
|
|
||||||
City Data Publisher
|
|
||||||
Set commercial city data
|
|
||||||
Platform Provider
|
|
||||||
Subscribe to proprietary data City Data Consumer
|
|
||||||
Commercialise City
|
|
||||||
UC7
|
|
||||||
Data
|
|
||||||
Manage commercial data City Data Publisher
|
|
||||||
|
|
||||||
3. City data is Manage data subscription City Data Consumer
|
|
||||||
orchestrated in a
|
|
||||||
Data Services Provider
|
|
||||||
market place Set commercial data services
|
|
||||||
Platform Provider
|
|
||||||
Subscribe to commercial services Data Services Consumer
|
|
||||||
Commercialise Data
|
|
||||||
UC8
|
|
||||||
Services
|
|
||||||
Manage commercial services Data Services Provider
|
|
||||||
|
|
||||||
Manage services subscription Data Services Consumer
|
|
||||||
UC9
|
|
||||||
Register in the Platform City Data Consumer
|
|
||||||
-
|
|
||||||
4. City data is Discover City Data City Data Consumer
|
|
||||||
UC10
|
|
||||||
offered in an
|
|
||||||
User consumes city data via data
|
|
||||||
accessible manner
|
|
||||||
API’s
|
|
||||||
Consume City Data UC11 City Data Consumer
|
|
||||||
User downloads datasets
|
|
||||||
5. User’s Data Services Provider
|
|
||||||
Deploy Data Services UC12
|
|
||||||
experience is Platform Provider
|
|
||||||
enhanced by the
|
|
||||||
Manage Services UC13 - Data Services Provider
|
|
||||||
provision of value-
|
|
||||||
added services
|
|
||||||
Utilise Data Services UC14 City Data Consumer
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.3 SUB-GOAL 1: City data is collected in an intelligent manner
|
|
||||||
Description: The urban platform enables the owners of city data to easily publish both historic and
|
|
||||||
data streams in the platform, as well as their associated metadata.
|
|
||||||
Rationale: This sub-goal is maintained by the services and functions to accept the publication of
|
|
||||||
city data from data providers (of both open and proprietary data) and prepare the contents for
|
|
||||||
storage and management within the urban platform. Functions include receiving data, performing
|
|
||||||
quality assurance on data, verifying data formatting and document standards, associating meta-
|
|
||||||
data information, and coordinating updates to databases and resources management.
|
|
||||||
|
|
||||||
Achieve [City data is exploited
|
|
||||||
to its full effect]
|
|
||||||
Goal
|
|
||||||
co-ena bles
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
How we achieve the platform overall goal?
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Maintain [City data is provided in
|
|
||||||
a harmonised manner]
|
|
||||||
S ub-Goal 1
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
What actions can maintain the sub-goal?
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Achieve [Register Publisher] Achieve [Publish City Data] Achieve [Manage Resources]
|
|
||||||
UC1 UC2 UC3
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Figure 5. Sub-Goal 1 “City data is collected in an intelligent manner” refinement.
|
|
||||||
|
|
||||||
|
|
||||||
Drivers: Ensure data is published in an easy and uniform way.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 16
|
|
||||||
|
|
||||||
|
|
||||||
Actions: For Sub-Goal 1 to be maintained in the long-run it requires the efficient realisation of use
|
|
||||||
cases: “Publish City Data” and “Manage Resources”, as shown in Figure 5.
|
|
||||||
|
|
||||||
|
|
||||||
2.3.1 Use Case: Register Publisher
|
|
||||||
ID: UC1
|
|
||||||
Refines: SUB-GOAL 1: City data is collected in an intelligent manner
|
|
||||||
Pre-condition: Data Publisher is not logged in the system
|
|
||||||
Actors: Data Publishers
|
|
||||||
Rationale: Data Publishers can register in the platform and request approval to submit city data.
|
|
||||||
They provide valid registration details (to be defined) and wait for registration confirmation.
|
|
||||||
Platform Providers may authorise or not data publishers to offer both open and proprietary city data
|
|
||||||
in the platform. Data submission agreement is a formal agreement between the Data Provider and
|
|
||||||
the Urban Platform defining the terms of the content, standards, metadata creation, and license
|
|
||||||
agreement. The Urban Platform will proactively work with Data Providers to agree on the content,
|
|
||||||
quality and format of city data. Agreements between Platform and Data Providers may be
|
|
||||||
renegotiated on a periodic or ad-hoc basis.
|
|
||||||
Refines into requirements: FREQ.1 to FREQ.5.
|
|
||||||
|
|
||||||
Use Case Basic Stimulus and Responses
|
|
||||||
1. The platform prompts the user for a username and password or
|
|
||||||
register new account.
|
|
||||||
2. The user selects registration option.
|
|
||||||
3. The platform prompts user for publisher registration information (e.g.
|
|
||||||
username, password, organisation)
|
|
||||||
4. The user enters in their information.
|
|
||||||
UC1. Register 5. Platform verifies information and creates account.
|
|
||||||
Publisher o If non-valid information, platform shows error message and
|
|
||||||
returns to step 1.
|
|
||||||
6. Platform provider is requested to approve the account
|
|
||||||
o Platform acknowledges registration has been successful
|
|
||||||
o If non approved, platform shows error message and returns to
|
|
||||||
step 1.
|
|
||||||
7. End of registration
|
|
||||||
|
|
||||||
|
|
||||||
2.3.2 Use Case: Publish City Data
|
|
||||||
ID: UC2
|
|
||||||
Refines: SUB-GOAL 1 - City data is collected in an intelligent manner
|
|
||||||
Pre-condition: User is authenticated in the platform
|
|
||||||
Actors: City data publisher
|
|
||||||
Rationale: The Publish City Data function provides the appropriate mechanisms to receive city data from
|
|
||||||
authorized data providers. Data may be manually uploaded or submitted via APIs. In general, data providers
|
|
||||||
with whom the Urban Platform negotiates submission agreements are the providers of proprietary city data
|
|
||||||
(those producing published material, i.e. publishers) and open data, and they can be either humans or
|
|
||||||
machines. The providers of the Urban Platform will provide data providers with specifications on the content,
|
|
||||||
quality and format of data, and publication terms and conditions.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 17
|
|
||||||
|
|
||||||
|
|
||||||
The Publish City Data function may represent a legal transfer of custody for the data in the urban platform,
|
|
||||||
and may require that special access controls be placed on the contents. This function provides a
|
|
||||||
confirmation of receipt of data publication to the Producer, which may include a request to resubmit data in
|
|
||||||
the case of errors resulting from the submission.
|
|
||||||
Once data has arrived, it must undergo several reviews, including virus checking, format compliance,
|
|
||||||
metadata minimum requirement agreement, quality and anticipated content and data formatting. The
|
|
||||||
platform must include the ability to record all actions and decisions made concerning the publication of city
|
|
||||||
data. The reasons for publication failure (e.g. missing metadata information, non-valid dataset) will be
|
|
||||||
provided back to the city data publisher. In some cases, the provider can then resubmit corrected data and
|
|
||||||
metadata information, while in other instances data publication refusal criteria should prevent the publisher
|
|
||||||
from submitting the same dataset at a later time period (e.g. in cases of suspicious datasets – copyrights
|
|
||||||
violation, viruses). When data is successfully submitted (either via APIs or manual upload), it will be
|
|
||||||
processed/prepared for storage into the platform’s database.
|
|
||||||
Specialised Use Cases: The Use Case Publish City Data data is distinguished into two
|
|
||||||
specialised Use Cases: “User publishes city data via data API’s (UC2.1)” and “User manually
|
|
||||||
uploads datasets (UC2.2)”.
|
|
||||||
Subordinated Use Cases: “Store City Data (UC4)”
|
|
||||||
Refines into requirements: FREQ 6 to FREQ.26.
|
|
||||||
|
|
||||||
|
|
||||||
Specialised Use Cases Basic Stimulus and Responses
|
|
||||||
1. Platform provides user with an interface for static data
|
|
||||||
publication
|
|
||||||
2. User selects datasets to be uploaded
|
|
||||||
3. User provides metadata associated with the data (license,
|
|
||||||
provenance, ownership, semantics) in accordance with defined
|
|
||||||
standards
|
|
||||||
UC2.1. User manually 4. User requests data publication
|
|
||||||
uploads datasets 5. Platform quickly process user’s request for data publication
|
|
||||||
6. Platform validates data submitted
|
|
||||||
o If valid data, platform acknowledges data publication has
|
|
||||||
been successful
|
|
||||||
o If non-valid data, platform shows error message and returns
|
|
||||||
to step 1.
|
|
||||||
7. End of data publication
|
|
||||||
1. Platform provides user with an interface for real-time data
|
|
||||||
publication
|
|
||||||
2. User input data API information
|
|
||||||
3. User provides metadata associated with the data (license,
|
|
||||||
provenance, ownership, semantics) in accordance with defined
|
|
||||||
standards
|
|
||||||
UC2.2. User publishes 4. User confirm information and request data publication
|
|
||||||
city data via data API’s 5. Platform quickly process user’s request for data publication
|
|
||||||
6. Platform validates data submitted
|
|
||||||
o If valid data, platform acknowledges data publication has
|
|
||||||
been successful
|
|
||||||
o If non-valid data, platform shows error message and returns
|
|
||||||
to step 1
|
|
||||||
End of data publication
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 18
|
|
||||||
|
|
||||||
|
|
||||||
2.3.3 Use Case: Manage Resources
|
|
||||||
ID: UC3
|
|
||||||
Refines: GOAL 1: City data is collected in an intelligent manner
|
|
||||||
Pre-condition: User successfully authenticates in the platform
|
|
||||||
Actors: City data publisher
|
|
||||||
Rationale: Manage resources provides the services and functions for updating, maintaining and
|
|
||||||
accessing both data and metadata, as well as tracking the usage of resources by users. Ideally
|
|
||||||
the owners of the resources should be the only authorised user to manage resources, and other
|
|
||||||
authorised users can track the usage of the resources in the platform. The platform must provide a
|
|
||||||
database update response indicating the status of the update, avoid update errors to be
|
|
||||||
propagated in the platform, and should keep an audit trail of all actions to enable rollback. Data
|
|
||||||
usage tracking includes performing queries on the data management data to generate result sets,
|
|
||||||
and producing reports from these result sets.
|
|
||||||
Specialised Use Cases: The Use Case Manage Resources data is distinguished into two
|
|
||||||
specialised Use Cases: “User manages resources (UC3.1)” and “User tracks resources usage
|
|
||||||
(UC3.2)”.
|
|
||||||
Subordinated Use Cases: “Transmit Data (UC5)”
|
|
||||||
Refines into requirements: FREQ.27 to FREQ.25.
|
|
||||||
|
|
||||||
|
|
||||||
Specialised Use Cases Basic Interactions and Responses
|
|
||||||
1. Platform provides user with an interface for resources
|
|
||||||
management (e.g. data and metadata, data usage)
|
|
||||||
2. User chooses to edit or delete data
|
|
||||||
3. If edit, user revise metadata associated with the data (license,
|
|
||||||
provenance, ownership, access-control, semantics);
|
|
||||||
UC3.1. User manages 4. If delete, user selects dataset(s) to be removed
|
|
||||||
resources 5. User confirms action
|
|
||||||
6. Platform quickly process user’s request
|
|
||||||
7. Platform confirms execution of request
|
|
||||||
o If valid request, platform acknowledges request has been
|
|
||||||
processed successfully
|
|
||||||
o If non-valid request, platform returns to step 1.
|
|
||||||
8. End of resources management
|
|
||||||
1. Platform provides user with an interface for resources
|
|
||||||
management (e.g. data and metadata, data usage)
|
|
||||||
2. User chooses to visualise usage information of a dataset
|
|
||||||
UC3.2. User tracks 3. Platform quickly process user’s request for data usage
|
|
||||||
resources usage information
|
|
||||||
4. Platform provides user with statistical information about data
|
|
||||||
usage and data users anonymised information
|
|
||||||
5. End of data usage tracking.
|
|
||||||
|
|
||||||
|
|
||||||
2.3.4 Functional Requirements
|
|
||||||
|
|
||||||
Req. ID UC. ID Description Priority Domain
|
|
||||||
Societal Needs,
|
|
||||||
FREQ.1 UC1 Allow data publishers to register to submit data for publication Must
|
|
||||||
Platform
|
|
||||||
Tracks data publication agreements between Data and Platform Business Needs,
|
|
||||||
FREQ.2 UC1 Must
|
|
||||||
Providers Platform
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 19
|
|
||||||
|
|
||||||
|
|
||||||
Store terms of agreements, and use them to monitor/review/process
|
|
||||||
FREQ.3 UC1 Must City Data, Platform
|
|
||||||
data submissions.
|
|
||||||
Able to add and edit terms of agreement, based on access of level of Business Needs,
|
|
||||||
FREQ.4 UC1 Must
|
|
||||||
user. Platform
|
|
||||||
|
|
||||||
FREQ.5 UC1 Data publications are managed and monitored Must City Data, Platform
|
|
||||||
|
|
||||||
Allow authenticated users from across different organisations to City Data, Platform,
|
|
||||||
FREQ.6 UC2 Must
|
|
||||||
publish city data Business Needs
|
|
||||||
Provide authorization mechanisms for users and sensors to publish
|
|
||||||
FREQ.7 UC2 Must City Data, Platform
|
|
||||||
city data
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.9 UC2 Provide mechanisms for static data publication Must
|
|
||||||
Business Needs
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.10 UC2 Provide mechanisms for real-time data publication Must
|
|
||||||
Business Needs
|
|
||||||
|
|
||||||
FREQ.11 UC2 Enable the publication of metadata Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.12 UC2 Maintain temporal information about the data Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.13 UC2 Support sensory data collection Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.14 UC2 Accepts content in numerous file types/formats Must City Data, Platform
|
|
||||||
|
|
||||||
Prompts a request for resubmission to the data provider if an error of
|
|
||||||
FREQ.15 UC2 Must City Data, Platform
|
|
||||||
data transmission or receipt occurs
|
|
||||||
|
|
||||||
FREQ.16 UC2 Enable the semantic description of connected devices Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.17 UC2 Gather data from authenticated and authorized devices Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.18 UC2 Validates automatically the successful transfer of the data Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.19 UC2 Performs virus checking on data Must City Data, Platform
|
|
||||||
|
|
||||||
Verifies the validity of the submission based on submitter, expected
|
|
||||||
FREQ.20 UC2 Must City Data, Platform
|
|
||||||
format, data quality, and completeness
|
|
||||||
Platform should have built-in checks on the incoming metadata. Data
|
|
||||||
FREQ.21 UC2 not containing the minimally defined set of attributes should be Must City Data, Platform
|
|
||||||
returned to the publisher for metadata enhancement.
|
|
||||||
System should have a user-friendly method of mapping non-standard
|
|
||||||
FREQ.22 UC2 Should City Data, Platform
|
|
||||||
metadata elements into approved standard elements.
|
|
||||||
Once ingested, metadata should be stored in a single common format.
|
|
||||||
FREQ.23 UC2 This format should be one that ensures against data loss, and allows a Must City Data, Platform
|
|
||||||
variety of access/distribution options
|
|
||||||
Data in the repository shall have sufficient technical metadata to
|
|
||||||
FREQ.24 UC2 assure functionality (e.g. viewing and display) to ensure accessibility Must City Data, Platform
|
|
||||||
and reusability.
|
|
||||||
Allows publishers to display and perform manual/visual quality control Business Needs, City
|
|
||||||
FREQ.25 UC2 Could
|
|
||||||
assurance via a user-friendly GUI Data, Platform
|
|
||||||
Business Needs, City
|
|
||||||
FREQ.26 UC2 Any errors shall prompt a request for resubmission of data Should
|
|
||||||
Data, Platform
|
|
||||||
|
|
||||||
FREQ.27 UC3 Enable data providers to manage their resources Must Business Needs
|
|
||||||
|
|
||||||
A minimal set of identifying information/metadata concerning data Business Needs,
|
|
||||||
FREQ.28 UC3 Must
|
|
||||||
publication submission must be recorded Platform
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 20
|
|
||||||
|
|
||||||
|
|
||||||
Stores and tracks versions of data. Links /connections between
|
|
||||||
FREQ.29 UC3 Must City Data, Platform
|
|
||||||
versions are created and maintained
|
|
||||||
Give service and data providers access to anonymized data of the Business Needs
|
|
||||||
FREQ.30 UC3 Should
|
|
||||||
subscribers of their data or services
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.31 UC3 Enable data providers to maintain and repair data and metadata Should
|
|
||||||
Business Needs
|
|
||||||
Tracks data publication agreements between Data and Platform Business Needs,
|
|
||||||
FREQ.32 UC3 Must
|
|
||||||
Providers Platform
|
|
||||||
Store terms of agreements, and use them to monitor/review/process
|
|
||||||
FREQ.33 UC3 Must City Data, Platform
|
|
||||||
data submissions.
|
|
||||||
Able to add and edit terms of agreement, based on access of level of Business Needs,
|
|
||||||
FREQ.34 UC3 Must
|
|
||||||
user. Platform
|
|
||||||
|
|
||||||
FREQ.35 UC3 Submission volumes and schedules are managed and monitored Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.4 SUB-GOAL 2: City data is managed in a safe and intelligent manner
|
|
||||||
Rationale: The urban platform enables users to publish, consume and commercialise data, as well
|
|
||||||
as deploy and manage services all in a secure and privacy protected manner.
|
|
||||||
|
|
||||||
Achieve [City data is exploited
|
|
||||||
to its full effect]
|
|
||||||
Goal
|
|
||||||
co-ena bles
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Maintain [Resources are managed in a
|
|
||||||
safe and intelligent manner]
|
|
||||||
S ub-Goal 1
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Achieve [Store Data] Achieve [Transmit Data] Achieve [Manage Infrastructure]
|
|
||||||
UC4 UC5 UC6
|
|
||||||
|
|
||||||
|
|
||||||
Figure 6. Sub-Goal 2 “City data is managed in a safe and intelligent manner” refinement.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Drivers: Ensure data is secured and the identity of users are preserved
|
|
||||||
Actions: For Sub-Goal 2 to be maintained in the long-run it requires the efficient realisation of use
|
|
||||||
cases: “Store City Data” and “Retrieve and Transmit City Data”, as shown in Figure 6.
|
|
||||||
|
|
||||||
|
|
||||||
2.4.1 Use Case: Store City Data
|
|
||||||
ID: UC4
|
|
||||||
Refines: SUB-GOAL 2: City data is managed in a safe and intelligent manner
|
|
||||||
Pre-condition: Data is successfully published in the platform
|
|
||||||
Actors: Urban Platform
|
|
||||||
Rationale: When data is successfully submitted (either via APIs or manual upload), it is
|
|
||||||
processed/prepared for storage into the platform’s database. This procedure will include the
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 21
|
|
||||||
|
|
||||||
|
|
||||||
generation of unique identifiers to the database, enrichment with ontologies (when applicable),
|
|
||||||
encrypted (when applicable), signed with digital certificates (when applicable) to ensure that the
|
|
||||||
data conforms to the platform data formatting, standards, security and regulation. Data may be
|
|
||||||
converted to accepted formats, as needed (e.g. graph model). A primary goal of the conversion of
|
|
||||||
content for the platform is the preservation of the content. Priority will be given to preserving the
|
|
||||||
data accordingly to the policies defined in section (2.6.2). Access-control levels and license models
|
|
||||||
are associated to data which is subject to restrictions relating to access and conditions of use.
|
|
||||||
Refines into requirements: FREQ 28 – FREQ 39.
|
|
||||||
|
|
||||||
Use Case Basic Interactions and Responses
|
|
||||||
1. Platform mechanisms converts submitted data into a standard format
|
|
||||||
2. Security enforcement (e.g. anonymisation, cryptography) is placed on
|
|
||||||
sensitive information.
|
|
||||||
UC4. Store City 3. Platform associates with datasets the access-control definitions set by
|
|
||||||
Data owner of resources.
|
|
||||||
4. Data is enriched with semantics and is associated with other datasets
|
|
||||||
5. Platform stores data in a scalable and secure database.
|
|
||||||
6. End of data storage.
|
|
||||||
|
|
||||||
|
|
||||||
2.4.2 Use Case: Transmit Data
|
|
||||||
ID: UC5
|
|
||||||
Refines: SUB-GOAL 2: City data is managed in a safe and intelligent manner
|
|
||||||
Pre-condition: Data is successfully published in the platform
|
|
||||||
Actors: Urban Platform
|
|
||||||
Rationale: The platform accepts data retrieval request, validates user’s rights to access the data,
|
|
||||||
retrieves city data from data storage, and moves a copy of the data to the relevant platform
|
|
||||||
component for further processing. If special processing is required, the retrieval function accesses
|
|
||||||
data in staging storage and applies the requested processes. The types of operations, which may
|
|
||||||
be carried out, include sub-sampling in temporal or spatial dimensions, conversions between
|
|
||||||
different data types or output formats, and other specialized processing (e.g., data visualisation).
|
|
||||||
Once it is finalised data will be sent to the appropriate delivery channels (e.g. API’s, GUI). It also
|
|
||||||
encompasses function to verify corruption during any internal data transfer. This function requires
|
|
||||||
that all hardware and software within the platform provide notification of potential errors and that
|
|
||||||
these errors are routed to standard error logs that are checked by the Platform Provider.
|
|
||||||
Refines into requirements: FREQ 40 to 54.
|
|
||||||
2.4.3 Use Case: Manage Infrastructure
|
|
||||||
ID: UC6
|
|
||||||
Refines: SUB-GOAL 2: City data is managed in a safe and intelligent manner
|
|
||||||
Pre-condition: The platform is available
|
|
||||||
Actors: Urban Platform
|
|
||||||
Rationale: Manage infrastructure provides the services and functions for the overall operation of
|
|
||||||
the urban platform. Administration functions include monitoring quality of service agreements,
|
|
||||||
auditing data publication to ensure that they meet archive standards, and maintaining configuration
|
|
||||||
management of system hardware and software. In overall, it provides system engineering
|
|
||||||
functions to monitor and improve platform operations, and to inventory, report on, and
|
|
||||||
migrate/update the contents of the platforms’ databases.
|
|
||||||
Refines into requirements: FREQ 55 to 62.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 22
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Use Case Basic Interactions and Responses
|
|
||||||
1. Platform keeps monitoring services at run-time to ensure operation
|
|
||||||
and integrity of city data
|
|
||||||
o If system failure, the platform activates mechanisms for recovery
|
|
||||||
UC6. Manage based on pre-defined rules
|
|
||||||
Infrastructure o Platform logs issue and issue alert messages to platform providers
|
|
||||||
2. Platform logs operation capabilities (e.g. performance, mean of time
|
|
||||||
failure, issues, etc.)
|
|
||||||
|
|
||||||
|
|
||||||
2.4.4 Functional Requirements
|
|
||||||
|
|
||||||
Req. ID UC. ID Description Priority Domain
|
|
||||||
A minimal set of identifying information/metadata concerning data Business Needs,
|
|
||||||
FREQ.28 UC4 Must
|
|
||||||
publication submission must be recorded. Platform
|
|
||||||
Stores and tracks versions of data. Links /connections between
|
|
||||||
FREQ.29 UC4 Must City Data, Platform
|
|
||||||
versions are created and maintained.
|
|
||||||
|
|
||||||
FREQ.30 UC4 Converts data to accepted file formats Must City Data, Platform
|
|
||||||
|
|
||||||
Keep sensitive information secured and accessible only to authorized
|
|
||||||
FREQ.31 UC4 Must City Data, Platform
|
|
||||||
users
|
|
||||||
|
|
||||||
FREQ.32 UC4 Keep user’s personal information protected Should City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.33 UC4 Keep city data and meta-data secured Must Platform Needs
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.34 UC4 Enable privacy preserving mechanisms associated to data Must Platform Needs
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.35 UC4 Model data in accordance with defined standards Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.36 UC4 Support the use of ontologies and semantic modelling of city data Could City Data
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.37 UC4 Support database-level provenance annotation Should City Data
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.38 UC4 Support data-level provenance annotation Should City Data
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.39 UC4 Enable data to be encrypted Should Platform Needs
|
|
||||||
|
|
||||||
System must have the ability to search and display metadata, preferably
|
|
||||||
City Data, Platform
|
|
||||||
FREQ.40 UC5 in a user-conformable, human readable display as well as in its native Must
|
|
||||||
Needs
|
|
||||||
format for machine harvesting and manipulation.
|
|
||||||
Controls access to data in the repository based on multiple permission
|
|
||||||
City Data, Platform
|
|
||||||
FREQ.41 UC5 levels. These permission levels determine the create/edit/read/delete Should
|
|
||||||
Needs
|
|
||||||
privileges granted users.
|
|
||||||
Access rights and conditions of use will be held for each data and its City Data, Platform
|
|
||||||
FREQ.42 UC5 Should
|
|
||||||
related metadata. Needs
|
|
||||||
Access rights and conditions can be inherited from a parent data to any City Data, Platform
|
|
||||||
FREQ.43 UC5 Could
|
|
||||||
data designated as a child data (derived information). Needs
|
|
||||||
Access rights and conditions of use will be machine readable and City Data, Platform
|
|
||||||
FREQ.44 UC5 Should
|
|
||||||
actionable. Needs
|
|
||||||
Access mechanisms must be sufficiently granular to allow the Business Needs, City
|
|
||||||
FREQ.45 UC5 identification of individual users, in order to maintain audit logs of actions Should
|
|
||||||
Data, Platform
|
|
||||||
performed by users.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 23
|
|
||||||
|
|
||||||
|
|
||||||
Maintains the integrity of the database which contains both metadata
|
|
||||||
FREQ.46 UC5 and system information. Must Platform Needs
|
|
||||||
|
|
||||||
Provides internal validation such as referential integrity of the contents of City Data, Platform
|
|
||||||
FREQ.47 UC5 the database. Must
|
|
||||||
Needs
|
|
||||||
Creates and maintains schema definitions required to support data
|
|
||||||
FREQ.48 UC5 management functions. Must Platform Needs
|
|
||||||
|
|
||||||
Monitors and ensures that data and metadata are not corrupted during
|
|
||||||
FREQ.49 UC5 transfers. Must Platform Needs
|
|
||||||
|
|
||||||
Provides statistically acceptable assurance that no components of the
|
|
||||||
FREQ.50 UC5 data are corrupted during any internal data transfer. Must Platform Needs
|
|
||||||
|
|
||||||
Performs routine and special data integrity checking for each dataset
|
|
||||||
FREQ.51 UC5 and generates error reports. Must Platform Needs
|
|
||||||
|
|
||||||
Provides disaster recovery capabilities including data backup, off-site
|
|
||||||
FREQ.52 UC5 data storage, data recovery, etc. Must Platform Needs
|
|
||||||
|
|
||||||
Refresh/replace data without service interruption, and update
|
|
||||||
FREQ.53 UC5 corresponding metadata as appropriate. Must Platform Needs
|
|
||||||
|
|
||||||
Ensure that any associated unique identifiers of the updated data are not
|
|
||||||
FREQ.54 UC5 altered. Must Platform Needs
|
|
||||||
|
|
||||||
Audits submissions to ensure that they meet archive/repository
|
|
||||||
FREQ.55 UC6 standards. Must Platform Needs
|
|
||||||
|
|
||||||
Maintains configuration management of the system hardware and
|
|
||||||
FREQ.56 UC6 software. Must Platform Needs
|
|
||||||
|
|
||||||
Has capability to inventory, report on and migrate the contents of the
|
|
||||||
FREQ.57 UC6 repository. Must Platform Needs
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.58 UC6 Ensures data integrity for version upgrades and format migration. Must Platform Needs
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.59 UC6 Monitors functionality of the entire repository. Must Platform Needs
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.60 UC6 Maintains integrity of system configuration. Must Platform Needs
|
|
||||||
|
|
||||||
Audits system operations, performance and usage.
|
|
||||||
FREQ.61 UC6 Must Platform Needs
|
|
||||||
|
|
||||||
Provides platform performance information and database holdings
|
|
||||||
FREQ.62 UC6 inventory reports Must Platform Needs
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.5 SUB-GOAL 3: City data is orchestrated in a marketplace
|
|
||||||
Rationale: The urban platform enables users to consume and publish data in a secure and privacy
|
|
||||||
protected manner.
|
|
||||||
Drivers: Ensure data is secured and the identity of users are preserved
|
|
||||||
Actions: For Sub-Goal 3 to be maintained in the long-run it requires the efficient realisation of use
|
|
||||||
cases: “Commercialise City Data” and “Commercialise Data Services”, as shown in Figure 7.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 24
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,700 +0,0 @@
|
|||||||
city data via data API’s 5. Platform quickly process user’s request for data publication
|
|
||||||
6. Platform validates data submitted
|
|
||||||
o If valid data, platform acknowledges data publication has
|
|
||||||
been successful
|
|
||||||
o If non-valid data, platform shows error message and returns
|
|
||||||
to step 1
|
|
||||||
End of data publication
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 18
|
|
||||||
|
|
||||||
|
|
||||||
2.3.3 Use Case: Manage Resources
|
|
||||||
ID: UC3
|
|
||||||
Refines: GOAL 1: City data is collected in an intelligent manner
|
|
||||||
Pre-condition: User successfully authenticates in the platform
|
|
||||||
Actors: City data publisher
|
|
||||||
Rationale: Manage resources provides the services and functions for updating, maintaining and
|
|
||||||
accessing both data and metadata, as well as tracking the usage of resources by users. Ideally
|
|
||||||
the owners of the resources should be the only authorised user to manage resources, and other
|
|
||||||
authorised users can track the usage of the resources in the platform. The platform must provide a
|
|
||||||
database update response indicating the status of the update, avoid update errors to be
|
|
||||||
propagated in the platform, and should keep an audit trail of all actions to enable rollback. Data
|
|
||||||
usage tracking includes performing queries on the data management data to generate result sets,
|
|
||||||
and producing reports from these result sets.
|
|
||||||
Specialised Use Cases: The Use Case Manage Resources data is distinguished into two
|
|
||||||
specialised Use Cases: “User manages resources (UC3.1)” and “User tracks resources usage
|
|
||||||
(UC3.2)”.
|
|
||||||
Subordinated Use Cases: “Transmit Data (UC5)”
|
|
||||||
Refines into requirements: FREQ.27 to FREQ.25.
|
|
||||||
|
|
||||||
|
|
||||||
Specialised Use Cases Basic Interactions and Responses
|
|
||||||
1. Platform provides user with an interface for resources
|
|
||||||
management (e.g. data and metadata, data usage)
|
|
||||||
2. User chooses to edit or delete data
|
|
||||||
3. If edit, user revise metadata associated with the data (license,
|
|
||||||
provenance, ownership, access-control, semantics);
|
|
||||||
UC3.1. User manages 4. If delete, user selects dataset(s) to be removed
|
|
||||||
resources 5. User confirms action
|
|
||||||
6. Platform quickly process user’s request
|
|
||||||
7. Platform confirms execution of request
|
|
||||||
o If valid request, platform acknowledges request has been
|
|
||||||
processed successfully
|
|
||||||
o If non-valid request, platform returns to step 1.
|
|
||||||
8. End of resources management
|
|
||||||
1. Platform provides user with an interface for resources
|
|
||||||
management (e.g. data and metadata, data usage)
|
|
||||||
2. User chooses to visualise usage information of a dataset
|
|
||||||
UC3.2. User tracks 3. Platform quickly process user’s request for data usage
|
|
||||||
resources usage information
|
|
||||||
4. Platform provides user with statistical information about data
|
|
||||||
usage and data users anonymised information
|
|
||||||
5. End of data usage tracking.
|
|
||||||
|
|
||||||
|
|
||||||
2.3.4 Functional Requirements
|
|
||||||
|
|
||||||
Req. ID UC. ID Description Priority Domain
|
|
||||||
Societal Needs,
|
|
||||||
FREQ.1 UC1 Allow data publishers to register to submit data for publication Must
|
|
||||||
Platform
|
|
||||||
Tracks data publication agreements between Data and Platform Business Needs,
|
|
||||||
FREQ.2 UC1 Must
|
|
||||||
Providers Platform
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 19
|
|
||||||
|
|
||||||
|
|
||||||
Store terms of agreements, and use them to monitor/review/process
|
|
||||||
FREQ.3 UC1 Must City Data, Platform
|
|
||||||
data submissions.
|
|
||||||
Able to add and edit terms of agreement, based on access of level of Business Needs,
|
|
||||||
FREQ.4 UC1 Must
|
|
||||||
user. Platform
|
|
||||||
|
|
||||||
FREQ.5 UC1 Data publications are managed and monitored Must City Data, Platform
|
|
||||||
|
|
||||||
Allow authenticated users from across different organisations to City Data, Platform,
|
|
||||||
FREQ.6 UC2 Must
|
|
||||||
publish city data Business Needs
|
|
||||||
Provide authorization mechanisms for users and sensors to publish
|
|
||||||
FREQ.7 UC2 Must City Data, Platform
|
|
||||||
city data
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.9 UC2 Provide mechanisms for static data publication Must
|
|
||||||
Business Needs
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.10 UC2 Provide mechanisms for real-time data publication Must
|
|
||||||
Business Needs
|
|
||||||
|
|
||||||
FREQ.11 UC2 Enable the publication of metadata Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.12 UC2 Maintain temporal information about the data Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.13 UC2 Support sensory data collection Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.14 UC2 Accepts content in numerous file types/formats Must City Data, Platform
|
|
||||||
|
|
||||||
Prompts a request for resubmission to the data provider if an error of
|
|
||||||
FREQ.15 UC2 Must City Data, Platform
|
|
||||||
data transmission or receipt occurs
|
|
||||||
|
|
||||||
FREQ.16 UC2 Enable the semantic description of connected devices Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.17 UC2 Gather data from authenticated and authorized devices Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.18 UC2 Validates automatically the successful transfer of the data Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.19 UC2 Performs virus checking on data Must City Data, Platform
|
|
||||||
|
|
||||||
Verifies the validity of the submission based on submitter, expected
|
|
||||||
FREQ.20 UC2 Must City Data, Platform
|
|
||||||
format, data quality, and completeness
|
|
||||||
Platform should have built-in checks on the incoming metadata. Data
|
|
||||||
FREQ.21 UC2 not containing the minimally defined set of attributes should be Must City Data, Platform
|
|
||||||
returned to the publisher for metadata enhancement.
|
|
||||||
System should have a user-friendly method of mapping non-standard
|
|
||||||
FREQ.22 UC2 Should City Data, Platform
|
|
||||||
metadata elements into approved standard elements.
|
|
||||||
Once ingested, metadata should be stored in a single common format.
|
|
||||||
FREQ.23 UC2 This format should be one that ensures against data loss, and allows a Must City Data, Platform
|
|
||||||
variety of access/distribution options
|
|
||||||
Data in the repository shall have sufficient technical metadata to
|
|
||||||
FREQ.24 UC2 assure functionality (e.g. viewing and display) to ensure accessibility Must City Data, Platform
|
|
||||||
and reusability.
|
|
||||||
Allows publishers to display and perform manual/visual quality control Business Needs, City
|
|
||||||
FREQ.25 UC2 Could
|
|
||||||
assurance via a user-friendly GUI Data, Platform
|
|
||||||
Business Needs, City
|
|
||||||
FREQ.26 UC2 Any errors shall prompt a request for resubmission of data Should
|
|
||||||
Data, Platform
|
|
||||||
|
|
||||||
FREQ.27 UC3 Enable data providers to manage their resources Must Business Needs
|
|
||||||
|
|
||||||
A minimal set of identifying information/metadata concerning data Business Needs,
|
|
||||||
FREQ.28 UC3 Must
|
|
||||||
publication submission must be recorded Platform
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 20
|
|
||||||
|
|
||||||
|
|
||||||
Stores and tracks versions of data. Links /connections between
|
|
||||||
FREQ.29 UC3 Must City Data, Platform
|
|
||||||
versions are created and maintained
|
|
||||||
Give service and data providers access to anonymized data of the Business Needs
|
|
||||||
FREQ.30 UC3 Should
|
|
||||||
subscribers of their data or services
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.31 UC3 Enable data providers to maintain and repair data and metadata Should
|
|
||||||
Business Needs
|
|
||||||
Tracks data publication agreements between Data and Platform Business Needs,
|
|
||||||
FREQ.32 UC3 Must
|
|
||||||
Providers Platform
|
|
||||||
Store terms of agreements, and use them to monitor/review/process
|
|
||||||
FREQ.33 UC3 Must City Data, Platform
|
|
||||||
data submissions.
|
|
||||||
Able to add and edit terms of agreement, based on access of level of Business Needs,
|
|
||||||
FREQ.34 UC3 Must
|
|
||||||
user. Platform
|
|
||||||
|
|
||||||
FREQ.35 UC3 Submission volumes and schedules are managed and monitored Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.4 SUB-GOAL 2: City data is managed in a safe and intelligent manner
|
|
||||||
Rationale: The urban platform enables users to publish, consume and commercialise data, as well
|
|
||||||
as deploy and manage services all in a secure and privacy protected manner.
|
|
||||||
|
|
||||||
Achieve [City data is exploited
|
|
||||||
to its full effect]
|
|
||||||
Goal
|
|
||||||
co-ena bles
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Maintain [Resources are managed in a
|
|
||||||
safe and intelligent manner]
|
|
||||||
S ub-Goal 1
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Achieve [Store Data] Achieve [Transmit Data] Achieve [Manage Infrastructure]
|
|
||||||
UC4 UC5 UC6
|
|
||||||
|
|
||||||
|
|
||||||
Figure 6. Sub-Goal 2 “City data is managed in a safe and intelligent manner” refinement.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Drivers: Ensure data is secured and the identity of users are preserved
|
|
||||||
Actions: For Sub-Goal 2 to be maintained in the long-run it requires the efficient realisation of use
|
|
||||||
cases: “Store City Data” and “Retrieve and Transmit City Data”, as shown in Figure 6.
|
|
||||||
|
|
||||||
|
|
||||||
2.4.1 Use Case: Store City Data
|
|
||||||
ID: UC4
|
|
||||||
Refines: SUB-GOAL 2: City data is managed in a safe and intelligent manner
|
|
||||||
Pre-condition: Data is successfully published in the platform
|
|
||||||
Actors: Urban Platform
|
|
||||||
Rationale: When data is successfully submitted (either via APIs or manual upload), it is
|
|
||||||
processed/prepared for storage into the platform’s database. This procedure will include the
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 21
|
|
||||||
|
|
||||||
|
|
||||||
generation of unique identifiers to the database, enrichment with ontologies (when applicable),
|
|
||||||
encrypted (when applicable), signed with digital certificates (when applicable) to ensure that the
|
|
||||||
data conforms to the platform data formatting, standards, security and regulation. Data may be
|
|
||||||
converted to accepted formats, as needed (e.g. graph model). A primary goal of the conversion of
|
|
||||||
content for the platform is the preservation of the content. Priority will be given to preserving the
|
|
||||||
data accordingly to the policies defined in section (2.6.2). Access-control levels and license models
|
|
||||||
are associated to data which is subject to restrictions relating to access and conditions of use.
|
|
||||||
Refines into requirements: FREQ 28 – FREQ 39.
|
|
||||||
|
|
||||||
Use Case Basic Interactions and Responses
|
|
||||||
1. Platform mechanisms converts submitted data into a standard format
|
|
||||||
2. Security enforcement (e.g. anonymisation, cryptography) is placed on
|
|
||||||
sensitive information.
|
|
||||||
UC4. Store City 3. Platform associates with datasets the access-control definitions set by
|
|
||||||
Data owner of resources.
|
|
||||||
4. Data is enriched with semantics and is associated with other datasets
|
|
||||||
5. Platform stores data in a scalable and secure database.
|
|
||||||
6. End of data storage.
|
|
||||||
|
|
||||||
|
|
||||||
2.4.2 Use Case: Transmit Data
|
|
||||||
ID: UC5
|
|
||||||
Refines: SUB-GOAL 2: City data is managed in a safe and intelligent manner
|
|
||||||
Pre-condition: Data is successfully published in the platform
|
|
||||||
Actors: Urban Platform
|
|
||||||
Rationale: The platform accepts data retrieval request, validates user’s rights to access the data,
|
|
||||||
retrieves city data from data storage, and moves a copy of the data to the relevant platform
|
|
||||||
component for further processing. If special processing is required, the retrieval function accesses
|
|
||||||
data in staging storage and applies the requested processes. The types of operations, which may
|
|
||||||
be carried out, include sub-sampling in temporal or spatial dimensions, conversions between
|
|
||||||
different data types or output formats, and other specialized processing (e.g., data visualisation).
|
|
||||||
Once it is finalised data will be sent to the appropriate delivery channels (e.g. API’s, GUI). It also
|
|
||||||
encompasses function to verify corruption during any internal data transfer. This function requires
|
|
||||||
that all hardware and software within the platform provide notification of potential errors and that
|
|
||||||
these errors are routed to standard error logs that are checked by the Platform Provider.
|
|
||||||
Refines into requirements: FREQ 40 to 54.
|
|
||||||
2.4.3 Use Case: Manage Infrastructure
|
|
||||||
ID: UC6
|
|
||||||
Refines: SUB-GOAL 2: City data is managed in a safe and intelligent manner
|
|
||||||
Pre-condition: The platform is available
|
|
||||||
Actors: Urban Platform
|
|
||||||
Rationale: Manage infrastructure provides the services and functions for the overall operation of
|
|
||||||
the urban platform. Administration functions include monitoring quality of service agreements,
|
|
||||||
auditing data publication to ensure that they meet archive standards, and maintaining configuration
|
|
||||||
management of system hardware and software. In overall, it provides system engineering
|
|
||||||
functions to monitor and improve platform operations, and to inventory, report on, and
|
|
||||||
migrate/update the contents of the platforms’ databases.
|
|
||||||
Refines into requirements: FREQ 55 to 62.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 22
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Use Case Basic Interactions and Responses
|
|
||||||
1. Platform keeps monitoring services at run-time to ensure operation
|
|
||||||
and integrity of city data
|
|
||||||
o If system failure, the platform activates mechanisms for recovery
|
|
||||||
UC6. Manage based on pre-defined rules
|
|
||||||
Infrastructure o Platform logs issue and issue alert messages to platform providers
|
|
||||||
2. Platform logs operation capabilities (e.g. performance, mean of time
|
|
||||||
failure, issues, etc.)
|
|
||||||
|
|
||||||
|
|
||||||
2.4.4 Functional Requirements
|
|
||||||
|
|
||||||
Req. ID UC. ID Description Priority Domain
|
|
||||||
A minimal set of identifying information/metadata concerning data Business Needs,
|
|
||||||
FREQ.28 UC4 Must
|
|
||||||
publication submission must be recorded. Platform
|
|
||||||
Stores and tracks versions of data. Links /connections between
|
|
||||||
FREQ.29 UC4 Must City Data, Platform
|
|
||||||
versions are created and maintained.
|
|
||||||
|
|
||||||
FREQ.30 UC4 Converts data to accepted file formats Must City Data, Platform
|
|
||||||
|
|
||||||
Keep sensitive information secured and accessible only to authorized
|
|
||||||
FREQ.31 UC4 Must City Data, Platform
|
|
||||||
users
|
|
||||||
|
|
||||||
FREQ.32 UC4 Keep user’s personal information protected Should City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.33 UC4 Keep city data and meta-data secured Must Platform Needs
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.34 UC4 Enable privacy preserving mechanisms associated to data Must Platform Needs
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.35 UC4 Model data in accordance with defined standards Must City Data, Platform
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.36 UC4 Support the use of ontologies and semantic modelling of city data Could City Data
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.37 UC4 Support database-level provenance annotation Should City Data
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.38 UC4 Support data-level provenance annotation Should City Data
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.39 UC4 Enable data to be encrypted Should Platform Needs
|
|
||||||
|
|
||||||
System must have the ability to search and display metadata, preferably
|
|
||||||
City Data, Platform
|
|
||||||
FREQ.40 UC5 in a user-conformable, human readable display as well as in its native Must
|
|
||||||
Needs
|
|
||||||
format for machine harvesting and manipulation.
|
|
||||||
Controls access to data in the repository based on multiple permission
|
|
||||||
City Data, Platform
|
|
||||||
FREQ.41 UC5 levels. These permission levels determine the create/edit/read/delete Should
|
|
||||||
Needs
|
|
||||||
privileges granted users.
|
|
||||||
Access rights and conditions of use will be held for each data and its City Data, Platform
|
|
||||||
FREQ.42 UC5 Should
|
|
||||||
related metadata. Needs
|
|
||||||
Access rights and conditions can be inherited from a parent data to any City Data, Platform
|
|
||||||
FREQ.43 UC5 Could
|
|
||||||
data designated as a child data (derived information). Needs
|
|
||||||
Access rights and conditions of use will be machine readable and City Data, Platform
|
|
||||||
FREQ.44 UC5 Should
|
|
||||||
actionable. Needs
|
|
||||||
Access mechanisms must be sufficiently granular to allow the Business Needs, City
|
|
||||||
FREQ.45 UC5 identification of individual users, in order to maintain audit logs of actions Should
|
|
||||||
Data, Platform
|
|
||||||
performed by users.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 23
|
|
||||||
|
|
||||||
|
|
||||||
Maintains the integrity of the database which contains both metadata
|
|
||||||
FREQ.46 UC5 and system information. Must Platform Needs
|
|
||||||
|
|
||||||
Provides internal validation such as referential integrity of the contents of City Data, Platform
|
|
||||||
FREQ.47 UC5 the database. Must
|
|
||||||
Needs
|
|
||||||
Creates and maintains schema definitions required to support data
|
|
||||||
FREQ.48 UC5 management functions. Must Platform Needs
|
|
||||||
|
|
||||||
Monitors and ensures that data and metadata are not corrupted during
|
|
||||||
FREQ.49 UC5 transfers. Must Platform Needs
|
|
||||||
|
|
||||||
Provides statistically acceptable assurance that no components of the
|
|
||||||
FREQ.50 UC5 data are corrupted during any internal data transfer. Must Platform Needs
|
|
||||||
|
|
||||||
Performs routine and special data integrity checking for each dataset
|
|
||||||
FREQ.51 UC5 and generates error reports. Must Platform Needs
|
|
||||||
|
|
||||||
Provides disaster recovery capabilities including data backup, off-site
|
|
||||||
FREQ.52 UC5 data storage, data recovery, etc. Must Platform Needs
|
|
||||||
|
|
||||||
Refresh/replace data without service interruption, and update
|
|
||||||
FREQ.53 UC5 corresponding metadata as appropriate. Must Platform Needs
|
|
||||||
|
|
||||||
Ensure that any associated unique identifiers of the updated data are not
|
|
||||||
FREQ.54 UC5 altered. Must Platform Needs
|
|
||||||
|
|
||||||
Audits submissions to ensure that they meet archive/repository
|
|
||||||
FREQ.55 UC6 standards. Must Platform Needs
|
|
||||||
|
|
||||||
Maintains configuration management of the system hardware and
|
|
||||||
FREQ.56 UC6 software. Must Platform Needs
|
|
||||||
|
|
||||||
Has capability to inventory, report on and migrate the contents of the
|
|
||||||
FREQ.57 UC6 repository. Must Platform Needs
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.58 UC6 Ensures data integrity for version upgrades and format migration. Must Platform Needs
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.59 UC6 Monitors functionality of the entire repository. Must Platform Needs
|
|
||||||
|
|
||||||
|
|
||||||
FREQ.60 UC6 Maintains integrity of system configuration. Must Platform Needs
|
|
||||||
|
|
||||||
Audits system operations, performance and usage.
|
|
||||||
FREQ.61 UC6 Must Platform Needs
|
|
||||||
|
|
||||||
Provides platform performance information and database holdings
|
|
||||||
FREQ.62 UC6 inventory reports Must Platform Needs
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.5 SUB-GOAL 3: City data is orchestrated in a marketplace
|
|
||||||
Rationale: The urban platform enables users to consume and publish data in a secure and privacy
|
|
||||||
protected manner.
|
|
||||||
Drivers: Ensure data is secured and the identity of users are preserved
|
|
||||||
Actions: For Sub-Goal 3 to be maintained in the long-run it requires the efficient realisation of use
|
|
||||||
cases: “Commercialise City Data” and “Commercialise Data Services”, as shown in Figure 7.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 24
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Achieve [City data is exploited
|
|
||||||
to its full effect]
|
|
||||||
Goal
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
co-ena bles
|
|
||||||
Maintain [City data is orchestrated in
|
|
||||||
a marketplace]
|
|
||||||
S ub-Goal 3
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Achieve [Commercialise Data
|
|
||||||
Achieve [Commercialise City Data]
|
|
||||||
Services]
|
|
||||||
UC7 UC8
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Figure 7. Sub-Goal 3 “City data is orchestrated in a marketplace” refinement.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.5.1 Use Case: Commercialise City Data
|
|
||||||
ID: UC7
|
|
||||||
Refines: SUB-GOAL 3: City data is orchestrated in a marketplace
|
|
||||||
Pre-condition: Data is successfully published in the platform, both publisher and consumers of
|
|
||||||
city data are authenticated in the platform, and there are billing capabilities available.
|
|
||||||
Actors: Data Publishers, Data Consumers
|
|
||||||
Rationale: The providers of city data can commercialise city data based on the policies and
|
|
||||||
financial models defined in the platform. After publishing their data, publishers can define which
|
|
||||||
data can be available as open data and which data should be available with the payment of a
|
|
||||||
subscription fee. Once publishers define which data is to be commercially exploited, the platform
|
|
||||||
will associate the data with their respective financial models and let it ready for subscription. City
|
|
||||||
data consumer chooses which data to purchase and is redirected to a billing interface where the
|
|
||||||
subscription payment is taken. The platform must provide an update response indicating the status
|
|
||||||
of the payment. If successful, data is ready available to be consumed by humans and machines,
|
|
||||||
otherwise the user can re-try the payment or cancel transaction.
|
|
||||||
Commercialise city data also involves the function of managing commercial data. It provides
|
|
||||||
services and functions for updating, maintaining and accessing both data and its respective
|
|
||||||
commercial transactions. Furthermore, it enables data providers to track the usage of commercial
|
|
||||||
data by users. Ideally the owners of the data should be the only authorised user to manage
|
|
||||||
resources, and other authorised users can track the usage of the data in the platform. Data usage
|
|
||||||
tracking includes performing queries on the data management data to generate result sets, and
|
|
||||||
producing reports from these result sets. Data consumers are provided with functions which enable
|
|
||||||
them to manage their subscriptions and financial transactions on the platform. These functions
|
|
||||||
include updating, maintaining and accessing financial transactions. For all these functions and
|
|
||||||
services, the platform must provide a database update response indicating the status of the
|
|
||||||
update, avoid update errors to be propagated in the platform, and should keep an audit trail of all
|
|
||||||
actions to enable rollback.
|
|
||||||
Specialised Use Cases: The Use Case Commercialise City Data is distinguished into four
|
|
||||||
specialised Use Cases: “Commercialise Data (UC7.1)”, “Consume Commercial City Data (UC7.2)”,
|
|
||||||
“Manage Commercial Data (UC7.3)” and “Manage Data Subscription (UC7.4)”.
|
|
||||||
Refines into requirements: FREQ 63 to 68.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 25
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Use Case Basic Interactions and Responses
|
|
||||||
1. User selects the data to be commercialised
|
|
||||||
2. User selects the commercial model for data consumption
|
|
||||||
UC7.1. 3. Platform validates selection
|
|
||||||
Commercialise 4. Platform associates data to subscription model
|
|
||||||
Data 5. Platform releases data for commercial exploitation in the marketplace
|
|
||||||
6. End of data commercialisation set up.
|
|
||||||
1. User selects the data to be subscribed to
|
|
||||||
2. User request subscription to data
|
|
||||||
UC7.2. 3. Platform validates selection
|
|
||||||
Consume 4. Platform redirects user to billing system
|
|
||||||
Proprietary 5. Billing system deals with user request
|
|
||||||
Data o If successful, user is redirected to a GUI where data is ready to use
|
|
||||||
o If unsuccessful, user can try payment again or cancel request
|
|
||||||
2. End of data subscription.
|
|
||||||
1. Platform provides user with an interface for commercial data
|
|
||||||
management
|
|
||||||
2. User chooses to edit or delete commercial data
|
|
||||||
o If edit, user revise commercial models, licenses, access-control,
|
|
||||||
semantics;
|
|
||||||
o If delete, user selects dataset(s) to be removed (following policies
|
|
||||||
UC7.3. Manage defined in the platform for data removal)
|
|
||||||
commercial 3. User confirms action
|
|
||||||
Data 4. Platform promptly process user’s request
|
|
||||||
5. Platform confirms execution of request
|
|
||||||
o If valid request, platform acknowledges request has been
|
|
||||||
processed successfully
|
|
||||||
o If non-valid request, platform returns to step 1.
|
|
||||||
6. End of resources management
|
|
||||||
1. Platform provides user with an interface for data subscription
|
|
||||||
management
|
|
||||||
2. User chooses to edit or cancel data subscription
|
|
||||||
o If edit, user revise payment and subscription timeframe;
|
|
||||||
o If cancel, user selects dataset(s) to have subscription cancelled
|
|
||||||
UC7.4. Manage (following policies defined in the platform for data subscription)
|
|
||||||
data 3. User confirms action
|
|
||||||
subscription 4. Platform promptly process user’s request
|
|
||||||
5. Platform confirms execution of request
|
|
||||||
o If valid request, platform updates acknowledges request has been
|
|
||||||
processed successfully
|
|
||||||
o If non-valid request, platform returns to step 1.
|
|
||||||
6. End of data subscription management
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.5.2 Use Case: Commercialise Data Services
|
|
||||||
ID: UC8
|
|
||||||
Refines: SUB-GOAL 3: City data is orchestrated in a marketplace
|
|
||||||
Pre-condition: Data is successfully published in the platform, both publisher and consumers of
|
|
||||||
city data are authenticated in the platform, and there are billing capabilities available.
|
|
||||||
Actors: Data Service Providers, Data Publishers and Consumers
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 26
|
|
||||||
|
|
||||||
|
|
||||||
Rationale: After deploying data services in the platform, the providers of data services can choose
|
|
||||||
to commercialise services based on the policies and financial models defined in the platform. Once
|
|
||||||
data service providers define which service(s) is (are) to be commercially exploited, the platform
|
|
||||||
will associate the services with their respective financial models and let available in the platform
|
|
||||||
applications module ready for use. User (either publisher or consumer) chooses which data
|
|
||||||
services to use, and in case a charged service is selected the platform redirects the user to a
|
|
||||||
billing interface where the payment is taken. The platform must provide an update response
|
|
||||||
indicating the status of the payment. If successful, service is ready available to be used, otherwise
|
|
||||||
the user can re-try the payment or cancel transaction. Note that data service owners should be
|
|
||||||
able to waive the payment of tariff to certain users’ categories.
|
|
||||||
Data services providers are offered with functions to manage their commercial services. The
|
|
||||||
platform provides functions for updating, maintaining and accessing both service and its respective
|
|
||||||
commercial transactions. Furthermore, it enables data services providers to track the usage of
|
|
||||||
services by users. Services usage tracking includes performing queries on the platform to
|
|
||||||
generate result sets, and producing reports from these result sets. The consumers of data services
|
|
||||||
are provided with functions which enable them to manage their subscriptions and financial
|
|
||||||
transactions. These functions include updating, maintaining and accessing financial transactions.
|
|
||||||
For all these functions and services, the platform must provide a database update response
|
|
||||||
indicating the status of the update, avoid update errors to be propagated in the platform, and
|
|
||||||
should keep an audit trail of all actions to enable rollback.
|
|
||||||
Specialised Use Cases: The Use Case Commercialise Data Services is distinguished into four
|
|
||||||
specialised Use Cases: “Commercialise Data Services (UC8.1)”, “Consume Data Services
|
|
||||||
(UC8.2)”, “Manage Commercial Services (UC8.3)” and “Manage Services Subscription (UC8.4)”.
|
|
||||||
Refines into requirements: FREQ 68 to 73.
|
|
||||||
|
|
||||||
Use Case Basic Interactions and Responses
|
|
||||||
1. User selects the data services to be commercialised
|
|
||||||
2. User selects the commercial model for data service usage based on the
|
|
||||||
category of users
|
|
||||||
UC8.1. 3. Platform validates selection
|
|
||||||
Commercialise 4. Platform associates data services to subscription model
|
|
||||||
Data Services 5. Platform enables data service to be commercially exploited in the
|
|
||||||
marketplace
|
|
||||||
6. End of services commercialisation set up.
|
|
||||||
1. User selects the data service to be subscribed to
|
|
||||||
2. User request subscription to service
|
|
||||||
3. Platform validates selection
|
|
||||||
UC8.2. 4. Platform validates user’s category its respective commercial models
|
|
||||||
Consume Data 4. If applicable, platform redirects user to billing system
|
|
||||||
Services 5. Billing system deals with user’s request
|
|
||||||
o If successful, user is redirected to a GUI where data is ready to use
|
|
||||||
o If unsuccessful, user can try payment again or cancel request
|
|
||||||
6. End of data subscription.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 27
|
|
||||||
|
|
||||||
|
|
||||||
1. Platform provides user with an interface for commercial data
|
|
||||||
management
|
|
||||||
2. User chooses to edit or delete commercial data
|
|
||||||
o If edit, user revise commercial models, licenses, access-control,
|
|
||||||
semantics;
|
|
||||||
o If delete, user selects dataset(s) to be removed (following policies
|
|
||||||
UC8.3. Manage defined in the platform for data removal)
|
|
||||||
commercial 3. User confirms action
|
|
||||||
services 4. Platform promptly process user’s request
|
|
||||||
5. Platform confirms execution of request
|
|
||||||
o If valid request, platform acknowledges request has been
|
|
||||||
processed successfully
|
|
||||||
o If non-valid request, platform returns to step 1.
|
|
||||||
6. End of resources management
|
|
||||||
1. Platform provides user with an interface for data subscription
|
|
||||||
management
|
|
||||||
2. User chooses to edit or cancel data subscription
|
|
||||||
o If edit, user revise payment and subscription timeframe;
|
|
||||||
o If cancel, user selects dataset(s) to have subscription cancelled
|
|
||||||
UC8.4. Manage (following policies defined in the platform for data subscription)
|
|
||||||
services 3. User confirms action
|
|
||||||
subscription 4. Platform promptly process user’s request
|
|
||||||
5. Platform confirms execution of request
|
|
||||||
o If valid request, platform updates acknowledges request has been
|
|
||||||
processed successfully
|
|
||||||
o If non-valid request, platform returns to step 1.
|
|
||||||
6. End of data subscription management
|
|
||||||
|
|
||||||
|
|
||||||
2.5.3 Functional Requirements
|
|
||||||
|
|
||||||
Req. ID UC. ID Description Priority Domain
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.63 UC7 Support the commercialization of city data Should Business Needs
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.64 UC7 Enable users to subscribe to city data through the payment of a tariff Should Business Needs
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.65 UC7 Enable users to manage their data subscriptions Should Business
|
|
||||||
Provide platform providers mechanisms to define the terms and
|
|
||||||
FREQ.66 UC7 Must Platform Needs
|
|
||||||
conditions for platform data usage
|
|
||||||
Enable data providers to manage the subscription models of their City Data, Platform,
|
|
||||||
FREQ.67 UC7 Should
|
|
||||||
data Business
|
|
||||||
UC7/ City Data, Platform,
|
|
||||||
FREQ.68 Utilise secure and reliable billing and payment management systems Must
|
|
||||||
UC8 Business
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.69 UC8 Support the commercialization of data services Should Business Needs
|
|
||||||
Enable data providers to manage the commercial models of their City Data, Platform,
|
|
||||||
FREQ.70 UC8 Should
|
|
||||||
services Business Needs
|
|
||||||
Provide service providers mechanisms to define the terms and City Data, Platform,
|
|
||||||
FREQ.71 UC8 Should
|
|
||||||
conditions of platform services Business Needs
|
|
||||||
Allow users to pay a tariff for using certain advanced services (e.g. City Data, Platform,
|
|
||||||
FREQ.72 UC8 Should
|
|
||||||
Data manipulation, enrichment) Business Needs
|
|
||||||
City Data, Platform,
|
|
||||||
FREQ.73 UC8 Enable users to manage their data services subscriptions Should Business Needs
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 28
|
|
||||||
|
|
||||||
|
|
||||||
2.6 SUB-GOAL 4: City data is offered in an accessible manner
|
|
||||||
Rationale: The urban platform provides city data in both human and machine (e.g. sensors,
|
|
||||||
actuators, systems) readable and understandable formats.
|
|
||||||
Drivers: Ensure data understandability and machine-to-machine data transaction.
|
|
||||||
Actions: For Sub-Goal 3 to be maintained in the long-run it requires the efficient realisation of use
|
|
||||||
cases: “Register Consumer”, “Discover City Data”, and “Consume City Data” as shown in Figure 8.
|
|
||||||
|
|
||||||
Achieve [City data is exploited
|
|
||||||
to its full effect]
|
|
||||||
Goal
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
co-ena bles
|
|
||||||
Maintain [City data is offered in an
|
|
||||||
accessible manner]
|
|
||||||
S ub-Goal 1
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Achieve [Register Consumer] Achieve [Discover City Data] Achieve [Consume City Data]
|
|
||||||
UC9 UC10 UC11
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Figure 8. Sub-Goal 4 “City data is offered in an accessible manner” refinement.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.6.1 Use Case: Register Data Consumer
|
|
||||||
ID: UC9
|
|
||||||
Refines: SUB-GOAL 4: City data is offered in an accessible manner
|
|
||||||
Pre-condition: User is not logged in the platform
|
|
||||||
Actors: Data Consumers
|
|
||||||
Rationale: Data Consumers can register in the platform and request approval to consume city data
|
|
||||||
via GUI or APIs. They provide valid registration details (to be defined) and wait for platform to
|
|
||||||
confirm their registration. Users must accept the terms and conditions of platform usage and define
|
|
||||||
how their personal data can be used by the Platform Owner. Users can manage and alter their
|
|
||||||
registration information at any time they want to.
|
|
||||||
Refines into requirements: FREQ 64.
|
|
||||||
|
|
||||||
Use Case Basic Stimulus and Responses
|
|
||||||
1. The platform prompts the user for a username and password or register
|
|
||||||
new account.
|
|
||||||
2. The user selects registration option.
|
|
||||||
3. The platform prompts user for data consumer registration information
|
|
||||||
UC1. Register 4. The user enters in their information.
|
|
||||||
Consumer 5. Platform verifies information and creates account.
|
|
||||||
o If non-valid information, platform shows error message and returns to
|
|
||||||
step 1.
|
|
||||||
6. Platform acknowledges registration has been successful
|
|
||||||
7. End of registration
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 29
|
|
||||||
|
|
||||||
|
|
||||||
2.6.2 Use Case: Discover City Data
|
|
||||||
ID: UC10
|
|
||||||
Refines: SUB-GOAL 4: City data is offered in an accessible manner
|
|
||||||
Pre-condition: User has access to either platform GUI or API
|
|
||||||
Actors: Data Consumers
|
|
||||||
Rationale: Data Consumers can register in the platform and request approval to consume city data
|
|
||||||
via GUI or APIs. They provide valid registration details (to be defined) and wait for platform to
|
|
||||||
confirm their registration.
|
|
||||||
Refines into requirements: FREQ 64.
|
|
||||||
|
|
||||||
Use Case Basic Stimulus and Responses
|
|
||||||
1. Users access specialised data query end-points (e.g. SPARQL)
|
|
||||||
2. Users provides information for pre-defined parameters for search
|
|
||||||
3. Users request data search
|
|
||||||
4. Platform quickly process users request for data
|
|
||||||
@@ -1,604 +0,0 @@
|
|||||||
Consume City o If authentication is successful, users are provided with requested
|
|
||||||
Data via APIs data streams
|
|
||||||
3. Users are provided with requested data via APIs
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.6.4 Functional Requirements
|
|
||||||
|
|
||||||
Req. ID UC. ID Description Priority Domain
|
|
||||||
Allow users to register to use services and consume proprietary city Societal Needs,
|
|
||||||
FREQ.74 UC9 Should
|
|
||||||
data and open data (optional) Platform
|
|
||||||
Keep sensitive information secured and accessible only to Societal Needs,
|
|
||||||
FREQ.75 UC9 Should
|
|
||||||
authorized users Platform
|
|
||||||
Societal Needs,
|
|
||||||
FREQ.76 UC9 Provide authentication mechanisms for users Must Platform
|
|
||||||
Societal Needs,
|
|
||||||
FREQ.77 UC9 Keep user’s personal information protected Must Platform
|
|
||||||
Allow users to control which data they are willing to provide and how Societal Needs,
|
|
||||||
FREQ.78 UC9 Must
|
|
||||||
their data should be used Platform
|
|
||||||
Societal Needs,
|
|
||||||
FREQ.79 UC10 Allow users to format data in any supported data formats Must
|
|
||||||
Platform
|
|
||||||
The query request may require data to be sourced from different Societal Needs,
|
|
||||||
FREQ.80 UC10 Must
|
|
||||||
storage locations Platform
|
|
||||||
Allows query requests against all metadata used to manage the Societal Needs,
|
|
||||||
FREQ.81 UC10 repository. Should Platform
|
|
||||||
Provide users information about the legal aspects of the data Societal Needs,
|
|
||||||
FREQ.82 UC11 Must
|
|
||||||
(license, ownership) City Data
|
|
||||||
Societal Needs,
|
|
||||||
FREQ.83 UC11 Keeps an audit trail of all actions. Must City Data
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 31
|
|
||||||
|
|
||||||
|
|
||||||
2.7 SUB-GOAL 5: User’s experience is enhanced by the provision of
|
|
||||||
value-added services
|
|
||||||
Rationale: The urban platform enables users to consume and publish data in a secure and privacy
|
|
||||||
protected manner
|
|
||||||
Drivers: Ensure data is secured and the identity of users are preserved
|
|
||||||
Actions: For Sub-Goal 5 to be maintained in the long-run it requires the efficient realisation of use
|
|
||||||
cases: “Deploy Data Services”, “Manage Services”, and “Utilise Data Services” as shown in Figure
|
|
||||||
8.
|
|
||||||
|
|
||||||
Achieve [City data is exploited
|
|
||||||
to its full effect]
|
|
||||||
Goal
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
co-ena bles
|
|
||||||
Maintain [User s experience is enhanced by
|
|
||||||
the provision of value-added services]
|
|
||||||
S ub-Goal 1
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Achieve [Deploy Data Services] Achieve [Manage Services] Achieve [Utilise Data Services]
|
|
||||||
UC12 UC13 UC14
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Figure 9. Sub-Goal 5 “User’s experience is enhanced by the provision of value-added services” refinement
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.7.1 Deploy Data Services
|
|
||||||
ID: UC12
|
|
||||||
Refines: SUB-GOAL 5: User’s experience is enhanced by the provision of value-added services
|
|
||||||
Pre-condition: Data Services Providers are provided with credentials, are authorised to deploy
|
|
||||||
their services in the platform, and have access to technical specifications of the platform interfaces.
|
|
||||||
Actors: Data Services Providers
|
|
||||||
Rationale: Data Services can register in the platform and request approval to deploy services in
|
|
||||||
the platform. They provide valid registration details (to be defined) and wait for registration
|
|
||||||
confirmation. Platform Providers may authorise or not data services providers to offer both open
|
|
||||||
and proprietary services in the platform. Data services providers must formally agree with service
|
|
||||||
deployment agreement with the Urban Platform. This agreement defines terms of the content,
|
|
||||||
policies, regulations, license agreement. The Urban Platform will proactively work with Service
|
|
||||||
Providers to agree on the technical specifications of interfaces and platform openness level.
|
|
||||||
Agreements between Platform and Data Service Providers may be renegotiated on a periodic or
|
|
||||||
ad-hoc basis.
|
|
||||||
Refines into requirements: FREQ.1 to FREQ.5.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 32
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Use Case Basic Stimulus and Responses
|
|
||||||
1. User authenticates in the platform
|
|
||||||
2. User is provided access to platform interfaces for service deployment
|
|
||||||
3. User deploy services
|
|
||||||
4. Platform checks compatibility and any technical issues arising from the
|
|
||||||
UC12. Deploy new service
|
|
||||||
Services 5. If approved the service is ready to be used and managed in the
|
|
||||||
platform
|
|
||||||
o If deployment is not approved, platform shows error message and
|
|
||||||
returns to step 1.
|
|
||||||
6. End of deployment
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.7.2 Use Case: Manage Services
|
|
||||||
ID: UC13
|
|
||||||
Refines: GOAL 5: User’s experience is enhanced by the provision of value-added services
|
|
||||||
Pre-condition: User successfully authenticates in the platform
|
|
||||||
Actors: Data Services Providers
|
|
||||||
Rationale: Manage services provides the functions for updating, maintaining and accessing
|
|
||||||
services as well as tracking their usage by users. Ideally the owners of the services should be the
|
|
||||||
only authorised user to manage them, and other authorised users can track the usage of the
|
|
||||||
services in the platform. In case of updates the platform must log in the database update details
|
|
||||||
and send to service providers a response indicating the status of the update. The platform should
|
|
||||||
also ensure update errors are not propagated nor affect the health of other services provided in the
|
|
||||||
platform, and should keep an audit trail of all actions to enable rollback. Data services usage
|
|
||||||
tracking includes performing queries on the data management data to generate result sets, and
|
|
||||||
producing reports from these result sets. All user’s information provided to service providers must
|
|
||||||
follow regulations of data protection and the user’s defined rules for their data use.
|
|
||||||
Specialised Use Cases: The Use Case Manage Services is distinguished into two specialised
|
|
||||||
Use Cases: “User manages services (UC13.1)” and “User tracks service usage (UC13.2)”.
|
|
||||||
Subordinated Use Cases: “Transmit Data (UC5)”
|
|
||||||
Refines into requirements: FREQ.27 to FREQ.25.
|
|
||||||
|
|
||||||
|
|
||||||
Specialised Use Cases Basic Interactions and Responses
|
|
||||||
1. Platform provides user with an interface for services management
|
|
||||||
2. User chooses to edit or delete services
|
|
||||||
3. If edit, user revise service information (access-control,
|
|
||||||
commercial models, parameters) and deployment;
|
|
||||||
If delete, user selects services to be removed / disabled
|
|
||||||
UC13.1. User manages 4. User confirms action
|
|
||||||
services 5. Platform quickly process user’s request
|
|
||||||
6. Platform confirms execution of request
|
|
||||||
o If valid request, platform acknowledges request has been
|
|
||||||
processed successfully
|
|
||||||
o If non-valid request, platform returns to step 1.
|
|
||||||
7. End of services management
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 33
|
|
||||||
|
|
||||||
|
|
||||||
1. Platform provides user with an interface for services management
|
|
||||||
2. User chooses to visualise usage information of a service
|
|
||||||
3. Platform quickly process user’s request for data usage
|
|
||||||
UC13.2. User tracks information
|
|
||||||
services usage 4. Platform provides user with statistical information about services
|
|
||||||
usage and data users anonymised information
|
|
||||||
5. End of data services tracking.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.7.3 Use Case: Utilise Data Services
|
|
||||||
ID: UC2
|
|
||||||
Refines: SUB-GOAL 1 - City data is collected in an intelligent manner
|
|
||||||
Pre-condition: User is authenticated in the platform
|
|
||||||
Actors: City data publisher
|
|
||||||
Rationale: Data Consumers can register in the platform and request approval to consume city data via GUI
|
|
||||||
or APIs. They provide valid registration details (to be defined) and wait for platform to confirm their
|
|
||||||
registration.
|
|
||||||
Subordinated Use Cases: “Commercialise Data Services (UC8)”
|
|
||||||
Refines into requirements: FREQ 6 to FREQ.26.
|
|
||||||
|
|
||||||
|
|
||||||
Specialised Use Cases Basic Interactions and Responses
|
|
||||||
1. Users / Machines select data service to be utilised
|
|
||||||
2. Users / Machines are redirected to authentication mechanism in
|
|
||||||
UC2.1. User utilises data case of registration is needed for the particular service
|
|
||||||
services o If authentication is successful, users are provided with
|
|
||||||
requested data streams
|
|
||||||
3. Users are provided with requested service either via API or GUI
|
|
||||||
|
|
||||||
|
|
||||||
2.7.4 Functional Requirements
|
|
||||||
|
|
||||||
Req. ID UC. ID Description Priority Domain
|
|
||||||
Provide stable and well-defined interfaces to ensure interoperability
|
|
||||||
Societal Needs,
|
|
||||||
FREQ.84 UC12 between the platform, services and the applications provided by Should Platform
|
|
||||||
services providers
|
|
||||||
Ensure the interfaces of the architecture are open to reduce entry Societal Needs,
|
|
||||||
FREQ.85 UC12 Should
|
|
||||||
barriers and integration issues Platform
|
|
||||||
Provide multi-purposed and network intelligent interfaces to Societal Needs,
|
|
||||||
FREQ.86 UC12 Must
|
|
||||||
providers and consumers of services Platform
|
|
||||||
Provide service providers mechanisms to define the terms and Societal Needs,
|
|
||||||
FREQ.87 UC12 Must
|
|
||||||
conditions of platform services deployment Platform
|
|
||||||
Business Needs,
|
|
||||||
FREQ.88 UC13 Provide statistical information of user’s feedback on service usage Must Platform
|
|
||||||
Allow users to use services to manipulate city data (e.g. Create Societal Needs,
|
|
||||||
FREQ.89 UC10 Must
|
|
||||||
mash ups, integrate) Platform
|
|
||||||
Allow users to provide feedback on usability, and quality of data Societal Needs,
|
|
||||||
FREQ.90 UC14 Must
|
|
||||||
and services provided by the platform Platform
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 34
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3. Non-functional Requirements
|
|
||||||
3.1 Run-time Quality Requirements
|
|
||||||
3.1.1 Scalability Requirements
|
|
||||||
|
|
||||||
Rationale: The ability of the system to execute its task within its expected performance profile and
|
|
||||||
to handle on-demand increased processing volumes of data and service requests
|
|
||||||
Drivers: Provide ready access to all data that underpins decision making processes in smart cities,
|
|
||||||
and accommodate users and data usage patterns
|
|
||||||
Refines into requirements: NFREQ.1, NFREQ.2, NFREQ.3, NFREQ.4
|
|
||||||
Relevance: Urban platforms have ambitious performance requirements. Such platforms must cope
|
|
||||||
with user’s demand for data and services, capture real-time data that will be catalysed by a myriad
|
|
||||||
of sensors. The demand for urban platform is very likely to significantly increase over time. It is
|
|
||||||
very difficult to have clear performance characteristics due to the ubiquity, heterogeneity high
|
|
||||||
connectivity of devices and end users.
|
|
||||||
|
|
||||||
SCALABILITY MEASURES
|
|
||||||
Actions Capture the performance requirements
|
|
||||||
Create service level agreements
|
|
||||||
Predict scalability using software simulation
|
|
||||||
Analyse the performance of the platform overtime
|
|
||||||
Conduct practical testing
|
|
||||||
Strategy Prioritize service and data requests
|
|
||||||
Distribute processing over time
|
|
||||||
Scale up or scale out as necessary
|
|
||||||
Reuse resources and results Partition and parallelize
|
|
||||||
Constantly monitor Quality of Service at runtime
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3.1.2 Availability and Reliability Requirements
|
|
||||||
|
|
||||||
Rationale: The ability of the system to be fully or partly operational as and when required and to
|
|
||||||
effectively handle failures that could affect system availability
|
|
||||||
Drivers: Build a reliable foundation for “on demand” exploitation of data
|
|
||||||
Refines into requirements: NFREQ.5, NFREQ.6, NFREQ.7, NFREQ.8
|
|
||||||
Relevance: Any system that has complex or extended availability requirements, complex recovery
|
|
||||||
processes, or a high profile (e.g., is visible to the public)
|
|
||||||
|
|
||||||
AVAILABILITY AND RELIABILITY MEASURES
|
|
||||||
Actions Capture the availability requirements Produce the availability schedule
|
|
||||||
Estimate platform availability Estimate functional availability Assess against the
|
|
||||||
requirements Rework the architecture
|
|
||||||
Strategy Adopt fault-tolerant hardware
|
|
||||||
Use reliable infrastructure database
|
|
||||||
Log transactions
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 35
|
|
||||||
|
|
||||||
|
|
||||||
Develop adaptive software to cope and recover from faults
|
|
||||||
Design and test for failure
|
|
||||||
Deploy load balancing
|
|
||||||
Identify suitable backup and disaster recovery solution
|
|
||||||
|
|
||||||
|
|
||||||
3.1.3 Trust Requirements
|
|
||||||
|
|
||||||
Rationale: A quality related to the user’s belief in the reliability, integrity and ability of the functional
|
|
||||||
behaviour of the platform
|
|
||||||
Drivers: Gain understanding of what influences user’s experience while interacting with services
|
|
||||||
provided
|
|
||||||
Requirements: NFREQ.16, NFREQ.17
|
|
||||||
Relevance: Relevant to the systems that share and collect information that may raise public
|
|
||||||
concern. In some cases, trust has to do with the reliability of data and their providers, whereas in
|
|
||||||
other cases trust can be associated with the security and privacy of the technology that was
|
|
||||||
deployed. Trust affects the reputation of the platform besides its dissemination and maturity on the
|
|
||||||
market.
|
|
||||||
|
|
||||||
TRUST MEASURES
|
|
||||||
Capture trust requirements
|
|
||||||
Perform risk analysis so measures can be implemented
|
|
||||||
Actions Explore the vulnerability aspects of city data
|
|
||||||
Check whether extensibility requirements impact on trust
|
|
||||||
Define a trust model
|
|
||||||
Adopt trust model
|
|
||||||
Deploy monitoring capabilities and assess its impact on scalability
|
|
||||||
Manage the data in a way that ensures its compliance with data
|
|
||||||
Strategy protection regulations
|
|
||||||
Implement tampering and data misuse detection
|
|
||||||
Make use of Cryptography when necessary
|
|
||||||
Allow Federation of trust between platforms
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3.1.4 Security Requirements
|
|
||||||
|
|
||||||
Rationale: Ability of the system to enforce the intended confidentiality, trust, integrity and service
|
|
||||||
and data access policies, and to detect and recover from failure in these security mechanisms.
|
|
||||||
Drivers: Manage the data and services in a way that ensures its integrity, and compliance with
|
|
||||||
data protection regulations
|
|
||||||
Relevance: Relevant to the systems that share and collect information that may raise public
|
|
||||||
concern. Urban platforms may become a valuable target for attackers which can potentially leave
|
|
||||||
huge swathes of information exposed. It could potentially undermine trust in the government and
|
|
||||||
damage its reputation.
|
|
||||||
Refines into requirements: NFREQ.9 to NFREQ.15
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 36
|
|
||||||
|
|
||||||
|
|
||||||
SECURITY MEASURES
|
|
||||||
Elicit the security requirements
|
|
||||||
Cross-check security requirements’ impact on services offered by different
|
|
||||||
providers
|
|
||||||
Verify security impact on service composition
|
|
||||||
Conduct risk analysis and impacts of security breach
|
|
||||||
Use scalable and efficient user authentication components
|
|
||||||
Use authentication and authorization components to secure interfacing with
|
|
||||||
Actions external services
|
|
||||||
Address aspects of data collection, storage and distribution
|
|
||||||
Address aspects of service and communication security among devices
|
|
||||||
Identify security requirements of the physical infrastructure
|
|
||||||
Balance and prioritize scalability (performance) and security requirements
|
|
||||||
Evaluate trade-offs between privacy considerations and preventing abuse. (While
|
|
||||||
anonymous access guarantees privacy of users, traceability of users due to abuse
|
|
||||||
of the service is not possible.)
|
|
||||||
Toughen user’s functional components
|
|
||||||
Authenticate subjects
|
|
||||||
Define and enforce access policies
|
|
||||||
Secure communication infrastructures
|
|
||||||
Secure interfaces with external systems and services
|
|
||||||
Strategy
|
|
||||||
Secure databases
|
|
||||||
Check data integrity for critical services
|
|
||||||
User digital certificates and encryption where necessary
|
|
||||||
Secure monetary transactions
|
|
||||||
Secure user’s personal information
|
|
||||||
|
|
||||||
|
|
||||||
3.1.5 Privacy Requirements
|
|
||||||
|
|
||||||
Rationale: Ability of the system to ensure that the collection and transmission of personal data is
|
|
||||||
minimized and handled in accordance with user’s expectation and regulations.
|
|
||||||
Drivers: Protect the vulnerability aspects volunteered citizen’s data
|
|
||||||
Requirements: NFREQ.18, NFREQ.19, NFREQ.20, NFREQ.21, NFREQ.22
|
|
||||||
Relevance: Ensuring users privacy is protected positively influences user’s experience,
|
|
||||||
acceptance and continuous use of the platform. Besides other factors, the reputation of the
|
|
||||||
platform depends on how well user’s information is secure and preserved.
|
|
||||||
|
|
||||||
PRIVACY MEASURES
|
|
||||||
Capture trust requirements
|
|
||||||
Perform risk analysis so measures can be implemented
|
|
||||||
Actions Explore the vulnerability aspects of city data
|
|
||||||
Check whether extensibility requirements impact on trust
|
|
||||||
Define a trust model
|
|
||||||
Allow users to interact with the platform anonymously in given
|
|
||||||
circumstances
|
|
||||||
Manage users identification securely
|
|
||||||
Strategy Anonymize data whenever necessary to comply with data regulations
|
|
||||||
Cryptograph personal identifiers during data transmission when that is
|
|
||||||
necessary
|
|
||||||
Avoid unauthorized access to implicit information (e.g. location)
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 37
|
|
||||||
|
|
||||||
|
|
||||||
Verify the impact of security, trust and scalability requirements trade-offs
|
|
||||||
on privacy
|
|
||||||
Allow the user to control how personal information is used
|
|
||||||
Empower user to control the data disclosure mechanism
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3.2 Non Run-time Quality Requirements
|
|
||||||
3.2.1 Evolvability Requirements
|
|
||||||
|
|
||||||
Rationale: The ability of the platform to withstand and easily adapt when new requirements and
|
|
||||||
changes is introduced.
|
|
||||||
Drivers: Ensure the platform is able to accommodate additional functionality and emerging
|
|
||||||
technologies at later stage at a fair and transparent cost
|
|
||||||
Refines into requirements: NFREQ.23
|
|
||||||
Relevance: Important for longer- lived and more widely used systems. Urban platforms are
|
|
||||||
expected to be highly evolvable in order to accommodate future emerging technologies and avoid
|
|
||||||
interoperability issues.
|
|
||||||
|
|
||||||
EVOLVABILITY MEASURES
|
|
||||||
Actions Characterize and assess the evolution needs
|
|
||||||
Consider the evolution trade-offs
|
|
||||||
Balance and negotiate potential conflicting requirements emerging from
|
|
||||||
the evolution capability.
|
|
||||||
Strategy Adopt standards and open interfaces
|
|
||||||
Design loose-coupled components
|
|
||||||
Preserve the platform resilience
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3.2.2 Extensibility Requirements
|
|
||||||
|
|
||||||
Rationale: The flexibility of the system to allow services and functionality to be extended and
|
|
||||||
augmented by service providers in order to increase value of services to both platform providers
|
|
||||||
and end-users. The extension of the platform services is determined by the Platform Openness
|
|
||||||
strategy.
|
|
||||||
Drivers: Stakeholders can extend the services provided by the urban platform, so that
|
|
||||||
partnerships can be built to deliver holistic and interoperable solutions. Identify integrated
|
|
||||||
approaches to design and service delivery which ensures that services fit together and that
|
|
||||||
synergies can be exploited.
|
|
||||||
Refines into requirements: NFREQ.24, NFREQ.25
|
|
||||||
Relevance: Important for longer- lived and more widely used systems. Urban platforms are
|
|
||||||
expected to be highly evolvable in order to accommodate future emerging technologies and avoid
|
|
||||||
interoperability issues.
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 38
|
|
||||||
|
|
||||||
|
|
||||||
EXTENSIBILITY
|
|
||||||
Actions Characterize and assess the extensibility needs
|
|
||||||
Trace the impacts and cost of extensions
|
|
||||||
Negotiate potential conflicting requirements emerging from adding extensions
|
|
||||||
in the platform
|
|
||||||
Strategy Develop a modular architecture with standard interfaces that reduces entry
|
|
||||||
barriers due to increased transparency and integration
|
|
||||||
Share technical information about interfaces will help service providers in
|
|
||||||
targeting opportunities around the platform
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3.3 List of Non-Functional Requirements
|
|
||||||
|
|
||||||
ID # Description Concern Priority Domain
|
|
||||||
|
|
||||||
Platform,
|
|
||||||
NFREQ.1 Support different service level agreements (SLA) Scalability Should
|
|
||||||
Infrastructure
|
|
||||||
Platform,
|
|
||||||
NFREQ.2 Process services and events on a set of distributed nodes Scalability Should
|
|
||||||
Infrastructure
|
|
||||||
Platform,
|
|
||||||
NFREQ.3 Continuously monitor Quality of Service at runtime Scalability Should
|
|
||||||
Infrastructure
|
|
||||||
Platform,
|
|
||||||
NFREQ.4 Balance its load at runtime Scalability Should
|
|
||||||
Infrastructure
|
|
||||||
Platform,
|
|
||||||
NFREQ.5 Provide high availability Availability Should
|
|
||||||
Infrastructure
|
|
||||||
Platform,
|
|
||||||
NFREQ.6 Guarantee infrastructure availability Availability Should
|
|
||||||
Infrastructure
|
|
||||||
Platform,
|
|
||||||
NFREQ.7 Ensure network availability Availability Should
|
|
||||||
Infrastructure
|
|
||||||
Platform,
|
|
||||||
NFREQ.8 Be able to perform self-healing Availability Should
|
|
||||||
Infrastructure
|
|
||||||
City Data,
|
|
||||||
NFREQ.9 Expose data and services to authorized users Security Must
|
|
||||||
Platform
|
|
||||||
City Data,
|
|
||||||
NFREQ.10 Ensure services are always accessible to entitled users Security Must
|
|
||||||
Platform
|
|
||||||
|
|
||||||
NFREQ.11 Ensure Data Freshness Security Must Platform
|
|
||||||
|
|
||||||
NFREQ.12 Support access control mechanisms Security Must Platform
|
|
||||||
|
|
||||||
Platform,
|
|
||||||
NFREQ.13 Have security mechanisms to protect data transmission Security Should
|
|
||||||
Infrastructure
|
|
||||||
Platform,
|
|
||||||
NFREQ.14 Make it difficult to spy on communicated messages Security Should
|
|
||||||
Infrastructure
|
|
||||||
Platform,
|
|
||||||
NFREQ.15 Be able to perform to detect threats at runtime Security Should
|
|
||||||
Infrastructure
|
|
||||||
Provide trusted and secure communication and information Platform,
|
|
||||||
NFREQ.16 Trust Should
|
|
||||||
management Infrastructure
|
|
||||||
|
|
||||||
NFREQ.17 The platform infrastructure and services shall be trustable Trust Should Infrastructure
|
|
||||||
|
|
||||||
Societal Needs,
|
|
||||||
NFREQ.18 Allow users to use free services anonymously Privacy Should
|
|
||||||
Platform
|
|
||||||
Societal Needs,
|
|
||||||
NFREQ.19 Allow people to use free services anonymously Privacy Should
|
|
||||||
Platform
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 39
|
|
||||||
|
|
||||||
|
|
||||||
Allow users to control which data they are willing to provide Societal Needs,
|
|
||||||
NFREQ.20 Privacy Should
|
|
||||||
and how their data should be used Platform
|
|
||||||
|
|
||||||
NFREQ.21 Keep users access-control rights/ policies secured. Privacy Should Platform
|
|
||||||
|
|
||||||
Provide privacy protection for users interacting with the Societal Needs,
|
|
||||||
NFREQ.22 Privacy Should
|
|
||||||
platform Platform
|
|
||||||
Platform,
|
|
||||||
NFREQ.23 Provide communication confidentiality Privacy Should
|
|
||||||
Infrastructure
|
|
||||||
Societal Needs,
|
|
||||||
NFREQ.24 Be extensible for future technologies. Evolvability Must City Data, Platform,
|
|
||||||
Infrastructure
|
|
||||||
Platform,
|
|
||||||
NFREQ.26 Provide standard interfaces for service providers Extensibility Should
|
|
||||||
Business Needs
|
|
||||||
|
|
||||||
NFREQ.26 Be able to provide services in an interoperable manner Extensibility Should Platform
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
4. Other Requirements
|
|
||||||
4.1 Minimal Descriptive Metadata Required from Data Provider
|
|
||||||
|
|
||||||
Minimal Descriptive Metadata Required from Data Provider
|
|
||||||
|
|
||||||
Attribute Required Definition Notes Examples
|
|
||||||
|
|
||||||
Database Name Y A name given to the resource
|
|
||||||
|
|
||||||
Author Name of a person or body associated with
|
|
||||||
Y
|
|
||||||
the creation of the resource
|
|
||||||
|
|
||||||
Maintainer Name of the entity responsible for making
|
|
||||||
Y
|
|
||||||
the resource available
|
|
||||||
|
|
||||||
Date Created Y Date the dataset was created
|
|
||||||
|
|
||||||
|
|
||||||
Date Modified Y Date the dataset was modified
|
|
||||||
|
|
||||||
|
|
||||||
Last Revision Y Date the dataset was last revised
|
|
||||||
|
|
||||||
|
|
||||||
Update Frequency Y Frequency of data maintenance
|
|
||||||
|
|
||||||
|
|
||||||
Mode of Release Y Open/Proprietary
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 40
|
|
||||||
|
|
||||||
|
|
||||||
4.2 Urban Platform Supported Data Formats
|
|
||||||
|
|
||||||
|
|
||||||
MIME type Description Extensions Level
|
|
||||||
|
|
||||||
application/json JavaScript Object Notation json supported
|
|
||||||
|
|
||||||
application/xml eXtensible markup language file xml supported
|
|
||||||
|
|
||||||
text/csv Comma separated values file csv supported
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
5. Conclusion & Forward Plans
|
|
||||||
This document represents the first set of requirements specification for urban platform. Future
|
|
||||||
activities will collaboratively assess, resolve requirements conflicts, prioritize, and validate the
|
|
||||||
requirements of the urban platform. Ultimately, this document will become a complete final
|
|
||||||
requirements specification document to guide and speed up the development open platform for
|
|
||||||
cities. Table below shows the milestones, deliverables and engagement activities completed and
|
|
||||||
yet to be completed by the Demand Side Engagement Stream.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Milestones, deliverables and engagement activity Forecast date Status
|
|
||||||
Stakeholders invitation Late Completed
|
|
||||||
September/2015
|
|
||||||
Online Workshop 1: Project Scope and Outcomes 20/11/2015 Completed
|
|
||||||
Online meeting held with city members to communicate goals,
|
|
||||||
expected outcomes, time commitment and required actions.
|
|
||||||
Start of Collaborative Requirements Engineering Process 20/11/2015 Completed
|
|
||||||
Participants reviewed the first draft of urban platform requirements,
|
|
||||||
provided comments, and suggested new requirements and changes.
|
|
||||||
Online Workshop 2: Review and Refine Requirements 08/12/2015 Completed
|
|
||||||
Participants had a conference call to review the requirements
|
|
||||||
specification document and provide their comments.
|
|
||||||
Deliverable: Requirements Specification Document v2.1 shared 05/01/2016 Completed
|
|
||||||
across the Working Streams for consultation
|
|
||||||
Online Workshop 3: Review Requirements Specification 12/01/2016 Completed
|
|
||||||
Document and discussion of Letter of Intent
|
|
||||||
Participants had a conference call to review the requirements
|
|
||||||
specification document and provide their comments, and discussed
|
|
||||||
the Letter of Intent to be signed by Mayor/Equiv of EU cities to
|
|
||||||
commit to application of common U.P. approach.
|
|
||||||
Urban Platforms Workshop in Brussels 19/01/2016 Completed
|
|
||||||
Participants provided feedback on the requirements specification
|
|
||||||
document and recommended changes in the document
|
|
||||||
Requirements Specification for Urban Platforms (EIP_SCC Initiative) Page 41
|
|
||||||
|
|
||||||
|
|
||||||
Deliverable: Updated Requirements Specification Document v2.2 29/01/2016 Completed
|
|
||||||
shared with Industry and Standardisation Working Streams
|
|
||||||
Deliverable: Online release of the Requirements Specification Early
|
|
||||||
Document and open calls for wider consultation on the Requirements February/2016
|
|
||||||
|
|
||||||
Online Workshop 4: Engagement with Industry Side Early
|
|
||||||
Capture industry feedback on the Requirements Specification February/2016
|
|
||||||
Document to assess its suitability to drive the development of the first
|
|
||||||
draft reference architecture.
|
|
||||||
Online Workshop 5: Review Requirements Specification Mid
|
|
||||||
Document v2.2 and discuss requirement prioritization and February/2016
|
|
||||||
balancing
|
|
||||||
Collectively determine which candidate requirements of urban
|
|
||||||
platforms should be prioritized as high importance to all the cities
|
|
||||||
(use of Value Oriented Prioritization Method). Requirements are also
|
|
||||||
prioritized to minimize risk during development of urban platforms so
|
|
||||||
that the most important requirements are made explicit and
|
|
||||||
considered in the reference architecture.
|
|
||||||
Capture of case studies from cities intended to support requirements Mid March/2016
|
|
||||||
validation (City of Porto), and learning and confidence building
|
|
||||||
amongst cities.
|
|
||||||
File diff suppressed because it is too large
Load Diff
7
go.mod
7
go.mod
@@ -4,6 +4,7 @@ go 1.23
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
|
github.com/openai/openai-go v1.12.0
|
||||||
modernc.org/sqlite v1.34.5
|
modernc.org/sqlite v1.34.5
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,7 +16,11 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/sys v0.22.0 // indirect
|
github.com/tidwall/gjson v1.14.4 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
modernc.org/libc v1.55.3 // indirect
|
modernc.org/libc v1.55.3 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
modernc.org/memory v1.8.0 // indirect
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
|||||||
16
go.sum
16
go.sum
@@ -16,8 +16,20 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
|
||||||
|
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
||||||
|
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
@@ -38,8 +50,8 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
|||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -46,3 +46,79 @@ func TestFormatRuntimeContextForPromptIncludesGOOS(t *testing.T) {
|
|||||||
t.Fatalf("expected runtime context contains GOOS=%s, got: %s", runtime.GOOS, doc)
|
t.Fatalf("expected runtime context contains GOOS=%s, got: %s", runtime.GOOS, doc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchSkillsByNameExact(t *testing.T) {
|
||||||
|
all := []knowledge.Skill{
|
||||||
|
{Name: "SAFe PI Planning", Content: "PI规划技能"},
|
||||||
|
{Name: "文件系统查询专家", Content: "文件查询"},
|
||||||
|
{Name: "代码生成", Content: "代码生成技能"},
|
||||||
|
}
|
||||||
|
matched := matchSkillsByName(all, []string{"SAFe PI Planning"})
|
||||||
|
if len(matched) != 1 {
|
||||||
|
t.Fatalf("expected 1 match, got %d", len(matched))
|
||||||
|
}
|
||||||
|
if matched[0].Name != "SAFe PI Planning" {
|
||||||
|
t.Fatalf("expected SAFe PI Planning, got %s", matched[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSkillsByNameFuzzy(t *testing.T) {
|
||||||
|
all := []knowledge.Skill{
|
||||||
|
{Name: "SAFe PI Planning", Content: "PI规划技能"},
|
||||||
|
{Name: "文件系统查询专家", Content: "文件查询"},
|
||||||
|
}
|
||||||
|
matched := matchSkillsByName(all, []string{"pi planning", "文件"})
|
||||||
|
if len(matched) != 2 {
|
||||||
|
t.Fatalf("expected 2 matches, got %d", len(matched))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSkillsByNameNoMatch(t *testing.T) {
|
||||||
|
all := []knowledge.Skill{
|
||||||
|
{Name: "文件系统查询专家", Content: "文件查询"},
|
||||||
|
}
|
||||||
|
matched := matchSkillsByName(all, []string{"不存在的技能"})
|
||||||
|
if len(matched) != 0 {
|
||||||
|
t.Fatalf("expected 0 matches, got %d", len(matched))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSkillsByNameEmpty(t *testing.T) {
|
||||||
|
matched := matchSkillsByName(nil, []string{"any"})
|
||||||
|
if len(matched) != 0 {
|
||||||
|
t.Fatalf("expected 0 matches, got %d", len(matched))
|
||||||
|
}
|
||||||
|
matched = matchSkillsByName([]knowledge.Skill{{Name: "test"}}, nil)
|
||||||
|
if len(matched) != 0 {
|
||||||
|
t.Fatalf("expected 0 matches, got %d", len(matched))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeUserFacingAnswerExtractsFinalAnswer(t *testing.T) {
|
||||||
|
raw := "Thought: 先分析用户问题\nObservation: 已经有足够信息\nFinal Answer: 这是给用户的结果"
|
||||||
|
got := sanitizeUserFacingAnswer(raw)
|
||||||
|
if got != "这是给用户的结果" {
|
||||||
|
t.Fatalf("expected final answer only, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeUserFacingAnswerDropsTraceLines(t *testing.T) {
|
||||||
|
raw := strings.Join([]string{
|
||||||
|
"Step 1 Thought: 检查上下文",
|
||||||
|
"Action: shell",
|
||||||
|
"Observation: ok",
|
||||||
|
"请执行以下变更。",
|
||||||
|
}, "\n")
|
||||||
|
got := sanitizeUserFacingAnswer(raw)
|
||||||
|
if got != "请执行以下变更。" {
|
||||||
|
t.Fatalf("expected user-facing text only, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeUserFacingAnswerKeepsNormalAnswer(t *testing.T) {
|
||||||
|
raw := "1. 先打开配置文件\n2. 修改端口后重启服务"
|
||||||
|
got := sanitizeUserFacingAnswer(raw)
|
||||||
|
if got != raw {
|
||||||
|
t.Fatalf("expected answer unchanged, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Config struct {
|
|||||||
ReactMaxSteps int
|
ReactMaxSteps int
|
||||||
ToolCallTimeoutSec int
|
ToolCallTimeoutSec int
|
||||||
ToolOutputMaxChars int
|
ToolOutputMaxChars int
|
||||||
|
PIPlanMaxChars int // PI 规划工具专用输出上限,独立于 TOOL_OUTPUT_MAX_CHARS
|
||||||
EnableCapabilityGap bool
|
EnableCapabilityGap bool
|
||||||
AutoSkillDir string
|
AutoSkillDir string
|
||||||
GapDraftTriggerCount int
|
GapDraftTriggerCount int
|
||||||
@@ -25,9 +26,11 @@ type Config struct {
|
|||||||
|
|
||||||
Telegram TelegramConfig
|
Telegram TelegramConfig
|
||||||
Feishu FeishuConfig
|
Feishu FeishuConfig
|
||||||
|
WebUI WebUIConfig
|
||||||
LLM LLMConfig
|
LLM LLMConfig
|
||||||
Security SecurityConfig
|
Security SecurityConfig
|
||||||
WebSearch WebSearchConfig
|
WebSearch WebSearchConfig
|
||||||
|
Gitea GiteaConfig
|
||||||
|
|
||||||
SQLitePath string
|
SQLitePath string
|
||||||
}
|
}
|
||||||
@@ -45,10 +48,18 @@ type FeishuConfig struct {
|
|||||||
EventPath string
|
EventPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WebUIConfig struct {
|
||||||
|
ListenAddr string
|
||||||
|
MaxUploadBytes int64
|
||||||
|
ExposeReasoning bool
|
||||||
|
}
|
||||||
|
|
||||||
type LLMConfig struct {
|
type LLMConfig struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
APIKey string
|
APIKey string
|
||||||
Model string
|
Model string
|
||||||
|
FileModel string
|
||||||
|
RouterModel string // 轻量路由模型,用于技能意图路由;为空则仅用关键词匹配
|
||||||
}
|
}
|
||||||
|
|
||||||
type SecurityConfig struct {
|
type SecurityConfig struct {
|
||||||
@@ -62,6 +73,13 @@ type WebSearchConfig struct {
|
|||||||
APIKey string
|
APIKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GiteaConfig struct {
|
||||||
|
BaseURL string // Gitea 实例地址
|
||||||
|
Token string // Personal Access Token
|
||||||
|
Owner string // 仓库所有者
|
||||||
|
Repo string // 仓库名称
|
||||||
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
agentWorkspaceDir := resolveAgentWorkspaceDir()
|
agentWorkspaceDir := resolveAgentWorkspaceDir()
|
||||||
if err := preloadEnvFiles(); err != nil {
|
if err := preloadEnvFiles(); err != nil {
|
||||||
@@ -78,6 +96,7 @@ func Load() (Config, error) {
|
|||||||
ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 0),
|
ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 0),
|
||||||
ToolCallTimeoutSec: intFromEnv("TOOL_CALL_TIMEOUT_SEC", 15),
|
ToolCallTimeoutSec: intFromEnv("TOOL_CALL_TIMEOUT_SEC", 15),
|
||||||
ToolOutputMaxChars: intFromEnv("TOOL_OUTPUT_MAX_CHARS", 4000),
|
ToolOutputMaxChars: intFromEnv("TOOL_OUTPUT_MAX_CHARS", 4000),
|
||||||
|
PIPlanMaxChars: intFromEnv("PI_PLAN_MAX_CHARS", 40000),
|
||||||
EnableCapabilityGap: boolFromEnv("ENABLE_CAPABILITY_GAP", true),
|
EnableCapabilityGap: boolFromEnv("ENABLE_CAPABILITY_GAP", true),
|
||||||
AutoSkillDir: defaultIfEmpty(os.Getenv("AUTO_SKILL_DIR"), filepath.Join(agentWorkspaceDir, "skills")),
|
AutoSkillDir: defaultIfEmpty(os.Getenv("AUTO_SKILL_DIR"), filepath.Join(agentWorkspaceDir, "skills")),
|
||||||
GapDraftTriggerCount: intFromEnv("GAP_DRAFT_TRIGGER_COUNT", 3),
|
GapDraftTriggerCount: intFromEnv("GAP_DRAFT_TRIGGER_COUNT", 3),
|
||||||
@@ -93,10 +112,17 @@ func Load() (Config, error) {
|
|||||||
ListenAddr: defaultIfEmpty(os.Getenv("FEISHU_LISTEN_ADDR"), ":8080"),
|
ListenAddr: defaultIfEmpty(os.Getenv("FEISHU_LISTEN_ADDR"), ":8080"),
|
||||||
EventPath: defaultIfEmpty(os.Getenv("FEISHU_EVENT_PATH"), "/feishu/events"),
|
EventPath: defaultIfEmpty(os.Getenv("FEISHU_EVENT_PATH"), "/feishu/events"),
|
||||||
},
|
},
|
||||||
|
WebUI: WebUIConfig{
|
||||||
|
ListenAddr: defaultIfEmpty(os.Getenv("WEBUI_LISTEN_ADDR"), ":8090"),
|
||||||
|
MaxUploadBytes: int64(intFromEnv("WEBUI_MAX_UPLOAD_MB", 20)) * 1024 * 1024,
|
||||||
|
ExposeReasoning: boolFromEnv("WEBUI_EXPOSE_REASONING", false),
|
||||||
|
},
|
||||||
LLM: LLMConfig{
|
LLM: LLMConfig{
|
||||||
BaseURL: strings.TrimRight(defaultIfEmpty(os.Getenv("LLM_BASE_URL"), "https://api.openai.com/v1"), "/"),
|
BaseURL: strings.TrimRight(defaultIfEmpty(os.Getenv("LLM_BASE_URL"), "https://api.openai.com/v1"), "/"),
|
||||||
APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")),
|
APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")),
|
||||||
Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"),
|
Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"),
|
||||||
|
FileModel: defaultIfEmpty(os.Getenv("LLM_FILE_MODEL"), defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini")),
|
||||||
|
RouterModel: strings.TrimSpace(os.Getenv("LLM_ROUTER_MODEL")),
|
||||||
},
|
},
|
||||||
SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), filepath.Join(defaultDataDir, "laodingbot.db")),
|
SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), filepath.Join(defaultDataDir, "laodingbot.db")),
|
||||||
WebSearch: WebSearchConfig{
|
WebSearch: WebSearchConfig{
|
||||||
@@ -108,12 +134,18 @@ func Load() (Config, error) {
|
|||||||
AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail,go")),
|
AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail,go")),
|
||||||
WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), defaultWorkSubdir),
|
WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), defaultWorkSubdir),
|
||||||
},
|
},
|
||||||
|
Gitea: GiteaConfig{
|
||||||
|
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("GITEA_BASE_URL")), "/"),
|
||||||
|
Token: strings.TrimSpace(os.Getenv("GITEA_TOKEN")),
|
||||||
|
Owner: strings.TrimSpace(os.Getenv("GITEA_OWNER")),
|
||||||
|
Repo: strings.TrimSpace(os.Getenv("GITEA_REPO")),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.MessageChannel = strings.ToLower(strings.TrimSpace(cfg.MessageChannel))
|
cfg.MessageChannel = strings.ToLower(strings.TrimSpace(cfg.MessageChannel))
|
||||||
cfg.LogLevel = strings.ToLower(strings.TrimSpace(cfg.LogLevel))
|
cfg.LogLevel = strings.ToLower(strings.TrimSpace(cfg.LogLevel))
|
||||||
if cfg.MessageChannel != "telegram" && cfg.MessageChannel != "feishu" {
|
if cfg.MessageChannel != "telegram" && cfg.MessageChannel != "feishu" && cfg.MessageChannel != "webui" {
|
||||||
return Config{}, fmt.Errorf("MESSAGE_CHANNEL must be telegram or feishu")
|
return Config{}, fmt.Errorf("MESSAGE_CHANNEL must be telegram, feishu, or webui")
|
||||||
}
|
}
|
||||||
if cfg.LogLevel != "debug" && cfg.LogLevel != "info" && cfg.LogLevel != "warn" && cfg.LogLevel != "error" {
|
if cfg.LogLevel != "debug" && cfg.LogLevel != "info" && cfg.LogLevel != "warn" && cfg.LogLevel != "error" {
|
||||||
return Config{}, fmt.Errorf("LOG_LEVEL must be debug, info, warn, or error")
|
return Config{}, fmt.Errorf("LOG_LEVEL must be debug, info, warn, or error")
|
||||||
@@ -127,12 +159,18 @@ func Load() (Config, error) {
|
|||||||
if cfg.ToolOutputMaxChars < 256 || cfg.ToolOutputMaxChars > 200000 {
|
if cfg.ToolOutputMaxChars < 256 || cfg.ToolOutputMaxChars > 200000 {
|
||||||
return Config{}, fmt.Errorf("TOOL_OUTPUT_MAX_CHARS must be between 256 and 200000")
|
return Config{}, fmt.Errorf("TOOL_OUTPUT_MAX_CHARS must be between 256 and 200000")
|
||||||
}
|
}
|
||||||
|
if cfg.PIPlanMaxChars < 1000 || cfg.PIPlanMaxChars > 500000 {
|
||||||
|
return Config{}, fmt.Errorf("PI_PLAN_MAX_CHARS must be between 1000 and 500000")
|
||||||
|
}
|
||||||
if cfg.GapDraftTriggerCount < 1 || cfg.GapDraftTriggerCount > 100 {
|
if cfg.GapDraftTriggerCount < 1 || cfg.GapDraftTriggerCount > 100 {
|
||||||
return Config{}, fmt.Errorf("GAP_DRAFT_TRIGGER_COUNT must be between 1 and 100")
|
return Config{}, fmt.Errorf("GAP_DRAFT_TRIGGER_COUNT must be between 1 and 100")
|
||||||
}
|
}
|
||||||
if cfg.GapClusterLookbackHours < 1 || cfg.GapClusterLookbackHours > 24*365 {
|
if cfg.GapClusterLookbackHours < 1 || cfg.GapClusterLookbackHours > 24*365 {
|
||||||
return Config{}, fmt.Errorf("GAP_CLUSTER_LOOKBACK_HOURS must be between 1 and 8760")
|
return Config{}, fmt.Errorf("GAP_CLUSTER_LOOKBACK_HOURS must be between 1 and 8760")
|
||||||
}
|
}
|
||||||
|
if cfg.WebUI.MaxUploadBytes < 1024 || cfg.WebUI.MaxUploadBytes > 200*1024*1024 {
|
||||||
|
return Config{}, fmt.Errorf("WEBUI_MAX_UPLOAD_MB must be between 1 and 200")
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.MessageChannel == "telegram" {
|
if cfg.MessageChannel == "telegram" {
|
||||||
if cfg.Telegram.Token == "" {
|
if cfg.Telegram.Token == "" {
|
||||||
@@ -152,6 +190,12 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.MessageChannel == "webui" {
|
||||||
|
if strings.TrimSpace(cfg.WebUI.ListenAddr) == "" {
|
||||||
|
return Config{}, fmt.Errorf("WEBUI_LISTEN_ADDR is required when MESSAGE_CHANNEL=webui")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.LLM.APIKey == "" {
|
if cfg.LLM.APIKey == "" {
|
||||||
return Config{}, fmt.Errorf("LLM_API_KEY is required")
|
return Config{}, fmt.Errorf("LLM_API_KEY is required")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,28 +5,75 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"laodingbot/internal/config"
|
"laodingbot/internal/config"
|
||||||
"laodingbot/internal/logger"
|
"laodingbot/internal/logger"
|
||||||
|
|
||||||
|
openai "github.com/openai/openai-go" // imported as openai
|
||||||
|
"github.com/openai/openai-go/option"
|
||||||
|
"github.com/openai/openai-go/packages/param"
|
||||||
|
"github.com/openai/openai-go/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
Generate(ctx context.Context, systemPrompt, userPrompt string) (string, error)
|
Generate(ctx context.Context, systemPrompt, userPrompt string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileChatClient interface {
|
type PromptMessage struct {
|
||||||
GenerateWithFiles(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string) (string, error)
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageChatClient interface {
|
||||||
|
GenerateMessages(ctx context.Context, messages []PromptMessage) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileUploader interface {
|
type FileUploader interface {
|
||||||
UploadFile(ctx context.Context, file InputFile, purpose string) (string, error)
|
UploadFile(ctx context.Context, file InputFile, purpose string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToolCallChatClient 支持原生 function calling 的 LLM 客户端接口。
|
||||||
|
type ToolCallChatClient interface {
|
||||||
|
GenerateWithTools(ctx context.Context, messages []PromptMessage, tools []ToolDefinition) (*ChatCompletion, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolDefinition 描述一个可供 LLM 调用的工具函数定义。
|
||||||
|
type ToolDefinition struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function ToolFunctionDef `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolFunctionDef 是工具函数的名称、描述和参数 JSON Schema。
|
||||||
|
type ToolFunctionDef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCall 是 LLM 在响应中返回的工具调用请求。
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function ToolCallFunction `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCallFunction 包含工具调用的函数名和参数。
|
||||||
|
type ToolCallFunction struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatCompletion 是 LLM 响应的结构化表示,包含文本内容和可选的工具调用。
|
||||||
|
type ChatCompletion struct {
|
||||||
|
Content string
|
||||||
|
ToolCalls []ToolCall
|
||||||
|
}
|
||||||
|
|
||||||
type InputFile struct {
|
type InputFile struct {
|
||||||
FileName string
|
FileName string
|
||||||
MimeType string
|
MimeType string
|
||||||
@@ -34,178 +81,258 @@ type InputFile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OpenAICompatibleClient struct {
|
type OpenAICompatibleClient struct {
|
||||||
baseURL string
|
client openai.Client
|
||||||
apiKey string
|
model string
|
||||||
model string
|
disableThinkingParam bool
|
||||||
http *http.Client
|
log *logger.Logger
|
||||||
log *logger.Logger
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAICompatibleClient {
|
func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAICompatibleClient {
|
||||||
return &OpenAICompatibleClient{
|
opts := []option.RequestOption{
|
||||||
baseURL: cfg.BaseURL,
|
option.WithAPIKey(cfg.APIKey),
|
||||||
apiKey: cfg.APIKey,
|
option.WithRequestTimeout(60 * time.Second),
|
||||||
model: cfg.Model,
|
}
|
||||||
http: &http.Client{Timeout: 60 * time.Second},
|
if strings.TrimSpace(cfg.BaseURL) != "" {
|
||||||
log: log,
|
opts = append(opts, option.WithBaseURL(cfg.BaseURL))
|
||||||
|
}
|
||||||
|
return &OpenAICompatibleClient{
|
||||||
|
client: openai.NewClient(opts...),
|
||||||
|
model: cfg.Model,
|
||||||
|
disableThinkingParam: shouldDisableThinkingParam(cfg.BaseURL),
|
||||||
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
type chatRequest struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Messages []chatMessage `json:"messages"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type chatMessage struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content any `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type chatContentPart struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text,omitempty"`
|
|
||||||
FileID string `json:"file_id,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type chatResponse struct {
|
|
||||||
Choices []struct {
|
|
||||||
Message struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"message"`
|
|
||||||
} `json:"choices"`
|
|
||||||
Error *struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type fileUploadResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Bytes int64 `json:"bytes,omitempty"`
|
|
||||||
CreatedAt int64 `json:"created_at,omitempty"`
|
|
||||||
Filename string `json:"filename,omitempty"`
|
|
||||||
Object string `json:"object,omitempty"`
|
|
||||||
Purpose string `json:"purpose,omitempty"`
|
|
||||||
Code int `json:"code,omitempty"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
Status any `json:"status,omitempty"`
|
|
||||||
StatusDetails any `json:"status_details,omitempty"`
|
|
||||||
Data *struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
} `json:"data,omitempty"`
|
|
||||||
Error *struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"error,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenAICompatibleClient) Generate(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
func (c *OpenAICompatibleClient) Generate(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
||||||
return c.generateInternal(ctx, systemPrompt, userPrompt, nil)
|
messages := []PromptMessage{
|
||||||
|
{Role: "system", Content: systemPrompt},
|
||||||
|
{Role: "user", Content: userPrompt},
|
||||||
|
}
|
||||||
|
return c.generateWithMessagesInternal(ctx, messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenAICompatibleClient) GenerateWithFiles(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string) (string, error) {
|
func (c *OpenAICompatibleClient) GenerateMessages(ctx context.Context, messages []PromptMessage) (string, error) {
|
||||||
return c.generateInternal(ctx, systemPrompt, userPrompt, fileIDs)
|
return c.generateWithMessagesInternal(ctx, messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenAICompatibleClient) generateInternal(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string) (string, error) {
|
// GenerateWithTools 使用原生 function calling 发送请求,返回结构化的 ChatCompletion。
|
||||||
|
func (c *OpenAICompatibleClient) GenerateWithTools(ctx context.Context, messages []PromptMessage, tools []ToolDefinition) (*ChatCompletion, error) {
|
||||||
|
model := c.model
|
||||||
|
|
||||||
|
sdkMessages := buildSDKMessages(messages)
|
||||||
|
sdkTools := toSDKTools(tools)
|
||||||
|
|
||||||
if c.log != nil {
|
if c.log != nil {
|
||||||
c.log.Debugf("llm request start model=%s system_len=%d user_len=%d file_count=%d", c.model, len(systemPrompt), len(userPrompt), len(fileIDs))
|
c.log.Debugf("llm tool-call request start model=%s messages=%d tools=%d", model, len(sdkMessages), len(sdkTools))
|
||||||
}
|
}
|
||||||
userContent := buildUserContent(userPrompt, fileIDs)
|
|
||||||
body := chatRequest{
|
params := openai.ChatCompletionNewParams{
|
||||||
Model: c.model,
|
Model: shared.ChatModel(model),
|
||||||
Messages: []chatMessage{
|
Messages: sdkMessages,
|
||||||
{Role: "system", Content: systemPrompt},
|
|
||||||
{Role: "user", Content: userContent},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
b, err := json.Marshal(body)
|
if len(sdkTools) > 0 {
|
||||||
|
params.Tools = sdkTools
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.log != nil {
|
||||||
|
if b, err := json.Marshal(params); err == nil {
|
||||||
|
c.log.Debugf("llm tool-call request params: %s", string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Chat.Completions.New(ctx, params, c.chatCompletionRequestOptions()...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("llm tool-call request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("llm returned empty choices")
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
resultToolCalls := fromSDKToolCalls(choice.Message.ToolCalls)
|
||||||
|
if c.log != nil {
|
||||||
|
toolNames := make([]string, 0, len(resultToolCalls))
|
||||||
|
for _, tc := range resultToolCalls {
|
||||||
|
toolNames = append(toolNames, tc.Function.Name)
|
||||||
|
}
|
||||||
|
c.log.Infof("llm tool-call response success model=%s content_len=%d tool_calls=%d names=%v finish=%s",
|
||||||
|
model, len(choice.Message.Content), len(resultToolCalls), toolNames, choice.FinishReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ChatCompletion{
|
||||||
|
Content: choice.Message.Content,
|
||||||
|
ToolCalls: resultToolCalls,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Context, messages []PromptMessage) (string, error) {
|
||||||
|
model := c.model
|
||||||
|
|
||||||
|
baseMessages := normalizePromptMessages(messages)
|
||||||
|
if len(baseMessages) == 0 {
|
||||||
|
baseMessages = []PromptMessage{{Role: "user", Content: ""}}
|
||||||
|
}
|
||||||
|
|
||||||
|
systemLen, userLen := promptMessageLengths(baseMessages)
|
||||||
|
if c.log != nil {
|
||||||
|
c.log.Debugf("llm request start model=%s system_len=%d user_len=%d", model, systemLen, userLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
sdkMessages := buildSDKMessages(baseMessages)
|
||||||
|
|
||||||
|
params := openai.ChatCompletionNewParams{
|
||||||
|
Model: shared.ChatModel(model),
|
||||||
|
Messages: sdkMessages,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Chat.Completions.New(ctx, params, c.chatCompletionRequestOptions()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if c.log != nil {
|
if c.log != nil {
|
||||||
c.log.Errorf("marshal llm request failed err=%v", err)
|
c.log.Errorf("llm request failed err=%v", err)
|
||||||
}
|
}
|
||||||
return "", err
|
return "", fmt.Errorf("llm request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimRight(c.baseURL, "/") + "/chat/completions"
|
if len(resp.Choices) == 0 {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
|
||||||
if err != nil {
|
|
||||||
if c.log != nil {
|
if c.log != nil {
|
||||||
c.log.Errorf("build llm request failed err=%v", err)
|
c.log.Errorf("llm returned empty choices")
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if c.log != nil {
|
|
||||||
c.log.Errorf("llm http request failed err=%v", err)
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
if c.log != nil {
|
|
||||||
c.log.Errorf("llm read response failed err=%v", err)
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var out chatResponse
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
if c.log != nil {
|
|
||||||
c.log.Errorf("llm response unmarshal failed status=%d err=%v", resp.StatusCode, err)
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
if c.log != nil {
|
|
||||||
c.log.Errorf("llm bad status=%d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if out.Error != nil && out.Error.Message != "" {
|
|
||||||
return "", fmt.Errorf("llm error: %s", out.Error.Message)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("llm error status: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(out.Choices) == 0 {
|
|
||||||
if c.log != nil {
|
|
||||||
c.log.Errorf("llm returned empty choices status=%d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("llm returned empty choices")
|
return "", fmt.Errorf("llm returned empty choices")
|
||||||
}
|
}
|
||||||
if c.log != nil {
|
|
||||||
c.log.Infof("llm response success model=%s output_len=%d", c.model, len(out.Choices[0].Message.Content))
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.Choices[0].Message.Content, nil
|
content := resp.Choices[0].Message.Content
|
||||||
|
if c.log != nil {
|
||||||
|
c.log.Infof("llm response success model=%s output_len=%d", model, len(content))
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildUserContent(userPrompt string, fileIDs []string) any {
|
// buildSDKMessages 将 PromptMessage 列表转换为 openai SDK 的消息格式。
|
||||||
trimmedPrompt := strings.TrimSpace(userPrompt)
|
func buildSDKMessages(base []PromptMessage) []openai.ChatCompletionMessageParamUnion {
|
||||||
if len(fileIDs) == 0 {
|
out := make([]openai.ChatCompletionMessageParamUnion, 0, len(base))
|
||||||
return userPrompt
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := make([]chatContentPart, 0, len(fileIDs)+1)
|
for _, m := range base {
|
||||||
if trimmedPrompt != "" {
|
role := normalizeRole(m.Role)
|
||||||
parts = append(parts, chatContentPart{Type: "text", Text: userPrompt})
|
if role == "" {
|
||||||
}
|
|
||||||
for _, id := range fileIDs {
|
|
||||||
id = strings.TrimSpace(id)
|
|
||||||
if id == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts = append(parts, chatContentPart{Type: "file", FileID: id})
|
out = append(out, toSDKMessage(m, role))
|
||||||
}
|
}
|
||||||
if len(parts) == 0 {
|
|
||||||
return userPrompt
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// toSDKMessage 将单个 PromptMessage 转换为 openai SDK 消息类型。
|
||||||
|
func toSDKMessage(m PromptMessage, role string) openai.ChatCompletionMessageParamUnion {
|
||||||
|
switch role {
|
||||||
|
case "system":
|
||||||
|
return openai.SystemMessage(m.Content)
|
||||||
|
case "user":
|
||||||
|
return openai.UserMessage(m.Content)
|
||||||
|
case "assistant":
|
||||||
|
if len(m.ToolCalls) > 0 {
|
||||||
|
sdkToolCalls := make([]openai.ChatCompletionMessageToolCallParam, 0, len(m.ToolCalls))
|
||||||
|
for _, tc := range m.ToolCalls {
|
||||||
|
sdkToolCalls = append(sdkToolCalls, openai.ChatCompletionMessageToolCallParam{
|
||||||
|
ID: tc.ID,
|
||||||
|
Function: openai.ChatCompletionMessageToolCallFunctionParam{
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: tc.Function.Arguments,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
msg := openai.AssistantMessage(m.Content)
|
||||||
|
msg.OfAssistant.ToolCalls = sdkToolCalls
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return openai.AssistantMessage(m.Content)
|
||||||
|
case "tool":
|
||||||
|
return openai.ToolMessage(m.Content, m.ToolCallID)
|
||||||
|
default:
|
||||||
|
return openai.UserMessage(m.Content)
|
||||||
}
|
}
|
||||||
return parts
|
}
|
||||||
|
|
||||||
|
// toSDKTools 将内部 ToolDefinition 列表转换为 openai SDK 的 ChatCompletionToolParam 列表。
|
||||||
|
func toSDKTools(tools []ToolDefinition) []openai.ChatCompletionToolParam {
|
||||||
|
if len(tools) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]openai.ChatCompletionToolParam, 0, len(tools))
|
||||||
|
for _, t := range tools {
|
||||||
|
var params shared.FunctionParameters
|
||||||
|
if len(t.Function.Parameters) > 0 {
|
||||||
|
_ = json.Unmarshal(t.Function.Parameters, ¶ms)
|
||||||
|
}
|
||||||
|
out = append(out, openai.ChatCompletionToolParam{
|
||||||
|
Function: shared.FunctionDefinitionParam{
|
||||||
|
Name: t.Function.Name,
|
||||||
|
Description: param.NewOpt(t.Function.Description),
|
||||||
|
Parameters: params,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fromSDKToolCalls 将 openai SDK 响应中的 tool calls 转换为内部 ToolCall 类型。
|
||||||
|
func fromSDKToolCalls(sdkCalls []openai.ChatCompletionMessageToolCall) []ToolCall {
|
||||||
|
if len(sdkCalls) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]ToolCall, 0, len(sdkCalls))
|
||||||
|
for _, tc := range sdkCalls {
|
||||||
|
out = append(out, ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolCallFunction{
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: tc.Function.Arguments,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePromptMessages(messages []PromptMessage) []PromptMessage {
|
||||||
|
out := make([]PromptMessage, 0, len(messages))
|
||||||
|
for _, m := range messages {
|
||||||
|
role := normalizeRole(m.Role)
|
||||||
|
if role == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, PromptMessage{
|
||||||
|
Role: role,
|
||||||
|
Content: m.Content,
|
||||||
|
ToolCalls: m.ToolCalls,
|
||||||
|
ToolCallID: m.ToolCallID,
|
||||||
|
Name: m.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRole(role string) string {
|
||||||
|
r := strings.ToLower(strings.TrimSpace(role))
|
||||||
|
if r != "system" && r != "user" && r != "assistant" && r != "tool" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptMessageLengths(messages []PromptMessage) (int, int) {
|
||||||
|
systemLen := 0
|
||||||
|
userLen := 0
|
||||||
|
for _, m := range messages {
|
||||||
|
switch normalizeRole(m.Role) {
|
||||||
|
case "system":
|
||||||
|
systemLen += len(m.Content)
|
||||||
|
case "user":
|
||||||
|
userLen += len(m.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return systemLen, userLen
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenAICompatibleClient) UploadFile(ctx context.Context, file InputFile, purpose string) (string, error) {
|
func (c *OpenAICompatibleClient) UploadFile(ctx context.Context, file InputFile, purpose string) (string, error) {
|
||||||
@@ -220,7 +347,6 @@ func (c *OpenAICompatibleClient) UploadFile(ctx context.Context, file InputFile,
|
|||||||
if purpose != "" {
|
if purpose != "" {
|
||||||
purposes = append(purposes, purpose)
|
purposes = append(purposes, purpose)
|
||||||
}
|
}
|
||||||
// Provider compatibility fallback order.
|
|
||||||
purposes = appendIfMissing(purposes, "file-extract")
|
purposes = appendIfMissing(purposes, "file-extract")
|
||||||
purposes = appendIfMissing(purposes, "batch")
|
purposes = appendIfMissing(purposes, "batch")
|
||||||
|
|
||||||
@@ -242,77 +368,24 @@ func (c *OpenAICompatibleClient) UploadFile(ctx context.Context, file InputFile,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenAICompatibleClient) uploadFileOnce(ctx context.Context, file InputFile, purpose string) (string, error) {
|
func (c *OpenAICompatibleClient) uploadFileOnce(ctx context.Context, file InputFile, purpose string) (string, error) {
|
||||||
|
resp, err := c.client.Files.New(ctx, openai.FileNewParams{
|
||||||
body := &bytes.Buffer{}
|
File: bytes.NewReader(file.Content),
|
||||||
writer := multipart.NewWriter(body)
|
Purpose: openai.FilePurpose(purpose),
|
||||||
if err := writer.WriteField("purpose", purpose); err != nil {
|
})
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
part, err := writer.CreateFormFile("file", file.FileName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", fmt.Errorf("llm file upload failed: %w", err)
|
||||||
}
|
|
||||||
if _, err := part.Write(file.Content); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := writer.Close(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimRight(c.baseURL, "/") + "/files"
|
fileID := strings.TrimSpace(resp.ID)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var out fileUploadResponse
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return "", fmt.Errorf("llm file upload response decode failed: %w body=%s", err, clipForError(raw))
|
|
||||||
}
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
if strings.TrimSpace(out.Message) != "" {
|
|
||||||
return "", fmt.Errorf("llm file upload error: %s", out.Message)
|
|
||||||
}
|
|
||||||
if out.Error != nil && out.Error.Message != "" {
|
|
||||||
return "", fmt.Errorf("llm file upload error: %s", out.Error.Message)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("llm file upload status: %d body=%s", resp.StatusCode, clipForError(raw))
|
|
||||||
}
|
|
||||||
fileID := strings.TrimSpace(out.ID)
|
|
||||||
if fileID == "" && out.Data != nil {
|
|
||||||
fileID = strings.TrimSpace(out.Data.ID)
|
|
||||||
}
|
|
||||||
if fileID == "" {
|
if fileID == "" {
|
||||||
return "", fmt.Errorf("llm file upload returned empty file id body=%s", clipForError(raw))
|
return "", fmt.Errorf("llm file upload returned empty file id")
|
||||||
}
|
}
|
||||||
if c.log != nil {
|
if c.log != nil {
|
||||||
c.log.Infof("llm file uploaded name=%s size=%d file_id=%s purpose=%s status=%v", file.FileName, len(file.Content), fileID, purpose, out.Status)
|
c.log.Infof("llm file uploaded name=%s size=%d file_id=%s purpose=%s", file.FileName, len(file.Content), fileID, purpose)
|
||||||
}
|
}
|
||||||
return fileID, nil
|
return fileID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func clipForError(raw []byte) string {
|
|
||||||
s := strings.TrimSpace(string(raw))
|
|
||||||
const max = 400
|
|
||||||
if len(s) <= max {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:max] + "...(truncated)"
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendIfMissing(items []string, value string) []string {
|
func appendIfMissing(items []string, value string) []string {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -325,3 +398,18 @@ func appendIfMissing(items []string, value string) []string {
|
|||||||
}
|
}
|
||||||
return append(items, value)
|
return append(items, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *OpenAICompatibleClient) chatCompletionRequestOptions() []option.RequestOption {
|
||||||
|
if !c.disableThinkingParam {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []option.RequestOption{option.WithJSONSet("enable_thinking", false)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldDisableThinkingParam(baseURL string) bool {
|
||||||
|
baseURL = strings.ToLower(strings.TrimSpace(baseURL))
|
||||||
|
if baseURL == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(baseURL, "dashscope.aliyuncs.com")
|
||||||
|
}
|
||||||
|
|||||||
24
internal/llm/client_test.go
Normal file
24
internal/llm/client_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestShouldDisableThinkingParam(t *testing.T) {
|
||||||
|
if !shouldDisableThinkingParam("https://dashscope.aliyuncs.com/compatible-mode/v1") {
|
||||||
|
t.Fatal("expected DashScope base URL to require enable_thinking=false")
|
||||||
|
}
|
||||||
|
if shouldDisableThinkingParam("https://api.openai.com/v1") {
|
||||||
|
t.Fatal("expected standard OpenAI base URL not to require enable_thinking=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatCompletionRequestOptions(t *testing.T) {
|
||||||
|
client := &OpenAICompatibleClient{disableThinkingParam: true}
|
||||||
|
if got := len(client.chatCompletionRequestOptions()); got != 1 {
|
||||||
|
t.Fatalf("expected 1 request option when disableThinkingParam=true, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.disableThinkingParam = false
|
||||||
|
if got := len(client.chatCompletionRequestOptions()); got != 0 {
|
||||||
|
t.Fatalf("expected 0 request options when disableThinkingParam=false, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,11 @@ import (
|
|||||||
"laodingbot/internal/config"
|
"laodingbot/internal/config"
|
||||||
"laodingbot/internal/logger"
|
"laodingbot/internal/logger"
|
||||||
"laodingbot/internal/tools"
|
"laodingbot/internal/tools"
|
||||||
|
"laodingbot/tools/filedoc"
|
||||||
"laodingbot/tools/fileoperation"
|
"laodingbot/tools/fileoperation"
|
||||||
"laodingbot/tools/git"
|
"laodingbot/tools/git"
|
||||||
|
"laodingbot/tools/giteaticket"
|
||||||
|
"laodingbot/tools/piplan"
|
||||||
"laodingbot/tools/shell"
|
"laodingbot/tools/shell"
|
||||||
"laodingbot/tools/websearch"
|
"laodingbot/tools/websearch"
|
||||||
)
|
)
|
||||||
@@ -20,6 +23,9 @@ func RunChild(ctx context.Context, cfg config.Config, log *logger.Logger) error
|
|||||||
var gitLog *logger.Logger
|
var gitLog *logger.Logger
|
||||||
var shellLog *logger.Logger
|
var shellLog *logger.Logger
|
||||||
var searchLog *logger.Logger
|
var searchLog *logger.Logger
|
||||||
|
var fileDocLog *logger.Logger
|
||||||
|
var piPlanLog *logger.Logger
|
||||||
|
var giteaTicketLog *logger.Logger
|
||||||
var serverLog *logger.Logger
|
var serverLog *logger.Logger
|
||||||
if log != nil {
|
if log != nil {
|
||||||
log.Infof("toolhost child starting")
|
log.Infof("toolhost child starting")
|
||||||
@@ -28,6 +34,9 @@ func RunChild(ctx context.Context, cfg config.Config, log *logger.Logger) error
|
|||||||
gitLog = log.WithComponent("toolhost.git")
|
gitLog = log.WithComponent("toolhost.git")
|
||||||
shellLog = log.WithComponent("toolhost.shell")
|
shellLog = log.WithComponent("toolhost.shell")
|
||||||
searchLog = log.WithComponent("toolhost.websearch")
|
searchLog = log.WithComponent("toolhost.websearch")
|
||||||
|
fileDocLog = log.WithComponent("toolhost.filedoc")
|
||||||
|
piPlanLog = log.WithComponent("toolhost.piplan")
|
||||||
|
giteaTicketLog = log.WithComponent("toolhost.giteaticket")
|
||||||
serverLog = log.WithComponent("toolhost.server")
|
serverLog = log.WithComponent("toolhost.server")
|
||||||
}
|
}
|
||||||
registry := tools.NewRegistry(registryLog)
|
registry := tools.NewRegistry(registryLog)
|
||||||
@@ -53,6 +62,27 @@ func RunChild(ctx context.Context, cfg config.Config, log *logger.Logger) error
|
|||||||
cfg.ToolOutputMaxChars,
|
cfg.ToolOutputMaxChars,
|
||||||
searchLog,
|
searchLog,
|
||||||
))
|
))
|
||||||
|
registry.Register(filedoc.New(
|
||||||
|
filedoc.Config{
|
||||||
|
APIKey: cfg.LLM.APIKey,
|
||||||
|
BaseURL: cfg.LLM.BaseURL,
|
||||||
|
Model: cfg.LLM.FileModel,
|
||||||
|
Timeout: time.Duration(cfg.ToolCallTimeoutSec) * time.Second,
|
||||||
|
},
|
||||||
|
cfg.ToolOutputMaxChars,
|
||||||
|
fileDocLog,
|
||||||
|
))
|
||||||
|
registry.Register(piplan.New(cfg.PIPlanMaxChars, piPlanLog))
|
||||||
|
registry.Register(giteaticket.New(
|
||||||
|
giteaticket.Config{
|
||||||
|
BaseURL: cfg.Gitea.BaseURL,
|
||||||
|
Token: cfg.Gitea.Token,
|
||||||
|
Owner: cfg.Gitea.Owner,
|
||||||
|
Repo: cfg.Gitea.Repo,
|
||||||
|
Timeout: time.Duration(cfg.ToolCallTimeoutSec) * time.Second,
|
||||||
|
},
|
||||||
|
giteaTicketLog,
|
||||||
|
))
|
||||||
|
|
||||||
server := NewServer(registry, serverLog)
|
server := NewServer(registry, serverLog)
|
||||||
if err := server.Serve(ctx, stdin(), stdout()); err != nil && ctx.Err() == nil {
|
if err := server.Serve(ctx, stdin(), stdout()); err != nil && ctx.Err() == nil {
|
||||||
|
|||||||
492
internal/transport/webui/bot.go
Normal file
492
internal/transport/webui/bot.go
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"laodingbot/internal/config"
|
||||||
|
"laodingbot/internal/llm"
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
"laodingbot/internal/memory"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IncomingMessage struct {
|
||||||
|
ChatID string
|
||||||
|
UserID string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamEventType 定义流式输出的事件类型
|
||||||
|
type StreamEventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StreamEventTypeThought StreamEventType = "thought" // LLM 思考过程
|
||||||
|
StreamEventTypeToolCall StreamEventType = "tool_call" // 工具调用请求
|
||||||
|
StreamEventTypeToolResult StreamEventType = "tool_result" // 工具执行结果
|
||||||
|
StreamEventTypeFinal StreamEventType = "final" // 最终答案
|
||||||
|
StreamEventTypeError StreamEventType = "error" // 错误信息
|
||||||
|
|
||||||
|
StreamEventTypeWorkspaceStart StreamEventType = "workspace_start" // 工具渲染开始
|
||||||
|
StreamEventTypeWorkspaceDelta StreamEventType = "workspace_delta" // 工具渲染增量内容
|
||||||
|
StreamEventTypeWorkspaceEnd StreamEventType = "workspace_end" // 工具渲染结束
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreamEvent 代表流式输出中的一个事件
|
||||||
|
type StreamEvent struct {
|
||||||
|
Type StreamEventType `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Step int `json:"step,omitempty"`
|
||||||
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
|
WorkspaceTitle string `json:"workspace_title,omitempty"` // 仅用于 workspace_start 类型
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatHandler func(context.Context, IncomingMessage) (string, error)
|
||||||
|
type StreamChatHandler func(context.Context, IncomingMessage, StreamEventCallback) (string, error)
|
||||||
|
type StreamEventCallback func(event StreamEvent) error
|
||||||
|
type UploadHandler func(context.Context, string, string, []llm.InputFile) ([]string, error)
|
||||||
|
type HistoryHandler func(context.Context, string, int) ([]memory.Message, error)
|
||||||
|
|
||||||
|
type Bot struct {
|
||||||
|
listenAddr string
|
||||||
|
maxUploadBytes int64
|
||||||
|
log *logger.Logger
|
||||||
|
|
||||||
|
chatHandler ChatHandler
|
||||||
|
streamChatHandler StreamChatHandler
|
||||||
|
uploadHandler UploadHandler
|
||||||
|
historyHandler HistoryHandler
|
||||||
|
counter uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *chatRequest) UnmarshalJSON(data []byte) error {
|
||||||
|
type rawChatRequest struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
SessionIDCamel string `json:"sessionId"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
UserIDCamel string `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw rawChatRequest
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Text = raw.Text
|
||||||
|
r.SessionID = firstNonEmpty(raw.SessionID, raw.SessionIDCamel)
|
||||||
|
r.UserID = firstNonEmpty(raw.UserID, raw.UserIDCamel)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Reply string `json:"reply"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadResponse struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
FileIDs []string `json:"file_ids"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
SizeBytes int `json:"size_bytes"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBot(cfg config.WebUIConfig, log *logger.Logger) (*Bot, error) {
|
||||||
|
if strings.TrimSpace(cfg.ListenAddr) == "" {
|
||||||
|
return nil, fmt.Errorf("empty webui listen address")
|
||||||
|
}
|
||||||
|
if cfg.MaxUploadBytes <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid webui max upload bytes")
|
||||||
|
}
|
||||||
|
return &Bot{
|
||||||
|
listenAddr: strings.TrimSpace(cfg.ListenAddr),
|
||||||
|
maxUploadBytes: cfg.MaxUploadBytes,
|
||||||
|
log: log,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) Run(ctx context.Context, chatHandler ChatHandler, streamChatHandler StreamChatHandler, uploadHandler UploadHandler, historyHandler HistoryHandler) error {
|
||||||
|
if chatHandler == nil {
|
||||||
|
return fmt.Errorf("nil webui chat handler")
|
||||||
|
}
|
||||||
|
if uploadHandler == nil {
|
||||||
|
return fmt.Errorf("nil webui upload handler")
|
||||||
|
}
|
||||||
|
b.chatHandler = chatHandler
|
||||||
|
b.streamChatHandler = streamChatHandler
|
||||||
|
b.uploadHandler = uploadHandler
|
||||||
|
b.historyHandler = historyHandler
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/chat", b.handleChat)
|
||||||
|
mux.HandleFunc("/api/chat/stream", b.handleChatStream)
|
||||||
|
mux.HandleFunc("/api/upload", b.handleUpload)
|
||||||
|
mux.HandleFunc("/api/history", b.handleHistory)
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: b.listenAddr,
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
err := srv.ListenAndServe()
|
||||||
|
if err != nil && err != http.ErrServerClosed {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errCh <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Infof("webui http transport started addr=%s", b.listenAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = srv.Shutdown(shutdownCtx)
|
||||||
|
err := <-errCh
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Infof("webui http transport stopped: %v", ctx.Err())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
case err := <-errCh:
|
||||||
|
if err != nil && b.log != nil {
|
||||||
|
b.log.Errorf("webui http transport failed err=%v", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))), "application/json") {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "content-type must be application/json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.chatHandler == nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "chat handler not ready"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req chatRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid json body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Text = strings.TrimSpace(req.Text)
|
||||||
|
if req.Text == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "text is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionID := b.resolveID(req.SessionID, "sess")
|
||||||
|
userID := b.resolveID(req.UserID, "user")
|
||||||
|
|
||||||
|
reply, err := b.chatHandler(r.Context(), IncomingMessage{
|
||||||
|
ChatID: sessionID,
|
||||||
|
UserID: userID,
|
||||||
|
Text: req.Text,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Errorf("webui chat handler failed session_id=%s user_id=%s err=%v", sessionID, userID, err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "chat failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, chatResponse{
|
||||||
|
Reply: reply,
|
||||||
|
SessionID: sessionID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.historyHandler == nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "history handler not ready"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
|
||||||
|
if sessionID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "session_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limitStr := strings.TrimSpace(r.URL.Query().Get("limit"))
|
||||||
|
limit := 20
|
||||||
|
if limitStr != "" {
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := b.historyHandler(r.Context(), sessionID, limit)
|
||||||
|
if err != nil {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Errorf("webui history handler failed session_id=%s err=%v", sessionID, err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "load history failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, history)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
|
||||||
|
for _, v := range vals {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleChatStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))), "application/json") {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "content-type must be application/json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.streamChatHandler == nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "stream chat handler not ready"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req chatRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid json body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Text = strings.TrimSpace(req.Text)
|
||||||
|
if req.Text == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "text is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionID := b.resolveID(req.SessionID, "sess")
|
||||||
|
userID := b.resolveID(req.UserID, "user")
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建回调函数来推送 SSE 事件
|
||||||
|
callback := func(event StreamEvent) error {
|
||||||
|
data, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", string(data))
|
||||||
|
flusher.Flush()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用流式处理器
|
||||||
|
reply, err := b.streamChatHandler(r.Context(), IncomingMessage{
|
||||||
|
ChatID: sessionID,
|
||||||
|
UserID: userID,
|
||||||
|
Text: req.Text,
|
||||||
|
}, callback)
|
||||||
|
if err != nil {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Errorf("webui stream chat handler failed session_id=%s user_id=%s err=%v", sessionID, userID, err)
|
||||||
|
}
|
||||||
|
// 推送错误事件
|
||||||
|
errEvent := StreamEvent{
|
||||||
|
Type: StreamEventTypeError,
|
||||||
|
Content: "stream error: " + err.Error(),
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(errEvent)
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", string(data))
|
||||||
|
flusher.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Infof("webui stream chat completed session_id=%s user_id=%s reply_len=%d", sessionID, userID, len(reply))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.uploadHandler == nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "upload handler not ready"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, b.maxUploadBytes)
|
||||||
|
if err := r.ParseMultipartForm(minInt64(b.maxUploadBytes, 32*1024*1024)); err != nil {
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "request body too large") {
|
||||||
|
writeJSON(w, http.StatusRequestEntityTooLarge, errorResponse{Error: "file too large"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid multipart form"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := b.resolveID(strings.TrimSpace(r.FormValue("session_id")), "sess")
|
||||||
|
userID := b.resolveID(strings.TrimSpace(r.FormValue("user_id")), "user")
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "file is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
fileName := sanitizeFileName(header.Filename)
|
||||||
|
if fileName == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid file name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "read file failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(content) == 0 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "empty file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType := strings.TrimSpace(header.Header.Get("Content-Type"))
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = detectMimeByName(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, err := b.uploadHandler(r.Context(), sessionID, userID, []llm.InputFile{{
|
||||||
|
FileName: fileName,
|
||||||
|
MimeType: mimeType,
|
||||||
|
Content: content,
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Errorf("webui upload handler failed session_id=%s user_id=%s file=%s err=%v", sessionID, userID, fileName, err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "upload failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(ids) == 0 || strings.TrimSpace(ids[0]) == "" {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "upload succeeded but file_id is empty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, uploadResponse{
|
||||||
|
FileID: strings.TrimSpace(ids[0]),
|
||||||
|
FileIDs: ids,
|
||||||
|
FileName: fileName,
|
||||||
|
MimeType: mimeType,
|
||||||
|
SizeBytes: len(content),
|
||||||
|
SessionID: sessionID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) resolveID(raw, prefix string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw != "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
n := atomic.AddUint64(&b.counter, 1)
|
||||||
|
return fmt.Sprintf("%s_%d_%d", prefix, time.Now().UnixNano(), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt64(a, b int64) int64 {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectMimeByName(fileName string) string {
|
||||||
|
ext := strings.ToLower(strings.TrimSpace(filepath.Ext(fileName)))
|
||||||
|
if ext == "" {
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
m := strings.TrimSpace(mime.TypeByExtension(ext))
|
||||||
|
if m == "" {
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFileName(fileName string) string {
|
||||||
|
name := strings.TrimSpace(filepath.Base(fileName))
|
||||||
|
if name == "" || name == "." || name == ".." {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range name {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte('_')
|
||||||
|
}
|
||||||
|
out := strings.TrimSpace(b.String())
|
||||||
|
if out == "" || out == "." || out == ".." {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(out, ".") {
|
||||||
|
out = "file" + out
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
304
internal/transport/webui/bot_test.go
Normal file
304
internal/transport/webui/bot_test.go
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"laodingbot/internal/config"
|
||||||
|
"laodingbot/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestBot(t *testing.T, maxUploadBytes int64) *Bot {
|
||||||
|
t.Helper()
|
||||||
|
b, err := NewBot(config.WebUIConfig{ListenAddr: ":8090", MaxUploadBytes: maxUploadBytes}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBot failed: %v", err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatSuccess(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
b.chatHandler = func(_ context.Context, msg IncomingMessage) (string, error) {
|
||||||
|
if msg.ChatID != "s1" || msg.UserID != "u1" || msg.Text != "hello" {
|
||||||
|
t.Fatalf("unexpected message: %+v", msg)
|
||||||
|
}
|
||||||
|
return "ok", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.NewReader(`{"text":"hello","session_id":"s1","user_id":"u1"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChat(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var out chatResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode response failed: %v", err)
|
||||||
|
}
|
||||||
|
if out.Reply != "ok" || out.SessionID != "s1" || out.UserID != "u1" {
|
||||||
|
t.Fatalf("unexpected response: %+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatMissingText(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
b.chatHandler = func(_ context.Context, _ IncomingMessage) (string, error) { return "", nil }
|
||||||
|
|
||||||
|
body := strings.NewReader(`{"text":" "}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChat(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUploadSuccess(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
b.uploadHandler = func(_ context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) {
|
||||||
|
if chatID != "s1" || userID != "u1" {
|
||||||
|
t.Fatalf("unexpected ids chat=%s user=%s", chatID, userID)
|
||||||
|
}
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("unexpected files len=%d", len(files))
|
||||||
|
}
|
||||||
|
if files[0].FileName != "doc.pdf" || len(files[0].Content) == 0 {
|
||||||
|
t.Fatalf("unexpected file payload: %+v", files[0])
|
||||||
|
}
|
||||||
|
return []string{"file_123"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&payload)
|
||||||
|
_ = writer.WriteField("session_id", "s1")
|
||||||
|
_ = writer.WriteField("user_id", "u1")
|
||||||
|
fw, err := writer.CreateFormFile("file", "doc.pdf")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile failed: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = fw.Write([]byte("pdf-content"))
|
||||||
|
_ = writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/upload", &payload)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleUpload(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var out uploadResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode response failed: %v", err)
|
||||||
|
}
|
||||||
|
if out.FileID != "file_123" || out.SessionID != "s1" || out.UserID != "u1" {
|
||||||
|
t.Fatalf("unexpected response: %+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUploadTooLarge(t *testing.T) {
|
||||||
|
b := newTestBot(t, 3)
|
||||||
|
b.uploadHandler = func(_ context.Context, _ string, _ string, _ []llm.InputFile) ([]string, error) {
|
||||||
|
return []string{"file_should_not_reach"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&payload)
|
||||||
|
fw, err := writer.CreateFormFile("file", "a.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile failed: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = fw.Write([]byte("12345"))
|
||||||
|
_ = writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/upload", &payload)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleUpload(w, req)
|
||||||
|
if w.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("expected 413, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUploadMissingFile(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
b.uploadHandler = func(_ context.Context, _ string, _ string, _ []llm.InputFile) ([]string, error) {
|
||||||
|
return []string{"file_should_not_reach"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&payload)
|
||||||
|
_ = writer.WriteField("session_id", "s1")
|
||||||
|
_ = writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/upload", &payload)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleUpload(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatStreamSuccess(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
b.streamChatHandler = func(_ context.Context, msg IncomingMessage, cb StreamEventCallback) (string, error) {
|
||||||
|
if msg.ChatID != "s1" || msg.UserID != "u1" || msg.Text != "hello" {
|
||||||
|
t.Fatalf("unexpected message: %+v", msg)
|
||||||
|
}
|
||||||
|
if err := cb(StreamEvent{Type: StreamEventTypeThought, Content: "thinking", Step: 1}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := cb(StreamEvent{Type: StreamEventTypeToolCall, Content: "{\"input\":\"pwd\"}", Step: 1, ToolName: "shell"}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := cb(StreamEvent{Type: StreamEventTypeToolResult, Content: "C:/Project", Step: 1, ToolName: "shell"}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := cb(StreamEvent{Type: StreamEventTypeFinal, Content: "done", Step: 2}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "done", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.NewReader(`{"text":"hello","session_id":"s1","user_id":"u1"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChatStream(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Content-Type"); got != "text/event-stream" {
|
||||||
|
t.Fatalf("expected text/event-stream, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var events []StreamEvent
|
||||||
|
chunks := strings.Split(strings.TrimSpace(w.Body.String()), "\n\n")
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
line := strings.TrimSpace(chunk)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(line, "data: ") {
|
||||||
|
t.Fatalf("invalid sse line: %q", line)
|
||||||
|
}
|
||||||
|
payload := strings.TrimPrefix(line, "data: ")
|
||||||
|
var ev StreamEvent
|
||||||
|
if err := json.Unmarshal([]byte(payload), &ev); err != nil {
|
||||||
|
t.Fatalf("unmarshal stream event failed: %v payload=%s", err, payload)
|
||||||
|
}
|
||||||
|
events = append(events, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(events) != 4 {
|
||||||
|
t.Fatalf("expected 4 events, got %d", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Type != StreamEventTypeThought {
|
||||||
|
t.Fatalf("event[0] type mismatch: %s", events[0].Type)
|
||||||
|
}
|
||||||
|
if events[1].Type != StreamEventTypeToolCall || events[1].ToolName != "shell" {
|
||||||
|
t.Fatalf("event[1] mismatch: %+v", events[1])
|
||||||
|
}
|
||||||
|
if events[2].Type != StreamEventTypeToolResult || events[2].ToolName != "shell" {
|
||||||
|
t.Fatalf("event[2] mismatch: %+v", events[2])
|
||||||
|
}
|
||||||
|
if events[3].Type != StreamEventTypeFinal || events[3].Content != "done" {
|
||||||
|
t.Fatalf("event[3] mismatch: %+v", events[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatStreamHandlerError(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
b.streamChatHandler = func(_ context.Context, _ IncomingMessage, _ StreamEventCallback) (string, error) {
|
||||||
|
return "", errors.New("boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.NewReader(`{"text":"hello"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChatStream(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
respBody := w.Body.String()
|
||||||
|
if !strings.Contains(respBody, `"type":"error"`) {
|
||||||
|
t.Fatalf("expected error event in stream, body=%q", respBody)
|
||||||
|
}
|
||||||
|
if !strings.Contains(respBody, "stream error: boom") {
|
||||||
|
t.Fatalf("expected error detail in stream, body=%q", respBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatStreamValidation(t *testing.T) {
|
||||||
|
t.Run("method not allowed", func(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/chat/stream", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChatStream(w, req)
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("content type must be json", func(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", strings.NewReader(`{"text":"hello"}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChatStream(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handler not ready", func(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", strings.NewReader(`{"text":"hello"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChatStream(w, req)
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("text required", func(t *testing.T) {
|
||||||
|
b := newTestBot(t, 1024*1024)
|
||||||
|
b.streamChatHandler = func(_ context.Context, _ IncomingMessage, _ StreamEventCallback) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", strings.NewReader(`{"text":" "}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
b.handleChatStream(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
258
skills/safe_pi_planning/skill.md
Normal file
258
skills/safe_pi_planning/skill.md
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
---
|
||||||
|
name: SAFe 铁三角 PI 规划
|
||||||
|
description: 扮演 SAFe 铁三角(PM、架构师、RTE),将宏观 Epic 拆解为 PI 规划,输出标准化架构蓝图,并在 Gitea 创建可执行工单。支持从 PDF 等文档提取需求。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: SAFe 铁三角 PI 规划
|
||||||
|
|
||||||
|
## 1. 触发条件
|
||||||
|
|
||||||
|
当用户的意图匹配以下任意一种时,**必须启用本技能**:
|
||||||
|
|
||||||
|
- 提交了一份**宏观业务需求**(Epic),要求进行 PI 级别拆解
|
||||||
|
- 要求进行**下一个 PI (Program Increment) 的规划**
|
||||||
|
- 提到 SAFe、PI Planning、铁三角、Feature 拆解、架构跑道等关键词
|
||||||
|
- 上传了**需求文档**(PDF、Word、Markdown)并要求分析和拆解
|
||||||
|
- 要求将规划结果**同步到 Gitea / 创建工单**
|
||||||
|
|
||||||
|
**不适用场景**:单纯的代码编写、Bug 修复、文件查询等操作性任务。
|
||||||
|
|
||||||
|
## 2. 可用工具
|
||||||
|
|
||||||
|
| 工具名 | 用途 | 阶段 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `extract_file_document` | 提取用户上传的 PDF/文档内容 | 输入准备 |
|
||||||
|
| `publish_pi_plan` | 将推演结果渲染为标准化架构蓝图 | 规划输出 |
|
||||||
|
| `create_gitea_ticket` | 在 Gitea 中创建 User Story 工单 | 任务下发 |
|
||||||
|
| `web_search` | 搜索技术方案、行业标准等参考信息 | 辅助决策 |
|
||||||
|
|
||||||
|
## 3. 执行流程
|
||||||
|
|
||||||
|
执行本技能时,严格遵循以下分阶段流程。每个阶段对应 ReAct 循环中的一个或多个 Thought → Action → Observation 步骤。
|
||||||
|
|
||||||
|
### 阶段 0:输入准备 — 文档提取(如有附件)
|
||||||
|
|
||||||
|
如果用户提交了文件(file_id),**必须先提取文档内容**,再进入正式推演。
|
||||||
|
|
||||||
|
**Action**: 调用 `extract_file_document`
|
||||||
|
```
|
||||||
|
输入: 用户提供的 file_id
|
||||||
|
```
|
||||||
|
**Observation**: 获取文档全文结构化摘要,包含标题、核心观点、关键数据。
|
||||||
|
|
||||||
|
将提取到的文档内容作为后续推演的输入素材。如果用户同时提交了多个文件,逐个提取后合并为统一的需求上下文。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 1:铁三角推演(核心 Thought 过程)
|
||||||
|
|
||||||
|
**这是本技能的核心。** 你必须在 Thought 中同时扮演三个角色,按以下顺序进行严格的自我推演和博弈。不可跳过任何视角。
|
||||||
|
|
||||||
|
#### 视角 1: 产品管理 (PM) — 决定"做什么"和"为什么做"
|
||||||
|
|
||||||
|
在 Thought 中以 `[PM]` 标记此视角的思考:
|
||||||
|
|
||||||
|
1. **需求拆解**: 将宏观 Epic 拆解为 2-4 个可在 8-12 周内交付的业务特性 (Feature)
|
||||||
|
2. **价值假设**: 为每个 Feature 写出清晰的 Benefit Hypothesis — 如果做了这个功能,会带来什么可量化的业务收益
|
||||||
|
3. **验收标准**: 从业务视角列出每个 Feature 的验收条件 (Acceptance Criteria),不涉及技术实现细节
|
||||||
|
4. **优先级排序**: 根据业务价值和紧迫性,给出 Feature 的建议交付顺序
|
||||||
|
|
||||||
|
**PM 视角的自检问题**:
|
||||||
|
- 每个 Feature 是否独立可交付,还是必须和其他 Feature 一起才有意义?
|
||||||
|
- 验收标准是否可以被测试团队直接转化为测试用例?
|
||||||
|
- 是否遗漏了用户(终端使用者)会关心的场景?
|
||||||
|
|
||||||
|
#### 视角 2: 系统架构师 (SA) — 决定"技术怎么接"和"底座跑道"
|
||||||
|
|
||||||
|
在 Thought 中以 `[SA]` 标记此视角的思考:
|
||||||
|
|
||||||
|
1. **架构跑道 (Architectural Runway)**: PM 提出的每个 Feature,当前系统能直接支撑吗?如果不能,需要提前铺设哪些底层基础设施?将这些转化为 Enabler
|
||||||
|
2. **瓶颈识别**: 当前系统架构的薄弱环节在哪?哪些 Enabler 是阻塞性的(不做就无法开始 Feature 开发)?
|
||||||
|
3. **NFRs 定义**: 强制规定非功能性需求:
|
||||||
|
- 性能指标(QPS、P99 延迟、吞吐量)
|
||||||
|
- 安全标准(加密协议、认证机制、数据脱敏)
|
||||||
|
4. **接口契约**: 如果涉及多个子系统交互,定义关键 API 契约
|
||||||
|
|
||||||
|
**SA 视角的自检问题**:
|
||||||
|
- 每个 Enabler 是否有明确的"完成标志"(而不是开放性的研究任务)?
|
||||||
|
- NFRs 的指标是否可量化、可自动化测试?
|
||||||
|
- 是否过度设计?Enabler 数量是否与 Feature 复杂度匹配?
|
||||||
|
|
||||||
|
#### 视角 3: 发布火车工程师 (RTE) — 决定"流程风险"与"依赖管理"
|
||||||
|
|
||||||
|
在 Thought 中以 `[RTE]` 标记此视角的思考:
|
||||||
|
|
||||||
|
1. **依赖分析**: 检查 PM 的 Feature 和 SA 的 Enabler 之间的时序依赖 — 哪些 Enabler 必须先完成才能开始哪些 Feature?
|
||||||
|
2. **风险评估**: 识别潜在的交付风险:
|
||||||
|
- 技术风险(新技术栈、外部 API 未就绪)
|
||||||
|
- 资源风险(关键人员依赖、跨团队协调)
|
||||||
|
- 集成风险(多个 Feature 的集成点)
|
||||||
|
3. **里程碑建议**: 基于依赖关系,给出关键里程碑的建议时间节点
|
||||||
|
|
||||||
|
**RTE 视角的自检问题**:
|
||||||
|
- 是否存在循环依赖?
|
||||||
|
- 关键路径上的任务是否有 buffer?
|
||||||
|
- 如果某个 Enabler 延期,哪些 Feature 会受影响?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 2:生成标准化 PI 规划
|
||||||
|
|
||||||
|
完成三个视角的推演后,**必须立即调用** `publish_pi_plan` 工具。
|
||||||
|
|
||||||
|
**Action**: 调用 `publish_pi_plan`,传入推演结果的 JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pi_vision": "本 PI 的核心业务愿景(一两句话概括)",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"feature_id": "FEAT_XXX_001",
|
||||||
|
"title": "动宾结构的简洁标题",
|
||||||
|
"benefit_hypothesis": "如果实现此功能,将带来 XXX 业务收益",
|
||||||
|
"acceptance_criteria": ["AC1: ...", "AC2: ..."]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"enablers": [
|
||||||
|
{
|
||||||
|
"enabler_id": "ENAB_XXX_001",
|
||||||
|
"title": "技术任务名称",
|
||||||
|
"architectural_purpose": "为什么需要这个底层改造"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nfrs": {
|
||||||
|
"performance": "具体的性能指标约束",
|
||||||
|
"security": "具体的安全与合规约束"
|
||||||
|
},
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"source_id": "ENAB_XXX_001",
|
||||||
|
"target_id": "FEAT_XXX_001",
|
||||||
|
"reason": "依赖原因的一句话描述"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**命名规范**:
|
||||||
|
- Feature ID: `FEAT_<领域缩写>_<序号>`,如 `FEAT_OTA_001`
|
||||||
|
- Enabler ID: `ENAB_<技术栈缩写>_<序号>`,如 `ENAB_KAFKA_001`
|
||||||
|
|
||||||
|
**Observation**: 获取渲染后的 Markdown 架构蓝图,包含愿景、特性清单、Enabler 表、NFRs、依赖关系、执行顺序和质量门禁检查清单。
|
||||||
|
|
||||||
|
**⚠️ 关键要求**:工具返回的内容(Observation)不会直接展示给用户。你**必须**将 `publish_pi_plan` 返回的蓝图 Markdown **全文**复制到你的最终回复中,让用户可以看到完整的规划内容。**严禁**仅用一句"蓝图已生成"代替正文输出。
|
||||||
|
|
||||||
|
输出蓝图后,征求用户反馈。如果用户没有异议,直接继续执行阶段 3。
|
||||||
|
|
||||||
|
**⚠️ 用户确认规则**:当用户回复表示确认(如"确认"、"开始创建工单"、"批量创建"、"没问题"、"可以了"等肯定性表达),**必须立即调用** `create_gitea_ticket` 工具开始创建工单,**严禁**重复输出蓝图或再次征求确认。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 3:任务下发到 Gitea
|
||||||
|
|
||||||
|
蓝图展示给用户后,将 Feature 和 Enabler **逐一拆解为 User Story**,通过 `create_gitea_ticket` 在 Gitea 创建工单。如果用户明确表示需要调整,先根据反馈修订蓝图后再创建工单。
|
||||||
|
|
||||||
|
#### 拆解原则
|
||||||
|
|
||||||
|
- 每个 Feature 拆解为 1-3 个 User Story(每个 Story 应在 1-3 天内可完成)
|
||||||
|
- 每个 Enabler 拆解为 1-2 个技术任务 Story
|
||||||
|
- Story 的标题采用**动宾结构**(如"实现固件版本解析 API","部署 Kafka 基础镜像")
|
||||||
|
|
||||||
|
#### 创建工单
|
||||||
|
|
||||||
|
对每个 Story,**Action**: 调用 `create_gitea_ticket`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "动宾结构的任务标题",
|
||||||
|
"body": "## 溯源\n- Parent: FEAT_XXX_001\n\n## 任务上下文\n<描述做什么、为什么>\n\n## 验收标准\n- [ ] AC-1: ...\n- [ ] AC-2: ...\n\n## NFRs\n- 性能: ...\n- 安全: ...\n\n## 技术实现思路\n<基于架构师视角补充>",
|
||||||
|
"labels": ["type/story", "domain/<领域>", "priority/<high|medium|low>", "status/todo"],
|
||||||
|
"parent_reference_id": "FEAT_XXX_001",
|
||||||
|
"estimated_hours": 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**标签约定**:
|
||||||
|
- `type/story` | `type/enabler` | `type/spike` — 任务类型
|
||||||
|
- `domain/<领域>` — 业务领域(如 `domain/cloud`、`domain/vehicle`、`domain/infra`)
|
||||||
|
- `priority/high` | `priority/medium` | `priority/low` — 优先级
|
||||||
|
- `status/todo` — 初始状态
|
||||||
|
|
||||||
|
**工时估算参考**:
|
||||||
|
- 简单 CRUD / 配置变更: 4-8 小时
|
||||||
|
- 标准 API 开发 + 单元测试: 8-16 小时
|
||||||
|
- 基础设施搭建 / 中间件部署: 16-24 小时
|
||||||
|
- 跨系统集成 + 联调: 16-32 小时
|
||||||
|
|
||||||
|
创建完成后,汇总所有工单链接,输出执行看板。
|
||||||
|
|
||||||
|
## 4. Thought 内部推演示范
|
||||||
|
|
||||||
|
以下示范 Thought 过程的结构(非固定内容,仅展示格式):
|
||||||
|
|
||||||
|
```
|
||||||
|
Thought:
|
||||||
|
用户提交了一个 Epic:"实现面向欧洲市场的车云 OTA 升级平台"。
|
||||||
|
我需要以铁三角三个视角依次推演。
|
||||||
|
|
||||||
|
[PM] 产品管理视角:
|
||||||
|
- 核心交付价值:让欧洲车主能安全、快速地接收 OTA 升级
|
||||||
|
- Feature 拆解:
|
||||||
|
1. FEAT_OTA_001: 云端固件版本依赖检查 — 防止下载不兼容固件
|
||||||
|
2. FEAT_OTA_002: 端侧断点续传 — 弱网环境下提升下载成功率
|
||||||
|
3. FEAT_OTA_003: 升级状态实时推送 — 车主可在 App 中看到升级进度
|
||||||
|
- 各 Feature 的 AC 已确定,均可测试化
|
||||||
|
|
||||||
|
[SA] 系统架构师视角:
|
||||||
|
- FEAT_OTA_001 需要异步版本校验,当前没有合适的消息中间件 → ENAB_KAFKA_001
|
||||||
|
- FEAT_OTA_002 需要支持 Range 请求的对象存储 → ENAB_S3_001
|
||||||
|
- NFRs:API P99 < 200ms, TLS 1.3, VIN 脱敏
|
||||||
|
- 当前架构可支撑 FEAT_OTA_003,无需新增 Enabler
|
||||||
|
|
||||||
|
[RTE] 发布火车工程师视角:
|
||||||
|
- ENAB_KAFKA_001 → FEAT_OTA_001(阻塞依赖)
|
||||||
|
- ENAB_S3_001 → FEAT_OTA_002(阻塞依赖)
|
||||||
|
- FEAT_OTA_003 无依赖,可并行开发
|
||||||
|
- 风险:Kafka 集群搭建预计 2 周,是关键路径
|
||||||
|
|
||||||
|
推演完成,下一步调用 publish_pi_plan 输出标准化蓝图。
|
||||||
|
|
||||||
|
Action: publish_pi_plan
|
||||||
|
Action Input: {"pi_vision": "...", "features": [...], ...}
|
||||||
|
|
||||||
|
Observation: (工具返回完整 Markdown 蓝图)
|
||||||
|
|
||||||
|
Thought: 蓝图已生成。由于 Observation 不会直接展示给用户,我必须在 Final Answer 中包含蓝图全文内容。同时继续执行阶段 3,创建 Gitea 工单。
|
||||||
|
|
||||||
|
Final Answer:
|
||||||
|
(此处粘贴 publish_pi_plan 返回的完整蓝图 Markdown 全文)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 输出规范
|
||||||
|
|
||||||
|
最终交付给用户的内容必须包含:
|
||||||
|
|
||||||
|
1. **PI 蓝图**:`publish_pi_plan` 返回的完整 Markdown 报告(必须全文输出,不可省略或仅用一句话概括)
|
||||||
|
2. **Gitea 工单汇总**(如已执行阶段 3):
|
||||||
|
- 工单编号与链接列表
|
||||||
|
- 按执行顺序排列
|
||||||
|
- 标注关键路径上的任务
|
||||||
|
3. **风险与建议**:RTE 视角识别的关键风险及缓解措施
|
||||||
|
|
||||||
|
## 6. 质量约束
|
||||||
|
|
||||||
|
- **三视角不可跳过**: 即使需求看起来简单,也必须走完 PM → SA → RTE 三步推演
|
||||||
|
- **ID 必须唯一**: Feature ID 和 Enabler ID 在同一个 PI 内不可重复
|
||||||
|
- **AC 必须可测试**: 每条验收标准都应能被转化为自动化测试用例
|
||||||
|
- **NFRs 必须可量化**: 不接受"性能要好"这样的模糊描述,必须有具体数字
|
||||||
|
- **依赖必须有理由**: 每条依赖关系都要说明为什么 A 必须先于 B
|
||||||
|
|
||||||
|
## 7. 失败回退策略
|
||||||
|
|
||||||
|
| 失败场景 | 处理方式 |
|
||||||
|
|----------|----------|
|
||||||
|
| 文档提取失败(file_id 无效) | 提示用户重新上传或提供文本版需求描述 |
|
||||||
|
| 需求信息不足以拆解 Feature | 列出缺失信息清单,请求用户补充后重新推演 |
|
||||||
|
| Gitea 配置缺失(token/repo 为空) | 仅输出 PI 蓝图,跳过工单创建,提示用户配置 Gitea 环境变量 |
|
||||||
|
| Gitea API 调用失败 | 输出 PI 蓝图,附上拟创建工单的 JSON 列表供用户手动创建 |
|
||||||
|
| 用户对规划有异议 | 记录反馈,调整对应视角的推演,重新生成蓝图 |
|
||||||
208
tools/filedoc/filedoc.go
Normal file
208
tools/filedoc/filedoc.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package filedoc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
|
||||||
|
openai "github.com/openai/openai-go"
|
||||||
|
"github.com/openai/openai-go/option"
|
||||||
|
"github.com/openai/openai-go/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
APIKey string
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tool struct {
|
||||||
|
client openai.Client
|
||||||
|
model string
|
||||||
|
maxOutputChars int
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config, maxOutputChars int, log *logger.Logger) *Tool {
|
||||||
|
if strings.TrimSpace(cfg.Model) == "" {
|
||||||
|
cfg.Model = "gpt-4o-mini"
|
||||||
|
}
|
||||||
|
if cfg.Timeout <= 0 {
|
||||||
|
cfg.Timeout = 60 * time.Second
|
||||||
|
}
|
||||||
|
if maxOutputChars <= 0 {
|
||||||
|
maxOutputChars = 12000
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []option.RequestOption{
|
||||||
|
option.WithAPIKey(strings.TrimSpace(cfg.APIKey)),
|
||||||
|
option.WithRequestTimeout(cfg.Timeout),
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.BaseURL) != "" {
|
||||||
|
opts = append(opts, option.WithBaseURL(strings.TrimSpace(cfg.BaseURL)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Tool{
|
||||||
|
client: openai.NewClient(opts...),
|
||||||
|
model: cfg.Model,
|
||||||
|
maxOutputChars: maxOutputChars,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Name() string { return "extract_file_document" }
|
||||||
|
|
||||||
|
func (t *Tool) Description() string {
|
||||||
|
return "Extract full document details from a file ID via OpenAI. Input: file_id (supports plain ID, fileid://ID, or JSON {\"file_id\":\"...\"})."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
||||||
|
fileID, userFocus, err := parseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := buildExtractionPrompt(fileID, userFocus)
|
||||||
|
messages := []openai.ChatCompletionMessageParamUnion{
|
||||||
|
openai.SystemMessage("fileid://" + fileID),
|
||||||
|
openai.UserMessage([]openai.ChatCompletionContentPartUnionParam{
|
||||||
|
openai.TextContentPart(prompt),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
params := openai.ChatCompletionNewParams{
|
||||||
|
Model: shared.ChatModel(t.model),
|
||||||
|
Messages: messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("filedoc tool request model=%s file_id=%s", t.model, fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := t.client.Chat.Completions.New(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("filedoc request failed: %w", err)
|
||||||
|
}
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("filedoc returned empty choices")
|
||||||
|
}
|
||||||
|
|
||||||
|
out := strings.TrimSpace(resp.Choices[0].Message.Content)
|
||||||
|
if out == "" {
|
||||||
|
out = "未提取到可读的文档内容。请确认 file_id 是否有效以及模型是否支持文件解析。"
|
||||||
|
}
|
||||||
|
if len(out) > t.maxOutputChars {
|
||||||
|
out = out[:t.maxOutputChars]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExtractionPrompt(fileID, userFocus string) string {
|
||||||
|
focus := strings.TrimSpace(userFocus)
|
||||||
|
if focus == "" {
|
||||||
|
focus = "请输出完整文档信息,包括标题、主题、核心观点、结构大纲、关键术语、重要结论、风险点与后续建议。"
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join([]string{
|
||||||
|
"请基于所附文件输出完整文档信息。",
|
||||||
|
"file_id: " + fileID,
|
||||||
|
"",
|
||||||
|
"输出要求:",
|
||||||
|
"1) 文档基本信息:标题、文档类型、语言、可能作者/组织(若可判断)、时间线索(若可判断)。",
|
||||||
|
"2) 结构化摘要:按章节或逻辑段落给出要点,尽量保持原文顺序。",
|
||||||
|
"3) 关键数据与事实:列出关键数字、术语、专有名词、约束条件。",
|
||||||
|
"4) 风险与不确定性:明确哪些信息来源于文档,哪些是无法确认。",
|
||||||
|
"5) 面向执行的建议:给出可落地的后续行动项。",
|
||||||
|
"",
|
||||||
|
"补充关注点:",
|
||||||
|
focus,
|
||||||
|
}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInput(input string) (fileID string, userFocus string, err error) {
|
||||||
|
raw := strings.TrimSpace(input)
|
||||||
|
if raw == "" {
|
||||||
|
return "", "", fmt.Errorf("empty input: expected file_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(raw, "{") {
|
||||||
|
var payload map[string]any
|
||||||
|
if jsonErr := json.Unmarshal([]byte(raw), &payload); jsonErr == nil {
|
||||||
|
if id := firstNonEmptyString(payload, "file_id", "fileid", "id", "fileID"); id != "" {
|
||||||
|
return normalizeFileID(id), firstNonEmptyString(payload, "focus", "query", "instruction", "prompt"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(raw, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
candidate := extractFileIDToken(line)
|
||||||
|
if candidate != "" {
|
||||||
|
focus := strings.TrimSpace(strings.ReplaceAll(raw, line, ""))
|
||||||
|
return normalizeFileID(candidate), focus, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := extractFileIDToken(raw)
|
||||||
|
if candidate == "" {
|
||||||
|
return "", "", fmt.Errorf("no file_id found in input")
|
||||||
|
}
|
||||||
|
return normalizeFileID(candidate), "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFileIDToken(s string) string {
|
||||||
|
fields := strings.FieldsFunc(s, func(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case ' ', '\t', '\n', '\r', ',', ';', '|':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, f := range fields {
|
||||||
|
tok := strings.TrimSpace(strings.Trim(f, "\"'()[]{}"))
|
||||||
|
if tok == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(tok)
|
||||||
|
if strings.HasPrefix(lower, "fileid://") {
|
||||||
|
return tok[len("fileid://"):]
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(lower, "file_id=") || strings.HasPrefix(lower, "fileid=") {
|
||||||
|
idx := strings.Index(tok, "=")
|
||||||
|
if idx >= 0 && idx+1 < len(tok) {
|
||||||
|
return tok[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(lower, "file_") || strings.HasPrefix(lower, "file-") {
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFileID(id string) string {
|
||||||
|
id = strings.TrimSpace(strings.Trim(id, "\"'"))
|
||||||
|
if strings.HasPrefix(strings.ToLower(id), "fileid://") {
|
||||||
|
return strings.TrimSpace(id[len("fileid://"):])
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyString(m map[string]any, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := m[k]; ok {
|
||||||
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
74
tools/filedoc/filedoc_test.go
Normal file
74
tools/filedoc/filedoc_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package filedoc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNameAndDescription(t *testing.T) {
|
||||||
|
tool := New(Config{APIKey: "k", Model: "gpt-4o-mini"}, 5000, nil)
|
||||||
|
if tool.Name() != "extract_file_document" {
|
||||||
|
t.Fatalf("unexpected tool name: %s", tool.Name())
|
||||||
|
}
|
||||||
|
if tool.Description() == "" {
|
||||||
|
t.Fatal("description should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputPlainFileID(t *testing.T) {
|
||||||
|
id, focus, err := parseInput("file_ec_452e96aad38940229058f193f5c5b9c6_12553222")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseInput returned error: %v", err)
|
||||||
|
}
|
||||||
|
if id != "file_ec_452e96aad38940229058f193f5c5b9c6_12553222" {
|
||||||
|
t.Fatalf("unexpected id: %s", id)
|
||||||
|
}
|
||||||
|
if focus != "" {
|
||||||
|
t.Fatalf("expected empty focus, got: %q", focus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputFileIDSchemeWithFocus(t *testing.T) {
|
||||||
|
input := "fileid://file_ec_12345\n重点关注风险与建议"
|
||||||
|
id, focus, err := parseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseInput returned error: %v", err)
|
||||||
|
}
|
||||||
|
if id != "file_ec_12345" {
|
||||||
|
t.Fatalf("unexpected id: %s", id)
|
||||||
|
}
|
||||||
|
if focus == "" {
|
||||||
|
t.Fatal("expected non-empty focus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputJSON(t *testing.T) {
|
||||||
|
input := `{"file_id":"file_ec_888", "focus":"提取关键结论"}`
|
||||||
|
id, focus, err := parseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseInput returned error: %v", err)
|
||||||
|
}
|
||||||
|
if id != "file_ec_888" {
|
||||||
|
t.Fatalf("unexpected id: %s", id)
|
||||||
|
}
|
||||||
|
if focus != "提取关键结论" {
|
||||||
|
t.Fatalf("unexpected focus: %s", focus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputInvalid(t *testing.T) {
|
||||||
|
_, _, err := parseInput("hello world")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected parse error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildExtractionPrompt(t *testing.T) {
|
||||||
|
p := buildExtractionPrompt("file_ec_abc", "关注测试条目")
|
||||||
|
if p == "" {
|
||||||
|
t.Fatal("prompt should not be empty")
|
||||||
|
}
|
||||||
|
if p != "" && !(strings.Contains(p, "file_ec_abc") && strings.Contains(p, "关注测试条目")) {
|
||||||
|
t.Fatalf("unexpected prompt content: %s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
336
tools/giteaticket/giteaticket.go
Normal file
336
tools/giteaticket/giteaticket.go
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
package giteaticket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config Gitea 工单工具的配置。
|
||||||
|
type Config struct {
|
||||||
|
BaseURL string // Gitea 实例地址,例如 https://gitea.example.com
|
||||||
|
Token string // Gitea Personal Access Token
|
||||||
|
Owner string // 仓库所有者(用户名或组织名)
|
||||||
|
Repo string // 仓库名称
|
||||||
|
Timeout time.Duration // HTTP 请求超时
|
||||||
|
}
|
||||||
|
|
||||||
|
// TicketInput 对应 tool schema 定义的输入参数。
|
||||||
|
type TicketInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
ParentReferenceID string `json:"parent_reference_id"`
|
||||||
|
EstimatedHours int `json:"estimated_hours,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// giteaCreateIssueReq Gitea Create Issue API 请求体。
|
||||||
|
type giteaCreateIssueReq struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Labels []int64 `json:"labels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// giteaIssueResp Gitea Issue API 响应(仅用到的字段)。
|
||||||
|
type giteaIssueResp struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Number int64 `json:"number"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
State string `json:"state"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// giteaLabelResp Gitea Label API 响应。
|
||||||
|
type giteaLabelResp struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool 实现 create_gitea_ticket 工具。
|
||||||
|
type Tool struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
owner string
|
||||||
|
repo string
|
||||||
|
httpClient *http.Client
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建一个新的 create_gitea_ticket 工具实例。
|
||||||
|
func New(cfg Config, log *logger.Logger) *Tool {
|
||||||
|
baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||||
|
if cfg.Timeout <= 0 {
|
||||||
|
cfg.Timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
if log != nil {
|
||||||
|
log.Infof("giteaticket tool initialized base_url=%q owner=%q repo=%q token_set=%v",
|
||||||
|
baseURL, strings.TrimSpace(cfg.Owner), strings.TrimSpace(cfg.Repo), strings.TrimSpace(cfg.Token) != "")
|
||||||
|
}
|
||||||
|
return &Tool{
|
||||||
|
baseURL: baseURL,
|
||||||
|
token: strings.TrimSpace(cfg.Token),
|
||||||
|
owner: strings.TrimSpace(cfg.Owner),
|
||||||
|
repo: strings.TrimSpace(cfg.Repo),
|
||||||
|
httpClient: &http.Client{Timeout: cfg.Timeout},
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Name() string { return "create_gitea_ticket" }
|
||||||
|
|
||||||
|
func (t *Tool) Description() string {
|
||||||
|
return `DevOps Agent 使用此工具,将 PI Plan 中的 Feature/Enabler 拆解为可执行 User Story,并在 Gitea 创建 Issue。输入 JSON: {"title":"...","body":"...","labels":[...],"parent_reference_id":"...","estimated_hours":8}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
||||||
|
ticket, err := parseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create_gitea_ticket: invalid input: %w", err)
|
||||||
|
}
|
||||||
|
if err := validate(ticket); err != nil {
|
||||||
|
return "", fmt.Errorf("create_gitea_ticket: validation failed: %w", err)
|
||||||
|
}
|
||||||
|
if t.baseURL == "" || t.token == "" || t.owner == "" || t.repo == "" {
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Errorf("giteaticket config missing: base_url=%q token_set=%v owner=%q repo=%q",
|
||||||
|
t.baseURL, t.token != "", t.owner, t.repo)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("create_gitea_ticket: missing Gitea configuration (base_url, token, owner, repo)")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := buildIssueBody(ticket)
|
||||||
|
|
||||||
|
labelIDs, err := t.resolveLabels(ctx, ticket.Labels)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create_gitea_ticket: label resolution failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("create_gitea_ticket: creating issue title=%q parent=%s labels=%v",
|
||||||
|
ticket.Title, ticket.ParentReferenceID, ticket.Labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
issue, err := t.createIssue(ctx, ticket.Title, body, labelIDs)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create_gitea_ticket: create issue failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := formatResult(issue, ticket)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInput(input string) (*TicketInput, error) {
|
||||||
|
raw := strings.TrimSpace(input)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, fmt.Errorf("empty input")
|
||||||
|
}
|
||||||
|
var ticket TicketInput
|
||||||
|
if err := json.Unmarshal([]byte(raw), &ticket); err != nil {
|
||||||
|
return nil, fmt.Errorf("JSON parse error: %w", err)
|
||||||
|
}
|
||||||
|
return &ticket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(t *TicketInput) error {
|
||||||
|
if strings.TrimSpace(t.Title) == "" {
|
||||||
|
return fmt.Errorf("title is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.Body) == "" {
|
||||||
|
return fmt.Errorf("body is required")
|
||||||
|
}
|
||||||
|
if len(t.Labels) == 0 {
|
||||||
|
return fmt.Errorf("labels is required and must contain at least one label")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.ParentReferenceID) == "" {
|
||||||
|
return fmt.Errorf("parent_reference_id is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildIssueBody 在原始 body 上追加溯源元数据和工时估算。
|
||||||
|
func buildIssueBody(ticket *TicketInput) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(ticket.Body)
|
||||||
|
b.WriteString("\n\n---\n\n")
|
||||||
|
b.WriteString("## 📋 SAFe 元数据\n\n")
|
||||||
|
b.WriteString(fmt.Sprintf("- **溯源 (Parent Reference)**: `%s`\n", ticket.ParentReferenceID))
|
||||||
|
if ticket.EstimatedHours > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("- **预估工时**: %d 小时\n", ticket.EstimatedHours))
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("- **标签**: %s\n", strings.Join(ticket.Labels, ", ")))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveLabels 通过 Gitea API 查询已有标签,将标签名映射为 ID。
|
||||||
|
// 如果标签不存在则自动创建。
|
||||||
|
func (t *Tool) resolveLabels(ctx context.Context, labelNames []string) ([]int64, error) {
|
||||||
|
existing, err := t.listLabels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nameToID := make(map[string]int64, len(existing))
|
||||||
|
for _, l := range existing {
|
||||||
|
nameToID[l.Name] = l.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int64, 0, len(labelNames))
|
||||||
|
for _, name := range labelNames {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id, ok := nameToID[name]; ok {
|
||||||
|
ids = append(ids, id)
|
||||||
|
} else {
|
||||||
|
id, err := t.createLabel(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create label %q: %w", name, err)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) listLabels(ctx context.Context) ([]giteaLabelResp, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", t.baseURL, t.owner, t.repo)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.setAuth(req)
|
||||||
|
|
||||||
|
resp, err := t.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list labels request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("list labels returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var labels []giteaLabelResp
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode labels response: %w", err)
|
||||||
|
}
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) createLabel(ctx context.Context, name string) (int64, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", t.baseURL, t.owner, t.repo)
|
||||||
|
payload := map[string]string{
|
||||||
|
"name": name,
|
||||||
|
"color": labelColor(name),
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
t.setAuth(req)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := t.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create label request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return 0, fmt.Errorf("create label returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var label giteaLabelResp
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&label); err != nil {
|
||||||
|
return 0, fmt.Errorf("decode create label response: %w", err)
|
||||||
|
}
|
||||||
|
return label.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) createIssue(ctx context.Context, title, body string, labelIDs []int64) (*giteaIssueResp, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", t.baseURL, t.owner, t.repo)
|
||||||
|
payload := giteaCreateIssueReq{
|
||||||
|
Title: title,
|
||||||
|
Body: body,
|
||||||
|
Labels: labelIDs,
|
||||||
|
}
|
||||||
|
jsonBody, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.setAuth(req)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := t.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create issue request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("create issue returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue giteaIssueResp
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode create issue response: %w", err)
|
||||||
|
}
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) setAuth(req *http.Request) {
|
||||||
|
if t.token != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+t.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatResult(issue *giteaIssueResp, ticket *TicketInput) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("✅ Gitea Issue 创建成功\n\n")
|
||||||
|
b.WriteString(fmt.Sprintf("- **Issue 编号**: #%d\n", issue.Number))
|
||||||
|
b.WriteString(fmt.Sprintf("- **标题**: %s\n", issue.Title))
|
||||||
|
b.WriteString(fmt.Sprintf("- **状态**: %s\n", issue.State))
|
||||||
|
b.WriteString(fmt.Sprintf("- **链接**: %s\n", issue.HTMLURL))
|
||||||
|
b.WriteString(fmt.Sprintf("- **溯源 (Parent)**: %s\n", ticket.ParentReferenceID))
|
||||||
|
if ticket.EstimatedHours > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("- **预估工时**: %d 小时\n", ticket.EstimatedHours))
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("- **标签**: %s\n", strings.Join(ticket.Labels, ", ")))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// labelColor 根据标签前缀返回一个辨识度高的颜色。
|
||||||
|
func labelColor(name string) string {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, "type/"):
|
||||||
|
return "#0075ca"
|
||||||
|
case strings.HasPrefix(lower, "domain/"):
|
||||||
|
return "#7057ff"
|
||||||
|
case strings.HasPrefix(lower, "priority/"):
|
||||||
|
if strings.Contains(lower, "high") || strings.Contains(lower, "critical") {
|
||||||
|
return "#d73a4a"
|
||||||
|
}
|
||||||
|
return "#e4e669"
|
||||||
|
case strings.HasPrefix(lower, "status/"):
|
||||||
|
return "#0e8a16"
|
||||||
|
default:
|
||||||
|
return "#ededed"
|
||||||
|
}
|
||||||
|
}
|
||||||
403
tools/giteaticket/giteaticket_test.go
Normal file
403
tools/giteaticket/giteaticket_test.go
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
package giteaticket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validTicketInput() TicketInput {
|
||||||
|
return TicketInput{
|
||||||
|
Title: "实现云端固件版本解析 API",
|
||||||
|
Body: `## 溯源
|
||||||
|
- Parent: FEAT_OTA_001
|
||||||
|
|
||||||
|
## 任务上下文
|
||||||
|
云端需要能够解析上传的固件包,提取版本号与依赖关系。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
- [ ] 上传 .bin 固件文件后返回解析结果 JSON
|
||||||
|
- [ ] 解析结果包含 version, dependencies 字段
|
||||||
|
|
||||||
|
## NFRs
|
||||||
|
- 响应时间 P99 < 500ms
|
||||||
|
|
||||||
|
## 技术实现思路
|
||||||
|
使用 Go 读取固件文件头部 metadata。`,
|
||||||
|
Labels: []string{"type/story", "domain/cloud", "priority/high", "status/todo"},
|
||||||
|
ParentReferenceID: "FEAT_OTA_001",
|
||||||
|
EstimatedHours: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parseInput tests ──
|
||||||
|
|
||||||
|
func TestParseInputValid(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
ticket, err := parseInput(string(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if ticket.Title != in.Title {
|
||||||
|
t.Errorf("title mismatch: got %q", ticket.Title)
|
||||||
|
}
|
||||||
|
if len(ticket.Labels) != 4 {
|
||||||
|
t.Errorf("expected 4 labels, got %d", len(ticket.Labels))
|
||||||
|
}
|
||||||
|
if ticket.ParentReferenceID != "FEAT_OTA_001" {
|
||||||
|
t.Errorf("parent_reference_id mismatch: got %q", ticket.ParentReferenceID)
|
||||||
|
}
|
||||||
|
if ticket.EstimatedHours != 8 {
|
||||||
|
t.Errorf("estimated_hours mismatch: got %d", ticket.EstimatedHours)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputEmpty(t *testing.T) {
|
||||||
|
_, err := parseInput("")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputInvalidJSON(t *testing.T) {
|
||||||
|
_, err := parseInput("{bad json}")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── validate tests ──
|
||||||
|
|
||||||
|
func TestValidateMissingTitle(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
in.Title = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "title") {
|
||||||
|
t.Fatalf("expected title error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMissingBody(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
in.Body = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "body") {
|
||||||
|
t.Fatalf("expected body error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMissingLabels(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
in.Labels = nil
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "labels") {
|
||||||
|
t.Fatalf("expected labels error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMissingParentRef(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
in.ParentReferenceID = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "parent_reference_id") {
|
||||||
|
t.Fatalf("expected parent_reference_id error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateOptionalEstimatedHours(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
in.EstimatedHours = 0
|
||||||
|
err := validate(&in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("estimated_hours should be optional, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── buildIssueBody tests ──
|
||||||
|
|
||||||
|
func TestBuildIssueBodyContainsMetadata(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
body := buildIssueBody(&in)
|
||||||
|
|
||||||
|
if !strings.Contains(body, "FEAT_OTA_001") {
|
||||||
|
t.Error("body missing parent_reference_id")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "8 小时") {
|
||||||
|
t.Error("body missing estimated_hours")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "type/story") {
|
||||||
|
t.Error("body missing labels")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "SAFe 元数据") {
|
||||||
|
t.Error("body missing metadata section header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildIssueBodyNoHours(t *testing.T) {
|
||||||
|
in := validTicketInput()
|
||||||
|
in.EstimatedHours = 0
|
||||||
|
body := buildIssueBody(&in)
|
||||||
|
if strings.Contains(body, "预估工时") {
|
||||||
|
t.Error("body should not contain estimated hours when 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── labelColor tests ──
|
||||||
|
|
||||||
|
func TestLabelColor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"type/story", "#0075ca"},
|
||||||
|
{"domain/cloud", "#7057ff"},
|
||||||
|
{"priority/high", "#d73a4a"},
|
||||||
|
{"priority/low", "#e4e669"},
|
||||||
|
{"status/todo", "#0e8a16"},
|
||||||
|
{"custom-label", "#ededed"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := labelColor(tc.name)
|
||||||
|
if got != tc.expected {
|
||||||
|
t.Errorf("labelColor(%q) = %q, want %q", tc.name, got, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Name / Description tests ──
|
||||||
|
|
||||||
|
func TestNameAndDescription(t *testing.T) {
|
||||||
|
tool := New(Config{}, nil)
|
||||||
|
if tool.Name() != "create_gitea_ticket" {
|
||||||
|
t.Errorf("unexpected name: %s", tool.Name())
|
||||||
|
}
|
||||||
|
if tool.Description() == "" {
|
||||||
|
t.Error("description should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Integration test with mock Gitea server ──
|
||||||
|
|
||||||
|
func TestCallWithMockGitea(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var createdIssue map[string]interface{}
|
||||||
|
labelIDCounter := int64(100)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Verify auth header
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth != "token test-token" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
// GET /api/v1/repos/owner/repo/labels
|
||||||
|
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/labels"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// Return one existing label
|
||||||
|
json.NewEncoder(w).Encode([]giteaLabelResp{
|
||||||
|
{ID: 1, Name: "type/story"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/v1/repos/owner/repo/labels
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/labels"):
|
||||||
|
var payload map[string]string
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
mu.Lock()
|
||||||
|
labelIDCounter++
|
||||||
|
id := labelIDCounter
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(giteaLabelResp{
|
||||||
|
ID: id,
|
||||||
|
Name: payload["name"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/v1/repos/owner/repo/issues
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/issues"):
|
||||||
|
json.NewDecoder(r.Body).Decode(&createdIssue)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(giteaIssueResp{
|
||||||
|
ID: 42,
|
||||||
|
Number: 42,
|
||||||
|
HTMLURL: "https://gitea.example.com/owner/repo/issues/42",
|
||||||
|
Title: createdIssue["title"].(string),
|
||||||
|
State: "open",
|
||||||
|
CreatedAt: "2026-03-11T10:00:00Z",
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tool := New(Config{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
Token: "test-token",
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
in := validTicketInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
result, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(result, "#42") {
|
||||||
|
t.Error("result missing issue number")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "创建成功") {
|
||||||
|
t.Error("result missing success message")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "FEAT_OTA_001") {
|
||||||
|
t.Error("result missing parent reference")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "8 小时") {
|
||||||
|
t.Error("result missing estimated hours")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the issue body sent to Gitea contains SAFe metadata
|
||||||
|
if body, ok := createdIssue["body"].(string); ok {
|
||||||
|
if !strings.Contains(body, "SAFe 元数据") {
|
||||||
|
t.Error("issue body missing SAFe metadata")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "FEAT_OTA_001") {
|
||||||
|
t.Error("issue body missing parent reference")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Error("createdIssue body not captured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallMissingConfig(t *testing.T) {
|
||||||
|
tool := New(Config{}, nil)
|
||||||
|
in := validTicketInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
_, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "missing Gitea configuration") {
|
||||||
|
t.Fatalf("expected config error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallInvalidInput(t *testing.T) {
|
||||||
|
tool := New(Config{
|
||||||
|
BaseURL: "http://localhost",
|
||||||
|
Token: "t",
|
||||||
|
Owner: "o",
|
||||||
|
Repo: "r",
|
||||||
|
}, nil)
|
||||||
|
_, err := tool.Call(context.Background(), "not json")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallValidationError(t *testing.T) {
|
||||||
|
tool := New(Config{
|
||||||
|
BaseURL: "http://localhost",
|
||||||
|
Token: "t",
|
||||||
|
Owner: "o",
|
||||||
|
Repo: "r",
|
||||||
|
}, nil)
|
||||||
|
in := validTicketInput()
|
||||||
|
in.Title = ""
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
_, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "title") {
|
||||||
|
t.Fatalf("expected title validation error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that labels resolution creates missing labels
|
||||||
|
func TestCallCreatesNewLabels(t *testing.T) {
|
||||||
|
createdLabels := []string{}
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/labels"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// No existing labels
|
||||||
|
json.NewEncoder(w).Encode([]giteaLabelResp{})
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/labels"):
|
||||||
|
var payload map[string]string
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
mu.Lock()
|
||||||
|
createdLabels = append(createdLabels, payload["name"])
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(giteaLabelResp{
|
||||||
|
ID: int64(len(createdLabels)),
|
||||||
|
Name: payload["name"],
|
||||||
|
})
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/issues"):
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(giteaIssueResp{
|
||||||
|
ID: 1, Number: 1, HTMLURL: "http://test/1", Title: "t", State: "open",
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tool := New(Config{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
Token: "tok",
|
||||||
|
Owner: "o",
|
||||||
|
Repo: "r",
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
in := validTicketInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
_, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(createdLabels) != 4 {
|
||||||
|
t.Errorf("expected 4 labels created, got %d: %v", len(createdLabels), createdLabels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatResult(t *testing.T) {
|
||||||
|
issue := &giteaIssueResp{
|
||||||
|
Number: 99,
|
||||||
|
Title: "测试任务",
|
||||||
|
State: "open",
|
||||||
|
HTMLURL: "https://gitea.example.com/issues/99",
|
||||||
|
}
|
||||||
|
ticket := &TicketInput{
|
||||||
|
ParentReferenceID: "ENAB_KAFKA_001",
|
||||||
|
Labels: []string{"type/enabler"},
|
||||||
|
EstimatedHours: 4,
|
||||||
|
}
|
||||||
|
result := formatResult(issue, ticket)
|
||||||
|
|
||||||
|
if !strings.Contains(result, "#99") {
|
||||||
|
t.Error("missing issue number")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "ENAB_KAFKA_001") {
|
||||||
|
t.Error("missing parent reference")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "4 小时") {
|
||||||
|
t.Error("missing estimated hours")
|
||||||
|
}
|
||||||
|
}
|
||||||
309
tools/piplan/piplan.go
Normal file
309
tools/piplan/piplan.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
package piplan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Feature 产品经理视角输出的业务特性。
|
||||||
|
type Feature struct {
|
||||||
|
FeatureID string `json:"feature_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
BenefitHypothesis string `json:"benefit_hypothesis"`
|
||||||
|
AcceptanceCriteria []string `json:"acceptance_criteria"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabler 系统架构师视角输出的技术赋能特性(架构跑道)。
|
||||||
|
type Enabler struct {
|
||||||
|
EnablerID string `json:"enabler_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArchitecturalPurpose string `json:"architectural_purpose"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NFRs 非功能性需求。
|
||||||
|
type NFRs struct {
|
||||||
|
Performance string `json:"performance"`
|
||||||
|
Security string `json:"security"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependency RTE 梳理的任务依赖关系。
|
||||||
|
type Dependency struct {
|
||||||
|
SourceID string `json:"source_id"`
|
||||||
|
TargetID string `json:"target_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PIPlanInput publish_pi_plan 工具的完整输入结构。
|
||||||
|
type PIPlanInput struct {
|
||||||
|
PIVision string `json:"pi_vision"`
|
||||||
|
Features []Feature `json:"features"`
|
||||||
|
Enablers []Enabler `json:"enablers"`
|
||||||
|
NFRs NFRs `json:"nfrs"`
|
||||||
|
Dependencies []Dependency `json:"dependencies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool 实现 SAFe PI 规划发布工具。
|
||||||
|
type Tool struct {
|
||||||
|
maxOutputChars int
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建一个新的 publish_pi_plan 工具实例。
|
||||||
|
func New(maxOutputChars int, log *logger.Logger) *Tool {
|
||||||
|
if maxOutputChars <= 0 {
|
||||||
|
maxOutputChars = 20000
|
||||||
|
}
|
||||||
|
return &Tool{
|
||||||
|
maxOutputChars: maxOutputChars,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Name() string { return "publish_pi_plan" }
|
||||||
|
|
||||||
|
func (t *Tool) Description() string {
|
||||||
|
return `当铁三角(PM, 架构师, RTE)完成 PI 规划推演后,调用此工具输出标准化的架构蓝图与任务清单。输入为 JSON,包含 pi_vision, features, enablers, nfrs, dependencies 字段。`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
||||||
|
plan, err := parseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("publish_pi_plan: invalid input: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validate(plan); err != nil {
|
||||||
|
return "", fmt.Errorf("publish_pi_plan: validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("publish_pi_plan: features=%d enablers=%d deps=%d",
|
||||||
|
len(plan.Features), len(plan.Enablers), len(plan.Dependencies))
|
||||||
|
}
|
||||||
|
|
||||||
|
output := render(plan)
|
||||||
|
|
||||||
|
if len([]rune(output)) > t.maxOutputChars {
|
||||||
|
output = string([]rune(output)[:t.maxOutputChars])
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInput(input string) (*PIPlanInput, error) {
|
||||||
|
raw := strings.TrimSpace(input)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, fmt.Errorf("empty input")
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan PIPlanInput
|
||||||
|
if err := json.Unmarshal([]byte(raw), &plan); err != nil {
|
||||||
|
return nil, fmt.Errorf("JSON parse error: %w", err)
|
||||||
|
}
|
||||||
|
return &plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(p *PIPlanInput) error {
|
||||||
|
if strings.TrimSpace(p.PIVision) == "" {
|
||||||
|
return fmt.Errorf("pi_vision is required")
|
||||||
|
}
|
||||||
|
if len(p.Features) == 0 {
|
||||||
|
return fmt.Errorf("at least one feature is required")
|
||||||
|
}
|
||||||
|
for i, f := range p.Features {
|
||||||
|
if strings.TrimSpace(f.FeatureID) == "" {
|
||||||
|
return fmt.Errorf("features[%d].feature_id is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(f.Title) == "" {
|
||||||
|
return fmt.Errorf("features[%d].title is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(f.BenefitHypothesis) == "" {
|
||||||
|
return fmt.Errorf("features[%d].benefit_hypothesis is required", i)
|
||||||
|
}
|
||||||
|
if len(f.AcceptanceCriteria) == 0 {
|
||||||
|
return fmt.Errorf("features[%d].acceptance_criteria requires at least one item", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, e := range p.Enablers {
|
||||||
|
if strings.TrimSpace(e.EnablerID) == "" {
|
||||||
|
return fmt.Errorf("enablers[%d].enabler_id is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.Title) == "" {
|
||||||
|
return fmt.Errorf("enablers[%d].title is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.ArchitecturalPurpose) == "" {
|
||||||
|
return fmt.Errorf("enablers[%d].architectural_purpose is required", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.NFRs.Performance) == "" {
|
||||||
|
return fmt.Errorf("nfrs.performance is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.NFRs.Security) == "" {
|
||||||
|
return fmt.Errorf("nfrs.security is required")
|
||||||
|
}
|
||||||
|
for i, d := range p.Dependencies {
|
||||||
|
if strings.TrimSpace(d.SourceID) == "" {
|
||||||
|
return fmt.Errorf("dependencies[%d].source_id is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.TargetID) == "" {
|
||||||
|
return fmt.Errorf("dependencies[%d].target_id is required", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// render 将 PI 规划输入渲染为标准化的 Markdown 架构蓝图与任务清单。
|
||||||
|
func render(p *PIPlanInput) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// ── 标题 ──
|
||||||
|
b.WriteString("# PI 规划架构蓝图与任务清单\n\n")
|
||||||
|
|
||||||
|
// ── 1. PI 愿景 ──
|
||||||
|
b.WriteString("## 1. PI 愿景\n\n")
|
||||||
|
b.WriteString(strings.TrimSpace(p.PIVision))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// ── 2. 业务特性清单 (Features) ──
|
||||||
|
b.WriteString("## 2. 业务特性清单 (Features)\n\n")
|
||||||
|
for _, f := range p.Features {
|
||||||
|
b.WriteString(fmt.Sprintf("### %s — %s\n\n", f.FeatureID, f.Title))
|
||||||
|
b.WriteString(fmt.Sprintf("**业务价值假设**: %s\n\n", f.BenefitHypothesis))
|
||||||
|
b.WriteString("**验收标准 (AC)**:\n\n")
|
||||||
|
for j, ac := range f.AcceptanceCriteria {
|
||||||
|
b.WriteString(fmt.Sprintf("- [ ] AC-%d: %s\n", j+1, ac))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. 技术赋能特性 (Enablers / 架构跑道) ──
|
||||||
|
b.WriteString("## 3. 技术赋能特性 (Enablers / 架构跑道)\n\n")
|
||||||
|
if len(p.Enablers) == 0 {
|
||||||
|
b.WriteString("_无技术赋能特性。_\n\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("| Enabler ID | 名称 | 架构意图 |\n")
|
||||||
|
b.WriteString("|------------|------|----------|\n")
|
||||||
|
for _, e := range p.Enablers {
|
||||||
|
b.WriteString(fmt.Sprintf("| %s | %s | %s |\n",
|
||||||
|
e.EnablerID, e.Title, e.ArchitecturalPurpose))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. 非功能性需求 (NFRs) ──
|
||||||
|
b.WriteString("## 4. 非功能性需求 (NFRs)\n\n")
|
||||||
|
b.WriteString(fmt.Sprintf("- **性能**: %s\n", p.NFRs.Performance))
|
||||||
|
b.WriteString(fmt.Sprintf("- **安全与合规**: %s\n", p.NFRs.Security))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// ── 5. 依赖关系图 ──
|
||||||
|
b.WriteString("## 5. 依赖关系\n\n")
|
||||||
|
if len(p.Dependencies) == 0 {
|
||||||
|
b.WriteString("_无跨任务依赖。_\n\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("| 前置任务 (Source) | 后续任务 (Target) | 依赖原因 |\n")
|
||||||
|
b.WriteString("|-------------------|-------------------|----------|\n")
|
||||||
|
for _, d := range p.Dependencies {
|
||||||
|
reason := d.Reason
|
||||||
|
if reason == "" {
|
||||||
|
reason = "—"
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("| %s | %s | %s |\n",
|
||||||
|
d.SourceID, d.TargetID, reason))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. 建议执行顺序 ──
|
||||||
|
b.WriteString("## 6. 建议执行顺序\n\n")
|
||||||
|
order := computeExecutionOrder(p)
|
||||||
|
for i, id := range order {
|
||||||
|
b.WriteString(fmt.Sprintf("%d. %s\n", i+1, id))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// ── 7. 质量门禁检查清单 ──
|
||||||
|
b.WriteString("## 7. 质量门禁检查清单\n\n")
|
||||||
|
b.WriteString("### 业务验收测试用例\n\n")
|
||||||
|
for _, f := range p.Features {
|
||||||
|
for j, ac := range f.AcceptanceCriteria {
|
||||||
|
b.WriteString(fmt.Sprintf("- [ ] [%s] AC-%d: %s\n", f.FeatureID, j+1, ac))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("\n### 非功能性验证\n\n")
|
||||||
|
b.WriteString(fmt.Sprintf("- [ ] 性能压测: %s\n", p.NFRs.Performance))
|
||||||
|
b.WriteString(fmt.Sprintf("- [ ] 安全扫描: %s\n", p.NFRs.Security))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeExecutionOrder 根据依赖关系计算拓扑排序的执行顺序。
|
||||||
|
// 先排 Enabler,再排 Feature;无依赖的排在前面。
|
||||||
|
func computeExecutionOrder(p *PIPlanInput) []string {
|
||||||
|
// 收集所有 ID
|
||||||
|
allIDs := make([]string, 0, len(p.Enablers)+len(p.Features))
|
||||||
|
idSet := make(map[string]bool)
|
||||||
|
for _, e := range p.Enablers {
|
||||||
|
allIDs = append(allIDs, e.EnablerID)
|
||||||
|
idSet[e.EnablerID] = true
|
||||||
|
}
|
||||||
|
for _, f := range p.Features {
|
||||||
|
allIDs = append(allIDs, f.FeatureID)
|
||||||
|
idSet[f.FeatureID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建入度表和邻接表
|
||||||
|
inDegree := make(map[string]int)
|
||||||
|
adj := make(map[string][]string)
|
||||||
|
for _, id := range allIDs {
|
||||||
|
inDegree[id] = 0
|
||||||
|
}
|
||||||
|
for _, d := range p.Dependencies {
|
||||||
|
if !idSet[d.SourceID] || !idSet[d.TargetID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
adj[d.SourceID] = append(adj[d.SourceID], d.TargetID)
|
||||||
|
inDegree[d.TargetID]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kahn 拓扑排序
|
||||||
|
queue := make([]string, 0)
|
||||||
|
// 先加入度为 0 的 Enabler,再加入度为 0 的 Feature,保持稳定顺序
|
||||||
|
for _, e := range p.Enablers {
|
||||||
|
if inDegree[e.EnablerID] == 0 {
|
||||||
|
queue = append(queue, e.EnablerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range p.Features {
|
||||||
|
if inDegree[f.FeatureID] == 0 {
|
||||||
|
queue = append(queue, f.FeatureID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
for len(queue) > 0 {
|
||||||
|
curr := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
result = append(result, curr)
|
||||||
|
for _, next := range adj[curr] {
|
||||||
|
inDegree[next]--
|
||||||
|
if inDegree[next] == 0 {
|
||||||
|
queue = append(queue, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果存在环,将未排序的节点追加到末尾并标记
|
||||||
|
if len(result) < len(allIDs) {
|
||||||
|
for _, id := range allIDs {
|
||||||
|
if inDegree[id] > 0 {
|
||||||
|
result = append(result, id+" ⚠️(循环依赖)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
334
tools/piplan/piplan_test.go
Normal file
334
tools/piplan/piplan_test.go
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
package piplan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validInput() PIPlanInput {
|
||||||
|
return PIPlanInput{
|
||||||
|
PIVision: "实现车云一体化 OTA 系统,支撑百万级终端设备的安全固件升级",
|
||||||
|
Features: []Feature{
|
||||||
|
{
|
||||||
|
FeatureID: "FEAT_OTA_001",
|
||||||
|
Title: "云端固件版本依赖检查",
|
||||||
|
BenefitHypothesis: "上线后减少 30% 的固件回退率",
|
||||||
|
AcceptanceCriteria: []string{
|
||||||
|
"上传固件时自动解析并记录版本依赖关系",
|
||||||
|
"下发升级任务时自动校验设备当前版本是否满足依赖",
|
||||||
|
"不满足依赖时返回明确的错误提示及所需前置版本",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FeatureID: "FEAT_OTA_002",
|
||||||
|
Title: "端侧断点续传",
|
||||||
|
BenefitHypothesis: "弱网环境下固件下载成功率提升至 99.5%",
|
||||||
|
AcceptanceCriteria: []string{
|
||||||
|
"支持分片下载与本地缓存校验",
|
||||||
|
"网络恢复后自动续传,无需用户干预",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Enablers: []Enabler{
|
||||||
|
{
|
||||||
|
EnablerID: "ENAB_KAFKA_001",
|
||||||
|
Title: "搭建跨可用区的高可用 Kafka 集群",
|
||||||
|
ArchitecturalPurpose: "为高并发 OTA 状态机提供可靠消息管道",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
EnablerID: "ENAB_S3_001",
|
||||||
|
Title: "对象存储多区域同步",
|
||||||
|
ArchitecturalPurpose: "保证固件文件在多区域的低延迟分发",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NFRs: NFRs{
|
||||||
|
Performance: "API 响应时间 P99 < 200ms,吞吐量 > 10000 QPS",
|
||||||
|
Security: "车云通信必须使用 TLS 1.3,敏感数据必须脱敏",
|
||||||
|
},
|
||||||
|
Dependencies: []Dependency{
|
||||||
|
{
|
||||||
|
SourceID: "ENAB_KAFKA_001",
|
||||||
|
TargetID: "FEAT_OTA_001",
|
||||||
|
Reason: "版本检查服务依赖 Kafka 进行异步事件通知",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: "ENAB_S3_001",
|
||||||
|
TargetID: "FEAT_OTA_002",
|
||||||
|
Reason: "断点续传需要对象存储支持 Range 请求",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputValid(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
plan, err := parseInput(string(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if plan.PIVision != in.PIVision {
|
||||||
|
t.Errorf("pi_vision mismatch: got %q", plan.PIVision)
|
||||||
|
}
|
||||||
|
if len(plan.Features) != 2 {
|
||||||
|
t.Errorf("expected 2 features, got %d", len(plan.Features))
|
||||||
|
}
|
||||||
|
if len(plan.Enablers) != 2 {
|
||||||
|
t.Errorf("expected 2 enablers, got %d", len(plan.Enablers))
|
||||||
|
}
|
||||||
|
if len(plan.Dependencies) != 2 {
|
||||||
|
t.Errorf("expected 2 dependencies, got %d", len(plan.Dependencies))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputEmpty(t *testing.T) {
|
||||||
|
_, err := parseInput("")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputInvalidJSON(t *testing.T) {
|
||||||
|
_, err := parseInput("{not json}")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMissingVision(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.PIVision = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "pi_vision") {
|
||||||
|
t.Fatalf("expected pi_vision error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNoFeatures(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.Features = nil
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "feature") {
|
||||||
|
t.Fatalf("expected feature error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFeatureMissingID(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.Features[0].FeatureID = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "feature_id") {
|
||||||
|
t.Fatalf("expected feature_id error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFeatureMissingAC(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.Features[0].AcceptanceCriteria = nil
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "acceptance_criteria") {
|
||||||
|
t.Fatalf("expected acceptance_criteria error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEnablerMissingPurpose(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.Enablers[0].ArchitecturalPurpose = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "architectural_purpose") {
|
||||||
|
t.Fatalf("expected architectural_purpose error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNFRsMissingPerformance(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.NFRs.Performance = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "performance") {
|
||||||
|
t.Fatalf("expected performance error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNFRsMissingSecurity(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.NFRs.Security = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "security") {
|
||||||
|
t.Fatalf("expected security error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDepMissingSourceID(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.Dependencies[0].SourceID = ""
|
||||||
|
err := validate(&in)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "source_id") {
|
||||||
|
t.Fatalf("expected source_id error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderContainsSections(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
out := render(&in)
|
||||||
|
|
||||||
|
sections := []string{
|
||||||
|
"# PI 规划架构蓝图与任务清单",
|
||||||
|
"## 1. PI 愿景",
|
||||||
|
"## 2. 业务特性清单 (Features)",
|
||||||
|
"## 3. 技术赋能特性 (Enablers / 架构跑道)",
|
||||||
|
"## 4. 非功能性需求 (NFRs)",
|
||||||
|
"## 5. 依赖关系",
|
||||||
|
"## 6. 建议执行顺序",
|
||||||
|
"## 7. 质量门禁检查清单",
|
||||||
|
}
|
||||||
|
for _, s := range sections {
|
||||||
|
if !strings.Contains(out, s) {
|
||||||
|
t.Errorf("output missing section: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderContainsFeatureDetails(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
out := render(&in)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "FEAT_OTA_001") {
|
||||||
|
t.Error("output missing FEAT_OTA_001")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "云端固件版本依赖检查") {
|
||||||
|
t.Error("output missing feature title")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "AC-1") {
|
||||||
|
t.Error("output missing acceptance criteria numbering")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderContainsEnablerTable(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
out := render(&in)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "ENAB_KAFKA_001") {
|
||||||
|
t.Error("output missing ENAB_KAFKA_001")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "ENAB_S3_001") {
|
||||||
|
t.Error("output missing ENAB_S3_001")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderContainsNFRs(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
out := render(&in)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "P99 < 200ms") {
|
||||||
|
t.Error("output missing performance NFR")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "TLS 1.3") {
|
||||||
|
t.Error("output missing security NFR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderContainsDependencies(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
out := render(&in)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "ENAB_KAFKA_001") || !strings.Contains(out, "FEAT_OTA_001") {
|
||||||
|
t.Error("output missing dependency pair")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeExecutionOrder(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
order := computeExecutionOrder(&in)
|
||||||
|
|
||||||
|
// Enablers should come before their dependent Features
|
||||||
|
enablerIdx := map[string]int{}
|
||||||
|
featureIdx := map[string]int{}
|
||||||
|
for i, id := range order {
|
||||||
|
if strings.HasPrefix(id, "ENAB_") {
|
||||||
|
enablerIdx[id] = i
|
||||||
|
} else if strings.HasPrefix(id, "FEAT_") {
|
||||||
|
featureIdx[id] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enablerIdx["ENAB_KAFKA_001"] >= featureIdx["FEAT_OTA_001"] {
|
||||||
|
t.Error("ENAB_KAFKA_001 should come before FEAT_OTA_001")
|
||||||
|
}
|
||||||
|
if enablerIdx["ENAB_S3_001"] >= featureIdx["FEAT_OTA_002"] {
|
||||||
|
t.Error("ENAB_S3_001 should come before FEAT_OTA_002")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeExecutionOrderNoDeps(t *testing.T) {
|
||||||
|
in := validInput()
|
||||||
|
in.Dependencies = nil
|
||||||
|
order := computeExecutionOrder(&in)
|
||||||
|
|
||||||
|
if len(order) != 4 {
|
||||||
|
t.Errorf("expected 4 items, got %d", len(order))
|
||||||
|
}
|
||||||
|
// Enablers first, then Features (stable order)
|
||||||
|
if order[0] != "ENAB_KAFKA_001" || order[1] != "ENAB_S3_001" {
|
||||||
|
t.Errorf("enablers should come first, got: %v", order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallEndToEnd(t *testing.T) {
|
||||||
|
tool := New(0, nil)
|
||||||
|
in := validInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
result, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "PI 规划架构蓝图") {
|
||||||
|
t.Error("output missing title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallInvalidInput(t *testing.T) {
|
||||||
|
tool := New(0, nil)
|
||||||
|
_, err := tool.Call(context.Background(), "not json")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallMissingRequiredField(t *testing.T) {
|
||||||
|
tool := New(0, nil)
|
||||||
|
in := validInput()
|
||||||
|
in.PIVision = ""
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
_, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNameAndDescription(t *testing.T) {
|
||||||
|
tool := New(0, nil)
|
||||||
|
if tool.Name() != "publish_pi_plan" {
|
||||||
|
t.Errorf("unexpected name: %s", tool.Name())
|
||||||
|
}
|
||||||
|
if tool.Description() == "" {
|
||||||
|
t.Error("description should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaxOutputTruncation(t *testing.T) {
|
||||||
|
tool := New(100, nil)
|
||||||
|
in := validInput()
|
||||||
|
data, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
result, err := tool.Call(context.Background(), string(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(result) > 100 {
|
||||||
|
t.Errorf("output should be truncated to 100 chars, got %d", len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
|
|
||||||
// Config 定义了网络搜索工具所需的配置参数。
|
// Config 定义了网络搜索工具所需的配置参数。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Engine string // 搜索引擎类型,支持 "duckduckgo" 或 "brave"
|
Engine string // 搜索引擎类型,支持 "duckduckgo"、"brave" 或 "tavily"
|
||||||
APIKey string // 搜索引擎的 API Key(Brave 搜索必填)
|
APIKey string // 搜索引擎的 API Key(Brave 或 Tavily 搜索必填)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool represents a web search tool.
|
// Tool represents a web search tool.
|
||||||
@@ -85,6 +85,8 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
|||||||
switch t.engine {
|
switch t.engine {
|
||||||
case "brave":
|
case "brave":
|
||||||
result, err = t.searchBrave(ctx, query)
|
result, err = t.searchBrave(ctx, query)
|
||||||
|
case "tavily":
|
||||||
|
result, err = t.searchTavily(ctx, query)
|
||||||
default:
|
default:
|
||||||
result, err = t.searchDuckDuckGo(ctx, query)
|
result, err = t.searchDuckDuckGo(ctx, query)
|
||||||
}
|
}
|
||||||
@@ -126,12 +128,20 @@ func (t *Tool) searchDuckDuckGo(ctx context.Context, query string) (string, erro
|
|||||||
return "", fmt.Errorf("read response body failed: %w", err)
|
return "", fmt.Errorf("read response body failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Debugf("duckduckgo raw response: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
var ddg duckDuckGoResponse
|
var ddg duckDuckGoResponse
|
||||||
if err := json.Unmarshal(body, &ddg); err != nil {
|
if err := json.Unmarshal(body, &ddg); err != nil {
|
||||||
return "", fmt.Errorf("parse duckduckgo response failed: %w", err)
|
return "", fmt.Errorf("parse duckduckgo response failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.formatDuckDuckGoResult(query, ddg), nil
|
result := t.formatDuckDuckGoResult(query, ddg)
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("duckduckgo search finished, content_found=%v", (ddg.Answer != "" || ddg.AbstractText != "" || len(ddg.RelatedTopics) > 0))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// duckDuckGoResponse 从 DuckDuckGo 获取的即时结果 JSON 映射结构。
|
// duckDuckGoResponse 从 DuckDuckGo 获取的即时结果 JSON 映射结构。
|
||||||
@@ -239,11 +249,19 @@ func (t *Tool) searchBrave(ctx context.Context, query string) (string, error) {
|
|||||||
return "", fmt.Errorf("read response body failed: %w", err)
|
return "", fmt.Errorf("read response body failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Debugf("brave search raw response: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
var braveResp braveSearchResponse
|
var braveResp braveSearchResponse
|
||||||
if err := json.Unmarshal(body, &braveResp); err != nil {
|
if err := json.Unmarshal(body, &braveResp); err != nil {
|
||||||
return "", fmt.Errorf("parse brave response failed: %w", err)
|
return "", fmt.Errorf("parse brave response failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("brave search finished, results_count=%d", len(braveResp.Web.Results))
|
||||||
|
}
|
||||||
|
|
||||||
return t.formatBraveResult(query, braveResp), nil
|
return t.formatBraveResult(query, braveResp), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,3 +304,92 @@ func (t *Tool) formatBraveResult(query string, resp braveSearchResponse) string
|
|||||||
|
|
||||||
return strings.TrimSpace(b.String())
|
return strings.TrimSpace(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// searchTavily uses the Tavily Search API (requires API key).
|
||||||
|
func (t *Tool) searchTavily(ctx context.Context, query string) (string, error) {
|
||||||
|
if t.apiKey == "" {
|
||||||
|
return "", fmt.Errorf("WEB_SEARCH_API_KEY is required for Tavily engine")
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := "https://api.tavily.com/search"
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"api_key": t.apiKey,
|
||||||
|
"query": query,
|
||||||
|
"search_depth": "basic",
|
||||||
|
"include_answer": true,
|
||||||
|
"include_images": false,
|
||||||
|
"include_raw_content": false,
|
||||||
|
"max_results": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal tavily payload failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(jsonData)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request failed: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := t.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("http request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodySnippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return "", fmt.Errorf("tavily search returned status %d: %s", resp.StatusCode, string(bodySnippet))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read response body failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Debugf("tavily search raw response: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tavilyResp tavilyResponse
|
||||||
|
if err := json.Unmarshal(body, &tavilyResp); err != nil {
|
||||||
|
return "", fmt.Errorf("parse tavily response failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.formatTavilyResult(query, tavilyResp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tavilyResponse struct {
|
||||||
|
Answer string `json:"answer"`
|
||||||
|
Results []tavilyResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tavilyResult struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) formatTavilyResult(query string, resp tavilyResponse) string {
|
||||||
|
b := strings.Builder{}
|
||||||
|
b.WriteString("Search: " + query + "\n")
|
||||||
|
b.WriteString("Engine: Tavily\n\n")
|
||||||
|
|
||||||
|
if resp.Answer != "" {
|
||||||
|
b.WriteString("Answer: " + resp.Answer + "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Results) == 0 && resp.Answer == "" {
|
||||||
|
b.WriteString("No results found.\n")
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, r := range resp.Results {
|
||||||
|
b.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,62 @@
|
|||||||
package websearch
|
package websearch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestTavilyIntegration(t *testing.T) {
|
||||||
|
// 这是一个集成测试,会实际请求网络。
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这里的 API Key 从环境变量中获取或手动填写的测试环境变量中读取。
|
||||||
|
apiKey := "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"
|
||||||
|
if apiKey == "" {
|
||||||
|
t.Skip("TAVILY_API_KEY is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := New(Config{Engine: "tavily", APIKey: apiKey}, 4000, nil)
|
||||||
|
ctx := context.Background()
|
||||||
|
query := "wuhan weather today"
|
||||||
|
|
||||||
|
result, err := tool.Call(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Tavily search failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Tavily search result for '%s':\n%s\n", query, result)
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
t.Fatal("expected non-empty result from Tavily")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuckDuckGoIntegration(t *testing.T) {
|
||||||
|
// 这是一个集成测试,会实际请求网络。
|
||||||
|
// 在 CI 环境中可能需要跳过。
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := New(Config{Engine: "duckduckgo"}, 4000, nil)
|
||||||
|
ctx := context.Background()
|
||||||
|
query := "wuhan weather"
|
||||||
|
|
||||||
|
result, err := tool.Call(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DuckDuckGo search failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("DuckDuckGo search result for '%s':\n%s\n", query, result)
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
t.Fatal("expected non-empty result from DuckDuckGo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewDefaultEngine(t *testing.T) {
|
func TestNewDefaultEngine(t *testing.T) {
|
||||||
tool := New(Config{}, 4000, nil)
|
tool := New(Config{}, 4000, nil)
|
||||||
if tool.Name() != "web_search" {
|
if tool.Name() != "web_search" {
|
||||||
|
|||||||
Reference in New Issue
Block a user