1240 lines
36 KiB
Markdown
1240 lines
36 KiB
Markdown
# 普通文案人味儿优化 Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add an independent `普通文案优化` tab that optimizes pasted copy with renwei-writing-inspired rules, returning optimized text, change notes, and AI-taste checks.
|
|
|
|
**Architecture:** Add shared copy-optimization domain types and zod schemas, a prompt/workflow module that calls the existing validated LLM JSON path, a protected API route, and a focused React panel rendered behind a new home-page tab. The existing GEO article workflow stays the default tab and keeps its current state and behavior.
|
|
|
|
**Tech Stack:** Next.js App Router, React client components, TypeScript, Zod, existing `generateValidatedJson` LLM client, Vitest, Playwright.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Modify `src/lib/domain/types.ts`: add copy optimization request/result interfaces and enums.
|
|
- Modify `src/lib/domain/validation.ts`: add zod schemas for copy optimization request and result.
|
|
- Modify `src/lib/domain/__tests__/validation.test.ts`: add schema tests.
|
|
- Modify `src/lib/llm/client.ts`: add the `renwei_copy_optimizer` task name.
|
|
- Modify `src/lib/llm/prompts.ts`: add renwei system prompt and prompt builder.
|
|
- Modify `src/lib/llm/__tests__/prompts.test.ts`: add prompt coverage.
|
|
- Create `src/lib/workflow/renwei-copy-optimizer.ts`: call the LLM with the prompt and result schema.
|
|
- Create `src/app/api/copy/renwei-optimize/route.ts`: protected POST endpoint.
|
|
- Create `src/app/api/__tests__/copy-renwei.test.ts`: API tests with mocked LLM.
|
|
- Create `src/components/renwei-copy-optimizer-panel.tsx`: client UI for the new tool.
|
|
- Modify `src/app/page.tsx`: add app-level tabs and render the new panel.
|
|
- Modify `src/app/globals.css`: add small tab/result layout styles.
|
|
- Create `tests/e2e/renwei-copy.spec.ts`: browser test using route interception for the new API.
|
|
|
|
## Task 1: Domain Types And Validation
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/domain/types.ts`
|
|
- Modify: `src/lib/domain/validation.ts`
|
|
- Test: `src/lib/domain/__tests__/validation.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing validation tests**
|
|
|
|
Append these tests inside the existing `describe` block in `src/lib/domain/__tests__/validation.test.ts`:
|
|
|
|
```ts
|
|
it("accepts a trimmed copy optimization request", () => {
|
|
const parsed = copyOptimizationRequestSchema.parse({
|
|
source_text: " 我写了一段有点卡的文案 ",
|
|
goal: "",
|
|
intensity: "light",
|
|
user_instructions: " 保留口语感 ",
|
|
});
|
|
|
|
expect(parsed).toEqual({
|
|
source_text: "我写了一段有点卡的文案",
|
|
goal: "保留原意,减少 AI 味",
|
|
intensity: "light",
|
|
user_instructions: "保留口语感",
|
|
});
|
|
});
|
|
|
|
it("rejects empty copy optimization source text", () => {
|
|
expect(() =>
|
|
copyOptimizationRequestSchema.parse({
|
|
source_text: " ",
|
|
intensity: "light",
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
it("accepts structured copy optimization results", () => {
|
|
const parsed = copyOptimizationResultSchema.parse({
|
|
optimized_text: "我把句子顺了一下。",
|
|
change_notes: [
|
|
{
|
|
original: "我把句子顺顺。",
|
|
revised: "我把句子顺了一下。",
|
|
reason: "修正口语里不顺的重复。",
|
|
confidence: "confident",
|
|
revertible: false,
|
|
},
|
|
],
|
|
ai_taste_checks: [
|
|
{
|
|
rule_id: "promotion_tone",
|
|
status: "pass",
|
|
evidence: "没有新增宣传词。",
|
|
suggestion: "",
|
|
},
|
|
],
|
|
warnings: [],
|
|
});
|
|
|
|
expect(parsed.optimized_text).toBe("我把句子顺了一下。");
|
|
expect(parsed.change_notes[0].confidence).toBe("confident");
|
|
expect(parsed.ai_taste_checks[0].rule_id).toBe("promotion_tone");
|
|
});
|
|
```
|
|
|
|
Also update the import at the top of the test file:
|
|
|
|
```ts
|
|
import {
|
|
articleInputSchema,
|
|
candidateFactCardSchema,
|
|
confirmedFactCardSchema,
|
|
copyOptimizationRequestSchema,
|
|
copyOptimizationResultSchema,
|
|
optimizedArticleSchema,
|
|
qaReportSchema,
|
|
} from "../validation";
|
|
```
|
|
|
|
- [ ] **Step 2: Run the validation tests to verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/domain/__tests__/validation.test.ts
|
|
```
|
|
|
|
Expected: FAIL with TypeScript or runtime errors saying `copyOptimizationRequestSchema` and `copyOptimizationResultSchema` are not exported.
|
|
|
|
- [ ] **Step 3: Add the domain types**
|
|
|
|
Append to `src/lib/domain/types.ts`:
|
|
|
|
```ts
|
|
export type CopyOptimizationIntensity = "light" | "medium" | "conversational";
|
|
|
|
export type CopyChangeConfidence = "confident" | "uncertain";
|
|
|
|
export type CopyAiTasteRuleId =
|
|
| "meaning_inflation"
|
|
| "promotion_tone"
|
|
| "formulaic_sentence"
|
|
| "format_trace"
|
|
| "chat_trace"
|
|
| "filler_hedging";
|
|
|
|
export type CopyAiTasteStatus = "pass" | "warn";
|
|
|
|
export interface CopyOptimizationRequest {
|
|
source_text: string;
|
|
goal: string;
|
|
intensity: CopyOptimizationIntensity;
|
|
user_instructions: string;
|
|
}
|
|
|
|
export interface CopyChangeNote {
|
|
original: string;
|
|
revised: string;
|
|
reason: string;
|
|
confidence: CopyChangeConfidence;
|
|
revertible: boolean;
|
|
}
|
|
|
|
export interface CopyAiTasteCheck {
|
|
rule_id: CopyAiTasteRuleId;
|
|
status: CopyAiTasteStatus;
|
|
evidence: string;
|
|
suggestion: string;
|
|
}
|
|
|
|
export interface CopyOptimizationResult {
|
|
optimized_text: string;
|
|
change_notes: CopyChangeNote[];
|
|
ai_taste_checks: CopyAiTasteCheck[];
|
|
warnings: string[];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add the zod schemas**
|
|
|
|
In `src/lib/domain/validation.ts`, add the new imported type names:
|
|
|
|
```ts
|
|
CopyAiTasteCheck,
|
|
CopyChangeNote,
|
|
CopyOptimizationRequest,
|
|
CopyOptimizationResult,
|
|
```
|
|
|
|
Then append these schemas near the other exported schemas:
|
|
|
|
```ts
|
|
export const copyOptimizationIntensitySchema = z.enum([
|
|
"light",
|
|
"medium",
|
|
"conversational",
|
|
]);
|
|
|
|
export const copyOptimizationRequestSchema = z.object({
|
|
source_text: z.string().trim().min(1),
|
|
goal: z
|
|
.preprocess((value) => {
|
|
if (typeof value !== "string") return value;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : "保留原意,减少 AI 味";
|
|
}, z.string().trim().min(1))
|
|
.default("保留原意,减少 AI 味"),
|
|
intensity: copyOptimizationIntensitySchema.default("light"),
|
|
user_instructions: z.string().trim().default(""),
|
|
}) satisfies z.ZodType<CopyOptimizationRequest>;
|
|
|
|
const copyChangeNoteSchema = z.object({
|
|
original: requiredLlmStringSchema,
|
|
revised: requiredLlmStringSchema,
|
|
reason: requiredLlmStringSchema,
|
|
confidence: z.enum(["confident", "uncertain"]),
|
|
revertible: z.boolean(),
|
|
}) satisfies z.ZodType<CopyChangeNote>;
|
|
|
|
const copyAiTasteCheckSchema = z.object({
|
|
rule_id: z.enum([
|
|
"meaning_inflation",
|
|
"promotion_tone",
|
|
"formulaic_sentence",
|
|
"format_trace",
|
|
"chat_trace",
|
|
"filler_hedging",
|
|
]),
|
|
status: z.enum(["pass", "warn"]),
|
|
evidence: requiredLlmStringSchema,
|
|
suggestion: optionalLlmStringSchema,
|
|
}) satisfies z.ZodType<CopyAiTasteCheck>;
|
|
|
|
export const copyOptimizationResultSchema = z.object({
|
|
optimized_text: requiredLlmStringSchema,
|
|
change_notes: z.array(copyChangeNoteSchema).default([]),
|
|
ai_taste_checks: z.array(copyAiTasteCheckSchema).default([]),
|
|
warnings: stringListSchema,
|
|
}) satisfies z.ZodType<CopyOptimizationResult>;
|
|
```
|
|
|
|
- [ ] **Step 5: Run the validation tests to verify GREEN**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/domain/__tests__/validation.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit Task 1**
|
|
|
|
```bash
|
|
git add src/lib/domain/types.ts src/lib/domain/validation.ts src/lib/domain/__tests__/validation.test.ts
|
|
git commit -m "新增普通文案优化数据校验"
|
|
```
|
|
|
|
## Task 2: Prompt Builder And Workflow
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/llm/client.ts`
|
|
- Modify: `src/lib/llm/prompts.ts`
|
|
- Modify: `src/lib/llm/__tests__/prompts.test.ts`
|
|
- Create: `src/lib/workflow/renwei-copy-optimizer.ts`
|
|
- Test: `src/lib/workflow/__tests__/llm-integration.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing prompt tests**
|
|
|
|
Update imports in `src/lib/llm/__tests__/prompts.test.ts`:
|
|
|
|
```ts
|
|
RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT,
|
|
buildRenweiCopyOptimizationPrompt,
|
|
```
|
|
|
|
Append these tests:
|
|
|
|
```ts
|
|
it("renwei copy prompt keeps the author present and defaults to small edits", () => {
|
|
const prompt = `${RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT}\n${buildRenweiCopyOptimizationPrompt({
|
|
source_text: "我观察到大家越来越难进入心流了。",
|
|
goal: "保留原意,减少 AI 味",
|
|
intensity: "light",
|
|
user_instructions: "保留作者的口语感。",
|
|
})}`;
|
|
|
|
expect(prompt).toContain("少动");
|
|
expect(prompt).toContain("保留作者");
|
|
expect(prompt).toContain("手迹");
|
|
expect(prompt).toContain("不凭空新增时间、地点、数字、案例");
|
|
expect(prompt).toContain("不写过度金句");
|
|
expect(prompt).toContain("逐处说明");
|
|
expect(prompt).toContain("只检查被改动句子");
|
|
expect(prompt).toContain("source_text");
|
|
});
|
|
|
|
it("renwei copy prompt describes all result fields and intensity modes", () => {
|
|
const prompt = buildRenweiCopyOptimizationPrompt({
|
|
source_text: "这是一段文案。",
|
|
goal: "更自然",
|
|
intensity: "conversational",
|
|
user_instructions: "",
|
|
});
|
|
|
|
expect(prompt).toContain("optimized_text");
|
|
expect(prompt).toContain("change_notes");
|
|
expect(prompt).toContain("ai_taste_checks");
|
|
expect(prompt).toContain("warnings");
|
|
expect(prompt).toContain("light");
|
|
expect(prompt).toContain("medium");
|
|
expect(prompt).toContain("conversational");
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run prompt tests to verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/llm/__tests__/prompts.test.ts
|
|
```
|
|
|
|
Expected: FAIL because `RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT` and `buildRenweiCopyOptimizationPrompt` are not exported.
|
|
|
|
- [ ] **Step 3: Write the failing workflow test**
|
|
|
|
In `src/lib/workflow/__tests__/llm-integration.test.ts`, update the hoisted LLM mock tests by importing:
|
|
|
|
```ts
|
|
import { optimizeRenweiCopy } from "../renwei-copy-optimizer";
|
|
```
|
|
|
|
Append this test:
|
|
|
|
```ts
|
|
it("uses validated LLM output for renwei copy optimization", async () => {
|
|
llmMocks.generateValidatedJson.mockResolvedValueOnce({
|
|
optimized_text: "我观察到大家越来越难进入心流了。",
|
|
change_notes: [
|
|
{
|
|
original: "我观察到大家越来越难进入心流",
|
|
revised: "我观察到大家越来越难进入心流了。",
|
|
reason: "补足句尾语气,让句子自然收住。",
|
|
confidence: "confident",
|
|
revertible: false,
|
|
},
|
|
],
|
|
ai_taste_checks: [
|
|
{
|
|
rule_id: "promotion_tone",
|
|
status: "pass",
|
|
evidence: "没有新增宣传腔。",
|
|
suggestion: "",
|
|
},
|
|
],
|
|
warnings: [],
|
|
});
|
|
|
|
const result = await optimizeRenweiCopy({
|
|
source_text: "我观察到大家越来越难进入心流",
|
|
goal: "保留原意,减少 AI 味",
|
|
intensity: "light",
|
|
user_instructions: "",
|
|
});
|
|
|
|
expect(result.optimized_text).toBe("我观察到大家越来越难进入心流了。");
|
|
expect(llmMocks.generateValidatedJson).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
task: "renwei_copy_optimizer",
|
|
temperature: 0.2,
|
|
}),
|
|
);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Run workflow test to verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/workflow/__tests__/llm-integration.test.ts -t "renwei copy"
|
|
```
|
|
|
|
Expected: FAIL because `../renwei-copy-optimizer` does not exist or task type does not accept `renwei_copy_optimizer`.
|
|
|
|
- [ ] **Step 5: Add the LLM task name**
|
|
|
|
Modify `src/lib/llm/client.ts`:
|
|
|
|
```ts
|
|
export type LlmTaskName =
|
|
| "unknown"
|
|
| "fact_extractor"
|
|
| "article_optimizer"
|
|
| "quality_inspector"
|
|
| "targeted_rewriter"
|
|
| "renwei_copy_optimizer";
|
|
```
|
|
|
|
- [ ] **Step 6: Add prompt constants and builder**
|
|
|
|
Append to `src/lib/llm/prompts.ts`:
|
|
|
|
```ts
|
|
import type { CopyOptimizationRequest } from "../domain/types";
|
|
```
|
|
|
|
If the file already has a type import from `../domain/types`, merge `CopyOptimizationRequest` into that existing import.
|
|
|
|
Then add:
|
|
|
|
```ts
|
|
const RENWEI_OUTPUT_CONTRACT = [
|
|
"Output type contract:",
|
|
"- optimized_text must be a string.",
|
|
"- change_notes must be an array of { original, revised, reason, confidence, revertible }.",
|
|
"- confidence must be confident or uncertain.",
|
|
"- revertible must be boolean.",
|
|
"- ai_taste_checks must be an array of { rule_id, status, evidence, suggestion }.",
|
|
"- rule_id must be one of meaning_inflation, promotion_tone, formulaic_sentence, format_trace, chat_trace, filler_hedging.",
|
|
"- status must be pass or warn.",
|
|
"- warnings must be string[].",
|
|
"Example:",
|
|
JSON.stringify(
|
|
{
|
|
optimized_text: "我把句子顺了一下。",
|
|
change_notes: [
|
|
{
|
|
original: "我把句子顺顺。",
|
|
revised: "我把句子顺了一下。",
|
|
reason: "修掉重复,让句子自然收住。",
|
|
confidence: "confident",
|
|
revertible: false,
|
|
},
|
|
],
|
|
ai_taste_checks: [
|
|
{
|
|
rule_id: "promotion_tone",
|
|
status: "pass",
|
|
evidence: "没有新增宣传腔。",
|
|
suggestion: "",
|
|
},
|
|
],
|
|
warnings: [],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
].join("\n");
|
|
|
|
export const RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT = [
|
|
"你是中文普通文案编辑,不是营销代笔工具。",
|
|
"目标是让文字更顺,但改完后作者还在。",
|
|
"硬性规则:少动,只改真正打绊的地方;保留作者的位置、语气、口头习惯和可识别手迹。",
|
|
"不得凭空新增时间、地点、数字、案例、情绪、场景、资质、效果承诺或没有来源的事实。",
|
|
"不要把普通句子改成宣传腔、排比、格言、万能展望、意义拔高或过度金句。",
|
|
"拿不准时白描,不拔高;拿不准的改动必须标记为 uncertain 且 revertible 为 true。",
|
|
"改后只检查被改动句子是否引入 AI 味,并逐处说明改了什么、为什么改。",
|
|
JSON_ONLY_PROMPT,
|
|
].join(" ");
|
|
|
|
function describeRenweiIntensity(intensity: CopyOptimizationRequest["intensity"]) {
|
|
const descriptions = {
|
|
light: "light / 轻微整理:尽量只修顺病句、错别字、明显卡顿。",
|
|
medium: "medium / 适度润色:允许调整句序和连接,但不改变作者表达的粗糙感。",
|
|
conversational:
|
|
"conversational / 更口语自然:让口吻更像真人说话,但不凭空增加表演性口语。",
|
|
} satisfies Record<CopyOptimizationRequest["intensity"], string>;
|
|
return descriptions[intensity];
|
|
}
|
|
|
|
export function buildRenweiCopyOptimizationPrompt(input: CopyOptimizationRequest) {
|
|
return [
|
|
"Return a CopyOptimizationResult JSON object with these exact keys:",
|
|
"optimized_text, change_notes, ai_taste_checks, warnings.",
|
|
"",
|
|
RENWEI_OUTPUT_CONTRACT,
|
|
"",
|
|
"强度选项:",
|
|
"- light:轻微整理。",
|
|
"- medium:适度润色。",
|
|
"- conversational:更口语自然。",
|
|
`当前修改强度:${describeRenweiIntensity(input.intensity)}`,
|
|
"",
|
|
"改稿要求:",
|
|
"- 默认少动,不追求更漂亮,只清掉绊脚处。",
|
|
"- 毛边先假设是作者手迹,不要机械删口语、停顿或重复。",
|
|
"- 不写“不是X,而是Y”、排比三连、万能展望、宣传腔或假深刻句式。",
|
|
"- 不使用破折号作为装饰性解释。",
|
|
"- 必须逐处说明改动;未改动的句子不要编造改动说明。",
|
|
"- AI 味检查只检查被改动句子。",
|
|
"",
|
|
"User request:",
|
|
JSON.stringify(
|
|
{
|
|
goal: input.goal,
|
|
intensity: input.intensity,
|
|
user_instructions: input.user_instructions,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"",
|
|
"source_text:",
|
|
input.source_text,
|
|
].join("\n");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Add the workflow module**
|
|
|
|
Create `src/lib/workflow/renwei-copy-optimizer.ts`:
|
|
|
|
```ts
|
|
import type {
|
|
CopyOptimizationRequest,
|
|
CopyOptimizationResult,
|
|
} from "../domain/types";
|
|
import { copyOptimizationResultSchema } from "../domain/validation";
|
|
import { generateValidatedJson } from "../llm/client";
|
|
import {
|
|
RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT,
|
|
buildRenweiCopyOptimizationPrompt,
|
|
} from "../llm/prompts";
|
|
|
|
export async function optimizeRenweiCopy(
|
|
input: CopyOptimizationRequest,
|
|
): Promise<CopyOptimizationResult> {
|
|
return generateValidatedJson({
|
|
schema: copyOptimizationResultSchema,
|
|
system: RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT,
|
|
prompt: buildRenweiCopyOptimizationPrompt(input),
|
|
temperature: 0.2,
|
|
task: "renwei_copy_optimizer",
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8: Run prompt and workflow tests to verify GREEN**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/llm/__tests__/prompts.test.ts src/lib/workflow/__tests__/llm-integration.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 9: Commit Task 2**
|
|
|
|
```bash
|
|
git add src/lib/llm/client.ts src/lib/llm/prompts.ts src/lib/llm/__tests__/prompts.test.ts src/lib/workflow/renwei-copy-optimizer.ts src/lib/workflow/__tests__/llm-integration.test.ts
|
|
git commit -m "新增人味儿文案优化提示词"
|
|
```
|
|
|
|
## Task 3: Protected API Route
|
|
|
|
**Files:**
|
|
- Create: `src/app/api/copy/renwei-optimize/route.ts`
|
|
- Create: `src/app/api/__tests__/copy-renwei.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing API route tests**
|
|
|
|
Create `src/app/api/__tests__/copy-renwei.test.ts`:
|
|
|
|
```ts
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const llmMocks = vi.hoisted(() => ({
|
|
generateValidatedJson: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../../lib/llm/client", async () => {
|
|
const actual = await vi.importActual<typeof import("../../../lib/llm/client")>(
|
|
"../../../lib/llm/client",
|
|
);
|
|
return {
|
|
...actual,
|
|
generateValidatedJson: llmMocks.generateValidatedJson,
|
|
};
|
|
});
|
|
|
|
import { POST as optimizeCopy } from "../copy/renwei-optimize/route";
|
|
|
|
describe("renwei copy optimization API route", () => {
|
|
const originalApiKey = process.env.API_ACCESS_KEY;
|
|
const originalAuthDisabled = process.env.API_AUTH_DISABLED;
|
|
|
|
beforeEach(() => {
|
|
process.env.API_ACCESS_KEY = "test-key";
|
|
process.env.API_AUTH_DISABLED = "false";
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.API_ACCESS_KEY = originalApiKey;
|
|
process.env.API_AUTH_DISABLED = originalAuthDisabled;
|
|
llmMocks.generateValidatedJson.mockReset();
|
|
});
|
|
|
|
it("rejects requests without the access key", async () => {
|
|
const response = await optimizeCopy(
|
|
request(
|
|
{
|
|
source_text: "这是一段普通文案。",
|
|
intensity: "light",
|
|
},
|
|
{ apiKey: null },
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
it("returns 400 for empty source text", async () => {
|
|
const response = await optimizeCopy(
|
|
request({
|
|
source_text: " ",
|
|
intensity: "light",
|
|
}),
|
|
);
|
|
const body = (await response.json()) as { error: string };
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(body.error).toBe("请输入需要优化的文案");
|
|
});
|
|
|
|
it("returns structured copy optimization results", async () => {
|
|
llmMocks.generateValidatedJson.mockResolvedValueOnce({
|
|
optimized_text: "我把这段文案顺了一下。",
|
|
change_notes: [
|
|
{
|
|
original: "我把这段文案顺顺。",
|
|
revised: "我把这段文案顺了一下。",
|
|
reason: "修正重复表达。",
|
|
confidence: "confident",
|
|
revertible: false,
|
|
},
|
|
],
|
|
ai_taste_checks: [
|
|
{
|
|
rule_id: "promotion_tone",
|
|
status: "pass",
|
|
evidence: "没有新增宣传腔。",
|
|
suggestion: "",
|
|
},
|
|
],
|
|
warnings: [],
|
|
});
|
|
|
|
const response = await optimizeCopy(
|
|
request({
|
|
source_text: "我把这段文案顺顺。",
|
|
goal: "",
|
|
intensity: "light",
|
|
user_instructions: "保留口语。",
|
|
}),
|
|
);
|
|
const body = (await response.json()) as {
|
|
result: { optimized_text: string; change_notes: unknown[] };
|
|
};
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body.result.optimized_text).toBe("我把这段文案顺了一下。");
|
|
expect(body.result.change_notes).toHaveLength(1);
|
|
expect(llmMocks.generateValidatedJson).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
task: "renwei_copy_optimizer",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("surfaces LLM failures as a 502", async () => {
|
|
llmMocks.generateValidatedJson.mockRejectedValueOnce(
|
|
new Error("LLM response failed schema validation: optimized_text"),
|
|
);
|
|
|
|
const response = await optimizeCopy(
|
|
request({
|
|
source_text: "这是一段普通文案。",
|
|
intensity: "light",
|
|
}),
|
|
);
|
|
const body = (await response.json()) as { error: string };
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(body.error).toBe(
|
|
"LLM response failed schema validation: optimized_text",
|
|
);
|
|
});
|
|
});
|
|
|
|
function request(body: unknown, options: { apiKey?: string | null } = {}) {
|
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
const apiKey = options.apiKey === undefined ? "test-key" : options.apiKey;
|
|
if (apiKey) {
|
|
headers["x-api-key"] = apiKey;
|
|
}
|
|
|
|
return new Request("http://localhost/api/copy/renwei-optimize", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
headers,
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run API tests to verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/app/api/__tests__/copy-renwei.test.ts
|
|
```
|
|
|
|
Expected: FAIL because `../copy/renwei-optimize/route` does not exist.
|
|
|
|
- [ ] **Step 3: Add the route implementation**
|
|
|
|
Create `src/app/api/copy/renwei-optimize/route.ts`:
|
|
|
|
```ts
|
|
import { NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
|
|
import { requireApiAccess } from "../../../../lib/api/auth";
|
|
import { copyOptimizationRequestSchema } from "../../../../lib/domain/validation";
|
|
import { LlmValidationError } from "../../../../lib/llm/client";
|
|
import { optimizeRenweiCopy } from "../../../../lib/workflow/renwei-copy-optimizer";
|
|
|
|
export async function POST(request: Request) {
|
|
const access = requireApiAccess(request);
|
|
if (!access.ok) {
|
|
return access.response;
|
|
}
|
|
|
|
try {
|
|
const payload = copyOptimizationRequestSchema.parse(await request.json());
|
|
const result = await optimizeRenweiCopy(payload);
|
|
return NextResponse.json({ result });
|
|
} catch (error) {
|
|
return jsonError(error, getErrorStatus(error));
|
|
}
|
|
}
|
|
|
|
function jsonError(error: unknown, status: number) {
|
|
const message =
|
|
error instanceof z.ZodError
|
|
? "请输入需要优化的文案"
|
|
: error instanceof Error
|
|
? error.message
|
|
: "文案优化失败";
|
|
|
|
return NextResponse.json({ error: message }, { status });
|
|
}
|
|
|
|
function getErrorStatus(error: unknown) {
|
|
if (error instanceof LlmValidationError) return 502;
|
|
if (error instanceof Error && /^LLM\b|provider/i.test(error.message)) return 502;
|
|
return 400;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run API tests to verify GREEN**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/app/api/__tests__/copy-renwei.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit Task 3**
|
|
|
|
```bash
|
|
git add src/app/api/copy/renwei-optimize/route.ts src/app/api/__tests__/copy-renwei.test.ts
|
|
git commit -m "新增普通文案优化接口"
|
|
```
|
|
|
|
## Task 4: UI Tab And Panel
|
|
|
|
**Files:**
|
|
- Create: `src/components/renwei-copy-optimizer-panel.tsx`
|
|
- Modify: `src/app/page.tsx`
|
|
- Modify: `src/app/globals.css`
|
|
- Create: `tests/e2e/renwei-copy.spec.ts`
|
|
|
|
- [ ] **Step 1: Write the failing E2E test**
|
|
|
|
Create `tests/e2e/renwei-copy.spec.ts`:
|
|
|
|
```ts
|
|
import { expect, test } from "@playwright/test";
|
|
|
|
test("普通文案优化标签页可以生成文案结果", async ({ page }) => {
|
|
await page.route("**/api/copy/renwei-optimize", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
result: {
|
|
optimized_text: "我观察到大家越来越难进入心流了。",
|
|
change_notes: [
|
|
{
|
|
original: "我观察到大家越来越难进入心流",
|
|
revised: "我观察到大家越来越难进入心流了。",
|
|
reason: "补足句尾语气,让句子自然收住。",
|
|
confidence: "confident",
|
|
revertible: false,
|
|
},
|
|
],
|
|
ai_taste_checks: [
|
|
{
|
|
rule_id: "promotion_tone",
|
|
status: "pass",
|
|
evidence: "没有新增宣传腔。",
|
|
suggestion: "",
|
|
},
|
|
],
|
|
warnings: [],
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.goto("/");
|
|
await expect(page.getByRole("heading", { name: "GEO 智能文章优化器" })).toBeVisible();
|
|
await expect(page.getByText("文章输入")).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "普通文案优化" }).click();
|
|
await expect(page.getByLabel("原始文案")).toBeVisible();
|
|
|
|
await page.getByLabel("访问密钥").fill("local-dev-key");
|
|
await page
|
|
.getByLabel("原始文案")
|
|
.fill("我观察到大家越来越难进入心流");
|
|
await page.getByRole("button", { name: "优化文案" }).click();
|
|
|
|
await expect(page.getByText("优化后文案")).toBeVisible();
|
|
await expect(page.getByText("我观察到大家越来越难进入心流了。")).toBeVisible();
|
|
await expect(page.getByText("改动说明")).toBeVisible();
|
|
await expect(page.getByText("AI 味检查")).toBeVisible();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run E2E test to verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npx playwright test tests/e2e/renwei-copy.spec.ts
|
|
```
|
|
|
|
Expected: FAIL because the `普通文案优化` tab does not exist.
|
|
|
|
- [ ] **Step 3: Create the client panel**
|
|
|
|
Create `src/components/renwei-copy-optimizer-panel.tsx`:
|
|
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useState, type FormEvent } from "react";
|
|
|
|
import type {
|
|
CopyOptimizationIntensity,
|
|
CopyOptimizationResult,
|
|
} from "../lib/domain/types";
|
|
|
|
interface RenweiCopyOptimizerPanelProps {
|
|
apiAccessKey: string;
|
|
}
|
|
|
|
interface CopyOptimizeResponse {
|
|
result?: CopyOptimizationResult;
|
|
error?: string;
|
|
}
|
|
|
|
const intensityOptions: Array<{
|
|
value: CopyOptimizationIntensity;
|
|
label: string;
|
|
}> = [
|
|
{ value: "light", label: "轻微整理" },
|
|
{ value: "medium", label: "适度润色" },
|
|
{ value: "conversational", label: "更口语自然" },
|
|
];
|
|
|
|
export function RenweiCopyOptimizerPanel({
|
|
apiAccessKey,
|
|
}: RenweiCopyOptimizerPanelProps) {
|
|
const [sourceText, setSourceText] = useState("");
|
|
const [goal, setGoal] = useState("保留原意,减少 AI 味");
|
|
const [intensity, setIntensity] =
|
|
useState<CopyOptimizationIntensity>("light");
|
|
const [userInstructions, setUserInstructions] = useState("");
|
|
const [result, setResult] = useState<CopyOptimizationResult | null>(null);
|
|
const [message, setMessage] = useState("");
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
setIsSubmitting(true);
|
|
setMessage("");
|
|
setResult(null);
|
|
|
|
try {
|
|
const response = await fetch("/api/copy/renwei-optimize", {
|
|
method: "POST",
|
|
headers: apiHeaders(apiAccessKey),
|
|
body: JSON.stringify({
|
|
source_text: sourceText,
|
|
goal,
|
|
intensity,
|
|
user_instructions: userInstructions,
|
|
}),
|
|
});
|
|
const body = (await response.json()) as CopyOptimizeResponse;
|
|
if (!response.ok || !body.result) {
|
|
throw new Error(body.error ?? "文案优化失败");
|
|
}
|
|
setResult(body.result);
|
|
setMessage("文案优化完成。");
|
|
} catch (error) {
|
|
setMessage(error instanceof Error ? error.message : "文案优化失败");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
|
|
async function copyResult() {
|
|
if (!result?.optimized_text) return;
|
|
await navigator.clipboard.writeText(result.optimized_text);
|
|
setMessage("已复制优化结果。");
|
|
}
|
|
|
|
return (
|
|
<section className="copy-optimizer-grid">
|
|
<form className="panel stack" onSubmit={submit}>
|
|
<div className="panel-heading">
|
|
<span>普通文案优化</span>
|
|
<button disabled={isSubmitting} type="submit">
|
|
{isSubmitting ? "优化中..." : "优化文案"}
|
|
</button>
|
|
</div>
|
|
<label>
|
|
<span>原始文案</span>
|
|
<textarea
|
|
className="body-input"
|
|
required
|
|
value={sourceText}
|
|
onChange={(event) => setSourceText(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>优化目标</span>
|
|
<input
|
|
value={goal}
|
|
onChange={(event) => setGoal(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>修改强度</span>
|
|
<select
|
|
value={intensity}
|
|
onChange={(event) =>
|
|
setIntensity(event.target.value as CopyOptimizationIntensity)
|
|
}
|
|
>
|
|
{intensityOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>补充要求</span>
|
|
<textarea
|
|
value={userInstructions}
|
|
onChange={(event) => setUserInstructions(event.target.value)}
|
|
/>
|
|
</label>
|
|
{message ? <p className="status-text">{message}</p> : null}
|
|
</form>
|
|
|
|
<section className="panel stack">
|
|
<div className="panel-heading">
|
|
<span>优化后文案</span>
|
|
<button disabled={!result} onClick={copyResult} type="button">
|
|
复制结果
|
|
</button>
|
|
</div>
|
|
{result ? (
|
|
<>
|
|
<pre className="copy-result">{result.optimized_text}</pre>
|
|
<section className="stack">
|
|
<h3>改动说明</h3>
|
|
<ul className="qa-list">
|
|
{result.change_notes.map((note, index) => (
|
|
<li className="qa-item" key={`${note.original}-${index}`}>
|
|
<p>{note.reason}</p>
|
|
<small>原句:{note.original}</small>
|
|
<small>改后:{note.revised}</small>
|
|
{note.revertible ? <em>这处可还原。</em> : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
<section className="stack">
|
|
<h3>AI 味检查</h3>
|
|
<ul className="qa-list">
|
|
{result.ai_taste_checks.map((check) => (
|
|
<li className="qa-item" key={check.rule_id}>
|
|
<p>
|
|
<span className={`status-pill ${check.status}`}>
|
|
{check.status}
|
|
</span>
|
|
</p>
|
|
<small>{check.evidence}</small>
|
|
{check.suggestion ? <small>{check.suggestion}</small> : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
{result.warnings.length > 0 ? (
|
|
<ul className="calibration-observations">
|
|
{result.warnings.map((warning) => (
|
|
<li key={warning}>{warning}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<p className="empty-panel">优化结果会显示在这里。</p>
|
|
)}
|
|
</section>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function apiHeaders(apiAccessKey: string) {
|
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
if (apiAccessKey) {
|
|
headers["x-api-key"] = apiAccessKey;
|
|
}
|
|
return headers;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add tabs to the home page**
|
|
|
|
In `src/app/page.tsx`, import the component:
|
|
|
|
```ts
|
|
import { RenweiCopyOptimizerPanel } from "../components/renwei-copy-optimizer-panel";
|
|
```
|
|
|
|
Add state inside `Home`:
|
|
|
|
```ts
|
|
const [activeTab, setActiveTab] = useState<"geo" | "copy">("geo");
|
|
```
|
|
|
|
Replace the single `<div className="workflow-grid">...</div>` block with:
|
|
|
|
```tsx
|
|
<nav className="app-tabs" aria-label="功能标签">
|
|
<button
|
|
className={activeTab === "geo" ? "active-tab" : undefined}
|
|
onClick={() => setActiveTab("geo")}
|
|
type="button"
|
|
>
|
|
GEO 文章优化
|
|
</button>
|
|
<button
|
|
className={activeTab === "copy" ? "active-tab" : undefined}
|
|
onClick={() => setActiveTab("copy")}
|
|
type="button"
|
|
>
|
|
普通文案优化
|
|
</button>
|
|
</nav>
|
|
{activeTab === "geo" ? (
|
|
<div className="workflow-grid">
|
|
<ArticleInputForm
|
|
isSubmitting={busyAction === "analyze"}
|
|
value={input}
|
|
onChange={setInput}
|
|
onSubmit={analyze}
|
|
/>
|
|
<FactCardEditor
|
|
factCard={factCard}
|
|
isSaving={busyAction === "confirm"}
|
|
onChange={setFactCard}
|
|
onConfirm={confirmFactCard}
|
|
/>
|
|
<OptimizedPreview
|
|
article={optimizedArticle}
|
|
jobId={jobId}
|
|
/>
|
|
<QaReportPanel report={qaReport} />
|
|
<PerformanceCalibrationPanel
|
|
apiAccessKey={apiAccessKey}
|
|
jobId={jobId}
|
|
key={`${jobId ?? "no-job"}-${optimizedArticle?.revision ?? "no-revision"}`}
|
|
optimizedRevision={optimizedArticle?.revision ?? null}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<RenweiCopyOptimizerPanel apiAccessKey={apiAccessKey} />
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 5: Add styles**
|
|
|
|
Append to `src/app/globals.css`:
|
|
|
|
```css
|
|
.app-tabs {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.app-tabs button {
|
|
background: #ffffff;
|
|
color: #172033;
|
|
}
|
|
|
|
.app-tabs button.active-tab {
|
|
background: #172033;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.copy-optimizer-grid {
|
|
display: grid;
|
|
gap: 1rem;
|
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
|
}
|
|
|
|
.copy-result {
|
|
background: #f6f7f9;
|
|
border: 1px solid #e5e9f0;
|
|
border-radius: 6px;
|
|
color: #172033;
|
|
margin: 0;
|
|
min-height: 10rem;
|
|
overflow: auto;
|
|
padding: 0.8rem;
|
|
white-space: pre-wrap;
|
|
}
|
|
```
|
|
|
|
Also extend the existing mobile media query:
|
|
|
|
```css
|
|
.copy-optimizer-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run E2E test to verify GREEN**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npx playwright test tests/e2e/renwei-copy.spec.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Commit Task 4**
|
|
|
|
```bash
|
|
git add src/components/renwei-copy-optimizer-panel.tsx src/app/page.tsx src/app/globals.css tests/e2e/renwei-copy.spec.ts
|
|
git commit -m "新增普通文案优化标签页"
|
|
```
|
|
|
|
## Task 5: Full Verification
|
|
|
|
**Files:**
|
|
- No new files.
|
|
|
|
- [ ] **Step 1: Run lint**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run lint
|
|
```
|
|
|
|
Expected: PASS with no ESLint errors.
|
|
|
|
- [ ] **Step 2: Run unit and API tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Run production build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run build
|
|
```
|
|
|
|
Expected: PASS and Next.js build completes.
|
|
|
|
- [ ] **Step 4: Run targeted E2E**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npx playwright test tests/e2e/renwei-copy.spec.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Check public-repo safety before any push**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git status --short --ignored=matching
|
|
rg -n "auth\\.token|secretKey|healthsource" . --glob '!node_modules/**' --glob '!.next/**' --glob '!.open-next/**' --glob '!deploy/*.toml'
|
|
```
|
|
|
|
Expected: no accidental credentials in tracked changes.
|
|
|
|
- [ ] **Step 6: Confirm verification left no uncommitted fixes**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git status --short
|
|
```
|
|
|
|
Expected: no uncommitted files from the implementation tasks. If this command prints implementation files, return to the task that introduced them, add a specific regression test if the fix changes behavior, rerun the relevant verification command, and commit those exact files with the Chinese message `修复普通文案优化验证问题`.
|
|
|
|
## Self-Review
|
|
|
|
- Spec coverage: Tasks 1-4 cover shared data models, prompt rules, API, frontend tab, result display, copy button, and tests. Task 5 covers lint, unit tests, build, E2E, and credential scan.
|
|
- Scope check: The plan does not add history, exports, database tables, publishing calibration, multi-turn editing, or vendored `renwei-writing` content.
|
|
- Type consistency: `CopyOptimizationRequest`, `CopyOptimizationResult`, `CopyChangeNote`, `CopyAiTasteCheck`, `copyOptimizationRequestSchema`, `copyOptimizationResultSchema`, `RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT`, `buildRenweiCopyOptimizationPrompt`, and `optimizeRenweiCopy` use consistent names across tasks.
|