feat: implement streaming chat, skill routing, and SAFe PI planning tools

- Add /api/chat/stream endpoint with Server-Sent Events (SSE) for real-time message streaming
  * Implement StreamEvent types (thought, tool_call, tool_result, final, error)
  * Add StreamEventCallback mechanism for event propagation
  * Create StreamChatHandler in webui/bot with proper HTTP headers and flushing

- Implement LLM-based skill router for intelligent capability selection
  * Add optional routerLLM client for semantic routing
  * Implement routeSkillsWithLLM() to match user intent to available skills
  * Add matchSkillsByName() for fuzzy skill matching
  * Update buildUnifiedSystemPrompt() to use routed skills

- Add streaming support to ReAct pipeline
  * Implement runUnifiedReActStream() for streaming thought/action/observation
  * Emit StreamEvent at each ReAct step
  * Support callback error handling in streaming mode

- Integrate three new DevOps tools
  * tools/filedoc: Extract document content from file_id via OpenAI
  * tools/giteaticket: Create Gitea issues from PI plan items with SAFe metadata
  * tools/piplan: Publish PI planning blueprints with dependency tracking

- Add SAFe PI Planning skill
  * Implement PM/SA/RTE (iron triangle) workflow
  * Support for Feature, Enabler, and Dependency definition
  * Automatic task decomposition and Gitea integration

- Create frontend integration documentation
  * Complete SSE protocol specification
  * TypeScript fetch + ReadableStream example
  * LLM-ready refactoring template for other projects

- Simplify file handling
  * Remove legacy file context structures and dual-mode processing
  * Consolidate file operations into UploadAndCacheFiles()
  * Remove FilePromptMode configuration and related complexity

- Update configuration
  * Add Router model support (LLM_ROUTER_MODEL)
  * Add Gitea configuration (BaseURL, Token, Owner, Repo)
  * WebSearch and additional tool infrastructure

Tests: All 22 test packages passing, 8/8 webui tests including 3 new stream tests
This commit is contained in:
2026-03-11 17:58:19 +08:00
parent 0e1a800646
commit 8dc5354fa4
17 changed files with 3086 additions and 565 deletions

309
tools/piplan/piplan.go Normal file
View 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(output) > t.maxOutputChars {
output = 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
View 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))
}
}