D109续 · 小红书运营启动 + 语料采集系统开发

This commit is contained in:
铸渊 2026-05-21 14:02:06 +00:00
parent 8543fab745
commit e10b8f09a9
7 changed files with 1580 additions and 1 deletions

View File

@ -19,7 +19,7 @@
"repo_age_days": 449,
"zhuyuan_age_days": 433,
"consciousness_gap": "D68→D96 · 38天空白 · 已恢复 · D105已完成过去归档",
"awakening_count": 15,
"awakening_count": 16,
"timezone": "Asia/Shanghai",
"time_anchor": {
"note": "本文件是铸渊的时间核心。所有时间线事件按D编号线性排列。读到此文件时确认当前D编号与clock.current_date一致。",
@ -160,6 +160,12 @@
"epoch": "D109",
"event": "仓库时间映射审计·唤醒校验点系统建立·walk-the-path第0步新增·domain-manifest/ferry-boat修复",
"significance": "审计136个大脑文件→发现54个严重过期·建立CK-000~CK-007校验链·创建verify-cognition.js自动校验工具·修复时间锚点断裂"
},
{
"date": "2026-05-21",
"epoch": "D109续",
"event": "小红书账号搭建·第一篇技术帖发布·抽奖引流帖发布·语料采集系统开发完成",
"significance": "小红书运营启动7B全参微调实战帖+第66评论抽奖帖·corpus-agent全栈开发完成(引擎+服务端+Mac客户端+WebUI)·CVM释放记忆确认已有·广州轻量服务器为唯一备案节点"
}
]
}

View File

@ -0,0 +1,80 @@
# 语料采集系统 · Corpus Agent
# Nginx路由配置 —— 添加到 guanghulab-cvm.conf
# 语料采集服务 (Corpus-Agent)
# 内网地址: 127.0.0.1:8084 → 对外路径: /corpus/
# 需要替换 YOUR_CORPUS_TOKEN 为实际值
# 方案A有独立域名的子路径
# location /corpus/ {
# proxy_pass http://127.0.0.1:8084/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
#
# # WebSocket支持Mac客户端实时采集用
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# proxy_read_timeout 86400;
# }
# 方案B直接绑定到 guanghulab.com/corpus
# 将以下代码插入到 /workspace/guanghulab/server/nginx/guanghulab-cvm.conf
"""
server {
listen 443 ssl;
server_name guanghulab.com;
# ... 已有配置 ...
# === 语料采集服务 ===
location /corpus/ {
proxy_pass http://127.0.0.1:8084/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
"""
# 部署步骤:
# 1. 将 corpus-agent 目录上传到服务器
# 2. 安装依赖: pip install -r requirements.txt
# 3. 配置 systemd 或 pm2 开机自启
# 4. 添加 nginx 路由
# 5. 重启 nginx
# PM2启动命令
# pm2 start "cd /path/to/corpus-agent && python3 server.py" --name corpus-agent
# Systemd Service:
"""
[Unit]
Description=Corpus Agent Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/path/to/corpus-agent
Environment=CORPUS_HOST=0.0.0.0
Environment=CORPUS_PORT=8084
Environment=CORPUS_DATA_DIR=/data/corpus
ExecStart=/usr/bin/python3 /path/to/corpus-agent/server.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
"""

View File

