From 4b039177b6f8c5f016b094fb87495ae7c6ea4b55 Mon Sep 17 00:00:00 2001 From: howard9199 <53228984+howard9199@users.noreply.github.com> Date: Tue, 24 Dec 2024 07:09:24 +0800 Subject: [PATCH] feat: group summarize stage and editable summary - Added a new GroupSummary component to handle displaying and editing group discussion summaries and keywords. - Enhanced ParticipantView to integrate GroupSummary, allowing users to view and update summaries based on session status. - Updated the Group schema to include status and updatedAt fields for better state management. - Implemented API endpoints for fetching and updating group summaries, including handling the transition between discussion and summary phases. - Improved error handling and notifications for group summary operations. This update enhances the collaborative experience by providing clear summaries and facilitating updates during group discussions. --- .../components/session/GroupSummary.svelte | 114 +++++++++++++ .../components/session/ParticipantView.svelte | 161 ++++++++++++++++-- src/lib/schema/group.ts | 2 + src/lib/server/llm.ts | 7 +- .../[group_number]/discussions/add/+server.ts | 3 +- .../[group_number]/discussions/end/+server.ts | 25 +++ .../discussions/summary/+server.ts | 17 +- 7 files changed, 311 insertions(+), 18 deletions(-) create mode 100644 src/lib/components/session/GroupSummary.svelte create mode 100644 src/routes/api/session/[id]/group/[group_number]/discussions/end/+server.ts diff --git a/src/lib/components/session/GroupSummary.svelte b/src/lib/components/session/GroupSummary.svelte new file mode 100644 index 0000000..aaad03d --- /dev/null +++ b/src/lib/components/session/GroupSummary.svelte @@ -0,0 +1,114 @@ + + +
+
+

群組討論總結

+
+ {#if !loading && !readonly} + + + {#if !isEditing} + + {/if} + {:else if loading} + + {/if} +
+
+ + {#if summaryData} +
+
+

討論總結:

+ {#if isEditing && !readonly} + + {:else} +

{summaryData.summary}

+ {/if} +
+ + {#if summaryData.keywords.length > 0} +
+

討論關鍵字:

+ {#if isEditing && !readonly} + + {#each editedKeywords as _, i} + + {/each} + {:else} +
    + {#each summaryData.keywords as point} +
  • {point}
  • + {/each} +
+ {/if} +
+ {/if} +
+ {:else if loading} +
+

正在生成討論總結,請稍候...

+
+ {:else} +
+

點擊上方按鈕生成討論總結

+
+ {/if} +
diff --git a/src/lib/components/session/ParticipantView.svelte b/src/lib/components/session/ParticipantView.svelte index de89335..578fcd0 100644 --- a/src/lib/components/session/ParticipantView.svelte +++ b/src/lib/components/session/ParticipantView.svelte @@ -7,7 +7,7 @@ import { Button, Input, Label } from 'flowbite-svelte'; import { notifications } from '$lib/stores/notifications'; import { page } from '$app/stores'; - import { UserPlus, Users } from 'lucide-svelte'; + import { UserPlus, Users, CircleCheck } from 'lucide-svelte'; import { db } from '$lib/firebase'; import { collection, query, where, onSnapshot } from 'firebase/firestore'; import { onDestroy, onMount } from 'svelte'; @@ -15,6 +15,7 @@ import Chatroom from '$lib/components/Chatroom.svelte'; import { MicVAD, utils } from '@ricky0123/vad-web'; import Summary from '$lib/components/session/Summary.svelte'; + import GroupSummary from '$lib/components/session/GroupSummary.svelte'; interface ChatroomConversation { name: string; @@ -31,10 +32,13 @@ }>(); let groupDoc = $state<{ data: Group; id: string } | null>(null); + let groupStatus = $derived.by(() => groupDoc?.data.status || 'discussion'); let conversationDoc = $state<{ data: Conversation; id: string } | null>(null); let conversationDocUnsubscribe: (() => void) | null = null; onDestroy(() => conversationDocUnsubscribe?.()); + let loadingGroupSummary = $state(false); + onMount(() => { const groupsRef = collection(db, 'sessions', $page.params.id, 'groups'); const groupDocQuery = query(groupsRef, where('participants', 'array-contains', user.uid)); @@ -315,7 +319,8 @@ }, body: JSON.stringify({ content: text, - speaker: $authUser?.displayName || 'Unknown User' + speaker: $authUser?.displayName || 'Unknown User', + audio: null }) } ); @@ -382,10 +387,123 @@ vad.destroy(); }; } + + async function fetchGroupSummary() { + if (!groupDoc) { + notifications.error('找不到群組'); + return; + } + + loadingGroupSummary = true; + try { + const response = await fetch( + `/api/session/${$page.params.id}/group/${groupDoc.id}/discussions/summary` + ); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '無法獲取群組討論總結'); + } + + notifications.success('成功獲取群組討論總結'); + } catch (error) { + console.error('獲取群組討論總結時出錯:', error); + notifications.error('無法獲取群組討論總結'); + } finally { + loadingGroupSummary = false; + } + } + + async function handleUpdateGroupSummary(summary: string, keywords: string[]) { + if (!groupDoc) { + notifications.error('找不到群組'); + return; + } + + try { + const response = await fetch( + `/api/session/${$page.params.id}/group/${groupDoc.id}/discussions/summary`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + updated_summary: summary, + keywords: keywords + }) + } + ); + + if (!response.ok) { + throw new Error('更新失敗'); + } + notifications.success('成功更新群組討論總結'); + } catch (error) { + console.error('更新群組討論總結時出錯:', error); + notifications.error('無法更新群組討論總結'); + } + } + + async function handleEndGroup() { + if (!groupDoc) { + notifications.error('找不到群組'); + return; + } + + try { + await fetchGroupSummary(); + notifications.success('成功結束群組討論'); + } catch (error) { + console.error('結束群組階段時出錯:', error); + notifications.error('無法結束群組階段'); + } + } + + async function handleEndSummarize() { + if (!groupDoc) { + notifications.error('找不到群組'); + return; + } + + try { + const response = await fetch( + `/api/session/${$page.params.id}/group/${groupDoc.id}/discussions/end`, + { + method: 'POST' + } + ); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '無法結束總結階段'); + } + + notifications.success('成功完成群組總結'); + } catch (error) { + console.error('結束總結階段時出錯:', error); + notifications.error('無法結束總結階段'); + } + }
-

