Commit 96799bc7 96799bc7a3005e121a90e208d4e77522720522cc by 沈秋雨

fix(review): 优化复核逻辑及同步流程

- 调整已审核记录的识别条件,区分不同审核状态
- 移除撤销审核权限中的审核人限制,允许所有审核人撤销
- 修正 SQL 领取语句,避免错误筛选已审核记录的审核人字段
- 更新前端领取逻辑,避免新建/合并/跳过等记录被错误领取
- 增加多人审核首次启用时的数据库迁移说明
- 前端页面增加审核任务领取、数据同步及冲突处理的详细说明
- 添加单元测试对更新代码的覆盖和验证
- 新增 title-norm Python 包相关项目配置及说明文档
1 parent 98d1d92a
......@@ -144,11 +144,19 @@ python -m pytest test_dedup.py::TestL2Benchmark::test_l2_recall_topk_efficiency_
### 7. 启动复核前端
首次启用多人审核时,先让服务补齐领取和乐观锁字段(已有审核结果不会被修改):
```bash
python serve_l2_dashboard.py --migrate-review-schema --host 127.0.0.1 --port 8765
```
后续正常启动:
```bash
python serve_l2_dashboard.py --host 127.0.0.1 --port 8765
```
然后访问 http://127.0.0.1:8765 查看测试报告、比对歌词、人工标注
然后访问 http://127.0.0.1:8765,填写审核人姓名或工号后开始审核。打开 `pending/unsure` 的人工复核记录时会自动领取;`new/merge/skip/已审核` 记录仅供浏览,不会领取。领取冲突只会将当前记录设为只读,不会刷新列表或跳转。其他审核人的结果约 15 秒内轻量同步,统计信息每分钟刷新一次,页面处于后台时暂停轮询
## 单元测试
......
......@@ -840,9 +840,10 @@
if (!query || state.run?.type !== 'staging' || !query.staging_id) return true;
const status = query.review_status || '';
const isPendingReview = rowDecision(query) === 'review' && ['pending', 'unsure', ''].includes(status);
const isOwnReviewedRecord = allowReviewed && query.reviewed_by === state.reviewer;
const isReviewedRecord = allowReviewed &&
['approved_import', 'rejected_duplicate', 'deleted'].includes(status);
const isNonReviewRecord = allowNonReview && status === 'not_required';
const reviewable = isPendingReview || isOwnReviewedRecord || isNonReviewRecord;
const reviewable = isPendingReview || isReviewedRecord || isNonReviewRecord;
// new / merge / skip 等记录只浏览,不应触发领取请求。
if (!reviewable) return true;
try {
......@@ -1103,8 +1104,7 @@
const needsManualReview = rowDecision(query) === 'review';
const hasFinalReview = Boolean(query.review_status) &&
!['pending', 'not_required', 'unsure'].includes(query.review_status);
const canUndoReview = hasFinalReview && query.staging_status !== 'imported' &&
query.reviewed_by === state.reviewer;
const canUndoReview = hasFinalReview && query.staging_status !== 'imported';
const canDeleteRecord = query.staging_status !== 'imported';
// Sticky area: 新入库歌词 + 召回候选(各含音频播放器)
......
# title-norm
`archive-bridge` 链路B `internal/subject/normalize.go`
`title -> (title_norm, version_type)` 算法的 Python 移植包,供跨语言/离线批处理场景复用。
移植范围(与 Go 源保持处理顺序与规则一致):
- `normalize_name`:姓名/文本归一化(全角转半角、繁转简、空白折叠、转小写)
- `normalize_title`:歌名 -> `(title_norm, version)` 主算法
- 书名号剥离 → 破折号后缀版本感知剥离 → 多重括号循环剥离 → 无括号版本后缀词剥离
`normalize_name` → 版本枚举归一
- `strip_clip_markers`:削去片段标题(如抖音切片)的版本/切片标记后缀
- `is_version_marker`:判定内容是否为版本标记(版本别名/固定词表/速度模式/黑名单)
- 其余辅助:`split_names` / `normalize_name_set` / `normalize_set` /
`singer_identity_key` / `author_identity_key` / `org_identity_key` /
`platform_from_singer_table`
## 安装
```bash
pip install -e .
```
繁简转换依赖 `opencc-python-reimplemented`(纯查表,非 AI)。若安装失败或加载出错,
`normalize_name` 会跳过繁简转换,不影响其余归一逻辑(与 Go 源行为一致)。
## 用法
```python
from title_norm import normalize_title
title_norm, version = normalize_title("七里香(现场版)")
# title_norm == "七里香", version == "live"
```
## 测试
```bash
pip install pytest
pytest
```
测试用例(`tests/test_title_norm.py`)与 Go 源 `normalize_test.go` 中的用例逐一对齐。
## 维护
Go 源是权威实现;若 `internal/subject/normalize.go` 的规则(词表、正则、处理顺序)有变更,
需同步更新本包,保持两侧行为一致。
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "title-norm"
version = "0.1.0"
description = "歌名归一化与版本提取算法(archive-bridge internal/subject/normalize.go 的 Python 移植)"
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"opencc-python-reimplemented>=0.1.4",
]
[tool.setuptools.packages.find]
include = ["title_norm*"]
......@@ -516,7 +516,7 @@ def _claim_staging_review(staging_id: int, reviewer: str, client_token: str) ->
AND (
biz_review_status IN ('pending', 'unsure')
OR biz_review_status = 'not_required'
OR (reviewed_by = %s AND biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted'))
OR biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted')
)
AND (
review_claim_expires_at IS NULL
......@@ -524,7 +524,7 @@ def _claim_staging_review(staging_id: int, reviewer: str, client_token: str) ->
OR review_claim_token = %s
)
""",
(reviewer, client_token, REVIEW_CLAIM_TTL_SECONDS, staging_id, reviewer, client_token),
(reviewer, client_token, REVIEW_CLAIM_TTL_SECONDS, staging_id, client_token),
)
if cursor.rowcount != 1:
current = _review_snapshot(cursor, staging_id)
......
......@@ -109,6 +109,8 @@ def test_claim_conflict_exposes_current_owner():
with pytest.raises(dashboard.ReviewConflictError) as raised:
dashboard._claim_staging_review(42, "alice", "alice-token")
claim_sql, _params = cursor.executions[0]
assert "reviewed_by = %s" not in claim_sql
assert raised.value.current_record["review_claimed_by"] == "bob"
assert conn.rolled_back
......@@ -191,3 +193,4 @@ def test_dashboard_distinguishes_reviewed_from_submitted():
assert row["staging_status"] == "imported"
assert '<option value="reviewed">已审核</option>' in html
assert '<option value="submitted">已提交</option>' in html
assert "hasFinalReview && query.staging_status !== 'imported'" in html
......