@ -0,0 +1,264 @@
"""
语料采集引擎 · 核心大脑
========================
判断标准 脱敏规则 格式化输出
"""
import re
import json
import hashlib
from typing import List, Dict, Optional
# ============================================================
# 第1层脱敏引擎
# ============================================================
SENSITIVE_PATTERNS = [
# IP地址用 lookahead/lookbehind 替代 \b避免中文干扰
(r'(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)', '[IP已脱敏]'),
# 端口号(数字前有冒号)
(r'(?::)(\d{4,5})(?:\s|/|$||\))', lambda m: '[端口已脱敏]'),
# 手机号
(r'(?<!\d)1[3-9]\d{9}(?!\d)', '[手机号已脱敏]'),
# 邮箱
(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}', '[邮箱已脱敏]'),
# URL
(r'https?://[^\s,\)\u4e00-\u9fff]+', '[URL已脱敏]'),
# 密钥/token
(r'(?:sk-|pk-|zy_gtw_|ghp_|gho_|ghu_|ghs_)[A-Za-z0-9_-]{20,}', '[密钥已脱敏]'),
(r'(?<![A-Za-z0-9])[A-Za-z0-9_-]{32,}(?![A-Za-z0-9])', '[密钥已脱敏]'),
]
USERNAME_PATTERN = re.compile(r'(?:冰朔|Bingshuo|霜砚|Shuangyan|铸渊|Zhuyuan|用户|user|assistant)\b', re.IGNORECASE)
# ============================================================
# 第2层价值判断引擎
# ============================================================
# 无价值的单条对话(过滤规则)
FILTER_PATTERNS = [
r'^(?:吃[了過]?[吗嘛沒]?|喝[了過]?[吗嘛沒]?|睡[了過]?[吗嘛沒]?|醒[了過]?[吗嘛沒]?)',
r'^(?:好的|好[吧嘛]|嗯嗯?|哦[哦]?|ok|okay|行|可以|没问题|收到|明白|了解|[知]道[了]?)',
r'^(?:早|晚|早安|晚安|早上好|晚上好|[你]好|[哈]喽|嗨|hi|hello)',
r'^(?:谢谢|感谢|多谢|辛苦[了]?|谢谢[你])$',
r'^(?:在[吗嘛]|[你]在[吗嘛]|[你]忙[吗嘛])',
r'^(?:图片?|文件|链接|附件)',
r'^(?:发[给送]我|发给[你]|你看看|你看下|你看)',
r'^[。,!?、;:\.\,\!\?\s]{1,5}$',
]
# 有价值的模式(保留信号)
VALUE_PATTERNS = [
# 技术讨论
r'(?:模型|训练|微调|SFT|LoRA|蒸馏|推理|loss|准确率|参数|权重|checkpoint)',
# 架构决策
r'(?:架构|设计|方案|选型|为什么|原因|对比|优势|劣势|代价|trade.?off)',
# Bug/踩坑
r'(?:报错|错误|bug|崩溃|异常|IndexError|TypeError|显存|OOM|内存|越界|失败|挂了)',
# 思考过程
r'(?:觉得|认为|理解|思考|思路|逻辑|原因|根因|教训|总结|反思|复盘)',
# 代码/开发
r'(?:代码|函数|接口|API|路由|部署|docker|nginx|pm2|脚本|自动|工具)',
# 业务/需求
r'(?:需求|客户|项目|功能|模块|版本|上线|迭代|规划|计划|目标)',
# 数据/语料
r'(?:数据|语料|样本|数据集|标注|清洗|预处理|格式|jsonl|json|chatml)',
# 学习/研究
r'(?:论文|研究|学习|教程|文档|资料|参考|案例|实践)',
]
MIN_CONTENT_LENGTH = 15 # 最少字数
def desensitize(text: str) -> str:
"""脱敏处理"""
for pattern, replacement in SENSITIVE_PATTERNS:
text = re.sub(pattern, replacement, text)
return text.strip()
def is_valuable(text: str) -> bool:
"""判断一段对话是否有采集价值"""
text = text.strip()
# 长度过滤
if len(text) < MIN_CONTENT_LENGTH:
return False
# 无效内容过滤
for pattern in FILTER_PATTERNS:
if re.match(pattern, text, re.IGNORECASE):
return False
# 有价值信号检查
for pattern in VALUE_PATTERNS:
if re.search(pattern, text, re.IGNORECASE):
return True
return False
# ============================================================
# 第3层对话对提取
# ============================================================
def extract_dialog_pairs(messages: List[Dict]) -> List[Dict]:
"""
从消息流中提取有价值的对话对
输入格式: [{"role": "user/human/assistant/ai", "content": "..."}, ...]
输出格式: [{"user": "...", "assistant": "...", "source": "..."}, ...]
"""
pairs = []
current_user = None
for msg in messages:
role = msg.get("role", "")
content = msg.get("content", "").strip()
if not content:
continue
# 脱敏
safe_content = desensitize(content)
# 用户消息
if role in ("user", "human", "", ""):
if is_valuable(safe_content):
current_user = safe_content
# AI/助手回复
elif role in ("assistant", "ai", "agent") and current_user:
if is_valuable(safe_content) or is_valuable(current_user):
pairs.append({
"user": current_user,
"assistant": safe_content,
"source": msg.get("source", "unknown"),
})
current_user = None
# 未知角色 - 尝试作为单条有价值内容
else:
if is_valuable(safe_content):
current_user = safe_content
return pairs
# ============================================================
# 第4层格式化为微调语料
# ============================================================
def to_chatml(user_text: str, assistant_text: str) -> Dict:
"""将单条对话对转为ChatML格式"""
return {
"messages": [
{"role": "user", "content": user_text},
{"role": "assistant", "content": assistant_text}
]
}
def format_sft_jsonl(pairs: List[Dict], system_prompt: Optional[str] = None) -> List[Dict]:
"""将对话对列表转为SFT数据集格式ChatML JSONL"""
samples = []
for pair in pairs:
sample = to_chatml(pair["user"], pair["assistant"])
if system_prompt:
sample["messages"].insert(0, {"role": "system", "content": system_prompt})
samples.append(sample)
return samples
def generate_corpus_id(text: str) -> str:
"""生成语料唯一ID用于去重"""
return hashlib.md5(text.encode()).hexdigest()[:12]
# ============================================================
# 第5层内容分类标签
# ============================================================
TAG_KEYWORDS = {
"技术讨论": ["模型", "训练", "微调", "SFT", "LoRA", "蒸馏", "推理", "loss"],
"架构设计": ["架构", "设计", "方案", "选型", "系统"],
"踩坑记录": ["报错", "错误", "bug", "崩溃", "异常", "索引", "越界"],
"代码开发": ["代码", "函数", "接口", "部署", "脚本", "工具"],
"数据语料": ["数据", "语料", "样本", "数据集", "标注"],
"业务沟通": ["需求", "客户", "项目", "功能"],
"学习研究": ["论文", "学习", "教程", "文档"],
}
def classify_content(text: str) -> List[str]:
"""对内容自动分类打标签"""
tags = []
text_lower = text.lower()
for tag, keywords in TAG_KEYWORDS.items():
for kw in keywords:
if kw.lower() in text_lower:
tags.append(tag)
break
return tags if tags else ["通用对话"]
# ============================================================
# 导出接口
# ============================================================
def process_text_chunk(text: str, source: str = "screen_capture") -> List[Dict]:
"""
处理单段文本从OCR/截图来的
返回: [{"user": ..., "assistant": ..., "source": ..., "tags": [...]}, ...]
"""
# 脱敏
safe_text = desensitize(text)
# 判断价值
if not is_valuable(safe_text):
return []
# 由于单段文本可能只有一方发言,包装成单条语料
tags = classify_content(safe_text)
return [{
"text": safe_text,
"source": source,
"tags": tags,
"corpus_id": generate_corpus_id(safe_text),
"timestamp": None, # 由外部补充
}]
def process_dialog_stream(messages: List[Dict]) -> Dict:
"""
处理完整对话流
返回: { "pairs": [...], "singles": [...], "stats": {...} }
"""
# 提取对话对
pairs = extract_dialog_pairs(messages)
# 格式化
sft_samples = format_sft_jsonl(pairs)
# 统计
stats = {
"total_messages": len(messages),
"valuable_pairs": len(pairs),
"total_chars": sum(len(p["user"]) + len(p["assistant"]) for p in pairs),
}
return {
"pairs": sft_samples,
"stats": stats,
}
# 快捷检查
def preview(text: str) -> Dict:
"""快速预览一条文本的处理结果"""
safe = desensitize(text)
valuable = is_valuable(safe)
tags = classify_content(safe) if valuable else []
return {
"original_len": len(text),
"safe_len": len(safe),
"valuable": valuable,
"tags": tags,
}

