Commit 41f78a0d authored by Siddique's avatar Siddique
Browse files

Added Export button, Fixed Docker file.

parent 08a9295f
# Optional host ports
FRONTEND_PORT=5173
BACKEND_PORT=8000
LOCAL_LLM_PORT=8080
# Bundled llama.cpp model configuration
LOCAL_LLM_MODEL_DIR=./models
LOCAL_LLM_MODEL_FILE=gemma-4-E4B-it-Q4_K_M.gguf
LOCAL_LLM_MODEL_NAME=local-gguf-model
# Optional Academic Cloud key. Leave blank when it is not used.
CHAT_AI_API_KEY=
# Used only with docker-compose.host-llm.yml
HOST_LLM_BASE_URL=http://host.docker.internal:8080/v1
HOST_LLM_MODEL=local-model
...@@ -50,12 +50,22 @@ You also need access to at least one supported LLM provider. ...@@ -50,12 +50,22 @@ You also need access to at least one supported LLM provider.
### Bundled local LLM on CPU ### Bundled local LLM on CPU
Place this model file in `models/`: Copy the example Docker configuration:
```powershell
Copy-Item .env.example .env
```
On macOS/Linux, use `cp .env.example .env`.
Place your GGUF model in `models/`. The default configuration expects:
```text ```text
gemma-4-E4B-it-Q4_K_M.gguf gemma-4-E4B-it-Q4_K_M.gguf
``` ```
To use another GGUF file, change `LOCAL_LLM_MODEL_FILE` in the root `.env`. Model weights are intentionally ignored by Git and must be downloaded separately by each user.
Then start the stack: Then start the stack:
```sh ```sh
...@@ -69,6 +79,7 @@ docker compose -f docker-compose.yml -f docker-compose.cuda.yml up --build ...@@ -69,6 +79,7 @@ docker compose -f docker-compose.yml -f docker-compose.cuda.yml up --build
``` ```
An NVIDIA GPU, compatible drivers, and the NVIDIA Container Toolkit are required. An NVIDIA GPU, compatible drivers, and the NVIDIA Container Toolkit are required.
The CUDA mode uses the same `LOCAL_LLM_MODEL_FILE` configuration as CPU mode.
### LLM server running on the host ### LLM server running on the host
...@@ -83,6 +94,10 @@ $env:HOST_LLM_BASE_URL = "http://host.docker.internal:8080/v1" ...@@ -83,6 +94,10 @@ $env:HOST_LLM_BASE_URL = "http://host.docker.internal:8080/v1"
$env:HOST_LLM_MODEL = "your-model-name" $env:HOST_LLM_MODEL = "your-model-name"
``` ```
The host LLM must expose an OpenAI-compatible `/v1` API. `host.docker.internal` is configured for Docker Desktop and Linux Docker Engine.
This override disables the bundled llama.cpp container. It can also be used when the deployment will use only Academic Cloud or **Use my OpenAI key**, because those providers do not require a local GGUF server.
After startup: After startup:
- Frontend: `http://localhost:5173` - Frontend: `http://localhost:5173`
...@@ -90,6 +105,19 @@ After startup: ...@@ -90,6 +105,19 @@ After startup:
- Interactive API documentation: `http://localhost:8000/docs` - Interactive API documentation: `http://localhost:8000/docs`
Docker stores uploaded files and the SQLite database in the named `asyst-backend-data` volume. Docker stores uploaded files and the SQLite database in the named `asyst-backend-data` volume.
The frontend is built as static production assets and served by Nginx. Nginx proxies `/api` requests to the backend inside the Compose network, so the browser does not depend on Docker-internal hostnames.
To stop the application while retaining its data:
```sh
docker compose down
```
To also permanently delete uploaded files and the SQLite database:
```sh
docker compose down --volumes
```
## Run Locally ## Run Locally
...@@ -167,6 +195,8 @@ npm run build ...@@ -167,6 +195,8 @@ npm run build
Do not commit API keys, local `.env` files, SQLite databases, uploaded student files, virtual environments, dependency directories, build output, or model weights. The included `.gitignore` excludes these files. Do not commit API keys, local `.env` files, SQLite databases, uploaded student files, virtual environments, dependency directories, build output, or model weights. The included `.gitignore` excludes these files.
When **Use my OpenAI key** is selected, the key is held in the frontend component state and sent in the grading-start request. The backend wraps the request value as a secret and uses it to construct the OpenAI-compatible client for that grading job. It is not written to SQLite, uploaded files, browser local storage, or API responses. It still travels in the HTTP request and remains temporarily in process memory, so HTTPS and a dedicated secret-management design would be required for a production deployment exposed beyond a trusted local environment.
Before the first GitLab commit, verify the repository contents with: Before the first GitLab commit, verify the repository contents with:
```sh ```sh
......
...@@ -12,6 +12,7 @@ class GradingExampleRequest(BaseModel): ...@@ -12,6 +12,7 @@ class GradingExampleRequest(BaseModel):
question: str question: str
answer: str answer: str
score: str score: str
max_points: str
class GradingStartRequest(BaseModel): class GradingStartRequest(BaseModel):
......
...@@ -108,6 +108,7 @@ def _coerce_grading_example(example: Any) -> GradingExample: ...@@ -108,6 +108,7 @@ def _coerce_grading_example(example: Any) -> GradingExample:
question = _example_value(example, "question") question = _example_value(example, "question")
answer = _example_value(example, "answer") answer = _example_value(example, "answer")
score_text = _example_value(example, "score") score_text = _example_value(example, "score")
max_points_text = _example_value(example, "max_points")
if not question: if not question:
raise ValueError("Each grading example needs a question") raise ValueError("Each grading example needs a question")
...@@ -115,16 +116,27 @@ def _coerce_grading_example(example: Any) -> GradingExample: ...@@ -115,16 +116,27 @@ def _coerce_grading_example(example: Any) -> GradingExample:
raise ValueError("Each grading example needs a student answer") raise ValueError("Each grading example needs a student answer")
if not score_text: if not score_text:
raise ValueError("Each grading example needs an awarded score") raise ValueError("Each grading example needs an awarded score")
if not max_points_text:
raise ValueError("Each grading example needs maximum points")
try: try:
score = float(score_text.replace(",", ".")) score = float(score_text.replace(",", "."))
except ValueError as exc: except ValueError as exc:
raise ValueError("Example score must be a number") from exc raise ValueError("Example score must be a number") from exc
try:
max_points = float(max_points_text.replace(",", "."))
except ValueError as exc:
raise ValueError("Example maximum points must be a number") from exc
if score < 0: if score < 0:
raise ValueError("Example score must be greater than or equal to 0") raise ValueError("Example score must be greater than or equal to 0")
if max_points <= 0:
raise ValueError("Example maximum points must be greater than 0")
if score > max_points:
raise ValueError("Example awarded score cannot exceed its maximum points")
return GradingExample(question=question, answer=answer, score=score) return GradingExample(question=question, answer=answer, score=score, max_points=max_points)
def _example_value(example: Any, key: str) -> str: def _example_value(example: Any, key: str) -> str:
......
...@@ -12,6 +12,7 @@ class GradingExample: ...@@ -12,6 +12,7 @@ class GradingExample:
question: str question: str
answer: str answer: str
score: float score: float
max_points: float
@dataclass @dataclass
......
...@@ -36,7 +36,7 @@ def valid_scores_text(max_points: float) -> str: ...@@ -36,7 +36,7 @@ def valid_scores_text(max_points: float) -> str:
def _base_system() -> str: def _base_system() -> str:
return ( return (
"Du bist ein erfahrener Korrektor fuer kurze studentische Antworten in der Informatik.\n" "Du bist ein erfahrener Korrektor fuer kurze studentische Antworten.\n"
"Bewerte ausschliesslich die fachliche Korrektheit.\n" "Bewerte ausschliesslich die fachliche Korrektheit.\n"
"Ignoriere Rechtschreibung, Grammatik, Stil und Zeichensetzung.\n" "Ignoriere Rechtschreibung, Grammatik, Stil und Zeichensetzung.\n"
"Bewerte nur belegte fachliche Inhalte.\n" "Bewerte nur belegte fachliche Inhalte.\n"
...@@ -97,6 +97,7 @@ Studentische Antwort: ...@@ -97,6 +97,7 @@ Studentische Antwort:
{example.answer} {example.answer}
<<<END_BEISPIEL_ANTWORT>>> <<<END_BEISPIEL_ANTWORT>>>
Maximale Punktzahl: {_format_score(example.max_points)}
Punktzahl: {_format_score(example.score)}""" Punktzahl: {_format_score(example.score)}"""
) )
return "\n\n".join(chunks) return "\n\n".join(chunks)
......
...@@ -132,6 +132,7 @@ def test_start_grading_accepts_prompt_options(monkeypatch) -> None: ...@@ -132,6 +132,7 @@ def test_start_grading_accepts_prompt_options(monkeypatch) -> None:
"question": "What is RAM?", "question": "What is RAM?",
"answer": "RAM stores active program data.", "answer": "RAM stores active program data.",
"score": "2", "score": "2",
"max_points": "2",
} }
], ],
), ),
...@@ -142,3 +143,4 @@ def test_start_grading_accepts_prompt_options(monkeypatch) -> None: ...@@ -142,3 +143,4 @@ def test_start_grading_accepts_prompt_options(monkeypatch) -> None:
assert len(captured["examples"]) == 1 assert len(captured["examples"]) == 1
assert captured["examples"][0].question == "What is RAM?" assert captured["examples"][0].question == "What is RAM?"
assert captured["examples"][0].score == "2" assert captured["examples"][0].score == "2"
assert captured["examples"][0].max_points == "2"
...@@ -161,8 +161,8 @@ def test_p3_prompt_includes_scored_examples() -> None: ...@@ -161,8 +161,8 @@ def test_p3_prompt_includes_scored_examples() -> None:
student_answer="RAM ist schneller Speicher.", student_answer="RAM ist schneller Speicher.",
max_points=2.0, max_points=2.0,
examples=( examples=(
GradingExample(question="Was ist RAM?", answer="RAM speichert aktive Daten.", score=2.0), GradingExample(question="Was ist RAM?", answer="RAM speichert aktive Daten.", score=2.0, max_points=2.0),
GradingExample(question="Was ist RAM?", answer="RAM ist eine Festplatte.", score=0.0), GradingExample(question="Was ist RAM?", answer="RAM ist eine Festplatte.", score=0.0, max_points=2.0),
), ),
) )
...@@ -170,6 +170,7 @@ def test_p3_prompt_includes_scored_examples() -> None: ...@@ -170,6 +170,7 @@ def test_p3_prompt_includes_scored_examples() -> None:
assert "Bewertungsbeispiel 1" in content assert "Bewertungsbeispiel 1" in content
assert "RAM speichert aktive Daten." in content assert "RAM speichert aktive Daten." in content
assert "Maximale Punktzahl: 2.0" in content
assert "Punktzahl: 2.0" in content assert "Punktzahl: 2.0" in content
assert "Bewertungsbeispiel 2" in content assert "Bewertungsbeispiel 2" in content
assert "RAM ist eine Festplatte." in content assert "RAM ist eine Festplatte." in content
...@@ -184,7 +185,7 @@ def test_include_feedback_option_works_for_example_guided_prompt() -> None: ...@@ -184,7 +185,7 @@ def test_include_feedback_option_works_for_example_guided_prompt() -> None:
max_points=2.0, max_points=2.0,
include_feedback=True, include_feedback=True,
examples=( examples=(
GradingExample(question="Was ist RAM?", answer="RAM speichert aktive Daten.", score=2.0), GradingExample(question="Was ist RAM?", answer="RAM speichert aktive Daten.", score=2.0, max_points=2.0),
), ),
) )
...@@ -207,10 +208,10 @@ def test_p3_validation_rejects_missing_or_invalid_examples() -> None: ...@@ -207,10 +208,10 @@ def test_p3_validation_rejects_missing_or_invalid_examples() -> None:
strategy="p3_true_one_shot", strategy="p3_true_one_shot",
custom_grading_rules=None, custom_grading_rules=None,
examples=[ examples=[
{"question": "Q", "answer": "A", "score": "1"}, {"question": "Q", "answer": "A", "score": "1", "max_points": "2"},
{"question": "Q", "answer": "A", "score": "1"}, {"question": "Q", "answer": "A", "score": "1", "max_points": "2"},
{"question": "Q", "answer": "A", "score": "1"}, {"question": "Q", "answer": "A", "score": "1", "max_points": "2"},
{"question": "Q", "answer": "A", "score": "1"}, {"question": "Q", "answer": "A", "score": "1", "max_points": "2"},
], ],
) )
...@@ -218,14 +219,28 @@ def test_p3_validation_rejects_missing_or_invalid_examples() -> None: ...@@ -218,14 +219,28 @@ def test_p3_validation_rejects_missing_or_invalid_examples() -> None:
validate_prompt_run_options( validate_prompt_run_options(
strategy="p3_true_one_shot", strategy="p3_true_one_shot",
custom_grading_rules=None, custom_grading_rules=None,
examples=[{"question": "Q", "answer": " ", "score": "1"}], examples=[{"question": "Q", "answer": " ", "score": "1", "max_points": "2"}],
) )
with pytest.raises(ValueError, match="must be a number"): with pytest.raises(ValueError, match="must be a number"):
validate_prompt_run_options( validate_prompt_run_options(
strategy="p3_true_one_shot", strategy="p3_true_one_shot",
custom_grading_rules=None, custom_grading_rules=None,
examples=[{"question": "Q", "answer": "A", "score": "full"}], examples=[{"question": "Q", "answer": "A", "score": "full", "max_points": "2"}],
)
with pytest.raises(ValueError, match="needs maximum points"):
validate_prompt_run_options(
strategy="p3_true_one_shot",
custom_grading_rules=None,
examples=[{"question": "Q", "answer": "A", "score": "1"}],
)
with pytest.raises(ValueError, match="cannot exceed"):
validate_prompt_run_options(
strategy="p3_true_one_shot",
custom_grading_rules=None,
examples=[{"question": "Q", "answer": "A", "score": "3", "max_points": "2"}],
) )
......
...@@ -2,3 +2,5 @@ node_modules/ ...@@ -2,3 +2,5 @@ node_modules/
dist/ dist/
.vite/ .vite/
*.log *.log
.env
.env.*
FROM node:22-alpine FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
...@@ -7,6 +7,17 @@ RUN npm ci ...@@ -7,6 +7,17 @@ RUN npm ci
COPY . . COPY . .
EXPOSE 5173 ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=3s --retries=6 \
CMD wget -q -O - http://127.0.0.1/health >/dev/null || exit 1
server {
listen 80;
server_name _;
client_max_body_size 50m;
location = /health {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://backend:8000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { MemoryRouter, Route, Routes } from "react-router-dom"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import BackendStudentsOverviewPage from "./BackendStudentsOverviewPage"
import { downloadMoodleExport, getRunStudents } from "../services/sessionsApi"
vi.mock("../services/sessionsApi", () => ({
deleteRun: vi.fn(),
downloadMoodleExport: vi.fn(),
getRunStudents: vi.fn()
}))
const mockedGetRunStudents = vi.mocked(getRunStudents)
const mockedDownloadMoodleExport = vi.mocked(downloadMoodleExport)
function renderPage() {
return render(
<MemoryRouter initialEntries={["/overview-live/run-1/students"]}>
<Routes>
<Route path="/overview-live/:runId/students" element={<BackendStudentsOverviewPage />} />
</Routes>
</MemoryRouter>
)
}
describe("BackendStudentsOverviewPage", () => {
afterEach(cleanup)
beforeEach(() => {
vi.clearAllMocks()
mockedGetRunStudents.mockResolvedValue({
run_id: "run-1",
items: [],
pagination: {
page: 1,
page_size: 500,
total: 0
}
})
mockedDownloadMoodleExport.mockResolvedValue()
})
it("shows the Overview heading and exports the current run", async () => {
renderPage()
expect(screen.getByRole("heading", { name: "Overview" })).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: /Export results/ }))
await waitFor(() => expect(mockedDownloadMoodleExport).toHaveBeenCalledWith("run-1"))
})
it("shows a useful export error", async () => {
mockedDownloadMoodleExport.mockRejectedValue(new Error("Complete all reviews before export."))
renderPage()
fireEvent.click(screen.getByRole("button", { name: /Export results/ }))
expect(await screen.findByText("Complete all reviews before export.")).toBeInTheDocument()
})
})
...@@ -2,7 +2,12 @@ import { useEffect, useState } from "react" ...@@ -2,7 +2,12 @@ import { useEffect, useState } from "react"
import { useNavigate, useParams } from "react-router-dom" import { useNavigate, useParams } from "react-router-dom"
import AppFrame from "../components/AppFrame" import AppFrame from "../components/AppFrame"
import { deleteRun, getRunStudents, type RunStudentRow } from "../services/sessionsApi" import {
deleteRun,
downloadMoodleExport,
getRunStudents,
type RunStudentRow
} from "../services/sessionsApi"
function BackendStudentsOverviewPage() { function BackendStudentsOverviewPage() {
const { runId } = useParams<{ runId: string }>() const { runId } = useParams<{ runId: string }>()
...@@ -11,6 +16,7 @@ function BackendStudentsOverviewPage() { ...@@ -11,6 +16,7 @@ function BackendStudentsOverviewPage() {
const [students, setStudents] = useState<RunStudentRow[]>([]) const [students, setStudents] = useState<RunStudentRow[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [isExporting, setIsExporting] = useState(false)
useEffect(() => { useEffect(() => {
if (!runId) return if (!runId) return
...@@ -65,14 +71,26 @@ function BackendStudentsOverviewPage() { ...@@ -65,14 +71,26 @@ function BackendStudentsOverviewPage() {
} }
} }
const handleExport = async () => {
setIsExporting(true)
setError(null)
try {
await downloadMoodleExport(runId)
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to export results")
} finally {
setIsExporting(false)
}
}
return ( return (
<AppFrame> <AppFrame>
<section className="space-y-6"> <section className="space-y-6">
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="brand-font text-4xl font-extrabold tracking-tight">Student Overview (New)</h1> <h1 className="brand-font text-4xl font-extrabold tracking-tight">Overview</h1>
<p className="mt-1 text-sm text-[var(--on-surface-variant)]"> <p className="mt-1 text-sm text-[var(--on-surface-variant)]">
Backend-powered student summary with review completion and scores. Student results, review completion, and final scores.
</p> </p>
</div> </div>
...@@ -84,6 +102,15 @@ function BackendStudentsOverviewPage() { ...@@ -84,6 +102,15 @@ function BackendStudentsOverviewPage() {
> >
Back to Question Overview Back to Question Overview
</button> </button>
<button
type="button"
onClick={handleExport}
disabled={isExporting}
className="custom-gradient-primary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined !text-base">download</span>
{isExporting ? "Exporting..." : "Export results"}
</button>
<button <button
type="button" type="button"
onClick={handleDeleteRun} onClick={handleDeleteRun}
......
...@@ -120,6 +120,9 @@ describe("UploadPage grading method drawer", () => { ...@@ -120,6 +120,9 @@ describe("UploadPage grading method drawer", () => {
fireEvent.change(screen.getByLabelText("Example 1 awarded score"), { fireEvent.change(screen.getByLabelText("Example 1 awarded score"), {
target: { value: "2" } target: { value: "2" }
}) })
fireEvent.change(screen.getByLabelText("Example 1 maximum points"), {
target: { value: "2" }
})
fireEvent.change(screen.getByLabelText("Example 2 question"), { fireEvent.change(screen.getByLabelText("Example 2 question"), {
target: { value: "What is RAM?" } target: { value: "What is RAM?" }
}) })
...@@ -129,6 +132,9 @@ describe("UploadPage grading method drawer", () => { ...@@ -129,6 +132,9 @@ describe("UploadPage grading method drawer", () => {
fireEvent.change(screen.getByLabelText("Example 2 awarded score"), { fireEvent.change(screen.getByLabelText("Example 2 awarded score"), {
target: { value: "0" } target: { value: "0" }
}) })
fireEvent.change(screen.getByLabelText("Example 2 maximum points"), {
target: { value: "2" }
})
fireEvent.click(screen.getByRole("button", { name: "Apply settings" })) fireEvent.click(screen.getByRole("button", { name: "Apply settings" }))
fireEvent.click(screen.getByRole("button", { name: /Upload & Start Grading/ })) fireEvent.click(screen.getByRole("button", { name: /Upload & Start Grading/ }))
...@@ -142,12 +148,14 @@ describe("UploadPage grading method drawer", () => { ...@@ -142,12 +148,14 @@ describe("UploadPage grading method drawer", () => {
{ {
question: "What is RAM?", question: "What is RAM?",
answer: "RAM stores active program data.", answer: "RAM stores active program data.",
score: "2" score: "2",
max_points: "2"
}, },
{ {
question: "What is RAM?", question: "What is RAM?",
answer: "RAM is permanent disk storage.", answer: "RAM is permanent disk storage.",
score: "0" score: "0",
max_points: "2"
} }
] ]
}) })
......
...@@ -32,6 +32,7 @@ interface GradingExampleDraft { ...@@ -32,6 +32,7 @@ interface GradingExampleDraft {
question: string question: string
answer: string answer: string
score: string score: string
maxPoints: string
} }
const GRADING_METHODS: Array<{ const GRADING_METHODS: Array<{
...@@ -68,9 +69,9 @@ const GRADING_METHODS: Array<{ ...@@ -68,9 +69,9 @@ const GRADING_METHODS: Array<{
function createExampleDrafts(): GradingExampleDraft[] { function createExampleDrafts(): GradingExampleDraft[] {
return [ return [
{ question: "", answer: "", score: "" }, { question: "", answer: "", score: "", maxPoints: "" },
{ question: "", answer: "", score: "" }, { question: "", answer: "", score: "", maxPoints: "" },
{ question: "", answer: "", score: "" } { question: "", answer: "", score: "", maxPoints: "" }
] ]
} }
...@@ -171,14 +172,22 @@ function UploadPage() { ...@@ -171,14 +172,22 @@ function UploadPage() {
for (let index = 0; index < activeExampleDrafts.length; index += 1) { for (let index = 0; index < activeExampleDrafts.length; index += 1) {
const example = activeExampleDrafts[index] const example = activeExampleDrafts[index]
if (!example.question.trim() || !example.answer.trim() || !example.score.trim()) { if (!example.question.trim() || !example.answer.trim() || !example.score.trim() || !example.maxPoints.trim()) {
setDrawerError(`Complete question, answer, and score for example ${index + 1}.`) setDrawerError(`Complete question, answer, awarded score, and maximum points for example ${index + 1}.`)
return false return false
} }
if (!isValidScoreText(example.score)) { if (!isValidScoreText(example.score)) {
setDrawerError(`Enter a valid non-negative score for example ${index + 1}.`) setDrawerError(`Enter a valid non-negative score for example ${index + 1}.`)
return false return false
} }
if (!isValidScoreText(example.maxPoints) || Number(example.maxPoints.trim().replace(",", ".")) <= 0) {
setDrawerError(`Enter maximum points greater than 0 for example ${index + 1}.`)
return false
}
if (Number(example.score.trim().replace(",", ".")) > Number(example.maxPoints.trim().replace(",", "."))) {
setDrawerError(`The awarded score cannot exceed maximum points for example ${index + 1}.`)
return false
}
} }
setDrawerError(null) setDrawerError(null)
...@@ -198,7 +207,8 @@ function UploadPage() { ...@@ -198,7 +207,8 @@ function UploadPage() {
options.examples = activeExampleDrafts.map(example => ({ options.examples = activeExampleDrafts.map(example => ({
question: example.question.trim(), question: example.question.trim(),
answer: example.answer.trim(), answer: example.answer.trim(),
score: example.score.trim() score: example.score.trim(),
max_points: example.maxPoints.trim()
})) }))
} }
return options return options
...@@ -883,6 +893,17 @@ function UploadPage() { ...@@ -883,6 +893,17 @@ function UploadPage() {
className="min-h-20 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]" className="min-h-20 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]"
/> />
</label> </label>
<label className="block space-y-1 text-xs font-bold text-[var(--on-primary-fixed-variant)]">
<span>Example {index + 1} maximum points</span>
<input
type="text"
inputMode="decimal"
value={example.maxPoints}
onChange={event => updateExampleDraft(index, "maxPoints", event.target.value)}
placeholder="e.g. 2"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]"
/>
</label>
<label className="block space-y-1 text-xs font-bold text-[var(--on-primary-fixed-variant)]"> <label className="block space-y-1 text-xs font-bold text-[var(--on-primary-fixed-variant)]">
<span>Example {index + 1} awarded score</span> <span>Example {index + 1} awarded score</span>
<input <input
......
...@@ -44,6 +44,7 @@ export interface StartGradingRunOptions { ...@@ -44,6 +44,7 @@ export interface StartGradingRunOptions {
question: string question: string
answer: string answer: string
score: string score: string
max_points: string
}> }>
llm_profile?: LlmProfile llm_profile?: LlmProfile
provider?: string provider?: string
...@@ -389,6 +390,27 @@ export async function getRunStudents( ...@@ -389,6 +390,27 @@ export async function getRunStudents(
return (await response.json()) as RunStudentsResponse return (await response.json()) as RunStudentsResponse
} }
export async function downloadMoodleExport(runId: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/runs/${encodeURIComponent(runId)}/export/moodle`)
if (!response.ok) {
const message = await parseError(response)
throw new Error(`Unable to export results: ${message}`)
}
const disposition = response.headers.get("Content-Disposition") ?? ""
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i)
const filename = filenameMatch?.[1] ?? `moodle_export_${runId}.csv`
const objectUrl = URL.createObjectURL(await response.blob())
const link = document.createElement("a")
link.href = objectUrl
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(objectUrl)
}
export async function getQuestionAnswers( export async function getQuestionAnswers(
runId: string, runId: string,
questionId: number, questionId: number,
......
...@@ -11,7 +11,9 @@ services: ...@@ -11,7 +11,9 @@ services:
NVIDIA_DRIVER_CAPABILITIES: compute,utility NVIDIA_DRIVER_CAPABILITIES: compute,utility
command: command:
- -m - -m
- /models/gemma-4-E4B-it-Q4_K_M.gguf - /models/${LOCAL_LLM_MODEL_FILE:-gemma-4-E4B-it-Q4_K_M.gguf}
- --alias
- ${LOCAL_LLM_MODEL_NAME:-local-gguf-model}
- --host - --host
- 0.0.0.0 - 0.0.0.0
- --port - --port
......
...@@ -5,12 +5,9 @@ services: ...@@ -5,12 +5,9 @@ services:
LOCAL_LLM_BASE_URL: ${HOST_LLM_BASE_URL:-http://host.docker.internal:8080/v1} LOCAL_LLM_BASE_URL: ${HOST_LLM_BASE_URL:-http://host.docker.internal:8080/v1}
LOCAL_LLM_MODEL: ${HOST_LLM_MODEL:-gemma-4-E4B-it-Q4_K_M.gguf} LOCAL_LLM_MODEL: ${HOST_LLM_MODEL:-gemma-4-E4B-it-Q4_K_M.gguf}
LLM_MODEL: ${HOST_LLM_MODEL:-gemma-4-E4B-it-Q4_K_M.gguf} LLM_MODEL: ${HOST_LLM_MODEL:-gemma-4-E4B-it-Q4_K_M.gguf}
extra_hosts:
- host.docker.internal:host-gateway
local-llm: local-llm:
image: busybox:1.36 profiles:
command: - bundled-local-disabled
- sh
- -c
- while true; do sleep 3600; done
ports: []
volumes: []
...@@ -3,53 +3,66 @@ services: ...@@ -3,53 +3,66 @@ services:
build: build:
context: ./asyst-backend context: ./asyst-backend
ports: ports:
- "8000:8000" - "${BACKEND_PORT:-8000}:8000"
environment: environment:
CORS_ORIGINS: http://localhost:5173,http://127.0.0.1:5173 CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173}
DATABASE_URL: sqlite:////data/asyst.db DATABASE_URL: sqlite:////data/asyst.db
UPLOAD_DIR: /data/uploads UPLOAD_DIR: /data/uploads
LLM_PROVIDER: openai_compatible LLM_PROVIDER: openai_compatible
LLM_BASE_URL: http://local-llm:8080/v1 LLM_BASE_URL: http://local-llm:8080/v1
LLM_MODEL: gemma-4-E4B-it-GGUF LLM_MODEL: ${LOCAL_LLM_MODEL_NAME:-local-gguf-model}
LLM_PROMPT_STRATEGY: p1_reference_zero_shot LLM_PROMPT_STRATEGY: p1_reference_zero_shot
LLM_MAX_TOKENS: "1024" LLM_MAX_TOKENS: "1024"
LLM_TEMPERATURE: "0" LLM_TEMPERATURE: "0"
LLM_TIMEOUT_SECONDS: "180" LLM_TIMEOUT_SECONDS: "180"
LLM_RETRY_MAX_ATTEMPTS: "60" LLM_RETRY_MAX_ATTEMPTS: "5"
LLM_RETRY_INITIAL_SECONDS: "5" LLM_RETRY_INITIAL_SECONDS: "3"
LLM_RETRY_MAX_SECONDS: "5" LLM_RETRY_MAX_SECONDS: "30"
LLM_REQUESTS_PER_MINUTE: "10" LLM_REQUESTS_PER_MINUTE: "10"
LLM_API_KEY: local-not-required LLM_API_KEY: local-not-required
LOCAL_LLM_BASE_URL: http://local-llm:8080/v1 LOCAL_LLM_BASE_URL: http://local-llm:8080/v1
LOCAL_LLM_MODEL: gemma-4-E4B-it-GGUF LOCAL_LLM_MODEL: ${LOCAL_LLM_MODEL_NAME:-local-gguf-model}
LOCAL_LLM_API_KEY: local-not-required LOCAL_LLM_API_KEY: local-not-required
CHAT_AI_BASE_URL: https://chat-ai.academiccloud.de/v1 CHAT_AI_BASE_URL: https://chat-ai.academiccloud.de/v1
CHAT_AI_MODEL: gemma-4-31b-it CHAT_AI_MODEL: gemma-4-31b-it
CHAT_AI_API_KEY: ${CHAT_AI_API_KEY:-} CHAT_AI_API_KEY: ${CHAT_AI_API_KEY:-}
volumes: volumes:
- asyst-backend-data:/data - asyst-backend-data:/data
depends_on: healthcheck:
- local-llm test:
- CMD
- python
- -c
- import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
restart: unless-stopped
frontend: frontend:
build: build:
context: ./asyst-ui context: ./asyst-ui
args:
VITE_API_BASE_URL: /api
ports: ports:
- "5173:5173" - "${FRONTEND_PORT:-5173}:80"
environment:
VITE_API_BASE_URL: http://localhost:8000
depends_on: depends_on:
- backend backend:
condition: service_healthy
restart: unless-stopped
local-llm: local-llm:
image: ghcr.io/ggml-org/llama.cpp:server image: ghcr.io/ggml-org/llama.cpp:server
ports: ports:
- "8080:8080" - "${LOCAL_LLM_PORT:-8080}:8080"
volumes: volumes:
- ${LOCAL_LLM_MODEL_DIR:-./models}:/models:ro - ${LOCAL_LLM_MODEL_DIR:-./models}:/models:ro
command: command:
- -m - -m
- /models/gemma-4-E4B-it-Q4_K_M.gguf - /models/${LOCAL_LLM_MODEL_FILE:-gemma-4-E4B-it-Q4_K_M.gguf}
- --alias
- ${LOCAL_LLM_MODEL_NAME:-local-gguf-model}
- --host - --host
- 0.0.0.0 - 0.0.0.0
- --port - --port
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment