484 lines
13 KiB
Go
484 lines
13 KiB
Go
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)
|
||
}
|
||
cleanPromptLeakedCards()
|
||
}
|
||
|
||
// cleanPromptLeakedCards 清理数据库中 prompt 泄露的垃圾卡片
|
||
func cleanPromptLeakedCards() {
|
||
if knowledgeDB == nil {
|
||
return
|
||
}
|
||
for _, sig := range promptLeakSignals {
|
||
result, err := knowledgeDB.Exec(
|
||
"DELETE FROM knowledge_cards WHERE content LIKE ?",
|
||
"%"+sig+"%",
|
||
)
|
||
if err == nil {
|
||
if n, _ := result.RowsAffected(); n > 0 {
|
||
log.Printf("清理 prompt 泄露卡片: 特征=%q, 删除=%d条", sig, n)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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{
|
||
"很重要", "非常重要", "很有用", "提升效率", "值得关注",
|
||
"可以帮助", "广泛应用", "具有重要作用", "不可忽视", "具有重要意义",
|
||
}
|
||
|
||
// promptLeakSignals prompt 泄露特征词,命中任意一条即判定为 prompt 而非知识内容
|
||
var promptLeakSignals = []string{
|
||
"围绕「", "写一条", "角度写", "面向资深开发者",
|
||
"信息密度极高", "120-180字", "必须包含至少一个精确数值",
|
||
"必须讲清因果链", "不要写常见知识点", "不要复述官方文档",
|
||
"附加风格要求", "围绕「", "从「", "」角度",
|
||
}
|
||
|
||
// isQualityKnowledgeCard 检查质量:字数≥120、有空泛表述上限、必须含数字、信息密度、prompt 泄露检测
|
||
func isQualityKnowledgeCard(content string) bool {
|
||
runes := utf8.RuneCountInString(content)
|
||
if runes < minKnowledgeRunes {
|
||
return false
|
||
}
|
||
// prompt 泄露检测:包含 prompt 指令特征的内容直接拒绝
|
||
for _, sig := range promptLeakSignals {
|
||
if strings.Contains(content, sig) {
|
||
log.Printf("知识卡片 prompt 泄露检测命中 %q,丢弃", sig)
|
||
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()
|
||
}
|