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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test:python": "cd packages/math-engine && uv run pytest",
"test": "pnpm test:node && pnpm test:python",
"typecheck": "pnpm -F @openmath/agent typecheck && pnpm -F @openmath/web typecheck",
"sweep:generate": "node scripts/sweep-generate.mjs",
"lint:python": "cd packages/math-engine && uv run ruff check .",
"prepare": "husky"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/prompts/constraint-critic.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ updated: 2026-05-18
`Critique` 형식 JSON:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Prompt behavior changed without bumping the prompt version. This breaks the repo’s prompt-versioning contract and can blur generation metadata/tracking.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/prompts/constraint-critic.md, line 42:

<comment>Prompt behavior changed without bumping the prompt version. This breaks the repo’s prompt-versioning contract and can blur generation metadata/tracking.</comment>

<file context>
@@ -39,6 +39,8 @@ updated: 2026-05-18
 `Critique` 형식 JSON:
 - `passes: boolean`
 - `hints: string[]` — 통과 못 했다면 Refiner가 반영할 *구체적* 힌트
+- JSON 문자열 안에 backslash를 쓰지 말 것. `\sqrt`, `\(`, `\frac` 같은 LaTeX 예시를 쓰면 안 된다.
+- `hints`는 plain Korean 문장으로만 써라. 수식 예시가 필요하면 "제곱근 표기를 JSON 안전한 plain text로 쓰라"처럼 말로 설명하라.
 
</file context>

- `passes: boolean`
- `hints: string[]` — 통과 못 했다면 Refiner가 반영할 *구체적* 힌트
- JSON 문자열 안에 backslash를 쓰지 말 것. `\sqrt`, `\(`, `\frac` 같은 LaTeX 예시를 쓰면 안 된다.
- `hints`는 plain Korean 문장으로만 써라. 수식 예시가 필요하면 "제곱근 표기를 JSON 안전한 plain text로 쓰라"처럼 말로 설명하라.

# Constraints

Expand Down
9 changes: 7 additions & 2 deletions packages/agent/prompts/independent-solver.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ updated: 2026-05-18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Prompt content changed without bumping the prompt version, so downstream prompt metadata will not reflect this new behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/prompts/independent-solver.md, line 24:

<comment>Prompt content changed without bumping the prompt version, so downstream prompt metadata will not reflect this new behavior.</comment>

<file context>
@@ -19,6 +19,10 @@ updated: 2026-05-18
 
+# Generation Kind
+
+{{candidate.generation_kind}}
+
 # Task
</file context>

{{candidate.question_text}}

# Generation Kind

{{candidate.generation_kind}}

# Task

1. 풀이 과정을 단계별로 기술
Expand All @@ -28,12 +32,13 @@ updated: 2026-05-18
# Output

`SolveAttempt` JSON:
- `derived_answer: string` (LaTeX)
- `derived_answer: string` (문제 유형에 맞는 최종 정답. 방정식 해는 `2, -5`, 확률은 `3/8`, 통계값은 `평균 12`, 기하는 `60도`처럼 간결히 쓴다)
- `trace: string`
- `confidence: "high" | "medium" | "low"`

# Constraints

- 원본 풀이를 *모른다* 고 가정. 새로 풀어내는 것이 핵심
- 도구 (계산기) 없이 손풀이 추론으로
- 정답 판단은 시스템(SymPy) 이 한다. 당신은 풀이 trace만 제공 (D-1)
- 정답 판단은 시스템 검증기가 한다. 당신은 풀이 trace만 제공 (D-1)
- JSON 문자열 안에서 raw backslash를 쓰지 말 것. `\(`, `\sqrt`, `\cdot` 같은 LaTeX 명령 대신 `sqrt(7)`, `5*x` 표기를 사용
33 changes: 27 additions & 6 deletions packages/agent/prompts/problem-generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ updated: 2026-05-18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Bump the prompt version when changing this prompt body; leaving version at 0.1.0 makes the prompt metadata stale.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/prompts/problem-generator.md, line 26:

<comment>Bump the prompt version when changing this prompt body; leaving `version` at 0.1.0 makes the prompt metadata stale.</comment>

<file context>
@@ -23,6 +23,26 @@ updated: 2026-05-18
 
 `{{request.mode}}` — `structural` 이면 같은 풀이법, 다른 숫자/표현. `conceptual` 이면 같은 학습 목표·평가 차원, 다른 풀이 경로.
 
+# Generation Kind
+
+`{{generationKind}}`
</file context>