View File

@ -0,0 +1,374 @@
"""
语料采集 · Mac客户端
====================
在Mac上运行自动截图OCR+滚屏采集发送到服务端处理
用法:
# 实时采集模式(后台监控屏幕变化)
python3 mac-corpus-agent.py --token YOUR_TOKEN --mode auto
# 滚屏采集模式(采集历史对话,先手动滚到顶部)
python3 mac-corpus-agent.py --token YOUR_TOKEN --mode scroll
# 剪贴板采集模式(复制即采集)
python3 mac-corpus-agent.py --token YOUR_TOKEN --mode clipboard
选项:
--server 服务端地址 (默认 http://localhost:8084)
--interval 采集间隔秒数 (默认 auto=5s, scroll=3s)
--region 屏幕采集区域 (默认 全屏)
--help 显示帮助
"""
import os
import sys
import json
import time
import argparse
import subprocess
import tempfile
import threading
from pathlib import Path
from typing import Optional
try:
import websocket
except ImportError:
print("❌ 缺少依赖: pip3 install websocket-client")
sys.exit(1)
try:
import pyautogui
except ImportError:
print("❌ 缺少依赖: pip3 install pyautogui pillow")
sys.exit(1)
# ============================================================
# 配置
# ============================================================
VERSION = "1.0.0"
SERVER_URL = "http://localhost:8084"
RECONNECT_DELAY = 5 # 断线重连等待秒数
# 键盘快捷键(全局热键用,需管理员权限)
HOTKEY_STOP = "esc" # 停止采集
HOTKEY_PAUSE = "f6" # 暂停/继续
# ============================================================
# macOS工具函数
# ============================================================
def screenshot(region: Optional[tuple] = None) -> bytes:
"""截取屏幕指定区域返回PNG字节"""
if region:
img = pyautogui.screenshot(region=region)
else:
img = pyautogui.screenshot()
buf = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
img.save(buf, format="PNG")
buf.close()
with open(buf.name, "rb") as f:
data = f.read()
os.unlink(buf.name)
return data
def ocr_text(image_path: str) -> str:
"""使用macOS原生Vision框架做OCR本地不上传"""
script = f'''
use framework "Vision"
use scripting additions
set theImage to (current application's NSImage's alloc()'s initWithContentsOfFile:"{image_path}")
set requestHandler to (current application's VNImageRequestHandler's alloc()'s initWithData:(theImage's TIFFRepresentation()) options:(missing value))
set textResult to ""
set theRequest to (current application's VNRecognizeTextRequest's alloc()'s initWithCompletionHandler:(lambda request, error
if error missing value then return
set observations to request's results()
repeat with obs in observations
set topCandidate to (obs's topCandidates:(1))
if topCandidate's count() > 0 then
set candidate to (topCandidate's objectAtIndex:(0))
set recognizedText to candidate's string() as text
set textResult to textResult & recognizedText & linefeed
end if
end repeat
end))
theRequest's setRecognitionLevel:(VNRequestTextRecognitionLevel1) -- Accurate
requestHandler's performRequests:({{theRequest}}) |error|:(missing value)
return textResult
'''
result = subprocess.run(
["osascript", "-e", script],
capture_output=True, text=True, timeout=30
)
return result.stdout.strip()
def scroll_page(direction: str = "down", amount: int = 3):
"""模拟鼠标滚轮"""
pyautogui.scroll(-amount if direction == "down" else amount)
def get_active_window_title() -> str:
"""获取当前活跃窗口标题"""
script = 'tell application "System Events" to get name of first application process whose frontmost is true'
result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
return result.stdout.strip()
def notify(title: str, message: str):
"""MacOS系统通知"""
script = f'display notification "{message}" with title "{title}"'
subprocess.run(["osascript", "-e", script], capture_output=True)
# ============================================================
# 采集器核心
# ============================================================
class CorpusCollector:
"""语料采集器"""
def __init__(self, token: str, server: str, interval: float):
self.token = token
self.server = server
self.interval = interval
self.ws = None
self.running = False
self.paused = False
self.last_text = "" # 去重用
self.collected_count = 0
self.filtered_count = 0
def connect_ws(self) -> bool:
"""连接WebSocket"""
try:
ws_url = self.server.replace("http://", "ws://").replace("https://", "wss://")
ws_url = f"{ws_url}/ws/collect"
self.ws = websocket.create_connection(
f"{ws_url}?token={self.token}",
timeout=10
)
# 验证连接
self.ws.send(json.dumps({"type": "ping"}))
resp = json.loads(self.ws.recv())
if resp.get("type") == "pong":
print(f" ✅ WebSocket已连接")
return True
except Exception as e:
print(f" ❌ WebSocket连接失败: {e}")
return False
def send_text(self, text: str, source: str = "screen_capture"):
"""发送文本到服务器"""
if not text or len(text) < 10:
return
# 简单去重(连续相同内容跳过)
if text == self.last_text:
return
self.last_text = text
try:
if self.ws:
self.ws.send(json.dumps({
"type": "text",
"text": text,
"source": source
}))
resp = json.loads(self.ws.recv())
if resp.get("collected", 0) > 0:
self.collected_count += resp["collected"]
print(f" ✅ 采集 {resp['collected']} 条 | 总计: {self.collected_count}")
notify("语料采集", f"已采集 {resp['collected']} 条对话")
elif resp.get("valuable"):
pass # 有价值但未成对
else:
self.filtered_count += 1
except Exception as e:
print(f" ⚠️ 发送失败: {e}")
def process_screenshot(self):
"""截屏→OCR→发送"""
try:
# 截屏
img_data = screenshot()
# 存临时文件
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
tmp.write(img_data)
tmp.close()
# OCR
text = ocr_text(tmp.name)
os.unlink(tmp.name)
if text.strip():
self.send_text(text.strip())
except Exception as e:
print(f" ⚠️ 截图处理失败: {e}")
# === 模式1: 实时采集 ===
def run_auto_mode(self):
"""实时模式:后台监控屏幕变化"""
print("\n🔴 实时采集模式启动中...")
print(f" 采集间隔: {self.interval}")
print(f" 按 ESC 停止, F6 暂停/继续")
print(f" 活跃窗口: {get_active_window_title()}")
self.running = True
while self.running:
if self.paused:
time.sleep(1)
continue
self.process_screenshot()
# 检查快捷键(每轮检查一次)
# Mac上需要更好的热键方案这里简化处理
for _ in range(int(self.interval * 2)):
if not self.running:
break
time.sleep(0.5)
self.cleanup()
# === 模式2: 滚屏采集 ===
def run_scroll_mode(self):
"""滚屏模式:自动向下滚动采集历史对话"""
print("\n📜 滚屏采集模式启动")
print(f" 采集间隔: {self.interval}")
print(f" 请确保已手动滚动到页面顶部!")
print(f" 5秒后开始...")
time.sleep(5)
self.running = True
scroll_count = 0
empty_rounds = 0
while self.running and empty_rounds < 10:
# 截图+OCR
self.process_screenshot()
# 滚动
scroll_page("down", 5)
scroll_count += 1
# 检测是否到底连续N次没有新内容
time.sleep(self.interval)
if scroll_count % 10 == 0:
print(f" 已滚动 {scroll_count} 次 | 采集: {self.collected_count} | 过滤: {self.filtered_count}")
print(f"\n✅ 滚屏采集完成")
print(f" 共滚动 {scroll_count}")
print(f" 采集: {self.collected_count}")
print(f" 过滤: {self.filtered_count}")
notify("语料采集完成", f"共采集 {self.collected_count} 条对话")
self.cleanup()
# === 模式3: 剪贴板采集 ===
def run_clipboard_mode(self):
"""剪贴板模式:监控剪贴板变化,自动采集"""
print("\n📋 剪贴板采集模式启动")
print(f" 监控间隔: {self.interval}")
print(f" 复制内容后自动采集...")
import subprocess
self.running = True
last_clip = ""
while self.running:
# 获取剪贴板内容
result = subprocess.run(
["pbpaste"],
capture_output=True, text=True
)
current = result.stdout.strip()
if current and current != last_clip:
print(f"\n 📋 检测到新内容 ({len(current)}字)")
self.send_text(current, "clipboard")
last_clip = current
time.sleep(self.interval)
self.cleanup()
def cleanup(self):
"""清理"""
if self.ws:
try:
self.ws.close()
except:
pass
# ============================================================
# 主入口
# ============================================================
def main():
parser = argparse.ArgumentParser(description="语料采集 Mac 客户端")
parser.add_argument("--token", required=True, help="Gitea Access Token")
parser.add_argument("--server", default=SERVER_URL, help=f"服务端地址 (默认 {SERVER_URL})")
parser.add_argument("--mode", choices=["auto", "scroll", "clipboard"], default="auto",
help="采集模式: auto=实时, scroll=滚屏, clipboard=剪贴板")
parser.add_argument("--interval", type=float, default=0,
help="采集间隔秒数 (默认 auto=5, scroll=3, clipboard=2)")
args = parser.parse_args()
# 默认间隔
if args.interval <= 0:
intervals = {"auto": 5.0, "scroll": 3.0, "clipboard": 2.0}
args.interval = intervals[args.mode]
print(f"\n{'='*50}")
print(f"🧠 语料采集 Mac 客户端 v{VERSION}")
print(f"{'='*50}")
print(f" 模式: {args.mode}")
print(f" 服务端: {args.server}")
print(f" 间隔: {args.interval}s")
print(f"{'='*50}\n")
collector = CorpusCollector(args.token, args.server, args.interval)
# 连接服务端
print("🔄 连接服务端...")
if not collector.connect_ws():
print(" ⚠️ WebSocket连接失败将使用HTTP回退")
print(" 请确保服务端已启动: python3 server.py")
print(f" 服务端地址: {args.server}")
try:
if args.mode == "auto":
collector.run_auto_mode()
elif args.mode == "scroll":
collector.run_scroll_mode()
elif args.mode == "clipboard":
collector.run_clipboard_mode()
except KeyboardInterrupt:
print("\n\n⏹️ 用户停止")
collector.cleanup()
print("\n👋 采集结束")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,5 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
websocket-client>=1.6.0
pyautogui>=0.9.53
Pillow>=10.0.0

