Skip to content

Commit bac8704

Browse files
committed
fix: normalize AI content before detecting edits
1 parent dd4b014 commit bac8704

6 files changed

Lines changed: 61 additions & 10 deletions

File tree

apps/web/__tests__/unit/edit-ai-content.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,22 @@ beforeEach(() => {
7272
});
7373

7474
describe("editing AI content", () => {
75+
it("does not treat existing surrounding whitespace as a manual edit", async () => {
76+
const original = {
77+
summary: " Original ",
78+
chapters: [{ title: " Intro ", start: 0 }],
79+
};
80+
metadata = { ...original, aiGenerationStatus: "COMPLETE" };
81+
expect(
82+
await editAiContent(videoId, { expected: original, value: original }),
83+
).toEqual({ success: true, data: original });
84+
expect(mocks.write).not.toHaveBeenCalled();
85+
await editAiContent(videoId, {
86+
expected: original,
87+
value: { ...original, summary: "Changed" },
88+
});
89+
expect(writeSql).not.toContain("chaptersManuallyEdited");
90+
});
7591
it("compares chapters independently of MySQL JSON key ordering", async () => {
7692
metadata.chapters = [{ start: 0, title: "Intro" }];
7793
expect(

apps/web/__tests__/unit/summary-editor.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,24 @@ const summary = () => {
9292
};
9393

9494
describe("summary and chapter editor", () => {
95+
it("opens whitespace-padded AI output without a dirty draft", async () => {
96+
await render(
97+
createElement(SummaryEditor, {
98+
videoId,
99+
initialContent: {
100+
summary: " Original ",
101+
chapters: [{ title: " Intro ", start: 0 }],
102+
},
103+
duration: 120,
104+
onClose,
105+
}),
106+
);
107+
expect(button("Save changes").disabled).toBe(true);
108+
await change(summary(), " Original ");
109+
expect(button("Save changes").disabled).toBe(true);
110+
await change(summary(), "Changed");
111+
expect(button("Save changes").disabled).toBe(false);
112+
});
95113
it("starts clean with MySQL chapter key ordering", async () => {
96114
await render(
97115
createElement(SummaryEditor, {

apps/web/actions/videos/edit-ai-content.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
MAX_CHAPTER_TITLE_LENGTH,
1414
MAX_CHAPTERS,
1515
MAX_SUMMARY_LENGTH,
16+
normalizeAiContent,
1617
validateAiContent,
1718
} from "@/lib/ai-content";
1819
import { isAiGenerationEnabledForUser } from "@/lib/ai-generation-entitlement";
@@ -52,14 +53,14 @@ export async function editAiContent(
5253
if (typeof videoId !== "string" || !videoId || !parsed.success) {
5354
return { success: false, message: "Invalid summary or chapter data." };
5455
}
55-
const { value, expected } = parsed.data;
56-
value.summary = value.summary.trim();
57-
value.chapters = value.chapters.map((chapter) => ({
58-
...chapter,
59-
title: chapter.title.trim(),
60-
}));
61-
const summaryChanged = value.summary !== expected.summary;
62-
const chaptersChanged = !chaptersEqual(value.chapters, expected.chapters);
56+
const { expected } = parsed.data;
57+
const value = normalizeAiContent(parsed.data.value);
58+
const normalizedExpected = normalizeAiContent(expected);
59+
const summaryChanged = value.summary !== normalizedExpected.summary;
60+
const chaptersChanged = !chaptersEqual(
61+
value.chapters,
62+
normalizedExpected.chapters,
63+
);
6364

6465
try {
6566
const result = await db().transaction(async (tx): Promise<EditResult> => {

apps/web/app/s/[videoId]/_components/tabs/SummaryEditor.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
MAX_CHAPTER_TITLE_LENGTH,
1515
MAX_CHAPTERS,
1616
MAX_SUMMARY_LENGTH,
17+
normalizeAiContent,
1718
parseChapterTime,
1819
validateAiContent,
1920
} from "@/lib/ai-content";
@@ -36,6 +37,9 @@ export function SummaryEditor({
3637
const queryClient = useQueryClient();
3738
const id = useId();
3839
const [expected] = useState(initialContent);
40+
const [normalizedExpected] = useState(() =>
41+
normalizeAiContent(initialContent),
42+
);
3943
const [summary, setSummary] = useState(initialContent.summary);
4044
const nextId = useRef(initialContent.chapters.length);
4145
const [chapters, setChapters] = useState<
@@ -60,8 +64,8 @@ export function SummaryEditor({
6064
})),
6165
};
6266
const dirty =
63-
value.summary !== expected.summary ||
64-
!chaptersEqual(value.chapters, expected.chapters);
67+
value.summary !== normalizedExpected.summary ||
68+
!chaptersEqual(value.chapters, normalizedExpected.chapters);
6569
const validationError = validateAiContent(value, duration);
6670

6771
useEffect(() => {

apps/web/lib/ai-content-metadata.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export function setGeneratedAiContent(
88
) {
99
const path = `$.${field}`;
1010
const editedPath = `$.${field}ManuallyEdited`;
11+
// Empty values are deliberate removals. clearAiMetadata in edit-video.ts
12+
// deletes the keys on media replacement, allowing regeneration despite old flags.
1113
return sql`IF(
1214
JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, ${editedPath})) = 'true'
1315
AND JSON_CONTAINS_PATH(${videos.metadata}, 'one', ${path}),

apps/web/lib/ai-content.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,13 @@ export function chaptersEqual(
6868
)
6969
);
7070
}
71+
72+
export function normalizeAiContent(content: AiContent): AiContent {
73+
return {
74+
summary: content.summary.trim(),
75+
chapters: content.chapters.map((chapter) => ({
76+
...chapter,
77+
title: chapter.title.trim(),
78+
})),
79+
};
80+
}

0 commit comments

Comments
 (0)