Skip to content

Commit

Permalink
feat: add classify survey feature
Browse files Browse the repository at this point in the history
  • Loading branch information
moonrailgun committed Feb 8, 2025
1 parent 9068935 commit 4c37eab
Show file tree
Hide file tree
Showing 12 changed files with 425 additions and 22 deletions.
182 changes: 182 additions & 0 deletions src/client/components/survey/SurveyAIBtn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { DateRange } from 'react-day-picker';
import { useCurrentWorkspaceId } from '@/store/user';
import { useTranslation } from '@i18next-toolkit/react';
import React, { useState } from 'react';
import dayjs from 'dayjs';
import { trpc } from '@/api/trpc';
import { useEventWithLoading } from '@/hooks/useEvent';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog';
import { Button } from '../ui/button';
import { LuBot } from 'react-icons/lu';
import { DatePicker } from '../DatePicker';
import { Select as AntdSelect } from 'antd';
import { toast } from 'sonner';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Checkbox } from '../ui/checkbox';
import { useWatch } from '@/hooks/useWatch';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';

interface SurveyAIBtnProps {
surveyId: string;
}
export const SurveyAIBtn: React.FC<SurveyAIBtnProps> = React.memo((props) => {
const { surveyId } = props;
const workspaceId = useCurrentWorkspaceId();
const [date, setDate] = useState<DateRange | undefined>({
from: dayjs().subtract(1, 'week').toDate(),
to: dayjs().toDate(),
});
const { t } = useTranslation();
const [category, setCategory] = useState<string[]>([]);
const [resultText, setResultText] = useState<string[]>([]);
const [contentField, setContentField] = useState<string>();
const [skipExised, setSkipExised] = useState(true);

const { data: info } = trpc.survey.get.useQuery({
workspaceId,
surveyId,
});
const { data: aiCategoryList } = trpc.survey.aiCategoryList.useQuery({
workspaceId,
surveyId,
});

useWatch([aiCategoryList], () => {
if (aiCategoryList) {
setCategory(
aiCategoryList.filter((item) => item.name !== null).map((c) => c.name!)
);
}
});

const classifySurveyMutation = trpc.ai.classifySurvey.useMutation();

const [handleStart, loading] = useEventWithLoading(async () => {
const startAt = date?.from?.valueOf();
const endAt = date?.to?.valueOf();

if (!contentField) {
toast(t('Content Field required'));
return;
}

if (!startAt || !endAt) {
toast(t('Date range is required'));
return;
}

try {
const { analysisCount, processedCount, categorys, effectCount } =
await classifySurveyMutation.mutateAsync({
workspaceId,
surveyId,
startAt,
endAt,
skipExised,
payloadContentField: contentField,
suggestionCategory: category,
});

setCategory(categorys);

setResultText([
t('Analysis Count: {{num}}', { num: analysisCount }),
t('Processed Count: {{num}}', { num: processedCount }),
t('Category count: {{num}}', { num: categorys.length }),
t('Effect Count: {{num}}', { num: effectCount }),
]);
} catch (err) {
toast(String(err));
}
});

return (
<Dialog modal={false}>
<DialogTrigger asChild>
<Button Icon={LuBot} size="icon" variant="outline" />
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t('AI Summary')}</DialogTitle>
<DialogDescription>{t('Summary Content with AI')}</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-3">
<div className="opacity-50">
{t('Step 1: Please select content field')}
</div>

<Select value={contentField} onValueChange={setContentField}>
<SelectTrigger>
<SelectValue placeholder="Please Select Content Field" />
</SelectTrigger>
<SelectContent>
{info?.payload.items.map((item) => (
<SelectItem key={item.name} value={item.name}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>

<div className="opacity-50">
{t('Step 2: Please select analysis range')}
</div>
<DatePicker value={date} onChange={setDate} />

<div className="opacity-50">
{t('Step 3: Please provide some suggestion category')}
</div>
<AntdSelect
mode="tags"
className="w-full"
placeholder="Input some category"
value={category}
onChange={setCategory}
maxTagCount={2}
/>

<div className="opacity-50">{t('Step 4: Run!')}</div>
<div className="flex items-center space-x-2">
<Checkbox
checked={skipExised}
onCheckedChange={(checked) => setSkipExised(Boolean(checked))}
/>
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t('Skip already parse record.')}
</label>
</div>

<Button loading={loading} onClick={handleStart}>
{t('Run')}
</Button>

{resultText.length > 0 && (
<Alert>
<LuBot className="h-4 w-4" />
<AlertTitle>{t('AI Classify completed!')}</AlertTitle>
<AlertDescription className="text-xs">
{resultText.map((t, i) => (
<div key={i}>{t}</div>
))}
</AlertDescription>
</Alert>
)}
</div>
</DialogContent>
</Dialog>
);
});
SurveyAIBtn.displayName = 'SurveyAIBtn';
8 changes: 8 additions & 0 deletions src/client/routes/survey/$surveyId/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { Empty } from 'antd';
import React from 'react';
import { useGlobalConfig } from '@/hooks/useConfig';
import { DataRender } from '@/components/DataRender';
import { SurveyAIBtn } from '@/components/survey/SurveyAIBtn';

type SurveyResultItem =
AppRouterOutput['survey']['resultList']['items'][number];
Expand Down Expand Up @@ -208,6 +209,7 @@ function PageComponent() {
<div className="flex justify-between">
<div>{count}</div>
<div className="flex gap-2">
{config.enableAI && <SurveyAIBtn surveyId={surveyId} />}
<SurveyUsageBtn surveyId={surveyId} />
<SurveyDownloadBtn surveyId={surveyId} />
</div>
Expand Down Expand Up @@ -269,6 +271,12 @@ function PageComponent() {
{selectedItem.id}
</SheetDataSection>

<SheetDataSection label={t('Category')}>
{selectedItem.aiCategory ?? (
<span className="opacity-40">(null)</span>
)}
</SheetDataSection>

<SheetDataSection label={t('Created At')}>
{dayjs(selectedItem.createdAt).format(
'YYYY-MM-DD HH:mm:ss'
Expand Down
6 changes: 6 additions & 0 deletions src/server/model/billing/credit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import retry from 'async-retry';
import { prisma } from '../_client.js';
import { getWorkspaceTier } from './workspace.js';

export const tokenCreditFactor = 1.5;

Expand All @@ -17,6 +18,11 @@ export async function checkCredit(workspaceId: string) {

const credit = res?.credit ?? 0;
if (credit <= 0) {
const workspaceTier = await getWorkspaceTier(workspaceId);
if (workspaceTier === 'UNLIMITED') {
return;
}

throw new Error('Workspace not have enough credit');
}
}
Expand Down
60 changes: 60 additions & 0 deletions src/server/model/openai.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import OpenAI from 'openai';
import { env } from '../utils/env.js';
import { encoding_for_model } from 'tiktoken';
import {
checkCredit,
costCredit,
tokenCreditFactor,
} from './billing/credit.js';
import { ChatCompletionCreateParamsBase } from 'openai/resources/chat/completions.mjs';
import { createAuditLog } from './auditLog.js';

export const modelName = 'gpt-4o-mini';

Expand All @@ -22,6 +29,59 @@ export function getOpenAIClient() {
}
}

export async function requestOpenAI(
workspaceId: string,
prompt: string,
question: string,
options: Omit<
ChatCompletionCreateParamsBase,
'model' | 'messages' | 'stream'
> = {},
context?: Record<string, string>
): Promise<string> {
if (!env.openai.enable) {
return '';
}

await checkCredit(workspaceId);

const res = await getOpenAIClient().chat.completions.create({
...options,
model: modelName,
messages: [
{
role: 'system',
content: prompt,
},
{
role: 'user',
content: question,
},
],
});

const content = res.choices[0].message.content;
const usage = res.usage;

const credit = tokenCreditFactor * (usage?.total_tokens ?? 0);

await costCredit(workspaceId, credit, 'ai', {
...usage,
...context,
});
createAuditLog({
workspaceId,
content: JSON.stringify({
type: 'ai',
usage,
context,
credit,
}),
});

return content ?? '';
}

export function calcOpenAIToken(message: string) {
const encoder = encoding_for_model(modelName);
const count = encoder.encode(message).length;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "WorkspaceBill" DROP CONSTRAINT "WorkspaceBill_workspaceId_fkey";

-- DropIndex
DROP INDEX "WorkspaceBill_workspaceId_key";
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SurveyResult" ADD COLUMN "aiCategory" TEXT;
8 changes: 4 additions & 4 deletions src/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ model Workspace {
updatedAt DateTime @updatedAt @db.Timestamptz(6)
subscription WorkspaceSubscription?
WorkspaceBill WorkspaceBill?
users WorkspacesOnUsers[]
websites Website[]
Expand Down Expand Up @@ -153,7 +152,7 @@ model WorkspaceSubscription {

model WorkspaceBill {
id String @id() @default(cuid()) @db.VarChar(30)
workspaceId String @unique @db.VarChar(30)
workspaceId String @db.VarChar(30)
type String
amount Int
/// [CommonPayload]
Expand All @@ -163,8 +162,6 @@ model WorkspaceBill {
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onUpdate: Cascade, onDelete: Cascade)
@@index([type])
}

Expand Down Expand Up @@ -604,6 +601,9 @@ model SurveyResult {
latitude Float?
accuracyRadius Int?
// ai
aiCategory String?
survey Survey @relation(fields: [surveyId], references: [id], onUpdate: Cascade, onDelete: Cascade)
@@index([surveyId])
Expand Down
1 change: 1 addition & 0 deletions src/server/prisma/zod/surveyresult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const SurveyResultModelSchema = z.object({
longitude: z.number().nullish(),
latitude: z.number().nullish(),
accuracyRadius: z.number().int().nullish(),
aiCategory: z.string().nullish(),
})

export interface CompleteSurveyResult extends z.infer<typeof SurveyResultModelSchema> {
Expand Down
4 changes: 1 addition & 3 deletions src/server/prisma/zod/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
import { CompleteWorkspaceSubscription, RelatedWorkspaceSubscriptionModelSchema, CompleteWorkspaceBill, RelatedWorkspaceBillModelSchema, CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteWebsite, RelatedWebsiteModelSchema, CompleteNotification, RelatedNotificationModelSchema, CompleteMonitor, RelatedMonitorModelSchema, CompleteMonitorStatusPage, RelatedMonitorStatusPageModelSchema, CompleteTelemetry, RelatedTelemetryModelSchema, CompleteWorkspaceDailyUsage, RelatedWorkspaceDailyUsageModelSchema, CompleteWorkspaceAuditLog, RelatedWorkspaceAuditLogModelSchema, CompleteSurvey, RelatedSurveyModelSchema, CompleteFeedChannel, RelatedFeedChannelModelSchema } from "./index.js"
import { CompleteWorkspaceSubscription, RelatedWorkspaceSubscriptionModelSchema, CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteWebsite, RelatedWebsiteModelSchema, CompleteNotification, RelatedNotificationModelSchema, CompleteMonitor, RelatedMonitorModelSchema, CompleteMonitorStatusPage, RelatedMonitorStatusPageModelSchema, CompleteTelemetry, RelatedTelemetryModelSchema, CompleteWorkspaceDailyUsage, RelatedWorkspaceDailyUsageModelSchema, CompleteWorkspaceAuditLog, RelatedWorkspaceAuditLogModelSchema, CompleteSurvey, RelatedSurveyModelSchema, CompleteFeedChannel, RelatedFeedChannelModelSchema } from "./index.js"

// Helper schema for JSON fields
type Literal = boolean | number | string
Expand Down Expand Up @@ -28,7 +28,6 @@ export const WorkspaceModelSchema = z.object({

export interface CompleteWorkspace extends z.infer<typeof WorkspaceModelSchema> {
subscription?: CompleteWorkspaceSubscription | null
WorkspaceBill?: CompleteWorkspaceBill | null
users: CompleteWorkspacesOnUsers[]
websites: CompleteWebsite[]
notifications: CompleteNotification[]
Expand All @@ -48,7 +47,6 @@ export interface CompleteWorkspace extends z.infer<typeof WorkspaceModelSchema>
*/
export const RelatedWorkspaceModelSchema: z.ZodSchema<CompleteWorkspace> = z.lazy(() => WorkspaceModelSchema.extend({
subscription: RelatedWorkspaceSubscriptionModelSchema.nullish(),
WorkspaceBill: RelatedWorkspaceBillModelSchema.nullish(),
users: RelatedWorkspacesOnUsersModelSchema.array(),
websites: RelatedWebsiteModelSchema.array(),
notifications: RelatedNotificationModelSchema.array(),
Expand Down
Loading

0 comments on commit 4c37eab

Please sign in to comment.