`{{request.mode}}` — `structural` 이면 같은 풀이법, 다른 숫자/표현. `conceptual` 이면 같은 학습 목표·평가 차원, 다른 풀이 경로.

# Generation Kind

`{{generationKind}}`

- `equation`: 등식의 해를 구하는 문제. `question_text`는 SymPy가 풀 수 있는 식을 포함해도 된다.
- `inequality`: 부등식의 해 또는 해집합을 구하는 문제. 등식으로 바꾸지 말 것.
- `system`: 미지수가 둘 이상인 연립방정식 문제. 단일 `x` 방정식으로 축소하지 말 것.
- `expression`: 식의 값, 식의 계산, 소인수분해, 근호 계산, 인수분해처럼 식을 변형하거나 계산하는 문제. 해를 구하는 방정식으로 바꾸지 말 것.
유리수와 순환소수 단원에서는 점을 찍어 순환마디를 고르는 객관식 문제를 피하고, 순환소수를 분수로 바꾸거나 순환마디/소수 전개를 plain text로 답하게 할 것.
- `function`: 함수값, 그래프의 성질, 일차/이차함수 식 찾기 문제. 단순 방정식 풀이로 바꾸지 말 것.
여러 명제의 참/거짓을 모두 고르는 문제는 만들지 말 것. 함수값, 기울기, 절편, 그래프 위의 한 점, 함수식 중 하나를 단일 정답으로 묻는 문제를 우선한다.
일차함수 `y = a*x + b`에서 `a > 0`이면 `x`가 증가할 때 `y`도 증가하고, `a < 0`이면 `y`는 감소한다.
이차함수 그래프 방향은 `위로 열린다`, `아래로 열린다`로 쓰고 `위로 볼록`, `아래로 볼록` 표현은 쓰지 말 것.
`y = a*x**2 + ...`에서 `a > 0`이면 `위로 열린다`, `a < 0`이면 `아래로 열린다`.
- `geometry`: 도형, 삼각비, 원의 성질을 이용하는 문제. 필요한 길이/각/비를 구하게 하되 단원 맥락을 유지할 것.
보이지 않는 그림에 의존하는 복잡한 배치, 내접사각형+접선+중심각처럼 성질이 여러 개 겹치는 새 상황은 피할 것.
그림 없이도 텍스트만으로 결정되는 표준 관계 하나를 묻고, 정답 산출에 필요한 길이·각 조건을 모두 명시할 것.
- `probability`: 경우의 수나 확률을 구하는 문제.
- `statistics`: 자료의 평균, 중앙값, 최빈값, 분산, 표준편차, 도수분포 해석 문제.

# Selected Source Problem

이 문제를 반드시 변형의 기준으로 삼아라: `{{request.source_problem_text}}`
Expand Down Expand Up @@ -57,12 +77,13 @@ updated: 2026-05-18
# Output

JSON으로만 응답.
- `question_text`는 KaTeX가 바로 렌더링할 수 있는 LaTeX `x` 방정식 문자열이어야 한다. `**`, `*`를 쓰지 말고 지수는 `x^{2}`, 곱셈은 `5x`, 제곱근은 `\\sqrt{7}`처럼 쓴다.
- `expected_answer`도 LaTeX로 쓴다. 예: `2, 5`, `\\sqrt{7}, -\\sqrt{7}`.
- 예시 출력: `{ "question_text": "x^{2} - 7x + 10 = 0", "expected_answer": "2, 5", "proposed_solution_trace": "인수분해하면 (x - 2)(x - 5) = 0이므로 x = 2 또는 x = 5이다." }`
- 서버가 SymPy로 다시 계산해 최종 정답을 검증한다.
- `proposed_solution_trace`에 풀이 단계와 출제 의도를 한국어로 명시한다.
- 풀이 가능하고 답이 실수인 중학교 수준 방정식만 생성한다.
- JSON 문자열 안에서 raw backslash를 쓰지 말 것. `\(`, `\sqrt`, `\cdot` 같은 LaTeX 명령을 쓰면 안 된다.
- `question_text`는 `{{generationKind}}` 유형에 맞는 문제 문장이어야 한다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Declare generationKind in the prompt variables list; otherwise the template contract is incomplete and the new placeholder can render empty or be missed by callers/tests.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/prompts/problem-generator.md, line 81:

<comment>Declare `generationKind` in the prompt variables list; otherwise the template contract is incomplete and the new placeholder can render empty or be missed by callers/tests.</comment>

