Skip to content

Commit d5f5a6f

Browse files
committed
fix(artifacts): tolerate swapped old_str/new_str close tags + real-model eval harness
A real-model eval (30 runs across GLM-5.2 + Kimi-K2.6) found a ~50% edit failure rate. The dominant cause: models routinely swap the closing tags (<old_str>A</new_str> or <new_str>B</old_str>), which the strict UPDATE_PAIR_REGEX silently dropped. Accept either closer for each half (capture groups unchanged, so applyArtifactUpdate/findMatch are untouched). Re-parsing the captured real transcripts shows 3 recovered, 27 unchanged, 0 regressed (e.g. multi/GLM-5.2 1->4 pairs). Also: prompt rules against title-as-old_str and tag-swapping, and a committed scripts/artifact-real-eval.ts harness that reproduces the exact HuggingChat path (router call -> assemble <think>+content -> real collectArtifacts).
1 parent 57c2fb0 commit d5f5a6f

5 files changed

Lines changed: 219 additions & 2 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ SECRET_CONFIG
1313
!.env
1414
gcp-*.json
1515
db
16+
# Output of scripts/artifact-real-eval.ts (real-model eval transcripts)
17+
eval-output/
1618
models/*
1719
!models/add-your-models-here.txt
1820
.claude/*

scripts/artifact-real-eval.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* Real-model artifact-edit eval harness.
3+
*
4+
* Reproduces the EXACT HuggingChat path: inject ARTIFACTS_SYSTEM_PROMPT, ask the
5+
* real router model to create an artifact, then to edit it, assemble the assistant
6+
* messages the way chat-ui does (reasoning wrapped in <think>, then content), and
7+
* run the captured transcript through the real client parser (collectArtifacts).
8+
*
9+
* Run: SCENARIO=color MODEL=zai-org/GLM-5.2 RUN=1 npx vite-node scripts/artifact-real-eval.ts
10+
* Prints one JSON result line to stdout and writes the raw transcript to eval-output/.
11+
*/
12+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
13+
import { collectArtifacts, splitArtifactSegments } from "../src/lib/utils/artifacts";
14+
import { ARTIFACTS_SYSTEM_PROMPT } from "../src/lib/server/textGeneration/artifacts";
15+
16+
const SCENARIOS: Record<string, { create: string; edit: string; expect: RegExp }> = {
17+
color: {
18+
create: "make a green button",
19+
edit: "make it red instead",
20+
// Common red hexes/keywords the models actually pick (avoid matching greens).
21+
expect:
22+
/ef4444|dc2626|f87171|ff6b6b|e83939|b91c1c|c0392b|e74c3c|db2828|\bred\b|crimson|firebrick/i,
23+
},
24+
label: {
25+
create: "make a button that says Click Me",
26+
edit: "change the button label to Submit",
27+
expect: />\s*Submit\s*</i,
28+
},
29+
multi: {
30+
create: "make a blue button labelled Go",
31+
edit: "change the color to orange and the label to Stop",
32+
expect: /Stop/i,
33+
},
34+
add: {
35+
create: "make a simple centered landing page with one big heading",
36+
edit: "add a short subtitle paragraph under the heading",
37+
expect: /<p|subtitle/i,
38+
},
39+
react: {
40+
create: "make a React counter component with an increment button",
41+
edit: "make the increment button add 2 instead of 1",
42+
expect: /\+\s*2|\+=\s*2|\bcount\s*\+\s*2/i,
43+
},
44+
};
45+
46+
function loadEnv(): Record<string, string> {
47+
const env: Record<string, string> = {};
48+
for (const line of readFileSync(".env.local", "utf8").split("\n")) {
49+
const i = line.indexOf("=");
50+
if (i < 0 || line.trim().startsWith("#")) continue;
51+
env[line.slice(0, i).trim()] = line
52+
.slice(i + 1)
53+
.trim()
54+
.replace(/^"|"$/g, "");
55+
}
56+
return env;
57+
}
58+
59+
async function complete(
60+
base: string,
61+
key: string,
62+
model: string,
63+
messages: Array<{ role: string; content: string }>
64+
): Promise<string> {
65+
const res = await fetch(`${base}/chat/completions`, {
66+
method: "POST",
67+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
68+
body: JSON.stringify({ model, messages, max_tokens: 12000, temperature: 0.6 }),
69+
});
70+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
71+
const j = (await res.json()) as {
72+
choices?: Array<{
73+
message?: { content?: string; reasoning?: string; reasoning_content?: string };
74+
}>;
75+
};
76+
const msg = j.choices?.[0]?.message ?? {};
77+
const reasoning = msg.reasoning ?? msg.reasoning_content ?? "";
78+
const content = typeof msg.content === "string" ? msg.content : "";
79+
// Assemble exactly like chat-ui's OpenAI path: reasoning becomes a <think> block
80+
// before the visible content. This is what collectArtifacts() actually receives.
81+
return reasoning ? `<think>${reasoning}</think>${content}` : content;
82+
}
83+
84+
async function main() {
85+
const scenarioId = process.env.SCENARIO ?? "color";
86+
const model = process.env.MODEL ?? "zai-org/GLM-5.2";
87+
const run = process.env.RUN ?? "1";
88+
const scenario = SCENARIOS[scenarioId];
89+
if (!scenario) throw new Error(`unknown scenario ${scenarioId}`);
90+
91+
const env = loadEnv();
92+
const base = env.OPENAI_BASE_URL;
93+
const key = env.OPENAI_API_KEY;
94+
const sys = { role: "system", content: ARTIFACTS_SYSTEM_PROMPT };
95+
96+
const createOut = await complete(base, key, model, [
97+
sys,
98+
{ role: "user", content: scenario.create },
99+
]);
100+
const editOut = await complete(base, key, model, [
101+
sys,
102+
{ role: "user", content: scenario.create },
103+
{ role: "assistant", content: createOut },
104+
{ role: "user", content: scenario.edit },
105+
]);
106+
107+
const msgs = [
108+
{ id: "u1", from: "user" as const, content: scenario.create },
109+
{ id: "a1", from: "assistant" as const, content: createOut },
110+
{ id: "u2", from: "user" as const, content: scenario.edit },
111+
{ id: "a2", from: "assistant" as const, content: editOut },
112+
];
113+
const registry = collectArtifacts(msgs);
114+
115+
// How many update pairs the parser extracted from the edit turn (think stripped, as collectArtifacts does)
116+
const editVisible = editOut.replace(/<think>[\s\S]*?(?:<\/think>|$)/gi, "");
117+
let parsedUpdatePairs = 0;
118+
let editOpKind = "none";
119+
for (const seg of splitArtifactSegments(editVisible)) {
120+
if (seg.type === "artifact") {
121+
editOpKind = seg.op.kind;
122+
if (seg.op.kind === "update") parsedUpdatePairs += seg.op.pairs.length;
123+
}
124+
}
125+
126+
const artifactsArr = [...registry.artifacts.values()];
127+
const deadCard = [...registry.byMessageOp.values()].some((r) => r.version === -1);
128+
const primary = artifactsArr[0];
129+
const versions = primary?.versions ?? [];
130+
const last = versions.at(-1);
131+
const v1 = versions[0];
132+
const contentChanged = !!last && !!v1 && last.content !== v1.content;
133+
const editReflected = !!last && scenario.expect.test(last.content);
134+
135+
const result = {
136+
scenario: scenarioId,
137+
model,
138+
run,
139+
artifactCount: artifactsArr.length,
140+
versions: versions.length,
141+
lastOp: last?.op ?? "none",
142+
editOpKind, // what the model emitted on the edit turn: create/update/none
143+
parsedUpdatePairs,
144+
failedPairs: last?.failedPairs ?? 0,
145+
deadCard,
146+
contentChanged,
147+
editReflected,
148+
// success = the edit produced a correct new version of the same artifact
149+
ok:
150+
artifactsArr.length === 1 &&
151+
versions.length >= 2 &&
152+
contentChanged &&
153+
editReflected &&
154+
!last?.failedPairs,
155+
};
156+
157+
mkdirSync("eval-output", { recursive: true });
158+
const safeModel = model.replace(/\//g, "_");
159+
writeFileSync(
160+
`eval-output/${scenarioId}-${safeModel}-${run}.txt`,
161+
`SCENARIO ${scenarioId} | MODEL ${model} | RUN ${run}\n\n===== CREATE OUTPUT =====\n${createOut}\n\n===== EDIT OUTPUT =====\n${editOut}\n\n===== RESULT =====\n${JSON.stringify(result, null, 2)}\n`
162+
);
163+
console.log(JSON.stringify(result));
164+
}
165+
166+
main().catch((e) => {
167+
console.log(JSON.stringify({ error: String(e?.message ?? e) }));
168+
process.exit(1);
169+
});

src/lib/server/textGeneration/artifacts.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ Editing an artifact you created earlier in the conversation:
3636
<new_str>replacement text</new_str>
3737
</artifact>
3838
39-
- Each old_str must match the latest version EXACTLY (including whitespace/indentation) and must be unique within it. Copy it verbatim from the latest version; do not retype, reformat, or re-indent it.
39+
- Each old_str must match the latest version EXACTLY (including whitespace/indentation) and must be unique within it. Copy it verbatim from the latest version; do not retype, reformat, or re-indent it. To change the title, set title="New Title" on the artifact update tag — never put the artifact's opening tag inside an old_str.
40+
- Close each tag with its OWN matching tag: old_str with </old_str>, new_str with </new_str>. Do not swap them or omit a closing tag.
4041
- Emit at most ONE update block per reply, with all the pairs (up to 4) inside that single block — never one block per pair.
4142
- For larger changes, re-emit the full artifact with the SAME identifier (this creates a new version).
4243
- Keep the identifier BYTE-IDENTICAL across every version, even when the title or content changes (renaming a green button to blue keeps the same identifier). Use a new identifier only for a genuinely different artifact.

src/lib/utils/artifacts.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,44 @@ describe("splitArtifactSegments", () => {
121121
expect(artifact.op.closed).toBe(false);
122122
});
123123

124+
// Real models (GLM-5.2, Kimi-K2.6) routinely swap the closing tags under token
125+
// pressure; the parser must still recover the pair. See artifact-real-eval.
126+
it("parses an update pair when old_str is closed by </new_str> (swapped)", () => {
127+
const segments = splitArtifactSegments(
128+
`<artifact identifier="x" type="update"><old_str>A</new_str><new_str>B</new_str></artifact>`
129+
);
130+
const artifact = segments[0];
131+
if (artifact.type !== "artifact" || artifact.op.kind !== "update") {
132+
throw new Error("expected update artifact");
133+
}
134+
expect(artifact.op.pairs).toEqual([{ old: "A", new: "B" }]);
135+
});
136+
137+
it("parses an update pair when new_str is closed by </old_str> (swapped)", () => {
138+
const segments = splitArtifactSegments(
139+
`<artifact identifier="x" type="update"><old_str>A</old_str><new_str>B</old_str></artifact>`
140+
);
141+
const artifact = segments[0];
142+
if (artifact.type !== "artifact" || artifact.op.kind !== "update") {
143+
throw new Error("expected update artifact");
144+
}
145+
expect(artifact.op.pairs).toEqual([{ old: "A", new: "B" }]);
146+
});
147+
148+
it("parses multiple pairs with mixed swapped closers", () => {
149+
const segments = splitArtifactSegments(
150+
`<artifact identifier="x" type="update"><old_str>A</new_str><new_str>B</new_str><old_str>C</old_str><new_str>D</old_str></artifact>`
151+
);
152+
const artifact = segments[0];
153+
if (artifact.type !== "artifact" || artifact.op.kind !== "update") {
154+
throw new Error("expected update artifact");
155+
}
156+
expect(artifact.op.pairs).toEqual([
157+
{ old: "A", new: "B" },
158+
{ old: "C", new: "D" },
159+
]);
160+
});
161+
124162
it("handles multiple artifacts in one message", () => {
125163
const segments = splitArtifactSegments(`${HTML_ARTIFACT}\nand\n${HTML_ARTIFACT}`);
126164
const kinds = segments.map((s) => s.type);

src/lib/utils/artifacts.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,14 @@ export type ArtifactSegment =
8282
// and, worse, `<old_str>…<new_str>` adjacency silently stops matching.
8383
const OPEN_TAG_REGEX = /<+artifact\b([^>]*)>/i;
8484
const CLOSE_TAG_REGEX = /<+\/artifact>/i;
85-
const UPDATE_PAIR_REGEX = /<+old_str>([\s\S]*?)<+\/old_str>\s*<+new_str>([\s\S]*?)<+\/new_str>/g;
85+
// Accept either closer for each half: models under token pressure routinely swap
86+
// them (`<old_str>A</new_str>` or `<new_str>B</old_str>`), which a strict
87+
// `</old_str>`-then-`</new_str>` matcher silently drops. Observed live with both
88+
// GLM-5.2 and Kimi-K2.6. Capture groups/positions are unchanged, so downstream
89+
// (applyArtifactUpdate/findMatch) is unaffected; the lazy quantifier still stops
90+
// at the first closer, so well-formed pairs parse identically.
91+
const UPDATE_PAIR_REGEX =
92+
/<+old_str>([\s\S]*?)<+\/(?:old|new)_str>\s*<+new_str>([\s\S]*?)<+\/(?:new|old)_str>/g;
8693

8794
function parseAttributes(raw: string): Record<string, string> {
8895
const attrs: Record<string, string> = {};

0 commit comments

Comments
 (0)