-
Notifications
You must be signed in to change notification settings - Fork 194
Add AI comment selection, LLM feedback, and usage stats dashboard #3662
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tzj04
wants to merge
61
commits into
source-academy:master
Choose a base branch
from
tzj04:feature-ai-comments
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,526
−948
Open
Changes from 13 commits
Commits
Show all changes
61 commits
Select commit
Hold shift + click to select a range
54c3375
feat(grading): add interactive AI comment selection and word-level di…
tzj04 8ee56da
feature(ai_grading): Rendering generate comments button based on xml …
leongyiquan e244d31
Merge remote-tracking branch 'yiquan/feat/generate-comments-toggling'…
tzj04 186f47c
feat(grading): Add LLM feedback submission for grading comments
tzj04 645fe92
feat(groundControl): Add LLM usage statistics monitoring
tzj04 8f89539
feat(core): Add LLM types and request handlers
tzj04 d598501
feat: 3 tiered prompt generation and prompt button adjustments
leongyiquan 895e3a2
feat(grading): streamline AI comment selection with inline editor syn…
tzj04 28e3c3d
refactor(api): simplify saveChosenComments to answer-based endpoint
tzj04 7f2523d
Merge branch 'master' into feature-ai-comments
tzj04 43cdbe3
merge: resolve conflicts and integrate 3-tier prompting with ai comments
leongyiquan eacfe94
feat: implement AI token cost tracking and stats UI
leongyiquan 9cfa21a
Added a new tab for coursewide summary for LLM stats
leongyiquan df6a1e1
Fixed fail save bug
leongyiquan 518102c
prevent unsafe changes bug
leongyiquan 0d4eb8c
fix(grading): prevent false unsaved state and guard save-and-continue…
tzj04 ffda3b2
fix(grading): preserve empty AI comment slots to keep index mapping s…
tzj04 e0fa5f7
fix: add timeout fallback for save controls unlock on grading failure
tzj04 cb0bc0d
feat(grading): make LLM feedback star rating keyboard-accessible and …
tzj04 58831f5
fix(grading): use typed selector hook in GradingEditor
tzj04 23979a1
fix(grading): use shared typed dispatch hook to avoid restricted reac…
tzj04 d04b4ec
fix(grading): gate is_llm using grading.enable_llm_grading instead of…
tzj04 4636129
fix(grading): keep LLM comment UX visible without prompts and gate pr…
tzj04 d1802d8
merge: resolve conflicts with upstream master
tzj04 9a9c51b
fix: Replaced direct yield of postGrading with a redux-saga CALL effect
tzj04 903368f
fix(navbar): remove stale hasLlmContent prop from academy navbar righ…
tzj04 426cdaf
chore(deps): update caniuse-lite browsers data
tzj04 c67418d
test(backend-saga): fix submit grading-and-continue navigation assert…
tzj04 fbe8b57
fix: sort imports in academyRoutes.test.ts
tzj04 d53877f
fix(types): add local declaration shim for @blueprintjs/core
tzj04 382fb50
fix(groundControl): resolve React 19 runtime compatibility issues
tzj04 3aaa053
fix(llm-stats): label feedback by task display order
tzj04 8672ad9
fix(grading): persist and rehydrate AI comment selection state; keep …
tzj04 2cd0377
fixed hard coded bug for llm in grading page
leongyiquan 4e9ce4e
fixed data handling mismatch
leongyiquan 9ec0c41
fixed formatting issue with \n
leongyiquan f1cc545
Merge branch 'master' into feature-ai-comments
martin-henz 90f52f2
fix(ground-control): ensure action buttons remain visible on small sc…
tzj04 1beb5b1
fix(grading): refactor AI comment selection with unified UI and persi…
tzj04 71e04d9
fix(grading): refactor AI comment selection
tzj04 d2580aa
Update LLMStatsTab and LLMStatsPage
tzj04 1617adf
Merge branch 'tzj04-feature-ai-comments'
tzj04 0bf8aa0
Save LLMStats updates before merging master
tzj04 5fb13e2
Merge branch 'master' into feature-ai-comments
tzj04 c938620
Merge branch 'master' of https://github.com/source-academy/frontend i…
tzj04 608f723
fix(grading): persist cleared AI comment selection after re-generate …
tzj04 b71637f
Merge branch 'master' into feature-ai-comments
martin-henz e14306a
Fixed rollback logic for AI comments
leongyiquan 047c2d5
Merge branch 'master' into feature-ai-comments
tzj04 a4b15f5
Merge branch 'master' into feature-ai-comments
tzj04 a16bfaa
fix(grading): prevent AI comment rollback corruption on regenerate+sa…
tzj04 efc78d9
resolve merge conlficts with 'master'
tzj04 1512451
Fix: resolve conflicts between local master and upstream
tzj04 e91be09
Merge branch 'master' into feature-ai-comments
tzj04 4febf6a
Fix incorrect past merge resolution
RichDom2185 f717559
Merge branch 'master' of https://github.com/source-academy/frontend i…
RichDom2185 4f1b9a3
Undo more incorrect changes
RichDom2185 80975b5
Remove unnecessary Vitest config change
RichDom2185 f5c2920
Merge branch 'master' of https://github.com/source-academy/frontend i…
RichDom2185 09c4815
Fix errors post-merge
RichDom2185 4186f20
Migrate changes post-merge
RichDom2185 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,363 @@ | ||
| import { | ||
| Button, | ||
| Card, | ||
| Divider, | ||
| H3, | ||
| H4, | ||
| Icon, | ||
| Intent, | ||
| NonIdealState, | ||
| Spinner, | ||
| SpinnerSize, | ||
| Tag | ||
| } from '@blueprintjs/core'; | ||
| import { IconNames } from '@blueprintjs/icons'; | ||
| import React, { useEffect, useMemo, useState } from 'react'; | ||
|
|
||
| import { LLMAssessmentStat, LLMCourseStat, LLMQuestionStat } from '../assessment/AssessmentTypes'; | ||
| import { getLLMCourseStats } from '../sagas/RequestsSaga'; | ||
| import { useTokens } from '../utils/Hooks'; | ||
|
|
||
| const formatNumber = (value: number | string | null | undefined): string => { | ||
| if (value === null || value === undefined || value === '') { | ||
| return '0'; | ||
| } | ||
| if (typeof value === 'string') { | ||
| const parsed = Number(value); | ||
| return Number.isNaN(parsed) ? value : parsed.toLocaleString(); | ||
| } | ||
| return value.toLocaleString(); | ||
| }; | ||
|
|
||
| const formatCost = (value: number | string | null | undefined): string => { | ||
| if (value === null || value === undefined || value === '') { | ||
| return '$0.00'; | ||
| } | ||
| const parsed = Number(value); | ||
| if (Number.isNaN(parsed)) { | ||
| return `${value}`; | ||
| } | ||
| return `$${parsed.toFixed(2)}`; | ||
| }; | ||
|
|
||
| const getTotalTokens = (assessment: LLMAssessmentStat): number => { | ||
| return (assessment.llm_total_input_tokens || 0) + (assessment.llm_total_output_tokens || 0); | ||
| }; | ||
|
|
||
| const getQuestionTotalTokens = (question: LLMQuestionStat): number => { | ||
| return (question.llm_total_input_tokens || 0) + (question.llm_total_output_tokens || 0); | ||
| }; | ||
|
|
||
| const LLMStatsTab: React.FC = () => { | ||
| const { accessToken, refreshToken } = useTokens(); | ||
| const [loading, setLoading] = useState(false); | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [data, setData] = useState<LLMCourseStat | null>(null); | ||
| const [sortKey, setSortKey] = useState< | ||
| 'title' | 'category' | 'total_uses' | 'total_tokens' | 'llm_total_cost' | 'avg_rating' | ||
| >('llm_total_cost'); | ||
| const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); | ||
| const [expanded, setExpanded] = useState<Set<number>>(new Set()); | ||
|
|
||
| useEffect(() => { | ||
| const fetch = async () => { | ||
| setLoading(true); | ||
| setError(null); | ||
| try { | ||
| const resp = await getLLMCourseStats({ accessToken, refreshToken }); | ||
| if (!resp) { | ||
| setError('Failed to load LLM statistics.'); | ||
| } else { | ||
| setData(resp); | ||
| } | ||
| } catch (err) { | ||
| setError('Failed to load LLM statistics.'); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| fetch(); | ||
| }, [accessToken, refreshToken]); | ||
|
|
||
| const sortedAssessments: LLMAssessmentStat[] = useMemo(() => { | ||
| if (!data?.assessments) { | ||
| return []; | ||
| } | ||
|
|
||
| const assessments = [...data.assessments]; | ||
| const compare = (a: LLMAssessmentStat, b: LLMAssessmentStat): number => { | ||
| const direction = sortDirection === 'asc' ? 1 : -1; | ||
| switch (sortKey) { | ||
| case 'title': | ||
| return direction * a.title.localeCompare(b.title); | ||
| case 'category': | ||
| return direction * a.category.localeCompare(b.category); | ||
| case 'total_uses': | ||
| return direction * (a.total_uses - b.total_uses); | ||
| case 'avg_rating': | ||
| return direction * ((a.avg_rating || 0) - (b.avg_rating || 0)); | ||
| case 'llm_total_cost': | ||
| return direction * (Number(a.llm_total_cost || 0) - Number(b.llm_total_cost || 0)); | ||
| case 'total_tokens': | ||
| return direction * (getTotalTokens(a) - getTotalTokens(b)); | ||
| default: | ||
| return 0; | ||
| } | ||
| }; | ||
|
|
||
| return assessments.sort(compare); | ||
| }, [data, sortKey, sortDirection]); | ||
|
|
||
| const toggleSort = (key: typeof sortKey) => { | ||
| if (sortKey === key) { | ||
| setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); | ||
| } else { | ||
| setSortKey(key); | ||
| setSortDirection('desc'); | ||
| } | ||
| }; | ||
|
|
||
| const toggleRow = (assessmentId: number) => { | ||
| const next = new Set(expanded); | ||
| if (next.has(assessmentId)) { | ||
| next.delete(assessmentId); | ||
| } else { | ||
| next.add(assessmentId); | ||
| } | ||
| setExpanded(next); | ||
| }; | ||
|
|
||
| const downloadReport = () => { | ||
| if (!data) { | ||
| return; | ||
| } | ||
|
|
||
| const headers = [ | ||
| 'Assessment Title', | ||
| 'Category', | ||
| 'Question Number', | ||
| 'Uses', | ||
| 'Input Tokens', | ||
| 'Output Tokens', | ||
| 'Total Tokens', | ||
| 'Cost', | ||
| 'Avg Rating' | ||
| ]; | ||
|
|
||
| const rows: string[] = []; | ||
| data.assessments.forEach(assessment => { | ||
| if (assessment.questions.length === 0) { | ||
| rows.push( | ||
| [ | ||
| assessment.title, | ||
| assessment.category, | ||
| '', | ||
| assessment.total_uses.toString(), | ||
| assessment.llm_total_input_tokens.toString(), | ||
| assessment.llm_total_output_tokens.toString(), | ||
| getTotalTokens(assessment).toString(), | ||
| Number(assessment.llm_total_cost || 0).toFixed(6), | ||
| assessment.avg_rating?.toString() ?? '' | ||
| ].join(',') | ||
| ); | ||
| } else { | ||
| assessment.questions.forEach(question => { | ||
| rows.push( | ||
| [ | ||
| assessment.title, | ||
| assessment.category, | ||
| question.display_order.toString(), | ||
| question.total_uses.toString(), | ||
| question.llm_total_input_tokens.toString(), | ||
| question.llm_total_output_tokens.toString(), | ||
| getQuestionTotalTokens(question).toString(), | ||
| Number(question.llm_total_cost || 0).toFixed(6), | ||
| question.avg_rating?.toString() ?? '' | ||
| ].join(',') | ||
| ); | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| const content = [headers.join(','), ...rows].join('\n'); | ||
| const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' }); | ||
| const url = URL.createObjectURL(blob); | ||
| const link = document.createElement('a'); | ||
|
|
||
| link.href = url; | ||
| link.setAttribute('download', 'llm_stats_report.csv'); | ||
| document.body.appendChild(link); | ||
| link.click(); | ||
| document.body.removeChild(link); | ||
| URL.revokeObjectURL(url); | ||
| }; | ||
|
|
||
| if (loading) { | ||
| return ( | ||
| <div style={{ padding: '20px', textAlign: 'center' }}> | ||
| <Spinner size={SpinnerSize.STANDARD} /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (error) { | ||
| return ( | ||
| <NonIdealState | ||
| icon={IconNames.ERROR} | ||
| title="Unable to load LLM statistics" | ||
| description={error} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| if (!data || data.assessments.length === 0) { | ||
| return ( | ||
| <NonIdealState | ||
| icon={IconNames.CHART} | ||
| title="No LLM usage data yet" | ||
| description="Once students start generating comments, this panel will show LLM statistics." | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div style={{ padding: '14px' }}> | ||
| <div style={{ display: 'flex', gap: '12px', marginBottom: '12px', flexWrap: 'wrap' }}> | ||
| <Card style={{ flex: 1, textAlign: 'center' }}> | ||
| <H3 style={{ margin: 0 }}>{formatNumber(data.course_total_input_tokens)}</H3> | ||
| <div>Course-wide Input Tokens</div> | ||
| </Card> | ||
| <Card style={{ flex: 1, textAlign: 'center' }}> | ||
| <H3 style={{ margin: 0 }}>{formatNumber(data.course_total_output_tokens)}</H3> | ||
| <div>Course-wide Output Tokens</div> | ||
| </Card> | ||
| <Card style={{ flex: 1, textAlign: 'center' }}> | ||
| <H3 style={{ margin: 0 }}>{formatCost(data.course_total_cost)}</H3> | ||
| <div>Total LLM Expenditure</div> | ||
| </Card> | ||
| </div> | ||
|
|
||
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | ||
| <H4 style={{ margin: '8px 0' }}>Assessments</H4> | ||
| <Button icon={IconNames.DOWNLOAD} intent={Intent.PRIMARY} onClick={downloadReport}> | ||
| Download Report | ||
| </Button> | ||
| </div> | ||
|
|
||
| <table | ||
| className="bp3-html-table bp3-html-table-striped bp3-html-table-condensed" | ||
| style={{ width: '100%', marginTop: '8px' }} | ||
| > | ||
| <thead> | ||
| <tr> | ||
| <th> | ||
| <Button minimal={true} small={true} onClick={() => toggleSort('title')}> | ||
| Title | ||
| </Button> | ||
| </th> | ||
| <th> | ||
| <Button minimal={true} small={true} onClick={() => toggleSort('category')}> | ||
| Category | ||
| </Button> | ||
| </th> | ||
| <th> | ||
| <Button minimal={true} small={true} onClick={() => toggleSort('total_uses')}> | ||
| Uses | ||
| </Button> | ||
| </th> | ||
| <th> | ||
| <Button minimal={true} small={true} onClick={() => toggleSort('total_tokens')}> | ||
| Total Tokens | ||
| </Button> | ||
| </th> | ||
| <th> | ||
| <Button minimal={true} small={true} onClick={() => toggleSort('llm_total_cost')}> | ||
| Total Cost | ||
| </Button> | ||
| </th> | ||
| <th> | ||
| <Button minimal={true} small={true} onClick={() => toggleSort('avg_rating')}> | ||
| Avg Rating | ||
| </Button> | ||
| </th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {sortedAssessments.map(assessment => { | ||
| const isExpanded = expanded.has(assessment.assessment_id); | ||
| return ( | ||
| <React.Fragment key={assessment.assessment_id}> | ||
| <tr | ||
| style={{ cursor: 'pointer' }} | ||
| onClick={() => toggleRow(assessment.assessment_id)} | ||
| > | ||
| <td> | ||
| <span style={{ marginRight: '6px' }}> | ||
| <Icon icon={isExpanded ? IconNames.CARET_DOWN : IconNames.CARET_RIGHT} /> | ||
| </span> | ||
| {assessment.title} | ||
| </td> | ||
| <td>{assessment.category}</td> | ||
| <td>{assessment.total_uses}</td> | ||
| <td>{formatNumber(getTotalTokens(assessment))}</td> | ||
| <td>{formatCost(assessment.llm_total_cost)}</td> | ||
| <td> | ||
| {assessment.avg_rating === null || assessment.avg_rating === undefined | ||
| ? 'N/A' | ||
| : assessment.avg_rating.toFixed(2)} | ||
| </td> | ||
| </tr> | ||
| {isExpanded && assessment.questions.length > 0 && ( | ||
| <tr> | ||
| <td colSpan={6} style={{ padding: 0, background: '#f3f3f3' }}> | ||
| <div style={{ padding: '8px' }}> | ||
| <table | ||
| className="bp3-html-table bp3-html-table-condensed" | ||
| style={{ width: '100%' }} | ||
| > | ||
| <thead> | ||
| <tr> | ||
| <th>Question</th> | ||
| <th>Uses</th> | ||
| <th>Tokens</th> | ||
| <th>Cost</th> | ||
| <th>Avg Rating</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {assessment.questions.map(question => ( | ||
| <tr key={question.question_id}> | ||
| <td>{question.display_order}</td> | ||
| <td>{question.total_uses}</td> | ||
| <td>{formatNumber(getQuestionTotalTokens(question))}</td> | ||
| <td>{formatCost(question.llm_total_cost)}</td> | ||
| <td> | ||
| {question.avg_rating === null || question.avg_rating === undefined | ||
| ? 'N/A' | ||
| : question.avg_rating.toFixed(2)} | ||
| </td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| </td> | ||
| </tr> | ||
| )} | ||
| </React.Fragment> | ||
| ); | ||
| })} | ||
| </tbody> | ||
| </table> | ||
|
|
||
| <Divider style={{ marginTop: '16px' }} /> | ||
|
|
||
| <Tag minimal intent={Intent.PRIMARY} style={{ marginTop: '8px' }}> | ||
| Data is shown for published assessments and questions with LLM prompts. | ||
| </Tag> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default LLMStatsTab; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.