Skip to content

Commit

Permalink
feat: support contest problem recommender (#98)
Browse files Browse the repository at this point in the history
  • Loading branch information
thezzisu authored Apr 20, 2024
1 parent 107ade0 commit f6bd7f3
Show file tree
Hide file tree
Showing 10 changed files with 2,153 additions and 2,228 deletions.
3 changes: 3 additions & 0 deletions .yarn/versions/a357fde8.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
releases:
"@aoi-js/frontend": patch
"@aoi-js/server": patch
2 changes: 1 addition & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"vue-i18n": "^9.10.1",
"vue-router": "^4.3.0",
"vue-toastification": "next",
"vue-tsc": "^2.0.4",
"vue-tsc": "^2.0.13",
"vuetify": "^3.5.7",
"webfontloader": "^1.6.28",
"xlsx": "^0.18.5",
Expand Down
105 changes: 105 additions & 0 deletions apps/frontend/src/components/contest/ContestProblemRecommender.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<template>
<VDialog width="auto" max-width="640" min-width="640">
<template v-slot:activator="{ props }">
<VBtn
color="info"
prepend-icon="mdi-tag-arrow-right-outline"
:text="t('action.recommend-problem')"
v-bind="props"
/>
</template>

<VCard :title="t('action.recommend-problem')">
<VCardText>
<VTextField v-model="search" :label="t('term.search')" clearable />
<VCombobox v-model="tags" :label="t('term.tags')" multiple chips />
<VBtn
block
variant="flat"
:text="t('action.recommend-problem')"
@click="problems.execute()"
/>
</VCardText>
<VDivider />
<VDataTable
:headers="headers"
:items="problems.state.value"
:loading="problems.isLoading.value"
item-value="_id"
>
<template v-slot:[`item.title`]="{ item }">
<div class="u-flex u-items-center u-gap-2">
<RouterLink :to="`/org/${orgId}/problem/${item._id}`" class="u-flex u-gap-2">
<div>
<div>
{{ item.title }}
</div>
<div class="u-text-xs text-secondary">
<code>{{ item.slug }}</code>
</div>
</div>
</RouterLink>
<ProblemStatus :org-id="orgId" :problem-id="item._id" :status="item.status" />
</div>
</template>
<template v-slot:[`item.accessLevel`]="{ item }">
<AccessLevelBadge variant="chip" :access-level="item.accessLevel" inline />
</template>
<template v-slot:[`item.tags`]="{ item }">
<ProblemTagGroup :tags="item.tags" :url-prefix="`/org/${orgId}/problem/tag`" />
</template>
<template v-slot:[`item.actions`]="{ item }">
<VBtn variant="tonal" @click="emit('update', item._id)" :text="t('action.select')" />
</template>
</VDataTable>
</VCard>
</VDialog>
</template>

<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ProblemStatus from '../problem/ProblemStatus.vue'
import ProblemTagGroup from '../problem/ProblemTagGroup.vue'
import type { IProblemDTO } from '../problem/types'
import AccessLevelBadge from '../utils/AccessLevelBadge.vue'
import { http } from '@/utils/http'
const props = defineProps<{
orgId: string
}>()
const emit = defineEmits<{
(ev: 'update', value: string): void
}>()
const { t } = useI18n()
const search = ref('')
const tags = ref<string[]>([])
const headers = [
{ title: t('term.name'), key: 'title', sortable: false },
{ title: t('term.tags'), key: 'tags', sortable: false },
{ title: t('term.access-level'), key: 'accessLevel', align: 'end', sortable: false },
{ title: t('term.actions'), key: 'actions', align: 'end', sortable: false }
] as const
const problems = useAsyncState(
async () =>
http
.post('problem/recommend', {
json: {
orgId: props.orgId,
search: search.value || undefined,
tags: tags.value.length ? tags.value : undefined,
sample: 10
}
})
.json<IProblemDTO[]>(),
[],
{ immediate: false, resetOnExecute: false }
)
</script>
2 changes: 2 additions & 0 deletions apps/frontend/src/locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ action:
select-tag: Select Tag
dismiss: Dismiss
fullscreen: Fullscreen
recommend-problem: Recommend Problem
select: Select

term:
content: Content
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/locales/zh-Hans.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ action:
select-tag: 选择标签
dismiss: 忽略并关闭
fullscreen: 全屏
recommend-problem: 推荐题目
select: 选择

term:
content: 内容
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<VBtn color="primary" variant="elevated" @click="addProblem()">
{{ t('action.add') }}
</VBtn>
<ContestProblemRecommender v-if="problemAdmin" :org-id @update="payload.problemId = $event" />
</VCardActions>
</VCard>
</template>
Expand All @@ -18,9 +19,11 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import ContestProblemRecommender from '@/components/contest/ContestProblemRecommender.vue'
import ContestProblemSettingsInput from '@/components/contest/ContestProblemSettingsInput.vue'
import type { IContestDTO, IContestProblemListDTO } from '@/components/contest/types'
import IdInput from '@/components/utils/IdInput.vue'
import { useOrgCapability } from '@/utils/capability'
import { http } from '@/utils/http'
const props = defineProps<{
Expand All @@ -36,6 +39,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const router = useRouter()
const toast = useToast()
const problemAdmin = useOrgCapability('problem')
const payload = reactive({
problemId: '',
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/routes/contest/problem/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export function loadProblemSettings(req: FastifyRequest) {
const problemId = tryLoadUUID(req.params, 'problemId')
if (!problemId) return [null, undefined] as const
const ctx = req.inject(kContestContext)
const settings = ctx._contest.problems.find((problem) => problemId.equals(problem.problemId))
?.settings
const settings = ctx._contest.problems.find((problem) =>
problemId.equals(problem.problemId)
)?.settings
return [problemId, settings] as const
}
84 changes: 84 additions & 0 deletions apps/server/src/routes/problem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,88 @@ export const problemRoutes = defineRoutes(async (s) => {
return { total, items }
}
)

s.post(
'/recommend',
{
schema: {
description: 'Recommend problems',
body: T.Object({
orgId: T.UUID(),
search: T.Optional(T.String({ minLength: 1 })),
tags: T.Optional(T.Array(T.String())),
sample: T.Integer({ minimum: 1, maximum: 15 })
}),
response: {
200: T.Array(
T.Object({
_id: T.UUID(),
orgId: T.UUID(),
slug: T.String(),
title: T.String(),
tags: T.Array(T.String()),
accessLevel: T.AccessLevel(),
createdAt: T.Integer(),
status: T.Optional(
T.Object({
solutionCount: T.Integer(),
lastSolutionId: T.UUID(),
lastSolutionScore: T.Number(),
lastSolutionStatus: T.String()
})
)
})
)
}
}
},
async (req) => {
const { orgId: rawOrgId, sample, ...rest } = req.body
const orgId = new UUID(rawOrgId)

const membership = await req.loadMembership(orgId)
ensureCapability(
membership?.capability ?? CAP_NONE,
ORG_CAPS.CAP_PROBLEM,
s.httpErrors.forbidden()
)

const items = await problems
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.aggregate<any>(
[
{
$match: {
$and: [{ orgId }, searchToFilter(rest, { maxConditions: Infinity })]
}
},
{ $sample: { size: sample } },
{
$lookup: {
from: 'problemStatuses',
let: { problemId: '$_id' },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ['$problemId', '$$problemId'] },
{ $eq: ['$userId', req.user.userId] }
]
}
}
}
],
as: 'status'
}
},
{ $unwind: { path: '$status', preserveNullAndEmptyArrays: true } }
],
{ ignoreUndefined: true }
)
.toArray()

return items
}
)
})
14 changes: 12 additions & 2 deletions apps/server/src/utils/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ export function escapeSearch(search: string) {
return search.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
}

export function searchToFilter(query: { search?: string; tag?: string }): Document | null {
if (Object.keys(query).length > 1) return null
export function searchToFilter(
query: {
search?: string
tag?: string
tags?: string[]
},
{ maxConditions } = { maxConditions: 1 }
): Document | null {
if (Object.keys(query).length > maxConditions) return null
const filter: Document = {}
if (query.search) {
const escapedRegex = escapeSearch(query.search)
Expand All @@ -15,6 +22,9 @@ export function searchToFilter(query: { search?: string; tag?: string }): Docume
if (query.tag) {
filter.tags = query.tag
}
if (query.tags) {
filter.tags = { $all: query.tags }
}
return filter
}

Expand Down
Loading

0 comments on commit f6bd7f3

Please sign in to comment.