Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
backend-test:
name: Backend Tests
Expand Down
6 changes: 4 additions & 2 deletions backend/server/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ class ChoiceQuestion(BaseQuestion):
@classmethod
def check_choice(cls, value: dict[str, object] | list[str]) -> dict[str, object] | list[str]:
labels = list(value)
if any(not label.strip() for label in labels) or len(set(labels)) != len(labels):
normalized = [label.strip() for label in labels]
if any(not label for label in normalized) or len(set(normalized)) != len(normalized):
raise ValueError("choice labels must be nonempty and unique")
return value

Expand All @@ -40,7 +41,8 @@ class ScoreQuestion(BaseQuestion):
@field_validator("criteria")
@classmethod
def check_score(cls, value: list[str]) -> list[str]:
if any(not label.strip() for label in value) or len(set(value)) != len(value):
normalized = [label.strip() for label in value]
if any(not label for label in normalized) or len(set(normalized)) != len(normalized):
raise ValueError("score criteria labels must be nonempty and unique")
return value

Expand Down
4 changes: 3 additions & 1 deletion backend/tests/test_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ def test_question_criteria_validation():
with pytest.raises(ValidationError, match="score criteria labels must be nonempty and unique"):
ScoreQuestion(instructions="Rate", type="score", criteria=["low", " "])

with pytest.raises(ValidationError, match="score criteria labels must be nonempty and unique"):
ScoreQuestion(instructions="Rate", type="score", criteria=["low", " low "])

ChoiceQuestion(instructions="Choose", type="choice", criteria=["option_a", "option_b"])
ChoiceQuestion(instructions="Choose", type="choice", criteria={"option_a": "desc A", "option_b": "desc B"})

Expand All @@ -237,4 +240,3 @@ def test_question_criteria_validation():

with pytest.raises(ValidationError, match="choice labels must be nonempty and unique"):
ChoiceQuestion(instructions="Choose", type="choice", criteria=["opt", " "])

37 changes: 2 additions & 35 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Activity, BookOpen, ChartNoAxesColumn, CircleHelp, Copy, KeyRound, Lang
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { apiErrorMessage, useI18n, type Locale, type MessageKey, type Translate } from "./i18n"
import { Playground, sampleRequest } from "./Playground"

type ApiKey = { id: number; name: string; mask: string; created_at: string; revoked_at: string | null; last_used_at: string | null }
type Usage = { totals: { requests: number; input_tokens: number; output_tokens: number }; daily: { day: string; requests: number; input_tokens: number; output_tokens: number }[]; sources: { source: string; key_id: number | null; requests: number; input_tokens: number; output_tokens: number }[] }
Expand Down Expand Up @@ -115,7 +116,7 @@ function App() {
</aside>
<div className="main">
<header className="topbar"><button className="menu-button" aria-label={t("openNavigation")} onClick={() => setMobileNav(true)}><Menu size={20} /></button><span>{title}</span><div className="topbar-controls"><span className="topbar-note">LAYA SERVER</span><LanguagePicker /></div></header>
<main className="content">{page === "home" ? <Home setPage={setPage} /> : page === "keys" ? <Keys csrf={session.csrf_token} /> : page === "usage" ? <UsagePage /> : page === "playground" ? <Playground csrf={session.csrf_token} /> : <Docs />}</main>
<main className="content">{page === "home" ? <Home setPage={setPage} /> : page === "keys" ? <Keys csrf={session.csrf_token} /> : page === "usage" ? <UsagePage /> : page === "playground" ? <Playground run={body => api("/internal/playground", { method: "POST", body }, session.csrf_token)} /> : <Docs />}</main>
</div>
{mobileNav ? <button className="nav-backdrop" aria-label={t("closeNavigation")} onClick={() => setMobileNav(false)} /> : null}
</div>
Expand Down Expand Up @@ -208,40 +209,6 @@ function UsagePage() {
</>
}

const sampleRequest = { state: { message: "I was charged twice and need a refund today." }, questions: { intent: { type: "choice", instructions: "What does the customer want?", criteria: { refund: "money returned", technical_help: "a technical problem" } }, urgent: { type: "noul", instructions: "Does the customer express urgency?" }, frustration: { type: "score", instructions: "How frustrated is the customer?", criteria: ["calm", "concerned", "very angry"] } }, model: "auto" }
const chineseSampleRequest = { ...sampleRequest, state: { message: "我的账户被重复扣费了,请尽快退款。" }, model: "multilingual" }
const traditionalSampleRequest = { ...sampleRequest, state: { message: "我的帳戶被重複扣款了,請盡快退款。" }, model: "multilingual" }

function Playground({ csrf }: { csrf: string }) {
const { t, locale } = useI18n()
const [body, setBody] = useState(() => JSON.stringify(sampleRequest, null, 2))
const [result, setResult] = useState("")
const [resultError, setResultError] = useState<unknown>(null)
const [busy, setBusy] = useState(false)

async function submit() {
setBusy(true)
setResultError(null)
try {
const data = await api("/internal/playground", { method: "POST", body }, csrf)
setResult(JSON.stringify(data, null, 2))
} catch (cause) {
setResult("")
setResultError(cause)
} finally {
setBusy(false)
}
}

return <>
<PageTitle title={t("playground")} description={t("playgroundDescription")} />
<div className="playground-grid">
<section><div className="section-head"><h2>{t("requestJson")}</h2><div className="sample-actions"><button onClick={() => setBody(JSON.stringify(sampleRequest, null, 2))}>{t("englishExample")}</button><button onClick={() => setBody(JSON.stringify(locale === "zh-TW" ? traditionalSampleRequest : chineseSampleRequest, null, 2))}>{t("chineseExample")}</button></div></div><textarea aria-label={t("requestJson")} value={body} onChange={event => setBody(event.target.value)} spellCheck={false} /><Button onClick={submit} disabled={busy}><Send size={16} /> {busy ? t("runningInference") : t("runInference")}</Button></section>
<section><div className="section-head"><h2>{t("response")}</h2></div><pre className="result">{resultError ? displayError(resultError, t) : result || t("noResult")}</pre></section>
</div>
</>
}

function Docs() {
const { t } = useI18n()
return <>
Expand Down
Loading