<file context>
@@ -57,12 +77,13 @@ updated: 2026-05-18
-- `proposed_solution_trace`에 풀이 단계와 출제 의도를 한국어로 명시한다.
-- 풀이 가능하고 답이 실수인 중학교 수준 방정식만 생성한다.
+- JSON 문자열 안에서 raw backslash를 쓰지 말 것. `\(`, `\sqrt`, `\cdot` 같은 LaTeX 명령을 쓰면 안 된다.
+- `question_text`는 `{{generationKind}}` 유형에 맞는 문제 문장이어야 한다.
+- 수식은 JSON 안전한 plain text로 쓴다. 지수는 `x**2`, 곱셈은 `5*x`, 제곱근은 `sqrt(7)`처럼 쓴다.
+- 방정식 단원이 아닌데 해를 구하는 `x` 방정식으로 바꾸면 안 된다.
</file context>

- 수식은 JSON 안전한 plain text로 쓴다. 지수는 `x**2`, 곱셈은 `5*x`, 제곱근은 `sqrt(7)`처럼 쓴다.
- 방정식 단원이 아닌데 해를 구하는 `x` 방정식으로 바꾸면 안 된다.
- `expected_answer`는 정답만 간결하게 쓴다. 방정식 해는 `2, 5`, 통계값은 `평균 12`, 확률은 `3/8`, 기하는 `60도`처럼 쓴다.
- 예시 출력: `{ "question_text": "다항식 (x + 3)(x - 5)를 전개하시오.", "expected_answer": "x**2 - 2*x - 15", "proposed_solution_trace": "분배법칙으로 각 항을 곱해 동류항을 정리한다." }`
- `proposed_solution_trace`에 풀이 단계와 출제 의도를 한국어로 명시하되, 수식도 JSON 안전한 plain text 표기만 사용한다.
- 결과 문제는 source problem과 달라야 하며, structural/conceptual 모드 차이가 풀이 설명에 드러나야 한다.

# TODO
Expand Down
222 changes: 222 additions & 0 deletions packages/agent/scripts/judge-quality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import { generateObject } from "ai";
import { z } from "zod";

import { DEFAULT_MODELS } from "../src/config/models.js";
import { loadEnv } from "../src/config/env.js";
import { resolveLanguageModel } from "../src/tools/llm-provider.js";

type Mode = "structural" | "conceptual";
type Status = "PASS" | "WARN" | "FAIL";

type SweepRow = {
readonly grade: number;
readonly code: string;
readonly name: string;
readonly question: string;
readonly answer: string;
readonly status: string;
};

type Topic = {
readonly code: string;
readonly achievement: string;
};

type JudgeRow = SweepRow & {
readonly mode: Mode;
readonly status: Status;
readonly scores: {
readonly math_correctness: number;
readonly topic_fit: number;
readonly isomorphism_fit: number;
readonly clarity: number;
};
readonly reasons: readonly string[];
readonly suggested_revision: string | null;
};

const JudgeSchema = z.object({
status: z.enum(["PASS", "WARN", "FAIL"]),
scores: z.object({
math_correctness: z.number().int().min(1).max(5),
topic_fit: z.number().int().min(1).max(5),
isomorphism_fit: z.number().int().min(1).max(5),
clarity: z.number().int().min(1).max(5),
}),
reasons: z.array(z.string()).max(4),
suggested_revision: z.string().nullable(),
});

const rootDir = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url)))));
const args = parseArgs(process.argv.slice(2));
const topics = await loadTopics();
const env = loadEnv();
const kind = env.LLM_PROVIDER === "cliproxy" ? "openai-compatible" : env.LLM_PROVIDER;
const modelId = env.LLM_MODEL ?? env.CLIPROXY_MODEL ?? env.OPENAI_MODEL ?? DEFAULT_MODELS.generator;
const model = resolveLanguageModel({
kind,
modelId,
baseUrl: env.LLM_BASE_URL ?? env.CLIPROXY_BASE_URL,
apiKey: env.LLM_API_KEY ?? env.CLIPROXY_API_KEY ?? env.OPENAI_API_KEY ?? "openmath-local",
allowedHosts: ["localhost", "127.0.0.1"],
});

const rows = [
...await loadSweep(args.structural, "structural"),
...await loadSweep(args.conceptual, "conceptual"),
];
const judged = await runPool(rows, args.concurrency, (row) => judge(row, topics));
const report = {
generated_at: new Date().toISOString(),
structural_report: args.structural,
conceptual_report: args.conceptual,
total: judged.length,
pass: judged.filter((row) => row.status === "PASS").length,
warn: judged.filter((row) => row.status === "WARN").length,
fail: judged.filter((row) => row.status === "FAIL").length,
rows: judged,
};

