@seasonkoh/webaz 0.1.16 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -5
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +3 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +836 -716
- package/dist/layer2-business/L2-8-feedback/build-feedback-engine.js +169 -0
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +16 -0
- package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +1 -0
- package/dist/mcp.js +7 -3
- package/dist/pwa/data/onboarding-cases.js +345 -0
- package/dist/pwa/data/onboarding-quiz.js +247 -0
- package/dist/pwa/public/app.js +1410 -96
- package/dist/pwa/public/i18n.js +280 -2
- package/dist/pwa/public/icon-192.png +0 -0
- package/dist/pwa/public/icon-512.png +0 -0
- package/dist/pwa/public/manifest.json +5 -2
- package/dist/pwa/public/openapi.json +1 -1
- package/dist/pwa/public/sw.js +1 -1
- package/dist/pwa/routes/admin-protocol-params.js +80 -2
- package/dist/pwa/routes/admin-reports.js +14 -9
- package/dist/pwa/routes/auth-read.js +3 -1
- package/dist/pwa/routes/build-feedback.js +67 -0
- package/dist/pwa/routes/disputes-write.js +149 -1
- package/dist/pwa/routes/governance-auto-deactivate.js +108 -0
- package/dist/pwa/routes/governance-onboarding.js +785 -0
- package/dist/pwa/routes/leaderboard.js +10 -2
- package/dist/pwa/routes/orders-action.js +5 -1
- package/dist/pwa/routes/products-meta.js +30 -0
- package/dist/pwa/routes/profile-identity.js +1 -1
- package/dist/pwa/routes/public-utils.js +44 -0
- package/dist/pwa/routes/rewards-apply.js +210 -0
- package/dist/pwa/routes/rewards-auto-downgrade.js +65 -0
- package/dist/pwa/routes/rewards-escrow-expire.js +48 -0
- package/dist/pwa/routes/webauthn.js +1 -1
- package/dist/pwa/server.js +570 -63
- package/package.json +6 -3
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { generateId } from '../../layer0-foundation/L0-1-database/schema.js';
|
|
2
|
+
import { recordRepEvent } from '../../layer4-economics/L4-3-reputation/reputation-engine.js';
|
|
3
|
+
export const FB_TYPES = new Set(['ux_issue', 'bug', 'proposal']);
|
|
4
|
+
export const FB_SEVERITY = new Set(['low', 'annoying', 'blocking']);
|
|
5
|
+
export const FB_STATUS = new Set(['received', 'triaged', 'in_progress', 'resolved', 'declined', 'duplicate']);
|
|
6
|
+
const RATE_LIMIT_PER_DAY = 10; // 每用户每日提交上限(反灌水)
|
|
7
|
+
const TEXT_MAX = 4000;
|
|
8
|
+
export function initBuildFeedbackSchema(db) {
|
|
9
|
+
db.exec(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS build_feedback (
|
|
11
|
+
id TEXT PRIMARY KEY, -- fb_xxx
|
|
12
|
+
user_id TEXT NOT NULL,
|
|
13
|
+
type TEXT NOT NULL, -- ux_issue | bug | proposal
|
|
14
|
+
area TEXT, -- search / order / dispute / ...(自由但建议枚举)
|
|
15
|
+
severity TEXT, -- low | annoying | blocking (ux_issue/bug)
|
|
16
|
+
subject TEXT,
|
|
17
|
+
body TEXT NOT NULL,
|
|
18
|
+
scene_json TEXT, -- 脱敏现场证据(最近调用摘要 + agent 提供的 context)
|
|
19
|
+
source TEXT NOT NULL DEFAULT 'agent', -- agent | pwa
|
|
20
|
+
status TEXT NOT NULL DEFAULT 'received',
|
|
21
|
+
dedup_of TEXT, -- 若判重,指向被合并的原始反馈
|
|
22
|
+
rfc_draft TEXT, -- proposal 够分量时 agent 起草的 RFC 草稿
|
|
23
|
+
resolution TEXT, -- maintainer 处置说明
|
|
24
|
+
credited_points INTEGER DEFAULT 0, -- 采纳时记入的 co-build 信誉分
|
|
25
|
+
handled_by TEXT,
|
|
26
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
27
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
28
|
+
)
|
|
29
|
+
`);
|
|
30
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_build_feedback_user ON build_feedback(user_id, created_at DESC)`);
|
|
31
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_build_feedback_status ON build_feedback(status, created_at DESC)`);
|
|
32
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_build_feedback_area ON build_feedback(area, type, status)`);
|
|
33
|
+
// 状态/记功审计(防 reputation gaming:每次状态变更可追溯)
|
|
34
|
+
db.exec(`
|
|
35
|
+
CREATE TABLE IF NOT EXISTS build_feedback_events (
|
|
36
|
+
id TEXT PRIMARY KEY, -- fbev_xxx
|
|
37
|
+
feedback_id TEXT NOT NULL,
|
|
38
|
+
actor_id TEXT,
|
|
39
|
+
from_status TEXT,
|
|
40
|
+
to_status TEXT NOT NULL,
|
|
41
|
+
note TEXT,
|
|
42
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
43
|
+
)
|
|
44
|
+
`);
|
|
45
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_build_feedback_events ON build_feedback_events(feedback_id, created_at)`);
|
|
46
|
+
}
|
|
47
|
+
export function submitBuildFeedback(db, input) {
|
|
48
|
+
const type = String(input.type || '').trim();
|
|
49
|
+
if (!FB_TYPES.has(type))
|
|
50
|
+
return { error: `type 必须是 ${[...FB_TYPES].join(' | ')}`, error_code: 'BAD_TYPE' };
|
|
51
|
+
const body = String(input.body || '').trim();
|
|
52
|
+
if (body.length < 5)
|
|
53
|
+
return { error: '反馈内容太短(至少 5 字)', error_code: 'BODY_TOO_SHORT' };
|
|
54
|
+
if (body.length > TEXT_MAX)
|
|
55
|
+
return { error: `反馈内容过长(上限 ${TEXT_MAX})`, error_code: 'BODY_TOO_LONG' };
|
|
56
|
+
const severity = input.severity && FB_SEVERITY.has(input.severity) ? input.severity : null;
|
|
57
|
+
const area = input.area ? String(input.area).slice(0, 64) : null;
|
|
58
|
+
const subject = input.subject ? String(input.subject).slice(0, 200) : null;
|
|
59
|
+
// 反噪音闸 2:频率限制
|
|
60
|
+
const todayCount = db.prepare(`SELECT COUNT(*) AS n FROM build_feedback WHERE user_id = ? AND created_at > datetime('now','-1 day')`).get(input.userId).n;
|
|
61
|
+
if (todayCount >= RATE_LIMIT_PER_DAY) {
|
|
62
|
+
return { error: `今日反馈已达上限(${RATE_LIMIT_PER_DAY}/天)`, error_code: 'RATE_LIMITED' };
|
|
63
|
+
}
|
|
64
|
+
// 反噪音闸 3(proposal 去重):同 area 已有 open proposal 且文本高度重合 → 标记重复
|
|
65
|
+
if (type === 'proposal' && area) {
|
|
66
|
+
const dup = findDuplicateProposal(db, area, body, input.userId);
|
|
67
|
+
if (dup) {
|
|
68
|
+
const id = generateId('fb');
|
|
69
|
+
db.prepare(`INSERT INTO build_feedback (id,user_id,type,area,severity,subject,body,scene_json,source,status,dedup_of)
|
|
70
|
+
VALUES (?,?,?,?,?,?,?,?,?, 'duplicate', ?)`).run(id, input.userId, type, area, severity, subject, body, input.sceneJson != null ? JSON.stringify(input.sceneJson) : null, input.source ?? 'agent', dup);
|
|
71
|
+
logEvent(db, id, input.userId, null, 'duplicate', `auto-dedup → ${dup}`);
|
|
72
|
+
return { id, status: 'duplicate', type, deduped_into: dup };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const id = generateId('fb');
|
|
76
|
+
db.prepare(`INSERT INTO build_feedback (id,user_id,type,area,severity,subject,body,scene_json,source,status)
|
|
77
|
+
VALUES (?,?,?,?,?,?,?,?,?, 'received')`).run(id, input.userId, type, area, severity, subject, body, input.sceneJson != null ? JSON.stringify(input.sceneJson) : null, input.source ?? 'agent');
|
|
78
|
+
logEvent(db, id, input.userId, null, 'received', null);
|
|
79
|
+
return { id, status: 'received', type };
|
|
80
|
+
}
|
|
81
|
+
// 简单去重:同 area 的 open proposal,且词集重合率 ≥ 0.6(phase A 启发式;AI 分级是后续增强)
|
|
82
|
+
function findDuplicateProposal(db, area, body, userId) {
|
|
83
|
+
const rows = db.prepare(`SELECT id, body FROM build_feedback
|
|
84
|
+
WHERE type = 'proposal' AND area = ? AND status IN ('received','triaged','in_progress')
|
|
85
|
+
ORDER BY created_at DESC LIMIT 50`).all(area);
|
|
86
|
+
const tok = (s) => new Set(s.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(w => w.length >= 2));
|
|
87
|
+
const a = tok(body);
|
|
88
|
+
if (a.size === 0)
|
|
89
|
+
return null;
|
|
90
|
+
for (const r of rows) {
|
|
91
|
+
const b = tok(r.body);
|
|
92
|
+
if (b.size === 0)
|
|
93
|
+
continue;
|
|
94
|
+
let inter = 0;
|
|
95
|
+
for (const w of a)
|
|
96
|
+
if (b.has(w))
|
|
97
|
+
inter++;
|
|
98
|
+
const overlap = inter / Math.min(a.size, b.size);
|
|
99
|
+
if (overlap >= 0.6)
|
|
100
|
+
return r.id;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
function logEvent(db, feedbackId, actorId, from, to, note) {
|
|
105
|
+
db.prepare(`INSERT INTO build_feedback_events (id,feedback_id,actor_id,from_status,to_status,note) VALUES (?,?,?,?,?,?)`)
|
|
106
|
+
.run(generateId('fbev'), feedbackId, actorId, from, to, note);
|
|
107
|
+
}
|
|
108
|
+
function parse(row) {
|
|
109
|
+
let scene = null;
|
|
110
|
+
if (row.scene_json) {
|
|
111
|
+
try {
|
|
112
|
+
scene = JSON.parse(row.scene_json);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
scene = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const { scene_json, ...rest } = row;
|
|
119
|
+
return { ...rest, scene };
|
|
120
|
+
}
|
|
121
|
+
export function listMyBuildFeedback(db, userId) {
|
|
122
|
+
const rows = db.prepare(`SELECT id, type, area, severity, subject, body, status, dedup_of, resolution, credited_points, created_at, updated_at
|
|
123
|
+
FROM build_feedback WHERE user_id = ? ORDER BY created_at DESC LIMIT 100`).all(userId);
|
|
124
|
+
return rows;
|
|
125
|
+
}
|
|
126
|
+
export function getBuildFeedback(db, id, userId, isAdmin) {
|
|
127
|
+
const row = db.prepare('SELECT * FROM build_feedback WHERE id = ?').get(id);
|
|
128
|
+
if (!row)
|
|
129
|
+
return null;
|
|
130
|
+
if (!isAdmin && row.user_id !== userId)
|
|
131
|
+
return null;
|
|
132
|
+
const events = db.prepare('SELECT from_status, to_status, note, created_at FROM build_feedback_events WHERE feedback_id = ? ORDER BY created_at').all(id);
|
|
133
|
+
return { ...parse(row), events };
|
|
134
|
+
}
|
|
135
|
+
export function adminListBuildFeedback(db, status) {
|
|
136
|
+
const rows = (status && FB_STATUS.has(status))
|
|
137
|
+
? db.prepare('SELECT * FROM build_feedback WHERE status = ? ORDER BY created_at DESC LIMIT 200').all(status)
|
|
138
|
+
: db.prepare('SELECT * FROM build_feedback ORDER BY created_at DESC LIMIT 200').all();
|
|
139
|
+
return rows.map(parse);
|
|
140
|
+
}
|
|
141
|
+
export function adminUpdateBuildFeedback(db, u) {
|
|
142
|
+
const row = db.prepare('SELECT * FROM build_feedback WHERE id = ?').get(u.id);
|
|
143
|
+
if (!row)
|
|
144
|
+
return { error: '反馈不存在' };
|
|
145
|
+
const fromStatus = row.status;
|
|
146
|
+
const newStatus = u.status && FB_STATUS.has(u.status) ? u.status : fromStatus;
|
|
147
|
+
// co-build 信誉:仅在置为 resolved + credit 且此前未记功时发放(防重复发放 / gaming)。
|
|
148
|
+
// 分级门(RFC-004 精确化):信誉只发给【有 Passkey 锚点】的提交者 —— 奖励必须锚真人;
|
|
149
|
+
// 无 Passkey 的报告者(报问题=用)可受理致谢,但无锚点不记分。
|
|
150
|
+
let credited = Number(row.credited_points) || 0;
|
|
151
|
+
let credit_skipped_no_anchor = false;
|
|
152
|
+
if (u.credit && newStatus === 'resolved' && credited === 0) {
|
|
153
|
+
const hasAnchor = ((db.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
|
|
154
|
+
.get(row.user_id)?.n) || 0) > 0;
|
|
155
|
+
if (hasAnchor) {
|
|
156
|
+
recordRepEvent(db, row.user_id, 'feedback_accepted', `build feedback ${u.id} 被采纳`);
|
|
157
|
+
credited = 8;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
credit_skipped_no_anchor = true; // 受理但不记分(提交者无 Passkey 锚点)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
db.prepare(`UPDATE build_feedback SET status = ?, resolution = COALESCE(?, resolution),
|
|
164
|
+
rfc_draft = COALESCE(?, rfc_draft), credited_points = ?, handled_by = ?, updated_at = datetime('now')
|
|
165
|
+
WHERE id = ?`).run(newStatus, u.resolution ?? null, u.rfcDraft ?? null, credited, u.adminId, u.id);
|
|
166
|
+
if (newStatus !== fromStatus)
|
|
167
|
+
logEvent(db, u.id, u.adminId, fromStatus, newStatus, u.resolution ?? null);
|
|
168
|
+
return { ok: true, credited, ...(credit_skipped_no_anchor ? { credit_skipped_no_anchor: true } : {}) };
|
|
169
|
+
}
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* - 仲裁员超时 → 协议默认退款给买家(买家保护原则)
|
|
9
9
|
*
|
|
10
10
|
* 覆盖模块:L3-1 争议触发、L3-2 证据收集、L3-3 超时自动判责、L3-5 处置执行
|
|
11
|
+
*
|
|
12
|
+
* 关联 / Related: AGENTS.md · 元规则 #1 当一切可见 / #5 不偏袒(判责规则对所有人一致) ·
|
|
13
|
+
* arbitrate 是 Iron-Rule 真人动作(需 Passkey) · 协议级改动审批见 CHARTER §3.2
|
|
11
14
|
*/
|
|
12
15
|
import { generateId } from '../../layer0-foundation/L0-1-database/schema.js';
|
|
13
16
|
import { transition } from '../../layer0-foundation/L0-2-state-machine/engine.js';
|
|
@@ -28,6 +31,12 @@ export function initDisputeSchema(db) {
|
|
|
28
31
|
// Phase 1 新增:多方举证 + 责任分配
|
|
29
32
|
`ALTER TABLE disputes ADD COLUMN party_evidence_ids TEXT DEFAULT '[]'`,
|
|
30
33
|
`ALTER TABLE disputes ADD COLUMN liability_parties TEXT DEFAULT '[]'`,
|
|
34
|
+
// 2026-06-02 task #1093 stage 6: arbitrator_pause_auto_judge (playbook §2.1)
|
|
35
|
+
// Freeze the 48h respondent-silence auto-judge clock + the arbitrate_deadline clock
|
|
36
|
+
// when arbitrator legitimately needs more time for evidence collection.
|
|
37
|
+
`ALTER TABLE disputes ADD COLUMN auto_judge_paused_until INTEGER`,
|
|
38
|
+
`ALTER TABLE disputes ADD COLUMN auto_judge_pause_reason TEXT`,
|
|
39
|
+
`ALTER TABLE disputes ADD COLUMN audit_log TEXT DEFAULT '[]'`,
|
|
31
40
|
];
|
|
32
41
|
for (const stmt of newColumns) {
|
|
33
42
|
try {
|
|
@@ -550,6 +559,13 @@ export function checkDisputeTimeouts(db) {
|
|
|
550
559
|
const openDisputes = db.prepare(`SELECT * FROM disputes WHERE status IN ('open', 'in_review')`).all();
|
|
551
560
|
const sysUser = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
|
|
552
561
|
for (const dispute of openDisputes) {
|
|
562
|
+
// task #1093 stage 6: skip auto-judge if arbitrator paused the clock (playbook §2.1)
|
|
563
|
+
// Pause expires automatically when auto_judge_paused_until passes; no resume needed
|
|
564
|
+
// for the clock to thaw — explicit resume just clears the field eagerly + audit log.
|
|
565
|
+
const pausedUntil = dispute.auto_judge_paused_until;
|
|
566
|
+
if (pausedUntil && pausedUntil * 1000 > Date.now()) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
553
569
|
if (dispute.status === 'open' && dispute.respond_deadline && now > dispute.respond_deadline) {
|
|
554
570
|
// 被告未在截止时间内回应 → 自动判发起方胜诉
|
|
555
571
|
const initiator = db.prepare('SELECT role FROM users WHERE id = ?')
|
|
@@ -136,6 +136,7 @@ const EVENT_POINTS = {
|
|
|
136
136
|
rating_received_good: () => 3,
|
|
137
137
|
rating_received_neutral: () => 0,
|
|
138
138
|
rating_received_bad: () => -5,
|
|
139
|
+
feedback_accepted: () => 8, // RFC-004 co-build:被采纳的反馈/提案
|
|
139
140
|
};
|
|
140
141
|
// ─── 写入声誉事件 ─────────────────────────────────────────────
|
|
141
142
|
export function recordRepEvent(db, userId, eventType, reason, orderId, role) {
|
package/dist/mcp.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* MCP Server 入口
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* MCP Server 入口 — WebAZ 的 agent 入口
|
|
4
|
+
*
|
|
5
|
+
* 运行:npm run mcp(或 npx @seasonkoh/webaz);通过 stdio 与 Claude 等 MCP 客户端通信。
|
|
6
|
+
* 工具实现都在 src/layer1-agent/L1-1-mcp-server/server.ts(本文件只是 bootstrap)。
|
|
7
|
+
* 双模:配 WEBAZ_API_KEY → NETWORK(调 webaz.xyz 共享网络);否则 SANDBOX(本机库)。详见 RFC-003。
|
|
8
|
+
*
|
|
9
|
+
* 关联 / Related: AGENTS.md(项目地图) · RFC-003(双模) · RFC-004(webaz_feedback)
|
|
6
10
|
*/
|
|
7
11
|
import { startMCPServer } from './layer1-agent/L1-1-mcp-server/server.js';
|
|
8
12
|
startMCPServer().catch((err) => {
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governance Onboarding 案例库(spec §4.2)
|
|
3
|
+
* task #1093 阶段 2b — arbitrator 5 案例 / verifier 3 案例
|
|
4
|
+
*
|
|
5
|
+
* **Status**: v1 draft(2026-06-02,user 后期 review 调整)
|
|
6
|
+
*
|
|
7
|
+
* 设计原则:
|
|
8
|
+
* - 脱敏 + 简化(phase A 不从真 disputes 表抽样,初版用编造案例;后续真用户出现后可补真案例)
|
|
9
|
+
* - 申请者写"我会怎么判 + 理由"(≥ 200 字,spec §4.2)
|
|
10
|
+
* - **不立即自动评分** — maintainer 在 §4.4 上岗签字前对比实际 expected_verdict,评估 reasoning 方向
|
|
11
|
+
* - expected_verdict 字段仅 maintainer 可见(server 端剥离)
|
|
12
|
+
* - key_principles 引用 spec(arbitration PLAYBOOK 案例 / META-RULES / framework §X)
|
|
13
|
+
*
|
|
14
|
+
* 覆盖范围:
|
|
15
|
+
* - arbitrator: 4 种 verdict(release_seller / refund_buyer / partial_refund / liability_split)+ Case 2 物流卡顿(资金流)
|
|
16
|
+
* - verifier: claim 真伪(pass / fail / no_fault)
|
|
17
|
+
*
|
|
18
|
+
* Phase B+ 升级方向(留 hook):
|
|
19
|
+
* - 真 disputes 表抽样脱敏 fixture
|
|
20
|
+
* - 给 maintainer review UI:对比申请者 review 与 expected_verdict + 给评语
|
|
21
|
+
*/
|
|
22
|
+
export const ONBOARDING_CASES = [
|
|
23
|
+
// ── arbitrator 5 案例 ─────────────────────────────────────
|
|
24
|
+
{
|
|
25
|
+
id: 'arb-1',
|
|
26
|
+
role_filter: 'arbitrator',
|
|
27
|
+
scenario_zh: '卖家发货严重延迟,无任何物流证据',
|
|
28
|
+
scenario_en: 'Seller severely delayed shipping with no logistics evidence',
|
|
29
|
+
facts_zh: [
|
|
30
|
+
'买家 2026-04-01 下单 100 WAZ 商品(标注 7 天内发货)',
|
|
31
|
+
'截至 2026-05-15(超期 38 天),卖家未发货也未沟通',
|
|
32
|
+
'买家提供:下单时的承诺截图 + 多次催促但卖家未回复',
|
|
33
|
+
'卖家提供:无任何物流单 / 沟通记录 / 备货证明',
|
|
34
|
+
'卖家 reputation 75(未达正常孵化阈值)',
|
|
35
|
+
],
|
|
36
|
+
facts_en: [
|
|
37
|
+
'Buyer ordered 100 WAZ product on 2026-04-01 (promised: ship within 7 days)',
|
|
38
|
+
'As of 2026-05-15 (38 days overdue), seller has neither shipped nor communicated',
|
|
39
|
+
'Buyer provides: order screenshot + multiple unreplied reminders',
|
|
40
|
+
'Seller provides: no logistics doc / communication / inventory proof',
|
|
41
|
+
'Seller reputation: 75 (below normal threshold)',
|
|
42
|
+
],
|
|
43
|
+
decision_options: [
|
|
44
|
+
{ key: 'release_seller', text_zh: 'release_seller(放款给卖家)', text_en: 'release_seller (release to seller)' },
|
|
45
|
+
{ key: 'refund_buyer', text_zh: 'refund_buyer(全额退款给买家)', text_en: 'refund_buyer (full refund to buyer)' },
|
|
46
|
+
{ key: 'partial_refund', text_zh: 'partial_refund(部分退款)', text_en: 'partial_refund (partial refund)' },
|
|
47
|
+
{ key: 'liability_split', text_zh: 'liability_split(平摊)', text_en: 'liability_split (split liability)' },
|
|
48
|
+
],
|
|
49
|
+
expected_verdict: 'refund_buyer',
|
|
50
|
+
key_principles: [
|
|
51
|
+
'ARBITRATION-PLAYBOOK 4 种 verdict 适用情境',
|
|
52
|
+
'META-RULES #4 不撒谎(卖家承诺 + 无履约证据)',
|
|
53
|
+
'举证责任在卖家(他承诺了发货)',
|
|
54
|
+
],
|
|
55
|
+
min_review_chars: 200,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'arb-2',
|
|
59
|
+
role_filter: 'arbitrator',
|
|
60
|
+
scenario_zh: 'Case 2 物流卡顿:卖家发货,买家未收到,物流方失联',
|
|
61
|
+
scenario_en: 'Case 2 logistics stuck: seller shipped, buyer never received, logistics gone',
|
|
62
|
+
facts_zh: [
|
|
63
|
+
'买家下单 200 WAZ 商品',
|
|
64
|
+
'卖家提供:有效发货单 + 物流取件签名',
|
|
65
|
+
'物流追踪显示"中转中"45 天无更新',
|
|
66
|
+
'物流方联系不上(电话停机,后台账号下线)',
|
|
67
|
+
'买家:未收到货,要求退款',
|
|
68
|
+
'物流方 stake 余额 70 WAZ(< 200 WAZ 订单价)',
|
|
69
|
+
],
|
|
70
|
+
facts_en: [
|
|
71
|
+
'Buyer ordered 200 WAZ product',
|
|
72
|
+
'Seller provides: valid shipping doc + logistics pickup signature',
|
|
73
|
+
'Logistics tracking shows "in transit" for 45 days, no updates',
|
|
74
|
+
'Logistics party unreachable (phone off, backend offline)',
|
|
75
|
+
'Buyer: never received, demands refund',
|
|
76
|
+
'Logistics stake balance: 70 WAZ (< 200 WAZ order value)',
|
|
77
|
+
],
|
|
78
|
+
decision_options: [
|
|
79
|
+
{ key: 'release_seller', text_zh: 'release_seller', text_en: 'release_seller' },
|
|
80
|
+
{ key: 'refund_buyer', text_zh: 'refund_buyer + 物流 stake 优先赔买家 + management_bonus_pool 兜底', text_en: 'refund_buyer + logistics stake to buyer first + management_bonus_pool covers shortfall' },
|
|
81
|
+
{ key: 'partial_refund', text_zh: 'partial_refund 50/50', text_en: 'partial_refund 50/50' },
|
|
82
|
+
{ key: 'liability_split', text_zh: 'liability_split(卖家一半 / 物流方一半)', text_en: 'liability_split (half seller / half logistics)' },
|
|
83
|
+
],
|
|
84
|
+
expected_verdict: 'refund_buyer',
|
|
85
|
+
key_principles: [
|
|
86
|
+
'ARBITRATION-PLAYBOOK Case 2 物流卡顿 — 卖家无过错应保护',
|
|
87
|
+
'management_bonus_pool 来源 = ECONOMIC §3 ④a + 失效活动罚没',
|
|
88
|
+
'资金流向:物流 stake 优先赔买家(不入 pool),pool 仅兜底差额',
|
|
89
|
+
'物流方 debt_to_protocol 累计 → > 1000 WAZ 角色暂停',
|
|
90
|
+
],
|
|
91
|
+
min_review_chars: 200,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'arb-3',
|
|
95
|
+
role_filter: 'arbitrator',
|
|
96
|
+
scenario_zh: '商品描述部分不符 — 主要功能 OK 但 minor 偏差',
|
|
97
|
+
scenario_en: 'Product description partial mismatch — main feature OK but minor deviation',
|
|
98
|
+
facts_zh: [
|
|
99
|
+
'买家下单 50 WAZ 二手手机',
|
|
100
|
+
'描述标注:"95 新,无明显划痕"',
|
|
101
|
+
'买家收到后:功能完好,但侧面有 1 个 2cm 浅划痕',
|
|
102
|
+
'买家提供:开箱照片(确实有划痕)+ 描述截图(承诺无划痕)',
|
|
103
|
+
'卖家承认:"漏拍了那一面,无意误导"',
|
|
104
|
+
'订单价低(50 WAZ),非贵重物品',
|
|
105
|
+
],
|
|
106
|
+
facts_en: [
|
|
107
|
+
'Buyer ordered 50 WAZ second-hand phone',
|
|
108
|
+
'Description: "95% new, no visible scratches"',
|
|
109
|
+
'Buyer received: functioning, but 2cm light scratch on side',
|
|
110
|
+
'Buyer provides: unboxing photo (scratch exists) + description screenshot (promised no scratches)',
|
|
111
|
+
'Seller acknowledges: "missed that side, no intent to mislead"',
|
|
112
|
+
'Order value low (50 WAZ), not a high-value item',
|
|
113
|
+
],
|
|
114
|
+
decision_options: [
|
|
115
|
+
{ key: 'release_seller', text_zh: 'release_seller(卖家承担轻微误差合理)', text_en: 'release_seller (acceptable minor deviation)' },
|
|
116
|
+
{ key: 'refund_buyer', text_zh: 'refund_buyer(全退)', text_en: 'refund_buyer (full refund)' },
|
|
117
|
+
{ key: 'partial_refund', text_zh: 'partial_refund(部分退款,5-15 WAZ 差异补偿)', text_en: 'partial_refund (5-15 WAZ compensation for deviation)' },
|
|
118
|
+
{ key: 'liability_split', text_zh: 'liability_split', text_en: 'liability_split' },
|
|
119
|
+
],
|
|
120
|
+
expected_verdict: 'partial_refund',
|
|
121
|
+
key_principles: [
|
|
122
|
+
'描述不符但功能完好 → partial_refund(差异补偿)',
|
|
123
|
+
'META-RULES #4 不撒谎 — 卖家描述有瑕疵但非恶意',
|
|
124
|
+
'"无意误导"与"恶意欺诈"不同,处置不同',
|
|
125
|
+
'比例:轻微偏差 5-15% 补偿,严重偏差 30-50%',
|
|
126
|
+
],
|
|
127
|
+
min_review_chars: 200,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: 'arb-4',
|
|
131
|
+
role_filter: 'arbitrator',
|
|
132
|
+
scenario_zh: '双方各执一词 + 证据部分有效',
|
|
133
|
+
scenario_en: 'Both parties insist + partial valid evidence on both sides',
|
|
134
|
+
facts_zh: [
|
|
135
|
+
'买家下单 80 WAZ 美妆产品',
|
|
136
|
+
'买家:"收到时已开封,要求退款"提供开箱照(可见塑封破损)',
|
|
137
|
+
'卖家:"快递可能粗暴搬运,我发货时是完好的"提供发货前完好照片',
|
|
138
|
+
'物流方:无监控证据,无法判定途中是否破损',
|
|
139
|
+
'双方都 reputation 良好(无前科)',
|
|
140
|
+
'没有第三方目击或客观证据决定责任在哪',
|
|
141
|
+
],
|
|
142
|
+
facts_en: [
|
|
143
|
+
'Buyer ordered 80 WAZ cosmetics',
|
|
144
|
+
'Buyer: "received already opened, demands refund" provides unboxing photo (visible seal damaged)',
|
|
145
|
+
'Seller: "logistics may have mishandled, was intact when shipped" provides pre-ship intact photo',
|
|
146
|
+
'Logistics: no surveillance evidence, cannot determine in-transit damage',
|
|
147
|
+
'Both parties have good reputation (no prior incidents)',
|
|
148
|
+
'No third-party witness or objective evidence to determine fault',
|
|
149
|
+
],
|
|
150
|
+
decision_options: [
|
|
151
|
+
{ key: 'release_seller', text_zh: 'release_seller', text_en: 'release_seller' },
|
|
152
|
+
{ key: 'refund_buyer', text_zh: 'refund_buyer', text_en: 'refund_buyer' },
|
|
153
|
+
{ key: 'partial_refund', text_zh: 'partial_refund', text_en: 'partial_refund' },
|
|
154
|
+
{ key: 'liability_split', text_zh: 'liability_split(三方各承担一份)', text_en: 'liability_split (three-way split)' },
|
|
155
|
+
],
|
|
156
|
+
expected_verdict: 'liability_split',
|
|
157
|
+
key_principles: [
|
|
158
|
+
'证据双方部分有效,无法 100% 归责 → liability_split',
|
|
159
|
+
'META-RULES #5 不偏袒 — 凭证据均衡时不向任一方倾斜',
|
|
160
|
+
'本案应考虑物流方分摊(stake)— 即便物流方无监控,procedural 上有责任',
|
|
161
|
+
'split 比例可参 stake 余额 / reputation 算',
|
|
162
|
+
],
|
|
163
|
+
min_review_chars: 200,
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: 'arb-5',
|
|
167
|
+
role_filter: 'arbitrator',
|
|
168
|
+
scenario_zh: '买家恶意 dispute — 已退款后又申诉',
|
|
169
|
+
scenario_en: 'Buyer malicious dispute — re-filing after already refunded',
|
|
170
|
+
facts_zh: [
|
|
171
|
+
'买家 2026-03 下单 120 WAZ 商品',
|
|
172
|
+
'2026-04 因物流延迟自动退款(release_buyer)',
|
|
173
|
+
'商品 2026-04-20 自动签收(买家未拒收)',
|
|
174
|
+
'买家 2026-05 提 dispute:"我没收到货,要求再退一次"',
|
|
175
|
+
'物流追踪:确认 4-20 签收(签名指纹匹配买家)',
|
|
176
|
+
'买家 history:近 3 个月已 5 次类似 pattern dispute',
|
|
177
|
+
'其他 4 次中 3 次被仲裁判 fault_buyer',
|
|
178
|
+
],
|
|
179
|
+
facts_en: [
|
|
180
|
+
'Buyer ordered 120 WAZ on 2026-03',
|
|
181
|
+
'Auto-refunded 2026-04 due to logistics delay (release_buyer)',
|
|
182
|
+
'Product auto-signed 2026-04-20 (buyer did not reject)',
|
|
183
|
+
'Buyer 2026-05 raises dispute: "never received, demands re-refund"',
|
|
184
|
+
'Logistics tracking confirms 4-20 signature (fingerprint matches buyer)',
|
|
185
|
+
'Buyer history: 5 similar pattern disputes in last 3 months',
|
|
186
|
+
'Of other 4, 3 ruled fault_buyer by arbitration',
|
|
187
|
+
],
|
|
188
|
+
decision_options: [
|
|
189
|
+
{ key: 'release_seller', text_zh: 'release_seller + 标买家恶意 + 信誉惩罚', text_en: 'release_seller + mark buyer malicious + reputation penalty' },
|
|
190
|
+
{ key: 'refund_buyer', text_zh: 'refund_buyer', text_en: 'refund_buyer' },
|
|
191
|
+
{ key: 'partial_refund', text_zh: 'partial_refund', text_en: 'partial_refund' },
|
|
192
|
+
{ key: 'liability_split', text_zh: 'liability_split', text_en: 'liability_split' },
|
|
193
|
+
],
|
|
194
|
+
expected_verdict: 'release_seller',
|
|
195
|
+
key_principles: [
|
|
196
|
+
'物流签收事实 + 买家 history pattern → 买家恶意',
|
|
197
|
+
'META-RULES #6 不滥用 — 系统性 dispute 是滥用',
|
|
198
|
+
'判决要标 buyer fault + reputation 惩罚(防再犯)',
|
|
199
|
+
'与正常退款情境(arb-1)对比看:有签收事实 vs 无证据',
|
|
200
|
+
],
|
|
201
|
+
min_review_chars: 200,
|
|
202
|
+
},
|
|
203
|
+
// ── verifier 3 案例 ──────────────────────────────────────
|
|
204
|
+
{
|
|
205
|
+
id: 'ver-1',
|
|
206
|
+
role_filter: 'verifier',
|
|
207
|
+
scenario_zh: 'claim_verify:卖家声称商品 origin "Made in Japan"',
|
|
208
|
+
scenario_en: 'claim_verify: seller claims product origin "Made in Japan"',
|
|
209
|
+
facts_zh: [
|
|
210
|
+
'卖家上架商品标注 origin:"Made in Japan"',
|
|
211
|
+
'买家发起 claim verify,提供事实证据:',
|
|
212
|
+
' - 收到的实物包装清晰印刷 "Made in China"',
|
|
213
|
+
' - 高清照片证实底面 sticker',
|
|
214
|
+
'卖家未提供任何"Made in Japan"的进口证明 / 海关单',
|
|
215
|
+
'其他 verifier 中 1 人投 fail,1 人投 pass,需第 3 票决定',
|
|
216
|
+
],
|
|
217
|
+
facts_en: [
|
|
218
|
+
'Seller listed product origin: "Made in Japan"',
|
|
219
|
+
'Buyer raised claim verify with factual evidence:',
|
|
220
|
+
' - Physical product packaging clearly printed "Made in China"',
|
|
221
|
+
' - HD photo confirms bottom sticker',
|
|
222
|
+
'Seller provided no proof of Japan origin (no import doc / customs)',
|
|
223
|
+
'Other verifiers: 1 voted fail, 1 voted pass, needs 3rd vote to decide',
|
|
224
|
+
],
|
|
225
|
+
decision_options: [
|
|
226
|
+
{ key: 'pass', text_zh: 'pass(claim 真实,Made in Japan)', text_en: 'pass (claim true, Made in Japan)' },
|
|
227
|
+
{ key: 'fail', text_zh: 'fail(claim 不实,实际 Made in China)', text_en: 'fail (claim false, actually Made in China)' },
|
|
228
|
+
{ key: 'no_fault', text_zh: 'no_fault(争议无法判定 / 中立)', text_en: 'no_fault (unable to determine / neutral)' },
|
|
229
|
+
],
|
|
230
|
+
expected_verdict: 'fail',
|
|
231
|
+
key_principles: [
|
|
232
|
+
'verifier 投 pass/fail/no_fault 是对 claim 真伪的判定',
|
|
233
|
+
'事实证据(物理实物 + 高清照片)> 卖家空口承诺',
|
|
234
|
+
'META-RULES #4 不撒谎 — 描述事实必须真',
|
|
235
|
+
'与 arbitrator 判 dispute 不同:verifier 只判 claim 真伪,不分配赔偿',
|
|
236
|
+
],
|
|
237
|
+
min_review_chars: 200,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: 'ver-2',
|
|
241
|
+
role_filter: 'verifier',
|
|
242
|
+
scenario_zh: 'claim_verify:卖家声称 condition "全新",轻微 deviation',
|
|
243
|
+
scenario_en: 'claim_verify: seller claims condition "brand new", minor deviation',
|
|
244
|
+
facts_zh: [
|
|
245
|
+
'卖家上架商品标注 condition:"全新,未拆封"',
|
|
246
|
+
'买家收到后发现:外包装 完好,但内部商品有 1 个轻微指纹(可擦除)',
|
|
247
|
+
'买家发起 claim verify',
|
|
248
|
+
'卖家解释:"运输中可能 minor 接触,商品本身全新"',
|
|
249
|
+
'事实判定:本质是新的(未使用),但 100% "未接触" claim 不成立',
|
|
250
|
+
],
|
|
251
|
+
facts_en: [
|
|
252
|
+
'Seller listed condition: "brand new, sealed"',
|
|
253
|
+
'Buyer found: outer packaging intact, but inner product has 1 minor fingerprint (erasable)',
|
|
254
|
+
'Buyer raises claim verify',
|
|
255
|
+
'Seller explains: "minor contact during transit possible, item itself is new"',
|
|
256
|
+
'Fact: essentially new (unused), but 100% "untouched" claim not perfect',
|
|
257
|
+
],
|
|
258
|
+
decision_options: [
|
|
259
|
+
{ key: 'pass', text_zh: 'pass(本质全新)', text_en: 'pass (essentially new)' },
|
|
260
|
+
{ key: 'fail', text_zh: 'fail(claim 不准)', text_en: 'fail (claim inaccurate)' },
|
|
261
|
+
{ key: 'no_fault', text_zh: 'no_fault(轻微偏差非欺诈)', text_en: 'no_fault (minor deviation not fraud)' },
|
|
262
|
+
],
|
|
263
|
+
expected_verdict: 'no_fault',
|
|
264
|
+
key_principles: [
|
|
265
|
+
'"全新"实质成立,但 100% 严格 claim 略偏差 — 不算 fail',
|
|
266
|
+
'no_fault = 卖家无主观恶意 + 实质 claim 成立',
|
|
267
|
+
'与 ver-1(本质事实不符)对比:此案是轻微 deviation',
|
|
268
|
+
'过严判 fail 会让所有 minor 包装变化都"被欺诈"',
|
|
269
|
+
],
|
|
270
|
+
min_review_chars: 200,
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
id: 'ver-3',
|
|
274
|
+
role_filter: 'verifier',
|
|
275
|
+
scenario_zh: 'claim_verify:卖家声称 warranty 30 天,实际只给 7 天',
|
|
276
|
+
scenario_en: 'claim_verify: seller claims 30-day warranty, only honors 7 days',
|
|
277
|
+
facts_zh: [
|
|
278
|
+
'卖家上架商品标注:warranty 30 天',
|
|
279
|
+
'买家收到后产品在第 20 天损坏,联系卖家要保修',
|
|
280
|
+
'卖家拒绝:"我的店铺政策实际只 7 天"',
|
|
281
|
+
'买家提供:商品页 warranty 30 天截图',
|
|
282
|
+
'卖家解释:"标错了,实际是 7 天"— 但商品页未改',
|
|
283
|
+
],
|
|
284
|
+
facts_en: [
|
|
285
|
+
'Seller listed: warranty 30 days',
|
|
286
|
+
'Buyer\'s product broke on day 20, contacted seller for warranty',
|
|
287
|
+
'Seller refuses: "my store policy is actually 7 days"',
|
|
288
|
+
'Buyer provides: screenshot of 30-day warranty on product page',
|
|
289
|
+
'Seller explains: "labeled wrong, actually 7 days" — but product page not updated',
|
|
290
|
+
],
|
|
291
|
+
decision_options: [
|
|
292
|
+
{ key: 'pass', text_zh: 'pass(卖家解释合理)', text_en: 'pass (seller explanation reasonable)' },
|
|
293
|
+
{ key: 'fail', text_zh: 'fail(claim 不实,以商品页 30 天为准)', text_en: 'fail (claim false, product page 30 days is canonical)' },
|
|
294
|
+
{ key: 'no_fault', text_zh: 'no_fault(标错而非欺诈)', text_en: 'no_fault (labeling error, not fraud)' },
|
|
295
|
+
],
|
|
296
|
+
expected_verdict: 'fail',
|
|
297
|
+
key_principles: [
|
|
298
|
+
'商品页明示的 claim 是承诺,事后说"标错"不成立',
|
|
299
|
+
'META-RULES #4 不撒谎 — 公示=承诺,改了 ≠ 没承诺过',
|
|
300
|
+
'META-RULES #2 代码即规则 — 公开数据是 canonical truth',
|
|
301
|
+
'区别于 ver-2:此案 claim 与事实有实质 gap(30 vs 7),非 minor 偏差',
|
|
302
|
+
],
|
|
303
|
+
min_review_chars: 200,
|
|
304
|
+
},
|
|
305
|
+
];
|
|
306
|
+
// 给前端 GET:剥离 expected_verdict + key_principles(防泄答案,maintainer 视角才看)
|
|
307
|
+
export function getCasesForRole(role) {
|
|
308
|
+
return ONBOARDING_CASES
|
|
309
|
+
.filter(c => c.role_filter === role)
|
|
310
|
+
.map(({ expected_verdict: _expected_verdict, key_principles: _key_principles, ...rest }) => rest);
|
|
311
|
+
}
|
|
312
|
+
// 给 maintainer 看的完整数据(server 端用,validate review 时)
|
|
313
|
+
export function getCasesForMaintainer(role) {
|
|
314
|
+
return ONBOARDING_CASES.filter(c => c.role_filter === role);
|
|
315
|
+
}
|
|
316
|
+
export function validateCaseReviews(role, reviews) {
|
|
317
|
+
const cases = getCasesForMaintainer(role);
|
|
318
|
+
const requiredIds = new Set(cases.map(c => c.id));
|
|
319
|
+
const errors = [];
|
|
320
|
+
const submittedIds = new Set(reviews.map(r => r.case_id));
|
|
321
|
+
// 检查未提交的 case
|
|
322
|
+
for (const id of requiredIds) {
|
|
323
|
+
if (!submittedIds.has(id)) {
|
|
324
|
+
errors.push({ case_id: id, reason: '未提交 review' });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// 检查每个 review
|
|
328
|
+
for (const review of reviews) {
|
|
329
|
+
const c = cases.find(c => c.id === review.case_id);
|
|
330
|
+
if (!c) {
|
|
331
|
+
errors.push({ case_id: review.case_id, reason: '案例 id 不存在或不属本 role' });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
// chosen_verdict 必须是 decision_options 之一
|
|
335
|
+
if (!c.decision_options.some(o => o.key === review.chosen_verdict)) {
|
|
336
|
+
errors.push({ case_id: review.case_id, reason: `chosen_verdict='${review.chosen_verdict}' 不在选项内` });
|
|
337
|
+
}
|
|
338
|
+
// reasoning 长度
|
|
339
|
+
const trimmed = (review.reasoning || '').trim();
|
|
340
|
+
if (trimmed.length < c.min_review_chars) {
|
|
341
|
+
errors.push({ case_id: review.case_id, reason: `reasoning 需 ≥ ${c.min_review_chars} 字符,当前 ${trimmed.length}` });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { ok: errors.length === 0, errors };
|
|
345
|
+
}
|