506 lines
16 KiB
Python
506 lines
16 KiB
Python
"""
|
||
语料采集系统 · 服务端
|
||
====================
|
||
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)
|