mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
fix(dataset): align validation with the platform data-format rules doc
Reviewed against the official text-tuning data rules; fixes two confirmed mismatches and fills enforcement gaps: - thinking: exempt assistant messages carrying tool_calls from the THINK_TAG_NOT_LAST check — the spec's tool+thinking combo example puts <think> on a non-last assistant and was previously false-flagged - DPO support matrix: reject image/video content items, tools, tool_calls and role:tool (DPO_UNSUPPORTED_ELEMENT); also scan chosen/rejected - DPO: messages not ending with user upgraded warning -> error - OpenAI migration: name/weight upgraded warning -> error (spec: must not carry); drop dead record-level name branch - tool_call_id: unmatched tool response upgraded warning -> error (one-to-one per spec); new TOOL_CALL_NO_RESPONSE warning for orphan calls - loss_weight: validate range at message level too; warn when placed on anything but the last assistant message (LOSS_WEIGHT_PLACEMENT) - video params: fps/sample_fps must be within [0.1, 10] (INVALID_VIDEO_FPS); mode-mismatched params warned (VIDEO_PARAM_MODE_MISMATCH); video_start/video_end type-checked - zip: skip macOS packaging metadata (__MACOSX/, .DS_Store, ._*) in filename constraints and image counting to stop false failures on Finder-created archives Tests 45 -> 59 covering every new/changed rule, including a replica of the spec's official tool+thinking example.
This commit is contained in:
@@ -20,6 +20,69 @@ import type { RecordSchemaSpec } from "./types.ts";
|
||||
|
||||
const VALID_ROLES = new Set(["system", "user", "assistant", "tool"]);
|
||||
|
||||
/** Platform bounds for video sampling rate params (`fps` / `sample_fps`). */
|
||||
const VIDEO_FPS_MIN = 0.1;
|
||||
const VIDEO_FPS_MAX = 10;
|
||||
|
||||
/**
|
||||
* Validate the sampling/clipping params carried by a video content item.
|
||||
* Mode rules (platform spec):
|
||||
* - path mode (video: string): `fps`, `video_start`, `video_end` allowed; `sample_fps` is not
|
||||
* - frame-list mode (video: string[]): `sample_fps` allowed; `fps` / `video_start` / `video_end` are not
|
||||
* `fps` / `sample_fps` must be numbers within [0.1, 10] when present.
|
||||
*/
|
||||
function inspectVideoParams(
|
||||
item: Record<string, unknown>,
|
||||
isFrameList: boolean,
|
||||
lineNo: number,
|
||||
itemPath: string,
|
||||
): ValidationIssue[] {
|
||||
const out: ValidationIssue[] = [];
|
||||
const checkFpsRange = (field: "fps" | "sample_fps"): void => {
|
||||
if (!(field in item)) return;
|
||||
const value = item[field];
|
||||
if (typeof value !== "number" || value < VIDEO_FPS_MIN || value > VIDEO_FPS_MAX) {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"error",
|
||||
"INVALID_VIDEO_FPS",
|
||||
`"${field}" must be a number between ${VIDEO_FPS_MIN} and ${VIDEO_FPS_MAX} (got ${JSON.stringify(value)}).`,
|
||||
{ line: lineNo, path: `${itemPath}.${field}` },
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
checkFpsRange("fps");
|
||||
checkFpsRange("sample_fps");
|
||||
|
||||
const wrongModeFields = isFrameList ? ["fps", "video_start", "video_end"] : ["sample_fps"];
|
||||
const modeName = isFrameList ? "frame-list" : "file-path";
|
||||
for (const field of wrongModeFields) {
|
||||
if (field in item) {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"warning",
|
||||
"VIDEO_PARAM_MODE_MISMATCH",
|
||||
`"${field}" does not apply to ${modeName} video mode and will be ignored by the platform.`,
|
||||
{ line: lineNo, path: `${itemPath}.${field}` },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of ["video_start", "video_end"] as const) {
|
||||
if (field in item && !isFrameList && typeof item[field] !== "number") {
|
||||
out.push(
|
||||
makeIssue("error", "INVALID_VIDEO_CLIP_TIME", `"${field}" must be a number (seconds).`, {
|
||||
line: lineNo,
|
||||
path: `${itemPath}.${field}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a content field that may be:
|
||||
* - A plain string (legacy format)
|
||||
@@ -112,19 +175,22 @@ export function inspectContentField(
|
||||
{ line: lineNo, path: `${itemPath}.video` },
|
||||
),
|
||||
);
|
||||
} else if (Array.isArray(video)) {
|
||||
for (let frameIdx = 0; frameIdx < video.length; frameIdx++) {
|
||||
if (typeof video[frameIdx] !== "string") {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"error",
|
||||
"INVALID_VIDEO_FRAME",
|
||||
`Video frame list item at index ${frameIdx} must be a string.`,
|
||||
{ line: lineNo, path: `${itemPath}.video[${frameIdx}]` },
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (Array.isArray(video)) {
|
||||
for (let frameIdx = 0; frameIdx < video.length; frameIdx++) {
|
||||
if (typeof video[frameIdx] !== "string") {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"error",
|
||||
"INVALID_VIDEO_FRAME",
|
||||
`Video frame list item at index ${frameIdx} must be a string.`,
|
||||
{ line: lineNo, path: `${itemPath}.video[${frameIdx}]` },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(...inspectVideoParams(obj, Array.isArray(video), lineNo, itemPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,13 +349,13 @@ export function inspectMessageObject(
|
||||
out.push(...inspectToolCalls(record.tool_calls, lineNo, `${path}.tool_calls`));
|
||||
}
|
||||
|
||||
// OpenAI migration guard: name / weight are not supported by Bailian
|
||||
// OpenAI migration guard: the platform rejects data carrying name / weight
|
||||
if ("name" in record) {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"warning",
|
||||
"error",
|
||||
"UNSUPPORTED_FIELD_NAME",
|
||||
`Field "name" is not supported by Bailian. Remove it when migrating from OpenAI/Azure.`,
|
||||
`Field "name" is not supported by Bailian and must be removed when migrating from OpenAI/Azure.`,
|
||||
{ line: lineNo, path: `${path}.name` },
|
||||
),
|
||||
);
|
||||
@@ -297,9 +363,10 @@ export function inspectMessageObject(
|
||||
if ("weight" in record) {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"warning",
|
||||
"error",
|
||||
"UNSUPPORTED_FIELD_WEIGHT",
|
||||
`Field "weight" is not supported by Bailian. Remove it when migrating from OpenAI/Azure.`,
|
||||
`Field "weight" is not supported by Bailian and must be removed when migrating from OpenAI/Azure. ` +
|
||||
`All assistant outputs are trained; per-line importance uses "loss_weight" (invite-only).`,
|
||||
{ line: lineNo, path: `${path}.weight` },
|
||||
),
|
||||
);
|
||||
@@ -419,12 +486,15 @@ export function inspectChatMLRecord(
|
||||
);
|
||||
}
|
||||
|
||||
// tool_call_id correspondence: every tool response should reference a known call id
|
||||
// tool_call_id correspondence must be one-to-one (platform spec):
|
||||
// every tool response must reference a known call id (hard error), and every
|
||||
// tool_call should receive a response (advisory — trailing calls are dubious
|
||||
// in training data but we cannot rule out platform-side tolerance).
|
||||
for (const responseId of toolResponseIds) {
|
||||
if (!toolCallIds.has(responseId)) {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"warning",
|
||||
"error",
|
||||
"TOOL_CALL_ID_UNMATCHED",
|
||||
`tool message references tool_call_id "${responseId}" which does not match any assistant tool_calls[].id.`,
|
||||
{ line: lineNo, path: "messages" },
|
||||
@@ -432,20 +502,37 @@ export function inspectChatMLRecord(
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const callId of toolCallIds) {
|
||||
if (!toolResponseIds.has(callId)) {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"warning",
|
||||
"TOOL_CALL_NO_RESPONSE",
|
||||
`assistant tool_calls[].id "${callId}" has no matching tool response message.`,
|
||||
{ line: lineNo, path: "messages" },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// thinking tag check: <think>…</think>` should only appear in the last assistant message
|
||||
// thinking tag check: <think>…</think> should only appear in the last
|
||||
// assistant message. Exemption (platform spec, tool+thinking combo): an
|
||||
// assistant that carries tool_calls may legitimately hold a <think> block
|
||||
// even when it is not the last assistant message.
|
||||
if (lastAssistantIdx >= 0) {
|
||||
for (let idx = 0; idx < messages.length; idx++) {
|
||||
if (idx === lastAssistantIdx) continue;
|
||||
const msg = messages[idx] as Record<string, unknown> | null;
|
||||
if (msg?.role !== "assistant") continue;
|
||||
if (msg && Array.isArray(msg.tool_calls)) continue;
|
||||
const content = msg?.content;
|
||||
if (contentHasThinkTag(content)) {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"warning",
|
||||
"THINK_TAG_NOT_LAST",
|
||||
`Thinking tags (<think>…</think>) should only appear in the last assistant message, found at messages[${idx}].`,
|
||||
`Thinking tags (<think>…</think>) should only appear in the last assistant message ` +
|
||||
`(or an assistant message carrying tool_calls), found at messages[${idx}].`,
|
||||
{ line: lineNo, path: `messages[${idx}].content` },
|
||||
),
|
||||
);
|
||||
@@ -453,39 +540,50 @@ export function inspectChatMLRecord(
|
||||
}
|
||||
}
|
||||
|
||||
// loss_weight validation (record-level, invite-only parameter)
|
||||
if ("loss_weight" in record) {
|
||||
const lossWeight = record.loss_weight;
|
||||
if (typeof lossWeight !== "number" || lossWeight < 0 || lossWeight > 1) {
|
||||
// loss_weight validation (invite-only parameter).
|
||||
// Range is enforced wherever the field appears (record level and message
|
||||
// level); placement follows the spec: only the LAST assistant message line
|
||||
// supports loss_weight — misplaced occurrences are advisory (invite-only
|
||||
// semantics are account-specific, so we do not hard-fail).
|
||||
const checkLossWeightRange = (value: unknown, path: string): void => {
|
||||
if (typeof value !== "number" || value < 0 || value > 1) {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"error",
|
||||
"INVALID_LOSS_WEIGHT",
|
||||
`"loss_weight" must be a number between 0.0 and 1.0 (got ${JSON.stringify(lossWeight)}).`,
|
||||
{ line: lineNo, path: "loss_weight" },
|
||||
`"loss_weight" must be a number between 0.0 and 1.0 (got ${JSON.stringify(value)}).`,
|
||||
{ line: lineNo, path },
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
if ("loss_weight" in record) {
|
||||
checkLossWeightRange(record.loss_weight, "loss_weight");
|
||||
}
|
||||
for (let idx = 0; idx < messages.length; idx++) {
|
||||
const msg = messages[idx] as Record<string, unknown> | null;
|
||||
if (!msg || !("loss_weight" in msg)) continue;
|
||||
checkLossWeightRange(msg.loss_weight, `messages[${idx}].loss_weight`);
|
||||
if (!(msg.role === "assistant" && idx === lastAssistantIdx)) {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"warning",
|
||||
"LOSS_WEIGHT_PLACEMENT",
|
||||
`"loss_weight" is only supported on the last assistant message; found at messages[${idx}] (role "${String(msg.role)}").`,
|
||||
{ line: lineNo, path: `messages[${idx}].loss_weight` },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI migration guard at record level
|
||||
if ("name" in record && !("messages" in record)) {
|
||||
// Only warn at record level if it's not inside messages (messages handled above)
|
||||
out.push(
|
||||
makeIssue(
|
||||
"warning",
|
||||
"UNSUPPORTED_FIELD_NAME",
|
||||
`Record-level field "name" is not supported by Bailian.`,
|
||||
{ line: lineNo, path: "name" },
|
||||
),
|
||||
);
|
||||
}
|
||||
// OpenAI migration guard at record level (message-level occurrences are
|
||||
// handled by inspectMessageObject above)
|
||||
if ("weight" in record) {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"warning",
|
||||
"error",
|
||||
"UNSUPPORTED_FIELD_WEIGHT",
|
||||
`Record-level field "weight" is not supported by Bailian. Remove it when migrating from OpenAI/Azure.`,
|
||||
`Record-level field "weight" is not supported by Bailian and must be removed when migrating from OpenAI/Azure.`,
|
||||
{ line: lineNo, path: "weight" },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -18,16 +18,70 @@ function inspectDPORecord(record: Record<string, unknown>, lineNo: number): Vali
|
||||
const messages = record.messages;
|
||||
if (!Array.isArray(messages) || messages.length === 0) return out;
|
||||
|
||||
// DPO convention: messages should end with a user message (the prompt that
|
||||
// chosen/rejected respond to). If the last message is assistant, it's likely
|
||||
// a structural mistake.
|
||||
/** image / video content items are outside the DPO support matrix */
|
||||
const mediaIssues = (content: unknown, basePath: string): ValidationIssue[] => {
|
||||
if (!Array.isArray(content)) return [];
|
||||
const found: ValidationIssue[] = [];
|
||||
for (let itemIdx = 0; itemIdx < content.length; itemIdx++) {
|
||||
const item = content[itemIdx] as Record<string, unknown> | null;
|
||||
if (!item || typeof item !== "object") continue;
|
||||
for (const mediaField of ["image", "video"] as const) {
|
||||
if (mediaField in item) {
|
||||
found.push(
|
||||
makeIssue(
|
||||
"error",
|
||||
"DPO_UNSUPPORTED_ELEMENT",
|
||||
`DPO training data does not support ${mediaField} inputs; found at ${basePath}.content[${itemIdx}].`,
|
||||
{ line: lineNo, path: `${basePath}.content[${itemIdx}].${mediaField}` },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
// Support matrix (platform spec): DPO is text + thinking ONLY — no image /
|
||||
// video inputs and no tool calling. Reject multimodal items and tool fields
|
||||
// that the SFT-oriented ChatML inspector would otherwise accept.
|
||||
if ("tools" in record) {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"error",
|
||||
"DPO_UNSUPPORTED_ELEMENT",
|
||||
`DPO training data does not support tool calling; remove the "tools" definition.`,
|
||||
{ line: lineNo, path: "tools" },
|
||||
),
|
||||
);
|
||||
}
|
||||
for (let idx = 0; idx < messages.length; idx++) {
|
||||
const msg = messages[idx] as Record<string, unknown> | null;
|
||||
if (!msg) continue;
|
||||
const msgPath = `messages[${idx}]`;
|
||||
if (msg.role === "tool" || "tool_calls" in msg) {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"error",
|
||||
"DPO_UNSUPPORTED_ELEMENT",
|
||||
`DPO training data does not support tool calling; found ${
|
||||
msg.role === "tool" ? `role "tool"` : `"tool_calls"`
|
||||
} at ${msgPath}.`,
|
||||
{ line: lineNo, path: msgPath },
|
||||
),
|
||||
);
|
||||
}
|
||||
out.push(...mediaIssues(msg.content, msgPath));
|
||||
}
|
||||
|
||||
// DPO trains the preference for the LAST user input — messages ending with
|
||||
// any other role make the chosen/rejected pair semantically meaningless.
|
||||
const lastMsg = messages[messages.length - 1] as Record<string, unknown> | null;
|
||||
if (lastMsg && lastMsg.role !== "user") {
|
||||
out.push(
|
||||
makeIssue(
|
||||
"warning",
|
||||
"error",
|
||||
"DPO_LAST_MSG_NOT_USER",
|
||||
`DPO "messages" should end with a "user" message (the prompt for chosen/rejected). ` +
|
||||
`DPO "messages" must end with a "user" message (the prompt for chosen/rejected). ` +
|
||||
`Got "${String(lastMsg.role)}" as the last message.`,
|
||||
{ line: lineNo, path: `messages[${messages.length - 1}].role` },
|
||||
),
|
||||
@@ -55,6 +109,7 @@ function inspectDPORecord(record: Record<string, unknown>, lineNo: number): Vali
|
||||
}
|
||||
if (hasChosen) {
|
||||
out.push(...inspectMessageObject(record.chosen, lineNo, "chosen"));
|
||||
out.push(...mediaIssues((record.chosen as Record<string, unknown> | null)?.content, "chosen"));
|
||||
const role = (record.chosen as Record<string, unknown> | null)?.role;
|
||||
if (typeof role === "string" && role !== "assistant") {
|
||||
out.push(
|
||||
@@ -69,6 +124,9 @@ function inspectDPORecord(record: Record<string, unknown>, lineNo: number): Vali
|
||||
}
|
||||
if (hasRejected) {
|
||||
out.push(...inspectMessageObject(record.rejected, lineNo, "rejected"));
|
||||
out.push(
|
||||
...mediaIssues((record.rejected as Record<string, unknown> | null)?.content, "rejected"),
|
||||
);
|
||||
const role = (record.rejected as Record<string, unknown> | null)?.role;
|
||||
if (typeof role === "string" && role !== "assistant") {
|
||||
out.push(
|
||||
|
||||
@@ -108,18 +108,36 @@ function basenameNoExt(segment: string): string {
|
||||
return dot > 0 ? segment.slice(0, dot) : segment;
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS Finder/zip metadata entries (`__MACOSX/` resource forks, `.DS_Store`,
|
||||
* AppleDouble `._*` files). They are packaging noise, not training data:
|
||||
* exclude them from filename constraints and media counting so Mac-created
|
||||
* archives don't fail on artifacts the user never sees.
|
||||
*/
|
||||
function isZipMetadataEntry(entry: string): boolean {
|
||||
if (entry === "__MACOSX" || entry.startsWith("__MACOSX/")) return true;
|
||||
const lastSegment =
|
||||
entry
|
||||
.split("/")
|
||||
.filter((segment) => segment.length > 0)
|
||||
.pop() ?? "";
|
||||
return lastSegment === ".DS_Store" || lastSegment.startsWith("._");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate ZIP entry filenames against platform constraints.
|
||||
* Returns issues for charset violations, over-length names, and duplicates.
|
||||
* Exported for direct unit testing (not re-exported by the barrel).
|
||||
*/
|
||||
function validateZipFilenames(entries: string[]): ValidationIssue[] {
|
||||
export function validateZipFilenames(entries: string[]): ValidationIssue[] {
|
||||
const out: ValidationIssue[] = [];
|
||||
const seenBasenames = new Map<string, string>(); // basename (no ext) → first full path
|
||||
const MAX_REPORTED = 10;
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip directory entries
|
||||
// Skip directory entries and macOS packaging metadata
|
||||
if (entry.endsWith("/")) continue;
|
||||
if (isZipMetadataEntry(entry)) continue;
|
||||
|
||||
// Check each path segment (folder names + file name)
|
||||
const segments = entry.split("/").filter((s) => s.length > 0);
|
||||
@@ -383,6 +401,7 @@ export const zipValidator: ValidatorSpec = {
|
||||
const imageFiles = entries.filter((entry) => {
|
||||
if (entry === "data.jsonl" || entry.endsWith("/data.jsonl")) return false;
|
||||
if (entry.endsWith("/")) return false; // directory entries
|
||||
if (isZipMetadataEntry(entry)) return false; // __MACOSX/._x.jpg is not an image
|
||||
const dot = entry.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? entry.slice(dot).toLowerCase() : "";
|
||||
return IMAGE_EXTENSIONS.has(ext);
|
||||
|
||||
@@ -106,13 +106,41 @@ describe("validateDataset — DPO schema", () => {
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("DPO messages ending with assistant → DPO_LAST_MSG_NOT_USER warning", async () => {
|
||||
test("DPO messages ending with assistant → DPO_LAST_MSG_NOT_USER error", async () => {
|
||||
const p = file("dpo_last_asst.jsonl", [
|
||||
'{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"yo"}],"chosen":{"role":"assistant","content":"good"},"rejected":{"role":"assistant","content":"bad"}}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true, schema: "dpo" });
|
||||
expect(r.valid).toBe(true);
|
||||
expect(codes(r).warnings).toContain("DPO_LAST_MSG_NOT_USER");
|
||||
expect(r.valid).toBe(false);
|
||||
expect(codes(r).errors).toContain("DPO_LAST_MSG_NOT_USER");
|
||||
});
|
||||
|
||||
test("DPO with image content item → DPO_UNSUPPORTED_ELEMENT error", async () => {
|
||||
const p = file("dpo_image.jsonl", [
|
||||
'{"messages":[{"role":"user","content":[{"text":"look"},{"image":"a.jpg"}]}],"chosen":{"role":"assistant","content":[{"text":"good"}]},"rejected":{"role":"assistant","content":[{"text":"bad"}]}}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true, schema: "dpo" });
|
||||
expect(r.valid).toBe(false);
|
||||
expect(codes(r).errors).toContain("DPO_UNSUPPORTED_ELEMENT");
|
||||
});
|
||||
|
||||
test("DPO with tools / tool_calls → DPO_UNSUPPORTED_ELEMENT error", async () => {
|
||||
const p = file("dpo_tools.jsonl", [
|
||||
'{"tools":[{"type":"function","function":{"name":"f","parameters":{}}}],"messages":[{"role":"user","content":"hi"}],"chosen":{"role":"assistant","content":"good"},"rejected":{"role":"assistant","content":"bad"}}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true, schema: "dpo" });
|
||||
expect(r.valid).toBe(false);
|
||||
expect(codes(r).errors).toContain("DPO_UNSUPPORTED_ELEMENT");
|
||||
});
|
||||
|
||||
test("DPO chosen carrying an image item → DPO_UNSUPPORTED_ELEMENT error", async () => {
|
||||
const p = file("dpo_chosen_image.jsonl", [
|
||||
'{"messages":[{"role":"user","content":"hi"}],"chosen":{"role":"assistant","content":[{"text":"good"},{"image":"x.png"}]},"rejected":{"role":"assistant","content":"bad"}}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true, schema: "dpo" });
|
||||
expect(r.valid).toBe(false);
|
||||
const err = r.errors.find((e) => e.code === "DPO_UNSUPPORTED_ELEMENT");
|
||||
expect(err?.path).toContain("chosen");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,13 +325,23 @@ describe("validateDataset — tool calling (function calling)", () => {
|
||||
expect(codes(r).errors).toContain("TOOL_MISSING_CALL_ID");
|
||||
});
|
||||
|
||||
test("tool_call_id unmatched → TOOL_CALL_ID_UNMATCHED warning", async () => {
|
||||
test("tool_call_id unmatched → TOOL_CALL_ID_UNMATCHED error", async () => {
|
||||
const p = file("tool_unmatched.jsonl", [
|
||||
'{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]},{"role":"tool","tool_call_id":"WRONG_ID","content":"result"},{"role":"assistant","content":"done"}]}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(r.valid).toBe(true);
|
||||
expect(codes(r).warnings).toContain("TOOL_CALL_ID_UNMATCHED");
|
||||
expect(r.valid).toBe(false);
|
||||
expect(codes(r).errors).toContain("TOOL_CALL_ID_UNMATCHED");
|
||||
// The orphaned call side is advisory
|
||||
expect(codes(r).warnings).toContain("TOOL_CALL_NO_RESPONSE");
|
||||
});
|
||||
|
||||
test("tool_calls without a tool response → TOOL_CALL_NO_RESPONSE warning", async () => {
|
||||
const p = file("tool_no_resp.jsonl", [
|
||||
'{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]},{"role":"assistant","content":"done"}]}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(codes(r).warnings).toContain("TOOL_CALL_NO_RESPONSE");
|
||||
});
|
||||
|
||||
test("tool_calls with missing function name → TOOL_CALL_FN_NO_NAME", async () => {
|
||||
@@ -352,34 +390,68 @@ describe("validateDataset — thinking tags", () => {
|
||||
expect(r.valid).toBe(true);
|
||||
expect(codes(r).warnings).toContain("THINK_TAG_NOT_LAST");
|
||||
});
|
||||
|
||||
test("official tool+thinking combo: think in non-last assistant WITH tool_calls is exempt", async () => {
|
||||
// Mirrors the platform spec's 工具与思考组合 example: the assistant that
|
||||
// issues tool_calls carries the <think> block, the final assistant answers.
|
||||
const p = file("think_tool_combo.jsonl", [
|
||||
JSON.stringify({
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "get_weather", description: "d", parameters: { type: "object" } },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: [{ text: "weather in Beijing?" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ text: "<think>\nneed the weather tool\n</think>\n" }],
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "get_weather", arguments: '{"city":"Beijing"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: [{ text: '{"weather":"sunny"}' }] },
|
||||
{ role: "assistant", content: [{ text: "It is sunny in Beijing." }] },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(r.valid).toBe(true);
|
||||
expect(codes(r).warnings).not.toContain("THINK_TAG_NOT_LAST");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateDataset — OpenAI migration guards", () => {
|
||||
test("message-level name field → UNSUPPORTED_FIELD_NAME warning", async () => {
|
||||
test("message-level name field → UNSUPPORTED_FIELD_NAME error", async () => {
|
||||
const p = file("openai_name.jsonl", [
|
||||
'{"messages":[{"role":"user","content":"hi","name":"alice"},{"role":"assistant","content":"hello"}]}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(r.valid).toBe(true);
|
||||
expect(codes(r).warnings).toContain("UNSUPPORTED_FIELD_NAME");
|
||||
expect(r.valid).toBe(false);
|
||||
expect(codes(r).errors).toContain("UNSUPPORTED_FIELD_NAME");
|
||||
});
|
||||
|
||||
test("message-level weight field → UNSUPPORTED_FIELD_WEIGHT warning", async () => {
|
||||
test("message-level weight field → UNSUPPORTED_FIELD_WEIGHT error", async () => {
|
||||
const p = file("openai_weight.jsonl", [
|
||||
'{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello","weight":0.5}]}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(r.valid).toBe(true);
|
||||
expect(codes(r).warnings).toContain("UNSUPPORTED_FIELD_WEIGHT");
|
||||
expect(r.valid).toBe(false);
|
||||
expect(codes(r).errors).toContain("UNSUPPORTED_FIELD_WEIGHT");
|
||||
});
|
||||
|
||||
test("record-level weight field → UNSUPPORTED_FIELD_WEIGHT warning", async () => {
|
||||
test("record-level weight field → UNSUPPORTED_FIELD_WEIGHT error", async () => {
|
||||
const p = file("record_weight.jsonl", [
|
||||
'{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"}],"weight":1}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(r.valid).toBe(true);
|
||||
expect(codes(r).warnings).toContain("UNSUPPORTED_FIELD_WEIGHT");
|
||||
expect(r.valid).toBe(false);
|
||||
expect(codes(r).errors).toContain("UNSUPPORTED_FIELD_WEIGHT");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -428,6 +500,91 @@ describe("validateDataset — loss_weight", () => {
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(r.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("message-level loss_weight on the LAST assistant passes without warning", async () => {
|
||||
const p = file("lw_msg_last.jsonl", [
|
||||
'{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello","loss_weight":0.8}]}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(r.valid).toBe(true);
|
||||
expect(codes(r).warnings).not.toContain("LOSS_WEIGHT_PLACEMENT");
|
||||
});
|
||||
|
||||
test("message-level loss_weight on a non-last assistant → LOSS_WEIGHT_PLACEMENT warning", async () => {
|
||||
const p = file("lw_msg_mid.jsonl", [
|
||||
'{"messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b","loss_weight":0.8},{"role":"user","content":"c"},{"role":"assistant","content":"d"}]}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(codes(r).warnings).toContain("LOSS_WEIGHT_PLACEMENT");
|
||||
});
|
||||
|
||||
test("message-level loss_weight out of range → INVALID_LOSS_WEIGHT", async () => {
|
||||
const p = file("lw_msg_range.jsonl", [
|
||||
'{"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello","loss_weight":2}]}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(r.valid).toBe(false);
|
||||
expect(codes(r).errors).toContain("INVALID_LOSS_WEIGHT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateDataset — video content params", () => {
|
||||
test("path-mode video with in-range fps and clip times passes", async () => {
|
||||
const p = file("video_path_ok.jsonl", [
|
||||
'{"messages":[{"role":"user","content":[{"text":"desc"},{"video":"v.mp4","fps":3.0,"video_start":0.0,"video_end":3.0}]},{"role":"assistant","content":[{"text":"ok"}]}]}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(r.valid).toBe(true);
|
||||
expect(codes(r).warnings).not.toContain("VIDEO_PARAM_MODE_MISMATCH");
|
||||
});
|
||||
|
||||
test("fps out of [0.1, 10] → INVALID_VIDEO_FPS error", async () => {
|
||||
const p = file("video_fps_bad.jsonl", [
|
||||
'{"messages":[{"role":"user","content":[{"text":"desc"},{"video":"v.mp4","fps":30}]},{"role":"assistant","content":[{"text":"ok"}]}]}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(r.valid).toBe(false);
|
||||
expect(codes(r).errors).toContain("INVALID_VIDEO_FPS");
|
||||
});
|
||||
|
||||
test("sample_fps on path-mode video → VIDEO_PARAM_MODE_MISMATCH warning", async () => {
|
||||
const p = file("video_mode_mix.jsonl", [
|
||||
'{"messages":[{"role":"user","content":[{"text":"desc"},{"video":"v.mp4","sample_fps":2.0}]},{"role":"assistant","content":[{"text":"ok"}]}]}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(codes(r).warnings).toContain("VIDEO_PARAM_MODE_MISMATCH");
|
||||
});
|
||||
|
||||
test("frame-list video with sample_fps passes; fps there is flagged", async () => {
|
||||
const p = file("video_frames.jsonl", [
|
||||
'{"messages":[{"role":"user","content":[{"text":"desc"},{"video":["0.jpg","1.jpg"],"sample_fps":5.0}]},{"role":"assistant","content":[{"text":"ok"}]}]}',
|
||||
'{"messages":[{"role":"user","content":[{"text":"desc"},{"video":["0.jpg"],"fps":2.0}]},{"role":"assistant","content":[{"text":"ok"}]}]}',
|
||||
]);
|
||||
const r = await validateDataset(p, { fullValidate: true });
|
||||
expect(r.valid).toBe(true);
|
||||
expect(codes(r).warnings).toContain("VIDEO_PARAM_MODE_MISMATCH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateZipFilenames — macOS metadata entries", () => {
|
||||
test("__MACOSX / .DS_Store / ._resource-fork entries are ignored", async () => {
|
||||
const { validateZipFilenames } = await import("../src/dataset/validate/zip.ts");
|
||||
const issues = validateZipFilenames([
|
||||
"data.jsonl",
|
||||
"image_1.jpg",
|
||||
"__MACOSX/._image_1.jpg",
|
||||
"__MACOSX/",
|
||||
".DS_Store",
|
||||
"train/._clip.wav",
|
||||
]);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
|
||||
test("real charset violations are still reported", async () => {
|
||||
const { validateZipFilenames } = await import("../src/dataset/validate/zip.ts");
|
||||
const issues = validateZipFilenames(["data.jsonl", "图片1.jpg"]);
|
||||
expect(issues.map((issue) => issue.code)).toContain("INVALID_FILENAME_CHARSET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDatasetSchemaFlag", () => {
|
||||
|
||||
Reference in New Issue
Block a user