Files
u-desktop/knowledge.go

450 lines
12 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.
package main
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"path/filepath"
"regexp"
"sync/atomic"
"strings"
"time"
"unicode"
"unicode/utf8"
_ "modernc.org/sqlite"
)
const cpaURL = "https://cpa.1216.top/v1/chat/completions"
const cpaModel = "deepseek-v4-pro"
const minKnowledgeRunes = 120
type knowledgeData struct {
Content string `json:"content"`
Keyword string `json:"keyword"`
}
var knowledgeDB *sql.DB
func initKnowledgeDB() {
dbPath := filepath.Join(configDir(), "knowledge.db")
var err error
knowledgeDB, err = sql.Open("sqlite", dbPath)
if err != nil {
log.Println("知识库打开失败:", err)
return
}
knowledgeDB.SetMaxOpenConns(1)
_, err = knowledgeDB.Exec(`CREATE TABLE IF NOT EXISTS knowledge_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
log.Println("知识库建表失败:", err)
}
}
func saveKnowledgeCard(keyword, content string) {
if knowledgeDB == nil {
return
}
_, err := knowledgeDB.Exec("INSERT INTO knowledge_cards (keyword, content) VALUES (?, ?)", keyword, content)
if err != nil {
log.Println("知识保存失败:", err)
}
}
func getRandomKnowledgeCard(keyword string) string {
if knowledgeDB == nil {
return ""
}
rows, err := knowledgeDB.Query(
"SELECT content FROM knowledge_cards WHERE keyword = ? ORDER BY RANDOM() LIMIT 20",
keyword,
)
if err != nil {
return ""
}
defer rows.Close()
for rows.Next() {
var content string
if rows.Scan(&content) == nil {
content = normalizeKnowledgeContent(content)
if isQualityKnowledgeCard(content) {
return content
}
}
}
return ""
}
func getKnowledgeCardCount(keyword string) int {
if knowledgeDB == nil {
return 0
}
var count int
err := knowledgeDB.QueryRow(
"SELECT COUNT(*) FROM knowledge_cards WHERE keyword = ?",
keyword,
).Scan(&count)
if err != nil {
return 0
}
return count
}
// systemPromptCodeMark 用于在 Go raw string 中拼接反引号
const systemPromptCodeMark = "`"
const knowledgeSystemPrompt = `你是一位在一线写了 15 年底层系统代码的工程师,只在踩过真实生产级坑之后才写知识卡片。
内容铁律:
- 必须是大多数人不知道的冷知识、反直觉行为、或版本变更导致的隐蔽 break
- 必须包含至少一个精确数值(阈值/延迟/版本号/缓冲区大小/默认值等)
- 必须讲清因果链:触发条件 → 内部机制 → 实际后果 → 正确做法
- 禁止泛泛而谈,禁止复述文档,禁止入门级内容
- 每次从不同角度切入,不要重复之前的知识点
格式:
- Markdown**加粗**重点、` + systemPromptCodeMark + `行内代码` + systemPromptCodeMark + `标记术语、简短列表
- 120-200字紧凑无废话
- 不要 emoji`
func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
basePrompt := buildKnowledgePrompt(keyword, cfg.KnowledgePrompt)
messages := []map[string]string{
{"role": "system", "content": knowledgeSystemPrompt},
{"role": "user", "content": basePrompt},
}
body := map[string]interface{}{
"model": cpaModel,
"max_completion_tokens": 2048,
"temperature": 0.8,
"messages": messages,
}
content := requestKnowledgeCompletion(body)
content = normalizeKnowledgeContent(content)
if isQualityKnowledgeCard(content) {
return content
}
log.Printf("知识卡片质量不足,重试: %q", content)
messages = append(messages, map[string]string{
"role": "assistant",
"content": content,
}, map[string]string{
"role": "user",
"content": fmt.Sprintf(
"上面这条「%s」知识卡片质量不行要么太短、要么没有具体数值、要么信息密度不够。"+
"请换一个完全不同的角度重写,必须包含:一个精确数值 + 一个底层机制 + 一个生产踩坑场景。",
keyword,
),
})
body["messages"] = messages
content = requestKnowledgeCompletion(body)
content = normalizeKnowledgeContent(content)
if isQualityKnowledgeCard(content) {
return content
}
return ""
}
const promptCodeMark = "`"
// 知识卡片随机切入角度,确保每次生成不同内容
var knowledgeAngles = []string{
"性能陷阱与调优",
"并发/竞态/死锁",
"内存泄漏与 GC",
"网络协议底层行为",
"安全漏洞与防御",
"错误处理的隐蔽坑",
"版本升级导致的 break change",
"配置默认值的陷阱",
"日志/监控/可观测性",
"序列化/反序列化边界",
"连接池/资源耗尽",
"编译器/运行时的反直觉行为",
"缓存一致性与失效",
"时区/时间处理陷阱",
"文件系统/IO 边界条件",
}
func buildKnowledgePrompt(keyword, customPrompt string) string {
angle := knowledgeAngles[rand.Intn(len(knowledgeAngles))]
basePrompt := fmt.Sprintf(`围绕「%s」从「%s」角度写一条面向资深开发者的技术知识卡片。
要求:
- 120-180字信息密度极高
- 必须是反常识、冷门陷阱、或版本差异等非常规知识点
- 必须包含至少一个精确数值(如:默认超时 30s、缓冲区 4KB、Go 1.21 修复等)
- 必须讲清因果链:什么场景触发 → 底层发生了什么 → 有什么后果 → 正确做法
- 用 Markdown 格式:**加粗重点**、`+promptCodeMark+`行内代码`+promptCodeMark+`标记术语
- 不要写常见知识点,不要复述官方文档`, keyword, angle)
if customPrompt != "" {
basePrompt += "\n附加风格要求不能覆盖上面的字数和质量要求" + customPrompt
}
return basePrompt
}
func requestKnowledgeCompletion(body map[string]interface{}) string {
jsonData, _ := json.Marshal(body)
req, err := http.NewRequest("POST", cpaURL, bytes.NewReader(jsonData))
if err != nil {
log.Println("知识API请求创建失败:", err)
return ""
}
key := loadConfig().cpaKey()
if key == "" {
log.Println("未配置知识卡片 API Key")
return ""
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
log.Println("知识API请求失败:", err)
return ""
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
log.Printf("知识API返回状态码: %d", resp.StatusCode)
if resp.StatusCode == 429 {
// 限流:等待后用新 body 重试
time.Sleep(3 * time.Second)
req2, err2 := http.NewRequest("POST", cpaURL, bytes.NewReader(jsonData))
if err2 != nil {
return ""
}
req2.Header.Set("Authorization", "Bearer "+key)
req2.Header.Set("Content-Type", "application/json")
resp2, err2 := httpClient.Do(req2)
if err2 != nil {
log.Println("知识API重试失败:", err2)
return ""
}
defer resp2.Body.Close()
if resp2.StatusCode != 200 {
log.Printf("知识API重试状态码: %d", resp2.StatusCode)
return ""
}
resp = resp2
} else {
return ""
}
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
} `json:"choices"`
}
if json.NewDecoder(resp.Body).Decode(&result) != nil {
log.Println("知识API响应解析失败")
return ""
}
if len(result.Choices) == 0 {
log.Println("知识API: choices 为空")
return ""
}
if len(result.Choices) > 0 {
c := result.Choices[0].Message.Content
if c == "" {
c = result.Choices[0].Message.ReasoningContent
}
return c
}
return ""
}
// normalizeKnowledgeContent 清洗 LLM 输出的格式残留,保留 Markdown 标记
func normalizeKnowledgeContent(content string) string {
content = strings.TrimSpace(content)
// 去除整段代码块包裹
if strings.HasPrefix(content, "```") && strings.HasSuffix(content, "```") {
content = strings.TrimPrefix(content, "```")
// 去除语言标识行
if idx := strings.Index(content, "\n"); idx >= 0 && !strings.Contains(content[:idx], " ") {
content = content[idx+1:]
}
content = strings.TrimSuffix(content, "```")
content = strings.TrimSpace(content)
}
// 去除常见前缀
content = strings.TrimPrefix(content, "知识卡片:")
content = strings.TrimPrefix(content, "知识卡片:")
content = strings.TrimPrefix(content, "正文:")
content = strings.TrimPrefix(content, "正文:")
// 统一换行
content = strings.ReplaceAll(content, "\r\n", "\n")
// 连续空行压缩为两个换行Markdown 段落分隔)
for strings.Contains(content, "\n\n\n") {
content = strings.ReplaceAll(content, "\n\n\n", "\n\n")
}
// 行尾空白清理
lines := strings.Split(content, "\n")
for i, line := range lines {
lines[i] = strings.TrimRight(line, " \t")
}
content = strings.Join(lines, "\n")
return strings.TrimSpace(content)
}
var numberRe = regexp.MustCompile(`\d+`)
// weakPhrases 空泛表述列表
var weakPhrases = []string{
"很重要", "非常重要", "很有用", "提升效率", "值得关注",
"可以帮助", "广泛应用", "具有重要作用", "不可忽视", "具有重要意义",
}
// isQualityKnowledgeCard 检查质量字数≥120、有空泛表述上限、必须含数字、信息密度
func isQualityKnowledgeCard(content string) bool {
runes := utf8.RuneCountInString(content)
if runes < minKnowledgeRunes {
return false
}
// 必须包含数字(确保有具体数值)
if !numberRe.MatchString(content) {
return false
}
// 空泛表述检查
weakHits := 0
for _, phrase := range weakPhrases {
if strings.Contains(content, phrase) {
weakHits++
}
}
if weakHits >= 3 {
return false
}
// 必须有标点(非空内容)
if !strings.ContainsAny(content, "。;;:,") {
return false
}
// 信息密度非中文字符代码、数字、标点、Markdown占比 ≥ 15%
nonCJK := 0
for _, r := range content {
if !unicode.Is(unicode.Han, r) && !unicode.IsSpace(r) {
nonCJK++
}
}
if float64(nonCJK)/float64(runes) < 0.10 {
return false
}
return true
}
func pushKnowledgeJSON(content, keyword string) {
data, _ := json.Marshal(knowledgeData{Content: content, Keyword: keyword})
evalJS(fmt.Sprintf(`if(window.updateKnowledgeFromGo) window.updateKnowledgeFromGo(%s)`, string(data)))
}
var lastFetchTime int64 // unix timestamp, 限流冷却
func fetchAndPushKnowledge() {
// 30s 冷却,防止频繁点击触发限流
now := time.Now().Unix()
if now-atomic.LoadInt64(&lastFetchTime) < 30 {
// 冷却中,回退显示缓存
cfg := loadConfig()
if cached := getRandomKnowledgeCard(cfg.KnowledgeKeyword); cached != "" {
pushKnowledgeJSON(cached, cfg.KnowledgeKeyword)
}
return
}
atomic.StoreInt64(&lastFetchTime, now)
cfg := loadConfig()
keyword := cfg.KnowledgeKeyword
if keyword == "" {
return
}
var content string
count := getKnowledgeCardCount(keyword)
if count > 0 && rand.Intn(10) < 3 {
content = getRandomKnowledgeCard(keyword)
}
if content == "" {
content = fetchKnowledgeFromLLM(keyword, cfg)
if content != "" {
saveKnowledgeCard(keyword, content)
}
}
if content == "" && count > 0 {
content = getRandomKnowledgeCard(keyword)
}
if content == "" {
pushKnowledgeJSON("知识卡片加载失败,稍后重试", keyword)
return
}
pushKnowledgeJSON(content, keyword)
preview := content
if len(preview) > 30 {
preview = preview[:30] + "..."
}
log.Println("知识卡片已推送:", preview)
}
func pushKnowledgeLoading(keyword string) {
pushKnowledgeJSON("加载中...", keyword)
}
func pushKnowledgePlaceholder() {
pushKnowledgeJSON("请设置知识关键字", "")
}
func knowledgeLoop() {
initKnowledgeDB()
cfg := loadConfig()
if cfg.KnowledgeKeyword != "" && !cfg.HideKnowledge {
if cached := getRandomKnowledgeCard(cfg.KnowledgeKeyword); cached != "" {
pushKnowledgeJSON(cached, cfg.KnowledgeKeyword)
} else {
pushKnowledgeLoading(cfg.KnowledgeKeyword)
}
} else if cfg.KnowledgeKeyword == "" {
pushKnowledgePlaceholder()
}
time.Sleep(3 * time.Second)
cfg = loadConfig()
if cfg.KnowledgeKeyword != "" && !cfg.HideKnowledge {
fetchAndPushKnowledge()
}
ticker := time.NewTicker(30 * time.Minute)
for range ticker.C {
cfg := loadConfig()
if cfg.KnowledgeKeyword != "" && !cfg.HideKnowledge {
fetchAndPushKnowledge()
}
}
}
func triggerKnowledgeRefresh() {
go fetchAndPushKnowledge()
}