From 351d8a033779ab79a474ff302b7740a448d96b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=B0=AC=EC=98=81?= Date: Thu, 4 Dec 2025 18:42:09 +0900 Subject: [PATCH 1/6] =?UTF-8?q?ai-moderation=20dependencies=20=EC=84=A4?= =?UTF-8?q?=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + yarn.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 06bc7a3..0eea613 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-slot": "^1.1.2", "@tailwindcss/vite": "^4.0.0", "@tanstack/react-query": "^5.64.2", + "ai-moderation": "/Users/jeongchan-yeong/Desktop/project-ai-moderation/ai-moderation", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "motion": "^12.16.0", diff --git a/yarn.lock b/yarn.lock index 7b578da..a1cc14a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5005,4 +5005,4 @@ yocto-queue@^0.1.0: yoctocolors-cjs@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz#f4b905a840a37506813a7acaa28febe97767a242" - integrity sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA== + integrity sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA== \ No newline at end of file From bdddb528b7e6fed63bbd158a07d6ea4c409338a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=B0=AC=EC=98=81?= Date: Tue, 9 Dec 2025 20:25:56 +0900 Subject: [PATCH 2/6] =?UTF-8?q?AI=20=EC=9A=95=EC=84=A4=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/moderation.ts | 64 ++++++++++++++ .../poll/edit/PollEditButton/hooks.ts | 13 ++- .../PollRegistButton/PollRegistButton.tsx | 19 ++-- src/hooks/useModerationCheck.tsx | 88 +++++++++++++++++++ 4 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 src/api/moderation.ts create mode 100644 src/hooks/useModerationCheck.tsx diff --git a/src/api/moderation.ts b/src/api/moderation.ts new file mode 100644 index 0000000..bba9414 --- /dev/null +++ b/src/api/moderation.ts @@ -0,0 +1,64 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query'; +import axios from 'axios'; + +export type ModerationResult = { + isAllowed: boolean; + category: + | 'ok' + | 'profanity' + | 'hate' + | 'sexual' + | 'violence' + | 'self_harm' + | 'etc'; + detectedWords?: string[]; +}; + +const MODERATION_API_URL = import.meta.env.VITE_MODERATION_API_URL; + +export async function requestModeration( + content: string, +): Promise { + if (!MODERATION_API_URL) { + throw new Error( + 'VITE_MODERATION_API_URL 환경 변수가 설정되지 않았습니다. .env 파일에 VITE_MODERATION_API_URL을 추가해주세요.', + ); + } + + const { data: result } = await axios.post( + MODERATION_API_URL, + { content }, + { + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: false, + }, + ); + + // detectedWords가 배열이 아니면 배열로 변환 + let detectedWords = result.detectedWords; + if (!Array.isArray(detectedWords)) { + if (detectedWords && typeof detectedWords === 'string') { + detectedWords = [detectedWords]; + } else { + detectedWords = []; + } + } + + // detectedWords가 없으면 빈 배열로 설정 + return { + ...result, + detectedWords: detectedWords || [], + }; +} + +// React Query 훅 +export function useModerateText( + options?: UseMutationOptions, +) { + return useMutation({ + mutationFn: (text: string) => requestModeration(text), + ...options, + }); +} diff --git a/src/components/poll/edit/PollEditButton/hooks.ts b/src/components/poll/edit/PollEditButton/hooks.ts index a15c760..d112342 100644 --- a/src/components/poll/edit/PollEditButton/hooks.ts +++ b/src/components/poll/edit/PollEditButton/hooks.ts @@ -1,13 +1,16 @@ import { useNavigate, useParams } from 'react-router-dom'; import useUpdatePoll from '@/api/useUpdatePoll'; import usePollForm from '@/components/poll/Provider/hooks'; +import { useModerationCheck } from '@/hooks/useModerationCheck'; export default function usePollEditButton() { const navigate = useNavigate(); const { isValid, data } = usePollForm(); const { pollId } = useParams<{ pollId: string }>(); + const { mutate: checkModeration, isPending: isModerationPending } = + useModerationCheck(); - const { mutate: updatePoll, isPending } = useUpdatePoll({ + const { mutate: updatePoll, isPending: isUpdatePending } = useUpdatePoll({ id: Number(pollId), options: { onSuccess: () => { @@ -16,8 +19,14 @@ export default function usePollEditButton() { }, }); + const isPending = isModerationPending || isUpdatePending; + const handleClickPollEditButton = () => { - updatePoll(data); + if (!isValid) return; + checkModeration({ + pollData: data, + onConfirm: () => updatePoll(data), + }); }; return { diff --git a/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx b/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx index 401cbc9..c201af9 100644 --- a/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx +++ b/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx @@ -8,6 +8,7 @@ import PollCreatedShareBottomSheet from '@/components/common/PollCreatedShareBot import useGetMyInfo from '@/api/useGetMyInfo'; import usePostRegistVote from '@/api/usePostRegistVote'; import useUpdateOnboarding from '@/api/useUpdateOnboarding'; +import { useModerationCheck } from '@/hooks/useModerationCheck'; export default function PollRegistButton() { const navigate = useNavigate(); @@ -32,15 +33,21 @@ export default function PollRegistButton() { openBottomSheet(); }, }); + const { mutate: checkModeration } = useModerationCheck(); const handleClickSubmitButton = () => { if (isValid) { - registVote({ - ...pollData, - pollChoices: pollData.pollChoices.map((choice) => ({ - title: choice.title, - imageUrl: choice.imageUrl, - })), + checkModeration({ + pollData, + onConfirm: () => { + registVote({ + ...pollData, + pollChoices: pollData.pollChoices.map((choice) => ({ + title: choice.title, + imageUrl: choice.imageUrl, + })), + }); + }, }); } }; diff --git a/src/hooks/useModerationCheck.tsx b/src/hooks/useModerationCheck.tsx new file mode 100644 index 0000000..21fbea0 --- /dev/null +++ b/src/hooks/useModerationCheck.tsx @@ -0,0 +1,88 @@ +import { useMutation } from '@tanstack/react-query'; +import { requestModeration } from '@/api/moderation'; +import Dialog from '@/components/common/Dialog/Dialog'; +import { useDialog } from '@/components/common/Dialog/hooks'; +import { PollFormData } from '@/components/poll/Provider/types'; + +interface ModerationCheckParams { + pollData: PollFormData; + onConfirm: () => void; +} + +export function useModerationCheck() { + const { openDialog, closeDialog } = useDialog(); + + return useMutation({ + mutationFn: async ({ pollData }: ModerationCheckParams) => { + // 제목, 내용, 이미지 이름을 모두 검사 + const textsToCheck = [ + pollData.title, + pollData.description, + ...pollData.pollChoices.map((choice) => choice.title), + ].filter(Boolean); + + // 모든 텍스트를 검사하고 결과 수집 (에러가 발생해도 계속 진행) + const moderationSettledResults = await Promise.allSettled( + textsToCheck.map((text) => requestModeration(text)), + ); + + // 성공한 결과만 필터링 + const moderationResults = moderationSettledResults + .filter((result) => result.status === 'fulfilled') + .map( + (result) => + ( + result as PromiseFulfilledResult< + Awaited> + > + ).value, + ); + + // 욕설이 감지된 경우 찾기 + const detectedResults = moderationResults.filter( + (result) => !result.isAllowed, + ); + + // 감지된 결과에서만 detectedWords 수집 + const allDetectedWords = detectedResults + .flatMap((result) => { + return result.detectedWords || []; + }) + .filter((word) => word && word.trim().length > 0) // 빈 문자열 제거 + .filter((word, index, self) => self.indexOf(word) === index); // 중복 제거 + + return { + hasDetectedWords: detectedResults.length > 0, + detectedWords: allDetectedWords, + }; + }, + onSuccess: (result, variables) => { + if (result.hasDetectedWords) { + const inlineMessage = + result.detectedWords.length > 0 + ? `비속어: ${result.detectedWords.map((word) => `"${word}"`).join(', ')}` + : ''; + + openDialog( + { + closeDialog(); + variables.onConfirm(); + }, + }} + showLaterButton={false} + inlineMessage={inlineMessage} + />, + ); + } else { + variables.onConfirm(); + } + }, + }); +} From f5e2fda778c6697674aeb3e21f0b0123649fef25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=B0=AC=EC=98=81?= Date: Tue, 9 Dec 2025 23:04:51 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=EC=97=90=EC=84=9C=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EB=8D=98=20=ED=98=95=EC=8B=9D=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=84=A4=EC=B9=98=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- yarn.lock | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0eea613..be1b481 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "@radix-ui/react-slot": "^1.1.2", "@tailwindcss/vite": "^4.0.0", "@tanstack/react-query": "^5.64.2", - "ai-moderation": "/Users/jeongchan-yeong/Desktop/project-ai-moderation/ai-moderation", "axios": "^1.7.9", + "chooz-ai-moderation": "^0.1.0", "class-variance-authority": "^0.7.1", "motion": "^12.16.0", "react": "^18.3.1", diff --git a/yarn.lock b/yarn.lock index a1cc14a..20a2a72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1849,6 +1849,13 @@ chooz-ai-code-review-cli@^0.1.0: "@octokit/rest" "^20.0.0" openai "^4.0.0" +chooz-ai-moderation@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/chooz-ai-moderation/-/chooz-ai-moderation-0.1.0.tgz#9461ce239cc983cabbcce4c7df4b3675c61fbc6d" + integrity sha512-B/DpJcJf+UNHeMTZqIvXUmuZK+d1od0L33F2DMe7dG6RQVt84lz7GYk3JrbQEqVJoF/9HtmtxFJRhUv/C6JCtQ== + dependencies: + openai "^6.9.1" + class-variance-authority@^0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz#4008a798a0e4553a781a57ac5177c9fb5d043787" @@ -3682,6 +3689,11 @@ openai@^4.0.0: formdata-node "^4.3.2" node-fetch "^2.6.7" +openai@^6.9.1: + version "6.10.0" + resolved "https://registry.yarnpkg.com/openai/-/openai-6.10.0.tgz#3f52d2ad7b6b2288124d064b0eb737c914d1f3ea" + integrity sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A== + optionator@^0.9.3: version "0.9.4" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" @@ -5005,4 +5017,4 @@ yocto-queue@^0.1.0: yoctocolors-cjs@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz#f4b905a840a37506813a7acaa28febe97767a242" - integrity sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA== \ No newline at end of file + integrity sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA== From b707e7e2a6855f268fa7bb096af7f3c5cd6c37b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=B0=AC=EC=98=81?= Date: Tue, 9 Dec 2025 23:46:10 +0900 Subject: [PATCH 4/6] =?UTF-8?q?ai-moderation=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=20=EC=84=A4=EC=B9=98=20=EB=B0=A9=ED=96=A5=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=ED=9B=84=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/moderation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/moderation.ts b/src/api/moderation.ts index bba9414..139833b 100644 --- a/src/api/moderation.ts +++ b/src/api/moderation.ts @@ -21,7 +21,7 @@ export async function requestModeration( ): Promise { if (!MODERATION_API_URL) { throw new Error( - 'VITE_MODERATION_API_URL 환경 변수가 설정되지 않았습니다. .env 파일에 VITE_MODERATION_API_URL을 추가해주세요.', + 'VITE_MODERATION_API_URL 환경 변수가 설정되지 않았습니다. .env 파일에 VITE_MODERATION_API_URL을 추가해주세요..', ); } From 99149a0394d92929f0fa3d6544c0335e3a8cb4ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=B0=AC=EC=98=81?= Date: Wed, 31 Dec 2025 09:01:23 +0900 Subject: [PATCH 5/6] =?UTF-8?q?lint=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../poll/regist/PollRegistButton/PollRegistButton.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx b/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx index c201af9..d59f11f 100644 --- a/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx +++ b/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx @@ -1,14 +1,14 @@ import ReactGA from 'react-ga4'; import { useNavigate } from 'react-router-dom'; -import usePollForm from '../../Provider/hooks'; +import useGetMyInfo from '@/api/useGetMyInfo'; +import usePostRegistVote from '@/api/usePostRegistVote'; +import useUpdateOnboarding from '@/api/useUpdateOnboarding'; import { useBottomSheet } from '@/components/common/BottomSheet/hooks'; import { Button } from '@/components/common/Button/Button'; import Loading from '@/components/common/Loading'; import PollCreatedShareBottomSheet from '@/components/common/PollCreatedShareBottomSheet'; -import useGetMyInfo from '@/api/useGetMyInfo'; -import usePostRegistVote from '@/api/usePostRegistVote'; -import useUpdateOnboarding from '@/api/useUpdateOnboarding'; import { useModerationCheck } from '@/hooks/useModerationCheck'; +import usePollForm from '../../Provider/hooks'; export default function PollRegistButton() { const navigate = useNavigate(); From ba975b6e3e06e24ba78774a48331b7c7f82aacb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=B0=AC=EC=98=81?= Date: Wed, 31 Dec 2025 09:03:21 +0900 Subject: [PATCH 6/6] =?UTF-8?q?lint=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../poll/regist/PollRegistButton/PollRegistButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx b/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx index d59f11f..74546a2 100644 --- a/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx +++ b/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx @@ -1,5 +1,6 @@ import ReactGA from 'react-ga4'; import { useNavigate } from 'react-router-dom'; +import usePollForm from '../../Provider/hooks'; import useGetMyInfo from '@/api/useGetMyInfo'; import usePostRegistVote from '@/api/usePostRegistVote'; import useUpdateOnboarding from '@/api/useUpdateOnboarding'; @@ -8,7 +9,6 @@ import { Button } from '@/components/common/Button/Button'; import Loading from '@/components/common/Loading'; import PollCreatedShareBottomSheet from '@/components/common/PollCreatedShareBottomSheet'; import { useModerationCheck } from '@/hooks/useModerationCheck'; -import usePollForm from '../../Provider/hooks'; export default function PollRegistButton() { const navigate = useNavigate();