Files
mai-bot/scripts/preview_reply_effect_scores.py
2026-04-19 14:21:05 +08:00

2533 lines
92 KiB
Python
Raw Permalink 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.
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
import argparse
import json
import mimetypes
import time
import webbrowser
DEFAULT_LOG_DIR = Path("logs") / "maisaka_reply_effect"
DEFAULT_MANUAL_DIR = Path("logs") / "maisaka_reply_effect_manual"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8765
DEFAULT_RECORD_LIMIT = 20
def normalize_name(value: str) -> str:
normalized = "".join(char if char.isalnum() or char in "._-" else "_" for char in str(value or "").strip())
normalized = normalized.strip("._")
return normalized or "unknown"
def load_json_file(file_path: Path) -> dict[str, Any]:
try:
payload = json.loads(file_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def write_json_file(file_path: Path, payload: dict[str, Any]) -> None:
file_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = file_path.with_name(f".{file_path.name}.tmp")
temp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
temp_path.replace(file_path)
class ReplyEffectRepository:
def __init__(self, log_dir: Path, manual_dir: Path) -> None:
self.log_dir = log_dir
self.manual_dir = manual_dir
def list_chats(self) -> list[dict[str, Any]]:
chats: list[dict[str, Any]] = []
if not self.log_dir.exists():
return chats
for chat_dir in sorted(path for path in self.log_dir.iterdir() if path.is_dir()):
records = list(chat_dir.glob("*.json"))
annotation_dir = self.manual_dir / normalize_name(chat_dir.name)
annotated_count = len(list(annotation_dir.glob("*.json"))) if annotation_dir.exists() else 0
chats.append(
{
"chat_id": chat_dir.name,
"record_count": len(records),
"finalized_count": None,
"pending_count": None,
"annotated_count": annotated_count,
}
)
return chats
def list_records(
self,
*,
chat_id: str | None = None,
status: str = "",
annotated: str = "",
limit: int = DEFAULT_RECORD_LIMIT,
offset: int = 0,
) -> dict[str, Any]:
records: list[dict[str, Any]] = []
normalized_limit = max(1, min(1000, int(limit or DEFAULT_RECORD_LIMIT)))
normalized_offset = max(0, int(offset or 0))
matched_count = 0
has_more = False
for record_file in self._iter_record_files(chat_id, newest_first=True):
payload = load_json_file(record_file)
if not payload:
continue
summary = self._build_record_summary(record_file, payload)
if status and summary["status"] != status:
continue
if annotated == "yes" and summary["manual"] is None:
continue
if annotated == "no" and summary["manual"] is not None:
continue
matched_count += 1
if matched_count <= normalized_offset:
continue
if len(records) >= normalized_limit:
has_more = True
break
records.append(summary)
return {
"records": records,
"has_more": has_more,
"limit": normalized_limit,
"offset": normalized_offset,
}
def get_record(self, chat_id: str, effect_id: str, *, compact: bool = False) -> dict[str, Any]:
record_file = self._find_record_file(chat_id, effect_id)
if record_file is None:
return {}
payload = load_json_file(record_file)
if not payload:
return {}
self._strip_heavy_reply_metadata(payload)
if compact:
payload["context_snapshot"] = []
payload["_manual"] = self.get_annotation(chat_id, effect_id)
payload["_record_path"] = str(record_file)
return payload
def get_annotation(self, chat_id: str, effect_id: str) -> dict[str, Any] | None:
annotation_path = self._annotation_path(chat_id, effect_id)
if not annotation_path.exists():
return None
payload = load_json_file(annotation_path)
return payload or None
def save_annotation(self, payload: dict[str, Any]) -> dict[str, Any]:
chat_id = normalize_name(str(payload.get("chat_id") or ""))
effect_id = normalize_name(str(payload.get("effect_id") or ""))
if not chat_id or chat_id == "unknown" or not effect_id or effect_id == "unknown":
raise ValueError("缺少 chat_id 或 effect_id")
if not self._record_exists(chat_id, effect_id):
raise ValueError("找不到对应的回复效果记录")
manual_score = payload.get("manual_score")
manual_score_5 = payload.get("manual_score_5")
normalized_score: float | None = None
normalized_score_5: int | None = None
if manual_score_5 not in {None, ""}:
try:
normalized_score_5 = int(manual_score_5)
except (TypeError, ValueError):
raise ValueError("manual_score_5 必须是 1-5 的整数") from None
if normalized_score_5 < 1 or normalized_score_5 > 5:
raise ValueError("manual_score_5 必须是 1-5 的整数")
normalized_score = round((normalized_score_5 - 1) / 4 * 100, 2)
elif manual_score not in {None, ""}:
try:
normalized_score = max(0.0, min(100.0, float(manual_score)))
except (TypeError, ValueError):
raise ValueError("manual_score 必须是 0-100 的数字") from None
else:
raise ValueError("缺少人工评分")
annotation = {
"schema_version": 1,
"chat_id": chat_id,
"effect_id": effect_id,
"manual_score": round(normalized_score, 2),
"manual_score_5": normalized_score_5,
"manual_label": str(payload.get("manual_label") or "").strip(),
"evaluator": str(payload.get("evaluator") or "manual").strip() or "manual",
"notes": str(payload.get("notes") or "").strip(),
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
}
write_json_file(self._annotation_path(chat_id, effect_id), annotation)
return annotation
def _iter_record_files(self, chat_id: str | None = None, *, newest_first: bool = False) -> list[Path]:
if not self.log_dir.exists():
return []
if chat_id:
chat_dir = self.log_dir / normalize_name(chat_id)
if not chat_dir.exists() or not chat_dir.is_dir():
return []
return self._sort_record_files(chat_dir.glob("*.json"), newest_first=newest_first)
record_files: list[Path] = []
for chat_dir in self.log_dir.iterdir():
if chat_dir.is_dir():
record_files.extend(chat_dir.glob("*.json"))
return self._sort_record_files(record_files, newest_first=newest_first)
@staticmethod
def _sort_record_files(record_files: Any, *, newest_first: bool) -> list[Path]:
return sorted(
record_files,
key=lambda path: (path.stat().st_mtime, path.name),
reverse=newest_first,
)
def _find_record_file(self, chat_id: str, effect_id: str) -> Path | None:
normalized_effect_id = normalize_name(effect_id)
direct_match = self._direct_record_file(chat_id, effect_id)
if direct_match is not None:
return direct_match
for record_file in self._iter_record_files(chat_id):
payload = load_json_file(record_file)
if normalize_name(str(payload.get("effect_id") or "")) == normalized_effect_id:
return record_file
return None
def _record_exists(self, chat_id: str, effect_id: str) -> bool:
if self._direct_record_file(chat_id, effect_id) is not None:
return True
return self._find_record_file(chat_id, effect_id) is not None
def _direct_record_file(self, chat_id: str, effect_id: str) -> Path | None:
chat_dir = self.log_dir / normalize_name(chat_id)
if not chat_dir.exists() or not chat_dir.is_dir():
return None
normalized_effect_id = normalize_name(effect_id)
matches = sorted(chat_dir.glob(f"*_{normalized_effect_id}.json"))
if not matches:
exact_path = chat_dir / f"{normalized_effect_id}.json"
return exact_path if exact_path.exists() else None
return matches[-1]
def _annotation_path(self, chat_id: str, record_file_or_effect_id: Path | str) -> Path:
if isinstance(record_file_or_effect_id, Path):
payload = load_json_file(record_file_or_effect_id)
effect_id = str(payload.get("effect_id") or record_file_or_effect_id.stem).strip()
else:
effect_id = str(record_file_or_effect_id or "").strip()
return self.manual_dir / normalize_name(chat_id) / f"{normalize_name(effect_id)}.json"
def _build_record_summary(self, record_file: Path, payload: dict[str, Any]) -> dict[str, Any]:
chat_id = record_file.parent.name
effect_id = str(payload.get("effect_id") or record_file.stem)
scores = payload.get("scores") if isinstance(payload.get("scores"), dict) else {}
reply = payload.get("reply") if isinstance(payload.get("reply"), dict) else {}
target_user = payload.get("target_user") if isinstance(payload.get("target_user"), dict) else {}
manual = self.get_annotation(chat_id, effect_id)
return {
"chat_id": chat_id,
"effect_id": effect_id,
"status": str(payload.get("status") or ""),
"created_at": str(payload.get("created_at") or ""),
"finalize_reason": str(payload.get("finalize_reason") or ""),
"asi": scores.get("asi"),
"behavior_score": scores.get("behavior_score"),
"relational_score": scores.get("relational_score"),
"friction_score": scores.get("friction_score"),
"manual": manual,
"reply_preview": self._truncate(str(reply.get("reply_text") or ""), 160),
"target_message_id": str(reply.get("target_message_id") or ""),
"target_user": target_user,
"followup_count": len(payload.get("followup_messages") or []),
"file_name": record_file.name,
}
@staticmethod
def _truncate(text: str, limit: int) -> str:
normalized_text = " ".join(str(text or "").split())
if len(normalized_text) <= limit:
return normalized_text
return f"{normalized_text[: limit - 1]}"
@staticmethod
def _strip_heavy_reply_metadata(payload: dict[str, Any]) -> None:
reply = payload.get("reply")
if not isinstance(reply, dict):
return
metadata = reply.get("reply_metadata")
if not isinstance(metadata, dict):
return
monitor_detail = metadata.get("monitor_detail")
if not isinstance(monitor_detail, dict):
return
compact_detail: dict[str, Any] = {}
for key in ("metrics", "output_text", "extra_sections"):
value = monitor_detail.get(key)
if value:
compact_detail[key] = value
metadata["monitor_detail"] = compact_detail
class ReplyEffectPreviewHandler(BaseHTTPRequestHandler):
repository: ReplyEffectRepository
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/":
self._send_html(INDEX_HTML_V3)
return
if parsed.path == "/api/chats":
self._send_json({"chats": self.repository.list_chats()})
return
if parsed.path == "/api/records":
query = parse_qs(parsed.query)
records = self.repository.list_records(
chat_id=self._first(query, "chat_id"),
status=self._first(query, "status"),
annotated=self._first(query, "annotated"),
limit=self._int(query, "limit", DEFAULT_RECORD_LIMIT),
offset=self._int(query, "offset", 0),
)
self._send_json(records)
return
if parsed.path == "/api/record":
query = parse_qs(parsed.query)
record = self.repository.get_record(
normalize_name(self._first(query, "chat_id")),
normalize_name(self._first(query, "effect_id")),
compact=self._first(query, "compact") in {"1", "true", "yes"},
)
if not record:
self._send_json({"error": "record not found"}, status=404)
return
self._send_json({"record": record})
return
if parsed.path == "/api/image":
query = parse_qs(parsed.query)
self._send_image(self._first(query, "path"))
return
if parsed.path == "/api/image_hash":
query = parse_qs(parsed.query)
self._send_image_by_hash(self._first(query, "hash"), self._first(query, "kind"))
return
self._send_json({"error": "not found"}, status=404)
def do_POST(self) -> None:
parsed = urlparse(self.path)
if parsed.path != "/api/annotations":
self._send_json({"error": "not found"}, status=404)
return
try:
payload = self._read_json_body()
annotation = self.repository.save_annotation(payload)
except ValueError as exc:
self._send_json({"error": str(exc)}, status=400)
return
self._send_json({"annotation": annotation})
def log_message(self, format: str, *args: Any) -> None:
return
def _send_html(self, content: str) -> None:
body = content.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_image_by_hash(self, image_hash: str, kind: str = "") -> None:
image_hash = str(image_hash or "").strip()
if not image_hash:
self._send_json({"error": "missing image hash"}, status=400)
return
image_path = self._resolve_image_path_by_hash(image_hash, kind)
if image_path is None:
self._send_json({"error": "image hash not found"}, status=404)
return
self._send_image(str(image_path))
@staticmethod
def _resolve_image_path_by_hash(image_hash: str, kind: str = "") -> Path | None:
try:
from sqlmodel import select
from src.common.database.database import get_db_session
from src.common.database.database_model import Images, ImageType
preferred_types = []
if kind == "emoji":
preferred_types.append(ImageType.EMOJI)
elif kind == "image":
preferred_types.append(ImageType.IMAGE)
preferred_types.extend(image_type for image_type in (ImageType.IMAGE, ImageType.EMOJI) if image_type not in preferred_types)
with get_db_session() as db:
for image_type in preferred_types:
statement = select(Images).filter_by(image_hash=image_hash, image_type=image_type).limit(1)
image_record = db.exec(statement).first()
if image_record is None or image_record.no_file_flag:
continue
image_path = Path(str(image_record.full_path or "")).expanduser().resolve()
if image_path.is_file():
return image_path
except Exception:
return None
return None
def _send_json(self, payload: dict[str, Any], status: int = 200) -> None:
body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_image(self, raw_path: str) -> None:
try:
image_path = Path(raw_path).expanduser().resolve()
if not image_path.is_file():
raise FileNotFoundError(raw_path)
mime_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream"
if not mime_type.startswith("image/"):
self._send_json({"error": "not an image"}, status=400)
return
body = image_path.read_bytes()
except OSError:
self._send_json({"error": "image not found"}, status=404)
return
self.send_response(200)
self.send_header("Content-Type", mime_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_json_body(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length") or 0)
raw_body = self.rfile.read(length).decode("utf-8")
payload = json.loads(raw_body or "{}")
if not isinstance(payload, dict):
raise ValueError("请求体必须是 JSON 对象")
return payload
@staticmethod
def _first(query: dict[str, list[str]], key: str) -> str:
values = query.get(key) or [""]
return values[0]
@classmethod
def _int(cls, query: dict[str, list[str]], key: str, default: int) -> int:
try:
return int(cls._first(query, key) or default)
except ValueError:
return default
INDEX_HTML = r"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Maisaka 回复效果评分预览</title>
<style>
:root {
color-scheme: light;
--bg: #f7f7f4;
--panel: #ffffff;
--line: #deded7;
--text: #202124;
--muted: #686b70;
--accent: #0f766e;
--danger: #b42318;
--warn: #b7791f;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
}
header {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
h1 { font-size: 18px; margin: 0; }
main {
display: grid;
grid-template-columns: 280px minmax(420px, 1fr) 420px;
height: calc(100vh - 56px);
min-height: 560px;
}
aside, section {
overflow: auto;
border-right: 1px solid var(--line);
background: var(--panel);
}
.sidebar { padding: 14px; }
.content { padding: 14px; background: var(--bg); }
.detail { padding: 14px; border-right: none; }
.toolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
input, select, textarea, button {
font: inherit;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
}
input, select { height: 34px; padding: 0 9px; }
textarea { width: 100%; min-height: 86px; padding: 8px; resize: vertical; }
button {
height: 34px;
padding: 0 12px;
cursor: pointer;
background: var(--accent);
color: white;
border-color: var(--accent);
}
button.secondary {
background: white;
color: var(--text);
border-color: var(--line);
}
.chat-item, .record-card {
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
cursor: pointer;
}
.chat-item.active, .record-card.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.14); }
.chat-id, .record-title { font-weight: 650; word-break: break-all; }
.meta { color: var(--muted); font-size: 12px; line-height: 1.6; }
.metrics { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
.pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 2px 8px;
border-radius: 999px;
background: #eef5f4;
color: #155e57;
font-size: 12px;
border: 1px solid #d1e7e3;
}
.pill.pending { background: #fff7ed; color: var(--warn); border-color: #fed7aa; }
.pill.bad { background: #fef3f2; color: var(--danger); border-color: #fecdca; }
.preview {
white-space: pre-wrap;
line-height: 1.55;
margin: 8px 0 0;
}
.block {
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.block h2 { font-size: 15px; margin: 0 0 10px; }
pre {
white-space: pre-wrap;
word-break: break-word;
background: #f5f5f2;
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px;
max-height: 260px;
overflow: auto;
}
label { display: block; font-size: 13px; color: var(--muted); margin: 10px 0 4px; }
.empty { color: var(--muted); padding: 18px; text-align: center; }
</style>
</head>
<body>
<header>
<h1>Maisaka 回复效果评分预览</h1>
<button class="secondary" onclick="reloadAll()">刷新</button>
</header>
<main>
<aside class="sidebar">
<div class="toolbar">
<input id="chatSearch" placeholder="筛选聊天流" oninput="renderChats()" />
</div>
<div id="chatList"></div>
</aside>
<section class="content">
<div class="toolbar">
<select id="statusFilter" onchange="loadRecords()">
<option value="">全部状态</option>
<option value="finalized">已完成</option>
<option value="pending">观察中</option>
</select>
<select id="annotationFilter" onchange="loadRecords()">
<option value="">全部标注</option>
<option value="yes">已人工评分</option>
<option value="no">未人工评分</option>
</select>
</div>
<div id="recordList"></div>
</section>
<section class="detail">
<div id="detailPane" class="empty">选择一条记录查看详情</div>
</section>
</main>
<script>
let chats = [];
let records = [];
let selectedChat = "";
let selectedEffect = "";
async function api(path, options) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "请求失败");
return data;
}
async function reloadAll() {
const data = await api("/api/chats");
chats = data.chats || [];
if (!selectedChat && chats.length) selectedChat = chats[0].chat_id;
renderChats();
await loadRecords();
}
function renderChats() {
const q = document.getElementById("chatSearch").value.trim().toLowerCase();
const list = document.getElementById("chatList");
const shown = chats.filter(c => !q || c.chat_id.toLowerCase().includes(q));
list.innerHTML = shown.map(chat => `
<div class="chat-item ${chat.chat_id === selectedChat ? "active" : ""}" onclick="selectChat('${escapeAttr(chat.chat_id)}')">
<div class="chat-id">${escapeHtml(chat.chat_id)}</div>
<div class="meta">记录 ${chat.record_count} | 完成 ${chat.finalized_count} | 观察中 ${chat.pending_count} | 人工 ${chat.annotated_count}</div>
</div>
`).join("") || `<div class="empty">没有聊天流</div>`;
}
async function selectChat(chatId) {
selectedChat = chatId;
selectedEffect = "";
renderChats();
await loadRecords();
document.getElementById("detailPane").innerHTML = "选择一条记录查看详情";
}
async function loadRecords(offset = recordOffset) {
recordOffset = Math.max(0, offset);
const params = new URLSearchParams();
if (selectedChat) params.set("chat_id", selectedChat);
const status = document.getElementById("statusFilter").value;
const annotated = document.getElementById("annotationFilter").value;
if (status) params.set("status", status);
if (annotated) params.set("annotated", annotated);
const data = await api(`/api/records?${params.toString()}`);
records = data.records || [];
renderRecords();
}
function renderRecords() {
const list = document.getElementById("recordList");
list.innerHTML = records.map(record => {
const asi = record.asi === null || record.asi === undefined ? "N/A" : Number(record.asi).toFixed(1);
const manual = record.manual ? Number(record.manual.manual_score).toFixed(1) : "未评";
return `
<div class="record-card ${record.effect_id === selectedEffect ? "active" : ""}" onclick="loadDetail('${escapeAttr(record.chat_id)}','${escapeAttr(record.effect_id)}')">
<div class="record-title">${escapeHtml(record.created_at || record.file_name)}</div>
<div class="metrics">
<span class="pill ${record.status === "pending" ? "pending" : ""}">${escapeHtml(record.status || "unknown")}</span>
<span class="pill">ASI ${asi}</span>
<span class="pill">人工 ${manual}</span>
<span class="pill">${escapeHtml(record.finalize_reason || "未结算")}</span>
</div>
<div class="meta">目标用户:${escapeHtml((record.target_user && (record.target_user.cardname || record.target_user.nickname || record.target_user.user_id)) || "未知")} | 后续 ${record.followup_count}</div>
<div class="preview">${escapeHtml(record.reply_preview || "")}</div>
</div>
`;
}).join("") || `<div class="empty">没有记录</div>`;
}
async function loadDetail(chatId, effectId) {
selectedChat = chatId;
selectedEffect = effectId;
renderChats();
renderRecords();
const data = await api(`/api/record?chat_id=${encodeURIComponent(chatId)}&effect_id=${encodeURIComponent(effectId)}&compact=1`);
renderDetail(data.record);
}
function renderDetail(record) {
const scores = record.scores || {};
const reply = record.reply || {};
const manual = record._manual || {};
const followups = record.followup_messages || [];
document.getElementById("detailPane").innerHTML = `
<div class="block">
<h2>自动评分</h2>
<div class="metrics">
<span class="pill">ASI ${fmt(scores.asi)}</span>
<span class="pill">行为 ${fmt(scores.behavior_score)}</span>
<span class="pill">感知 ${fmt(scores.relational_score)}</span>
<span class="pill">摩擦 ${fmt(scores.friction_score)}</span>
</div>
<div class="meta">完成原因:${escapeHtml(record.finalize_reason || "未完成")}</div>
<div class="meta">${escapeHtml(record.confidence_note || "")}</div>
</div>
<div class="block">
<h2>人工评分</h2>
<label>人工分数 0-100</label>
<input id="manualScore" type="number" min="0" max="100" step="1" value="${manual.manual_score ?? ""}" />
<label>标签</label>
<select id="manualLabel">
${["", "good", "neutral", "bad", "uncertain"].map(v => `<option value="${v}" ${manual.manual_label === v ? "selected" : ""}>${v || "未选择"}</option>`).join("")}
</select>
<label>评价人</label>
<input id="evaluator" value="${escapeAttr(manual.evaluator || "manual")}" />
<label>备注</label>
<textarea id="manualNotes">${escapeHtml(manual.notes || "")}</textarea>
<div class="toolbar"><button onclick="saveManual('${escapeAttr(record.session.platform_type_id)}','${escapeAttr(record.effect_id)}')">保存人工评分</button></div>
</div>
<div class="block">
<h2>回复内容</h2>
<pre>${escapeHtml(reply.reply_text || "")}</pre>
</div>
<div class="block">
<h2>后续消息</h2>
<pre>${escapeHtml(followups.map((m, i) => `${i + 1}. ${m.is_target_user ? "[目标]" : "[其他]"} ${m.visible_text || m.plain_text || ""}`).join("\n\n") || "暂无")}</pre>
</div>
<div class="block">
<h2>完整 JSON</h2>
<pre>${escapeHtml(JSON.stringify(record, null, 2))}</pre>
</div>
`;
}
async function saveManual(chatId, effectId) {
const payload = {
chat_id: chatId,
effect_id: effectId,
manual_score: document.getElementById("manualScore").value,
manual_label: document.getElementById("manualLabel").value,
evaluator: document.getElementById("evaluator").value,
notes: document.getElementById("manualNotes").value,
};
try {
await api("/api/annotations", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
await reloadAll();
await loadDetail(chatId, effectId);
} catch (err) {
alert(err.message);
}
}
function fmt(v) {
return v === null || v === undefined || v === "" ? "N/A" : Number(v).toFixed(2);
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#96;");
}
reloadAll();
</script>
</body>
</html>
"""
INDEX_HTML_V2 = r"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Maisaka 回复效果评分预览</title>
<style>
:root {
color-scheme: light;
--bg: #f7f7f4;
--panel: #ffffff;
--line: #deded7;
--text: #202124;
--muted: #686b70;
--accent: #0f766e;
--accent-soft: #eef5f4;
--danger: #b42318;
--warn: #b7791f;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
}
header {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
h1 { font-size: 18px; margin: 0; }
main {
display: grid;
grid-template-columns: 280px minmax(420px, 1fr) 460px;
height: calc(100vh - 56px);
min-height: 560px;
}
aside, section {
overflow: auto;
border-right: 1px solid var(--line);
background: var(--panel);
}
.sidebar { padding: 14px; }
.content { padding: 14px; background: var(--bg); }
.detail { padding: 14px; border-right: none; }
.toolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.header-tools { margin-bottom: 0; justify-content: flex-end; }
.global-evaluator {
width: 150px;
}
.global-evaluator {
width: 150px;
}
input, select, textarea, button {
font: inherit;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
}
input, select { height: 34px; padding: 0 9px; }
textarea { width: 100%; min-height: 86px; padding: 8px; resize: vertical; }
button {
height: 34px;
padding: 0 12px;
cursor: pointer;
background: var(--accent);
color: white;
border-color: var(--accent);
}
button.secondary {
background: white;
color: var(--text);
border-color: var(--line);
}
button.tab-button {
background: white;
color: var(--text);
border-color: var(--line);
}
button.tab-button.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
button.score-button {
width: 44px;
height: 44px;
border-radius: 8px;
font-weight: 700;
background: white;
color: var(--text);
border-color: var(--line);
}
button.score-button.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.hidden { display: none; }
.chat-item, .record-card {
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
cursor: pointer;
}
.chat-item.active, .record-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.14);
}
.chat-id, .record-title { font-weight: 650; word-break: break-all; }
.meta { color: var(--muted); font-size: 12px; line-height: 1.6; }
.metrics { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
.pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 2px 8px;
border-radius: 999px;
background: var(--accent-soft);
color: #155e57;
font-size: 12px;
border: 1px solid #d1e7e3;
}
.pill.pending { background: #fff7ed; color: var(--warn); border-color: #fed7aa; }
.pill.bad { background: #fef3f2; color: var(--danger); border-color: #fecdca; }
.preview {
white-space: pre-wrap;
line-height: 1.55;
margin: 8px 0 0;
}
.block {
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.block h2 { font-size: 15px; margin: 0 0 10px; }
.score-grid {
display: grid;
grid-template-columns: repeat(5, 44px);
gap: 8px;
margin: 8px 0 12px;
}
.kv {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 6px 10px;
font-size: 13px;
line-height: 1.6;
}
.kv div:nth-child(odd) { color: var(--muted); }
pre {
white-space: pre-wrap;
word-break: break-word;
background: #f5f5f2;
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px;
max-height: 300px;
overflow: auto;
}
label { display: block; font-size: 13px; color: var(--muted); margin: 10px 0 4px; }
.empty { color: var(--muted); padding: 18px; text-align: center; }
</style>
</head>
<body>
<header>
<h1>Maisaka 回复效果评分预览</h1>
<div class="toolbar header-tools">
<span class="meta">评价人</span>
<input id="globalEvaluator" class="global-evaluator" placeholder="manual" oninput="saveGlobalEvaluator()" />
<button id="browseTab" class="tab-button active" onclick="setMode('browse')">浏览</button>
<button id="rateTab" class="tab-button" onclick="setMode('rate')">逐条评分</button>
<button class="secondary" onclick="reloadAll()">刷新</button>
</div>
</header>
<main>
<aside class="sidebar">
<div class="toolbar">
<input id="chatSearch" placeholder="筛选聊天流" oninput="renderChats()" />
</div>
<div id="chatList"></div>
</aside>
<section class="content">
<div id="browsePanel">
<div class="toolbar">
<select id="statusFilter" onchange="loadRecords(0)">
<option value="">全部状态</option>
<option value="finalized">已完成</option>
<option value="pending">观察中</option>
</select>
<select id="annotationFilter" onchange="loadRecords(0)">
<option value="">全部标注</option>
<option value="yes">已人工评分</option>
<option value="no">未人工评分</option>
</select>
</div>
<div id="recordPager" class="toolbar"></div>
<div id="recordList"></div>
</div>
<div id="ratingPanel" class="hidden">
<div class="toolbar">
<button onclick="loadRatingQueue()">刷新队列</button>
<button class="secondary" onclick="moveRating(-1)">上一条</button>
<button class="secondary" onclick="moveRating(1)">下一条</button>
</div>
<div id="ratingQueueInfo" class="meta"></div>
<div id="ratingPager" class="toolbar"></div>
<div id="ratingQueueList"></div>
</div>
</section>
<section class="detail">
<div id="detailPane" class="empty">选择一条记录查看详情</div>
</section>
</main>
<script>
let chats = [];
let records = [];
let ratingQueue = [];
let ratingIndex = 0;
let selectedChat = "";
let selectedEffect = "";
let activeMode = "browse";
let selectedFivePointScore = 0;
let currentTargetMessageId = "";
async function api(path, options) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "请求失败");
return data;
}
async function reloadAll() {
const data = await api("/api/chats");
chats = data.chats || [];
if (!selectedChat && chats.length) selectedChat = chats[0].chat_id;
renderChats();
if (activeMode === "rate") {
await loadRatingQueue();
} else {
await loadRecords();
}
}
function setMode(mode) {
activeMode = mode;
document.getElementById("browseTab").classList.toggle("active", mode === "browse");
document.getElementById("rateTab").classList.toggle("active", mode === "rate");
document.getElementById("browsePanel").classList.toggle("hidden", mode !== "browse");
document.getElementById("ratingPanel").classList.toggle("hidden", mode !== "rate");
if (mode === "rate") {
loadRatingQueue();
} else {
loadRecords();
}
}
function renderChats() {
const q = document.getElementById("chatSearch").value.trim().toLowerCase();
const list = document.getElementById("chatList");
const shown = chats.filter(c => !q || c.chat_id.toLowerCase().includes(q));
list.innerHTML = shown.map(chat => `
<div class="chat-item ${chat.chat_id === selectedChat ? "active" : ""}"
onclick="selectChat('${escapeAttr(chat.chat_id)}')">
<div class="chat-id">${escapeHtml(chat.chat_id)}</div>
<div class="meta">
记录 ${chat.record_count} | 完成 ${chat.finalized_count}
| 观察中 ${chat.pending_count} | 人工 ${chat.annotated_count}
</div>
</div>
`).join("") || `<div class="empty">没有聊天流</div>`;
}
async function selectChat(chatId) {
selectedChat = chatId;
selectedEffect = "";
renderChats();
document.getElementById("detailPane").innerHTML = "选择一条记录查看详情";
if (activeMode === "rate") {
await loadRatingQueue();
} else {
await loadRecords();
}
}
async function loadRecords() {
const params = new URLSearchParams();
if (selectedChat) params.set("chat_id", selectedChat);
const status = document.getElementById("statusFilter").value;
const annotated = document.getElementById("annotationFilter").value;
if (status) params.set("status", status);
if (annotated) params.set("annotated", annotated);
const data = await api(`/api/records?${params.toString()}`);
records = data.records || [];
renderRecords();
}
function renderRecords() {
const list = document.getElementById("recordList");
list.innerHTML = records.map(record => {
const asi = scoreText(record.asi);
const manual = manualScoreText(record.manual);
const target = userName(record.target_user);
return `
<div class="record-card ${record.effect_id === selectedEffect ? "active" : ""}"
onclick="loadDetail('${escapeAttr(record.chat_id)}','${escapeAttr(record.effect_id)}')">
<div class="record-title">${escapeHtml(record.created_at || record.file_name)}</div>
<div class="metrics">
<span class="pill ${record.status === "pending" ? "pending" : ""}">
${escapeHtml(record.status || "unknown")}
</span>
<span class="pill">ASI ${asi}</span>
<span class="pill">人工 ${manual}</span>
<span class="pill">${escapeHtml(record.finalize_reason || "未结算")}</span>
</div>
<div class="meta">目标用户:${escapeHtml(target)} | 后续 ${record.followup_count}</div>
<div class="preview">${escapeHtml(record.reply_preview || "")}</div>
</div>
`;
}).join("") || `<div class="empty">没有记录</div>`;
}
async function loadDetail(chatId, effectId) {
selectedChat = chatId;
selectedEffect = effectId;
renderChats();
renderRecords();
const data = await api(`/api/record?chat_id=${encodeURIComponent(chatId)}&effect_id=${encodeURIComponent(effectId)}`);
renderDetail(data.record);
}
function renderDetail(record) {
const scores = record.scores || {};
const reply = record.reply || {};
const manual = record._manual || {};
const followups = record.followup_messages || [];
document.getElementById("detailPane").innerHTML = `
<div class="block">
<h2>自动评分</h2>
<div class="metrics">
<span class="pill">ASI ${fmt(scores.asi)}</span>
<span class="pill">行为 ${fmt(scores.behavior_score)}</span>
<span class="pill">感知 ${fmt(scores.relational_score)}</span>
<span class="pill">摩擦 ${fmt(scores.friction_score)}</span>
</div>
<div class="meta">完成原因:${escapeHtml(record.finalize_reason || "未完成")}</div>
<div class="meta">${escapeHtml(record.confidence_note || "")}</div>
</div>
<div class="block">
<h2>人工评分</h2>
<label>人工分数 0-100</label>
<input id="manualScore" type="number" min="0" max="100" step="1"
value="${manual.manual_score ?? ""}" />
<label>标签</label>
<select id="manualLabel">
${["", "good", "neutral", "bad", "uncertain"].map(v => `
<option value="${v}" ${manual.manual_label === v ? "selected" : ""}>
${v || "未选择"}
</option>
`).join("")}
</select>
<label>评价人</label>
<input id="evaluator" value="${escapeAttr(manual.evaluator || "manual")}" />
<label>备注</label>
<textarea id="manualNotes">${escapeHtml(manual.notes || "")}</textarea>
<div class="toolbar">
<button onclick="saveManual('${escapeAttr(record.session.platform_type_id)}','${escapeAttr(record.effect_id)}')">
保存人工评分
</button>
</div>
</div>
<div class="block">
<h2>回复内容</h2>
<pre>${escapeHtml(reply.reply_text || "")}</pre>
</div>
<div class="block">
<h2>后续消息</h2>
<pre>${escapeHtml(formatFollowups(followups))}</pre>
</div>
<div class="block">
<h2>完整 JSON</h2>
<pre>${escapeHtml(JSON.stringify(record, null, 2))}</pre>
</div>
`;
}
async function loadRatingQueue() {
const params = new URLSearchParams();
if (selectedChat) params.set("chat_id", selectedChat);
params.set("status", "finalized");
params.set("annotated", "no");
params.set("limit", String(RATING_QUEUE_LIMIT));
const data = await api(`/api/records?${params.toString()}`);
ratingQueue = data.records || [];
ratingIndex = 0;
renderRatingQueue();
if (ratingQueue.length) {
await loadRatingDetail(0);
} else {
document.getElementById("detailPane").innerHTML = `
<div class="empty">当前聊天流没有待人工评分的已完成记录</div>
`;
}
}
function renderRatingQueue() {
const info = document.getElementById("ratingQueueInfo");
const list = document.getElementById("ratingQueueList");
info.textContent = selectedChat
? `当前聊天流:${selectedChat},待评分 ${ratingQueue.length} 条`
: `全部聊天流待评分 ${ratingQueue.length} 条`;
list.innerHTML = ratingQueue.map((record, index) => `
<div class="record-card ${index === ratingIndex ? "active" : ""}"
onclick="loadRatingDetail(${index})">
<div class="record-title">${escapeHtml(record.created_at || record.file_name)}</div>
<div class="metrics">
<span class="pill">ASI ${scoreText(record.asi)}</span>
<span class="pill">${escapeHtml(record.chat_id)}</span>
</div>
<div class="preview">${escapeHtml(record.reply_preview || "")}</div>
</div>
`).join("") || `<div class="empty">没有待评分记录</div>`;
}
async function loadRatingDetail(index) {
if (!ratingQueue.length) return;
ratingIndex = Math.max(0, Math.min(index, ratingQueue.length - 1));
const item = ratingQueue[ratingIndex];
selectedChat = item.chat_id;
selectedEffect = item.effect_id;
selectedFivePointScore = 0;
renderChats();
renderRatingQueue();
const data = await api(`/api/record?chat_id=${encodeURIComponent(item.chat_id)}&effect_id=${encodeURIComponent(item.effect_id)}`);
renderRatingDetail(data.record);
}
function moveRating(offset) {
if (!ratingQueue.length) return;
loadRatingDetail(ratingIndex + offset);
}
function renderRatingDetail(record) {
const scores = record.scores || {};
const reply = record.reply || {};
const target = record.target_user || {};
const followups = record.followup_messages || [];
const context = record.context_snapshot || {};
document.getElementById("detailPane").innerHTML = `
<div class="block">
<h2>逐条评分</h2>
<div class="kv">
<div>聊天流</div><div>${escapeHtml(record.session?.platform_type_id || "")}</div>
<div>记录 ID</div><div>${escapeHtml(record.effect_id || "")}</div>
<div>创建时间</div><div>${escapeHtml(record.created_at || "")}</div>
<div>完成原因</div><div>${escapeHtml(record.finalize_reason || "")}</div>
<div>目标用户</div><div>${escapeHtml(userName(target))}</div>
</div>
<div class="metrics">
<span class="pill">ASI ${fmt(scores.asi)}</span>
<span class="pill">行为 ${fmt(scores.behavior_score)}</span>
<span class="pill">感知 ${fmt(scores.relational_score)}</span>
<span class="pill">摩擦 ${fmt(scores.friction_score)}</span>
</div>
</div>
<div class="block">
<h2>上下文</h2>
<pre>${escapeHtml(JSON.stringify(context, null, 2))}</pre>
</div>
<div class="block">
<h2>Bot 回复</h2>
<pre>${escapeHtml(reply.reply_text || "")}</pre>
</div>
<div class="block">
<h2>后续消息</h2>
<pre>${escapeHtml(formatFollowups(followups))}</pre>
</div>
<div class="block">
<h2>人工五点评分</h2>
<div class="meta">1=很差2=较差3=一般4=较好5=很好</div>
<div class="score-grid">
${[1, 2, 3, 4, 5].map(score => `
<button class="score-button" id="scoreButton${score}" onclick="selectFivePointScore(${score})">
${score}
</button>
`).join("")}
</div>
<label>标签</label>
<select id="ratingManualLabel">
<option value="">未选择</option>
<option value="good">good</option>
<option value="neutral">neutral</option>
<option value="bad">bad</option>
<option value="uncertain">uncertain</option>
</select>
<label>评价人</label>
<input id="ratingEvaluator" value="manual" />
<label>备注</label>
<textarea id="ratingNotes"></textarea>
<div class="toolbar">
<button onclick="saveFivePointManual('${escapeAttr(record.session.platform_type_id)}','${escapeAttr(record.effect_id)}')">
保存并下一条
</button>
</div>
</div>
`;
}
function selectFivePointScore(score) {
selectedFivePointScore = score;
[1, 2, 3, 4, 5].forEach(item => {
const button = document.getElementById(`scoreButton${item}`);
if (button) button.classList.toggle("active", item === score);
});
}
async function saveFivePointManual(chatId, effectId) {
if (!selectedFivePointScore) {
alert("请先选择 1-5 的人工评分");
return;
}
const payload = {
chat_id: chatId,
effect_id: effectId,
manual_score_5: selectedFivePointScore,
manual_label: document.getElementById("ratingManualLabel").value,
evaluator: document.getElementById("ratingEvaluator").value,
notes: document.getElementById("ratingNotes").value,
};
try {
await api("/api/annotations", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
await reloadAll();
} catch (err) {
alert(err.message);
}
}
async function saveManual(chatId, effectId) {
const payload = {
chat_id: chatId,
effect_id: effectId,
manual_score: document.getElementById("manualScore").value,
manual_label: document.getElementById("manualLabel").value,
evaluator: document.getElementById("evaluator").value,
notes: document.getElementById("manualNotes").value,
};
try {
await api("/api/annotations", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
await reloadAll();
await loadDetail(chatId, effectId);
} catch (err) {
alert(err.message);
}
}
function manualScoreText(manual) {
if (!manual) return "未评";
if (manual.manual_score_5) return `${manual.manual_score_5}/5`;
return scoreText(manual.manual_score);
}
function userName(user) {
if (!user) return "未知";
return user.cardname || user.nickname || user.user_id || "未知";
}
function formatFollowups(followups) {
if (!followups || !followups.length) return "暂无";
return followups.map((message, index) => {
const tag = message.is_target_user ? "[目标]" : "[其他]";
const text = message.visible_text || message.plain_text || "";
return `${index + 1}. ${tag} ${text}`;
}).join("\n\n");
}
function scoreText(v) {
return v === null || v === undefined || v === "" ? "N/A" : Number(v).toFixed(1);
}
function fmt(v) {
return v === null || v === undefined || v === "" ? "N/A" : Number(v).toFixed(2);
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, c => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[c]));
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#96;");
}
reloadAll();
</script>
</body>
</html>
"""
INDEX_HTML_V3 = r"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Maisaka 回复效果评分预览</title>
<style>
:root {
color-scheme: light;
--bg: #f7f7f4;
--panel: #ffffff;
--line: #deded7;
--text: #202124;
--muted: #686b70;
--accent: #0f766e;
--accent-soft: #eef5f4;
--danger: #b42318;
--warn: #b7791f;
--self: #e9f7f3;
--other: #ffffff;
--tool: #f3f1ea;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
}
header {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
h1 { font-size: 18px; margin: 0; }
main {
display: grid;
grid-template-columns: 220px 340px minmax(520px, 1fr);
height: calc(100vh - 56px);
min-height: 560px;
}
body.rate-mode main {
grid-template-columns: 320px minmax(0, 1fr);
}
body.rate-mode .sidebar { display: none; }
body.rate-mode .content { border-right: 1px solid var(--line); }
body.rate-mode .detail { display: block; }
body.rate-drawer-collapsed main { grid-template-columns: 44px minmax(0, 1fr); }
body.rate-drawer-collapsed #ratingPanel > :not(.drawer-toggle-row) { display: none; }
aside, section {
overflow: auto;
border-right: 1px solid var(--line);
background: var(--panel);
}
.sidebar { padding: 12px; }
.content { padding: 12px; background: var(--bg); }
.detail { padding: 14px; border-right: none; }
.toolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.header-tools { margin-bottom: 0; justify-content: flex-end; }
input, select, textarea, button {
font: inherit;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
}
input, select { height: 34px; padding: 0 9px; max-width: 100%; }
textarea { width: 100%; min-height: 86px; padding: 8px; resize: vertical; }
button {
height: 34px;
padding: 0 12px;
cursor: pointer;
background: var(--accent);
color: white;
border-color: var(--accent);
white-space: nowrap;
}
button.secondary,
button.tab-button {
background: white;
color: var(--text);
border-color: var(--line);
}
button.tab-button.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
button.icon-button {
width: 34px;
padding: 0;
font-weight: 700;
}
button.score-button {
width: 44px;
height: 44px;
border-radius: 8px;
font-weight: 700;
background: white;
color: var(--text);
border-color: var(--line);
}
button.score-button.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.hidden { display: none; }
.chat-item, .record-card {
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
cursor: pointer;
}
.chat-item.active, .record-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.14);
}
.chat-id, .record-title { font-weight: 650; word-break: break-all; }
.meta { color: var(--muted); font-size: 12px; line-height: 1.6; }
.metrics { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
.pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 2px 8px;
border-radius: 999px;
background: var(--accent-soft);
color: #155e57;
font-size: 12px;
border: 1px solid #d1e7e3;
}
.pill.pending { background: #fff7ed; color: var(--warn); border-color: #fed7aa; }
.pill.bad { background: #fef3f2; color: var(--danger); border-color: #fecdca; }
.preview {
white-space: pre-wrap;
line-height: 1.55;
margin: 8px 0 0;
}
.block {
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.block h2 { font-size: 15px; margin: 0 0 10px; }
.score-grid {
display: grid;
grid-template-columns: repeat(5, 44px);
gap: 8px;
margin: 8px 0 12px;
}
.kv {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 6px 10px;
font-size: 13px;
line-height: 1.6;
}
.kv div:nth-child(odd) { color: var(--muted); }
.rate-top {
position: sticky;
top: -14px;
z-index: 2;
margin: -14px -14px 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.96);
}
.rate-top select { min-width: 260px; }
.message-card {
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 10px;
background: var(--other);
max-width: 78%;
}
.message-row {
display: flex;
justify-content: flex-start;
margin-bottom: 7px;
}
.message-row.bot {
justify-content: flex-end;
}
.message-row.bot .message-card {
background: var(--self);
border-color: #cde9e1;
}
.message-row.user .message-card {
background: var(--other);
}
.message-card.assistant,
.message-card.guided_reply { background: var(--self); border-color: #cde9e1; }
.message-card.tool,
.message-card.continue { background: var(--tool); }
.message-card.target { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }
.message-head {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
color: var(--muted);
font-size: 12px;
}
.message-name { font-weight: 650; color: var(--text); }
.message-text { white-space: pre-wrap; word-break: break-word; line-height: 1.45; }
.quote-card {
border-left: 3px solid var(--accent);
background: var(--accent-soft);
border-radius: 6px;
padding: 6px 8px;
margin: 0 0 6px;
font-size: 12px;
color: var(--muted);
}
.quote-card.missing {
border-left-color: var(--warn);
background: #fff7ed;
}
.quote-title {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 3px;
font-weight: 650;
color: var(--text);
}
.quote-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.35;
}
.message-attachments {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
.message-image {
display: block;
max-width: 180px;
max-height: 180px;
border-radius: 6px;
border: 1px solid var(--line);
object-fit: contain;
background: #fff;
}
.message-image-caption {
max-width: 180px;
color: var(--muted);
font-size: 11px;
line-height: 1.35;
margin-top: 3px;
word-break: break-word;
}
.reply-focus {
border: 1px solid #cde9e1;
background: var(--self);
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
font-size: 16px;
line-height: 1.65;
white-space: pre-wrap;
}
pre {
white-space: pre-wrap;
word-break: break-word;
background: #f5f5f2;
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px;
max-height: 300px;
overflow: auto;
}
label { display: block; font-size: 13px; color: var(--muted); margin: 10px 0 4px; }
.empty { color: var(--muted); padding: 18px; text-align: center; }
</style>
</head>
<body>
<header>
<h1>Maisaka 回复效果评分预览</h1>
<div class="toolbar header-tools">
<span class="meta">评价人</span>
<input id="globalEvaluator" class="global-evaluator" placeholder="manual" oninput="saveGlobalEvaluator()" />
<button id="browseTab" class="tab-button active" onclick="setMode('browse')">浏览</button>
<button id="rateTab" class="tab-button" onclick="setMode('rate')">逐条评分</button>
<button class="secondary" onclick="reloadAll()">刷新</button>
</div>
</header>
<main>
<aside class="sidebar">
<div class="toolbar">
<input id="chatSearch" placeholder="筛选聊天流" oninput="renderChats()" />
</div>
<div id="chatList"></div>
</aside>
<section class="content">
<div id="browsePanel">
<div class="toolbar">
<select id="statusFilter" onchange="loadRecords()">
<option value="">全部状态</option>
<option value="finalized">已完成</option>
<option value="pending">观察中</option>
</select>
<select id="annotationFilter" onchange="loadRecords()">
<option value="">全部标注</option>
<option value="yes">已人工评分</option>
<option value="no">未人工评分</option>
</select>
</div>
<div id="recordList"></div>
</div>
<div id="ratingPanel" class="hidden">
<div class="drawer-toggle-row toolbar">
<button class="secondary icon-button" onclick="toggleRatingDrawer()">☰</button>
<button onclick="loadRatingQueue()">刷新</button>
</div>
<div class="toolbar">
<button class="secondary" onclick="moveRating(-1)">上一条</button>
<button class="secondary" onclick="moveRating(1)">下一条</button>
</div>
<div id="ratingQueueInfo" class="meta"></div>
<div id="ratingQueueList"></div>
</div>
</section>
<section class="detail">
<div id="detailPane" class="empty">选择一条记录查看详情</div>
</section>
</main>
<script>
let chats = [];
let records = [];
let ratingQueue = [];
let ratingIndex = 0;
let selectedChat = "";
let selectedEffect = "";
let activeMode = "browse";
let selectedFivePointScore = 0;
let currentTargetMessageId = "";
let currentMessageIndex = new Map();
let recordOffset = 0;
let recordHasMore = false;
let ratingOffset = 0;
let ratingHasMore = false;
const RECORD_LIST_LIMIT = 20;
const RATING_QUEUE_LIMIT = 20;
async function api(path, options) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "请求失败");
return data;
}
async function reloadAll() {
const data = await api("/api/chats");
chats = data.chats || [];
if (!selectedChat && chats.length) selectedChat = chats[0].chat_id;
renderChats();
renderRateChatSelect();
if (activeMode === "rate") {
await loadRatingQueue(0);
} else {
await loadRecords(0);
}
}
function setMode(mode) {
activeMode = mode;
document.body.classList.toggle("rate-mode", mode === "rate");
document.getElementById("browseTab").classList.toggle("active", mode === "browse");
document.getElementById("rateTab").classList.toggle("active", mode === "rate");
document.getElementById("browsePanel").classList.toggle("hidden", mode !== "browse");
document.getElementById("ratingPanel").classList.toggle("hidden", mode !== "rate");
if (mode === "rate") {
loadRatingQueue(0);
} else {
document.body.classList.remove("rate-drawer-collapsed");
loadRecords(0);
}
}
function toggleRatingDrawer() {
document.body.classList.toggle("rate-drawer-collapsed");
}
function renderChats() {
const q = document.getElementById("chatSearch").value.trim().toLowerCase();
const list = document.getElementById("chatList");
const shown = chats.filter(c => !q || c.chat_id.toLowerCase().includes(q));
list.innerHTML = shown.map(chat => `
<div class="chat-item ${chat.chat_id === selectedChat ? "active" : ""}"
onclick="selectChat('${escapeAttr(chat.chat_id)}')">
<div class="chat-id">${escapeHtml(chat.chat_id)}</div>
<div class="meta">记录 ${chat.record_count} | 人工 ${chat.annotated_count}</div>
</div>
`).join("") || `<div class="empty">没有聊天流</div>`;
}
function renderRateChatSelect() {
const select = document.getElementById("rateChatSelect");
if (!select) return;
select.innerHTML = chats.map(chat => `
<option value="${escapeAttr(chat.chat_id)}" ${chat.chat_id === selectedChat ? "selected" : ""}>
${escapeHtml(chat.chat_id)} (${chat.annotated_count}/${chat.record_count})
</option>
`).join("");
}
async function selectChat(chatId) {
selectedChat = chatId;
selectedEffect = "";
renderChats();
renderRateChatSelect();
document.getElementById("detailPane").innerHTML = "选择一条记录查看详情";
if (activeMode === "rate") {
await loadRatingQueue(0);
} else {
await loadRecords(0);
}
}
async function selectRateChat() {
const select = document.getElementById("rateChatSelect");
if (!select) return;
await selectChat(select.value);
}
async function loadRecords() {
const params = new URLSearchParams();
if (selectedChat) params.set("chat_id", selectedChat);
const status = document.getElementById("statusFilter").value;
const annotated = document.getElementById("annotationFilter").value;
if (status) params.set("status", status);
if (annotated) params.set("annotated", annotated);
params.set("limit", String(RECORD_LIST_LIMIT));
params.set("offset", String(recordOffset));
const data = await api(`/api/records?${params.toString()}`);
records = data.records || [];
recordHasMore = Boolean(data.has_more);
renderRecords();
renderRecordPager();
}
function renderRecords() {
const list = document.getElementById("recordList");
list.innerHTML = records.map(record => {
const asi = scoreText(record.asi);
const manual = manualFivePointText(record.manual);
const target = userName(record.target_user);
return `
<div class="record-card ${record.effect_id === selectedEffect ? "active" : ""}"
onclick="loadDetail('${escapeAttr(record.chat_id)}','${escapeAttr(record.effect_id)}')">
<div class="record-title">${escapeHtml(record.created_at || record.file_name)}</div>
<div class="metrics">
<span class="pill ${record.status === "pending" ? "pending" : ""}">${escapeHtml(record.status || "unknown")}</span>
<span class="pill">ASI ${asi}</span>
<span class="pill">人工 ${manual}</span>
<span class="pill">${escapeHtml(record.finalize_reason || "未结算")}</span>
</div>
<div class="meta">目标用户:${escapeHtml(target)} | 后续 ${record.followup_count}</div>
<div class="preview">${escapeHtml(record.reply_preview || "")}</div>
</div>
`;
}).join("") || `<div class="empty">没有记录</div>`;
}
function renderRecordPager() {
const pager = document.getElementById("recordPager");
if (!pager) return;
const pageNumber = Math.floor(recordOffset / RECORD_LIST_LIMIT) + 1;
pager.innerHTML = `
<button class="secondary" ${recordOffset <= 0 ? "disabled" : ""} onclick="loadRecords(${Math.max(0, recordOffset - RECORD_LIST_LIMIT)})">上一页</button>
<span class="meta">第 ${pageNumber} 页,每页 ${RECORD_LIST_LIMIT} 条</span>
<button class="secondary" ${!recordHasMore ? "disabled" : ""} onclick="loadRecords(${recordOffset + RECORD_LIST_LIMIT})">下一页</button>
`;
}
async function loadDetail(chatId, effectId) {
selectedChat = chatId;
selectedEffect = effectId;
renderChats();
renderRecords();
const data = await api(`/api/record?chat_id=${encodeURIComponent(chatId)}&effect_id=${encodeURIComponent(effectId)}`);
renderDetail(data.record);
}
function renderDetail(record) {
const scores = record.scores || {};
const reply = record.reply || {};
const manual = record._manual || {};
const followups = record.followup_messages || [];
currentTargetMessageId = String(reply.target_message_id || "");
const normalizedFollowups = normalizeFollowupMessages(followups);
const botReply = normalizeBotReply(reply);
buildCurrentMessageIndex([], botReply, normalizedFollowups);
selectedFivePointScore = Number(manual.manual_score_5 || score100ToFive(manual.manual_score) || 0);
document.getElementById("detailPane").innerHTML = `
<div class="block">
<h2>自动评分</h2>
<div class="metrics">
<span class="pill">ASI ${fmt(scores.asi)}</span>
<span class="pill">行为 ${fmt(scores.behavior_score)}</span>
<span class="pill">感知 ${fmt(scores.relational_score)}</span>
<span class="pill">摩擦 ${fmt(scores.friction_score)}</span>
</div>
<div class="meta">完成原因:${escapeHtml(record.finalize_reason || "未完成")}</div>
<div class="meta">${escapeHtml(record.confidence_note || "")}</div>
</div>
<div class="block">
<h2>人工五点评分</h2>
<div class="score-grid">
${[1, 2, 3, 4, 5].map(score => `
<button class="score-button ${score === selectedFivePointScore ? "active" : ""}"
id="scoreButton${score}" onclick="selectFivePointScore(${score})">${score}</button>
`).join("")}
</div>
<div class="toolbar">
<button onclick="saveFivePointManual('${escapeAttr(record.session.platform_type_id)}','${escapeAttr(record.effect_id)}', false)">
保存人工评分
</button>
</div>
</div>
<div class="block">
<h2>回复内容</h2>
${renderChatMessageCard(botReply)}
</div>
<div class="block">
<h2>后续消息</h2>
${renderMessageCards(normalizedFollowups, "暂无")}
</div>
`;
}
async function loadRatingQueue(offset = ratingOffset) {
ratingOffset = Math.max(0, offset);
const params = new URLSearchParams();
if (selectedChat) params.set("chat_id", selectedChat);
params.set("status", "finalized");
params.set("annotated", "no");
params.set("limit", String(RATING_QUEUE_LIMIT));
params.set("offset", String(ratingOffset));
const data = await api(`/api/records?${params.toString()}`);
ratingQueue = data.records || [];
ratingHasMore = Boolean(data.has_more);
ratingIndex = 0;
renderRatingQueue();
renderRatingPager();
if (ratingQueue.length) {
await loadRatingDetail(0);
} else {
renderEmptyRatingDetail();
}
}
function renderRatingQueue() {
const info = document.getElementById("ratingQueueInfo");
const list = document.getElementById("ratingQueueList");
info.textContent = selectedChat ? `当前聊天流待评分 ${ratingQueue.length} 条` : `全部聊天流待评分 ${ratingQueue.length} 条`;
list.innerHTML = ratingQueue.map((record, index) => `
<div class="record-card ${index === ratingIndex ? "active" : ""}" onclick="loadRatingDetail(${index})">
<div class="record-title">${escapeHtml(shortTime(record.created_at || record.file_name))}</div>
<div class="metrics">
<span class="pill">ASI ${scoreText(record.asi)}</span>
<span class="pill">${escapeHtml(manualFivePointText(record.manual))}</span>
</div>
<div class="preview">${escapeHtml(record.reply_preview || "")}</div>
</div>
`).join("") || `<div class="empty">没有待评分记录</div>`;
}
function renderRatingPager() {
const pager = document.getElementById("ratingPager");
if (!pager) return;
const pageNumber = Math.floor(ratingOffset / RATING_QUEUE_LIMIT) + 1;
pager.innerHTML = `
<button class="secondary" ${ratingOffset <= 0 ? "disabled" : ""} onclick="loadRatingQueue(${Math.max(0, ratingOffset - RATING_QUEUE_LIMIT)})">上一页</button>
<span class="meta">第 ${pageNumber} 页,每页 ${RATING_QUEUE_LIMIT} 条</span>
<button class="secondary" ${!ratingHasMore ? "disabled" : ""} onclick="loadRatingQueue(${ratingOffset + RATING_QUEUE_LIMIT})">下一页</button>
`;
}
async function loadRatingDetail(index) {
if (!ratingQueue.length) return;
ratingIndex = Math.max(0, Math.min(index, ratingQueue.length - 1));
const item = ratingQueue[ratingIndex];
selectedChat = item.chat_id;
selectedEffect = item.effect_id;
selectedFivePointScore = 0;
renderChats();
renderRatingQueue();
const data = await api(`/api/record?chat_id=${encodeURIComponent(item.chat_id)}&effect_id=${encodeURIComponent(item.effect_id)}`);
renderRatingDetail(data.record);
}
function moveRating(offset) {
if (!ratingQueue.length) return;
loadRatingDetail(ratingIndex + offset);
}
function renderEmptyRatingDetail() {
document.getElementById("detailPane").innerHTML = `
<div class="rate-top toolbar">
<label>聊天流</label>
<select id="rateChatSelect" onchange="selectRateChat()"></select>
</div>
<div class="empty">当前聊天流没有待人工评分的已完成记录</div>
`;
renderRateChatSelect();
}
function renderRatingDetail(record) {
const scores = record.scores || {};
const reply = record.reply || {};
const target = record.target_user || {};
const followups = record.followup_messages || [];
currentTargetMessageId = String(reply.target_message_id || "");
const context = normalizeContextMessages(record.context_snapshot || []);
const normalizedFollowups = normalizeFollowupMessages(followups);
const botReply = normalizeBotReply(reply);
buildCurrentMessageIndex(context, botReply, normalizedFollowups);
document.getElementById("detailPane").innerHTML = `
<div class="rate-top">
<div class="toolbar">
<label>聊天流</label>
<select id="rateChatSelect" onchange="selectRateChat()"></select>
<button class="secondary" onclick="moveRating(-1)">上一条</button>
<button class="secondary" onclick="moveRating(1)">下一条</button>
</div>
<div class="metrics">
<span class="pill">第 ${ratingIndex + 1}/${ratingQueue.length} 条</span>
<span class="pill">ASI ${fmt(scores.asi)}</span>
<span class="pill">行为 ${fmt(scores.behavior_score)}</span>
<span class="pill">感知 ${fmt(scores.relational_score)}</span>
<span class="pill">摩擦 ${fmt(scores.friction_score)}</span>
</div>
<div class="meta">
目标用户:${escapeHtml(userName(target))}
| 完成原因:${escapeHtml(record.finalize_reason || "")}
| 记录:${escapeHtml(record.effect_id || "")}
</div>
</div>
<div class="block">
<h2>上下文</h2>
${renderMessageCards(context)}
</div>
<div class="block">
<h2>Bot 回复</h2>
${renderChatMessageCard(botReply)}
</div>
<div class="block">
<h2>后续消息</h2>
${renderMessageCards(normalizedFollowups, "暂无")}
</div>
<div class="block">
<h2>人工五点评分</h2>
<div class="meta">1=很差2=较差3=一般4=较好5=很好</div>
<div class="score-grid">
${[1, 2, 3, 4, 5].map(score => `
<button class="score-button" id="scoreButton${score}" onclick="selectFivePointScore(${score})">${score}</button>
`).join("")}
</div>
<div class="toolbar">
<button onclick="saveFivePointManual('${escapeAttr(record.session.platform_type_id)}','${escapeAttr(record.effect_id)}', true)">
保存并下一条
</button>
</div>
</div>
`;
renderRateChatSelect();
}
function selectFivePointScore(score) {
selectedFivePointScore = score;
[1, 2, 3, 4, 5].forEach(item => {
const button = document.getElementById(`scoreButton${item}`);
if (button) button.classList.toggle("active", item === score);
});
}
async function saveFivePointManual(chatId, effectId, moveNext = true) {
if (!selectedFivePointScore) {
alert("请先选择 1-5 的人工评分");
return;
}
const payload = {
chat_id: chatId,
effect_id: effectId,
manual_score_5: selectedFivePointScore,
manual_label: "",
evaluator: currentEvaluator(),
notes: "",
};
try {
await api("/api/annotations", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
if (activeMode === "rate" && moveNext) {
await advanceRatingQueueAfterSave(effectId);
} else {
markRecordAnnotated(effectId, selectedFivePointScore);
renderRecords();
await loadDetail(chatId, effectId);
}
} catch (err) {
alert(err.message);
}
}
async function advanceRatingQueueAfterSave(effectId) {
const savedIndex = ratingQueue.findIndex(record => record.effect_id === effectId);
if (savedIndex >= 0) {
ratingQueue.splice(savedIndex, 1);
}
if (!ratingQueue.length) {
if (ratingHasMore) {
await loadRatingQueue(ratingOffset);
refreshChatsQuietly();
return;
}
renderRatingQueue();
renderRatingPager();
renderEmptyRatingDetail();
refreshChatsQuietly();
return;
}
ratingIndex = Math.min(savedIndex >= 0 ? savedIndex : ratingIndex, ratingQueue.length - 1);
renderRatingQueue();
renderRatingPager();
await loadRatingDetail(ratingIndex);
refreshChatsQuietly();
}
function markRecordAnnotated(effectId, score) {
const item = records.find(record => record.effect_id === effectId);
if (!item) return;
item.manual = {
manual_score_5: score,
evaluator: currentEvaluator(),
};
}
async function refreshChatsQuietly() {
try {
const data = await api("/api/chats");
chats = data.chats || [];
renderChats();
renderRateChatSelect();
} catch (_err) {
return;
}
}
function normalizeContextMessages(context) {
const items = Array.isArray(context) ? context : [];
return items.filter(item => !isToolContextMessage(item)).map((item, index) => {
const parsed = parseVisibleText(item.text || "");
const rawText = parsed.content || item.text || "";
const messageId = item.message_id || parsed.messageId || "";
const attachments = Array.isArray(item.attachments) ? item.attachments : [];
return {
index,
role: item.role || parsed.role || "message",
source: item.source || "",
timestamp: item.timestamp || parsed.time || "",
name: parsed.name || roleName(item.role, item.source),
messageId,
text: cleanMessageText(rawText, attachments),
quoteTargetIds: quoteTargetIdsFromMessage(item, rawText),
attachments,
isTarget: messageId && String(messageId) === String(selectedTargetMessageId()),
};
});
}
function isToolContextMessage(item) {
const role = String(item.role || "").toLowerCase();
const source = String(item.source || "").toLowerCase();
if (role === "assistant") return true;
if (role === "tool") return true;
if (source === "continue") return true;
if (source === "tool" || source.includes("tool")) return true;
return false;
}
function parseVisibleText(text) {
const value = String(text || "");
const plannerPattern = /^\s*\[时间\](?<time>[^\n]*)\n\[用户名\](?<name>[^\n]*)\n(?:\[用户群昵称\][^\n]*\n)?(?:\[msg_id\](?<plannerMessageId>[^\n]*)\n)?\[发言内容\](?<plannerContent>[\s\S]*)$/;
const plannerMatch = value.match(plannerPattern);
if (plannerMatch && plannerMatch.groups) {
return {
time: (plannerMatch.groups.time || "").trim(),
messageId: (plannerMatch.groups.plannerMessageId || "").trim(),
name: (plannerMatch.groups.name || "").trim(),
content: (plannerMatch.groups.plannerContent || "").trim(),
};
}
const pattern = /^\s*(?<time>\d{1,2}:\d{2}:\d{2})?(?:\[msg_id(?::|\])(?<messageId>[^\]\n]+)\]?)?(?:\[(?<name>[^\]]+)\])?(?<content>[\s\S]*)$/;
const match = value.match(pattern);
if (!match || !match.groups) return { content: value };
if (!match.groups.time && !match.groups.messageId && match.groups.name && !(match.groups.content || "").trim()) {
return { content: value };
}
return {
time: match.groups.time || "",
messageId: match.groups.messageId || "",
name: match.groups.name || "",
content: (match.groups.content || "").trim(),
};
}
function selectedTargetMessageId() {
return currentTargetMessageId;
}
function normalizeBotReply(reply) {
const metadata = reply.reply_metadata || {};
const sentIds = Array.isArray(metadata.sent_message_ids) ? metadata.sent_message_ids : [];
return {
side: "bot",
role: "assistant",
source: "guided_reply",
name: "Bot",
timestamp: "本次回复",
messageId: sentIds[0] || "",
messageIds: sentIds,
text: cleanMessageText(reply.reply_text || "", []),
quoteTargetIds: [],
attachments: [],
isTarget: false,
};
}
function normalizeFollowupMessages(followups) {
if (!followups || !followups.length) return [];
return followups.map(message => {
const rawText = message.visible_text || message.plain_text || "";
const parsed = parseVisibleText(rawText);
const attachments = Array.isArray(message.attachments) ? message.attachments : [];
const messageId = message.message_id || parsed.messageId || "";
return {
side: "user",
role: "user",
source: "followup",
name: `${userName(message) || parsed.name}${message.is_target_user ? " · 目标用户" : ""}`,
timestamp: message.timestamp || "",
messageId,
text: cleanMessageText(parsed.content || rawText, attachments),
quoteTargetIds: quoteTargetIdsFromMessage(message, rawText),
attachments,
isTarget: message.is_target_user,
};
});
}
function buildCurrentMessageIndex(contextMessages, botReply, followupMessages) {
currentMessageIndex = new Map();
[...contextMessages, botReply, ...followupMessages].forEach(message => {
const ids = [message.messageId, ...(Array.isArray(message.messageIds) ? message.messageIds : [])]
.map(id => String(id || "").trim())
.filter(Boolean);
ids.forEach(id => {
if (!currentMessageIndex.has(id)) currentMessageIndex.set(id, message);
});
});
}
function quoteTargetIdsFromMessage(message, text) {
const structuredIds = Array.isArray(message.quote_target_ids) ? message.quote_target_ids : [];
const textIds = [];
String(text || "").replace(/\[引用回复\]\(([^)]+)\)/g, (_, id) => {
const normalizedId = String(id || "").trim();
if (normalizedId) textIds.push(normalizedId);
return "";
});
return [...new Set([...structuredIds, ...textIds].map(id => String(id || "").trim()).filter(Boolean))];
}
function renderMessageCards(messages, emptyText = "暂无上下文") {
if (!messages.length) return `<div class="empty">${escapeHtml(emptyText)}</div>`;
return messages.map(message => {
const side = isBotContextMessage(message) ? "bot" : "user";
return renderChatMessageCard({
side,
role: message.role,
source: message.source,
name: message.name,
timestamp: message.timestamp,
messageId: message.messageId,
text: message.text,
quoteTargetIds: message.quoteTargetIds || [],
attachments: message.attachments,
isTarget: message.isTarget,
});
}).join("");
}
function renderChatMessageCard(message) {
const messageIdText = message.messageId ? ` · ${escapeHtml(message.messageId)}` : "";
const textHtml = message.text ? `<div class="message-text">${escapeHtml(message.text)}</div>` : "";
return `
<div class="message-row ${escapeAttr(message.side || "user")}">
<div class="message-card ${escapeAttr(message.role || "")} ${escapeAttr(message.source || "")} ${message.isTarget ? "target" : ""}">
<div class="message-head">
<span class="message-name">${escapeHtml(message.name || "消息")}</span>
<span>${escapeHtml(message.timestamp || "")}${messageIdText}</span>
</div>
${renderQuoteCards(message.quoteTargetIds || [])}
${textHtml}
${renderAttachments(message.attachments || [])}
</div>
</div>
`;
}
function renderQuoteCards(quoteTargetIds) {
if (!quoteTargetIds || !quoteTargetIds.length) return "";
return quoteTargetIds.map(targetId => {
const quoted = currentMessageIndex.get(String(targetId || ""));
if (!quoted) {
return `
<div class="quote-card missing">
<div class="quote-title">
<span>引用回复</span>
<span>${escapeHtml(targetId)}</span>
</div>
<div class="quote-text">未在本记录的上下文或后续消息中找到这条消息</div>
</div>
`;
}
const quotedName = quoted.name || roleName(quoted.role, quoted.source);
const quotedText = quoted.text || attachmentSummary(quoted.attachments || []) || "无文本内容";
return `
<div class="quote-card">
<div class="quote-title">
<span>引用 ${escapeHtml(quotedName)}</span>
<span>${escapeHtml(targetId)}</span>
</div>
<div class="quote-text">${escapeHtml(quotedText)}</div>
</div>
`;
}).join("");
}
function renderAttachments(attachments) {
const shown = (attachments || []).filter(item => attachmentUrl(item));
if (!shown.length) return "";
return `
<div class="message-attachments">
${shown.map(item => `
<div>
<img class="message-image" src="${escapeAttr(attachmentUrl(item))}" alt="${escapeAttr(item.content || item.kind || "图片")}" loading="lazy" />
</div>
`).join("")}
</div>
`;
}
function attachmentUrl(item) {
if (!item) return "";
if (item.data_url) return item.data_url;
if (item.path) return `/api/image?path=${encodeURIComponent(item.path)}`;
if (item.hash) return `/api/image_hash?hash=${encodeURIComponent(item.hash)}&kind=${encodeURIComponent(item.kind || "")}`;
return "";
}
function cleanMessageText(text, attachments = []) {
let normalized = stripVisibleMessagePrefix(String(text || "")).replace(/\[引用回复\]\([^)]+\)/g, "");
const shownAttachments = (attachments || []).filter(item => attachmentUrl(item));
if (shownAttachments.length) {
normalized = normalized
.replace(/\[图片\]/g, "")
.replace(/\[表情包?\]/g, "");
for (const attachment of shownAttachments) {
const content = String(attachment.content || "").trim();
if (!content) continue;
normalized = normalized.split(content).join("");
}
}
return normalized.trim();
}
function stripVisibleMessagePrefix(text) {
const parsed = parseVisibleText(text);
if (parsed.content && parsed.content !== text) return parsed.content;
return String(text || "");
}
function attachmentSummary(attachments) {
const count = Array.isArray(attachments) ? attachments.length : 0;
if (!count) return "";
return count === 1 ? "[图片]" : `[${count} 张图片]`;
}
function isBotContextMessage(message) {
const role = String(message.role || "").toLowerCase();
const source = String(message.source || "").toLowerCase();
return role === "assistant" || source === "guided_reply";
}
function roleName(role, source) {
if (source === "guided_reply") return "Bot 已发送";
if (role === "assistant") return "Bot 思考";
if (role === "tool") return "工具";
if (role === "user") return "用户";
return source || role || "消息";
}
function manualFivePointText(manual) {
if (!manual) return "未评";
if (manual.manual_score_5) return `${manual.manual_score_5}/5`;
const score = score100ToFive(manual.manual_score);
return score ? `${score}/5` : "未评";
}
function score100ToFive(value) {
const number = Number(value);
if (!Number.isFinite(number)) return 0;
return Math.max(1, Math.min(5, Math.round(number / 25 + 1)));
}
function userName(user) {
if (!user) return "未知";
return user.cardname || user.nickname || user.user_id || "未知";
}
function shortTime(value) {
return String(value || "").replace("T", " ").replace("+08:00", "");
}
function valueOf(id) {
const element = document.getElementById(id);
return element ? element.value : "";
}
function currentEvaluator() {
return valueOf("globalEvaluator").trim() || "manual";
}
function saveGlobalEvaluator() {
try {
localStorage.setItem("replyEffectEvaluator", currentEvaluator());
} catch (_err) {
return;
}
}
function restoreGlobalEvaluator() {
const input = document.getElementById("globalEvaluator");
if (!input) return;
try {
input.value = localStorage.getItem("replyEffectEvaluator") || "manual";
} catch (_err) {
input.value = "manual";
}
}
function scoreText(v) {
return v === null || v === undefined || v === "" ? "N/A" : Number(v).toFixed(1);
}
function fmt(v) {
return v === null || v === undefined || v === "" ? "N/A" : Number(v).toFixed(2);
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, c => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[c]));
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#96;");
}
restoreGlobalEvaluator();
reloadAll();
</script>
</body>
</html>
"""
def build_handler(repository: ReplyEffectRepository) -> type[ReplyEffectPreviewHandler]:
class ConfiguredHandler(ReplyEffectPreviewHandler):
pass
ConfiguredHandler.repository = repository
return ConfiguredHandler
def main() -> None:
parser = argparse.ArgumentParser(description="预览 Maisaka 回复效果评分,并记录人工评分。")
parser.add_argument("--host", default=DEFAULT_HOST, help=f"监听地址,默认 {DEFAULT_HOST}")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"监听端口,默认 {DEFAULT_PORT}")
parser.add_argument("--log-dir", type=Path, default=DEFAULT_LOG_DIR, help="回复效果 JSON 日志目录")
parser.add_argument("--manual-dir", type=Path, default=DEFAULT_MANUAL_DIR, help="人工评分 JSON 保存目录")
parser.add_argument("--no-browser", action="store_true", help="不自动打开浏览器")
args = parser.parse_args()
mimetypes.add_type("text/html", ".html")
repository = ReplyEffectRepository(args.log_dir, args.manual_dir)
server = ThreadingHTTPServer((args.host, args.port), build_handler(repository))
url = f"http://{args.host}:{args.port}/"
print(f"Maisaka 回复效果评分预览已启动: {url}")
print(f"自动评分目录: {args.log_dir}")
print(f"人工评分目录: {args.manual_dir}")
if not args.no_browser:
webbrowser.open(url)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n正在关闭预览服务...")
finally:
server.server_close()
if __name__ == "__main__":
main()