{$session?.title}

+
+

{$session?.title}

+ {#if $session?.status === 'group' && groupDoc && groupDoc.data.participants[0] === user.uid} + {#if groupStatus === 'discussion'} + + {:else if groupStatus === 'summarize'} + + {/if} + {/if} +
@@ -430,17 +548,17 @@

Members:

    - {#each groupDoc.data.participants as participant} + {#each groupDoc.data.participants as participant, index}
  • {#if participant === user.uid} - You + You{index === 0 ? ' (Leader)' : ''} {:else} {#await getUser(participant)} 載入中... {:then profile} - {profile.displayName} + {profile.displayName}{index === 0 ? ' (Leader)' : ''} {:catch} 未知使用者 {/await} @@ -508,11 +626,32 @@
{:else if $session?.status === 'group'}
- + {#if groupStatus === 'discussion' && !loadingGroupSummary} + + {:else if groupStatus === 'summarize' || loadingGroupSummary} + {#if groupDoc} + + {/if} + {:else if groupStatus === 'end'} + {#if groupDoc} + + {/if} + {/if}
{/if}
diff --git a/src/lib/schema/group.ts b/src/lib/schema/group.ts index 8fa5f85..21d9eb9 100644 --- a/src/lib/schema/group.ts +++ b/src/lib/schema/group.ts @@ -14,6 +14,8 @@ export const GroupSchema = z.object({ audio: z.string().nullable() // to find the raw file }) ), + updatedAt: z.date().nullable(), + status: z.enum(['discussion', 'summarize', 'end']).default('discussion'), summary: z.string().nullable(), // lock on stage 2 finalize transaction keywords: z.record(z.string(), z.number().min(1).max(5)) }); diff --git a/src/lib/server/llm.ts b/src/lib/server/llm.ts index bad2698..9319d4b 100644 --- a/src/lib/server/llm.ts +++ b/src/lib/server/llm.ts @@ -349,9 +349,10 @@ export async function summarizeConcepts( export async function summarizeGroupOpinions( student_opinion: StudentSpeak[] -): Promise<{ success: boolean; summary: string; error?: string }> { +): Promise<{ success: boolean; summary: string; keywords: string[]; error?: string }> { try { const formatted_opinions = student_opinion + .filter((opinion) => opinion.role !== '摘要小幫手') .map((opinion) => `${opinion.role}: ${opinion.content}`) .join('\n'); @@ -374,13 +375,15 @@ export async function summarizeGroupOpinions( return { success: true, - summary: message.group_summary + summary: message.group_summary, + keywords: message.group_key_points }; } catch (error) { console.error('Error in summarizeGroupOpinions:', error); return { success: false, summary: '', + keywords: [], error: 'Failed to summarize group opinions' }; } diff --git a/src/routes/api/session/[id]/group/[group_number]/discussions/add/+server.ts b/src/routes/api/session/[id]/group/[group_number]/discussions/add/+server.ts index ed23a20..7a966b1 100644 --- a/src/routes/api/session/[id]/group/[group_number]/discussions/add/+server.ts +++ b/src/routes/api/session/[id]/group/[group_number]/discussions/add/+server.ts @@ -41,7 +41,8 @@ export const POST: RequestHandler = async ({ request, params, locals }) => { speaker: speaker, audio: audio } - ] + ], + updatedAt: new Date() }); }); diff --git a/src/routes/api/session/[id]/group/[group_number]/discussions/end/+server.ts b/src/routes/api/session/[id]/group/[group_number]/discussions/end/+server.ts new file mode 100644 index 0000000..8293276 --- /dev/null +++ b/src/routes/api/session/[id]/group/[group_number]/discussions/end/+server.ts @@ -0,0 +1,25 @@ +import { adminDb } from '$lib/server/firebase'; +import { error, json } from '@sveltejs/kit'; + +export async function POST({ params, locals }) { + if (!locals.user) { + throw error(401, '未經授權'); + } + + try { + const groupRef = adminDb + .collection('sessions') + .doc(params.id) + .collection('groups') + .doc(params.group_number); + + await groupRef.update({ + status: 'end' + }); + + return json({ status: 'success' }); + } catch (e) { + console.error('Error ending group summarize phase:', e); + throw error(500, '無法結束群組總結階段'); + } +} diff --git a/src/routes/api/session/[id]/group/[group_number]/discussions/summary/+server.ts b/src/routes/api/session/[id]/group/[group_number]/discussions/summary/+server.ts index eea1860..b42ca03 100644 --- a/src/routes/api/session/[id]/group/[group_number]/discussions/summary/+server.ts +++ b/src/routes/api/session/[id]/group/[group_number]/discussions/summary/+server.ts @@ -21,6 +21,11 @@ export const GET: RequestHandler = async ({ params, locals }) => { const group_ref = getGroupRef(id, group_number); const { discussions } = await getGroupData(group_ref); + + group_ref.update({ + status: 'summarize' + }); + const student_opinions = discussion2StudentSpeak(discussions); const response = await summarizeGroupOpinions(student_opinions); @@ -29,7 +34,8 @@ export const GET: RequestHandler = async ({ params, locals }) => { } group_ref.update({ - group_summary: response.summary + summary: response.summary, + keywords: response.keywords }); return json({ success: true }, { status: 200 }); @@ -43,7 +49,8 @@ export const GET: RequestHandler = async ({ params, locals }) => { // PUT /api/session/[id]/group/[group_number]/discussions/summary/+server // Request data format const requestDataFormat = z.object({ - updated_summary: z.string() + updated_summary: z.string(), + keywords: z.array(z.string()) }); export const PUT: RequestHandler = async ({ request, params, locals }) => { @@ -57,10 +64,12 @@ export const PUT: RequestHandler = async ({ request, params, locals }) => { return json({ error: 'Missing parameters' }, { status: 400 }); } - const { updated_summary } = await getRequestData(request); + const { updated_summary, keywords } = await getRequestData(request); const group_ref = getGroupRef(id, group_number); + console.log('Updating group summary...', updated_summary, keywords); group_ref.update({ - group_summary: updated_summary + summary: updated_summary, + keywords: keywords }); return json({ success: true }, { status: 200 });