const stamp = report.generated_at.replaceAll(":", "").replaceAll(".", "");
const outDir = join(rootDir, "reports/quality");
await mkdir(outDir, { recursive: true });
const jsonPath = join(outDir, `quality-judge-${stamp}.json`);
const mdPath = join(outDir, `quality-judge-${stamp}.md`);
await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await writeFile(mdPath, renderMarkdown(report.rows));
console.log(`JUDGE PASS ${report.pass}/${report.total} WARN ${report.warn} FAIL ${report.fail}`);
console.log(mdPath);
console.log(jsonPath);
if (report.fail > 0) process.exitCode = 1;

function parseArgs(argv: readonly string[]): { readonly structural: string; readonly conceptual: string; readonly concurrency: number } {
let structural = "";
let conceptual = "";
let concurrency = 3;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
if (arg === "--structural" && next !== undefined) {
structural = resolve(rootDir, next);
index += 1;
} else if (arg === "--conceptual" && next !== undefined) {
conceptual = resolve(rootDir, next);
index += 1;
} else if (arg === "--concurrency" && next !== undefined) {
concurrency = Number.parseInt(next, 10);
index += 1;
} else {
throw new Error(`Unknown argument: ${arg ?? ""}`);
}
}
if (structural.length === 0 || conceptual.length === 0) throw new Error("--structural and --conceptual are required");
if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 8) throw new Error("--concurrency must be between 1 and 8");
return { structural, conceptual, concurrency };
}

async function loadTopics(): Promise<ReadonlyMap<string, Topic>> {
const source = await readFile(join(rootDir, "packages/web/app/app/new/topic/data.ts"), "utf8");
const pattern = /\{\s*grade:\s*(?:1|2|3),\s*category:\s*"[^"]+",\s*code:\s*"([^"]+)",\s*name:\s*"[^"]+",\s*achievement:\s*"([^"]+)",\s*\}/gms;
return new Map(Array.from(source.matchAll(pattern)).map((match) => [
match[1] ?? "",
{ code: match[1] ?? "", achievement: (match[2] ?? "").replace(/\s+/g, " ").trim() },
]));
}

async function loadSweep(path: string, mode: Mode): Promise<readonly (SweepRow & { readonly mode: Mode })[]> {
const parsed = JSON.parse(await readFile(path, "utf8")) as { readonly rows?: readonly SweepRow[] };
const rows = parsed.rows ?? [];
if (rows.length !== 30) throw new Error(`${path} must contain 30 rows`);
return rows.map((row) => ({ ...row, mode }));
}

async function judge(row: SweepRow & { readonly mode: Mode }, topics: ReadonlyMap<string, Topic>): Promise<JudgeRow> {
const topic = topics.get(row.code);
for (let attempt = 1; attempt <= 2; attempt += 1) {
try {
const { object } = await generateObject({
model,
schema: JudgeSchema,
mode: "json",
temperature: 0,
prompt: renderPrompt(row, topic?.achievement ?? "", attempt),
});
return { ...row, ...object };
} catch (error) {
if (attempt === 2) {
return fallbackJudge(row, error);
}
}
}
return fallbackJudge(row, new Error("unreachable judge retry state"));
}

function renderPrompt(row: SweepRow & { readonly mode: Mode }, achievement: string, attempt: number): string {
return [
"너는 한국 중학교 수학 출제 품질을 엄격히 검수하는 교사다.",
"문항을 PASS/WARN/FAIL로 판정하라.",
"PASS는 바로 학생에게 제공 가능, WARN은 사용 가능하지만 개선 권장, FAIL은 오답/단원 불일치/동형성 실패/애매한 문항이다.",
"구조동형은 풀이 구조가 같은지, 개념동형은 같은 성취기준 안에서 상황/표현/풀이 관점이 충분히 바뀌었는지 평가하라.",
"자동 검증은 이미 통과했으므로, 교육 품질과 문장 명확성에 집중하라.",
"JSON 문자열에는 백슬래시, LaTeX, \\( \\), \\sqrt 같은 표기를 절대 쓰지 말고 plain Korean text만 써라.",
attempt === 1 ? "" : "이전 응답은 JSON 파싱에 실패했다. 이번에는 reasons와 suggested_revision에 백슬래시를 절대 넣지 마라.",
"",
`학년: ${row.grade}`,
`단원: ${row.code} ${row.name}`,
`성취기준: ${achievement}`,
`모드: ${row.mode}`,
`문제: ${row.question}`,
`정답: ${row.answer}`,
].join("\n");
}

function fallbackJudge(row: SweepRow & { readonly mode: Mode }, error: unknown): JudgeRow {
const message = error instanceof Error ? error.message : String(error);
return {
...row,
status: "WARN",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Judge parse/runtime failures are downgraded to WARN, so a broken audit run can still exit 0 and look successful.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/scripts/judge-quality.ts, line 181:

<comment>Judge parse/runtime failures are downgraded to WARN, so a broken audit run can still exit 0 and look successful.</comment>

<file context>
@@ -0,0 +1,222 @@
+  const message = error instanceof Error ? error.message : String(error);
+  return {
+    ...row,
+    status: "WARN",
+    scores: { math_correctness: 3, topic_fit: 3, isomorphism_fit: 3, clarity: 3 },
+    reasons: [`LLM judge parse failed: ${message.slice(0, 120)}`],
</file context>

scores: { math_correctness: 3, topic_fit: 3, isomorphism_fit: 3, clarity: 3 },
reasons: [`LLM judge parse failed: ${message.slice(0, 120)}`],
suggested_revision: "Judge 응답 파싱 실패. 사람이 직접 확인해야 합니다.",
};
}

async function runPool<T, R>(items: readonly T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> {
const results: R[] = [];
let cursor = 0;
async function runNext(): Promise<void> {
while (cursor < items.length) {
const current = cursor;
cursor += 1;
results[current] = await worker(items[current] as T);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runNext()));
return results;
}

function renderMarkdown(rows: readonly JudgeRow[]): string {
const lines = [
"# OpenMath LLM Quality Judge",
"",
"| Grade | Code | Topic | Mode | Status | Scores | Reasons | Suggested Revision | Question | Answer |",
"|---:|---|---|---|---|---|---|---|---|---|",
];
for (const row of rows) {
lines.push(`| ${row.grade} | ${row.code} | ${escapeMd(row.name)} | ${row.mode} | ${row.status} | ${scoreText(row)} | ${escapeMd(row.reasons.join("; "))} | ${escapeMd(row.suggested_revision ?? "")} | ${escapeMd(row.question)} | ${escapeMd(row.answer)} |`);
}
return `${lines.join("\n")}\n`;
}

function scoreText(row: JudgeRow): string {
const s = row.scores;
return `math ${s.math_correctness}, topic ${s.topic_fit}, iso ${s.isomorphism_fit}, clarity ${s.clarity}`;
}

function escapeMd(value: string): string {
return value.replaceAll("|", "\\|").replaceAll("\n", " ").slice(0, 260);
}
41 changes: 33 additions & 8 deletions packages/agent/src/agents/constraint-critic-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,39 @@ export function createConstraintCriticAgent(
intent: input.intent,
strategy: input.strategy === null ? "" : JSON.stringify(input.strategy, null, 2),
});
const { object } = await generateObject({
model: deps.model,
schema: CritiqueSchema,
mode: "json",
temperature: prompt.metadata.temperature,
prompt: rendered,
});
return object;
try {
const { object } = await generateObject({
model: deps.model,
schema: CritiqueSchema,
mode: "json",
temperature: prompt.metadata.temperature,
prompt: rendered,
});
return object;
} catch (error) {
const text = modelTextFromError(error);
if (text !== null) {
return parseCritiqueJson(text);
}
throw error;
}
},
};
}

