|
|
|
|
@ -0,0 +1,685 @@
|
|
|
|
|
package course
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"archive/zip"
|
|
|
|
|
"bytes"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// coursePlan holds the parsed course structure.
|
|
|
|
|
type coursePlan struct {
|
|
|
|
|
RepoName string // English repo name (URL-safe)
|
|
|
|
|
Title string // Chinese course title
|
|
|
|
|
Description string // Course description
|
|
|
|
|
Chapters []string // Chapter titles
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func runCreate(ctx *common.RuntimeContext) error {
|
|
|
|
|
source := ctx.Arg("source")
|
|
|
|
|
if source == "" {
|
|
|
|
|
return fmt.Errorf("--source is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 1. Parse material
|
|
|
|
|
plan, err := parseMaterial(source, ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("parse material: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Try LLM enhancement if configured
|
|
|
|
|
if err := tryLLMEnhance(plan, ctx); err != nil {
|
|
|
|
|
// LLM is optional — continue with parsed content
|
|
|
|
|
fmt.Fprintf(os.Stderr, "[warn] LLM enhancement skipped: %v\n", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Get current user
|
|
|
|
|
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("get current user: %w", err)
|
|
|
|
|
}
|
|
|
|
|
userData, _ := userEnv.Data.(map[string]interface{})
|
|
|
|
|
login, _ := userData["login"].(string)
|
|
|
|
|
if login == "" {
|
|
|
|
|
return fmt.Errorf("cannot determine current user login")
|
|
|
|
|
}
|
|
|
|
|
userID, _ := userData["user_id"].(float64)
|
|
|
|
|
|
|
|
|
|
owner := ctx.Arg("owner")
|
|
|
|
|
if owner == "" {
|
|
|
|
|
owner = login
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
repoName := plan.RepoName
|
|
|
|
|
|
|
|
|
|
// 4. Create repository
|
|
|
|
|
fmt.Fprintf(os.Stderr, "[info] Creating repository: %s/%s\n", owner, repoName)
|
|
|
|
|
createBody := map[string]interface{}{
|
|
|
|
|
"name": repoName,
|
|
|
|
|
"repository_name": repoName,
|
|
|
|
|
"user_id": int(userID),
|
|
|
|
|
"description": plan.Description,
|
|
|
|
|
}
|
|
|
|
|
if ctx.Arg("private") == "true" {
|
|
|
|
|
createBody["private"] = true
|
|
|
|
|
}
|
|
|
|
|
_, err = ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, repoName), createBody)
|
|
|
|
|
if err != nil {
|
|
|
|
|
// Repository might already exist — continue
|
|
|
|
|
fmt.Fprintf(os.Stderr, "[warn] Repository creation (may already exist): %v\n", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 5. Prepare local git repo
|
|
|
|
|
tmpDir, err := os.MkdirTemp("", "course-*")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("create temp dir: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
|
|
|
|
|
|
token := os.Getenv("GITLINK_TOKEN")
|
|
|
|
|
cloneURL := fmt.Sprintf("https://%s@www.gitlink.org.cn/%s/%s.git", token, owner, repoName)
|
|
|
|
|
|
|
|
|
|
runCmd := func(name string, args ...string) error {
|
|
|
|
|
cmd := exec.Command(name, args...)
|
|
|
|
|
cmd.Dir = tmpDir
|
|
|
|
|
cmd.Stderr = os.Stderr
|
|
|
|
|
cmd.Stdout = os.Stderr
|
|
|
|
|
return cmd.Run()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fmt.Fprintf(os.Stderr, "[info] Cloning repository...\n")
|
|
|
|
|
if err := exec.Command("git", "clone", cloneURL, tmpDir).Run(); err != nil {
|
|
|
|
|
// Try without token
|
|
|
|
|
cloneURL2 := fmt.Sprintf("https://www.gitlink.org.cn/%s/%s.git", owner, repoName)
|
|
|
|
|
if err2 := exec.Command("git", "clone", cloneURL2, tmpDir).Run(); err2 != nil {
|
|
|
|
|
return fmt.Errorf("clone repository: %w (also tried: %v)", err, err2)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check default branch
|
|
|
|
|
branch := "master"
|
|
|
|
|
branchBytes, _ := exec.Command("git", "-C", tmpDir, "rev-parse", "--abbrev-ref", "HEAD").Output()
|
|
|
|
|
if strings.TrimSpace(string(branchBytes)) != "" {
|
|
|
|
|
branch = strings.TrimSpace(string(branchBytes))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 6. Generate course files
|
|
|
|
|
fmt.Fprintf(os.Stderr, "[info] Generating course content...\n")
|
|
|
|
|
if err := generateCourseFiles(tmpDir, plan); err != nil {
|
|
|
|
|
return fmt.Errorf("generate course files: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 7. Git commit and push
|
|
|
|
|
fmt.Fprintf(os.Stderr, "[info] Pushing to repository...\n")
|
|
|
|
|
cmds := [][]string{
|
|
|
|
|
{"-C", tmpDir, "add", "-A"},
|
|
|
|
|
{"-C", tmpDir, "-c", "user.name=GitLink Course Creator", "-c", "user.email=course@gitlink.org.cn", "commit", "--allow-empty", "-m", "feat: initialize course - " + plan.Title},
|
|
|
|
|
{"-C", tmpDir, "push", "origin", branch},
|
|
|
|
|
}
|
|
|
|
|
for _, args := range cmds {
|
|
|
|
|
cmd := exec.Command("git", args...)
|
|
|
|
|
cmd.Stderr = os.Stderr
|
|
|
|
|
cmd.Stdout = os.Stderr
|
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
|
|
|
return fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 8. Create learning issues
|
|
|
|
|
fmt.Fprintf(os.Stderr, "[info] Creating learning check-in issues...\n")
|
|
|
|
|
ghostURL := fmt.Sprintf("https://www.gitlink.org.cn/%s/%s/tree/%s", owner, repoName, branch)
|
|
|
|
|
for i, chapter := range plan.Chapters {
|
|
|
|
|
chNum := i + 1
|
|
|
|
|
title := fmt.Sprintf("学习打卡 #%d:%s", chNum, chapter)
|
|
|
|
|
docURL := fmt.Sprintf("%s/docs/chapter%d.md", ghostURL, chNum)
|
|
|
|
|
body := fmt.Sprintf(`## 📖 本章学习任务
|
|
|
|
|
|
|
|
|
|
阅读第%d章 [%s](%s),完成以下任务:
|
|
|
|
|
|
|
|
|
|
### 学习目标
|
|
|
|
|
- [ ] 掌握本章核心知识点
|
|
|
|
|
- [ ] 完成章节对应的实操练习
|
|
|
|
|
|
|
|
|
|
### 思考题
|
|
|
|
|
1. 阅读%s后,你最大的收获是什么?
|
|
|
|
|
2. 有哪些知识点你还想深入了解?
|
|
|
|
|
|
|
|
|
|
### 打卡要求
|
|
|
|
|
完成学习后在评论区回复:
|
|
|
|
|
- 你学到了什么(至少3点)
|
|
|
|
|
- 思考题的回答(选答一题即可)
|
|
|
|
|
`, chNum, chapter, docURL, chapter)
|
|
|
|
|
|
|
|
|
|
issueBody := map[string]interface{}{
|
|
|
|
|
"subject": title,
|
|
|
|
|
"description": body,
|
|
|
|
|
}
|
|
|
|
|
_, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/issues", owner, repoName), issueBody)
|
|
|
|
|
if err != nil {
|
|
|
|
|
fmt.Fprintf(os.Stderr, "[warn] Failed to create issue #%d: %v\n", chNum, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 9. Output result
|
|
|
|
|
repoURL := fmt.Sprintf("https://www.gitlink.org.cn/%s/%s", owner, repoName)
|
|
|
|
|
fmt.Printf("\n✅ Course project created successfully!\n")
|
|
|
|
|
fmt.Printf(" Repository: %s\n", repoURL)
|
|
|
|
|
fmt.Printf(" Chapters: %d\n", len(plan.Chapters))
|
|
|
|
|
fmt.Printf("\nRun 'gitlink-cli issue +list --owner %s --repo %s' to view issues.\n", owner, repoName)
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseMaterial parses the source file and extracts course structure.
|
|
|
|
|
func parseMaterial(source string, ctx *common.RuntimeContext) (*coursePlan, error) {
|
|
|
|
|
ext := strings.ToLower(filepath.Ext(source))
|
|
|
|
|
baseName := strings.TrimSuffix(filepath.Base(source), ext)
|
|
|
|
|
|
|
|
|
|
switch ext {
|
|
|
|
|
case ".pptx":
|
|
|
|
|
return parsePPTX(source, baseName)
|
|
|
|
|
case ".pdf":
|
|
|
|
|
return parsePDF(source, baseName)
|
|
|
|
|
case ".md", ".txt":
|
|
|
|
|
return parseText(source, baseName)
|
|
|
|
|
default:
|
|
|
|
|
return nil, fmt.Errorf("unsupported file format: %s (supported: .pptx, .pdf, .md, .txt)", ext)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parsePPTX extracts text and structure from a PPTX file.
|
|
|
|
|
func parsePPTX(path, baseName string) (*coursePlan, error) {
|
|
|
|
|
r, err := zip.OpenReader(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("open pptx: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer r.Close()
|
|
|
|
|
|
|
|
|
|
// Collect slide texts in order
|
|
|
|
|
var slides []string
|
|
|
|
|
for _, f := range r.File {
|
|
|
|
|
if strings.HasPrefix(f.Name, "ppt/slides/slide") && strings.HasSuffix(f.Name, ".xml") {
|
|
|
|
|
rc, err := f.Open()
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
buf := new(bytes.Buffer)
|
|
|
|
|
io.Copy(buf, rc)
|
|
|
|
|
rc.Close()
|
|
|
|
|
text := extractTextFromXML(buf.String())
|
|
|
|
|
if strings.TrimSpace(text) != "" {
|
|
|
|
|
slides = append(slides, text)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return buildPlanFromSlides(slides, baseName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// extractTextFromXML removes XML tags and extracts text content.
|
|
|
|
|
func extractTextFromXML(xml string) string {
|
|
|
|
|
// Remove XML tags
|
|
|
|
|
re := regexp.MustCompile(`<[^>]*>`)
|
|
|
|
|
text := re.ReplaceAllString(xml, " ")
|
|
|
|
|
// Collapse whitespace
|
|
|
|
|
re = regexp.MustCompile(`\s+`)
|
|
|
|
|
text = re.ReplaceAllString(text, " ")
|
|
|
|
|
return strings.TrimSpace(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// buildPlanFromSlides extracts course structure from slide text.
|
|
|
|
|
func buildPlanFromSlides(slides []string, baseName string) (*coursePlan, error) {
|
|
|
|
|
plan := &coursePlan{
|
|
|
|
|
RepoName: toRepoName(baseName),
|
|
|
|
|
Title: baseName,
|
|
|
|
|
Description: "基于课程材料创建的开源实践项目",
|
|
|
|
|
Chapters: []string{},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to find title from the first slide
|
|
|
|
|
if len(slides) > 0 {
|
|
|
|
|
first := slides[0]
|
|
|
|
|
// Title is usually the first line of the first slide
|
|
|
|
|
lines := strings.SplitN(first, "\n", 2)
|
|
|
|
|
if len(lines) > 0 && len(lines[0]) > 2 && len(lines[0]) < 100 {
|
|
|
|
|
plan.Title = strings.TrimSpace(lines[0])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to extract chapter structure from slide content
|
|
|
|
|
// Look for patterns like "第X章", "Chapter X", "1.", "2." etc.
|
|
|
|
|
chapterPattern := regexp.MustCompile(`(?:第[一二三四五六七八九十\d]+[章节]|Chapter\s+\d+|^\d+[\.、])\s*(.+)`)
|
|
|
|
|
seen := make(map[string]bool)
|
|
|
|
|
for _, slide := range slides {
|
|
|
|
|
lines := strings.Split(slide, "\n")
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
line = strings.TrimSpace(line)
|
|
|
|
|
if line == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
matches := chapterPattern.FindStringSubmatch(line)
|
|
|
|
|
if matches != nil {
|
|
|
|
|
ch := strings.TrimSpace(matches[1])
|
|
|
|
|
if ch != "" && !seen[ch] && len(ch) < 80 {
|
|
|
|
|
plan.Chapters = append(plan.Chapters, ch)
|
|
|
|
|
seen[ch] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If no chapters found, generate default ones
|
|
|
|
|
if len(plan.Chapters) == 0 {
|
|
|
|
|
// Try to extract from slide sequence
|
|
|
|
|
for i, slide := range slides {
|
|
|
|
|
if i == 0 {
|
|
|
|
|
continue // Skip title slide
|
|
|
|
|
}
|
|
|
|
|
lines := strings.Split(slide, "\n")
|
|
|
|
|
firstLine := strings.TrimSpace(lines[0])
|
|
|
|
|
if firstLine != "" && len(firstLine) < 80 && !strings.HasPrefix(firstLine, "目") && !strings.HasPrefix(firstLine, "内") {
|
|
|
|
|
plan.Chapters = append(plan.Chapters, firstLine)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Limit to reasonable number
|
|
|
|
|
if len(plan.Chapters) > 12 {
|
|
|
|
|
plan.Chapters = plan.Chapters[:12]
|
|
|
|
|
}
|
|
|
|
|
if len(plan.Chapters) == 0 {
|
|
|
|
|
plan.Chapters = []string{"课程概述", "基础知识", "核心概念", "实践应用", "总结与展望"}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return plan, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parsePDF extracts text from a PDF file (basic text extraction).
|
|
|
|
|
func parsePDF(path, baseName string) (*coursePlan, error) {
|
|
|
|
|
// Read file and try to extract text
|
|
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("read pdf: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Basic PDF text extraction — look for text between parentheses in PDF streams
|
|
|
|
|
content := string(data)
|
|
|
|
|
|
|
|
|
|
// Extract text between parentheses (PDF text objects)
|
|
|
|
|
textRe := regexp.MustCompile(`\(([^)]*)\)`)
|
|
|
|
|
matches := textRe.FindAllStringSubmatch(content, -1)
|
|
|
|
|
var texts []string
|
|
|
|
|
for _, m := range matches {
|
|
|
|
|
t := strings.TrimSpace(m[1])
|
|
|
|
|
if len(t) > 2 {
|
|
|
|
|
texts = append(texts, t)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Group into "slides" (every ~20 lines as a pseudo-slide)
|
|
|
|
|
var slides []string
|
|
|
|
|
var buf []string
|
|
|
|
|
for i, t := range texts {
|
|
|
|
|
buf = append(buf, t)
|
|
|
|
|
if (i+1)%20 == 0 {
|
|
|
|
|
slides = append(slides, strings.Join(buf, "\n"))
|
|
|
|
|
buf = nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(buf) > 0 {
|
|
|
|
|
slides = append(slides, strings.Join(buf, "\n"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(slides) == 0 {
|
|
|
|
|
slides = []string{baseName}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return buildPlanFromSlides(slides, baseName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseText extracts structure from a markdown or text file.
|
|
|
|
|
func parseText(path, baseName string) (*coursePlan, error) {
|
|
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("read text: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
content := string(data)
|
|
|
|
|
lines := strings.Split(content, "\n")
|
|
|
|
|
|
|
|
|
|
plan := &coursePlan{
|
|
|
|
|
RepoName: toRepoName(baseName),
|
|
|
|
|
Title: baseName,
|
|
|
|
|
Description: "基于课程材料创建的开源实践项目",
|
|
|
|
|
Chapters: []string{},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// First line might be title (if it's a heading)
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
|
if trimmed != "" {
|
|
|
|
|
plan.Title = strings.TrimPrefix(trimmed, "# ")
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract chapter headings (## heading or # heading after first line)
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
|
if strings.HasPrefix(trimmed, "## ") {
|
|
|
|
|
ch := strings.TrimPrefix(trimmed, "## ")
|
|
|
|
|
if ch != "" && len(ch) < 80 {
|
|
|
|
|
plan.Chapters = append(plan.Chapters, ch)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(plan.Chapters) == 0 {
|
|
|
|
|
plan.Chapters = []string{"课程概述", "基础知识", "核心概念", "实践应用", "总结与展望"}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return plan, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// tryLLMEnhance attempts to enhance the course plan using an LLM.
|
|
|
|
|
func tryLLMEnhance(plan *coursePlan, ctx *common.RuntimeContext) error {
|
|
|
|
|
provider := ctx.Arg("llm-provider")
|
|
|
|
|
key := ctx.Arg("llm-key")
|
|
|
|
|
if key == "" {
|
|
|
|
|
key = os.Getenv("GITLINK_LLM_KEY")
|
|
|
|
|
}
|
|
|
|
|
if provider == "" || key == "" {
|
|
|
|
|
// Try to read from ~/.gc/settings.json
|
|
|
|
|
home, _ := os.UserHomeDir()
|
|
|
|
|
gcConfig := filepath.Join(home, ".gc", "settings.json")
|
|
|
|
|
if data, err := os.ReadFile(gcConfig); err == nil {
|
|
|
|
|
var cfg struct {
|
|
|
|
|
Providers map[string]struct {
|
|
|
|
|
APIKey string `json:"apiKey"`
|
|
|
|
|
BaseURL string `json:"baseUrl,omitempty"`
|
|
|
|
|
} `json:"providers"`
|
|
|
|
|
}
|
|
|
|
|
if json.Unmarshal(data, &cfg) == nil {
|
|
|
|
|
for p, c := range cfg.Providers {
|
|
|
|
|
if provider == "" {
|
|
|
|
|
provider = p
|
|
|
|
|
}
|
|
|
|
|
if key == "" {
|
|
|
|
|
key = c.APIKey
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if provider == "" || key == "" {
|
|
|
|
|
return fmt.Errorf("no LLM configured — skipping enhancement")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build prompt
|
|
|
|
|
prompt := fmt.Sprintf(`你是一个课程设计专家。请根据以下课程信息,生成一个高质量的课程方案。
|
|
|
|
|
|
|
|
|
|
课程标题:%s
|
|
|
|
|
课程简介:%s
|
|
|
|
|
章节列表:%s
|
|
|
|
|
|
|
|
|
|
请返回JSON格式,包含:
|
|
|
|
|
1. title: 优化后的课程标题(中文)
|
|
|
|
|
2. description: 课程简介(50-100字)
|
|
|
|
|
3. chapters: 优化后的章节名称列表(每个章节名称10-20字,保持原数量)
|
|
|
|
|
|
|
|
|
|
只返回JSON,不要其他内容。`, plan.Title, plan.Description, strings.Join(plan.Chapters, "、"))
|
|
|
|
|
|
|
|
|
|
// Call LLM API
|
|
|
|
|
result, err := callLLM(provider, key, prompt)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var enhanced struct {
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
Description string `json:"description"`
|
|
|
|
|
Chapters []string `json:"chapters"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal([]byte(result), &enhanced); err != nil {
|
|
|
|
|
return fmt.Errorf("parse LLM response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if enhanced.Title != "" {
|
|
|
|
|
plan.Title = enhanced.Title
|
|
|
|
|
}
|
|
|
|
|
if enhanced.Description != "" {
|
|
|
|
|
plan.Description = enhanced.Description
|
|
|
|
|
}
|
|
|
|
|
if len(enhanced.Chapters) > 0 {
|
|
|
|
|
plan.Chapters = enhanced.Chapters
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// callLLM calls the specified LLM provider's API.
|
|
|
|
|
func callLLM(provider, key, prompt string) (string, error) {
|
|
|
|
|
var apiURL, model string
|
|
|
|
|
switch provider {
|
|
|
|
|
case "deepseek":
|
|
|
|
|
apiURL = "https://api.deepseek.com/v1/chat/completions"
|
|
|
|
|
model = "deepseek-chat"
|
|
|
|
|
case "openai":
|
|
|
|
|
apiURL = "https://api.openai.com/v1/chat/completions"
|
|
|
|
|
model = "gpt-4o-mini"
|
|
|
|
|
default:
|
|
|
|
|
return "", fmt.Errorf("unsupported LLM provider: %s", provider)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body := map[string]interface{}{
|
|
|
|
|
"model": model,
|
|
|
|
|
"messages": []map[string]string{
|
|
|
|
|
{"role": "system", "content": "你是一个课程设计专家,擅长从教学材料中提取课程结构并优化课程方案。"},
|
|
|
|
|
{"role": "user", "content": prompt},
|
|
|
|
|
},
|
|
|
|
|
"temperature": 0.3,
|
|
|
|
|
"max_tokens": 2048,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
|
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bodyBytes))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+key)
|
|
|
|
|
|
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return "", fmt.Errorf("LLM API returned %d: %s", resp.StatusCode, string(respBody))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result struct {
|
|
|
|
|
Choices []struct {
|
|
|
|
|
Message struct {
|
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
} `json:"message"`
|
|
|
|
|
} `json:"choices"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
if len(result.Choices) == 0 {
|
|
|
|
|
return "", fmt.Errorf("LLM returned no choices")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result.Choices[0].Message.Content, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// generateCourseFiles creates README.md and chapter docs in the repo directory.
|
|
|
|
|
func generateCourseFiles(repoDir string, plan *coursePlan) error {
|
|
|
|
|
// Create docs directory
|
|
|
|
|
docsDir := filepath.Join(repoDir, "docs")
|
|
|
|
|
if err := os.MkdirAll(docsDir, 0755); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build chapter table for README
|
|
|
|
|
var chapterRows []string
|
|
|
|
|
tableHeader := "| 章节 | 章节名称 | 主要内容 | 学习目标 |\n|------|----------|----------|----------|"
|
|
|
|
|
chapterRows = append(chapterRows, tableHeader)
|
|
|
|
|
|
|
|
|
|
for i, ch := range plan.Chapters {
|
|
|
|
|
num := i + 1
|
|
|
|
|
link := fmt.Sprintf("[%s](docs/chapter%d.md)", ch, num)
|
|
|
|
|
row := fmt.Sprintf("| %d | %s | 待补充 | 待补充 |", num, link)
|
|
|
|
|
chapterRows = append(chapterRows, row)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate README.md
|
|
|
|
|
readme := fmt.Sprintf(`# %s
|
|
|
|
|
|
|
|
|
|
## 1. 课程概述
|
|
|
|
|
|
|
|
|
|
%s
|
|
|
|
|
|
|
|
|
|
## 2. 学习目标
|
|
|
|
|
|
|
|
|
|
通过学习本课程,您将:
|
|
|
|
|
1. 掌握核心知识点
|
|
|
|
|
2. 了解相关技术和方法
|
|
|
|
|
3. 能够将所学知识应用于实践
|
|
|
|
|
|
|
|
|
|
## 3. 适合的学习者
|
|
|
|
|
|
|
|
|
|
对相关内容感兴趣的学习者
|
|
|
|
|
|
|
|
|
|
## 4. 课程结构
|
|
|
|
|
|
|
|
|
|
%s
|
|
|
|
|
|
|
|
|
|
## 5. 实操演练
|
|
|
|
|
|
|
|
|
|
本课程秉持"做中学 (Learning by Doing)"原则,实验演练贯穿全章节。
|
|
|
|
|
|
|
|
|
|
## 6. 课程打卡指引
|
|
|
|
|
|
|
|
|
|
本项目采用 **Issue 评论打卡** 的方式。完成每项任务后,在对应 Issue 下提交您的学习成果。
|
|
|
|
|
|
|
|
|
|
## 7. 共创与贡献
|
|
|
|
|
|
|
|
|
|
欢迎所有学习者参与贡献!
|
|
|
|
|
|
|
|
|
|
**贡献方式**:
|
|
|
|
|
- 提交 Issue 反馈问题或建议
|
|
|
|
|
- 提交 Pull Request 完善内容
|
|
|
|
|
- 分享您的学习经验
|
|
|
|
|
|
|
|
|
|
## 8. 许可说明
|
|
|
|
|
|
|
|
|
|
本课程采用 **CC BY-SA 4.0** 开源许可证。
|
|
|
|
|
`, plan.Title, plan.Description, strings.Join(chapterRows, "\n"))
|
|
|
|
|
|
|
|
|
|
if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte(readme), 0644); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate chapter docs
|
|
|
|
|
for i, ch := range plan.Chapters {
|
|
|
|
|
num := i + 1
|
|
|
|
|
chapterContent := fmt.Sprintf(`# 第%d章:%s
|
|
|
|
|
|
|
|
|
|
## 本章概述
|
|
|
|
|
|
|
|
|
|
本章将介绍 %s 的核心概念和基础知识。
|
|
|
|
|
|
|
|
|
|
## 学习目标
|
|
|
|
|
|
|
|
|
|
完成本章学习后,您将能够:
|
|
|
|
|
1. 理解 %s 的基本概念
|
|
|
|
|
2. 掌握相关知识和技能
|
|
|
|
|
3. 完成对应的实践练习
|
|
|
|
|
|
|
|
|
|
## 内容要点
|
|
|
|
|
|
|
|
|
|
### 1. 核心概念
|
|
|
|
|
|
|
|
|
|
- 概念一:待补充
|
|
|
|
|
- 概念二:待补充
|
|
|
|
|
- 概念三:待补充
|
|
|
|
|
|
|
|
|
|
### 2. 实践操作
|
|
|
|
|
|
|
|
|
|
待补充具体操作步骤和练习内容。
|
|
|
|
|
|
|
|
|
|
## 本章小结
|
|
|
|
|
|
|
|
|
|
本章主要介绍了 %s 的核心内容,为后续学习打下基础。
|
|
|
|
|
|
|
|
|
|
## 课后练习
|
|
|
|
|
|
|
|
|
|
1. 练习一:待补充
|
|
|
|
|
2. 练习二:待补充
|
|
|
|
|
3. 练习三:待补充
|
|
|
|
|
`, num, ch, ch, ch, ch)
|
|
|
|
|
|
|
|
|
|
filePath := filepath.Join(docsDir, fmt.Sprintf("chapter%d.md", num))
|
|
|
|
|
if err := os.WriteFile(filePath, []byte(chapterContent), 0644); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// toRepoName converts a base name to a URL-safe repository name.
|
|
|
|
|
func toRepoName(name string) string {
|
|
|
|
|
// Remove extension and special chars
|
|
|
|
|
name = strings.TrimSuffix(name, filepath.Ext(name))
|
|
|
|
|
|
|
|
|
|
// Replace Chinese/CJK characters with English equivalents
|
|
|
|
|
// Simple approach: replace non-alphanumeric with hyphens
|
|
|
|
|
re := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
|
|
|
|
name = re.ReplaceAllString(name, "-")
|
|
|
|
|
|
|
|
|
|
// Collapse multiple hyphens
|
|
|
|
|
re = regexp.MustCompile(`-+`)
|
|
|
|
|
name = re.ReplaceAllString(name, "-")
|
|
|
|
|
|
|
|
|
|
// Trim leading/trailing hyphens
|
|
|
|
|
name = strings.Trim(name, "-")
|
|
|
|
|
|
|
|
|
|
// Limit length
|
|
|
|
|
if len(name) > 100 {
|
|
|
|
|
name = name[:100]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If name is empty after sanitization, use a default
|
|
|
|
|
if name == "" {
|
|
|
|
|
name = "course-project"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// init registers the env var for LLM key.
|
|
|
|
|
func init() {
|
|
|
|
|
// GITLINK_LLM_KEY is read in tryLLMEnhance
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure unused imports are used
|
|
|
|
|
var _ = strconv.Itoa
|
|
|
|
|
var _ = url.Values{}
|