gitlink-cli/cmd/server/server.go

590 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 子赛题四「网页终端」HTTP 演示服务
*
* 前端搜索驱动界面 → 后端串行运行 Python 算法脚本
* 支持 7 个科研分析维度(热点/画像/启发/谱系/合规/报告/可视化)
* Python 子进程继承 GITLINK_TOKEN / DEEPSEEK_API_KEY 环境变量
*
* API:
* GET /api/dimensions - 返回可用维度列表
* POST /api/chain - 统一全链路分析入口
* POST /api/run - 单场景执行(向后兼容)
* GET /api/result/{key} - 查询缓存产物
*/
package server
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/spf13/cobra"
)
//go:embed static/*
var staticFS embed.FS
const defaultPort = 8080
// 场景定义:前端按钮 ↔ 后端 Python 脚本。
type scenarioDef struct {
Key string `json:"key"`
Label string `json:"label"`
Desc string `json:"desc"`
Script string `json:"script"`
Needs []string `json:"needs"` // 需要的输入: "owner","repo","keyword"
Timeout int `json:"timeout_s"`
}
var scenarios = []scenarioDef{
{Key: "s1", Label: "S1 仓库洞悉", Desc: "科研项目演进谱系 + 创新点", Script: "lineage.py", Needs: []string{"owner", "repo"}, Timeout: 180},
{Key: "s2", Label: "S2 知识图谱", Desc: "科研领域知识图谱(networkx)", Script: "graph_build.py", Needs: []string{"keyword"}, Timeout: 240},
{Key: "s3", Label: "S3 合规复现", Desc: "许可证/密钥/复现性检查", Script: "repro.py", Needs: []string{"owner", "repo"}, Timeout: 120},
{Key: "s4", Label: "S4 协作匹配", Desc: "学者×缺口 智能匹配", Script: "match.py", Needs: []string{"owner", "repo"}, Timeout: 180},
{Key: "s5", Label: "S5 进度预警", Desc: "周报 + 风险预警", Script: "report.py", Needs: []string{"owner", "repo"}, Timeout: 180},
{Key: "s6", Label: "S6 成果可视化", Desc: "交互图表(plotly)", Script: "visual.py", Needs: []string{"owner", "repo"}, Timeout: 240},
{Key: "hotspot", Label: "🔥 热点追踪(关键词)", Desc: "关键词搜索:飙升项目+活跃讨论+主题热度+学者团队", Script: "hotspot.py", Needs: []string{"keyword"}, Timeout: 300},
{Key: "hotspot-cat", Label: "🔥 热点追踪(分类精选)", Desc: "GitLink 官方分类精选 → 领域热点(缩范围)", Script: "hotspot.py", Needs: []string{"category"}, Timeout: 300},
{Key: "profile", Label: "🪪 主体画像", Desc: "项目画像:主题/语言/贡献者/研究维度评分", Script: "profile.py", Needs: []string{"owner", "repo"}, Timeout: 150},
{Key: "inspire", Label: "💡 创新启发", Desc: "缺口挖掘 + 合作者匹配 + LLM 研究方向建议", Script: "inspire.py", Needs: []string{"owner", "repo"}, Timeout: 240},
{Key: "chain", Label: "🔬 全链路", Desc: "分类→热点→画像→启发→合规→分析(一条命令打通)", Script: "research.py", Needs: []string{"category"}, Timeout: 600},
}
// dimensionDef 分析维度:前端展示用,对应一个 Python 脚本。
type dimensionDef struct {
Key string `json:"key"`
Label string `json:"label"`
Icon string `json:"icon"`
Desc string `json:"desc"`
Script string `json:"script"`
Needs []string `json:"needs"` // 需要的参数: "keyword","category","owner","repo","owner_repo"
Timeout int `json:"timeout_s"`
}
var dimensions = []dimensionDef{
{Key: "hotspot", Label: "科研热点分析", Icon: "🔥", Desc: "飙升项目 + 活跃讨论 + 主题热度 + 核心学者", Script: "hotspot.py", Needs: []string{"keyword_or_category"}, Timeout: 300},
{Key: "profile", Label: "主体画像", Icon: "🪪", Desc: "项目/学者画像:主题向量/语言/研究维度评分", Script: "profile.py", Needs: []string{"owner_repo_or_category"}, Timeout: 150},
{Key: "inspire", Label: "创新启发", Icon: "💡", Desc: "缺口挖掘 + 合作者匹配 + LLM 研究方向建议", Script: "inspire.py", Needs: []string{"owner_repo_or_category"}, Timeout: 240},
{Key: "lineage", Label: "演进谱系", Icon: "🌳", Desc: "创新点识别 + 项目演化分支 + 贡献者参与分析", Script: "lineage.py", Needs: []string{"owner", "repo"}, Timeout: 180},
{Key: "repro", Label: "合规复现", Icon: "✅", Desc: "许可证/依赖锁定/容器化/密钥泄漏/复现性评分", Script: "repro.py", Needs: []string{"owner", "repo"}, Timeout: 120},
{Key: "report", Label: "进度报告", Icon: "📋", Desc: "周报 + 里程碑追踪 + 风险预警(交通灯系统)", Script: "report.py", Needs: []string{"owner", "repo"}, Timeout: 180},
{Key: "visual", Label: "成果可视化", Icon: "📊", Desc: "交互式 Plotly 图表:时间线/热力图/语言饼图/Gantt", Script: "visual.py", Needs: []string{"owner", "repo"}, Timeout: 240},
}
// chainRequest 统一分析请求。
type chainRequest struct {
Dimensions []string `json:"dimensions"` // 选中的维度 key 列表
Keyword string `json:"keyword"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Category string `json:"category"`
}
// dimensionResult 单个维度的运行结果。
type dimensionResult struct {
Key string `json:"key"`
Label string `json:"label"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Duration string `json:"duration"`
Command string `json:"command"`
Stdout string `json:"stdout"`
OutDir string `json:"out_dir"`
Artifacts map[string]string `json:"artifacts"`
}
// chainResponse 统一分析 API 返回。
type chainResponse struct {
OK bool `json:"ok"`
SessionID string `json:"session_id"`
Duration string `json:"duration"`
Results map[string]*dimensionResult `json:"results"`
}
type Options struct {
Port int
ResearchDir string // scripts/research 目录
WorkDir string // 产物输出根目录
Token string // 可选鉴权 token
LLMKey string // LLM API Key内置到服务端非前端输入
LLMBase string // LLM API Base URL
LLMModel string // LLM Model 名称
GitLinkToken string // GitLink API Token内置Python 子进程通过 GITLINK_TOKEN 使用)
}
func NewServerCmd() *cobra.Command {
opts := Options{Port: defaultPort, ResearchDir: "scripts/research", WorkDir: "research-output", LLMKey: "sk-52b7f7db19fe41118d3b931bded9403c", LLMBase: "https://api.deepseek.com/anthropic", LLMModel: "deepseek-v4-pro", GitLinkToken: "330e35fbb163da345df372b4cbe1cf973aae2b67"}
cmd := &cobra.Command{
Use: "server",
Short: "启动子赛题四网页终端HTTP 演示服务)",
RunE: func(cmd *cobra.Command, args []string) error {
return Run(opts)
},
}
cmd.Flags().IntVarP(&opts.Port, "port", "p", defaultPort, "监听端口")
cmd.Flags().StringVar(&opts.ResearchDir, "research-dir", "scripts/research", "scripts/research 目录")
cmd.Flags().StringVar(&opts.WorkDir, "work-dir", "research-output", "产物输出根目录")
cmd.Flags().StringVar(&opts.Token, "token", "", "可选鉴权 token亦可用 DEMO_TOKEN 环境变量)")
cmd.Flags().StringVar(&opts.LLMKey, "llm-key", opts.LLMKey, "LLM API Key默认内置")
cmd.Flags().StringVar(&opts.LLMBase, "llm-base", opts.LLMBase, "LLM API Base URL")
cmd.Flags().StringVar(&opts.LLMModel, "llm-model", opts.LLMModel, "LLM Model 名称")
cmd.Flags().StringVar(&opts.GitLinkToken, "gitlink-token", opts.GitLinkToken, "GitLink API Token默认内置")
return cmd
}
// Run 启动 HTTP 服务(阻塞)。
func Run(opts Options) error {
if t := os.Getenv("DEMO_TOKEN"); t != "" && opts.Token == "" {
opts.Token = t
}
// LLM key 优先级:--llm-key > DEEPSEEK_API_KEY > LLM_API_KEY
if opts.LLMKey == "" {
if k := os.Getenv("DEEPSEEK_API_KEY"); k != "" {
opts.LLMKey = k
} else if k := os.Getenv("LLM_API_KEY"); k != "" {
opts.LLMKey = k
}
}
// GitLink token内置默认值注入进程环境子进程自动继承
if opts.GitLinkToken != "" && os.Getenv("GITLINK_TOKEN") == "" {
_ = os.Setenv("GITLINK_TOKEN", opts.GitLinkToken)
}
// 让 python 子进程复用本二进制gitlink_data.cli_path 读 GITLINK_CLI免去额外配置
if os.Getenv("GITLINK_CLI") == "" {
if exe, err := filepath.Abs(os.Args[0]); err == nil {
_ = os.Setenv("GITLINK_CLI", exe)
}
}
_ = os.MkdirAll(opts.WorkDir, 0o755)
mux := http.NewServeMux()
h := &handler{opts: opts}
mux.HandleFunc("GET /api/scenarios", h.handleScenarios)
mux.HandleFunc("POST /api/run", h.handleRun)
mux.HandleFunc("GET /api/result/{key}", h.handleResult)
mux.HandleFunc("GET /api/health", h.handleHealth)
mux.HandleFunc("GET /api/dimensions", h.handleDimensions)
mux.HandleFunc("POST /api/chain", h.handleChain)
sub, err := fs.Sub(staticFS, "static")
if err != nil {
return fmt.Errorf("static fs: %w", err)
}
mux.Handle("GET /", http.FileServer(http.FS(sub)))
addr := fmt.Sprintf(":%d", opts.Port)
fmt.Fprintf(os.Stderr, "子赛题四 网页终端已启动: http://localhost%s\n", addr)
fmt.Fprintf(os.Stderr, " research-dir=%s work-dir=%s auth=%v\n", opts.ResearchDir, opts.WorkDir, opts.Token != "")
if opts.LLMKey != "" {
fmt.Fprintf(os.Stderr, " LLM: enabled (model=%s)\n", opts.LLMModel)
} else {
fmt.Fprintf(os.Stderr, " LLM: disabled (no key)\n")
}
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
return srv.ListenAndServe()
}
type handler struct {
opts Options
mu sync.Mutex // 串行化场景执行,避免并发打爆 GitLink API
}
func (h *handler) authed(r *http.Request) bool {
if h.opts.Token == "" {
return true
}
return r.Header.Get("X-Demo-Token") == h.opts.Token
}
func (h *handler) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true, "scenarios": len(scenarios), "dimensions": len(dimensions)})
}
func (h *handler) handleDimensions(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
writeJSON(w, map[string]any{"ok": true, "dimensions": dimensions})
}
func (h *handler) handleScenarios(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
writeJSON(w, map[string]any{"ok": true, "scenarios": scenarios})
}
// handleResult 返回某场景最近一次运行的产物(供 result.html 独立结果页按 key 读取,
// URL 可刷新/分享,便于演示讲解)。无需鉴权串行锁——只读已落盘产物。
func (h *handler) handleResult(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
key := r.PathValue("key")
sc, ok := findScenario(key)
if !ok {
writeJSON(w, map[string]any{"ok": false, "error": "unknown scenario: " + key})
return
}
outDir := filepath.Join(h.opts.WorkDir, sc.Key)
writeJSON(w, map[string]any{
"ok": true,
"scenario": sc.Key,
"label": sc.Label,
"desc": sc.Desc,
"artifacts": readArtifacts(outDir),
})
}
type runRequest struct {
Scenario string `json:"scenario"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Keyword string `json:"keyword"`
Category string `json:"category"`
}
func (h *handler) handleRun(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req runRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": "bad request: " + err.Error()})
return
}
sc, ok := findScenario(req.Scenario)
if !ok {
writeJSON(w, map[string]any{"ok": false, "error": "unknown scenario: " + req.Scenario})
return
}
for _, need := range sc.Needs {
if (need == "keyword" && req.Keyword == "") ||
(need == "owner" && req.Owner == "") ||
(need == "repo" && req.Repo == "") ||
(need == "category" && req.Category == "") {
writeJSON(w, map[string]any{"ok": false, "error": "missing parameter: " + need})
return
}
}
// 串行执行:一次只跑一个场景,保护 GitLink API。
h.mu.Lock()
defer h.mu.Unlock()
scriptPath := filepath.Join(h.opts.ResearchDir, sc.Script)
outDir := filepath.Join(h.opts.WorkDir, sc.Key)
_ = os.MkdirAll(outDir, 0o755)
argv := []string{scriptPath, "--out", outDir}
if contains(sc.Needs, "owner") {
argv = append(argv, "--owner", req.Owner, "--repo", req.Repo)
}
if contains(sc.Needs, "keyword") {
argv = append(argv, "--keywords", req.Keyword)
}
if contains(sc.Needs, "category") {
argv = append(argv, "--category", req.Category)
}
// chain 支持可选焦点仓(省略则自动取热点榜 top-1
if sc.Key == "chain" && req.Owner != "" && req.Repo != "" {
argv = append(argv, "--repo", req.Owner+"/"+req.Repo)
}
// python3 优先,回退 python
py, err := pythonBin()
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
cmd := exec.Command(py, argv...)
// 子进程复用本二进制python 经 GITLINK_CLI 找 gitlink-cli直接注入子进程 env最稳。
env := os.Environ()
if !envHas(env, "GITLINK_CLI") {
if exe, err := filepath.Abs(os.Args[0]); err == nil {
env = append(env, "GITLINK_CLI="+exe)
}
}
cmd.Env = env
start := time.Now()
out, err := cmd.CombinedOutput()
dur := time.Since(start)
resp := map[string]any{
"ok": err == nil,
"scenario": sc.Key,
"command": py + " " + strings.Join(argv, " "),
"duration": dur.Truncate(time.Millisecond).String(),
"stdout": string(out),
"out_dir": outDir,
}
if err != nil {
resp["error"] = err.Error()
}
// 附带读取关键产物json + 第一个 mmd + report.md便于前端直接渲染
resp["artifacts"] = readArtifacts(outDir)
writeJSON(w, resp)
}
// handleChain 统一全链路科研分析入口
//
// 接受维度 key 列表 + 关键词/分类/仓库参数,串行执行各 Python 脚本,
// 聚合结果返回给前端 Tab 面板渲染。每个维度独立计时并记录成功/失败状态。
// 复用全局 mutex 防止并发打爆 GitLink API。
//
// 参数:
// w - HTTP ResponseWriter
// r - HTTP RequestJSON body 为 chainRequest
//
// 返回:
// JSON chainResponse包含 session_id、总耗时、各维度结果映射
func (h *handler) handleChain(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req chainRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, chainResponse{OK: false, Results: map[string]*dimensionResult{"_error": {Key: "_error", Label: "parse error", OK: false, Error: "bad request: " + err.Error()}}})
return
}
if len(req.Dimensions) == 0 {
writeJSON(w, chainResponse{OK: false, Results: map[string]*dimensionResult{"_error": {Key: "_error", Label: "no dimensions", OK: false, Error: "至少选择一个分析维度"}}})
return
}
// 校验所有维度 key 合法
for _, dk := range req.Dimensions {
if _, ok := findDimension(dk); !ok {
writeJSON(w, chainResponse{OK: false, Results: map[string]*dimensionResult{"_error": {Key: dk, Label: dk, OK: false, Error: "未知分析维度: " + dk}}})
return
}
}
// 串行执行(复用 mutex 保护 GitLink API
h.mu.Lock()
defer h.mu.Unlock()
sessionID := fmt.Sprintf("session_%s", time.Now().Format("20060102_150405"))
results := make(map[string]*dimensionResult)
totalStart := time.Now()
for _, dk := range req.Dimensions {
dim, _ := findDimension(dk)
dimArgs, err := buildDimensionArgs(dim, req)
outDir := filepath.Join(h.opts.WorkDir, sessionID, dim.Key)
_ = os.MkdirAll(outDir, 0o755)
dr := &dimensionResult{Key: dim.Key, Label: dim.Label, OutDir: outDir}
if err != nil {
dr.OK = false
dr.Error = err.Error()
results[dim.Key] = dr
continue
}
py, err := pythonBin()
if err != nil {
dr.OK = false
dr.Error = err.Error()
results[dim.Key] = dr
continue
}
scriptPath := filepath.Join(h.opts.ResearchDir, dim.Script)
argv := append([]string{scriptPath, "--out", outDir}, dimArgs...)
cmd := exec.Command(py, argv...)
env := os.Environ()
if !envHas(env, "GITLINK_CLI") {
if exe, e2 := filepath.Abs(os.Args[0]); e2 == nil {
env = append(env, "GITLINK_CLI="+exe)
}
}
// LLM key 注入子进程
if h.opts.LLMKey != "" {
if !envHas(env, "DEEPSEEK_API_KEY") && !envHas(env, "LLM_API_KEY") {
env = append(env, "DEEPSEEK_API_KEY="+h.opts.LLMKey)
}
if !envHas(env, "DEEPSEEK_BASE_URL") && !envHas(env, "LLM_BASE_URL") {
env = append(env, "DEEPSEEK_BASE_URL="+h.opts.LLMBase)
}
if !envHas(env, "DEEPSEEK_MODEL") && !envHas(env, "LLM_MODEL") {
env = append(env, "DEEPSEEK_MODEL="+h.opts.LLMModel)
}
}
cmd.Env = env
dr.Command = py + " " + strings.Join(argv, " ")
dimStart := time.Now()
out, runErr := cmd.CombinedOutput()
dr.Duration = time.Since(dimStart).Truncate(time.Millisecond).String()
dr.Stdout = string(out)
dr.OK = runErr == nil
if runErr != nil {
dr.Error = runErr.Error()
}
dr.Artifacts = readArtifacts(outDir)
results[dim.Key] = dr
}
resp := chainResponse{
OK: true,
SessionID: sessionID,
Duration: time.Since(totalStart).Truncate(time.Millisecond).String(),
Results: results,
}
writeJSON(w, resp)
}
func findScenario(key string) (scenarioDef, bool) {
for _, s := range scenarios {
if s.Key == key || strings.EqualFold(s.Key, key) {
return s, true
}
}
return scenarioDef{}, false
}
func findDimension(key string) (dimensionDef, bool) {
for _, d := range dimensions {
if d.Key == key || strings.EqualFold(d.Key, key) {
return d, true
}
}
return dimensionDef{}, false
}
func readArtifacts(dir string) map[string]string {
out := map[string]string{}
// json 产物(取第一个 *.json
if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
switch {
case strings.HasSuffix(name, ".json"):
b, _ := os.ReadFile(filepath.Join(dir, name))
out["json"] = string(b)
case strings.HasSuffix(name, ".mmd"):
b, _ := os.ReadFile(filepath.Join(dir, name))
out["mermaid"] = string(b)
case name == "visual.html":
b, _ := os.ReadFile(filepath.Join(dir, name))
out["html"] = string(b)
case strings.HasSuffix(name, ".md"):
// 第一份 .md 报告report.md / weekly_report.md / compliance_report.md
if _, ok := out["report"]; !ok {
b, _ := os.ReadFile(filepath.Join(dir, name))
out["report"] = string(b)
}
}
}
}
return out
}
func pythonBin() (string, error) {
// 候选按 Linux 习惯 python3 优先,再 python / py(Windows)。
// 必须实测能产出Windows 的 WindowsApps\python3.exe 是 Store 桩,对 -c 也可能 exit 0 但不真正执行,
// 故用「stdout 必须含 PYOK」来拦截桩。
for _, name := range []string{"python3", "python", "py"} {
path, err := exec.LookPath(name)
if err != nil {
continue
}
if out, err := exec.Command(path, "-c", "print('PYOK')").Output(); err == nil &&
strings.Contains(string(out), "PYOK") {
return path, nil
}
}
return "", fmt.Errorf("python 未安装;容器需内置 python3 并 pip install -r scripts/research/requirements.txt")
}
func contains(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}
// buildDimensionArgs 根据维度定义和请求参数构建 Python 脚本 CLI 参数。
func buildDimensionArgs(dim dimensionDef, req chainRequest) ([]string, error) {
argv := []string{} // script 在调用方追加
hasKeyword := req.Keyword != ""
hasCategory := req.Category != ""
hasRepo := req.Owner != "" && req.Repo != ""
// 检查每个 need
for _, need := range dim.Needs {
switch need {
case "keyword_or_category":
if !hasKeyword && !hasCategory {
return nil, fmt.Errorf("维度 %s 需要 --keywords 或 --category", dim.Key)
}
if hasCategory {
argv = append(argv, "--category", req.Category)
} else {
argv = append(argv, "--keywords", req.Keyword)
}
case "keyword":
if !hasKeyword {
return nil, fmt.Errorf("维度 %s 需要 --keywords", dim.Key)
}
argv = append(argv, "--keywords", req.Keyword)
case "owner_repo_or_category":
if !hasRepo && !hasCategory {
return nil, fmt.Errorf("维度 %s 需要 --owner/--repo 或 --category", dim.Key)
}
if hasCategory {
argv = append(argv, "--category", req.Category)
} else {
argv = append(argv, "--owner", req.Owner, "--repo", req.Repo)
}
case "owner":
if !hasRepo {
return nil, fmt.Errorf("维度 %s 需要 --owner 和 --repo", dim.Key)
}
argv = append(argv, "--owner", req.Owner)
case "repo":
if !hasRepo {
return nil, fmt.Errorf("维度 %s 需要 --repo", dim.Key)
}
argv = append(argv, "--repo", req.Repo)
}
}
return argv, nil
}
// envHas 报告环境变量切片里是否已含某 KEY形如 "KEY=...")。
func envHas(env []string, key string) bool {
prefix := key + "="
for _, e := range env {
if strings.HasPrefix(e, prefix) {
return true
}
}
return false
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(v)
}