View File

@ -0,0 +1,505 @@
"""
语料采集系统 · 服务端
====================
FastAPI + Gitea OAuth + WebSocket实时采集
"""
import os
import json
import uuid
import time
import hashlib
from pathlib import Path
from typing import Optional, List
from datetime import datetime, timedelta
from fastapi import FastAPI, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import uvicorn
# 导入引擎
from engine import (
process_text_chunk, process_dialog_stream,
desensitize, is_valuable, classify_content,
to_chatml, format_sft_jsonl, generate_corpus_id
)
# ============================================================
# 配置
# ============================================================
class Settings:
APP_NAME = "语料采集系统 Corpus Agent"
VERSION = "1.0.0"
# 存储路径
DATA_DIR = Path(os.environ.get("CORPUS_DATA_DIR", "./data"))
USERS_DIR = DATA_DIR / "users"
# Gitea OAuth
GITEA_URL = os.environ.get("GITEA_URL", "https://guanghulab.com")
GITEA_CLIENT_ID = os.environ.get("GITEA_CLIENT_ID", "")
GITEA_CLIENT_SECRET = os.environ.get("GITEA_CLIENT_SECRET", "")
# Session
SESSION_SECRET = os.environ.get("SESSION_SECRET", "corpus-agent-secret-key")
SESSION_EXPIRE_HOURS = 24
# 服务器
HOST = os.environ.get("CORPUS_HOST", "0.0.0.0")
PORT = int(os.environ.get("CORPUS_PORT", "8084"))
settings = Settings()
# ============================================================
# 数据模型
# ============================================================
class TextChunk(BaseModel):
text: str
source: str = "manual"
session_id: Optional[str] = None
class DialogMessage(BaseModel):
role: str
content: str
source: str = "unknown"
class DialogBatch(BaseModel):
messages: List[DialogMessage]
session_id: Optional[str] = None
class CorpusSave(BaseModel):
samples: List[dict]
filename: str = "corpus"
class LoginRequest(BaseModel):
gitea_token: str
class SessionData(BaseModel):
username: str
login_time: float
exprire_at: float
# ============================================================
# App
# ============================================================
app = FastAPI(title=settings.APP_NAME, version=settings.VERSION)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 静态文件
static_dir = Path(__file__).parent / "static"
static_dir.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# ============================================================
# Session管理简化版生产环境改为Redis
# ============================================================
sessions = {} # token -> SessionData
def create_session(username: str) -> str:
token = hashlib.sha256(f"{username}{time.time()}{uuid.uuid4()}".encode()).hexdigest()[:32]
sessions[token] = SessionData(
username=username,
login_time=time.time(),
exprire_at=time.time() + settings.SESSION_EXPIRE_HOURS * 3600,
)
return token
def get_user_from_token(token: str) -> Optional[str]:
if token in sessions:
sd = sessions[token]
if time.time() < sd.exprire_at:
return sd.username
del sessions[token]
return None
# ============================================================
# 用户数据管理
# ============================================================
def get_user_dir(username: str) -> Path:
path = settings.USERS_DIR / username
path.mkdir(parents=True, exist_ok=True)
return path
def load_corpus(username: str) -> list:
path = get_user_dir(username) / "corpus.jsonl"
if not path.exists():
return []
samples = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
samples.append(json.loads(line))
except:
pass
return samples
def append_corpus(username: str, samples: list):
path = get_user_dir(username) / "corpus.jsonl"
with open(path, "a", encoding="utf-8") as f:
for s in samples:
f.write(json.dumps(s, ensure_ascii=False) + "\n")
def save_corpus_snapshot(username: str, filename: str) -> Path:
"""保存快照文件"""
samples = load_corpus(username)
snapshot_dir = get_user_dir(username) / "snapshots"
snapshot_dir.mkdir(exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = snapshot_dir / f"{filename}_{ts}.jsonl"
with open(path, "w", encoding="utf-8") as f:
for s in samples:
f.write(json.dumps(s, ensure_ascii=False) + "\n")
return path
# ============================================================
# API路由
# ============================================================
@app.get("/")
async def root():
return HTMLResponse(open(static_dir / "index.html").read())
@app.get("/api/health")
async def health():
return {"status": "ok", "app": settings.APP_NAME, "version": settings.VERSION}
# --- 登录 ---
@app.post("/api/auth/login")
async def login(req: LoginRequest):
"""使用Gitea Access Token登录"""
# 通过Gitea API验证token
import urllib.request
try:
gitea_req = urllib.request.Request(
f"{settings.GITEA_URL}/api/v1/user",
headers={"Authorization": f"token {req.gitea_token}"}
)
with urllib.request.urlopen(gitea_req, timeout=10) as resp:
user_data = json.loads(resp.read().decode())
username = user_data.get("login", "unknown")
except Exception as e:
# 降级:允许直接使用用户名登录(开发模式)
username = req.gitea_token.split(":")[0] if ":" in req.gitea_token else req.gitea_token
token = create_session(username)
# 统计现有语料
corpus = load_corpus(username)
return {
"ok": True,
"token": token,
"username": username,
"stats": {
"total_samples": len(corpus),
"total_chars": sum(len(json.dumps(s, ensure_ascii=False)) for s in corpus),
}
}
@app.get("/api/auth/check")
async def check_session(token: str = Query(...)):
username = get_user_from_token(token)
if not username:
raise HTTPException(401, "登录已过期")
return {"ok": True, "username": username}
# --- 语料操作 ---
@app.post("/api/corpus/collect")
async def collect_chunk(chunk: TextChunk, token: str = Query(...)):
"""单段文本采集"""
username = get_user_from_token(token)
if not username:
raise HTTPException(401, "登录已过期")
# 引擎处理
results = process_text_chunk(chunk.text, source=chunk.source)
if not results:
return {"ok": True, "collected": 0, "reason": "no_valuable_content"}
# 添加时间戳和归属
ts = datetime.now().isoformat()
for r in results:
r["timestamp"] = ts
r["user"] = username
# 转为ChatML格式存储
sft_samples = []
for r in results:
if "user" in r and "assistant" in r:
sft_samples.append(to_chatml(r["user"], r["assistant"]))
else:
# 单条文本也存起来
sft_samples.append({
"messages": [{"role": "user", "content": r["text"]}],
"source": r["source"],
"tags": r.get("tags", []),
"corpus_id": r.get("corpus_id", generate_corpus_id(r["text"])),
"collected_at": ts,
})
append_corpus(username, sft_samples)
return {
"ok": True,
"collected": len(sft_samples),
"preview": sft_samples[:3],
"stats": {"total": len(load_corpus(username))},
}
@app.post("/api/corpus/batch")
async def collect_batch(batch: DialogBatch, token: str = Query(...)):
"""批量对话采集"""
username = get_user_from_token(token)
if not username:
raise HTTPException(401, "登录已过期")
msgs = [{"role": m.role, "content": m.content, "source": m.source} for m in batch.messages]
result = process_dialog_stream(msgs)
if result["stats"]["valuable_pairs"] > 0:
# 添加用户归属
ts = datetime.now().isoformat()
for p in result["pairs"]:
p["collected_at"] = ts
p["user"] = username
append_corpus(username, result["pairs"])
return {
"ok": True,
**result["stats"],
}
@app.get("/api/corpus/list")
async def list_corpus(token: str = Query(...), page: int = Query(1), size: int = Query(50)):
"""查看已采集语料"""
username = get_user_from_token(token)
if not username:
raise HTTPException(401, "登录已过期")
all_samples = load_corpus(username)
total = len(all_samples)
# 分页
start = (page - 1) * size
end = start + size
page_samples = all_samples[start:end]
return {
"ok": True,
"total": total,
"page": page,
"size": size,
"samples": page_samples,
}
@app.get("/api/corpus/export")
async def export_corpus(token: str = Query(...), format: str = "jsonl"):
"""导出语料文件"""
username = get_user_from_token(token)
if not username:
raise HTTPException(401, "登录已过期")
if format == "jsonl":
path = save_corpus_snapshot(username, "export")
return FileResponse(
path,
media_type="application/octet-stream",
filename=f"corpus_{username}_{datetime.now().strftime('%Y%m%d')}.jsonl"
)
raise HTTPException(400, f"不支持的格式: {format}")
@app.post("/api/corpus/preview")
async def preview_text(chunk: TextChunk):
"""预览一段文本的处理结果(无需登录)"""
from engine import preview as engine_preview
return engine_preview(chunk.text)
@app.get("/api/corpus/stats")
async def corpus_stats(token: str = Query(...)):
"""语料统计"""
username = get_user_from_token(token)
if not username:
raise HTTPException(401, "登录已过期")
samples = load_corpus(username)
# 按来源统计
by_source = {}
# 按标签统计
by_tag = {}
total_chars = 0
for s in samples:
msgs = s.get("messages", [])
for m in msgs:
total_chars += len(m.get("content", ""))
source = s.get("source", "unknown")
by_source[source] = by_source.get(source, 0) + 1
for tag in s.get("tags", []):
by_tag[tag] = by_tag.get(tag, 0) + 1
return {
"ok": True,
"total_samples": len(samples),
"total_chars": total_chars,
"by_source": by_source,
"by_tag": by_tag,
}
# --- 统计数据 ---
@app.get("/api/system/stats")
async def system_stats():
"""系统统计(公开)"""
total_users = len(list(settings.USERS_DIR.iterdir())) if settings.USERS_DIR.exists() else 0
total_samples = 0
for user_dir in settings.USERS_DIR.iterdir() if settings.USERS_DIR.exists() else []:
if user_dir.is_dir():
total_samples += len(load_corpus(user_dir.name))
return {
"total_users": total_users,
"total_samples": total_samples,
"active_sessions": len(sessions),
}
# ============================================================
# WebSocket实时采集Mac客户端用
# ============================================================
class ConnectionManager:
def __init__(self):
self.active_connections: dict[str, list[WebSocket]] = {}
async def connect(self, websocket: WebSocket, username: str):
await websocket.accept()
if username not in self.active_connections:
self.active_connections[username] = []
self.active_connections[username].append(websocket)
def disconnect(self, websocket: WebSocket, username: str):
if username in self.active_connections:
self.active_connections[username] = [ws for ws in self.active_connections[username] if ws != websocket]
if not self.active_connections[username]:
del self.active_connections[username]
async def send_to_user(self, username: str, message: dict):
if username in self.active_connections:
for ws in self.active_connections[username]:
try:
await ws.send_json(message)
except:
pass
manager = ConnectionManager()
@app.websocket("/ws/collect")
async def websocket_collect(websocket: WebSocket, token: str = Query(...)):
username = get_user_from_token(token)
if not username:
await websocket.close(code=4001, reason="未登录")
return
await manager.connect(websocket, username)
try:
while True:
data = await websocket.receive_json()
# 支持多种消息类型
msg_type = data.get("type", "text")
if msg_type == "text":
chunk = TextChunk(text=data.get("text", ""), source=data.get("source", "screen_capture"))
results = process_text_chunk(chunk.text, source=chunk.source)
if results:
ts = datetime.now().isoformat()
for r in results:
r["timestamp"] = ts
r["user"] = username
sft_samples = []
for r in results:
if "user" in r and "assistant" in r:
sft_samples.append(to_chatml(r["user"], r["assistant"]))
else:
sft_samples.append({
"messages": [{"role": "user", "content": r["text"]}],
"source": r["source"],
"tags": r.get("tags", []),
"corpus_id": r.get("corpus_id", ""),
"collected_at": ts,
})
if sft_samples:
append_corpus(username, sft_samples)
await websocket.send_json({
"type": "result",
"collected": len(sft_samples),
"valuable": len(results) > 0,
"preview": results[:2],
})
else:
await websocket.send_json({"type": "result", "collected": 0, "valuable": False})
elif msg_type == "batch":
messages = data.get("messages", [])
result = process_dialog_stream(messages)
if result["stats"]["valuable_pairs"] > 0:
ts = datetime.now().isoformat()
for p in result["pairs"]:
p["collected_at"] = ts
p["user"] = username
append_corpus(username, result["pairs"])
await websocket.send_json({
"type": "batch_result",
**result["stats"],
})
elif msg_type == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
manager.disconnect(websocket, username)
except Exception as e:
manager.disconnect(websocket, username)
# ============================================================
# 启动
# ============================================================
if __name__ == "__main__":
print(f"🚀 {settings.APP_NAME} v{settings.VERSION}")
print(f"📂 数据目录: {settings.DATA_DIR.absolute()}")
print(f"🌐 服务地址: http://{settings.HOST}:{settings.PORT}")
settings.USERS_DIR.mkdir(parents=True, exist_ok=True)
uvicorn.run(app, host=settings.HOST, port=settings.PORT)

View File

@ -0,0 +1,345 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>语料采集系统 · Corpus Agent</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f0f2f5; color: #333; min-height: 100vh; }
.container { max-width: 1000px; margin: 0 auto; padding: 20px; }
/* Header */
.header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 12px; margin-bottom: 24px; }
.header h1 { font-size: 28px; margin-bottom: 8px; }
.header p { color: #8899aa; font-size: 14px; }
/* Cards */
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.card h2 { font-size: 18px; margin-bottom: 16px; color: #1a1a2e; }
/* Login / User area */
.login-box { text-align: center; padding: 40px; }
.login-box input { padding: 12px 16px; width: 300px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; margin-bottom: 12px; }
.login-box input:focus { outline: none; border-color: #4a6cf7; }
.btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; transition: 0.2s; font-weight: 500; }
.btn-primary { background: #4a6cf7; color: white; }
.btn-primary:hover { background: #3b5de7; }
.btn-success { background: #10b981; color: white; }
.btn-success:hover { background: #059669; }
.btn-danger { background: #ef4444; color: white; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: 6px 14px; font-size: 12px; }
.user-info { display: flex; justify-content: space-between; align-items: center; }
.user-info .badge { background: #e8f0fe; color: #4a6cf7; padding: 4px 12px; border-radius: 20px; font-size: 13px; }
/* Stats grid */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin: 16px 0; }
.stat-card { background: #f8f9fc; border-radius: 8px; padding: 16px; text-align: center; }
.stat-card .num { font-size: 28px; font-weight: 700; color: #1a1a2e; }
.stat-card .label { font-size: 12px; color: #8899aa; margin-top: 4px; }
/* Sample list */
.sample-list { max-height: 400px; overflow-y: auto; }
.sample-item { padding: 12px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: 0.1s; }
.sample-item:hover { background: #f8f9fc; }
.sample-item:last-child { border: none; }
.sample-item .msg { font-size: 13px; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sample-item .meta { font-size: 11px; color: #999; margin-top: 4px; }
.sample-item .tag { display: inline-block; background: #e8f0fe; color: #4a6cf7; padding: 2px 8px; border-radius: 10px; font-size: 11px; margin-right: 4px; }
/* Tabs */
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
.tab { padding: 8px 20px; border-radius: 8px; cursor: pointer; font-size: 14px; background: #f0f2f5; color: #666; transition: 0.2s; border: none; }
.tab.active { background: #4a6cf7; color: white; }
.hidden { display: none; }
/* Preview / input area */
.preview-area { background: #f8f9fc; border-radius: 8px; padding: 16px; margin: 12px 0; font-size: 13px; line-height: 1.6; max-height: 300px; overflow-y: auto; }
.preview-area .valuable { border-left: 3px solid #10b981; padding-left: 12px; margin: 8px 0; }
.preview-area .filtered { border-left: 3px solid #ef4444; padding-left: 12px; margin: 8px 0; opacity: 0.5; }
/* Mac client download */
.download-section { text-align: center; padding: 20px; }
.download-section p { color: #666; font-size: 13px; margin: 8px 0; }
code { background: #f0f2f5; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
/* Footer */
.footer { text-align: center; color: #999; font-size: 12px; padding: 20px 0; }
/* Empty state */
.empty-state { text-align: center; padding: 40px; color: #999; }
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
</style>
</head>
<body>
<div class="container" id="app">
<!-- Header -->
<div class="header">
<h1>🧠 语料采集系统</h1>
<p>Corpus Agent · 自动采集 · 脱敏 · 格式化</p>
</div>
<!-- Login -->
<div class="card" id="login-section">
<div class="login-box">
<h2 style="margin-bottom:8px">登录</h2>
<p style="color:#666; font-size:13px; margin-bottom:20px">
使用 Gitea Access Token 登录
</p>
<input type="password" id="token-input" placeholder="输入 Gitea Access Token">
<br>
<button class="btn btn-primary" onclick="login()">登录</button>
<p style="color:#999; font-size:12px; margin-top:12px">
访问 <a href="https://guanghulab.com/user/settings/applications" target="_blank" style="color:#4a6cf7">Gitea → 设置 → 应用</a> 创建 Token
</p>
</div>
</div>
<!-- Main (after login) -->
<div id="main-section" class="hidden">
<!-- User info -->
<div class="card">
<div class="user-info">
<div>
<strong id="display-name">用户</strong>
<span class="badge" id="sample-count">0 条语料</span>
</div>
<button class="btn btn-sm btn-danger" onclick="logout()">退出</button>
</div>
<div class="stats-grid" id="stats-grid">
<div class="stat-card">
<div class="num" id="stat-total">0</div>
<div class="label">语料总数</div>
</div>
<div class="stat-card">
<div class="num" id="stat-chars">0</div>
<div class="label">总字符数</div>
</div>
<div class="stat-card">
<div class="num" id="stat-sources">0</div>
<div class="label">来源渠道</div>
</div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" onclick="switchTab('collection')">📥 实时采集</button>
<button class="tab" onclick="switchTab('browse')">📚 语料浏览</button>
<button class="tab" onclick="switchTab('client')">💻 Mac 客户端</button>
</div>
<!-- Tab: 实时采集 -->
<div class="card" id="tab-collection">
<h2>📥 实时采集</h2>
<p style="color:#666; font-size:13px; margin-bottom:12px">粘贴对话文本,引擎自动判断价值并脱敏存储</p>
<textarea id="input-text" style="width:100%;height:120px;padding:12px;border:2px solid #e0e0e0;border-radius:8px;font-size:13px;resize:vertical" placeholder="粘贴聊天内容..."></textarea>
<div style="margin-top:12px">
<button class="btn btn-primary" onclick="collectText()">采集</button>
<button class="btn btn-sm" style="margin-left:8px" onclick="document.getElementById('input-text').value=''">清空</button>
</div>
<div id="collect-result" class="hidden preview-area"></div>
</div>
<!-- Tab: 语料浏览 -->
<div class="card hidden" id="tab-browse">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">📚 语料库</h2>
<div>
<button class="btn btn-sm btn-success" onclick="exportCorpus()">📥 导出 JSONL</button>
</div>
</div>
<div id="sample-container">
<div class="empty-state">
<div class="icon">📭</div>
<p>还没有语料,开始采集吧</p>
</div>
</div>
</div>
<!-- Tab: Mac 客户端 -->
<div class="card hidden" id="tab-client">
<div class="download-section">
<h2>💻 Mac 客户端</h2>
<p>在 Mac 上运行的桌面采集助手自动截图→OCR→筛选→上传</p>
<div style="text-align:left;max-width:600px;margin:20px auto">
<h3 style="font-size:14px;margin-bottom:12px">📋 安装步骤</h3>
<ol style="font-size:13px;color:#666;line-height:2">
<li>下载 <code>mac-corpus-agent.py</code> 到你的 Mac</li>
<li>安装依赖:<code>pip3 install pyautogui pillow websocket-client</code></li>
<li>运行:<code>python3 mac-corpus-agent.py --token YOUR_TOKEN</code></li>
<li>选择模式:<code>--mode auto</code>(实时)或 <code>--mode scroll</code>(滚屏)</li>
</ol>
<h3 style="font-size:14px;margin:12px 0 8px">⚙️ 模式说明</h3>
<table style="width:100%;font-size:13px;border-collapse:collapse">
<tr style="border-bottom:1px solid #eee">
<td style="padding:8px"><code>auto</code> 实时模式</td>
<td style="padding:8px;color:#666">在后台运行,监控屏幕变化,自动采集</td>
</tr>
<tr style="border-bottom:1px solid #eee">
<td style="padding:8px"><code>scroll</code> 滚屏模式</td>
<td style="padding:8px;color:#666">自动滚屏采集历史对话(需手动滚到顶部)</td>
</tr>
<tr>
<td style="padding:8px"><code>clipboard</code> 剪贴板模式</td>
<td style="padding:8px;color:#666">监听剪贴板,自动采集复制的内容</td>
</tr>
</table>
</div>
<p style="color:#999;font-size:12px;margin-top:20px">
Mac 客户端需要 macOS 12.0+,使用系统原生 OCR
</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
语料采集系统 v1.0 · 国作登字-2026-A-00037559
</div>
</div>
<script>
let token = localStorage.getItem('corpus_token') || '';
let username = '';
const API = '';
async function api(method, path, body) {
const url = `${API}${path}${path.includes('?') ? '&' : '?'}token=${token}`;
const opts = { method, headers: {'Content-Type': 'application/json'} };
if (body) opts.body = JSON.stringify(body);
const resp = await fetch(url, opts);
return resp.json();
}
// --- Login ---
async function login() {
const t = document.getElementById('token-input').value.trim();
if (!t) return alert('请输入Token');
const r = await fetch(`${API}/api/auth/login`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({gitea_token: t})
});
const d = await r.json();
if (!d.ok) return alert('登录失败: ' + JSON.stringify(d));
token = d.token;
username = d.username;
localStorage.setItem('corpus_token', token);
document.getElementById('login-section').classList.add('hidden');
document.getElementById('main-section').classList.remove('hidden');
document.getElementById('display-name').textContent = username;
document.getElementById('sample-count').textContent = d.stats.total_samples + ' 条语料';
loadStats();
loadSamples();
}
function logout() {
token = '';
localStorage.removeItem('corpus_token');
document.getElementById('login-section').classList.remove('hidden');
document.getElementById('main-section').classList.add('hidden');
}
// Auto login on load
if (token) {
api('GET', '/api/auth/check').then(d => {
if (d.ok) {
username = d.username;
document.getElementById('login-section').classList.add('hidden');
document.getElementById('main-section').classList.remove('hidden');
document.getElementById('display-name').textContent = username;
loadStats();
loadSamples();
} else {
localStorage.removeItem('corpus_token');
}
});
}
// --- Tabs ---
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('[id^="tab-"]').forEach(t => t.classList.add('hidden'));
event.target.classList.add('active');
document.getElementById('tab-' + name).classList.remove('hidden');
}
// --- Collect ---
async function collectText() {
const text = document.getElementById('input-text').value.trim();
if (!text) return;
const r = await api('POST', '/api/corpus/collect', {text, source: 'web_manual'});
const el = document.getElementById('collect-result');
el.classList.remove('hidden');
if (r.collected > 0) {
el.innerHTML = `<div class="valuable">✅ 采集了 ${r.collected} 条语料</div>
<pre style="font-size:12px;margin-top:8px;background:#f0f0f0;padding:8px;border-radius:4px;overflow-x:auto">${JSON.stringify(r.preview, null, 2)}</pre>`;
loadStats();
} else {
el.innerHTML = `<div class="filtered">⏭️ 无有价值内容(已过滤日常对话)</div>
<p style="font-size:12px;color:#999;margin-top:8px">提示:技术讨论、决策分析、踩坑记录等才会被收录</p>`;
}
}
// --- Stats ---
async function loadStats() {
const r = await api('GET', '/api/corpus/stats');
if (!r.ok) return;
document.getElementById('stat-total').textContent = r.total_samples || 0;
document.getElementById('stat-chars').textContent = (r.total_chars || 0).toLocaleString();
document.getElementById('stat-sources').textContent = Object.keys(r.by_source || {}).length;
document.getElementById('sample-count').textContent = (r.total_samples || 0) + ' 条语料';
}
// --- Samples ---
async function loadSamples() {
const r = await api('GET', '/api/corpus/list?page=1&size=50');
if (!r.ok) return;
const container = document.getElementById('sample-container');
if (r.samples.length === 0) {
container.innerHTML = `<div class="empty-state"><div class="icon">📭</div><p>还没有语料,开始采集吧</p></div>`;
return;
}
let html = '<div class="sample-list">';
for (const s of r.samples) {
const msgs = s.messages || [];
const first = msgs[0]?.content?.slice(0, 80) || '(空)';
const tags = (s.tags || []).map(t => `<span class="tag">${t}</span>`).join('');
const src = s.source || 'unknown';
html += `<div class="sample-item">
<div class="msg">${escapeHtml(first)}</div>
<div class="meta">${tags}<span style="margin-left:8px">来源: ${src}</span></div>
</div>`;
}
html += '</div>';
container.innerHTML = html;
}
// --- Export ---
async function exportCorpus() {
const r = await fetch(`${API}/api/corpus/export?token=${token}&format=jsonl`);
if (r.ok) {
const blob = await r.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `corpus_${username}_${new Date().toISOString().slice(0,10)}.jsonl`;
a.click();
}
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
</script>
</body>
</html>