Skip to content

Commit a4b850e

Browse files
committed
feat: improve moderation/admin UX + language-aware quality gate
- API: owner-visible responses for hidden/soft-deleted skills\n- Admin: add unban user mutations + docs\n- Quality: Intl.Segmenter tokenization + CJK signal to reduce false rejects\n- Jobs: skill-stat-events interval 15m -> 5m\n- Tests: add coverage for owner-visible states + non-Latin docs\n- Changelog: add Unreleased entry
1 parent 7e0b21f commit a4b850e

File tree

12 files changed

+250
-7
lines changed

12 files changed

+250
-7
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@
22

33
## Unreleased
44

5+
### Added
6+
- Admin: add manual unban for banned users (clears `deletedAt` + `banReason`, audit log entry). Revoked API tokens stay revoked.
7+
8+
### Changed
9+
- Quality gate: language-aware word counting (`Intl.Segmenter`) and new `cjkChars` signal to reduce false rejects for non-Latin docs.
10+
- Jobs: run skill stat event processing every 5 minutes (was 15).
11+
512
### Fixed
613
- Users: sync handle on ensure when GitHub login changes (#293) (thanks @christianhpoe).
14+
- API: for owners, return clearer status/messages for hidden/soft-deleted skills instead of a generic 404.
715

816
## 0.6.1 - 2026-02-13
917

convex/crons.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ crons.interval(
2626

2727
crons.interval(
2828
'skill-stat-events',
29-
{ minutes: 15 },
29+
{ minutes: 5 },
3030
internal.skillStatEvents.processSkillStatEventsAction,
3131
{},
3232
)

convex/httpApiV1.handlers.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
33

44
vi.mock('./lib/apiTokenAuth', () => ({
55
requireApiTokenUser: vi.fn(),
6+
getOptionalApiTokenUserId: vi.fn(),
67
}))
78

89
vi.mock('./skills', () => ({
910
publishVersionForUser: vi.fn(),
1011
}))
1112

12-
const { requireApiTokenUser } = await import('./lib/apiTokenAuth')
13+
const { getOptionalApiTokenUserId, requireApiTokenUser } = await import('./lib/apiTokenAuth')
1314
const { publishVersionForUser } = await import('./skills')
1415
const { __handlers } = await import('./httpApiV1')
1516

@@ -59,6 +60,8 @@ const blockedRate = () => ({
5960
})
6061

6162
beforeEach(() => {
63+
vi.mocked(getOptionalApiTokenUserId).mockReset()
64+
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue(null)
6265
vi.mocked(requireApiTokenUser).mockReset()
6366
vi.mocked(publishVersionForUser).mockReset()
6467
})
@@ -218,6 +221,52 @@ describe('httpApiV1 handlers', () => {
218221
expect(response.status).toBe(404)
219222
})
220223

224+
it('get skill returns pending-scan message for owner api token', async () => {
225+
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue('users:1' as never)
226+
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
227+
if ('slug' in args) {
228+
return {
229+
_id: 'skills:1',
230+
slug: 'demo',
231+
ownerUserId: 'users:1',
232+
moderationStatus: 'hidden',
233+
moderationReason: 'pending.scan',
234+
}
235+
}
236+
return null
237+
})
238+
const runMutation = vi.fn().mockResolvedValue(okRate())
239+
const response = await __handlers.skillsGetRouterV1Handler(
240+
makeCtx({ runQuery, runMutation }),
241+
new Request('https://example.com/api/v1/skills/demo'),
242+
)
243+
expect(response.status).toBe(423)
244+
expect(await response.text()).toContain('security scan is pending')
245+
})
246+
247+
it('get skill returns undelete hint for owner soft-deleted skill', async () => {
248+
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue('users:1' as never)
249+
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
250+
if ('slug' in args) {
251+
return {
252+
_id: 'skills:1',
253+
slug: 'demo',
254+
ownerUserId: 'users:1',
255+
softDeletedAt: 1,
256+
moderationStatus: 'hidden',
257+
}
258+
}
259+
return null
260+
})
261+
const runMutation = vi.fn().mockResolvedValue(okRate())
262+
const response = await __handlers.skillsGetRouterV1Handler(
263+
makeCtx({ runQuery, runMutation }),
264+
new Request('https://example.com/api/v1/skills/demo'),
265+
)
266+
expect(response.status).toBe(410)
267+
expect(await response.text()).toContain('clawhub undelete demo')
268+
})
269+
221270
it('get skill returns payload', async () => {
222271
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
223272
if ('slug' in args) {

convex/httpApiV1.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { api, internal } from './_generated/api'
33
import type { Doc, Id } from './_generated/dataModel'
44
import type { ActionCtx } from './_generated/server'
55
import { httpAction } from './_generated/server'
6-
import { requireApiTokenUser } from './lib/apiTokenAuth'
6+
import { getOptionalApiTokenUserId, requireApiTokenUser } from './lib/apiTokenAuth'
77
import { hashToken } from './lib/tokens'
88
import { publishVersionForUser } from './skills'
99
import { publishSoulVersionForUser } from './souls'
@@ -251,7 +251,11 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
251251

252252
if (segments.length === 1) {
253253
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
254-
if (!result?.skill) return text('Skill not found', 404, rate.headers)
254+
if (!result?.skill) {
255+
const hidden = await describeOwnerVisibleSkillState(ctx, request, slug)
256+
if (hidden) return text(hidden.message, hidden.status, rate.headers)
257+
return text('Skill not found', 404, rate.headers)
258+
}
255259

256260
const tags = await resolveTags(ctx, result.skill.tags)
257261
return json(
@@ -415,6 +419,52 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
415419
return text('Not found', 404, rate.headers)
416420
}
417421

422+
async function describeOwnerVisibleSkillState(
423+
ctx: ActionCtx,
424+
request: Request,
425+
slug: string,
426+
): Promise<{ status: number; message: string } | null> {
427+
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
428+
if (!skill) return null
429+
430+
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request)
431+
const isOwner = Boolean(apiTokenUserId && apiTokenUserId === skill.ownerUserId)
432+
if (!isOwner) return null
433+
434+
if (skill.softDeletedAt) {
435+
return {
436+
status: 410,
437+
message: `Skill is hidden/deleted. Run "clawhub undelete ${slug}" to restore it.`,
438+
}
439+
}
440+
441+
if (skill.moderationStatus === 'hidden') {
442+
if (skill.moderationReason === 'pending.scan' || skill.moderationReason === 'scanner.vt.pending') {
443+
return {
444+
status: 423,
445+
message: 'Skill is hidden while security scan is pending. Try again in a few minutes.',
446+
}
447+
}
448+
if (skill.moderationReason === 'quality.low') {
449+
return {
450+
status: 403,
451+
message:
452+
'Skill is hidden by quality checks. Update SKILL.md content or run "clawhub undelete <slug>" after review.',
453+
}
454+
}
455+
return {
456+
status: 403,
457+
message: `Skill is hidden by moderation${skill.moderationReason ? ` (${skill.moderationReason})` : ''}.`,
458+
}
459+
}
460+
461+
if (skill.moderationStatus === 'removed') {
462+
return { status: 410, message: 'Skill has been removed by moderation.' }
463+
}
464+
465+
return null
466+
}
467+
418468
export const skillsGetRouterV1Http = httpAction(skillsGetRouterV1Handler)
419469

420470
async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {

convex/lib/skillPublish.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,29 @@ description: Expert guidance for sushi-rolls.
7777
expect(quality.decision).toBe('reject')
7878
expect(quality.reason).toContain('template spam')
7979
})
80+
81+
it('does not undercount non-latin skill docs', () => {
82+
const signals = __test.computeQualitySignals({
83+
readmeText: `# 飞书图片助手
84+
## 核心能力
85+
- 上传本地图片到飞书并自动返回 image_key,避免重复上传浪费配额。
86+
- 支持群聊与私聊,自动识别目标类型并校验参数,减少调用错误。
87+
- 提供重试与错误分类,方便排查网络问题、权限问题与资源限制。
88+
## 使用说明
89+
先配置应用凭证,然后传入目标会话与文件路径。技能会先检查缓存,再执行上传,并在发送阶段附带日志说明,便于团队追踪。
90+
如果出现失败,输出会包含建议动作,例如补齐权限、检查文件大小、确认机器人是否在群内,以及如何重放请求。
91+
还会记录每一步耗时、返回码与上下文摘要,方便后续做性能分析、告警聚合和批量回放,避免同类问题反复出现。
92+
`,
93+
summary: '上传并发送图片到飞书,支持缓存、重试和错误诊断。',
94+
})
95+
96+
const quality = __test.evaluateQuality({
97+
signals,
98+
trustTier: 'low',
99+
similarRecentCount: 0,
100+
})
101+
102+
expect(signals.bodyWords).toBeGreaterThanOrEqual(45)
103+
expect(quality.decision).toBe('pass')
104+
})
80105
})