export function parseCritiqueJson(text: string): Critique {
return CritiqueSchema.parse(JSON.parse(escapeRawBackslashes(text)));
}

function escapeRawBackslashes(text: string): string {
return text.replace(/\\(?!["\\/bfnrtu])/g, "\\\\");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Backslash-repair regex over-escapes already-valid \\ sequences, which can turn valid critique JSON into invalid JSON and break fallback parsing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/src/agents/constraint-critic-agent.ts, line 71:

<comment>Backslash-repair regex over-escapes already-valid `\\` sequences, which can turn valid critique JSON into invalid JSON and break fallback parsing.</comment>

<file context>
@@ -43,14 +43,39 @@ export function createConstraintCriticAgent(
+}
+
+function escapeRawBackslashes(text: string): string {
+  return text.replace(/\\(?!["\\/bfnrtu])/g, "\\\\");
+}
+
</file context>
Suggested change
return text.replace(/\\(?!["\\/bfnrtu])/g, "\\\\");
return text.replace(/(^|[^\\])\\(?!["\\/bfnrtu])/g, "$1\\\\");

}

function modelTextFromError(error: unknown): string | null {
if (!hasTextProperty(error)) return null;
return typeof error.text === "string" ? error.text : null;
}

function hasTextProperty(value: unknown): value is { readonly text?: unknown } {
return typeof value === "object" && value !== null && "text" in value;
}
Loading
Loading