convex/lib/skillQuality.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type QualitySignals = {
2323
bulletCount: number
2424
templateMarkerHits: number
2525
genericSummary: boolean
26+
cjkChars: number
2627
structuralFingerprint: string
2728
}
2829

@@ -40,6 +41,29 @@ function stripFrontmatter(raw: string) {
4041
}
4142

4243
function tokenizeWords(text: string) {
44+
const segmenterCtor = (Intl as typeof Intl & {
45+
Segmenter?: new (
46+
locale?: string | string[],
47+
options?: { granularity?: 'grapheme' | 'word' | 'sentence' },
48+
) => {
49+
segment: (
50+
input: string,
51+
) => Iterable<{ segment: string; isWordLike?: boolean }>
52+
}
53+
}).Segmenter
54+
55+
if (segmenterCtor) {
56+
const segmenter = new segmenterCtor(undefined, { granularity: 'word' })
57+
const tokens: string[] = []
58+
for (const entry of segmenter.segment(text)) {
59+
if (!entry.isWordLike) continue
60+
const token = entry.segment.trim().toLowerCase()
61+
if (!token) continue
62+
tokens.push(token)
63+
}
64+
if (tokens.length > 0) return tokens
65+
}
66+
4367
return (text.toLowerCase().match(/[a-z0-9][a-z0-9'-]*/g) ?? []).filter((word) => word.length > 1)
4468
}
4569

@@ -95,6 +119,7 @@ export function computeQualitySignals(args: {
95119
const templateMarkerHits = TEMPLATE_MARKERS.filter((marker) => bodyLower.includes(marker)).length
96120
const summary = (args.summary ?? '').trim().toLowerCase()
97121
const genericSummary = /^expert guidance for [a-z0-9-]+\.?$/.test(summary)
122+
const cjkChars = (body.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu) ?? []).length
98123

99124
return {
100125
bodyChars,
@@ -104,6 +129,7 @@ export function computeQualitySignals(args: {
104129
bulletCount,
105130
templateMarkerHits,
106131
genericSummary,
132+
cjkChars,
107133
structuralFingerprint: toStructuralFingerprint(args.readmeText),
108134
}
109135
}
@@ -127,8 +153,14 @@ export function evaluateQuality(args: {
127153
}): QualityAssessment {
128154
const { signals, trustTier, similarRecentCount } = args
129155
const score = scoreQuality(signals)
130-
const rejectWordsThreshold = trustTier === 'low' ? 45 : trustTier === 'medium' ? 35 : 28
131-
const rejectCharsThreshold = trustTier === 'low' ? 260 : trustTier === 'medium' ? 180 : 140
156+
const cjkHeavy =
157+
signals.cjkChars >= 40 || (signals.bodyChars > 0 && signals.cjkChars / signals.bodyChars >= 0.15)
158+
let rejectWordsThreshold = trustTier === 'low' ? 45 : trustTier === 'medium' ? 35 : 28
159+
let rejectCharsThreshold = trustTier === 'low' ? 260 : trustTier === 'medium' ? 180 : 140
160+
if (cjkHeavy) {
161+
rejectWordsThreshold = Math.max(24, rejectWordsThreshold - 16)
162+
rejectCharsThreshold = Math.max(140, rejectCharsThreshold - 120)
163+
}
132164
const quarantineScoreThreshold = trustTier === 'low' ? 72 : trustTier === 'medium' ? 60 : 50
133165
const similarityRejectThreshold = trustTier === 'low' ? 5 : trustTier === 'medium' ? 8 : 12
134166

@@ -157,6 +189,7 @@ export function evaluateQuality(args: {
157189
bulletCount: signals.bulletCount,
158190
templateMarkerHits: signals.templateMarkerHits,
159191
genericSummary: signals.genericSummary,
192+
cjkChars: signals.cjkChars,
160193
},
161194
}
162195
}
@@ -176,6 +209,7 @@ export function evaluateQuality(args: {
176209
bulletCount: signals.bulletCount,
177210
templateMarkerHits: signals.templateMarkerHits,
178211
genericSummary: signals.genericSummary,
212+
cjkChars: signals.cjkChars,
179213
},
180214
}
181215
}
@@ -194,6 +228,7 @@ export function evaluateQuality(args: {
194228
bulletCount: signals.bulletCount,
195229
templateMarkerHits: signals.templateMarkerHits,
196230
genericSummary: signals.genericSummary,
231+
cjkChars: signals.cjkChars,
197232
},
198233
}
199234
}

convex/maintenance.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,7 @@ export const applyEmptySkillCleanupInternal = internalMutation({
999999
bulletCount: v.number(),
10001000
templateMarkerHits: v.number(),
10011001
genericSummary: v.boolean(),
1002+
cjkChars: v.optional(v.number()),
10021003
}),
10031004
}),
10041005
},

convex/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ const skills = defineTable({
9696
bulletCount: v.number(),
9797
templateMarkerHits: v.number(),
9898
genericSummary: v.boolean(),
99+
cjkChars: v.optional(v.number()),
99100
}),
100101
evaluatedAt: v.number(),
101102
}),

convex/skills.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3002,6 +3002,7 @@ export const insertVersion = internalMutation({
30023002
bulletCount: v.number(),
30033003
templateMarkerHits: v.number(),
30043004
genericSummary: v.boolean(),
3005+
cjkChars: v.optional(v.number()),
30053006
}),
30063007
}),
30073008
),

0 commit comments

Comments
 (0)