Compare commits

38 Commits
Author SHA1 Message Date
czj b4472fe348 新增案例库浏览器登录态 2026-07-08 17:02:37 +08:00
czj fd1b792f2c 完善案例库最终验证修正 2026-07-08 14:22:26 +08:00
czj cf74ad920c 新增人味文案评分和效果学习 2026-07-08 14:05:40 +08:00
czj c9ca93faa0 新增案例库列表和详情页 2026-07-08 13:59:26 +08:00
czj 1f382c280c 修复LLM审计回调类型 2026-07-08 13:58:56 +08:00
czj f09fd5afe8 新增优化案例API 2026-07-08 13:48:03 +08:00
czj e867f4f07e 接入人味文案案例自动保存 2026-07-08 13:33:45 +08:00
czj 79913ef7f7 接入文章优化案例自动保存 2026-07-08 13:24:50 +08:00
czj ee98c3304b 新增统一案例存储仓储 2026-07-08 13:19:35 +08:00
czj 01c02049d4 新增LLM审计摘要边界 2026-07-08 13:10:36 +08:00
czj de539aecb0 新增优化案例领域模型 2026-07-08 13:08:12 +08:00
czj 8b10969c49 添加统一案例库实施计划 2026-07-08 13:06:15 +08:00
czj 3fb9db8474 设计长期优化案例存储机制 2026-07-08 11:30:05 +08:00
czj bf7be12dad 新增普通文案优化标签页 2026-07-02 14:38:39 +08:00
czj 3919887a8d 新增普通文案优化接口 2026-07-02 14:35:35 +08:00
czj 27d7a44ad0 新增人味儿文案优化提示词 2026-07-02 14:34:29 +08:00
czj c2010fa8f4 新增普通文案优化数据校验 2026-07-02 14:32:20 +08:00
czj 31d53d3165 优化结果预览渲染体验 2026-07-02 13:28:41 +08:00
czj e0b0e9eb8a 补充样例文章验收测试说明 2026-07-01 15:12:48 +08:00
czj 66fee182f5 验证样例文章验收冒烟流程 2026-07-01 15:12:13 +08:00
czj 762380c937 新增样例文章验收运行脚本 2026-07-01 15:05:07 +08:00
czj 7f6557cd4e 新增样例文章实时验收测试 2026-07-01 15:02:41 +08:00
czj 5164eeef4a 新增样例页面验收流程工具 2026-07-01 15:01:28 +08:00
czj b699c227bd 新增样例验收报告工具 2026-07-01 14:59:36 +08:00
czj 1781088640 新增样例文章加载器 2026-07-01 14:58:13 +08:00
czj 34e6da23b0 新增样例文章验收实现计划 2026-07-01 14:52:29 +08:00
czj 28bf0b4135 新增样例文章自动验收工作流设计 2026-07-01 14:40:55 +08:00
czj 016b653b76 改造一键流式优化首页 2026-07-01 13:25:07 +08:00
czj 8a1ceb3b14 新增一键流式优化接口 2026-07-01 13:15:14 +08:00
czj a2183a34af 新增一键优化流式编排 2026-07-01 13:09:54 +08:00
czj 382f468267 新增优化流事件契约 2026-07-01 13:08:44 +08:00
czj f9b8bae714 放宽标题和事实卡优化约束 2026-07-01 13:07:44 +08:00
czj 9dad89ae43 新增一键流式优化实施计划 2026-07-01 12:59:01 +08:00
czj d5cf51f62d 新增一键流式优化主流程设计 2026-07-01 11:18:19 +08:00
czj 9dd2ad72ef 新增普通文案优化实施计划 2026-06-25 15:19:41 +08:00
czj 5294755def 更新三候选优化对比设计 2026-06-25 15:18:25 +08:00
czj c47036d28d 新增普通文案优化设计 2026-06-25 14:46:25 +08:00
czj b453800494 支持待确认事项逐条采纳 2026-06-25 13:51:41 +08:00
106 changed files with 20033 additions and 506 deletions
+137
View File
@@ -0,0 +1,137 @@
# GEO Agent Article Optimizer
This context defines the product language for the GEO article optimization workflow and its long-term learning records.
## Language
**优化案例**:
一次优化从原始输入、约束信息、优化结果到后续回看的完整业务记录。
_Avoid_: 任务, 作业, job, 单个导出文件
**文章优化案例**:
长期存储覆盖的优化案例类型之一,围绕文章原文、事实卡、优化稿、质量检查、导出文件和发布表现形成记录。
_Avoid_: 所有文案工具
**人味文案优化案例**:
长期存储覆盖的优化案例类型之一,围绕短文案原文、优化目标、优化强度、优化后文案、修改说明、AI 味检查和发布表现形成记录。
_Avoid_: 仁微文案优化, 普通文章优化
**原始输入**:
用户提交给优化流程的原文、图片说明或链接、目标平台和补充要求,是优化案例可复用和可解释的起点。
_Avoid_: 请求体, 日志, 草稿
**优化案例库**:
用于回看、复用和重新导出优化案例的长期业务资产集合。
_Avoid_: 文件归档, 历史列表
**统一案例库**:
同时容纳文章优化案例和人味文案优化案例的优化案例库,通过案例类型区分不同详情内容。
_Avoid_: 两套案例库, 分散入口
**案例类型**:
区分优化案例业务形态的分类,目前包括文章优化案例和人味文案优化案例。
_Avoid_: 技术路由, 数据表名
**效果学习库**:
沉淀发布表现、质量评分和校准结论的长期业务资产集合,用来比较生成时的质量预期与发布后的真实效果。
_Avoid_: 日志仓库, 埋点表, 监控数据
**评分口径**:
某类优化案例用于判断优化质量和预期效果的评价标准,不同案例类型可以有不同评分口径。
_Avoid_: 全局统一分数, 发布指标
**LLM 审计摘要**:
一次模型调用的长期可追溯记录,只包含供应商、模型、任务、耗时、校验状态、错误摘要和内容指纹等元数据。
_Avoid_: 完整 prompt, 完整 response, 控制台日志
**调试证据**:
为排查模型或 schema 问题而临时保留的完整模型输入输出,应有明确过期时间。
_Avoid_: 长期训练样本, 默认日志
**中间稿**:
优化流程在最终版本之前生成的草稿或修复稿,默认不是长期业务记录。
_Avoid_: 最终优化稿, 优化案例
**过程摘要**:
对优化流程中关键步骤、失败规则、修复轮次和耗时的轻量记录,不包含每一轮中间稿全文。
_Avoid_: 完整草稿历史, 前端进度
**特殊样本**:
因失败复盘、人工采纳对比或用户显式标记而保留更多过程内容的优化案例。
_Avoid_: 普通优化案例, 调试证据
**失败案例**:
优化流程未产出正式产物但仍被保存的优化案例,用于排查失败阶段、错误原因和输入质量。
_Avoid_: 系统日志, 被丢弃的请求
**归档案例**:
从默认列表中隐藏但仍保留长期记录的优化案例,可用于追溯、恢复或复盘。
_Avoid_: 硬删除, 清理缓存
**正式产物**:
优化案例中用户可回看、复用或下载的最终业务结果,包括结构化结果和导出文件快照。
_Avoid_: 中间稿, 临时预览
**结果版本**:
同一优化案例内一次成功优化产出的正式产物版本;重新运行优化会产生新的结果版本。
_Avoid_: 覆盖结果, 临时草稿
**修改说明**:
人味文案优化中说明原文与优化后文案差异、修改原因和不确定性的正式产物组成部分。
_Avoid_: 调试日志, 模型解释
**AI 味检查**:
人味文案优化中对宣传腔、套路句、格式痕迹和语气失真等风险的正式产物组成部分。
_Avoid_: QA 报告, 模型日志
**结构化结果**:
系统可查询和再处理的最终优化稿、QA 报告、评分与校准数据,是正式产物的事实源。
_Avoid_: 导出文件, 页面展示内容
**导出文件快照**:
用户在某次优化完成后可下载的 Markdown、DOCX 或 QA JSON 文件版本,用来保留当时实际交付的文件形态。
_Avoid_: 可随时重算的下载结果, 模板
**案例归属**:
优化案例所属的品牌、客户和项目标签,用于组织、检索和复用长期案例。
_Avoid_: 用户账号, 权限策略, 全局流水账
**客户**:
优化案例服务的外部或内部业务对象,可以拥有多个品牌或项目。
_Avoid_: 登录用户, API 调用者
**项目标签**:
给优化案例附加的业务分组标记,例如活动、产品线、行业专题或阶段性服务包。
_Avoid_: 技术标签, 日志级别
**案例备注**:
用户为优化案例补充的自由文本说明,用于记录复盘判断、客户反馈或后续动作。
_Avoid_: 系统日志, QA 报告
**发布目标**:
优化内容计划使用的渠道或场景,例如官网、媒体文章、公众号、朋友圈、私域、短视频口播或销售私信。
_Avoid_: 技术平台, 存储位置
**发布记录**:
某个结果版本实际发布到目标平台后的业务记录,包含平台、地址、发布时间和状态。
_Avoid_: 导出文件, 优化结果
**效果快照**:
在某个时间窗口采集到的发布表现数据和反馈摘要,用于比较质量预期与真实效果。
_Avoid_: 实时监控, 普通日志
**手动表现录入**:
用户人工录入效果快照的方式,是效果学习库的第一阶段数据来源。
_Avoid_: 平台 API 集成, 自动采集
**数据保留策略**:
对不同类型长期数据和临时数据设定不同保留边界的业务规则。
_Avoid_: 数据库清理任务, 备份策略
**长期保留**:
默认持续保存的业务资产状态,适用于优化案例、正式产物、发布记录和效果快照。
_Avoid_: 永不删除, 临时缓存
**自动过期**:
临时数据在达到约定时间后自动失效或删除的保留方式,主要适用于调试证据。
_Avoid_: 手动清理, 长期保留
+31
View File
@@ -82,6 +82,37 @@ npm run build
npx playwright test
```
## Sample Article E2E
Run the live sample-article browser workflow:
```bash
npm run test:e2e:samples
```
The runner reads `.env.local`, starts an isolated local Next.js server, loads
`samples/articles/*.json`, opens the web UI with Playwright, runs each sample
through the one-click optimization flow, validates authenticated exports, and
writes a report under:
```text
test-results/geo-sample-flow/<timestamp>/
```
Useful options:
```bash
npm run test:e2e:samples -- --limit 1
npm run test:e2e:samples -- --sample title-quality
npm run test:e2e:samples -- --headed
npm run test:e2e:samples -- --reuse-server --base-url http://localhost:3000
```
This workflow uses the real configured LLM provider by default. It requires
`API_ACCESS_KEY` and the provider API key in `.env.local` or the shell
environment. Reports and exports are written to `test-results/`, which is
ignored by Git.
## Cloudflare Workers Deployment
Cloudflare deployment is manual. Pushing to Git does not deploy or hot-update
@@ -0,0 +1,3 @@
# Unified Optimization Case Library
We will model long-term storage as a unified optimization case library rather than separate archives for article optimization and human-tone copy optimization. Cases are saved automatically during optimization so original inputs, audit summaries, process summaries, and formal outputs are not lost; users can enrich them afterward with titles, ownership metadata, tags, and notes. Re-running optimization from a case creates a new result version rather than overwriting prior output, preserving comparison and learning history. The shared library supports type-based filtering, shared ownership metadata, shared LLM audit summaries, and shared list/detail navigation, while each case type keeps type-specific detail modules. Both case types include a first-version entry point and empty state for publication performance and learning feedback so the case library can also become the performance learning library. Publication records and performance snapshots are shared concepts, but quality scoring uses type-specific rubrics so article quality and human-tone copy quality are not forced into one artificial score.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -14,11 +14,16 @@ The MVP is a local web application:
2. System extracts a candidate fact card.
3. User confirms or edits the fact card.
4. Confirmed fact card is saved as a reusable local brand template.
5. System optimizes the article under fact-card constraints.
6. System runs quality gates.
7. Failed checks trigger targeted rewriting for up to two rounds.
8. User previews optimized content and QA report.
9. User downloads Markdown and a basic Word document.
5. System generates three optimization candidates under fact-card constraints:
`标准GEO版`, `去AI味版`, and `综合增强版`.
6. System runs quality gates for each candidate.
7. Failed checks trigger targeted rewriting for up to two rounds inside each
candidate path.
8. System scores each candidate for GEO quality and AI-flavor/style quality.
9. User compares all candidates side by side, with `综合增强版` highlighted as
the recommended default.
10. User downloads Markdown, Word, and QA artifacts for the selected candidate
or the full comparison.
## Explicitly Out Of Scope
@@ -36,12 +41,15 @@ The MVP is a local web application:
flowchart TD
A["Input Article"] --> B["Auto Analyze"]
B --> C["Confirm Fact Card"]
C --> D["Optimize Article"]
D --> E["Quality Check"]
E -->|Pass| F["Preview Result"]
E -->|Fail| G["Targeted Rewrite"]
G --> D
F --> H["Download Markdown / Word"]
C --> D["Generate Three Candidates"]
D --> E1["标准GEO版 QA"]
D --> E2["去AI味版 QA"]
D --> E3["综合增强版 QA"]
E1 --> F["Compare Results"]
E2 --> F
E3 --> F
F --> G["Select Candidate"]
G --> H["Download Markdown / Word / QA"]
```
## Page Areas
@@ -78,17 +86,24 @@ Fields:
The user must resolve uncertain items before optimization starts.
### Optimized Result
### Candidate Comparison
Display:
- Candidate label: `标准GEO版`, `去AI味版`, or `综合增强版`.
- Method summary that explains what this candidate optimized for.
- Optimized title.
- Summary.
- Optimized body.
- Image suggestions.
- QA status and failed/warned rule count.
- GEO score.
- AI-flavor/style score.
- Timing.
- Changed sections.
The UI should mark content that needs user confirmation.
The UI should mark content that needs user confirmation and should make
`综合增强版` visually identifiable as the recommended default without hiding the
other two candidates.
### Quality Report
@@ -98,9 +113,11 @@ Display each gate as pass, warn, or fail, with evidence, reason, suggested fix,
Downloads:
- `optimized.md`
- `optimized.docx`
- `qa_report.json`
- Selected candidate: `optimized.md`, `optimized.docx`, `qa_report.json`.
- Full comparison: `variant_comparison.json`.
- If all candidate files are exported together, use stable Chinese labels in the
metadata and ASCII-safe file names on disk, such as
`standard-geo.md`, `anti-ai-flavor.md`, and `integrated-enhanced.md`.
## Internal Agent Nodes
@@ -110,11 +127,23 @@ The product is delivered as a simple web app, but the internals are split into e
flowchart LR
A["InputNormalizer"] --> B["FactExtractor"]
B --> C["UserConfirmedFactCard"]
C --> D["ArticleOptimizer"]
D --> E["QualityInspector"]
E -->|fail| F["TargetedRewriter"]
F --> E
E -->|pass/warn| G["Exporter"]
C --> D["OptimizationVariantPlanner"]
D --> E1["标准GEO版 ArticleOptimizer"]
D --> E2["去AI味版 ArticleOptimizer"]
D --> E3["综合增强版 ArticleOptimizer"]
E1 --> F1["QualityInspector + StyleScorer"]
E2 --> F2["QualityInspector + StyleScorer"]
E3 --> F3["QualityInspector + StyleScorer"]
F1 -->|fail| G1["TargetedRewriter"]
F2 -->|fail| G2["TargetedRewriter"]
F3 -->|fail| G3["TargetedRewriter"]
G1 --> F1
G2 --> F2
G3 --> F3
F1 --> H["VariantComparator"]
F2 --> H
F3 --> H
H --> I["Exporter"]
```
### LLM Provider Integration
@@ -133,12 +162,14 @@ DEEPSEEK_THINKING=disabled
Rules:
- `src/lib/llm/client.ts` exposes `generateText`, `generateJson<T>`, `isLlmConfigured`, and `getLlmProviderStatus`.
- `src/lib/llm/client.ts` exposes `generateText`, `generateJson<T>`,
`generateValidatedJson`, `isLlmConfigured`, and `getLlmProviderStatus`.
- `LLM_PROVIDER` defaults to `deepseek` when unset.
- DeepSeek is accessed through the OpenAI-compatible SDK with `baseURL` set to `https://api.deepseek.com`.
- `generateJson<T>` must use JSON output mode and prompts that explicitly require valid JSON only.
- Thinking mode is disabled by default for deterministic article rewrites and structured QA output.
- Missing credentials use deterministic local fallbacks so the MVP remains testable and usable without a live API key.
- Missing credentials and provider failures surface as explicit API errors; the
workflow must not silently fall back when the user expects live LLM behavior.
- Provider errors are normalized inside the LLM client before they reach workflow nodes or API routes.
### InputNormalizer
@@ -212,6 +243,49 @@ Forbidden:
- Change industry.
- Add exaggerated marketing promises.
### OptimizationVariantPlanner
Purpose: create a fixed set of candidate methods for one confirmed fact card.
The first comparison version always creates exactly three candidates:
| Variant Key | Label | Method |
| --- | --- | --- |
| `standard_geo` | `标准GEO版` | Current GEO rewrite behavior: fact-card fidelity, platform fit, clear structure, and QA repair. |
| `anti_ai_flavor` | `去AI味版` | GEO rewrite plus localized Stop Slop rules to remove AI-flavored prose patterns. |
| `integrated_enhanced` | `综合增强版` | Balances GEO density, factual trust, platform fit, readability, and AI-flavor reduction. |
Candidate generation may run sequentially in the first implementation to avoid
provider rate-limit problems and to keep progress events understandable. The
API response still returns the three candidates together for side-by-side
comparison.
### Stop Slop / AI-Flavor Rules
The `hardikpandya/stop-slop` repository is an MIT-licensed writing skill rather
than an installable runtime dependency. The original rules are mostly English
writing rules, so the product should not copy them blindly into Chinese article
optimization.
The first implementation uses localized guidance derived from the rule intent:
- Remove template openers and filler transitions, such as generic "本文将",
"值得注意的是", "从某种意义上", and repeated "通过...实现..." chains.
- Replace empty business language with concrete claims already present in the
fact card.
- Avoid formulaic contrast paragraphs that first list what something is not and
then reveal what it is.
- Avoid slogan-like short fragments that sound like pull quotes instead of
article prose.
- Prefer active, specific Chinese sentences with clear actors when the source
facts identify an actor.
- Vary paragraph length and sentence rhythm without using theatrical emphasis.
If substantial text from the upstream Stop Slop files is copied into this
repository, add the MIT license notice to the committed artifact. A distilled
Chinese rule set written for this product can live in source without vendoring
the upstream repository.
### QualityInspector
Purpose: convert the document's issue list into executable quality gates.
@@ -236,6 +310,53 @@ Examples:
- Delete or mark unsupported claims for hallucination risk.
- Warn instead of rewriting when image-text confidence is low.
### StyleScorer
Purpose: score each candidate for AI-flavor risk and naturalness without turning
style concerns into hard QA failures.
The first version returns a 0-50 score with five 0-10 dimensions:
| Dimension | Question |
| --- | --- |
| `directness` | Does the article state concrete points instead of announcing them? |
| `rhythm` | Do sentence and paragraph lengths vary naturally? |
| `specificity` | Are claims concrete and tied to the fact card? |
| `trust` | Does the prose avoid exaggerated intimacy, sweeping claims, and unsupported certainty? |
| `density` | Can obvious filler be removed without losing meaning? |
Scores below 35 should show a visible warning on the candidate card. They should
not block export.
### GeoScore
Purpose: reuse the existing GEO article performance rubric to score each
candidate for fact integrity, platform fit, search intent fit, answer density,
trust signal quality, and readability.
The score is computed from the candidate article plus its QA report. It is a
comparison aid for the three candidates, not a publishing guarantee.
### VariantComparator
Purpose: assemble the candidate list for the API response and frontend.
Each candidate result includes:
- `variant_key`
- `variant_label`
- `method_summary`
- `article`
- `qa_report`
- `geo_score`
- `style_score`
- `rewrite_rounds`
- `timing`
- `is_recommended`
`integrated_enhanced` is recommended by default unless it has a hard QA failure
and another candidate does not.
## Quality Gates
| Rule ID | Issue Prevented | First Version Behavior |
@@ -276,15 +397,25 @@ Examples:
The first version uses local SQLite plus an export folder.
The first implementation can reuse the existing `optimized_articles` and
`qa_reports` tables by saving each candidate as a normal article revision with
variant metadata inside the JSON payload. The matching QA report is saved
against that revision. A separate comparison-run table is out of scope until the
product needs historical side-by-side comparison beyond the immediate job
result.
```text
data/
app.db
exports/
job_xxx/
original.md
optimized.md
optimized.docx
qa_report.json
variant_comparison.json
standard-geo.md
anti-ai-flavor.md
integrated-enhanced.md
```
### `brand_template`
@@ -358,6 +489,9 @@ One revision of optimized content.
{
"job_id": "job_xxx",
"revision": 2,
"variant_key": "integrated_enhanced",
"variant_label": "综合增强版",
"method_summary": "同时优化GEO信息密度、事实可信度、平台适配和表达自然度。",
"title": "Optimized title",
"summary": "Optimized summary",
"body_markdown": "Optimized body in Markdown",
@@ -367,7 +501,22 @@ One revision of optimized content.
"suggestion": "Use product dashboard screenshot; avoid unrelated people photos"
}
],
"changed_sections": ["title", "first paragraph"]
"changed_sections": ["title", "first paragraph"],
"geo_score": {
"total": 8.1,
"rationale": "事实一致性与答案密度较好,平台适配仍需复核。"
},
"style_score": {
"total": 42,
"dimensions": {
"directness": 8,
"rhythm": 8,
"specificity": 9,
"trust": 8,
"density": 9
},
"warnings": []
}
}
```
@@ -408,9 +557,13 @@ Examples:
### QA Failure
Hard failures block export. Warnings allow export with visible confirmation prompts.
Hard failures do not block export in the current product direction. The app must
preserve export files and show visible warnings so the user can review the
candidate manually.
Failed checks trigger targeted rewrite for up to two rounds. After two failed rounds, the app stops rewriting and shows manual review fields.
Failed checks trigger targeted rewrite for up to two rounds inside each
candidate. After two failed rounds, the app stops rewriting that candidate and
shows manual review fields.
## Acceptance Criteria
@@ -420,9 +573,14 @@ The MVP is complete when:
2. System can extract a fact card and require user confirmation.
3. Confirmed fact card can be saved and reused as a local brand template.
4. System can generate an optimized article without changing confirmed facts.
5. System can generate a structured QA report for the 10 quality gates.
6. Hard failures block export until fixed or manually reviewed.
7. User can download Markdown and a basic Word document.
5. System can generate exactly three labeled candidates: `标准GEO版`,
`去AI味版`, and `综合增强版`.
6. System can generate a structured QA report for the 10 quality gates for each
candidate.
7. System can score and compare the three candidates for AI-flavor/style quality.
8. QA failures and low style scores remain visible but do not remove export links.
9. User can download Markdown, a basic Word document, QA JSON, and a comparison
JSON artifact.
## Minimum Test Samples
@@ -0,0 +1,265 @@
# 普通文案人味儿优化标签页设计
## 目标
在现有 GEO 智能文章优化器中增加一个简化版标签页,用于普通文案的轻量优化。用户粘贴一段文案后,可以直接得到更自然、更少 AI 味的版本,同时看到每一处改动说明和改后检查结果。
这个功能参考 `github.com/orange2ai/renwei-writing` 的写作方法论,但不把该仓库作为运行时依赖,也不复制其完整 skill 内容到本仓库。实现时只吸收核心原则:少动、保留作者手迹、不新增事实或场景、不写过度金句、改后检查被改动句子的 AI 味痕迹。
## 现状
当前首页 `src/app/page.tsx` 是单页工作台,包含:
- 文章输入。
- 候选事实卡确认。
- GEO 文章优化。
- 质量报告。
- 发布表现校准。
这条主流程适合 GEO 文章,但对普通文案过重。普通文案优化不需要事实卡、任务 ID、QA 门禁、导出文件或发布表现记录。
## 方案
在首页顶部增加应用级标签:
- `GEO 文章优化`:保持现有流程不变,仍作为默认标签。
- `普通文案优化`:新增独立小工具。
普通文案优化采用轻量 API,不落库、不创建 job、不生成导出文件。它复用现有 API 访问密钥校验和 LLM 客户端边界,保持本地与线上部署模型一致。
## 用户流程
```mermaid
flowchart TD
A["打开首页"] --> B["切换到普通文案优化"]
B --> C["粘贴原始文案"]
C --> D["选择修改强度和优化目标"]
D --> E["点击优化文案"]
E --> F["后端调用 LLM 生成结构化结果"]
F --> G["展示优化后文案"]
G --> H["展示逐处改动说明"]
G --> I["展示 AI 味检查"]
G --> J["复制结果"]
```
## 页面设计
### 顶部标签
标签放在标题区域下方或工作区上方,使用普通按钮样式即可。切换标签只影响页面内容,不改变访问密钥输入框的位置。
要求:
- 默认展示 `GEO 文章优化`
- 切到 `普通文案优化` 后隐藏现有主流程面板,显示简化工具。
- 切回 `GEO 文章优化` 时保留原来输入状态。
### 普通文案优化工具
采用双栏布局,移动端降为单栏。
左侧输入区:
- `原始文案`:必填 textarea。
- `优化目标`:普通文本输入,默认值为 `保留原意,减少 AI 味`
- `修改强度`select,选项为 `轻微整理``适度润色``更口语自然`,默认 `轻微整理`
- `补充要求`:可选 textarea,例如不要营销腔、保留口语、适合朋友圈等。
- `优化文案` 按钮。
右侧结果区:
- `优化后文案`:显示结构化返回中的 `optimized_text`
- `复制结果` 按钮。
- `改动说明`:逐条列出原句、改后句、原因和是否可还原。
- `AI 味检查`:列出检查项状态、证据和建议。
空状态文案保持克制,例如 `优化结果会显示在这里。`
## API 设计
新增路由:
```text
POST /api/copy/renwei-optimize
```
请求头:
- 继续使用现有 `x-api-key` 访问密钥规则。
请求体:
```json
{
"source_text": "原始文案",
"goal": "保留原意,减少 AI 味",
"intensity": "light",
"user_instructions": "保留作者语气"
}
```
字段规则:
- `source_text` 必填,去掉首尾空白后不能为空。
- `goal` 可选,空值时使用默认目标。
- `intensity` 只能是 `light``medium``conversational`
- `user_instructions` 可选。
响应体:
```json
{
"result": {
"optimized_text": "优化后的文案",
"change_notes": [
{
"original": "原句",
"revised": "改后句",
"reason": "改动原因",
"confidence": "confident",
"revertible": false
}
],
"ai_taste_checks": [
{
"rule_id": "promotion_tone",
"status": "pass",
"evidence": "未发现宣传腔新增",
"suggestion": ""
}
],
"warnings": []
}
}
```
## 数据模型
新增前后端共享类型:
```ts
type CopyOptimizationIntensity = "light" | "medium" | "conversational";
interface CopyOptimizationRequest {
source_text: string;
goal?: string;
intensity: CopyOptimizationIntensity;
user_instructions?: string;
}
interface CopyChangeNote {
original: string;
revised: string;
reason: string;
confidence: "confident" | "uncertain";
revertible: boolean;
}
interface CopyAiTasteCheck {
rule_id:
| "meaning_inflation"
| "promotion_tone"
| "formulaic_sentence"
| "format_trace"
| "chat_trace"
| "filler_hedging";
status: "pass" | "warn";
evidence: string;
suggestion: string;
}
interface CopyOptimizationResult {
optimized_text: string;
change_notes: CopyChangeNote[];
ai_taste_checks: CopyAiTasteCheck[];
warnings: string[];
}
```
不新增数据库表,不写入 D1/SQLite/R2。
## Prompt 设计
新增 prompt builder,例如:
```text
buildRenweiCopyOptimizationPrompt(input)
```
系统提示目标:
- 你是中文普通文案编辑,不是营销代笔工具。
- 只改真正打绊的地方,默认少动。
- 保留作者的位置、语气、口头习惯和可识别手迹。
- 不凭空新增时间、地点、数字、案例、情绪或场景。
- 不把普通句子改成宣传腔、排比、格言、万能展望或过度金句。
- 拿不准时白描,不拔高。
- 改后只检查被改动句子是否引入 AI 味。
- 必须逐处说明改了什么、为什么改。
- 拿不准的改动标记为 `confidence: "uncertain"``revertible: true`
- 返回 JSON,不返回 Markdown 代码块或解释性闲聊。
强度解释:
- `light`:尽量只修顺病句、错别字、明显卡顿。
- `medium`:允许调整句序和连接,但不改变作者表达的粗糙感。
- `conversational`:让口吻更像真人说话,但不凭空增加表演性口语。
## 错误处理
- 缺少或错误访问密钥:沿用现有 `401` 行为。
- `source_text` 为空:返回 `400` 和中文错误 `请输入需要优化的文案`
- LLM 未配置或调用失败:返回明确错误,不静默生成伪结果。
- LLM 返回结构不合法:通过现有结构化校验路径报错,提示 `文案优化结果格式不正确,请重试`
## 测试计划
单元测试:
- Prompt 包含少动、保留作者手迹、不新增事实、逐处说明、AI 味检查等关键规则。
- 请求体验证会拒绝空文案和非法强度。
- LLM 结果校验能接受合法结构,拒绝缺少 `optimized_text` 或数组字段类型错误的结果。
API 测试:
- 未提供访问密钥时返回 `401`
- 空文案返回 `400`
- mock LLM 返回合法结果时,API 返回 `result.optimized_text``change_notes``ai_taste_checks`
- LLM 抛错时错误向前端显式暴露。
E2E 测试:
- 首页默认仍显示 GEO 文章优化。
- 用户切换到 `普通文案优化`
- 填入文案并点击 `优化文案`
- 页面显示 `优化后文案``改动说明``AI 味检查`
验证命令:
```bash
npm run lint
npm test
npm run build
```
## 不做范围
- 不做历史记录。
- 不做批量文案。
- 不生成 Markdown、DOCX 或 QA JSON 导出。
- 不接入发布表现校准。
- 不把普通文案优化结果写入数据库。
- 不自动安装或 vendor `renwei-writing` 仓库。
- 不复制参考 skill 的完整文本到本项目。
- 不做多轮对话式改稿。
## 实现边界
优先复用现有结构:
- API 访问控制复用 `src/lib/api/auth.ts`
- LLM 调用复用 `src/lib/llm/client.ts` 的结构化 JSON 路径。
- Prompt 与现有 `src/lib/llm/prompts.ts` 同层维护。
- 页面组件放在 `src/components`,避免继续膨胀 `src/app/page.tsx`
为了保持首页可维护,实施时建议顺手把现有 GEO 主流程抽成一个组件,例如 `GeoArticleOptimizerWorkspace`,再新增 `RenweiCopyOptimizerPanel`。这属于服务本功能的局部整理,不改变现有业务行为。
@@ -0,0 +1,329 @@
# GEO 样例文章 E2E skill 工作流设计
## 目标
设计一个未来可以包装成 Codex skill 的自动验收工作流。用户启动该 skill 后,系统使用 `samples/articles/*.json` 中的测试文章,打开当前网页,按真实用户路径完成一键流式优化,并生成可复查的测试报告。
默认模式必须使用当前真实 LLM 配置,不使用 mock,不静默降级。本工作流的定位是“近似人工验收”的自动化,而不是快速单元测试或只测 API 的冒烟测试。
## 当前实现依据
当前首页主路径是一键流式优化:
- 用户填写 `访问密钥`
- 用户在文章输入区填写 `文章内容``图片描述或图片链接``目标平台``用户要求`
- 用户点击 `开始优化`
- 前端调用 `POST /api/jobs/optimize-stream`,读取 NDJSON 流式事件。
- 页面依次展示事实卡、草稿或终稿、质量报告、导出入口。
- 终稿到达后显示 `optimized.md``optimized.docx``qa_report.json` 三个导出链接。
现有 `tests/e2e/mvp.spec.ts` 仍保留旧的“分析文章 -> 确认事实卡 -> 开始优化”路径,后续实现时应新增或替换为当前一键流式路径。
样例文件形状为:
```json
{
"name": "Sample name",
"input": {
"title": "",
"body": "",
"image_lines": "",
"platform": "official_site",
"user_instructions": ""
},
"expectedHardFailures": [],
"expectedWarnings": []
}
```
## 工作流范围
本设计覆盖:
- 本地启动或复用 Next.js 开发服务。
- 读取 `samples/articles` 下的 JSON 样例。
- 使用 Playwright 打开网页并完成当前 UI 主流程。
- 使用真实 LLM 跑事实提取、文章优化、QA 和必要的定向修复。
- 验证页面结果、质量报告和导出文件。
- 为每篇样例保存截图、日志、耗时和结构化结论。
- 输出汇总报告,供用户判断当前实现是否可交付或需要排查。
本设计不覆盖:
- 自动修复失败。
- token 级模型流式输出验证。
- 线上 Cloudflare 环境压测。
- 多用户协作、权限矩阵或发布平台 API。
- 把测试报告提交进 Git。
## 总体架构
建议把能力拆成三层。
第一层是仓库内 runner,作为真实执行入口。它负责读取配置、启动服务、调用 Playwright、收集报告。未来可以通过 `npm run test:e2e:samples` 或等价命令运行。
第二层是 Playwright 测试和辅助库。它负责页面操作、等待流式结果、下载导出文件、截图和 trace 采集。
第三层是 Codex skill 包装器。skill 本身保持轻薄,只做前置说明、参数解析、运行仓库命令、总结结果。仓库脚本仍是行为来源,避免把项目选择器和业务断言硬编码进用户级 skill。
## 默认执行模式
默认模式为 `live`
-`.env.local` 和当前 shell 环境读取配置。
- 要求 `API_ACCESS_KEY` 可用,除非 `API_AUTH_DISABLED=true`
- 要求当前 `LLM_PROVIDER` 对应的真实 API key 已配置。
- 不安装 mock handler。
- 不把 LLM/provider/schema 错误转换成通过。
- 每篇样例顺序执行,默认并发为 1,避免真实 LLM 请求互相干扰或触发限流。
可选模式可以后续增加:
- `--sample <name-or-glob>`:只跑指定样例。
- `--limit <n>`:只跑前 n 篇样例。
- `--headed`:打开可见浏览器,方便人工观察。
- `--reuse-server`:复用已有服务。
- `--mock`:开发调试用 mock 模式,但不是默认入口。
## 服务启动策略
默认策略是启动专用本地服务:
1. 读取 `.env.local`,合并当前 shell 环境。
2. 为本次运行创建报告目录。
3. 设置 `APP_DATA_DIR=<reportDir>/app-data`,隔离 SQLite 数据和导出文件。
4. 找一个可用端口,优先 `3000`,被占用时使用下一个可用端口。
5. 运行 `npm run dev -- --port <port>`
6.`curl` 或 Playwright request 确认首页返回 `200 OK`
7. 捕获服务 stdout/stderr,保存到报告目录。
如果用户显式传入 `--reuse-server`,runner 可以复用已有服务,但报告必须标记:
- `server_mode: "reused"`
- 无法保证数据隔离。
- 无法完整证明 LLM 日志来自本次运行,除非用户同时提供服务日志。
## LLM 参与证明
由于默认是 live 模式,runner 必须做两类证明。
前置证明:
- `LLM_PROVIDER` 有效。
- DeepSeek 模式下 `DEEPSEEK_API_KEY` 存在。
- OpenAI 模式下 `OPENAI_API_KEY` 存在。
- 报告只记录 provider 和 model,不记录密钥。
运行证明:
- 专用服务模式下,从服务日志中提取 `[llm:start]``[llm:response]``[llm:validated]``[llm:error]`
- 每篇成功样例至少应看到 `fact_extractor``article_optimizer``quality_inspector` 任务。
- `targeted_rewriter` 只在 QA 失败触发修复时出现,不作为所有样例的必需任务。
- 如果 LLM 报错或 schema 校验失败,该样例失败,但报告保留真实错误和阶段。
## 样例读取
runner 按文件名排序读取 `samples/articles/*.json`,保证运行顺序稳定。
每个样例先做静态校验:
- `input.body` 必须是非空字符串。
- `input.platform` 必须是当前系统支持的发布平台。
- `input.image_lines``input.user_instructions` 缺失时按空字符串处理。
- `expectedHardFailures``expectedWarnings` 缺失时按空数组处理。
无效样例不进入浏览器流程,直接记为 `sample_invalid`
## 浏览器操作流程
每篇样例使用新的页面,减少状态串扰。
页面操作按当前实现执行:
1. 打开首页。
2. 如果存在 `访问密钥` 输入框,填写 API key。
3. 填写文章输入框。当前标签是 `文章内容`,后续实现可兼容 `粘贴文章`
4. 填写 `图片描述或图片链接`
5. 选择 `目标平台`
6. 填写 `用户要求`
7. 点击 `开始优化`
8. 等待事实卡区从待生成状态进入可见事实卡状态。
9. 等待结果区出现草稿、终稿或完成提示。
10. 等待 `质量报告` 出现。
11. 等待 `optimized.md``optimized.docx``qa_report.json` 三个导出入口出现。
等待终稿的默认超时建议为每篇 10 分钟。长文和真实 LLM 可能较慢,超时值必须可通过参数覆盖。
## 导出验证
当前导出链接是页面上的普通 `<a>`,但导出 API 仍需要 `x-api-key`。普通浏览器点击无法附加自定义 header。因此测试拆成两层:
- UI 层:确认页面显示三个导出入口。
- API 层:用 Playwright request 或 Node fetch 携带 `x-api-key` 请求导出 URL,确认文件可读。
导出断言:
- `optimized.md` 返回 200,正文非空,并包含优化标题或正文片段。
- `optimized.docx` 返回 200content-type 或文件头符合 Word 文档预期。
- `qa_report.json` 返回 200,可解析为 JSON,并包含 `overall_status``checks`
如果未来产品要求“用户直接点击即可下载”,应另加一个点击下载断言。按当前实现,该断言预期会暴露访问密钥无法通过 `<a>` header 传递的问题。
## QA 期望对照
`expectedHardFailures``expectedWarnings` 用于校准样例意图,但第一版不把它们作为硬性失败条件。
原因是 live LLM 输出会波动,而且优化后文章可能已经修复了原文缺陷。测试报告应记录:
- 样例预期的 hard failures。
- 样例预期的 warnings。
- 实际 QA 中 fail/warn/pass 的 rule_id。
- 预期缺陷是否被命中。
- 预期缺陷是否被修复或未复现。
这些差异在 `summary.md` 中作为“质量对照”呈现,不影响浏览器全流程是否通过。
## 失败分类
每篇样例只能有一个主失败分类,方便汇总:
- `preflight_failed`:环境、服务、密钥或 LLM 配置不满足。
- `sample_invalid`:样例 JSON 不符合最低要求。
- `page_flow_failed`:页面选择器、按钮、导航或 UI 状态异常。
- `stream_timeout`:超过单篇超时仍未出现终稿或失败提示。
- `stream_failed`:页面或捕获的流式事件返回失败。
- `llm_failed`LLM provider、网络、额度、schema 校验等真实 LLM 错误。
- `export_failed`:导出入口缺失或带密钥请求导出文件失败。
- `console_error`:页面出现未允许的 console error 或 pageerror。
- `unknown_failed`:无法归类的异常。
失败时必须保留:
- 最后一张页面截图。
- 当前 URL。
- 控制台日志。
- 网络请求摘要。
- 服务日志片段。
- 如果能提取到,保留 LLM task 和错误文本。
## 报告输出
报告目录建议为:
```text
test-results/geo-sample-flow/<timestamp>/
```
目录结构:
```text
summary.json
summary.md
server.log
samples/
<sample-slug>/
result.json
console.jsonl
network.jsonl
final.png
failure.png
trace.zip
exports/
optimized.md
optimized.docx
qa_report.json
```
`summary.json` 保存机器可读信息:
```json
{
"started_at": "2026-07-01T00:00:00.000Z",
"mode": "live",
"provider": "deepseek",
"model": "deepseek-v4-pro",
"base_url": "http://127.0.0.1:3000",
"totals": {
"passed": 0,
"failed": 0,
"skipped": 0
},
"samples": []
}
```
每篇 `result.json` 保存:
```json
{
"file": "samples/articles/title-quality.json",
"name": "title quality sample",
"status": "passed",
"duration_ms": 180000,
"job_id": "job_xxx",
"qa_status": "warn",
"qa_fail_rules": [],
"qa_warn_rules": ["title_quality"],
"expected_hard_failures": [],
"expected_warnings": ["title_quality"],
"exports": {
"optimized.md": "passed",
"optimized.docx": "passed",
"qa_report.json": "passed"
},
"llm_tasks": ["fact_extractor", "article_optimizer", "quality_inspector"]
}
```
`summary.md` 面向人阅读,包含:
- 本次运行环境。
- 总通过率。
- 每篇样例的状态、耗时、QA 状态、失败原因。
- LLM 任务参与情况。
- 导出文件校验结果。
- 需要人工复查的问题列表。
## 安全和 Git 边界
报告目录可能包含模型输出、客户文章、服务日志和导出文件,不能提交进 Git。
后续实现时应确认:
- `test-results/``.gitignore` 忽略。
- 报告不写入真实 API key。
- 日志中的 `x-api-key`、Authorization header 和 provider key 必须脱敏。
- 不清理或覆盖用户已有 `data/`,除非 runner 启动了自己的隔离 `APP_DATA_DIR`
## 后续实现建议
第一阶段:
- 新增 repo 内 sample runner 和 Playwright 辅助函数。
- 新增 `npm run test:e2e:samples`
- 让 runner 默认 live 模式、顺序跑全部样例。
- 更新或替换旧的 `tests/e2e/mvp.spec.ts`,避免继续测试已下线的分步主路径。
第二阶段:
- 增加 `--sample``--limit``--headed``--reuse-server` 参数。
- 报告中加入 LLM 日志解析和 QA 期望对照表。
- 对失败样例生成更易读的 Markdown 复盘。
第三阶段:
- 创建 Codex skill。
- skill 读取本设计约定,调用 repo 命令。
- skill 在最终回复里摘要展示通过率、失败样例、报告路径和下一步排查入口。
## 验收标准
本设计实现后,用户启动 skill 应得到以下结果:
- 本地网页被真实打开。
- `samples/articles` 中的样例被逐篇填入页面。
- 每篇样例真实触发当前 LLM provider。
- 成功样例展示事实卡、优化结果、质量报告和三个导出入口。
- 导出文件通过带密钥请求验证可读。
- 失败样例保留可复查证据,而不是只返回一行超时或错误。
- 最终报告能回答三个问题:哪些样例跑通了,失败在哪里,当前实现是否值得人工继续验收。
@@ -0,0 +1,247 @@
# 一键流式优化主流程设计
## 目标
把当前 GEO 文章优化主流程从“分析文章 -> 确认事实卡 -> 开始优化”改成“一键开始优化”。用户只需要粘贴完整文章并点击开始,系统自动提取事实卡、生成草稿、检查质量、定向修复并产出终稿。
第一版要让优化结果区像主流 LLM 产品一样持续有反馈,但不做 token 级逐字模型流。真实内容以阶段事件形式到达,前端负责用光标、呼吸高亮和打字机播放已到达内容来制造流式感。
## 范围
本设计覆盖现有 GEO 主流程,不改变普通文案优化标签页设计,不引入批量任务、团队审批、发布平台 API 或多品牌事实库。
本设计覆盖并替代旧 MVP 设计中“必须确认事实卡后才能优化”的主路径要求。旧的确认接口和非流式优化接口可以暂时保留为兼容和调试入口,但新首页主路径不再依赖它们。
## 用户流程
```mermaid
flowchart TD
A["粘贴完整文章"] --> B["点击开始优化"]
B --> C["后端创建任务并提取事实卡"]
C --> D["事实卡区展示紧凑事实卡"]
C --> E["结果区进入生成中状态"]
E --> F["显示优化草稿"]
F --> G["质量检查"]
G --> H{"需要定向修复?"}
H -->|是| I["显示修复中状态并更新稿件"]
I --> J["显示终稿"]
H -->|否| J
D --> K["用户可编辑事实卡"]
K --> B
```
## 输入体验
文章输入取消标题和正文的强制区别。
- 主输入改成一个大文本框,标签为 `粘贴文章`
- `body` 仍是唯一必填内容。
- `title` 可空,放到“更多选项”里作为可选输入。
- 如果标题为空,后端把 `title` 规范化为空字符串并保存;不从正文首行派生内部标题。最终优化结果仍必须返回标题。
- 图片描述、目标平台、用户要求保持现有能力。
用户点击 `开始优化` 后,前端清空旧结果,进入流式读取状态。
## 事实卡体验
事实卡仍然是独立事实卡区,不放进进度区。
事实卡区缩小为紧凑展示面板:
- 默认展示公司全称或简称、产品/品牌、目标行业、目标受众、核心事实数量、待确认事项数量。
- `uncertain_items` 不再阻塞优化,而是以黄色提示展示。
- 用户可以展开编辑完整事实卡字段。
- 用户编辑事实卡后,再次点击 `开始优化`,使用当前事实卡重新跑一次优化。
第一版重新优化建议创建新 job,而不是覆盖原 job 的事实卡和导出结果。这样历史语义清楚,避免旧导出和新事实卡互相污染。
## 结果区流式感
结果区采用“草稿到终稿”的路线。
开始后立即显示动态结果框:
- 顶部显示当前阶段:`正在提取事实``正在生成草稿``正在检查质量``正在定向修复``终稿完成`
- 内容区显示空白稿纸、骨架段落和动态光标。
- 等待真实内容时,可以让光标旋转或跳动,让当前段落轻微呼吸高亮。
- 收到真实草稿后,前端用打字机效果播放已到达的草稿内容。
- 进入 QA 或修复阶段时,结果区保留当前稿件,并对正在处理的段落做呼吸高亮。
- 收到修复稿后,更新稿件内容,并可短暂高亮被替换的段落。
- 收到终稿后停止动效,展示终稿、导出链接和 QA 报告。
前端可以展示状态提示和骨架文本,但不能凭空生成正文内容伪装成模型输出。所有正文、标题、摘要、修复稿和终稿必须来自后端真实事件。
## API 设计
新增流式一键接口:
```text
POST /api/jobs/optimize-stream
```
该接口使用 `fetch` + `ReadableStream`,不使用浏览器原生 `EventSource`。原因是当前项目通过 `x-api-key` 传递访问密钥,`fetch` 可以继续安全地发送请求头。
请求头继续使用现有访问控制:
```text
x-api-key: <访问密钥>
content-type: application/json
```
请求体:
```json
{
"title": "",
"body": "完整文章内容",
"image_lines": "",
"platform": "official_site",
"user_instructions": "",
"fact_card": null
}
```
`fact_card` 为可选字段。为空时后端从文章提取事实卡;有值时表示用户编辑过事实卡,本次优化直接使用传入事实卡。
## 流式事件
响应采用 newline-delimited JSON,每行一个事件:
```json
{"type":"job_created","job":{"id":"job_xxx"}}
{"type":"fact_card_ready","job_id":"job_xxx","fact_card":{}}
{"type":"draft_started","job_id":"job_xxx","message":"正在生成优化草稿"}
{"type":"draft_ready","job_id":"job_xxx","article":{}}
{"type":"qa_started","job_id":"job_xxx","message":"正在检查质量"}
{"type":"qa_ready","job_id":"job_xxx","qa_report":{}}
{"type":"rewrite_started","job_id":"job_xxx","round":1}
{"type":"rewrite_ready","job_id":"job_xxx","round":1,"article":{}}
{"type":"final_ready","job_id":"job_xxx","optimized_article":{},"qa_report":{},"export_paths":{}}
```
失败事件:
```json
{"type":"failed","job_id":"job_xxx","stage":"draft","error":"LLM provider error: ..."}
```
事件规则:
- `fact_card_ready` 只更新事实卡区。
- `draft_ready``rewrite_ready``final_ready` 更新优化结果区。
- `qa_started``rewrite_started` 更新结果区状态和动效,不生成正文。
- `failed` 保留已经收到的事实卡或草稿,并显示失败阶段和真实错误。
## 后端编排
后端按以下顺序执行:
1. 校验访问密钥。
2. 校验输入,`body` 不能为空,`title` 可以为空。
3. 创建 article job。
4. 如果请求带 `fact_card`,解析并保存该事实卡;否则调用事实卡提取器。
5. 推送 `fact_card_ready`
6. 调用文章优化器生成草稿,推送 `draft_ready`
7. 调用质量检查器,推送 `qa_ready`
8. 如果 QA 有失败项,最多执行现有定向修复轮次,并推送 `rewrite_started` / `rewrite_ready`
9. 保存最终优化稿、QA 报告和导出文件。
10. 推送 `final_ready`
新增一个流式 workflow wrapper,复用 `optimizeArticle``inspectQualityWithLlm``rewriteFailedSections` 和导出逻辑。现有 `runOptimizationWorkflow` 保留给非流式兼容接口,避免一次改动同时重塑两条 API 路径。
## 数据模型调整
`ArticleInput.title``articleInputSchema.title` 需要允许空字符串。
事实卡保存需要支持“未人工确认但可作为本次优化约束”的状态。第一版新增 `OptimizationFactCard`,并把 repository 的 `saveFactCard` / `getFactCard` 类型从 `ConfirmedFactCard` 放宽到 `OptimizationFactCard``OptimizationFactCard` 保留 `confirmed_by_user?: boolean`,避免继续把“系统自动事实卡”伪装成“用户确认事实卡”。
`confirmedFactCardSchema` 可以保留给旧确认接口;新流式接口使用新的 `optimizationFactCardSchema`
## 错误处理
- 空正文:返回中文错误 `请输入需要优化的文章内容`
- 访问密钥缺失或错误:沿用现有 `401`
- LLM/provider 错误:推送 `failed`,显示真实错误,不静默 fallback。
- LLM 结构校验失败:推送 `failed`,保留阶段和校验摘要。
- 事实卡公司名、行业或受众为空:允许继续优化,但事实卡区提示约束不足,QA 中体现事实风险。
- `uncertain_items` 不阻塞优化,只作为事实卡区提示和 QA 风险信息。
- 用户中途再次点击开始优化:前端应中断当前 stream,清空旧动效,创建新优化请求。
## 前端状态
首页主状态新增:
- `streamStatus`: idle | running | completed | failed
- `streamStage`: 当前阶段标签
- `streamError`: 失败文本
- `displayedDraft`: 结果区正在播放或展示的稿件
- `finalArticle`: 终稿
- `factCardExpanded`: 事实卡是否展开
结果区组件负责:
- 解析阶段文章。
- 播放打字机效果。
- 展示光标和呼吸高亮。
- 在终稿到达后停止动效。
页面级组件负责:
- 发起 stream 请求。
- 读取 NDJSON。
- 将事实卡事件分发给事实卡区。
- 将稿件事件分发给结果区。
- 处理中断和失败。
## 兼容策略
保留现有接口:
- `POST /api/jobs`
- `POST /api/jobs/:jobId/confirm-fact-card`
- `POST /api/jobs/:jobId/optimize`
- `GET /api/jobs/:jobId/progress`
新首页主流程改用 `POST /api/jobs/optimize-stream`。旧接口可以继续服务测试、调试或未来非流式降级路径。
## 测试计划
单元测试:
- `articleInputSchema` 接受空标题但拒绝空正文。
-`optimizationFactCardSchema` 接受未确认事实卡和非空/空的可编辑字段。
- NDJSON 编码 helper 可以按行输出合法 JSON。
API 测试:
- 未提供访问密钥返回 `401`
- 空正文返回中文错误。
-`fact_card` 时会调用事实卡提取器,并输出 `job_created``fact_card_ready``draft_ready``qa_ready``final_ready`
-`fact_card` 时跳过事实卡提取器,直接使用传入事实卡。
- LLM 失败时输出 `failed`,错误包含阶段。
- QA 失败时仍写导出文件,并通过修复事件展示修复轮次。
组件测试:
- 开始优化后结果区显示动态光标和阶段文案。
- 收到 `draft_ready` 后打字机播放真实草稿。
- 收到 `rewrite_ready` 后结果区更新稿件并高亮变化。
- 收到 `final_ready` 后停止动效并显示导出链接。
- `fact_card_ready` 更新紧凑事实卡区,不进入进度区。
E2E 测试:
- 用户只粘贴正文,点击 `开始优化`
- 页面显示事实卡小面板。
- 优化结果区出现动态状态和草稿。
- 最终显示终稿、QA 报告和导出链接。
- 用户展开并编辑事实卡后,再次点击 `开始优化` 可以开始新一轮流式优化。
## 不做范围
- 不做 token 级模型逐字输出。
- 不把事实卡放进进度区。
- 不要求用户确认事实卡后才能优化。
- 不用前端伪造正文内容。
- 不移除旧 API。
- 不新增团队审批、批量队列或发布平台 API。
@@ -0,0 +1,573 @@
# 长期优化案例存储设计
## 目标
建立一套长期存储机制,把运行过程中的原始输入、关键约束、正式产物、过程摘要、LLM 审计摘要和发布后的效果反馈沉淀为可回看、可复用、可学习的优化案例库。
这套机制第一版覆盖两类案例:
- `文章优化案例`GEO 文章优化主流程产生的案例。
- `人味文案优化案例`:普通文案人味优化产生的案例。
案例库不仅是历史记录,也是效果学习库。它要能解释“当时为什么这样生成”,也要能继续记录“发布后表现如何”。
## 背景
当前项目已经有一部分长期数据能力:
- 文章优化会保存 `article_jobs`、事实卡、优化稿、QA 报告和导出路径。
- 文章导出在本地写入 `data/exports/<jobId>/`Cloudflare 路径写入私有 R2。
- 发布表现校准已有发布记录、效果快照、评分 run 和校准事件模型。
- 人味文案优化当前是一次性 API,返回优化后文案、修改说明和 AI 味检查,但不落库。
现有结构能支撑文章优化,但缺少统一案例入口、跨类型列表、案例元数据、失败案例保留、LLM 审计摘要和人味文案长期记录。
## 范围
第一版包含:
- 统一优化案例库。
- 文章优化案例和人味文案优化案例两种案例类型。
- 优化时自动保存案例。
- 成功和失败案例都保存。
- 结果版本化,重新运行优化生成新版本。
- 案例列表和案例详情页。
- 客户、品牌、项目标签、备注等可选归属信息。
- 基础关键词搜索和常用筛选。
- 发布记录、效果快照和效果学习入口。
- 两类案例各自一套评分口径,但共用发布记录和效果快照结构。
第一版不包含:
- 用户账号、团队权限或多租户隔离。
- 平台 API 自动采集效果数据。
- 完整全文搜索引擎和搜索高亮。
- 案例硬删除。
- 人味文案优化导出文件快照。
- 默认长期保存完整 prompt/response。
## 核心决策
### 统一案例库
文章优化和人味文案优化都进入同一个优化案例库,通过案例类型筛选。
理由:
- 两类案例都包含原始输入、优化结果、LLM 审计摘要、归属信息和发布表现。
- 用户需要从一个地方检索和复用历史成果。
- 详情内容按类型拆分即可,不需要拆成两个入口。
### 自动保存
案例在优化流程中自动保存,不要求用户优化完成后再手动点击保存。
运行开始时创建案例记录,后续持续补充过程摘要、结果版本、审计摘要和错误状态。用户可以在完成后补标题、客户、品牌、项目标签和备注。
### 失败案例保留
失败的优化也保存为失败案例。
失败案例不进入正式产物区,但详情页展示:
- 原始输入。
- 失败阶段。
- 错误摘要。
- LLM 审计摘要。
- 已产生的过程摘要。
### 结果版本
同一案例重新运行优化时生成新的结果版本,不覆盖旧结果。
发布记录绑定结果版本,而不是绑定整个案例。效果快照继续绑定发布记录。
关系是:
```text
优化案例 -> 结果版本 -> 发布记录 -> 效果快照 -> 校准结论
```
### LLM 审计边界
默认长期保存 LLM 审计摘要,不保存完整 prompt/response。
审计摘要包含:
- provider。
- model。
- task。
- duration_ms。
- schema 校验状态。
- 错误摘要。
- 输入内容 hash。
- 输出内容 hash。
完整 prompt/response 只作为调试证据临时保留,并应有过期时间。第一版可以先不实现完整调试证据存储,但不能把完整 prompt/response 默认写入长期库。
## 数据保留边界
### 文章优化案例
长期保存:
- 原文标题和正文。
- 图片说明或链接。
- 目标发布平台。
- 用户补充要求。
- 事实卡。
- 最终优化稿。
- QA 报告。
- 评分结果。
- 导出文件快照。
- 过程摘要。
- LLM 审计摘要。
- 发布记录、效果快照和校准结论。
默认不长期保存:
- 每一轮草稿和 rewrite 全文。
- 完整 prompt/response。
- 前端流式事件原始流。
### 人味文案优化案例
长期保存:
- 原始文案。
- 优化目标。
- 修改强度。
- 补充要求。
- 发布目标。
- 优化后文案。
- 修改说明。
- AI 味检查。
- warnings。
- 过程摘要。
- LLM 审计摘要。
- 发布记录、效果快照和校准结论。
第一版不保存:
- Markdown/DOCX 导出文件快照。
- 完整 prompt/response。
### 过程摘要
过程摘要只保存可复盘的轻量信息:
- 阶段名。
- 开始和结束时间。
- 耗时。
- 状态。
- 失败规则或错误摘要。
- rewrite 轮次。
- 是否产出正式版本。
中间稿全文默认不保存。特殊样本保留更多过程内容可以作为后续能力。
## 概念模型
### 优化案例
统一案例库中的顶层记录。
公共字段:
- `id`
- `case_type`: `article``human_copy`
- `title`
- `summary`
- `status`: `running``optimized``failed``archived`
- `customer_name`
- `brand_name`
- `project_tags`
- `notes`
- `created_at`
- `updated_at`
- `archived_at`
`customer_name``brand_name``project_tags``notes` 第一版都可选,允许优化完成后补录和编辑。
### 案例输入
保存每个案例的原始输入和约束信息。
文章优化输入包含:
- `source_title`
- `source_body`
- `image_inputs`
- `publish_platform`
- `user_instructions`
- `fact_card`
人味文案输入包含:
- `source_text`
- `goal`
- `intensity`
- `user_instructions`
- `publish_target`
### 结果版本
一次成功优化产出的正式产物版本。
公共字段:
- `id`
- `case_id`
- `version`
- `status`
- `result_summary`
- `process_summary`
- `created_at`
文章优化版本包含:
- 优化稿结构化结果。
- QA 报告。
- 评分 run。
- 导出文件快照引用。
人味文案版本包含:
- `optimized_text`
- `change_notes`
- `ai_taste_checks`
- `warnings`
- 人味文案评分 run。
### 发布记录
发布记录绑定到某个结果版本。
字段包含:
- `result_version_id`
- `publish_target`
- `url`
- `published_at`
- `status`
- `notes`
文章优化和人味文案优化共用这套概念。
### 效果快照
效果快照绑定发布记录。
第一版以手动表现录入为主,保留来源字段:
- `source`: `manual` 或未来的 `adapter:<name>`
- `window_label`
- `metrics`
- `feedback_summary`
- `raw_reference`
- `snapshot_at`
## 与现有模型的关系
### 文章优化
现有 `article_jobs``fact_cards``optimized_articles``qa_reports` 和导出存储仍可作为文章优化的底层实现。
第一版实现时建议引入统一案例元数据层,并让文章优化创建案例时关联现有 article job
- 新增统一案例记录。
- `article_jobs` 关联 `case_id`
- `optimized_articles.revision` 对应文章案例的结果版本号。
- `qa_reports` 继续按文章结果版本保存。
- 现有导出路径继续作为文章结果版本的导出文件快照。
这样可以避免一次性重写文章优化主链路。
### 人味文案优化
人味文案优化当前接口可以保留技术路由名 `POST /api/copy/renwei-optimize`,但产品文案统一使用“人味文案优化”。
第一版要把一次性 API 改为自动创建案例:
- 请求开始时创建 `human_copy` 案例。
- 保存原始文案、优化目标、强度、补充要求和发布目标。
- 成功后保存结果版本,包含优化后文案、修改说明、AI 味检查和 warnings。
- 失败时保存失败案例和错误摘要。
### 发布和效果
当前发布记录以文章 job 和 revision 为核心。统一案例库需要把发布记录的业务语义提升为“绑定结果版本”。
实现可以选择兼容迁移路径:
- 为发布记录增加统一结果版本引用。
- 文章旧字段保留兼容,逐步迁移到结果版本引用。
- 新的人味文案发布记录直接绑定结果版本。
效果快照继续绑定发布记录。
## 页面设计
### 入口
新增 `案例库` 入口。
案例库是一个工作台页面,不是营销页。默认展示案例列表。
### 案例列表
默认列:
- 标题/摘要。
- 案例类型。
- 客户/品牌。
- 项目标签。
- 发布目标。
- 状态。
- 创建时间。
- 最近更新时间。
默认隐藏归档案例。
搜索和筛选:
- 关键词搜索:标题、原文摘要、结果摘要、客户、品牌、备注。
- 筛选:案例类型、状态、发布目标、项目标签、创建时间。
- 第一版不做完整全文搜索引擎和高亮。
### 案例详情
详情页采用共享头部 + 类型专属模块。
共享头部:
- 标题。
- 案例类型。
- 状态。
- 客户/品牌。
- 项目标签。
- 备注。
- 创建和更新时间。
- 最新结果版本。
- LLM 审计摘要概览。
- 归档/恢复操作。
共享区域:
- 结果版本列表。
- 发布记录。
- 效果快照。
- 校准结论。
- 重新运行优化。
文章优化专属模块:
- 原文标题和正文。
- 图片输入。
- 目标平台和补充要求。
- 事实卡。
- 当前版本优化稿。
- QA 报告。
- 导出链接。
人味文案专属模块:
- 原始文案。
- 优化目标。
- 修改强度。
- 补充要求。
- 发布目标。
- 优化后文案。
- 修改说明。
- AI 味检查。
- warnings。
失败案例详情:
- 显示失败状态。
- 显示失败阶段和错误摘要。
- 显示原始输入和 LLM 审计摘要。
- 不展示正式产物区。
- 允许从失败案例重新运行优化,成功后生成新结果版本。
## API 设计
新增案例库 API
```text
GET /api/cases
GET /api/cases/:caseId
PATCH /api/cases/:caseId
POST /api/cases/:caseId/archive
POST /api/cases/:caseId/restore
POST /api/cases/:caseId/rerun
```
发布和效果 API
```text
POST /api/cases/:caseId/versions/:versionId/publications
GET /api/cases/:caseId/versions/:versionId/publications
POST /api/publications/:publicationId/performance
```
现有文章优化接口继续保留,但需要在运行过程中自动创建和更新案例:
```text
POST /api/jobs/optimize-stream
POST /api/jobs
POST /api/jobs/:jobId/optimize
```
现有人味文案接口保留技术路径,但响应中增加案例信息:
```text
POST /api/copy/renwei-optimize
```
成功响应应包含:
```json
{
"case": {
"id": "case_xxx",
"case_type": "human_copy"
},
"result_version": {
"id": "ver_xxx",
"version": 1
},
"result": {}
}
```
失败响应仍返回明确错误,同时案例库中留下失败案例。
## 状态规则
案例状态:
- `running`:优化已开始但尚未完成。
- `optimized`:至少有一个成功结果版本。
- `failed`:当前运行失败且没有成功结果版本,或最近一次运行失败。
- `archived`:从默认列表隐藏但仍保留记录。
结果版本状态:
- `optimized`:成功产出正式产物。
- `failed`:本次运行失败,没有正式产物。
如果一个已有成功版本的案例重新运行失败,案例仍保留历史成功版本;详情页提示最近一次运行失败。
## 评分口径
文章优化和人味文案优化使用不同评分口径。
文章优化评分继续关注:
- 事实一致性。
- 平台适配。
- 搜索意图匹配。
- 答案密度。
- 信任信号质量。
- 可读性。
人味文案优化评分新增独立口径,建议关注:
- 改动克制。
- 原意保真。
- 语气自然度。
- 目标匹配。
- AI 味风险。
- 互动或转化表现。
发布记录和效果快照共用结构,但评分 run 必须记录自己的 rubric 版本。
## 错误处理
- API 访问密钥继续沿用现有 `x-api-key` 规则。
- 案例创建失败时,本次优化应返回明确错误,不继续产生孤立结果。
- LLM 失败时保存失败阶段和错误摘要。
- schema 校验失败时保存校验摘要,不保存完整 response。
- 导出文件写入失败时,文章案例可以保存结构化结果并标记导出失败。
- 发布记录必须绑定存在的结果版本,否则返回 `409``404`
- 效果快照必须绑定存在的发布记录,否则返回 `404`
## 测试计划
单元测试:
- 案例类型校验接受 `article``human_copy`
- 案例元数据允许客户、品牌、项目标签和备注为空。
- 人味文案结果校验包含优化后文案、修改说明、AI 味检查和 warnings。
- LLM 审计摘要不包含完整 prompt/response。
- 结果版本号重新运行时递增。
Repository 测试:
- 可以创建文章优化案例。
- 可以创建人味文案优化案例。
- 可以保存失败案例。
- 可以保存多个结果版本并按版本倒序读取。
- 发布记录绑定结果版本。
- 效果快照绑定发布记录。
- 归档案例默认不出现在普通列表。
API 测试:
- 文章优化成功后可以在案例列表查到。
- 人味文案优化成功后可以在案例列表查到。
- LLM 失败后可以查到失败案例。
- `PATCH /api/cases/:caseId` 可以补录客户、品牌、项目标签和备注。
- `POST /api/cases/:caseId/archive` 后默认列表隐藏该案例。
- `POST /api/cases/:caseId/rerun` 生成新结果版本,不覆盖旧版本。
组件测试:
- 案例列表展示默认列。
- 案例列表支持类型、状态、发布目标、项目标签和时间筛选。
- 文章案例详情展示事实卡、QA、导出链接和效果学习入口。
- 人味文案案例详情展示修改说明、AI 味检查和效果学习入口。
- 失败案例详情展示失败阶段和错误摘要,不展示正式产物区。
E2E 测试:
- 完成一次文章优化后进入案例库,能打开详情并下载文章导出文件。
- 完成一次人味文案优化后进入案例库,能打开详情并查看修改说明。
- 给案例补项目标签和备注后,列表可搜索或筛选到。
- 从详情页重新运行优化后出现新结果版本。
- 为某个结果版本录入发布记录和手动效果快照后,详情页显示效果学习信息。
验证命令:
```bash
npm run lint
npm test
npm run build
```
## 迁移策略
第一步先服务新产生的案例,不要求立即迁移所有旧数据。
推荐顺序:
1. 增加统一案例元数据和结果版本能力。
2. 让文章优化新请求自动创建案例,并关联现有 article job。
3. 让人味文案优化新请求自动创建案例和结果版本。
4. 增加案例列表和详情页。
5. 将发布记录和效果快照入口接到结果版本。
6. 再评估是否需要把历史 `article_jobs` 批量补成案例。
历史数据迁移可以延后,因为当前主要目标是让后续运行不再丢失长期资产。
## 不做范围
- 不做账号、角色和权限。
- 不做平台 API 自动采集。
- 不做人味文案导出文件快照。
- 不默认保存完整 prompt/response。
- 不做全文搜索引擎。
- 不做硬删除。
- 不把失败案例当作正式产物。
- 不把两类案例强行合并为同一套评分口径。
@@ -0,0 +1,134 @@
CREATE TABLE IF NOT EXISTS optimization_cases (
id TEXT PRIMARY KEY,
case_type TEXT NOT NULL,
title TEXT NOT NULL,
summary TEXT NOT NULL,
status TEXT NOT NULL,
customer_name TEXT NOT NULL DEFAULT '',
brand_name TEXT NOT NULL DEFAULT '',
project_tags TEXT NOT NULL DEFAULT '[]',
notes TEXT NOT NULL DEFAULT '',
publish_target TEXT NOT NULL DEFAULT '',
source_excerpt TEXT NOT NULL DEFAULT '',
result_excerpt TEXT NOT NULL DEFAULT '',
latest_result_version_id TEXT,
latest_version_number INTEGER,
last_error_stage TEXT,
last_error_summary TEXT,
archived_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS case_inputs (
case_id TEXT PRIMARY KEY,
case_type TEXT NOT NULL,
article_job_id TEXT,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (case_id) REFERENCES optimization_cases(id) ON DELETE CASCADE,
FOREIGN KEY (article_job_id) REFERENCES article_jobs(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS optimization_result_versions (
id TEXT PRIMARY KEY,
case_id TEXT NOT NULL,
case_type TEXT NOT NULL,
version INTEGER NOT NULL,
status TEXT NOT NULL,
article_job_id TEXT,
article_revision INTEGER,
result_summary TEXT NOT NULL,
payload TEXT NOT NULL,
process_summary TEXT NOT NULL,
llm_audit_summary TEXT NOT NULL,
error_stage TEXT,
error_summary TEXT,
created_at TEXT NOT NULL,
UNIQUE (case_id, version),
FOREIGN KEY (case_id) REFERENCES optimization_cases(id) ON DELETE CASCADE,
FOREIGN KEY (article_job_id) REFERENCES article_jobs(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_optimization_cases_updated_at
ON optimization_cases(updated_at);
CREATE INDEX IF NOT EXISTS idx_optimization_cases_case_type
ON optimization_cases(case_type);
CREATE INDEX IF NOT EXISTS idx_optimization_cases_status
ON optimization_cases(status);
CREATE INDEX IF NOT EXISTS idx_optimization_result_versions_case
ON optimization_result_versions(case_id, version DESC);
ALTER TABLE article_jobs ADD COLUMN case_id TEXT;
CREATE TABLE IF NOT EXISTS publication_records_next (
id TEXT PRIMARY KEY,
result_version_id TEXT,
job_id TEXT,
revision INTEGER,
publish_target TEXT NOT NULL,
url TEXT NOT NULL,
published_at TEXT NOT NULL,
status TEXT NOT NULL,
notes TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (result_version_id)
REFERENCES optimization_result_versions(id) ON DELETE CASCADE
);
INSERT INTO publication_records_next (
id, result_version_id, job_id, revision, publish_target, url,
published_at, status, notes, created_at, updated_at
)
SELECT
id, NULL, job_id, revision, platform, url,
published_at, status, notes, created_at, updated_at
FROM publication_records;
DROP TABLE publication_records;
ALTER TABLE publication_records_next RENAME TO publication_records;
CREATE INDEX IF NOT EXISTS idx_publication_records_result_version
ON publication_records(result_version_id);
CREATE INDEX IF NOT EXISTS idx_publication_records_job_revision
ON publication_records(job_id, revision);
CREATE TABLE IF NOT EXISTS scoring_runs_next (
id TEXT PRIMARY KEY,
result_version_id TEXT,
case_type TEXT NOT NULL DEFAULT 'article',
job_id TEXT,
revision INTEGER,
rubric_version_id TEXT NOT NULL,
dimension_scores TEXT NOT NULL,
composite_score REAL NOT NULL,
rationale TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (result_version_id)
REFERENCES optimization_result_versions(id) ON DELETE CASCADE,
FOREIGN KEY (rubric_version_id) REFERENCES rubric_versions(id)
);
INSERT INTO scoring_runs_next (
id, result_version_id, case_type, job_id, revision, rubric_version_id,
dimension_scores, composite_score, rationale, created_at
)
SELECT
id, NULL, 'article', job_id, revision, rubric_version_id,
dimension_scores, composite_score, rationale, created_at
FROM scoring_runs;
DROP TABLE scoring_runs;
ALTER TABLE scoring_runs_next RENAME TO scoring_runs;
CREATE INDEX IF NOT EXISTS idx_scoring_runs_result_version
ON scoring_runs(result_version_id);
CREATE INDEX IF NOT EXISTS idx_scoring_runs_job_revision
ON scoring_runs(job_id, revision);
+1
View File
@@ -3,6 +3,7 @@
"version": "0.1.0",
"scripts": {
"test": "vitest run",
"test:e2e:samples": "node scripts/run-geo-sample-e2e.mjs",
"dev": "next dev",
"build": "next build",
"start": "next start",
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.GEO_SAMPLE_BASE_URL ?? "http://localhost:3000";
const timeout = Number(process.env.GEO_SAMPLE_TIMEOUT_MS ?? "600000") + 60_000;
export default defineConfig({
testDir: "./tests/e2e",
testMatch: "sample-flow.spec.ts",
fullyParallel: false,
workers: 1,
timeout,
retries: 0,
reporter: [["list"]],
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
actionTimeout: 30_000,
navigationTimeout: 60_000,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:net";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptPath = resolveScriptPath(import.meta.url);
const repoRoot = resolve(dirname(scriptPath), "..");
if (process.argv[1] && resolve(process.argv[1]) === scriptPath) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
export async function main(rawArgs = process.argv.slice(2)) {
const startedAt = new Date().toISOString();
const args = parseArgs(rawArgs);
const envFile = join(repoRoot, ".env.local");
const fileEnv = existsSync(envFile)
? parseEnvFile(readFileSync(envFile, "utf8"))
: {};
const mergedEnv = { ...fileEnv, ...process.env };
const provider = (mergedEnv.LLM_PROVIDER || "deepseek").toLowerCase();
const model =
provider === "openai"
? mergedEnv.OPENAI_MODEL || "gpt-4.1-mini"
: mergedEnv.DEEPSEEK_MODEL || "deepseek-v4-pro";
const timestamp = startedAt.replace(/[:.]/g, "-");
const reportDir = resolve(
repoRoot,
"test-results",
"geo-sample-flow",
timestamp,
);
preflight(mergedEnv, provider);
mkdirSync(reportDir, { recursive: true });
const port = args.port ? Number(args.port) : await findAvailablePort(3000);
const baseURL = args.baseURL || baseURLForPort(port);
const appDataDir = join(reportDir, "app-data");
mkdirSync(appDataDir, { recursive: true });
const runEnv = {
...mergedEnv,
APP_DATA_DIR: appDataDir,
GEO_SAMPLE_BASE_URL: baseURL,
GEO_SAMPLE_REPORT_DIR: reportDir,
GEO_SAMPLE_TIMEOUT_MS: String(args.timeoutMs ?? 600000),
GEO_SAMPLE_FILTER: args.sample ?? "",
GEO_SAMPLE_LIMIT: args.limit ? String(args.limit) : "",
};
let serverProcess = null;
let status = 1;
try {
if (!args.reuseServer) {
serverProcess = spawn("npm", ["run", "dev", "--", "--port", String(port)], {
cwd: repoRoot,
env: runEnv,
stdio: ["ignore", "pipe", "pipe"],
});
captureServerLogs(serverProcess, reportDir, runEnv);
await waitForServer(baseURL, 120000, serverProcess);
}
status = await runPlaywright(runEnv, Boolean(args.headed));
aggregateReport({
reportDir,
startedAt,
provider,
model,
baseURL,
invalidSamplesPath: join(reportDir, "invalid-samples.json"),
});
console.log(`GEO sample E2E report: ${join(reportDir, "summary.md")}`);
} finally {
if (serverProcess) {
serverProcess.kill("SIGTERM");
}
}
process.exitCode = status;
return status;
}
export function parseArgs(rawArgs) {
const parsed = {};
for (let index = 0; index < rawArgs.length; index += 1) {
const arg = rawArgs[index];
if (arg === "--sample") parsed.sample = rawArgs[++index];
else if (arg === "--limit") parsed.limit = Number(rawArgs[++index]);
else if (arg === "--timeout-ms") parsed.timeoutMs = Number(rawArgs[++index]);
else if (arg === "--port") parsed.port = Number(rawArgs[++index]);
else if (arg === "--base-url") parsed.baseURL = rawArgs[++index];
else if (arg === "--reuse-server") parsed.reuseServer = true;
else if (arg === "--headed") parsed.headed = true;
}
return parsed;
}
export function baseURLForPort(port) {
return `http://localhost:${port}`;
}
export function parseEnvFile(content) {
const values = {};
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
values[match[1]] = match[2].replace(/^['"]|['"]$/g, "");
}
return values;
}
export function preflight(env, providerName) {
if (env.API_AUTH_DISABLED !== "true" && !env.API_ACCESS_KEY) {
throw new Error("API_ACCESS_KEY is required unless API_AUTH_DISABLED=true");
}
if (providerName === "openai" && !env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY is required for live sample E2E");
}
if (providerName !== "openai" && !env.DEEPSEEK_API_KEY) {
throw new Error("DEEPSEEK_API_KEY is required for live sample E2E");
}
}
async function findAvailablePort(startPort) {
for (let port = startPort; port < startPort + 50; port += 1) {
if (await canListen(port)) return port;
}
throw new Error(`no available port found from ${startPort}`);
}
function canListen(port) {
return new Promise((resolveCanListen) => {
const server = createServer();
server.once("error", () => resolveCanListen(false));
server.once("listening", () => {
server.close(() => resolveCanListen(true));
});
server.listen(port, "127.0.0.1");
});
}
function captureServerLogs(child, outputDir, env) {
const logPath = join(outputDir, "server.log");
const secrets = [
env.API_ACCESS_KEY,
env.DEEPSEEK_API_KEY,
env.OPENAI_API_KEY,
].filter(Boolean);
const append = (chunk) => {
const text = redact(String(chunk), secrets);
writeFileSync(logPath, text, { flag: "a" });
};
child.stdout.on("data", append);
child.stderr.on("data", append);
}
export function redact(text, secrets) {
let next = text
.replace(/(x-api-key:\s*)([^\s]+)/gi, "$1[REDACTED]")
.replace(/(authorization:\s*bearer\s+)([^\s]+)/gi, "$1[REDACTED]");
for (const secret of secrets) {
next = next.split(secret).join("[REDACTED]");
}
return next;
}
export async function waitForServer(baseURL, timeoutMs, serverProcess = null) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (serverProcess?.exitCode !== null) {
throw new Error(
`dev server exited before ${baseURL} became ready; see server.log in the report directory`,
);
}
try {
const response = await fetch(baseURL, { method: "HEAD" });
if (response.ok) return;
} catch {
await delay(1000);
}
await delay(1000);
}
throw new Error(`server did not become ready at ${baseURL}`);
}
function delay(ms) {
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
}
function runPlaywright(env, headed) {
return new Promise((resolveRun) => {
const argsForPlaywright = [
"playwright",
"test",
"--config",
"playwright.samples.config.ts",
];
if (headed) argsForPlaywright.push("--headed");
const child = spawn("npx", argsForPlaywright, {
cwd: repoRoot,
env,
stdio: "inherit",
});
child.on("close", (code) => resolveRun(code ?? 1));
});
}
function aggregateReport({
reportDir,
startedAt,
provider,
model,
baseURL,
invalidSamplesPath,
}) {
const invalidSamples = existsSync(invalidSamplesPath)
? JSON.parse(readFileSync(invalidSamplesPath, "utf8"))
: [];
const samples = readSampleResults(reportDir);
const summary = {
started_at: startedAt,
finished_at: new Date().toISOString(),
mode: "live",
provider,
model,
base_url: baseURL,
report_dir: reportDir,
totals: {
passed: samples.filter((sample) => sample.status === "passed").length,
failed: samples.filter((sample) => sample.status === "failed").length,
skipped:
samples.filter((sample) => sample.status === "skipped").length +
invalidSamples.length,
},
samples,
invalid_samples: invalidSamples,
};
writeFileSync(
join(reportDir, "summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
);
writeFileSync(join(reportDir, "summary.md"), renderSummaryMarkdown(summary));
}
function readSampleResults(reportDir) {
const samplesDir = join(reportDir, "samples");
if (!existsSync(samplesDir)) return [];
return readdirSync(samplesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(samplesDir, entry.name, "result.json"))
.filter((filePath) => existsSync(filePath))
.map((filePath) => JSON.parse(readFileSync(filePath, "utf8")))
.sort((left, right) => left.file.localeCompare(right.file));
}
function renderSummaryMarkdown(summary) {
const rows = summary.samples.map(
(sample) =>
`| ${sample.name} | ${sample.status} | ${sample.qa_status ?? ""} | ${Math.round(sample.duration_ms / 1000)}s | ${sample.failure_category ?? ""} |`,
);
const lines = [
"# GEO 样例文章 E2E 测试报告",
"",
`- 模式:${summary.mode}`,
`- Provider${summary.provider}`,
`- Model${summary.model}`,
`- Base URL${summary.base_url}`,
`- 报告目录:${summary.report_dir}`,
`- 结果:通过 ${summary.totals.passed},失败 ${summary.totals.failed},跳过 ${summary.totals.skipped}`,
"",
"| 样例 | 状态 | QA | 耗时 | 失败分类 |",
"| --- | --- | --- | ---: | --- |",
...rows,
];
if (summary.invalid_samples.length > 0) {
lines.push("", "## 无效样例", "");
for (const sample of summary.invalid_samples) {
lines.push(`- ${sample.fileName}: ${sample.reason}`);
}
}
lines.push("");
return lines.join("\n");
}
function resolveScriptPath(metaUrl) {
try {
return fileURLToPath(metaUrl);
} catch {
return resolve(metaUrl);
}
}
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
DELETE as deleteSession,
GET as getSession,
POST as createSession,
} from "../auth/session/route";
describe("auth session API", () => {
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;
});
it("creates an HttpOnly browser session after validating the access key", async () => {
const response = await createSession(request({ api_access_key: "test-key" }));
const body = (await response.json()) as { authenticated: boolean };
const setCookie = response.headers.get("set-cookie") ?? "";
expect(response.status).toBe(200);
expect(body.authenticated).toBe(true);
expect(setCookie).toContain("geo_api_session=");
expect(setCookie).toContain("HttpOnly");
expect(setCookie).toMatch(/SameSite=Lax/i);
expect(setCookie).toContain("Path=/");
expect(setCookie).not.toContain("test-key");
});
it("rejects invalid access keys without setting a session cookie", async () => {
const response = await createSession(request({ api_access_key: "wrong" }));
expect(response.status).toBe(401);
expect(response.headers.get("set-cookie")).toBeNull();
});
it("reports the current browser session state", async () => {
const login = await createSession(request({ api_access_key: "test-key" }));
const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? "";
const anonymous = await getSession(new Request("http://localhost/api/auth/session"));
const authenticated = await getSession(
new Request("http://localhost/api/auth/session", {
headers: { cookie },
}),
);
await expect(anonymous.json()).resolves.toEqual({ authenticated: false });
await expect(authenticated.json()).resolves.toEqual({ authenticated: true });
});
it("clears the browser session cookie", async () => {
const response = await deleteSession(
new Request("http://localhost/api/auth/session", { method: "DELETE" }),
);
const setCookie = response.headers.get("set-cookie") ?? "";
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ authenticated: false });
expect(setCookie).toContain("geo_api_session=");
expect(setCookie).toContain("Max-Age=0");
expect(setCookie).toContain("HttpOnly");
});
});
function request(body: unknown) {
return new Request("http://localhost/api/auth/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
+447
View File
@@ -0,0 +1,447 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
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 { createSqliteRepository } from "../../../lib/db/sqlite-repository";
import {
HUMAN_COPY_RUBRIC_V1,
scoreHumanCopyResult,
} from "../../../lib/calibration/scoring";
import { GET as listCases } from "../cases/route";
import { GET as getCase, PATCH as patchCase } from "../cases/[caseId]/route";
import { POST as archiveCase } from "../cases/[caseId]/archive/route";
import { POST as restoreCase } from "../cases/[caseId]/restore/route";
import { POST as rerunCase } from "../cases/[caseId]/rerun/route";
import {
GET as listVersionPublications,
POST as createVersionPublication,
} from "../cases/[caseId]/versions/[versionId]/publications/route";
import { POST as recordPerformance } from "../publications/[publicationId]/performance/route";
describe("case APIs", () => {
let tempDir: string;
const originalDataDir = process.env.APP_DATA_DIR;
const originalApiKey = process.env.API_ACCESS_KEY;
const originalAuthDisabled = process.env.API_AUTH_DISABLED;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "geo-cases-api-"));
process.env.APP_DATA_DIR = tempDir;
process.env.API_ACCESS_KEY = "test-key";
process.env.API_AUTH_DISABLED = "false";
});
afterEach(() => {
process.env.APP_DATA_DIR = originalDataDir;
process.env.API_ACCESS_KEY = originalApiKey;
process.env.API_AUTH_DISABLED = originalAuthDisabled;
llmMocks.generateValidatedJson.mockReset();
rmSync(tempDir, { recursive: true, force: true });
});
it("lists, reads, patches, archives, and restores cases", async () => {
const repository = createSqliteRepository();
const created = await repository.createOptimizationCase({
case_type: "human_copy",
title: "人味文案优化:朋友圈",
summary: "原文",
publish_target: "朋友圈",
source_excerpt: "原文",
});
const patchResponse = await patchCase(
request({
customer_name: "客户A",
brand_name: "品牌B",
project_tags: ["朋友圈"],
notes: "保留口语。",
}),
params({ caseId: created.id }),
);
expect(patchResponse.status).toBe(200);
const archiveResponse = await archiveCase(
request({}),
params({ caseId: created.id }),
);
expect(archiveResponse.status).toBe(200);
const listResponse = await listCases(request({}));
const listBody = (await listResponse.json()) as { cases: unknown[] };
expect(listBody.cases).toHaveLength(0);
const restoreResponse = await restoreCase(
request({}),
params({ caseId: created.id }),
);
expect(restoreResponse.status).toBe(200);
const detailResponse = await getCase(
request({}),
params({ caseId: created.id }),
);
const detailBody = (await detailResponse.json()) as {
case: { customer_name: string; project_tags: string[] };
};
expect(detailBody.case.customer_name).toBe("客户A");
expect(detailBody.case.project_tags).toEqual(["朋友圈"]);
});
it("creates publication records for result versions", async () => {
const repository = createSqliteRepository();
const created = await repository.createOptimizationCase({
case_type: "human_copy",
title: "人味文案优化:私域",
summary: "原文",
publish_target: "私域",
source_excerpt: "原文",
});
const version = await repository.createOptimizationResultVersion({
case_id: created.id,
case_type: "human_copy",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "优化后文案",
payload: {
optimized_text: "优化后文案",
change_notes: [],
ai_taste_checks: [],
warnings: [],
},
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
});
const createResponse = await createVersionPublication(
request({
publish_target: "私域",
url: "https://example.com/private",
published_at: "2026-07-08T12:00:00.000Z",
notes: "客户发布",
}),
params({ caseId: created.id, versionId: version.id }),
);
expect(createResponse.status).toBe(201);
const listResponse = await listVersionPublications(
request({}),
params({ caseId: created.id, versionId: version.id }),
);
const listBody = (await listResponse.json()) as {
publications: Array<{ publish_target: string }>;
};
expect(listBody.publications).toEqual([
expect.objectContaining({ publish_target: "私域" }),
]);
});
it("records manual performance for a result-version publication without scoring", async () => {
const repository = createSqliteRepository();
const created = await repository.createOptimizationCase({
case_type: "human_copy",
title: "人味文案优化:私域",
summary: "原文",
publish_target: "私域",
source_excerpt: "原文",
});
const version = await repository.createOptimizationResultVersion({
case_id: created.id,
case_type: "human_copy",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "优化后文案",
payload: {
optimized_text: "优化后文案",
change_notes: [],
ai_taste_checks: [],
warnings: [],
},
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
});
const publication = await repository.createPublicationRecord({
result_version_id: version.id,
job_id: null,
revision: null,
publish_target: "私域",
url: "https://example.com/private",
published_at: "2026-07-08T12:00:00.000Z",
status: "published",
notes: "",
});
const response = await recordPerformance(
request({
window_label: "T+7d",
views: "1200",
feedback_summary: "客户反馈更自然",
}),
params({ publicationId: publication.id }),
);
const body = (await response.json()) as {
snapshot?: { metrics: { views: number } };
calibrationEvent: null;
};
expect(response.status).toBe(201);
expect(body.snapshot?.metrics.views).toBe(1200);
expect(body.calibrationEvent).toBeNull();
});
it("creates calibration events for scored human-copy result-version performance", async () => {
const repository = createSqliteRepository();
const created = await repository.createOptimizationCase({
case_type: "human_copy",
title: "人味文案优化:私域",
summary: "原文",
publish_target: "私域",
source_excerpt: "原文",
});
const result = {
optimized_text: "优化后文案",
change_notes: [],
ai_taste_checks: [],
warnings: [],
};
const version = await repository.createOptimizationResultVersion({
case_id: created.id,
case_type: "human_copy",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "优化后文案",
payload: result,
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
});
await repository.saveRubricVersion(HUMAN_COPY_RUBRIC_V1);
await repository.saveScoringRun(
scoreHumanCopyResult({
resultVersionId: version.id,
result,
}),
);
const publication = await repository.createPublicationRecord({
result_version_id: version.id,
job_id: null,
revision: null,
publish_target: "私域",
url: "https://example.com/private",
published_at: "2026-07-08T12:00:00.000Z",
status: "published",
notes: "",
});
const response = await recordPerformance(
request({
window_label: "T+7d",
views: "1200",
feedback_summary: "客户反馈更自然",
}),
params({ publicationId: publication.id }),
);
const body = (await response.json()) as {
calibrationEvent: { observations: string[] } | null;
};
expect(response.status).toBe(201);
expect(body.calibrationEvent?.observations.join(" ")).toContain("综合评分");
});
it("reruns an article case into a new result version and article job", async () => {
const repository = createSqliteRepository();
const created = await repository.createOptimizationCase({
case_type: "article",
title: "GEO 指南",
summary: "原文摘要",
publish_target: "official_site",
source_excerpt: "原文摘要",
});
const sourceJob = await repository.createArticleJob({
case_id: created.id,
source_title: "GEO 指南",
source_body: "Example Technology Co., Ltd. has 8 years of GEO experience.",
image_inputs: [{ type: "description", content: "dashboard" }],
publish_platform: "official_site",
user_instructions: "保持事实准确",
});
await repository.saveFactCard(sourceJob.id, confirmedFactCard);
await repository.saveCaseInput({
case_id: created.id,
case_type: "article",
article_job_id: sourceJob.id,
payload: {
source_title: "GEO 指南",
source_body: "Example Technology Co., Ltd. has 8 years of GEO experience.",
image_inputs: [{ type: "description", content: "dashboard" }],
publish_platform: "official_site",
user_instructions: "保持事实准确",
},
});
await repository.createOptimizationResultVersion({
case_id: created.id,
case_type: "article",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "第一版",
payload: null,
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
});
llmMocks.generateValidatedJson
.mockResolvedValueOnce({
title: "第二版 GEO 指南",
summary: "第二版 official site GEO optimization 摘要",
body_markdown:
"## 第二版\nExample Technology Co., Ltd. has 8 years of GEO optimization experience.",
image_suggestions: [],
changed_sections: ["标题"],
requires_user_confirmation: [],
})
.mockResolvedValueOnce({
overall_status: "pass",
checks: [],
});
const response = await rerunCase(request({}), params({ caseId: created.id }));
const body = (await response.json()) as {
result_version: { version: number };
optimizedArticle: { title: string; job_id: string };
qaReport: { overall_status: string };
exportPaths: Record<string, string>;
};
expect(response.status).toBe(201);
expect(body.result_version.version).toBe(2);
expect(body.optimizedArticle.title).toBe("第二版 GEO 指南");
expect(body.qaReport.overall_status).toBe("pass");
expect(body.exportPaths.markdown).toContain(body.optimizedArticle.job_id);
await expect(repository.getArticleJob(body.optimizedArticle.job_id)).resolves.toMatchObject({
case_id: created.id,
status: "optimized",
});
});
it("reruns a human-copy case into a new result version", async () => {
const repository = createSqliteRepository();
const created = await repository.createOptimizationCase({
case_type: "human_copy",
title: "人味文案优化:朋友圈",
summary: "原文",
publish_target: "朋友圈",
source_excerpt: "原文",
});
await repository.saveCaseInput({
case_id: created.id,
case_type: "human_copy",
article_job_id: null,
payload: {
source_text: "原文",
goal: "自然一点",
intensity: "light",
user_instructions: "",
publish_target: "朋友圈",
},
});
await repository.createOptimizationResultVersion({
case_id: created.id,
case_type: "human_copy",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "第一版",
payload: {
optimized_text: "第一版",
change_notes: [],
ai_taste_checks: [],
warnings: [],
},
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
});
llmMocks.generateValidatedJson.mockResolvedValueOnce({
optimized_text: "第二版",
change_notes: [],
ai_taste_checks: [],
warnings: [],
});
const response = await rerunCase(request({}), params({ caseId: created.id }));
const body = (await response.json()) as {
result_version: { id: string; version: number };
result: { optimized_text: string };
};
expect(response.status).toBe(201);
expect(body.result_version.version).toBe(2);
expect(body.result.optimized_text).toBe("第二版");
await expect(
repository.getLatestScoringRunForResultVersion(body.result_version.id),
).resolves.toMatchObject({
case_type: "human_copy",
rubric_version_id: HUMAN_COPY_RUBRIC_V1.id,
});
});
});
const confirmedFactCard = {
company_full_name: "Example Technology Co., Ltd.",
company_short_names: ["Example Tech"],
brand_names: ["Example"],
product_names: ["Example GEO"],
target_industry: "GEO optimization",
target_audience: "Marketing teams",
experience_years: 8,
core_claims: ["8 years of GEO optimization experience"],
forbidden_claims: ["industry first"],
image_topics: ["dashboard"],
uncertain_items: [],
is_ready_for_optimization: true,
confirmed_by_user: true,
} as const;
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/cases", {
method: "POST",
body: JSON.stringify(body),
headers,
});
}
function params<T extends Record<string, string>>(values: T) {
return { params: Promise.resolve(values) };
}
+169
View File
@@ -0,0 +1,169 @@
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: "保留口语。",
publish_target: "朋友圈",
}),
);
const body = (await response.json()) as {
case: { id: string; case_type: string };
result_version: { id: string; version: number };
result: { optimized_text: string; change_notes: unknown[] };
};
expect(response.status).toBe(200);
expect(body.case.case_type).toBe("human_copy");
expect(body.result_version.version).toBe(1);
expect(body.result.optimized_text).toBe("我把这段文案顺了一下。");
expect(body.result.change_notes).toHaveLength(1);
expect(llmMocks.generateValidatedJson).toHaveBeenCalledWith(
expect.objectContaining({
task: "renwei_copy_optimizer",
}),
);
});
it("saves LLM failures as failed human-copy cases", async () => {
llmMocks.generateValidatedJson.mockRejectedValueOnce(
new Error("LLM response failed schema validation: optimized_text"),
);
const response = await optimizeCopy(
request({
source_text: "这是一段普通文案。",
intensity: "light",
publish_target: "私域",
}),
);
const body = (await response.json()) as {
error: string;
case?: { id: string; case_type: string };
result_version?: { id: string; version: number };
};
expect(response.status).toBe(502);
expect(body.error).toBe(
"LLM response failed schema validation: optimized_text",
);
expect(body.case?.case_type).toBe("human_copy");
expect(body.result_version?.version).toBe(1);
});
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,
});
}
+268
View File
@@ -27,8 +27,10 @@ import {
POST as createPublication,
} from "../jobs/[jobId]/publications/route";
import { GET as getJobProgress } from "../jobs/[jobId]/progress/route";
import { POST as optimizeStream } from "../jobs/optimize-stream/route";
import { POST as createJob } from "../jobs/route";
import { POST as recordPerformance } from "../publications/[publicationId]/performance/route";
import { createSqliteRepository } from "../../../lib/db/sqlite-repository";
const validFactCard = {
company_full_name: "Example Technology Co., Ltd.",
@@ -84,6 +86,21 @@ interface OptimizeJobResponse {
timing: TimingResponse;
}
interface StreamEventResponse {
type: string;
job_id?: string;
job?: { id: string };
case?: { id: string; case_type: string };
result_version?: { id: string; version: number };
fact_card?: { company_full_name: string; confirmed_by_user?: boolean };
article?: { title: string; body_markdown?: string };
optimized_article?: { title: string };
qa_report?: { overall_status?: string };
export_paths?: Record<string, string>;
stage?: string;
error?: string;
}
describe("job API routes", () => {
let tempDir: string;
const originalDataDir = process.env.APP_DATA_DIR;
@@ -187,6 +204,202 @@ describe("job API routes", () => {
expect(response.status).toBe(400);
});
it("streams a one-click optimization from body-only input", async () => {
llmMocks.generateValidatedJson
.mockResolvedValueOnce(validCandidateFactCard)
.mockResolvedValueOnce({
title: "流式优化标题",
summary: "流式优化摘要。",
body_markdown:
"## 服务能力\nExample Technology Co., Ltd. 提供 GEO optimization 服务。",
image_suggestions: [],
changed_sections: ["title", "body"],
requires_user_confirmation: [],
})
.mockResolvedValueOnce({ checks: [] });
const response = await optimizeStream(
request({
body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
image_lines: "",
platform: "official_site",
user_instructions: "",
}),
);
const events = await streamEvents(response);
expect(response.status).toBe(200);
expect(events.map((event) => event.type)).toEqual([
"job_created",
"fact_card_ready",
"draft_started",
"draft_ready",
"qa_started",
"qa_ready",
"final_ready",
]);
expect(
events.find((event) => event.type === "fact_card_ready")?.fact_card,
).toEqual(expect.objectContaining({ confirmed_by_user: false }));
expect(
events.find((event) => event.type === "final_ready")?.optimized_article
?.title,
).toBe("流式优化标题");
});
it("auto-saves stream article optimization as a case and result version", async () => {
llmMocks.generateValidatedJson
.mockResolvedValueOnce(validCandidateFactCard)
.mockResolvedValueOnce({
title: "流式优化标题",
summary: "流式优化摘要。",
body_markdown:
"## 服务能力\nExample Technology Co., Ltd. 提供 GEO optimization 服务。",
image_suggestions: [],
changed_sections: ["title", "body"],
requires_user_confirmation: [],
})
.mockResolvedValueOnce({ checks: [] });
const response = await optimizeStream(
request({
body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
platform: "official_site",
}),
);
const events = await streamEvents(response);
const finalEvent = events.find((event) => event.type === "final_ready");
expect(finalEvent?.case?.case_type).toBe("article");
expect(finalEvent?.result_version?.version).toBe(1);
const repository = createSqliteRepository();
await expect(
repository.listOptimizationCases({ include_archived: false }),
).resolves.toEqual([
expect.objectContaining({
id: finalEvent?.case?.id,
case_type: "article",
status: "optimized",
}),
]);
});
it("uses an edited fact card without extracting a new one", async () => {
llmMocks.generateValidatedJson
.mockResolvedValueOnce({
title: "使用编辑事实卡的标题",
summary: "使用编辑事实卡的摘要。",
body_markdown: "## 服务能力\n示例科技提供GEO内容优化服务。",
image_suggestions: [],
changed_sections: ["title"],
requires_user_confirmation: [],
})
.mockResolvedValueOnce({ checks: [] });
const response = await optimizeStream(
request({
body: "示例科技提供GEO内容优化服务。",
platform: "official_site",
fact_card: {
...validCandidateFactCard,
company_full_name: "",
uncertain_items: ["公司全称需要确认"],
confirmed_by_user: false,
},
}),
);
const events = await streamEvents(response);
expect(response.status).toBe(200);
expect(events.map((event) => event.type)).toContain("fact_card_ready");
expect(
events.find((event) => event.type === "fact_card_ready")?.fact_card
?.company_full_name,
).toBe("");
expect(llmMocks.generateValidatedJson).toHaveBeenCalledTimes(2);
expect(llmMocks.generateValidatedJson).not.toHaveBeenCalledWith(
expect.objectContaining({ task: "fact_extractor" }),
);
});
it("returns a Chinese validation error for empty stream input bodies", async () => {
const response = await optimizeStream(
request({
title: "",
body: " ",
platform: "official_site",
}),
);
const body = (await response.json()) as { error: string };
expect(response.status).toBe(400);
expect(body.error).toBe("请输入需要优化的文章内容");
});
it("streams failed events when the LLM fails after the job is created", async () => {
llmMocks.generateValidatedJson
.mockResolvedValueOnce(validCandidateFactCard)
.mockRejectedValueOnce(new Error("LLM provider error: timeout"));
const response = await optimizeStream(
request({
body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
platform: "official_site",
}),
);
const events = await streamEvents(response);
expect(response.status).toBe(200);
expect(events.map((event) => event.type)).toEqual([
"job_created",
"fact_card_ready",
"draft_started",
"failed",
]);
expect(events[events.length - 1]).toEqual(
expect.objectContaining({
type: "failed",
stage: "draft",
error: "LLM provider error: timeout",
}),
);
});
it("auto-saves stream article LLM failure as a failed case", async () => {
llmMocks.generateValidatedJson
.mockResolvedValueOnce(validCandidateFactCard)
.mockRejectedValueOnce(new Error("LLM provider error: timeout"));
const response = await optimizeStream(
request({
body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
platform: "official_site",
}),
);
const events = await streamEvents(response);
const failedEvent = events[events.length - 1];
expect(failedEvent.type).toBe("failed");
expect(failedEvent.case?.id).toMatch(/^case_/);
expect(failedEvent.result_version?.version).toBe(1);
const repository = createSqliteRepository();
const detail = await repository.getOptimizationCaseDetail(
failedEvent.case?.id ?? "",
);
expect(detail).toMatchObject({
case: { status: "failed", last_error_stage: "draft" },
versions: [
expect.objectContaining({
status: "failed",
error_summary: "LLM provider error: timeout",
}),
],
});
});
it("rejects unresolved uncertain items when confirming a fact card", async () => {
const { job } = await createJobFixture();
const response = await confirmFactCard(
@@ -249,6 +462,52 @@ describe("job API routes", () => {
expect(body.timing.steps.every((step) => step.duration_ms >= 0)).toBe(true);
});
it("auto-saves non-stream article optimization as a case result version", async () => {
const { job } = await createJobFixture();
await confirmFactCard(
request(validFactCard),
params<{ jobId: string }>({ jobId: job.id }),
);
llmMocks.generateValidatedJson
.mockResolvedValueOnce({
title: "非流式优化标题",
summary:
"Example Technology Co., Ltd. 面向市场团队提供 GEO optimization 服务。",
body_markdown:
"Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
image_suggestions: [],
changed_sections: ["title", "body"],
requires_user_confirmation: [],
})
.mockResolvedValueOnce({ checks: [] });
const response = await optimizeJob(
request({}),
params<{ jobId: string }>({ jobId: job.id }),
);
expect(response.status).toBe(200);
const repository = createSqliteRepository();
const savedJob = await repository.getArticleJob(job.id);
expect(savedJob?.case_id).toMatch(/^case_/);
const detail = await repository.getOptimizationCaseDetail(
savedJob?.case_id ?? "",
);
expect(detail).toMatchObject({
case: { case_type: "article", status: "optimized" },
versions: [
expect.objectContaining({
version: 1,
status: "optimized",
article_job_id: job.id,
}),
],
});
});
it("scores a revision, registers publication, and records manual performance", async () => {
const { job } = await createJobFixture();
await confirmFactCard(
@@ -539,6 +798,15 @@ function request(body: unknown, options: { apiKey?: string | null } = {}) {
});
}
async function streamEvents(response: Response) {
const text = await response.text();
return text
.split(/\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as StreamEventResponse);
}
function params<T extends Record<string, string>>(values: T) {
return { params: Promise.resolve(values) };
}
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import {
apiSessionCookieName,
createApiSessionToken,
getConfiguredApiAccessKey,
hasValidApiSession,
} from "../../../../lib/api/auth";
const sessionMaxAgeSeconds = 60 * 60 * 24 * 30;
export async function GET(request: Request) {
if (process.env.API_AUTH_DISABLED === "true") {
return NextResponse.json({ authenticated: true });
}
const apiAccessKey = getConfiguredApiAccessKey();
return NextResponse.json({
authenticated: apiAccessKey
? hasValidApiSession(request, apiAccessKey)
: false,
});
}
export async function POST(request: Request) {
if (process.env.API_AUTH_DISABLED === "true") {
return NextResponse.json({ authenticated: true });
}
const apiAccessKey = getConfiguredApiAccessKey();
if (!apiAccessKey) {
return NextResponse.json(
{ error: "API access key is not configured" },
{ status: 401 },
);
}
const body = (await request.json().catch(() => ({}))) as {
api_access_key?: unknown;
apiAccessKey?: unknown;
};
const provided =
typeof body.api_access_key === "string"
? body.api_access_key
: body.apiAccessKey;
if (provided !== apiAccessKey) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const response = NextResponse.json({ authenticated: true });
response.cookies.set({
name: apiSessionCookieName,
value: createApiSessionToken(apiAccessKey),
httpOnly: true,
sameSite: "lax",
secure: new URL(request.url).protocol === "https:",
path: "/",
maxAge: sessionMaxAgeSeconds,
});
return response;
}
export async function DELETE(request: Request) {
const response = NextResponse.json({ authenticated: false });
response.cookies.set({
name: apiSessionCookieName,
value: "",
httpOnly: true,
sameSite: "lax",
secure: new URL(request.url).protocol === "https:",
path: "/",
maxAge: 0,
});
return response;
}
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../../../lib/api/auth";
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
interface RouteContext {
params: Promise<{ caseId: string }>;
}
export async function POST(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { caseId } = await context.params;
const repository = getRepositoryFromRuntime();
const optimizationCase = await repository.archiveOptimizationCase(caseId);
if (!optimizationCase) {
return NextResponse.json({ error: "Case not found" }, { status: 404 });
}
return NextResponse.json({ case: optimizationCase });
}
+315
View File
@@ -0,0 +1,315 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireApiAccess } from "../../../../../lib/api/auth";
import {
HUMAN_COPY_RUBRIC_V1,
scoreHumanCopyResult,
} from "../../../../../lib/calibration/scoring";
import type {
ArticleCaseInputPayload,
OptimizationCaseDetail,
ProcessSummaryStep,
} from "../../../../../lib/cases/types";
import { createProcessStep, excerpt } from "../../../../../lib/cases/summaries";
import {
getRepositoryFromRuntime,
type AppRepository,
} from "../../../../../lib/db/repository";
import {
articleInputSchema,
copyOptimizationRequestSchema,
optimizationFactCardSchema,
} from "../../../../../lib/domain/validation";
import type { LlmAuditSummary } from "../../../../../lib/llm/audit";
import { LlmValidationError } from "../../../../../lib/llm/client";
import { getExportStoreFromRuntime } from "../../../../../lib/workflow/export-store";
import { extractCandidateFactCard } from "../../../../../lib/workflow/fact-extractor";
import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator";
import { optimizeRenweiCopy } from "../../../../../lib/workflow/renwei-copy-optimizer";
interface RouteContext {
params: Promise<{ caseId: string }>;
}
export async function POST(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { caseId } = await context.params;
const repository = getRepositoryFromRuntime();
const detail = await repository.getOptimizationCaseDetail(caseId);
if (!detail?.input) {
return NextResponse.json({ error: "Case not found" }, { status: 404 });
}
const caseDetail = { ...detail, input: detail.input } satisfies CaseDetailWithInput;
if (detail.case.case_type === "article") {
return rerunArticleCase(repository, caseDetail);
}
if (detail.case.case_type === "human_copy") {
return rerunHumanCopyCase(repository, caseDetail);
}
return NextResponse.json({ error: "Unsupported case type" }, { status: 400 });
}
type CaseDetailWithInput = OptimizationCaseDetail & {
input: NonNullable<OptimizationCaseDetail["input"]>;
};
async function rerunHumanCopyCase(
repository: AppRepository,
detail: CaseDetailWithInput,
) {
const payload = copyOptimizationRequestSchema.parse(detail.input.payload);
const startedAt = Date.now();
const llmAuditSummary: LlmAuditSummary[] = [];
try {
const result = await optimizeRenweiCopy(payload, {
onAuditSummary: (summary) => {
llmAuditSummary.push(summary);
},
});
const resultVersion = await repository.createOptimizationResultVersion({
case_id: detail.case.id,
case_type: "human_copy",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: excerpt(result.optimized_text),
payload: result,
process_summary: [
createProcessStep({
stage: "human_copy_optimize",
startedAt,
endedAt: Date.now(),
status: "success",
producedResultVersion: true,
}),
],
llm_audit_summary: llmAuditSummary,
error_stage: null,
error_summary: null,
});
await repository.saveRubricVersion(HUMAN_COPY_RUBRIC_V1);
await repository.saveScoringRun(
scoreHumanCopyResult({
resultVersionId: resultVersion.id,
result,
}),
);
return NextResponse.json(
{
case: { id: detail.case.id, case_type: "human_copy" },
result_version: { id: resultVersion.id, version: resultVersion.version },
result,
},
{ status: 201 },
);
} catch (error) {
const message = errorMessage(error);
const resultVersion = await repository.createOptimizationResultVersion({
case_id: detail.case.id,
case_type: "human_copy",
status: "failed",
article_job_id: null,
article_revision: null,
result_summary: "",
payload: null,
process_summary: [
createProcessStep({
stage: "human_copy_optimize",
startedAt,
endedAt: Date.now(),
status: "failed",
errorSummary: message,
producedResultVersion: false,
}),
],
llm_audit_summary: llmAuditSummary,
error_stage: "human_copy_optimize",
error_summary: message,
});
return NextResponse.json(
{
error: message,
case: { id: detail.case.id, case_type: "human_copy" },
result_version: { id: resultVersion.id, version: resultVersion.version },
},
{ status: getErrorStatus(error) },
);
}
}
function errorMessage(error: unknown) {
return error instanceof z.ZodError
? "请输入需要优化的文案"
: error instanceof Error
? error.message
: "文案优化失败";
}
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;
}
async function rerunArticleCase(
repository: AppRepository,
detail: CaseDetailWithInput,
) {
const payload = detail.input.payload as ArticleCaseInputPayload;
const articleInput = articleInputSchema.parse({
title: payload.source_title,
body: payload.source_body,
images: payload.image_inputs,
platform: payload.publish_platform,
user_instructions: payload.user_instructions,
});
const llmAuditSummary: LlmAuditSummary[] = [];
const processSummary: ProcessSummaryStep[] = [];
const requestStartedAt = Date.now();
let jobId: string | null = null;
let stage = "job";
try {
const job = await repository.createArticleJob({
case_id: detail.case.id,
source_title: articleInput.title,
source_body: articleInput.body,
image_inputs: articleInput.images,
publish_platform: articleInput.platform,
user_instructions: articleInput.user_instructions,
});
jobId = job.id;
stage = "fact_card";
const factCardStartedAt = Date.now();
const existingFactCard = detail.input.article_job_id
? await repository.getFactCard(detail.input.article_job_id)
: null;
const factCard = optimizationFactCardSchema.parse(
payload.fact_card ??
existingFactCard ??
(await extractCandidateFactCard(articleInput, {
onAuditSummary: (summary) => {
llmAuditSummary.push(summary);
},
})),
);
processSummary.push(
createProcessStep({
stage: "fact_card",
startedAt: factCardStartedAt,
endedAt: Date.now(),
status: "success",
producedResultVersion: false,
}),
);
const savedFactCard = await repository.saveFactCard(job.id, factCard);
stage = "optimize";
const result = await runOptimizationWorkflow({
input: articleInput,
factCard: savedFactCard,
onAuditSummary: (summary) => {
llmAuditSummary.push(summary);
},
});
const optimizedArticle = await repository.saveOptimizedArticle(
job.id,
result.article,
);
const qaReport = await repository.saveQaReport(
job.id,
optimizedArticle.revision ?? 1,
result.qaReport,
);
const exportStore = getExportStoreFromRuntime();
const exportPaths = await exportStore.writeJobExports({
jobId: job.id,
article: optimizedArticle,
qaReport,
});
await repository.updateArticleJob(job.id, {
status: "optimized",
export_paths: exportPaths,
});
const resultVersion = await repository.createOptimizationResultVersion({
case_id: detail.case.id,
case_type: "article",
status: "optimized",
article_job_id: job.id,
article_revision: optimizedArticle.revision ?? 1,
result_summary: optimizedArticle.summary,
payload: {
article: optimizedArticle,
qa_report: qaReport,
export_paths: exportPaths,
},
process_summary: processSummary,
llm_audit_summary: llmAuditSummary,
error_stage: null,
error_summary: null,
});
return NextResponse.json(
{
case: { id: detail.case.id, case_type: "article" },
result_version: { id: resultVersion.id, version: resultVersion.version },
optimizedArticle,
qaReport,
exportPaths,
rewriteRounds: result.rewrite_rounds,
stoppedAfterMaxRewrites: result.stopped_after_max_rewrites,
timing: result.timing,
},
{ status: 201 },
);
} catch (error) {
const message = error instanceof Error ? error.message : "文章优化失败";
const resultVersion = await repository.createOptimizationResultVersion({
case_id: detail.case.id,
case_type: "article",
status: "failed",
article_job_id: jobId,
article_revision: null,
result_summary: "",
payload: null,
process_summary: [
...processSummary,
createProcessStep({
stage,
startedAt: requestStartedAt,
endedAt: Date.now(),
status: "failed",
errorSummary: message,
producedResultVersion: false,
}),
],
llm_audit_summary: llmAuditSummary,
error_stage: stage,
error_summary: message,
});
if (jobId) {
await repository.updateArticleJob(jobId, { status: "failed" });
}
return NextResponse.json(
{
error: message,
case: { id: detail.case.id, case_type: "article" },
result_version: { id: resultVersion.id, version: resultVersion.version },
},
{ status: getErrorStatus(error) },
);
}
}
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../../../lib/api/auth";
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
interface RouteContext {
params: Promise<{ caseId: string }>;
}
export async function POST(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { caseId } = await context.params;
const repository = getRepositoryFromRuntime();
const optimizationCase = await repository.restoreOptimizationCase(caseId);
if (!optimizationCase) {
return NextResponse.json({ error: "Case not found" }, { status: 404 });
}
return NextResponse.json({ case: optimizationCase });
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../../lib/api/auth";
import { caseMetadataPatchSchema } from "../../../../lib/cases/validation";
import { getRepositoryFromRuntime } from "../../../../lib/db/repository";
interface RouteContext {
params: Promise<{ caseId: string }>;
}
export async function GET(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { caseId } = await context.params;
const repository = getRepositoryFromRuntime();
const detail = await repository.getOptimizationCaseDetail(caseId);
if (!detail) {
return NextResponse.json({ error: "Case not found" }, { status: 404 });
}
return NextResponse.json(detail);
}
export async function PATCH(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { caseId } = await context.params;
const repository = getRepositoryFromRuntime();
const changes = caseMetadataPatchSchema.parse(await request.json());
const optimizationCase = await repository.updateOptimizationCaseMetadata(
caseId,
changes,
);
if (!optimizationCase) {
return NextResponse.json({ error: "Case not found" }, { status: 404 });
}
return NextResponse.json({ case: optimizationCase });
}
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../../../../../lib/api/auth";
import { resultVersionPublicationInputSchema } from "../../../../../../../lib/cases/validation";
import { getRepositoryFromRuntime } from "../../../../../../../lib/db/repository";
interface RouteContext {
params: Promise<{ caseId: string; versionId: string }>;
}
export async function GET(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { caseId, versionId } = await context.params;
const repository = getRepositoryFromRuntime();
const version = await repository.getOptimizationResultVersion(versionId);
if (!version || version.case_id !== caseId) {
return NextResponse.json(
{ error: "Result version not found" },
{ status: 404 },
);
}
return NextResponse.json({
publications: await repository.listPublicationRecordsForResultVersion(
version.id,
),
});
}
export async function POST(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { caseId, versionId } = await context.params;
const repository = getRepositoryFromRuntime();
const version = await repository.getOptimizationResultVersion(versionId);
if (!version || version.case_id !== caseId) {
return NextResponse.json(
{ error: "Result version not found" },
{ status: 404 },
);
}
const input = resultVersionPublicationInputSchema.parse(await request.json());
const publication = await repository.createPublicationRecord({
result_version_id: version.id,
job_id: version.article_job_id,
revision: version.article_revision,
publish_target: input.publish_target,
url: input.url,
published_at: input.published_at,
status: "published",
notes: input.notes,
});
return NextResponse.json({ publication }, { status: 201 });
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../lib/api/auth";
import { caseListFiltersSchema } from "../../../lib/cases/validation";
import { getRepositoryFromRuntime } from "../../../lib/db/repository";
export async function GET(request: Request) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const url = new URL(request.url);
const filters = caseListFiltersSchema.parse(
Object.fromEntries(url.searchParams.entries()),
);
const repository = getRepositoryFromRuntime();
return NextResponse.json({
cases: await repository.listOptimizationCases(filters),
});
}
+134
View File
@@ -0,0 +1,134 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireApiAccess } from "../../../../lib/api/auth";
import {
HUMAN_COPY_RUBRIC_V1,
scoreHumanCopyResult,
} from "../../../../lib/calibration/scoring";
import { buildHumanCopyCaseSummary, createProcessStep, excerpt } from "../../../../lib/cases/summaries";
import { getRepositoryFromRuntime } from "../../../../lib/db/repository";
import { copyOptimizationRequestSchema } from "../../../../lib/domain/validation";
import type { LlmAuditSummary } from "../../../../lib/llm/audit";
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 repository = getRepositoryFromRuntime();
const caseSummary = buildHumanCopyCaseSummary(payload);
const optimizationCase = await repository.createOptimizationCase({
case_type: "human_copy",
...caseSummary,
});
await repository.saveCaseInput({
case_id: optimizationCase.id,
case_type: "human_copy",
article_job_id: null,
payload,
});
const startedAt = Date.now();
const llmAuditSummary: LlmAuditSummary[] = [];
try {
const result = await optimizeRenweiCopy(payload, {
onAuditSummary: (summary) => {
llmAuditSummary.push(summary);
},
});
const resultVersion = await repository.createOptimizationResultVersion({
case_id: optimizationCase.id,
case_type: "human_copy",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: excerpt(result.optimized_text),
payload: result,
process_summary: [
createProcessStep({
stage: "human_copy_optimize",
startedAt,
endedAt: Date.now(),
status: "success",
producedResultVersion: true,
}),
],
llm_audit_summary: llmAuditSummary,
error_stage: null,
error_summary: null,
});
await repository.saveRubricVersion(HUMAN_COPY_RUBRIC_V1);
await repository.saveScoringRun(
scoreHumanCopyResult({
resultVersionId: resultVersion.id,
result,
}),
);
return NextResponse.json({
case: { id: optimizationCase.id, case_type: "human_copy" },
result_version: { id: resultVersion.id, version: resultVersion.version },
result,
});
} catch (error) {
const message = errorMessage(error);
const resultVersion = await repository.createOptimizationResultVersion({
case_id: optimizationCase.id,
case_type: "human_copy",
status: "failed",
article_job_id: null,
article_revision: null,
result_summary: "",
payload: null,
process_summary: [
createProcessStep({
stage: "human_copy_optimize",
startedAt,
endedAt: Date.now(),
status: "failed",
errorSummary: message,
producedResultVersion: false,
}),
],
llm_audit_summary: llmAuditSummary,
error_stage: "human_copy_optimize",
error_summary: message,
});
return NextResponse.json(
{
error: message,
case: { id: optimizationCase.id, case_type: "human_copy" },
result_version: { id: resultVersion.id, version: resultVersion.version },
},
{ status: getErrorStatus(error) },
);
}
} catch (error) {
return jsonError(error, getErrorStatus(error));
}
}
function errorMessage(error: unknown) {
return error instanceof z.ZodError
? "请输入需要优化的文案"
: error instanceof Error
? error.message
: "文案优化失败";
}
function jsonError(error: unknown, status: number) {
return NextResponse.json({ error: errorMessage(error) }, { 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;
}
@@ -29,8 +29,17 @@ export async function POST(request: Request, context: RouteContext) {
}
await repository.saveRubricVersion(GEO_RUBRIC_V1);
const resultVersion = await repository.findResultVersionForArticleRevision(
jobId,
article.revision ?? 1,
);
const scoringRun = await repository.saveScoringRun(
scoreOptimizedArticle({ jobId, article, qaReport }),
scoreOptimizedArticle({
jobId,
article,
qaReport,
resultVersionId: resultVersion?.id ?? null,
}),
);
return NextResponse.json({ scoringRun }, { status: 201 });
+80 -1
View File
@@ -1,7 +1,10 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../../../lib/api/auth";
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
import { buildArticleCaseSummary, createProcessStep } from "../../../../../lib/cases/summaries";
import { getRepositoryFromRuntime, type AppRepository } from "../../../../../lib/db/repository";
import type { ArticleJob } from "../../../../../lib/db/repositories";
import type { LlmAuditSummary } from "../../../../../lib/llm/audit";
import { LlmValidationError } from "../../../../../lib/llm/client";
import { getExportStoreFromRuntime } from "../../../../../lib/workflow/export-store";
import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator";
@@ -36,6 +39,8 @@ export async function POST(request: Request, context: RouteContext) {
}
const requestStartedAt = Date.now();
const caseId = await ensureArticleCase(repository, job);
const llmAuditSummary: LlmAuditSummary[] = [];
startWorkflowProgress(jobId);
try {
const result = await runOptimizationWorkflow({
@@ -50,6 +55,9 @@ export async function POST(request: Request, context: RouteContext) {
onProgress: (event) => {
recordWorkflowProgress(jobId, event);
},
onAuditSummary: (summary) => {
llmAuditSummary.push(summary);
},
});
const optimizedArticle = await repository.saveOptimizedArticle(
jobId,
@@ -70,8 +78,27 @@ export async function POST(request: Request, context: RouteContext) {
status: "optimized",
export_paths: exportPaths,
});
const resultVersion = await repository.createOptimizationResultVersion({
case_id: caseId,
case_type: "article",
status: "optimized",
article_job_id: jobId,
article_revision: optimizedArticle.revision ?? 1,
result_summary: optimizedArticle.summary,
payload: {
article: optimizedArticle,
qa_report: qaReport,
export_paths: exportPaths,
},
process_summary: [],
llm_audit_summary: llmAuditSummary,
error_stage: null,
error_summary: null,
});
return NextResponse.json({
case: { id: caseId, case_type: "article" },
resultVersion: { id: resultVersion.id, version: resultVersion.version },
optimizedArticle,
qaReport,
exportPaths,
@@ -81,6 +108,28 @@ export async function POST(request: Request, context: RouteContext) {
});
} catch (error) {
const message = error instanceof Error ? error.message : "LLM optimization failed";
await repository.createOptimizationResultVersion({
case_id: caseId,
case_type: "article",
status: "failed",
article_job_id: jobId,
article_revision: null,
result_summary: "",
payload: null,
process_summary: [
createProcessStep({
stage: "optimize",
startedAt: requestStartedAt,
endedAt: Date.now(),
status: "failed",
errorSummary: message,
producedResultVersion: false,
}),
],
llm_audit_summary: llmAuditSummary,
error_stage: "optimize",
error_summary: message,
});
return NextResponse.json(
{
error: message,
@@ -94,6 +143,36 @@ export async function POST(request: Request, context: RouteContext) {
}
}
async function ensureArticleCase(
repository: AppRepository,
job: ArticleJob,
) {
if (job.case_id) return job.case_id;
const caseSummary = buildArticleCaseSummary({
source_title: job.source_title,
source_body: job.source_body,
publish_platform: job.publish_platform,
});
const optimizationCase = await repository.createOptimizationCase({
case_type: "article",
...caseSummary,
});
await repository.saveCaseInput({
case_id: optimizationCase.id,
case_type: "article",
article_job_id: job.id,
payload: {
source_title: job.source_title,
source_body: job.source_body,
image_inputs: job.image_inputs,
publish_platform: job.publish_platform,
user_instructions: job.user_instructions,
},
});
return optimizationCase.id;
}
function getErrorStatus(error: unknown) {
if (error instanceof LlmValidationError) return 502;
if (error instanceof Error && /^LLM\b|provider/i.test(error.message)) return 502;
+287
View File
@@ -0,0 +1,287 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../../lib/api/auth";
import type { ProcessSummaryStep } from "../../../../lib/cases/types";
import { buildArticleCaseSummary, createProcessStep } from "../../../../lib/cases/summaries";
import { getRepositoryFromRuntime } from "../../../../lib/db/repository";
import { optimizationFactCardSchema } from "../../../../lib/domain/validation";
import type { LlmAuditSummary } from "../../../../lib/llm/audit";
import { LlmValidationError } from "../../../../lib/llm/client";
import { getExportStoreFromRuntime } from "../../../../lib/workflow/export-store";
import { extractCandidateFactCard } from "../../../../lib/workflow/fact-extractor";
import {
normalizeInput,
type RawArticleInput,
} from "../../../../lib/workflow/input-normalizer";
import {
encodeOptimizationStreamEvent,
type OptimizationStreamEvent,
type OptimizationStreamStage,
} from "../../../../lib/workflow/stream-events";
import { runStreamingOptimizationWorkflow } from "../../../../lib/workflow/streaming-optimizer";
interface OptimizeStreamPayload extends RawArticleInput {
fact_card?: unknown;
}
export async function POST(request: Request) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
let payload: OptimizeStreamPayload;
try {
payload = (await request.json()) as OptimizeStreamPayload;
} catch {
return NextResponse.json({ error: "请求体不是合法 JSON" }, { status: 400 });
}
if (!hasOptimizableBody(payload)) {
return NextResponse.json(
{ error: "请输入需要优化的文章内容" },
{ status: 400 },
);
}
let normalized: ReturnType<typeof normalizeInput>;
try {
normalized = normalizeInput(payload);
} catch (error) {
return jsonError(error, getErrorStatus(error));
}
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: OptimizationStreamEvent) => {
controller.enqueue(encoder.encode(encodeOptimizationStreamEvent(event)));
};
let jobId: string | undefined;
let caseId: string | undefined;
let stage: OptimizationStreamStage = "job";
const llmAuditSummary: LlmAuditSummary[] = [];
const processSummary: ProcessSummaryStep[] = [];
const requestStartedAt = Date.now();
try {
const repository = getRepositoryFromRuntime();
const caseSummary = buildArticleCaseSummary({
source_title: normalized.articleInput.title,
source_body: normalized.articleInput.body,
publish_platform: normalized.articleInput.platform,
});
const optimizationCase = await repository.createOptimizationCase({
case_type: "article",
...caseSummary,
});
caseId = optimizationCase.id;
const job = await repository.createArticleJob({
case_id: optimizationCase.id,
source_title: normalized.articleInput.title,
source_body: normalized.articleInput.body,
image_inputs: normalized.articleInput.images,
publish_platform: normalized.articleInput.platform,
user_instructions: normalized.articleInput.user_instructions,
});
jobId = job.id;
await repository.saveCaseInput({
case_id: optimizationCase.id,
case_type: "article",
article_job_id: job.id,
payload: {
source_title: normalized.articleInput.title,
source_body: normalized.articleInput.body,
image_inputs: normalized.articleInput.images,
publish_platform: normalized.articleInput.platform,
user_instructions: normalized.articleInput.user_instructions,
},
});
send({
type: "job_created",
job: { id: job.id },
case: { id: optimizationCase.id, case_type: "article" },
});
stage = "fact_card";
const factCardStartedAt = Date.now();
const factCard = optimizationFactCardSchema.parse(
payload.fact_card ??
(await extractCandidateFactCard(normalized.articleInput, {
onAuditSummary: (summary) => {
llmAuditSummary.push(summary);
},
})),
);
processSummary.push(
createProcessStep({
stage: "fact_card",
startedAt: factCardStartedAt,
endedAt: Date.now(),
status: "success",
producedResultVersion: false,
}),
);
const savedFactCard = await repository.saveFactCard(job.id, factCard);
send({
type: "fact_card_ready",
job_id: job.id,
fact_card: savedFactCard,
});
const result = await runStreamingOptimizationWorkflow({
jobId: job.id,
input: normalized.articleInput,
factCard: savedFactCard,
onEvent: (event) => {
stage = stageForEvent(event, stage);
send(event);
},
onAuditSummary: (summary) => {
llmAuditSummary.push(summary);
},
});
stage = "final";
const optimizedArticle = await repository.saveOptimizedArticle(
job.id,
result.article,
);
const qaReport = await repository.saveQaReport(
job.id,
optimizedArticle.revision ?? 1,
result.qaReport,
);
const exportStore = getExportStoreFromRuntime();
const exportPaths = await exportStore.writeJobExports({
jobId: job.id,
article: optimizedArticle,
qaReport,
});
await repository.updateArticleJob(job.id, {
status: "optimized",
export_paths: exportPaths,
});
const resultVersion = await repository.createOptimizationResultVersion({
case_id: optimizationCase.id,
case_type: "article",
status: "optimized",
article_job_id: job.id,
article_revision: optimizedArticle.revision ?? 1,
result_summary: optimizedArticle.summary,
payload: {
article: optimizedArticle,
qa_report: qaReport,
export_paths: exportPaths,
},
process_summary: [...processSummary, ...result.processSummary],
llm_audit_summary: llmAuditSummary,
error_stage: null,
error_summary: null,
});
send({
type: "final_ready",
job_id: job.id,
case: { id: optimizationCase.id, case_type: "article" },
result_version: { id: resultVersion.id, version: resultVersion.version },
optimized_article: optimizedArticle,
qa_report: qaReport,
export_paths: exportPaths,
});
} catch (error) {
const message = error instanceof Error ? error.message : "优化失败";
let failedVersion:
| { id: string; version: number }
| undefined;
if (caseId) {
try {
const repository = getRepositoryFromRuntime();
const version = await repository.createOptimizationResultVersion({
case_id: caseId,
case_type: "article",
status: "failed",
article_job_id: jobId ?? null,
article_revision: null,
result_summary: "",
payload: null,
process_summary: [
...processSummary,
createProcessStep({
stage,
startedAt: requestStartedAt,
endedAt: Date.now(),
status: "failed",
errorSummary: message,
producedResultVersion: false,
}),
],
llm_audit_summary: llmAuditSummary,
error_stage: stage,
error_summary: message,
});
failedVersion = { id: version.id, version: version.version };
} catch {
failedVersion = undefined;
}
}
send({
type: "failed",
job_id: jobId,
case: caseId ? { id: caseId, case_type: "article" } : undefined,
result_version: failedVersion,
stage,
error: message,
});
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"content-type": "application/x-ndjson; charset=utf-8",
"cache-control": "no-cache, no-transform",
},
});
}
function hasOptimizableBody(payload: unknown): payload is OptimizeStreamPayload {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return false;
}
const body = (payload as Partial<OptimizeStreamPayload>).body;
return typeof body === "string" && body.trim().length > 0;
}
function stageForEvent(
event: OptimizationStreamEvent,
fallback: OptimizationStreamStage,
): OptimizationStreamStage {
switch (event.type) {
case "draft_started":
case "draft_ready":
return "draft";
case "qa_started":
case "qa_ready":
return "qa";
case "rewrite_started":
case "rewrite_ready":
return "rewrite";
default:
return fallback;
}
}
function jsonError(error: unknown, status: number) {
const message = error instanceof Error ? error.message : "Request failed";
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;
}
+56 -1
View File
@@ -1,7 +1,9 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../lib/api/auth";
import { buildArticleCaseSummary, createProcessStep } from "../../../lib/cases/summaries";
import { getRepositoryFromRuntime } from "../../../lib/db/repository";
import type { LlmAuditSummary } from "../../../lib/llm/audit";
import { LlmValidationError } from "../../../lib/llm/client";
import { extractCandidateFactCard } from "../../../lib/workflow/fact-extractor";
import { normalizeInput, type RawArticleInput } from "../../../lib/workflow/input-normalizer";
@@ -16,29 +18,82 @@ export async function POST(request: Request) {
const payload = (await request.json()) as RawArticleInput;
const normalized = normalizeInput(payload);
const repository = getRepositoryFromRuntime();
const caseSummary = buildArticleCaseSummary({
source_title: normalized.articleInput.title,
source_body: normalized.articleInput.body,
publish_platform: normalized.articleInput.platform,
});
const optimizationCase = await repository.createOptimizationCase({
case_type: "article",
...caseSummary,
});
const job = await repository.createArticleJob({
case_id: optimizationCase.id,
source_title: normalized.articleInput.title,
source_body: normalized.articleInput.body,
image_inputs: normalized.articleInput.images,
publish_platform: normalized.articleInput.platform,
user_instructions: normalized.articleInput.user_instructions,
});
await repository.saveCaseInput({
case_id: optimizationCase.id,
case_type: "article",
article_job_id: job.id,
payload: {
source_title: normalized.articleInput.title,
source_body: normalized.articleInput.body,
image_inputs: normalized.articleInput.images,
publish_platform: normalized.articleInput.platform,
user_instructions: normalized.articleInput.user_instructions,
},
});
const factStartedAt = Date.now();
const llmAuditSummary: LlmAuditSummary[] = [];
const timing = {
total_ms: 0,
steps: [] as Array<{ label: string; duration_ms: number }>,
};
try {
const candidateFactCard = await extractCandidateFactCard(normalized.articleInput);
const candidateFactCard = await extractCandidateFactCard(
normalized.articleInput,
{
onAuditSummary: (summary) => {
llmAuditSummary.push(summary);
},
},
);
const duration = Date.now() - factStartedAt;
timing.total_ms = duration;
timing.steps.push({ label: "事实卡提取", duration_ms: duration });
return NextResponse.json({ job, candidateFactCard, timing }, { status: 201 });
} catch (error) {
const message = error instanceof Error ? error.message : "Request failed";
const duration = Date.now() - factStartedAt;
timing.total_ms = duration;
timing.steps.push({ label: "事实卡提取", duration_ms: duration });
await repository.createOptimizationResultVersion({
case_id: optimizationCase.id,
case_type: "article",
status: "failed",
article_job_id: job.id,
article_revision: null,
result_summary: "",
payload: null,
process_summary: [
createProcessStep({
stage: "fact_card",
startedAt: factStartedAt,
endedAt: Date.now(),
status: "failed",
errorSummary: message,
producedResultVersion: false,
}),
],
llm_audit_summary: llmAuditSummary,
error_stage: "fact_card",
error_summary: message,
});
return jsonError(error, getErrorStatus(error), timing);
}
} catch (error) {
@@ -4,6 +4,7 @@ import { requireApiAccess } from "../../../../../lib/api/auth";
import { createManualPerformanceAdapter } from "../../../../../lib/calibration/manual-adapter";
import { createCalibrationEvent } from "../../../../../lib/calibration/scoring";
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
import type { QaReport } from "../../../../../lib/domain/types";
interface RouteContext {
params: Promise<{ publicationId: string }>;
@@ -22,12 +23,27 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: "Publication not found" }, { status: 404 });
}
const scoringRun = await repository.getLatestScoringRun(
publication.job_id,
publication.revision,
);
const qaReport = await repository.getLatestQaReport(publication.job_id);
if (!scoringRun || !qaReport) {
const scoringRun = publication.result_version_id
? await repository.getLatestScoringRunForResultVersion(
publication.result_version_id,
)
: publication.job_id && publication.revision
? await repository.getLatestScoringRun(
publication.job_id,
publication.revision,
)
: null;
const qaReport: QaReport | null = publication.job_id
? await repository.getLatestQaReport(publication.job_id)
: scoringRun?.case_type === "human_copy"
? {
overall_status: "pass",
checks: [],
}
: null;
const manualInput = await request.json();
if (!publication.result_version_id && (!scoringRun || !qaReport)) {
return NextResponse.json(
{ error: "Score the optimized revision before recording performance" },
{ status: 409 },
@@ -40,9 +56,15 @@ export async function POST(request: Request, context: RouteContext) {
await adapter.fetch({
publication,
window_label: "manual",
manualInput: await request.json(),
manualInput,
}),
);
if (!scoringRun || !qaReport) {
return NextResponse.json(
{ snapshot, calibrationEvent: null },
{ status: 201 },
);
}
const calibrationEvent = await repository.saveCalibrationEvent(
createCalibrationEvent({ scoringRun, qaReport, snapshot }),
);
+11
View File
@@ -0,0 +1,11 @@
import { CaseDetail } from "../../../components/cases/case-detail";
export default async function CaseDetailPage({
params,
}: {
params: Promise<{ caseId: string }>;
}) {
const { caseId } = await params;
return <CaseDetail caseId={caseId} />;
}
+6
View File
@@ -0,0 +1,6 @@
import { CaseList } from "../../components/cases/case-list";
export default function CasesPage() {
return <CaseList />;
}
+512 -3
View File
@@ -47,6 +47,12 @@ button:disabled {
opacity: 0.45;
}
.secondary-button {
background: #ffffff;
border-color: #cbd3df;
color: #172033;
}
input,
select,
textarea {
@@ -69,6 +75,7 @@ label {
}
label span,
.field-label,
h3 {
color: #586174;
font-size: 0.82rem;
@@ -76,6 +83,11 @@ h3 {
text-transform: uppercase;
}
.field-group {
display: grid;
gap: 0.5rem;
}
.app-shell {
display: grid;
gap: 1rem;
@@ -104,14 +116,65 @@ h3 {
color: #586174;
}
.topbar-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: flex-end;
}
.api-key-field {
min-width: min(16rem, 100%);
}
.api-session-control {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
max-width: 28rem;
}
.api-session-control small {
color: #586174;
flex-basis: 100%;
font-size: 0.78rem;
text-align: right;
}
.text-link {
color: #2f6fdd;
font-weight: 800;
text-decoration: none;
}
.text-link:hover {
text-decoration: underline;
}
.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;
}
.workflow-grid {
align-items: start;
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(22rem, 0.86fr) minmax(0, 1.14fr);
}
.panel {
@@ -122,6 +185,19 @@ h3 {
padding: 1rem;
}
.compact-panel {
min-height: auto;
padding: 0.85rem;
}
.input-panel {
grid-row: span 2;
}
.fact-card-panel {
gap: 0.7rem;
}
.stack {
display: grid;
align-content: start;
@@ -146,8 +222,16 @@ h3 {
font-weight: 800;
}
.panel-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
.body-input {
min-height: 14rem;
min-height: 20rem;
}
.two-col {
@@ -161,6 +245,28 @@ h3 {
font-weight: 700;
}
.uncertain-list {
display: grid;
gap: 0.65rem;
}
.uncertain-row {
border-top: 1px solid #e5e9f0;
display: grid;
gap: 0.5rem;
padding-top: 0.65rem;
}
.uncertain-row p {
margin: 0;
}
.uncertain-actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.status-pill {
border-radius: 999px;
display: inline-flex;
@@ -208,6 +314,43 @@ h3 {
white-space: pre-wrap;
}
.markdown-preview {
background: #f6f7f9;
border-radius: 6px;
display: grid;
gap: 0.75rem;
margin-top: 0.75rem;
padding: 0.85rem;
}
.markdown-block {
animation: markdown-block-in 0.36s ease both;
}
.markdown-heading {
color: #172033;
font-size: 1rem;
font-weight: 800;
line-height: 1.45;
margin-top: 0.2rem;
}
.markdown-paragraph {
color: #172033;
line-height: 1.75;
margin: 0;
white-space: pre-wrap;
}
.markdown-list {
color: #172033;
display: grid;
gap: 0.45rem;
line-height: 1.7;
margin: 0;
padding-left: 1.2rem;
}
.tag-row,
.export-row {
display: flex;
@@ -222,6 +365,141 @@ h3 {
padding: 0.25rem 0.55rem;
}
.warn-tag {
background: #fff7e6;
border-color: #f0c978;
color: #7b560b;
}
.fact-summary-grid {
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.fact-summary-grid div {
background: #f4faf8;
border: 1px solid #d9ebe6;
border-radius: 6px;
display: grid;
gap: 0.22rem;
min-width: 0;
padding: 0.55rem;
}
.fact-summary-grid span {
color: #667085;
font-size: 0.72rem;
font-weight: 700;
}
.fact-summary-grid strong {
color: #172033;
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.fact-compact-list,
.fact-edit-grid {
display: grid;
gap: 0.55rem;
}
.fact-edit-grid {
border-top: 1px solid #e5e9f0;
padding-top: 0.7rem;
}
.optimized-panel {
min-height: 24rem;
overflow: hidden;
position: relative;
}
.optimized-panel.is-streaming {
border-color: #91cdbf;
box-shadow: 0 0 0 3px rgba(38, 138, 116, 0.08);
}
.stream-status {
align-items: center;
color: #0d6b58;
display: inline-flex;
font-size: 0.78rem;
font-weight: 800;
gap: 0.45rem;
}
.stream-cursor {
animation: stream-spin 0.8s linear infinite;
border: 2px solid #b7ddd5;
border-radius: 999px;
border-top-color: #0d6b58;
display: inline-block;
height: 1rem;
width: 1rem;
}
.streaming-preview {
border-left-color: #20846d;
}
.streaming-preview .markdown-preview {
animation: preview-breathe 1.8s ease-in-out infinite;
background: #f4fbf8;
border: 1px solid #d6eee8;
}
.stream-placeholder {
display: grid;
gap: 0.7rem;
}
.preview-pulse-line,
.preview-pulse-block {
animation: preview-breathe 1.6s ease-in-out infinite;
background: linear-gradient(90deg, #edf5f2, #dceee8, #edf5f2);
border-radius: 6px;
}
.preview-pulse-line {
height: 1rem;
width: 82%;
}
.preview-pulse-line.title-line {
height: 1.5rem;
width: 54%;
}
.preview-pulse-line.short-line {
width: 66%;
}
.preview-pulse-block {
height: 12rem;
width: 100%;
}
.stream-word-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
min-height: 1.8rem;
}
.stream-word {
animation: word-pop 0.46s ease both;
background: #e9f6f2;
border: 1px solid #b7ddd5;
border-radius: 999px;
color: #0d6b58;
font-size: 0.78rem;
font-weight: 800;
padding: 0.22rem 0.5rem;
}
.qa-list {
display: grid;
gap: 0.7rem;
@@ -311,15 +589,246 @@ h3 {
padding: 0.75rem 0.75rem 0.75rem 1.4rem;
}
.copy-optimizer-grid {
align-items: start;
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;
}
.case-toolbar {
align-items: end;
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(16rem, 1fr) 12rem 12rem;
}
.case-table {
border: 1px solid #dce2eb;
border-radius: 8px;
background: #ffffff;
overflow: hidden;
}
.case-row {
align-items: center;
color: #172033;
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(16rem, 1.45fr) 7rem minmax(8rem, 0.9fr) minmax(7rem, 0.75fr) minmax(7rem, 0.8fr) 6rem 7rem;
min-height: 4.25rem;
padding: 0.75rem 0.9rem;
text-decoration: none;
}
.case-row + .case-row {
border-top: 1px solid #e5e9f0;
}
.case-row:hover {
background: #f8fafc;
}
.case-row-heading {
background: #f6f7f9;
color: #586174;
font-size: 0.76rem;
font-weight: 800;
min-height: auto;
text-transform: uppercase;
}
.case-row span,
.case-row strong,
.case-row small {
min-width: 0;
overflow-wrap: anywhere;
}
.case-row strong {
display: block;
}
.case-row small {
color: #586174;
display: block;
font-size: 0.78rem;
margin-top: 0.2rem;
}
.case-detail-grid {
align-items: start;
display: grid;
gap: 1rem;
grid-template-columns: minmax(17rem, 0.35fr) minmax(0, 1fr);
}
.case-meta-grid {
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.case-meta-grid div {
background: #f6f7f9;
border: 1px solid #e5e9f0;
border-radius: 6px;
display: grid;
gap: 0.22rem;
min-width: 0;
padding: 0.55rem;
}
.case-meta-grid span {
color: #667085;
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.case-meta-grid strong {
color: #172033;
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.case-version-list,
.case-publication-list,
.case-publication-form {
display: grid;
gap: 0.6rem;
}
.case-version-item {
background: #ffffff;
border-color: #dce2eb;
color: #172033;
display: grid;
gap: 0.25rem;
min-height: 4rem;
padding: 0.65rem;
text-align: left;
white-space: normal;
}
.case-version-item.selected {
border-color: #172033;
box-shadow: 0 0 0 2px rgba(23, 32, 51, 0.08);
}
.case-version-item small {
color: #586174;
}
.case-json-preview,
.case-markdown-preview {
background: #f6f7f9;
border: 1px solid #e5e9f0;
border-radius: 6px;
margin: 0;
overflow: auto;
padding: 0.8rem;
white-space: pre-wrap;
}
.case-publication-list {
list-style: none;
margin: 0;
padding: 0;
}
.case-publication-list li {
border: 1px solid #e5e9f0;
border-radius: 6px;
display: grid;
gap: 0.25rem;
padding: 0.65rem;
}
.case-publication-list small {
color: #586174;
}
@media (max-width: 900px) {
.workflow-grid,
.copy-optimizer-grid,
.two-col,
.calibration-metrics {
.fact-summary-grid,
.calibration-metrics,
.case-toolbar,
.case-detail-grid,
.case-meta-grid {
grid-template-columns: 1fr;
}
.case-row,
.case-row-heading {
grid-template-columns: 1fr;
}
.case-row-heading {
display: none;
}
.input-panel {
grid-row: auto;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
}
@keyframes stream-spin {
to {
transform: rotate(360deg);
}
}
@keyframes preview-breathe {
0%,
100% {
opacity: 0.72;
}
50% {
opacity: 1;
}
}
@keyframes word-pop {
0% {
opacity: 0;
transform: translateY(0.4rem) scale(0.9);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes markdown-block-in {
0% {
opacity: 0;
transform: translateY(0.35rem);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
+186 -204
View File
@@ -1,25 +1,28 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import {
ArticleInputForm,
type ArticleInputPayload,
} from "../components/article-input-form";
import {
FactCardEditor,
toConfirmedFactCard,
} from "../components/fact-card-editor";
import { FactCardEditor } from "../components/fact-card-editor";
import { OptimizedPreview } from "../components/optimized-preview";
import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel";
import { ProgressPanel } from "../components/progress-panel";
import { QaReportPanel } from "../components/qa-report-panel";
import { RenweiCopyOptimizerPanel } from "../components/renwei-copy-optimizer-panel";
import type {
CandidateFactCard,
OptimizationFactCard,
OptimizedArticle,
QaReport,
} from "../lib/domain/types";
import type { ProgressAction } from "../lib/progress/progress";
import {
parseOptimizationStreamChunk,
type OptimizationStreamEvent,
} from "../lib/workflow/stream-events";
const initialInput: ArticleInputPayload = {
title: "",
@@ -31,7 +34,6 @@ const initialInput: ArticleInputPayload = {
interface ApiErrorResponse {
error?: string;
timing?: TimingSummary;
}
interface TimingStep {
@@ -44,42 +46,12 @@ interface TimingSummary {
steps: TimingStep[];
}
interface ProgressStep {
label: string;
status: "running" | "completed" | "failed";
duration_ms?: number;
}
interface WorkflowProgress {
current_step: string | null;
status: "idle" | "running" | "completed" | "failed";
steps: ProgressStep[];
}
interface ProgressResponse extends ApiErrorResponse {
progress: WorkflowProgress;
}
interface CreateJobResponse extends ApiErrorResponse {
job: { id: string };
candidateFactCard: CandidateFactCard;
timing?: TimingSummary;
}
interface ConfirmFactCardResponse extends ApiErrorResponse {
factCard: CandidateFactCard;
}
interface OptimizeJobResponse extends ApiErrorResponse {
optimizedArticle: OptimizedArticle;
qaReport: QaReport;
timing?: TimingSummary;
}
export default function Home() {
const [activeTab, setActiveTab] = useState<"geo" | "copy">("geo");
const [input, setInput] = useState(initialInput);
const [jobId, setJobId] = useState<string | null>(null);
const [factCard, setFactCard] = useState<CandidateFactCard | null>(null);
const [factCard, setFactCard] = useState<OptimizationFactCard | null>(null);
const [draftArticle, setDraftArticle] = useState<OptimizedArticle | null>(null);
const [optimizedArticle, setOptimizedArticle] =
useState<OptimizedArticle | null>(null);
const [qaReport, setQaReport] = useState<QaReport | null>(null);
@@ -88,10 +60,7 @@ export default function Home() {
const [apiAccessKey, setApiAccessKey] = useState("");
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [lastTiming, setLastTiming] = useState<TimingSummary | null>(null);
const [liveProgress, setLiveProgress] = useState<WorkflowProgress | null>(null);
const canOptimize =
Boolean(jobId) && Boolean(factCard) && factCard?.uncertain_items.length === 0;
const [streamActivity, setStreamActivity] = useState("");
useEffect(() => {
if (!busyAction) return;
@@ -102,127 +71,98 @@ export default function Home() {
return () => window.clearInterval(timer);
}, [busyAction]);
useEffect(() => {
if (busyAction !== "optimize" || !jobId) return;
let cancelled = false;
async function pollProgress() {
try {
const response = await fetch(`/api/jobs/${jobId}/progress`, {
headers: apiHeaders(apiAccessKey),
});
const body = (await response.json()) as ProgressResponse;
if (!cancelled && response.ok) {
setLiveProgress(body.progress);
}
} catch {
// The main optimization request still owns user-facing errors.
}
}
void pollProgress();
const timer = window.setInterval(pollProgress, 2000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [apiAccessKey, busyAction, jobId]);
function startBusyAction(action: ProgressAction) {
setElapsedSeconds(0);
setBusyAction(action);
}
async function analyze() {
startBusyAction("analyze");
setMessage("");
setLastTiming(null);
setLiveProgress(null);
setOptimizedArticle(null);
setQaReport(null);
try {
const response = await fetch("/api/jobs", {
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify(input),
});
const body = (await response.json()) as CreateJobResponse;
if (!response.ok) throw new ApiResponseError(body.error ?? "分析失败", body.timing);
setJobId(body.job.id);
setFactCard(body.candidateFactCard);
setLastTiming(body.timing ?? null);
setMessage(
body.timing
? `候选事实卡已生成,用时 ${formatTiming(body.timing.total_ms)}`
: "候选事实卡已生成。",
);
} catch (error) {
if (error instanceof ApiResponseError) {
setLastTiming(error.timing ?? null);
}
setMessage(error instanceof Error ? error.message : "分析失败");
} finally {
setBusyAction(null);
}
}
async function confirmFactCard() {
if (!jobId || !factCard) return;
startBusyAction("confirm");
setMessage("");
setLastTiming(null);
try {
const response = await fetch(`/api/jobs/${jobId}/confirm-fact-card`, {
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify(toConfirmedFactCard(factCard)),
});
const body = (await response.json()) as ConfirmFactCardResponse;
if (!response.ok) throw new Error(body.error ?? "确认失败");
setFactCard(body.factCard);
setMessage("事实卡已确认。");
} catch (error) {
setMessage(error instanceof Error ? error.message : "确认失败");
} finally {
setBusyAction(null);
}
}
async function optimize() {
if (!jobId) return;
startBusyAction("optimize");
setMessage("");
setLastTiming(null);
setLiveProgress(null);
setDraftArticle(null);
setOptimizedArticle(null);
setQaReport(null);
setStreamActivity("正在创建任务");
try {
const response = await fetch(`/api/jobs/${jobId}/optimize`, {
const payload: ArticleInputPayload & {
fact_card?: OptimizationFactCard;
} = {
...input,
title: "",
};
if (factCard) {
payload.fact_card = factCard;
}
const response = await fetch("/api/jobs/optimize-stream", {
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify(payload),
});
const body = (await response.json()) as OptimizeJobResponse;
if (!response.ok) throw new ApiResponseError(body.error ?? "优化失败", body.timing);
setOptimizedArticle(body.optimizedArticle);
setQaReport(body.qaReport);
setLastTiming(body.timing ?? null);
setLiveProgress(null);
const timingText = body.timing
? `用时 ${formatTiming(body.timing.total_ms)}`
: "";
setMessage(
body.qaReport.overall_status === "fail"
? `质检发现需要复核的问题,已保留导出文件。${timingText}`
: `优化完成。${timingText}`,
);
} catch (error) {
if (error instanceof ApiResponseError) {
setLastTiming(error.timing ?? null);
if (!response.ok) {
const body = (await response.json()) as ApiErrorResponse;
throw new Error(body.error ?? "优化失败");
}
await readOptimizationStream(response, handleStreamEvent);
} catch (error) {
setMessage(error instanceof Error ? error.message : "优化失败");
} finally {
setBusyAction(null);
}
}
function handleStreamEvent(event: OptimizationStreamEvent) {
switch (event.type) {
case "job_created":
setJobId(event.job.id);
setStreamActivity("任务已创建");
break;
case "fact_card_ready":
setFactCard(event.fact_card);
setStreamActivity("事实卡已就绪");
break;
case "draft_started":
setStreamActivity(event.message);
break;
case "draft_ready":
setDraftArticle(event.article);
setStreamActivity("草稿已生成");
break;
case "qa_started":
setStreamActivity(event.message);
break;
case "qa_ready":
setQaReport(event.qa_report);
setStreamActivity("质量检查完成");
break;
case "rewrite_started":
setStreamActivity(`正在修复第 ${event.round}`);
break;
case "rewrite_ready":
setDraftArticle(event.article);
setStreamActivity(`${event.round} 轮修复完成`);
break;
case "final_ready":
setOptimizedArticle(event.optimized_article);
setDraftArticle(null);
setQaReport(event.qa_report);
setStreamActivity("终稿已生成");
setMessage(
event.qa_report.overall_status === "fail"
? "优化完成,质检发现需要复核的问题。"
: "优化完成。",
);
break;
case "failed":
setStreamActivity("优化失败");
throw new Error(event.error);
}
}
return (
<main className="app-shell">
<header className="topbar">
@@ -230,58 +170,118 @@ export default function Home() {
<h1>GEO </h1>
{message && <p>{message}</p>}
</div>
<label className="api-key-field">
<span>访</span>
<input
autoComplete="off"
onChange={(event) => setApiAccessKey(event.target.value)}
type="password"
value={apiAccessKey}
/>
</label>
<div className="topbar-actions">
<Link className="text-link" href="/cases">
</Link>
<label className="api-key-field">
<span>访</span>
<input
autoComplete="off"
onChange={(event) => setApiAccessKey(event.target.value)}
type="password"
value={apiAccessKey}
/>
</label>
</div>
</header>
<nav className="app-tabs" aria-label="功能标签">
<button
disabled={!canOptimize || busyAction === "optimize"}
onClick={optimize}
className={activeTab === "geo" ? "active-tab" : undefined}
onClick={() => setActiveTab("geo")}
type="button"
>
{busyAction === "optimize" ? "优化中..." : "开始优化"}
GEO
</button>
</header>
<ProgressPanel
action={busyAction}
elapsedSeconds={elapsedSeconds}
lastTiming={lastTiming}
liveProgress={liveProgress}
/>
<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
<button
className={activeTab === "copy" ? "active-tab" : undefined}
onClick={() => setActiveTab("copy")}
type="button"
>
</button>
</nav>
{activeTab === "geo" ? (
<>
<ProgressPanel
action={busyAction}
elapsedSeconds={elapsedSeconds}
lastTiming={lastTiming}
liveProgress={null}
/>
<div className="workflow-grid">
<ArticleInputForm
isSubmitting={busyAction === "optimize"}
value={input}
onChange={setInput}
onSubmit={optimize}
/>
<FactCardEditor
factCard={factCard}
isSaving={busyAction === "optimize"}
onChange={setFactCard}
/>
<OptimizedPreview
activityText={streamActivity}
article={optimizedArticle}
draftArticle={draftArticle}
isStreaming={busyAction === "optimize"}
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}
jobId={jobId}
key={`${jobId ?? "no-job"}-${optimizedArticle?.revision ?? "no-revision"}`}
optimizedRevision={optimizedArticle?.revision ?? null}
/>
</div>
)}
</main>
);
}
async function readOptimizationStream(
response: Response,
onEvent: (event: OptimizationStreamEvent) => void,
) {
if (!response.body) {
throw new Error("当前浏览器不支持流式响应");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let remainder = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const parsed = parseOptimizationStreamChunk(
remainder,
decoder.decode(value, { stream: true }),
);
remainder = parsed.remainder;
parsed.events.forEach(onEvent);
}
const finalText = decoder.decode();
if (finalText) {
const parsed = parseOptimizationStreamChunk(remainder, finalText);
remainder = parsed.remainder;
parsed.events.forEach(onEvent);
}
if (remainder.trim().length > 0) {
onEvent(JSON.parse(remainder) as OptimizationStreamEvent);
}
}
function apiHeaders(apiAccessKey: string) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiAccessKey) {
@@ -289,21 +289,3 @@ function apiHeaders(apiAccessKey: string) {
}
return headers;
}
function formatTiming(milliseconds: number) {
const seconds = Math.round(milliseconds / 1000);
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}${remainingSeconds}`;
}
class ApiResponseError extends Error {
constructor(
message: string,
public readonly timing?: TimingSummary,
) {
super(message);
this.name = "ApiResponseError";
}
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import type { CandidateFactCard } from "../../lib/domain/types";
import { resolveUncertainItem } from "../fact-card-editor";
const baseFactCard: CandidateFactCard = {
company_full_name: "示例科技有限公司",
company_short_names: ["示例科技"],
brand_names: ["示例品牌"],
product_names: ["GEO内容优化平台"],
target_industry: "GEO内容优化",
target_audience: "市场团队",
experience_years: 8,
core_claims: ["提供GEO内容优化服务"],
forbidden_claims: [],
image_topics: ["产品后台截图"],
uncertain_items: ["客户案例缺少来源", "出海能力需要确认"],
is_ready_for_optimization: false,
};
describe("FactCardEditor uncertain item resolution", () => {
it("adds a confirmed uncertain item to the selected fact field and removes it", () => {
const resolved = resolveUncertainItem(baseFactCard, 0, "core_claims");
expect(resolved.core_claims).toEqual([
"提供GEO内容优化服务",
"客户案例缺少来源",
]);
expect(resolved.uncertain_items).toEqual(["出海能力需要确认"]);
expect(resolved.is_ready_for_optimization).toBe(false);
});
it("marks the fact card ready after the final uncertain item is resolved", () => {
const resolved = resolveUncertainItem(
{
...baseFactCard,
uncertain_items: ["图片主题需要确认"],
},
0,
"image_topics",
);
expect(resolved.image_topics).toEqual(["产品后台截图", "图片主题需要确认"]);
expect(resolved.uncertain_items).toEqual([]);
expect(resolved.is_ready_for_optimization).toBe(true);
});
it("can ignore an uncertain item without adding it to fact fields", () => {
const resolved = resolveUncertainItem(baseFactCard, 0, "ignore");
expect(resolved.core_claims).toEqual(["提供GEO内容优化服务"]);
expect(resolved.forbidden_claims).toEqual([]);
expect(resolved.image_topics).toEqual(["产品后台截图"]);
expect(resolved.uncertain_items).toEqual(["出海能力需要确认"]);
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
formatChangedSection,
getVisibleMarkdownBlocks,
parseMarkdownBlocks,
} from "../optimized-preview";
describe("OptimizedPreview markdown helpers", () => {
it("parses markdown headings, paragraphs, and bullets without exposing markers", () => {
const blocks = parseMarkdownBlocks(
"## 服务特点\n\n- **定制化方案**:拒绝模板化。\n- 数据驱动:提升运营效率。\n\n伟思德鲁提供全域增长服务。",
);
expect(blocks).toEqual([
{ type: "heading", text: "服务特点", level: 2 },
{
type: "list",
items: ["**定制化方案**:拒绝模板化。", "数据驱动:提升运营效率。"],
},
{ type: "paragraph", text: "伟思德鲁提供全域增长服务。" },
]);
expect(blocks.map((block) => JSON.stringify(block)).join("\n")).not.toContain(
"##",
);
});
it("reveals markdown blocks progressively while streaming", () => {
const blocks = parseMarkdownBlocks(
"## 第一段\n\n正文一。\n\n## 第二段\n\n正文二。",
);
expect(getVisibleMarkdownBlocks(blocks, true, 2)).toEqual(blocks.slice(0, 2));
expect(getVisibleMarkdownBlocks(blocks, false, 1)).toEqual(blocks);
});
it("formats changed section keys as customer-readable Chinese labels", () => {
expect(formatChangedSection("title")).toBe("标题优化");
expect(formatChangedSection("body_markdown")).toBe("正文结构与表达");
expect(formatChangedSection("client_cases")).toBe("客户案例");
expect(formatChangedSection("unknown_internal_key")).toBe("其他内容");
});
});
+7 -15
View File
@@ -1,6 +1,6 @@
"use client";
import type { FormEvent } from "react";
import type { Dispatch, FormEvent, SetStateAction } from "react";
import type { PublishPlatform } from "../lib/domain/types";
@@ -15,7 +15,7 @@ export interface ArticleInputPayload {
interface ArticleInputFormProps {
value: ArticleInputPayload;
isSubmitting: boolean;
onChange: (value: ArticleInputPayload) => void;
onChange: Dispatch<SetStateAction<ArticleInputPayload>>;
onSubmit: () => void;
}
@@ -36,7 +36,7 @@ export function ArticleInputForm({
key: K,
nextValue: ArticleInputPayload[K],
) {
onChange({ ...value, [key]: nextValue });
onChange((current) => ({ ...current, [key]: nextValue }));
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -45,23 +45,15 @@ export function ArticleInputForm({
}
return (
<form className="panel stack" onSubmit={handleSubmit}>
<form className="panel stack input-panel" onSubmit={handleSubmit}>
<div className="panel-heading">
<span></span>
<button disabled={isSubmitting} type="submit">
{isSubmitting ? "分析中..." : "分析文章"}
<button disabled={isSubmitting || value.body.trim().length === 0} type="submit">
{isSubmitting ? "优化中..." : "开始优化"}
</button>
</div>
<label>
<span></span>
<input
required
value={value.title}
onChange={(event) => update("title", event.target.value)}
/>
</label>
<label>
<span></span>
<span></span>
<textarea
required
className="body-input"
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
apiFetch,
getApiSession,
loginApiSession,
logoutApiSession,
} from "../api-client";
describe("case API session client", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("checks browser session state with same-origin credentials", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ authenticated: true })),
);
await expect(getApiSession()).resolves.toEqual({ authenticated: true });
expect(fetch).toHaveBeenCalledWith("/api/auth/session", {
credentials: "same-origin",
});
});
it("creates a browser session with the provided access key", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ authenticated: true })),
);
await expect(loginApiSession("local-dev-key")).resolves.toEqual({
authenticated: true,
});
expect(fetch).toHaveBeenCalledWith("/api/auth/session", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({ api_access_key: "local-dev-key" }),
});
});
it("surfaces login errors from the session endpoint", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ error: "Unauthorized" }, 401)),
);
await expect(loginApiSession("wrong")).resolves.toEqual({
authenticated: false,
error: "Unauthorized",
});
});
it("clears the browser session", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ authenticated: false })),
);
await expect(logoutApiSession()).resolves.toEqual({ authenticated: false });
expect(fetch).toHaveBeenCalledWith("/api/auth/session", {
method: "DELETE",
credentials: "same-origin",
});
});
it("adds same-origin credentials to protected API requests", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ ok: true })));
await apiFetch("/api/cases", { headers: { accept: "application/json" } });
expect(fetch).toHaveBeenCalledWith("/api/cases", {
credentials: "same-origin",
headers: { accept: "application/json" },
});
});
});
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
+53
View File
@@ -0,0 +1,53 @@
export interface ApiSessionResult {
authenticated: boolean;
error?: string;
}
export function apiHeaders(apiAccessKey: string) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiAccessKey) {
headers["x-api-key"] = apiAccessKey;
}
return headers;
}
export function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) {
return fetch(input, {
...init,
credentials: init.credentials ?? "same-origin",
});
}
export async function getApiSession(): Promise<ApiSessionResult> {
const response = await apiFetch("/api/auth/session");
return readSessionResponse(response);
}
export async function loginApiSession(
apiAccessKey: string,
): Promise<ApiSessionResult> {
const response = await apiFetch("/api/auth/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ api_access_key: apiAccessKey }),
});
return readSessionResponse(response);
}
export async function logoutApiSession(): Promise<ApiSessionResult> {
const response = await apiFetch("/api/auth/session", {
method: "DELETE",
});
return readSessionResponse(response);
}
async function readSessionResponse(response: Response): Promise<ApiSessionResult> {
const body = (await response.json().catch(() => ({}))) as {
authenticated?: unknown;
error?: unknown;
};
return {
authenticated: response.ok && body.authenticated === true,
error: typeof body.error === "string" ? body.error : undefined,
};
}
@@ -0,0 +1,149 @@
"use client";
import type {
ArticleCaseInputPayload,
ArticleResultVersionPayload,
OptimizationResultVersion,
} from "../../lib/cases/types";
interface ArticleCaseDetailProps {
input: unknown;
version: OptimizationResultVersion | null;
}
const platformLabels: Record<string, string> = {
official_site: "官网文章",
media_article: "媒体稿",
comparison_review: "对比评测",
recommendation_list: "推荐榜单",
};
export function ArticleCaseDetail({ input, version }: ArticleCaseDetailProps) {
const payload = isArticleInput(input) ? input : null;
const result = isArticleResult(version?.payload) ? version.payload : null;
return (
<>
<section className="panel stack">
<div className="panel-heading"></div>
{payload ? (
<>
<div className="case-meta-grid">
<MetaItem label="标题" value={payload.source_title || "未填写"} />
<MetaItem
label="目标平台"
value={platformLabels[payload.publish_platform] ?? payload.publish_platform}
/>
<MetaItem
label="补充要求"
value={payload.user_instructions || "无"}
/>
<MetaItem
label="图片输入"
value={
payload.image_inputs.length > 0
? `${payload.image_inputs.length}`
: "无"
}
/>
</div>
<pre className="copy-result">{payload.source_body}</pre>
{payload.image_inputs.length > 0 ? (
<div className="tag-row">
{payload.image_inputs.map((item, index) => (
<span className="tag" key={`${item.content}-${index}`}>
{item.type === "link" ? "链接" : "描述"}{item.content}
</span>
))}
</div>
) : null}
</>
) : (
<p className="empty-panel"></p>
)}
</section>
{payload?.fact_card ? (
<section className="panel stack">
<div className="panel-heading"></div>
<pre className="case-json-preview">
{JSON.stringify(payload.fact_card, null, 2)}
</pre>
</section>
) : null}
<section className="panel stack">
<div className="panel-heading">稿</div>
{result ? (
<>
<article className="preview">
<h2>{result.article.title}</h2>
<p>{result.article.summary}</p>
<pre className="case-markdown-preview">
{result.article.body_markdown}
</pre>
</article>
<section className="stack">
<h3></h3>
<div className="qa-list">
{result.qa_report.checks.map((check) => (
<div className="qa-item" key={check.rule_id}>
<div className="qa-title-row">
<strong>{check.rule_id}</strong>
<span className={`status-pill ${check.status}`}>
{check.status}
</span>
</div>
<p>{check.reason}</p>
<small>{check.evidence}</small>
</div>
))}
</div>
</section>
<section className="stack">
<h3></h3>
<div className="export-row">
{Object.entries(result.export_paths).map(([name, path]) => (
<a className="download-link" href={path} key={name}>
{name}
</a>
))}
</div>
</section>
</>
) : (
<p className="empty-panel">
{version?.error_summary ?? "当前版本没有优化稿。"}
</p>
)}
</section>
</>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function isArticleInput(value: unknown): value is ArticleCaseInputPayload {
return (
value !== null &&
typeof value === "object" &&
"source_body" in value &&
"publish_platform" in value
);
}
function isArticleResult(value: unknown): value is ArticleResultVersionPayload {
return (
value !== null &&
typeof value === "object" &&
"article" in value &&
"qa_report" in value
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState, type FormEvent } from "react";
import {
getApiSession,
loginApiSession,
logoutApiSession,
} from "./api-client";
interface CaseAuthControlProps {
authenticated: boolean;
onAuthenticatedChange: (authenticated: boolean) => void;
}
export function CaseAuthControl({
authenticated,
onAuthenticatedChange,
}: CaseAuthControlProps) {
const [apiAccessKey, setApiAccessKey] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isChecking, setIsChecking] = useState(true);
const [message, setMessage] = useState("");
useEffect(() => {
let isCurrent = true;
getApiSession()
.then((session) => {
if (!isCurrent) return;
onAuthenticatedChange(session.authenticated);
setMessage(session.authenticated ? "已登录" : "");
})
.catch(() => {
if (!isCurrent) return;
onAuthenticatedChange(false);
setMessage("登录状态检查失败");
})
.finally(() => {
if (isCurrent) setIsChecking(false);
});
return () => {
isCurrent = false;
};
}, [onAuthenticatedChange]);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = apiAccessKey.trim();
if (!trimmed) {
setMessage("请输入访问密钥。");
return;
}
setIsSubmitting(true);
setMessage("");
try {
const session = await loginApiSession(trimmed);
onAuthenticatedChange(session.authenticated);
if (session.authenticated) {
setApiAccessKey("");
setMessage("已登录");
} else {
setMessage(session.error ?? "访问密钥无效或已失效");
}
} catch (error) {
onAuthenticatedChange(false);
setMessage(error instanceof Error ? error.message : "登录失败");
} finally {
setIsSubmitting(false);
}
}
async function logout() {
setIsSubmitting(true);
setMessage("");
try {
await logoutApiSession();
onAuthenticatedChange(false);
setMessage("已退出登录。");
} catch (error) {
setMessage(error instanceof Error ? error.message : "退出登录失败");
} finally {
setIsSubmitting(false);
}
}
if (authenticated) {
return (
<div className="api-session-control" aria-label="访问密钥登录状态">
<span className="status-pill pass"></span>
<button
className="secondary-button"
disabled={isSubmitting}
onClick={logout}
type="button"
>
退
</button>
{message && message !== "已登录" ? <small>{message}</small> : null}
</div>
);
}
return (
<form className="api-session-control" onSubmit={submit}>
<label className="api-key-field">
<span>访</span>
<input
autoComplete="current-password"
disabled={isChecking || isSubmitting}
onChange={(event) => setApiAccessKey(event.target.value)}
type="password"
value={apiAccessKey}
/>
</label>
<button disabled={isChecking || isSubmitting} type="submit">
{isSubmitting ? "登录中..." : "确认登录"}
</button>
{message ? <small>{message}</small> : null}
</form>
);
}
+200
View File
@@ -0,0 +1,200 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import type {
OptimizationCase,
OptimizationCaseDetail,
} from "../../lib/cases/types";
import { apiFetch } from "./api-client";
import { ArticleCaseDetail } from "./article-case-detail";
import { CaseAuthControl } from "./case-auth-control";
import { CasePublicationPanel } from "./case-publication-panel";
import { formatDate } from "./case-format";
import { CaseResultVersionList } from "./case-result-version-list";
import { HumanCopyCaseDetail } from "./human-copy-case-detail";
interface CaseDetailProps {
caseId: string;
}
type CaseDetailResponse = Partial<OptimizationCaseDetail> & {
error?: string;
};
const caseTypeLabels: Record<OptimizationCase["case_type"], string> = {
article: "文章优化案例",
human_copy: "人味文案优化案例",
};
const statusLabels: Record<OptimizationCase["status"], string> = {
running: "运行中",
optimized: "已优化",
failed: "失败",
archived: "已归档",
};
export function CaseDetail({ caseId }: CaseDetailProps) {
const [detail, setDetail] = useState<OptimizationCaseDetail | null>(null);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
const [authenticated, setAuthenticated] = useState(false);
const [sessionChecked, setSessionChecked] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
if (!sessionChecked || !authenticated) return;
let isCurrent = true;
apiFetch(`/api/cases/${caseId}`)
.then((response) => response.json() as Promise<CaseDetailResponse>)
.then((body: CaseDetailResponse) => {
if (!isCurrent) return;
if (body.error || !body.case || !body.versions) {
setMessage(body.error ?? "案例加载失败");
setDetail(null);
return;
}
const nextDetail = {
case: body.case,
input: body.input ?? null,
versions: body.versions,
};
setDetail(nextDetail);
setSelectedVersionId(
body.case.latest_result_version_id ??
body.versions[0]?.id ??
null,
);
setMessage("");
})
.catch((error) => {
if (!isCurrent) return;
setMessage(error instanceof Error ? error.message : "案例加载失败");
setDetail(null);
});
return () => {
isCurrent = false;
};
}, [authenticated, caseId, sessionChecked]);
const selectedVersion = useMemo(() => {
if (!detail) return null;
return (
detail.versions.find((version) => version.id === selectedVersionId) ??
detail.versions[0] ??
null
);
}, [detail, selectedVersionId]);
const updateAuthenticated = useCallback((nextAuthenticated: boolean) => {
setAuthenticated(nextAuthenticated);
setSessionChecked(true);
setMessage("");
if (!nextAuthenticated) {
setDetail(null);
setSelectedVersionId(null);
}
}, []);
const visibleMessage =
message ||
(sessionChecked && !authenticated ? "请先登录后查看案例详情。" : "");
return (
<main className="app-shell">
<header className="topbar">
<div>
<h1>{detail?.case.title ?? "案例详情"}</h1>
{visibleMessage ? (
<p>{visibleMessage}</p>
) : detail ? (
<p>{detail.case.summary}</p>
) : null}
</div>
<div className="topbar-actions">
<Link className="text-link" href="/cases">
</Link>
<CaseAuthControl
authenticated={authenticated}
onAuthenticatedChange={updateAuthenticated}
/>
</div>
</header>
{detail ? (
<>
<section className="panel compact-panel stack">
<div className="case-meta-grid">
<MetaItem label="类型" value={caseTypeLabels[detail.case.case_type]} />
<MetaItem label="状态" value={statusLabels[detail.case.status]} />
<MetaItem label="客户" value={detail.case.customer_name || "未填写"} />
<MetaItem label="品牌" value={detail.case.brand_name || "未填写"} />
<MetaItem
label="项目标签"
value={
detail.case.project_tags.length > 0
? detail.case.project_tags.join("、")
: "未标记"
}
/>
<MetaItem
label="发布目标"
value={detail.case.publish_target || "未指定"}
/>
<MetaItem label="版本" value={`v${detail.case.latest_version_number ?? 0}`} />
<MetaItem label="更新" value={formatDate(detail.case.updated_at)} />
</div>
{detail.case.notes ? <p>{detail.case.notes}</p> : null}
</section>
{detail.case.last_error_summary ? (
<section className="panel compact-panel stack">
<div className="panel-heading"></div>
<p className="status-text">{detail.case.last_error_stage}</p>
<p>{detail.case.last_error_summary}</p>
</section>
) : null}
<section className="case-detail-grid">
<CaseResultVersionList
selectedVersionId={selectedVersion?.id ?? null}
versions={detail.versions}
onSelect={setSelectedVersionId}
/>
<section className="stack">
{detail.case.case_type === "article" ? (
<ArticleCaseDetail
input={detail.input?.payload ?? null}
version={selectedVersion}
/>
) : (
<HumanCopyCaseDetail
input={detail.input?.payload ?? null}
version={selectedVersion}
/>
)}
<CasePublicationPanel
caseId={detail.case.id}
publishTarget={detail.case.publish_target}
versionId={selectedVersion?.id ?? null}
/>
</section>
</section>
</>
) : (
<section className="panel empty-panel"></section>
)}
</main>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { OptimizationResultVersion } from "../../lib/cases/types";
export function formatDate(value: string) {
return new Date(value).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export function versionLabel(version: OptimizationResultVersion) {
return `v${version.version} · ${
version.status === "optimized" ? "已优化" : "失败"
}`;
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { OptimizationCase } from "../../lib/cases/types";
import { apiFetch } from "./api-client";
import { CaseAuthControl } from "./case-auth-control";
interface CaseListResponse {
cases?: OptimizationCase[];
error?: string;
}
const caseTypeLabels: Record<OptimizationCase["case_type"], string> = {
article: "文章优化",
human_copy: "人味文案",
};
const statusLabels: Record<OptimizationCase["status"], string> = {
running: "运行中",
optimized: "已优化",
failed: "失败",
archived: "已归档",
};
export function CaseList() {
const [cases, setCases] = useState<OptimizationCase[]>([]);
const [caseType, setCaseType] = useState("");
const [status, setStatus] = useState("");
const [query, setQuery] = useState("");
const [message, setMessage] = useState("");
const [authenticated, setAuthenticated] = useState(false);
const [sessionChecked, setSessionChecked] = useState(false);
const search = useMemo(() => {
const params = new URLSearchParams();
if (caseType) params.set("case_type", caseType);
if (status) params.set("status", status);
if (query) params.set("q", query);
return params.toString();
}, [caseType, status, query]);
useEffect(() => {
if (!sessionChecked || !authenticated) return;
let isCurrent = true;
apiFetch(`/api/cases${search ? `?${search}` : ""}`)
.then((response) => response.json() as Promise<CaseListResponse>)
.then((body: CaseListResponse) => {
if (!isCurrent) return;
setCases(body.cases ?? []);
setMessage(body.error ?? "");
})
.catch((error) => {
if (!isCurrent) return;
setMessage(error instanceof Error ? error.message : "案例加载失败");
});
return () => {
isCurrent = false;
};
}, [authenticated, search, sessionChecked]);
const updateAuthenticated = useCallback((nextAuthenticated: boolean) => {
setAuthenticated(nextAuthenticated);
setSessionChecked(true);
setMessage("");
if (!nextAuthenticated) {
setCases([]);
}
}, []);
const visibleMessage =
message ||
(sessionChecked && !authenticated ? "请先登录后查看案例库。" : "");
return (
<main className="app-shell">
<header className="topbar">
<div>
<h1></h1>
{visibleMessage ? <p>{visibleMessage}</p> : null}
</div>
<div className="topbar-actions">
<Link className="text-link" href="/">
</Link>
<CaseAuthControl
authenticated={authenticated}
onAuthenticatedChange={updateAuthenticated}
/>
</div>
</header>
<section className="case-toolbar" aria-label="案例筛选">
<input
aria-label="关键词"
placeholder="搜索标题、客户、品牌、备注"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<select
aria-label="案例类型"
value={caseType}
onChange={(event) => setCaseType(event.target.value)}
>
<option value=""></option>
<option value="article"></option>
<option value="human_copy"></option>
</select>
<select
aria-label="状态"
value={status}
onChange={(event) => setStatus(event.target.value)}
>
<option value=""></option>
<option value="running"></option>
<option value="optimized"></option>
<option value="failed"></option>
</select>
</section>
<section className="case-table" aria-label="案例列表">
<div className="case-row case-row-heading">
<span>/</span>
<span></span>
<span>/</span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{cases.map((item) => (
<Link className="case-row" href={`/cases/${item.id}`} key={item.id}>
<span>
<strong>{item.title}</strong>
<small>{item.summary}</small>
</span>
<span>{caseTypeLabels[item.case_type]}</span>
<span>
{[item.customer_name, item.brand_name].filter(Boolean).join(" / ") ||
"未填写"}
</span>
<span>
{item.project_tags.length > 0
? item.project_tags.join("、")
: "未标记"}
</span>
<span>{item.publish_target || "未指定"}</span>
<span>
<span className={`status-pill ${statusClass(item.status)}`}>
{statusLabels[item.status]}
</span>
</span>
<span>{formatDate(item.updated_at)}</span>
</Link>
))}
{authenticated && cases.length === 0 ? (
<p className="empty-panel"></p>
) : null}
</section>
</main>
);
}
function formatDate(value: string) {
return new Date(value).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function statusClass(status: OptimizationCase["status"]) {
if (status === "failed") return "fail";
if (status === "running") return "warn";
return "pass";
}
@@ -0,0 +1,222 @@
"use client";
import { useEffect, useState } from "react";
import type { PublicationRecord } from "../../lib/calibration/types";
import { apiFetch } from "./api-client";
import { formatDate } from "./case-format";
interface CasePublicationPanelProps {
caseId: string;
versionId: string | null;
publishTarget: string;
}
interface PublicationListResponse {
publications?: PublicationRecord[];
error?: string;
}
interface PublicationCreateResponse {
publication?: PublicationRecord;
error?: string;
}
interface PerformanceResponse {
snapshot?: { metrics: Record<string, number> };
calibrationEvent?: { observations: string[]; recommended_action: string } | null;
error?: string;
}
export function CasePublicationPanel({
caseId,
versionId,
publishTarget,
}: CasePublicationPanelProps) {
const [publications, setPublications] = useState<PublicationRecord[]>([]);
const [url, setUrl] = useState("");
const [publishedAt, setPublishedAt] = useState(() =>
new Date().toISOString().slice(0, 16),
);
const [notes, setNotes] = useState("");
const [publicationId, setPublicationId] = useState<string | null>(null);
const [views, setViews] = useState("");
const [clicks, setClicks] = useState("");
const [inquiries, setInquiries] = useState("");
const [feedbackSummary, setFeedbackSummary] = useState("");
const [message, setMessage] = useState("");
const [observations, setObservations] = useState<string[]>([]);
useEffect(() => {
if (!versionId) {
return;
}
let isCurrent = true;
apiFetch(`/api/cases/${caseId}/versions/${versionId}/publications`)
.then((response) => response.json() as Promise<PublicationListResponse>)
.then((body: PublicationListResponse) => {
if (!isCurrent) return;
setPublications(body.publications ?? []);
setMessage(body.error ?? "");
})
.catch((error) => {
if (!isCurrent) return;
setMessage(error instanceof Error ? error.message : "发布记录加载失败");
});
return () => {
isCurrent = false;
};
}, [caseId, versionId]);
async function registerPublication() {
if (!versionId || !url || !publishedAt) return;
setMessage("");
const response = await apiFetch(
`/api/cases/${caseId}/versions/${versionId}/publications`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
publish_target: publishTarget || "未指定",
url,
published_at: new Date(publishedAt).toISOString(),
notes,
}),
},
);
const body = (await response.json()) as PublicationCreateResponse;
if (!response.ok || !body.publication) {
setMessage(body.error ?? "发布记录保存失败");
return;
}
setPublicationId(body.publication.id);
setPublications((items) => [body.publication!, ...items]);
setMessage("发布记录已保存。");
}
async function recordPerformance() {
if (!publicationId) return;
setMessage("");
const response = await apiFetch(`/api/publications/${publicationId}/performance`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
window_label: "T+7d",
views,
clicks,
inquiries,
feedback_summary: feedbackSummary,
}),
});
const body = (await response.json()) as PerformanceResponse;
if (!response.ok || !body.snapshot) {
setMessage(body.error ?? "表现数据保存失败");
return;
}
setObservations(body.calibrationEvent?.observations ?? []);
setMessage(
body.calibrationEvent?.recommended_action ?? "表现数据已保存。",
);
}
return (
<section className="panel stack">
<div className="panel-heading"></div>
{versionId && publications.length > 0 ? (
<ul className="case-publication-list">
{publications.map((publication) => (
<li key={publication.id}>
<a href={publication.url}>{publication.url}</a>
<small>
{publication.publish_target} · {formatDate(publication.published_at)}
</small>
{publication.notes ? <small>{publication.notes}</small> : null}
</li>
))}
</ul>
) : (
<p className="empty-panel"></p>
)}
<div className="case-publication-form">
<label>
<span></span>
<input
disabled={!versionId}
placeholder="https://example.com/article"
value={url}
onChange={(event) => setUrl(event.target.value)}
/>
</label>
<label>
<span></span>
<input
disabled={!versionId}
type="datetime-local"
value={publishedAt}
onChange={(event) => setPublishedAt(event.target.value)}
/>
</label>
<label>
<span></span>
<input
disabled={!versionId}
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</label>
<button disabled={!versionId || !url || !publishedAt} onClick={registerPublication} type="button">
</button>
</div>
<div className="calibration-metrics">
<label>
<span>/</span>
<input
min="0"
type="number"
value={views}
onChange={(event) => setViews(event.target.value)}
/>
</label>
<label>
<span></span>
<input
min="0"
type="number"
value={clicks}
onChange={(event) => setClicks(event.target.value)}
/>
</label>
<label>
<span></span>
<input
min="0"
type="number"
value={inquiries}
onChange={(event) => setInquiries(event.target.value)}
/>
</label>
</div>
<label>
<span></span>
<textarea
value={feedbackSummary}
onChange={(event) => setFeedbackSummary(event.target.value)}
/>
</label>
<button disabled={!publicationId} onClick={recordPerformance} type="button">
</button>
{message ? <p className="status-text">{message}</p> : null}
{observations.length > 0 ? (
<ul className="calibration-observations">
{observations.map((observation) => (
<li key={observation}>{observation}</li>
))}
</ul>
) : null}
</section>
);
}
@@ -0,0 +1,44 @@
"use client";
import type { OptimizationResultVersion } from "../../lib/cases/types";
import { formatDate, versionLabel } from "./case-format";
interface CaseResultVersionListProps {
versions: OptimizationResultVersion[];
selectedVersionId: string | null;
onSelect: (versionId: string) => void;
}
export function CaseResultVersionList({
versions,
selectedVersionId,
onSelect,
}: CaseResultVersionListProps) {
return (
<section className="panel stack">
<div className="panel-heading"></div>
{versions.length > 0 ? (
<div className="case-version-list">
{versions.map((version) => (
<button
className={
version.id === selectedVersionId
? "case-version-item selected"
: "case-version-item"
}
key={version.id}
onClick={() => onSelect(version.id)}
type="button"
>
<strong>{versionLabel(version)}</strong>
<small>{version.result_summary || version.error_summary || "暂无摘要"}</small>
<small>{formatDate(version.created_at)}</small>
</button>
))}
</div>
) : (
<p className="empty-panel"></p>
)}
</section>
);
}
@@ -0,0 +1,141 @@
"use client";
import type {
HumanCopyCaseInputPayload,
HumanCopyResultVersionPayload,
OptimizationResultVersion,
} from "../../lib/cases/types";
interface HumanCopyCaseDetailProps {
input: unknown;
version: OptimizationResultVersion | null;
}
const intensityLabels: Record<string, string> = {
light: "轻微整理",
medium: "适度润色",
conversational: "更口语自然",
};
export function HumanCopyCaseDetail({
input,
version,
}: HumanCopyCaseDetailProps) {
const payload = isHumanCopyInput(input) ? input : null;
const result = isHumanCopyResult(version?.payload) ? version.payload : null;
return (
<>
<section className="panel stack">
<div className="panel-heading"></div>
{payload ? (
<>
<pre className="copy-result">{payload.source_text}</pre>
<div className="case-meta-grid">
<MetaItem label="优化目标" value={payload.goal} />
<MetaItem
label="修改强度"
value={intensityLabels[payload.intensity] ?? payload.intensity}
/>
<MetaItem
label="发布目标"
value={payload.publish_target || "未指定"}
/>
<MetaItem
label="补充要求"
value={payload.user_instructions || "无"}
/>
</div>
</>
) : (
<p className="empty-panel"></p>
)}
</section>
<section className="panel stack">
<div className="panel-heading"></div>
{result ? (
<>
<pre className="copy-result">{result.optimized_text}</pre>
<section className="stack">
<h3></h3>
{result.change_notes.length > 0 ? (
<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>
) : (
<p className="empty-panel"></p>
)}
</section>
<section className="stack">
<h3>AI </h3>
{result.ai_taste_checks.length > 0 ? (
<ul className="qa-list">
{result.ai_taste_checks.map((check) => (
<li className="qa-item" key={check.rule_id}>
<div className="qa-title-row">
<strong>{check.rule_id}</strong>
<span className={`status-pill ${check.status}`}>
{check.status}
</span>
</div>
<small>{check.evidence}</small>
{check.suggestion ? <small>{check.suggestion}</small> : null}
</li>
))}
</ul>
) : (
<p className="empty-panel"> AI </p>
)}
</section>
{result.warnings.length > 0 ? (
<ul className="calibration-observations">
{result.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
) : null}
</>
) : (
<p className="empty-panel">
{version?.error_summary ?? "当前版本没有优化结果。"}
</p>
)}
</section>
</>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function isHumanCopyInput(value: unknown): value is HumanCopyCaseInputPayload {
return (
value !== null &&
typeof value === "object" &&
"source_text" in value &&
"goal" in value
);
}
function isHumanCopyResult(value: unknown): value is HumanCopyResultVersionPayload {
return (
value !== null &&
typeof value === "object" &&
"optimized_text" in value &&
"change_notes" in value
);
}
+226 -83
View File
@@ -1,12 +1,13 @@
"use client";
import type { CandidateFactCard, ConfirmedFactCard } from "../lib/domain/types";
import { useState } from "react";
import type { CandidateFactCard, OptimizationFactCard } from "../lib/domain/types";
interface FactCardEditorProps {
factCard: CandidateFactCard | null;
factCard: OptimizationFactCard | null;
isSaving: boolean;
onChange: (factCard: CandidateFactCard) => void;
onConfirm: () => void;
onChange: (factCard: OptimizationFactCard) => void;
}
const listFields = [
@@ -16,7 +17,6 @@ const listFields = [
"core_claims",
"forbidden_claims",
"image_topics",
"uncertain_items",
] as const;
const fieldLabels: Record<(typeof listFields)[number], string> = {
@@ -26,118 +26,261 @@ const fieldLabels: Record<(typeof listFields)[number], string> = {
core_claims: "核心事实/主张",
forbidden_claims: "禁止使用的主张",
image_topics: "图片主题",
uncertain_items: "待确认事项",
};
type UncertainItemResolutionTarget =
| "core_claims"
| "forbidden_claims"
| "image_topics"
| "ignore";
const uncertainItemActions: {
label: string;
target: UncertainItemResolutionTarget;
}[] = [
{ label: "采纳为核心事实", target: "core_claims" },
{ label: "标记为禁止主张", target: "forbidden_claims" },
{ label: "采纳为图片主题", target: "image_topics" },
{ label: "忽略", target: "ignore" },
];
export function FactCardEditor({
factCard,
isSaving,
onChange,
onConfirm,
}: FactCardEditorProps) {
const [isEditing, setIsEditing] = useState(false);
if (!factCard) {
return (
<section className="panel empty-panel">
<div className="panel-heading"></div>
<section className="panel compact-panel fact-card-panel empty-panel">
<div className="panel-heading">
<span></span>
<span className="status-pill warn"></span>
</div>
</section>
);
}
const currentFactCard = factCard;
const canOptimize = currentFactCard.uncertain_items.length === 0;
const hasUncertainItems = factCard.uncertain_items.length > 0;
function update<K extends keyof CandidateFactCard>(
function update<K extends keyof OptimizationFactCard>(
key: K,
value: CandidateFactCard[K],
value: OptimizationFactCard[K],
) {
if (!factCard) return;
onChange({
...currentFactCard,
...factCard,
[key]: value,
confirmed_by_user: false,
is_ready_for_optimization:
key === "uncertain_items"
? (value as string[]).length === 0
: currentFactCard.uncertain_items.length === 0,
: factCard.uncertain_items.length === 0,
});
}
function resolveItem(
itemIndex: number,
target: UncertainItemResolutionTarget,
) {
if (!factCard) return;
onChange(resolveUncertainItem(factCard, itemIndex, target));
}
return (
<section className="panel stack">
<section className="panel compact-panel fact-card-panel stack">
<div className="panel-heading">
<span></span>
<button disabled={!canOptimize || isSaving} onClick={onConfirm}>
{isSaving ? "保存中..." : "确认事实卡"}
</button>
<div className="panel-actions">
<span className={`status-pill ${hasUncertainItems ? "warn" : "pass"}`}>
{hasUncertainItems ? "待复核" : "可用"}
</span>
<button
className="secondary-button"
disabled={isSaving}
onClick={() => setIsEditing((editing) => !editing)}
type="button"
>
{isEditing ? "收起编辑" : "编辑事实卡"}
</button>
</div>
</div>
<label>
<span></span>
<input
value={factCard.company_full_name}
onChange={(event) => update("company_full_name", event.target.value)}
/>
</label>
<div className="two-col">
<label>
<span></span>
<input
value={factCard.target_industry}
onChange={(event) => update("target_industry", event.target.value)}
/>
</label>
<label>
<span></span>
<input
value={factCard.target_audience}
onChange={(event) => update("target_audience", event.target.value)}
/>
</label>
<div className="fact-summary-grid">
<div>
<span></span>
<strong>{factCard.company_full_name || "待补充"}</strong>
</div>
<div>
<span></span>
<strong>{factCard.target_industry || "待补充"}</strong>
</div>
<div>
<span></span>
<strong>{factCard.target_audience || "待补充"}</strong>
</div>
<div>
<span></span>
<strong>
{factCard.experience_years === null
? "待补充"
: `${factCard.experience_years}`}
</strong>
</div>
</div>
<label>
<span></span>
<input
min="0"
type="number"
value={factCard.experience_years ?? ""}
onChange={(event) =>
update(
"experience_years",
event.target.value ? Number(event.target.value) : null,
)
}
/>
</label>
{listFields.map((field) => (
<label key={field}>
<span>{fieldLabels[field]}</span>
<textarea
value={factCard[field].join("\n")}
onChange={(event) =>
update(
field,
event.target.value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean) as CandidateFactCard[typeof field],
)
}
/>
</label>
))}
{!canOptimize && (
<p className="status-text fail">
</p>
{factCard.core_claims.length > 0 && (
<div className="fact-compact-list">
<span className="field-label"></span>
<div className="tag-row">
{factCard.core_claims.slice(0, 4).map((claim) => (
<span className="tag" key={claim}>
{claim}
</span>
))}
</div>
</div>
)}
{hasUncertainItems && (
<div className="fact-compact-list">
<span className="field-label"></span>
<div className="tag-row">
{factCard.uncertain_items.slice(0, 4).map((item) => (
<span className="tag warn-tag" key={item}>
{item}
</span>
))}
</div>
</div>
)}
{isEditing && (
<div className="fact-edit-grid">
<label>
<span></span>
<input
disabled={isSaving}
value={factCard.company_full_name}
onChange={(event) =>
update("company_full_name", event.target.value)
}
/>
</label>
<div className="two-col">
<label>
<span></span>
<input
disabled={isSaving}
value={factCard.target_industry}
onChange={(event) =>
update("target_industry", event.target.value)
}
/>
</label>
<label>
<span></span>
<input
disabled={isSaving}
value={factCard.target_audience}
onChange={(event) =>
update("target_audience", event.target.value)
}
/>
</label>
</div>
<label>
<span></span>
<input
disabled={isSaving}
min="0"
type="number"
value={factCard.experience_years ?? ""}
onChange={(event) =>
update(
"experience_years",
event.target.value ? Number(event.target.value) : null,
)
}
/>
</label>
{listFields.map((field) => (
<label key={field}>
<span>{fieldLabels[field]}</span>
<textarea
disabled={isSaving}
value={factCard[field].join("\n")}
onChange={(event) =>
update(
field,
event.target.value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean) as OptimizationFactCard[typeof field],
)
}
/>
</label>
))}
<div className="field-group">
<div className="field-label"></div>
{factCard.uncertain_items.length > 0 ? (
<div className="uncertain-list">
{factCard.uncertain_items.map((item, index) => (
<div className="uncertain-row" key={`${item}-${index}`}>
<p>{item}</p>
<div className="uncertain-actions">
{uncertainItemActions.map((action) => (
<button
className="secondary-button"
disabled={isSaving}
key={action.target}
onClick={() => resolveItem(index, action.target)}
type="button"
>
{action.label}
</button>
))}
</div>
</div>
))}
</div>
) : (
<p className="status-text pass"></p>
)}
</div>
</div>
)}
</section>
);
}
export function toConfirmedFactCard(
factCard: CandidateFactCard,
): ConfirmedFactCard {
return {
export function resolveUncertainItem<T extends CandidateFactCard>(
factCard: T,
itemIndex: number,
target: UncertainItemResolutionTarget,
): T {
const item = factCard.uncertain_items[itemIndex]?.trim();
if (!item) return factCard;
const uncertainItems = factCard.uncertain_items.filter(
(_, index) => index !== itemIndex,
);
const nextFactCard = {
...factCard,
uncertain_items: [],
is_ready_for_optimization: true,
confirmed_by_user: true,
uncertain_items: uncertainItems,
is_ready_for_optimization: uncertainItems.length === 0,
};
if (target === "ignore") return nextFactCard;
return {
...nextFactCard,
[target]: appendUniqueLine(nextFactCard[target], item),
};
}
function appendUniqueLine(values: string[], value: string) {
return values.includes(value) ? values : [...values, value];
}
+300 -50
View File
@@ -1,81 +1,331 @@
"use client";
import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react";
import type { OptimizedArticle } from "../lib/domain/types";
interface OptimizedPreviewProps {
article: OptimizedArticle | null;
draftArticle?: OptimizedArticle | null;
jobId: string | null;
isStreaming?: boolean;
activityText?: string;
}
const exportFiles = ["optimized.md", "optimized.docx", "qa_report.json"];
const streamWords = ["事实", "标题", "结构", "语气", "检索", "改写", "终稿"];
const changedSectionLabels: Record<string, string> = {
title: "标题优化",
summary: "摘要优化",
body: "正文结构与表达",
body_markdown: "正文结构与表达",
client_cases: "客户案例",
social_contribution: "社会价值",
team_background_claims: "团队背景",
core_competitiveness_claims: "核心竞争力",
image_suggestions: "图片建议",
};
export type MarkdownBlock =
| { type: "heading"; text: string; level: number }
| { type: "paragraph"; text: string }
| { type: "list"; items: string[] };
export function OptimizedPreview({
article,
draftArticle = null,
jobId,
isStreaming = false,
activityText = "",
}: OptimizedPreviewProps) {
if (!article) {
const [streamTick, setStreamTick] = useState(0);
const visibleArticle = article ?? draftArticle;
const bodyMarkdown = visibleArticle?.body_markdown ?? "";
const markdownBlocks = useMemo(
() => parseMarkdownBlocks(bodyMarkdown),
[bodyMarkdown],
);
const changedLabels = visibleArticle
? getChangedSectionLabels(visibleArticle.changed_sections)
: [];
const visibleWordCount = isStreaming ? Math.min(streamTick, 6) : 0;
const visibleWords = Array.from({ length: visibleWordCount }, (_, offset) => {
const index = streamTick - visibleWordCount + offset;
return streamWords[index % streamWords.length];
});
useEffect(() => {
if (!isStreaming) return;
const timer = window.setInterval(() => {
setStreamTick((tick) => tick + 1);
}, 850);
return () => window.clearInterval(timer);
}, [isStreaming]);
if (!visibleArticle && !isStreaming) {
return (
<section className="panel empty-panel">
<section className="panel empty-panel optimized-panel">
<div className="panel-heading"></div>
</section>
);
}
return (
<section className="panel stack">
<div className="panel-heading"></div>
<article className="preview">
<h2>{article.title}</h2>
<p>{article.summary}</p>
<pre>{article.body_markdown}</pre>
</article>
<div>
<h3></h3>
<ul>
{article.image_suggestions.map((item) => (
<li key={item.source}>
<strong>{item.source}</strong>: {item.suggestion}
</li>
))}
</ul>
<section
aria-live="polite"
className={`panel stack optimized-panel ${isStreaming ? "is-streaming" : ""}`}
>
<div className="panel-heading">
<span></span>
{isStreaming && (
<span className="stream-status">
<span className="stream-cursor" aria-hidden="true" />
{activityText || "正在优化"}
</span>
)}
</div>
<div>
<h3></h3>
<div className="tag-row">
{article.changed_sections.map((section) => (
<span className="tag" key={section}>
{section}
{visibleArticle ? (
<article className={`preview ${isStreaming ? "streaming-preview" : ""}`}>
<h2>{visibleArticle.title}</h2>
<p>{visibleArticle.summary}</p>
<ProgressiveMarkdownPreview
blocks={markdownBlocks}
isStreaming={isStreaming}
key={bodyMarkdown}
/>
</article>
) : (
<div className="preview stream-placeholder">
<div className="preview-pulse-line title-line" />
<div className="preview-pulse-line" />
<div className="preview-pulse-line short-line" />
<div className="preview-pulse-block" />
</div>
)}
{isStreaming && visibleWords.length > 0 && (
<div className="stream-word-row" aria-hidden="true">
{visibleWords.map((word, index) => (
<span className="stream-word" key={`${word}-${streamTick}-${index}`}>
{word}
</span>
))}
</div>
</div>
{article.requires_user_confirmation.length > 0 && (
<div>
<h3></h3>
<ul>
{article.requires_user_confirmation.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
{visibleArticle && (
<>
<div>
<h3></h3>
{visibleArticle.image_suggestions.length > 0 ? (
<ul>
{visibleArticle.image_suggestions.map((item) => (
<li key={item.source}>
<strong>{item.source}</strong>: {item.suggestion}
</li>
))}
</ul>
) : (
<p className="empty-panel"></p>
)}
</div>
{changedLabels.length > 0 && (
<div>
<h3></h3>
<div className="tag-row">
{changedLabels.map((section) => (
<span className="tag" key={section}>
{section}
</span>
))}
</div>
</div>
)}
{visibleArticle.requires_user_confirmation.length > 0 && (
<div>
<h3></h3>
<ul>
{visibleArticle.requires_user_confirmation.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
)}
</>
)}
{article && (
<div className="export-row">
{exportFiles.map((fileName) =>
jobId ? (
<a
className="download-link"
href={`/api/jobs/${jobId}/exports/${fileName}`}
key={fileName}
>
{fileName}
</a>
) : (
<button disabled key={fileName} type="button">
{fileName}
</button>
),
)}
</div>
)}
<div className="export-row">
{exportFiles.map((fileName) =>
jobId ? (
<a
className="download-link"
href={`/api/jobs/${jobId}/exports/${fileName}`}
key={fileName}
>
{fileName}
</a>
) : (
<button disabled key={fileName} type="button">
{fileName}
</button>
),
)}
</div>
</section>
);
}
export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
const blocks: MarkdownBlock[] = [];
const paragraphLines: string[] = [];
const listItems: string[] = [];
function flushParagraph() {
if (paragraphLines.length === 0) return;
blocks.push({ type: "paragraph", text: paragraphLines.join("\n").trim() });
paragraphLines.length = 0;
}
function flushList() {
if (listItems.length === 0) return;
blocks.push({ type: "list", items: [...listItems] });
listItems.length = 0;
}
for (const rawLine of markdown.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) {
flushParagraph();
flushList();
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
flushParagraph();
flushList();
blocks.push({
type: "heading",
level: heading[1].length,
text: heading[2].trim(),
});
continue;
}
const listItem = line.match(/^[-*]\s+(.+)$/);
if (listItem) {
flushParagraph();
listItems.push(listItem[1].trim());
continue;
}
flushList();
paragraphLines.push(line);
}
flushParagraph();
flushList();
return blocks;
}
export function getVisibleMarkdownBlocks(
blocks: MarkdownBlock[],
isStreaming: boolean,
visibleCount: number,
) {
if (!isStreaming) return blocks;
return blocks.slice(0, Math.min(Math.max(visibleCount, 1), blocks.length));
}
export function formatChangedSection(section: string) {
const key = section.trim().toLowerCase().split(":")[0]?.trim() ?? "";
return changedSectionLabels[key] ?? "其他内容";
}
function getChangedSectionLabels(sections: string[]) {
return Array.from(new Set(sections.map(formatChangedSection)));
}
function MarkdownPreview({ blocks }: { blocks: MarkdownBlock[] }) {
if (blocks.length === 0) {
return <p className="empty-panel"></p>;
}
return (
<div className="markdown-preview">
{blocks.map((block, index) => (
<MarkdownBlockView block={block} key={`${block.type}-${index}`} />
))}
</div>
);
}
function ProgressiveMarkdownPreview({
blocks,
isStreaming,
}: {
blocks: MarkdownBlock[];
isStreaming: boolean;
}) {
const [visibleCount, setVisibleCount] = useState(1);
useEffect(() => {
if (!isStreaming || blocks.length <= 1) return;
const timer = window.setInterval(() => {
setVisibleCount((count) => Math.min(count + 1, blocks.length));
}, 720);
return () => window.clearInterval(timer);
}, [blocks.length, isStreaming]);
return (
<MarkdownPreview
blocks={getVisibleMarkdownBlocks(blocks, isStreaming, visibleCount)}
/>
);
}
function MarkdownBlockView({ block }: { block: MarkdownBlock }) {
if (block.type === "heading") {
return (
<div
aria-level={Math.min(block.level + 1, 6)}
className="markdown-heading markdown-block"
role="heading"
>
{renderInlineMarkdown(block.text)}
</div>
);
}
if (block.type === "list") {
return (
<ul className="markdown-list markdown-block">
{block.items.map((item) => (
<li key={item}>{renderInlineMarkdown(item)}</li>
))}
</ul>
);
}
return (
<p className="markdown-paragraph markdown-block">
{renderInlineMarkdown(block.text)}
</p>
);
}
function renderInlineMarkdown(text: string): ReactNode {
return text.split(/(\*\*[^*]+\*\*)/g).map((part, index) => {
if (part.startsWith("**") && part.endsWith("**")) {
return <strong key={index}>{part.slice(2, -2)}</strong>;
}
return <Fragment key={index}>{part}</Fragment>;
});
}
@@ -0,0 +1,203 @@
"use client";
import { useState, type FormEvent } from "react";
import type {
CopyOptimizationIntensity,
CopyOptimizationResult,
} from "../lib/domain/types";
interface RenweiCopyOptimizerPanelProps {
apiAccessKey: string;
}
interface CopyOptimizeResponse {
case?: { id: string; case_type: "human_copy" };
result_version?: { id: string; version: number };
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 [publishTarget, setPublishTarget] = useState("朋友圈");
const [result, setResult] = useState<CopyOptimizationResult | null>(null);
const [caseId, setCaseId] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSubmitting(true);
setMessage("");
setResult(null);
setCaseId(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,
publish_target: publishTarget,
}),
});
const body = (await response.json()) as CopyOptimizeResponse;
if (!response.ok || !body.result) {
throw new Error(body.error ?? "文案优化失败");
}
setResult(body.result);
setCaseId(body.case?.id ?? null);
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>
<label>
<span></span>
<input
value={publishTarget}
onChange={(event) => setPublishTarget(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 ? (
<>
{caseId ? (
<a className="text-link" href={`/cases/${caseId}`}>
</a>
) : null}
<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;
}
+33 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { requireApiAccess } from "../auth";
import {
apiSessionCookieName,
createApiSessionToken,
requireApiAccess,
} from "../auth";
describe("requireApiAccess", () => {
test("allows local test requests when auth is explicitly disabled", () => {
@@ -53,4 +57,32 @@ describe("requireApiAccess", () => {
expect(result.ok).toBe(true);
});
test("allows requests with a valid HttpOnly session cookie", () => {
const request = new Request("http://localhost/api/jobs", {
headers: {
cookie: `${apiSessionCookieName}=${createApiSessionToken("secret")}`,
},
});
const result = requireApiAccess(request, {
apiAccessKey: "secret",
authDisabled: false,
});
expect(result.ok).toBe(true);
});
test("rejects requests with an invalid session cookie", () => {
const request = new Request("http://localhost/api/jobs", {
headers: { cookie: `${apiSessionCookieName}=wrong` },
});
const result = requireApiAccess(request, {
apiAccessKey: "secret",
authDisabled: false,
});
expect(result.ok).toBe(false);
});
});
+47 -2
View File
@@ -1,7 +1,13 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { getAppCloudflareEnv } from "../runtime/cloudflare";
export const apiSessionCookieName = "geo_api_session";
const sessionTokenMessage = "geo-agent-api-session:v1";
interface ApiAccessOptions {
apiAccessKey?: string;
authDisabled?: boolean;
@@ -33,7 +39,10 @@ export function requireApiAccess(
}
const provided = request.headers.get("x-api-key");
if (provided !== options.apiAccessKey) {
if (
provided !== options.apiAccessKey &&
!hasValidApiSession(request, options.apiAccessKey)
) {
return {
ok: false,
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
@@ -43,6 +52,42 @@ export function requireApiAccess(
return { ok: true };
}
function getConfiguredApiAccessKey() {
export function hasValidApiSession(request: Request, apiAccessKey: string) {
const cookieValue = parseCookieHeader(request.headers.get("cookie"))[
apiSessionCookieName
];
if (!cookieValue) return false;
return safeEqual(cookieValue, createApiSessionToken(apiAccessKey));
}
export function createApiSessionToken(apiAccessKey: string) {
return createHmac("sha256", apiAccessKey)
.update(sessionTokenMessage)
.digest("hex");
}
export function getConfiguredApiAccessKey() {
return getAppCloudflareEnv()?.API_ACCESS_KEY ?? process.env.API_ACCESS_KEY;
}
function parseCookieHeader(value: string | null) {
const cookies: Record<string, string> = {};
if (!value) return cookies;
for (const part of value.split(";")) {
const separator = part.indexOf("=");
if (separator < 0) continue;
const name = part.slice(0, separator).trim();
const cookieValue = part.slice(separator + 1).trim();
if (name) cookies[name] = decodeURIComponent(cookieValue);
}
return cookies;
}
function safeEqual(left: string, right: string) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) return false;
return timingSafeEqual(leftBuffer, rightBuffer);
}
@@ -5,6 +5,7 @@ import { createManualPerformanceAdapter } from "../manual-adapter";
import {
createCalibrationEvent,
GEO_RUBRIC_V1,
scoreHumanCopyResult,
scoreOptimizedArticle,
} from "../scoring";
@@ -56,8 +57,10 @@ describe("calibration scoring", () => {
const snapshot = await adapter.fetch({
publication: {
id: "pub_123",
result_version_id: null,
job_id: "job_123",
revision: 2,
publish_target: "official_site",
platform: "official_site",
url: "https://example.com/article",
published_at: "2026-06-24T12:00:00.000Z",
@@ -103,4 +106,39 @@ describe("calibration scoring", () => {
expect(event.observations.join(" ")).toContain("询盘");
expect(event.recommended_action).toContain("积累");
});
it("scores human-copy result with a separate rubric", () => {
const scoringRun = scoreHumanCopyResult({
resultVersionId: "ver_1",
result: {
optimized_text: "我把这段文案顺了一下。",
change_notes: [
{
original: "我把这段文案顺顺。",
revised: "我把这段文案顺了一下。",
reason: "修正重复表达。",
confidence: "confident",
revertible: false,
},
],
ai_taste_checks: [
{
rule_id: "promotion_tone",
status: "pass",
evidence: "没有新增宣传腔。",
suggestion: "",
},
],
warnings: [],
},
});
expect(scoringRun).toMatchObject({
result_version_id: "ver_1",
case_type: "human_copy",
rubric_version_id: "rubric_human_copy_v1",
composite_score: expect.any(Number),
});
expect(scoringRun.composite_score).toBeGreaterThan(0);
});
});
+93 -3
View File
@@ -1,6 +1,10 @@
import { nanoid } from "nanoid";
import type { OptimizedArticle, QaReport } from "../domain/types";
import type {
CopyOptimizationResult,
OptimizedArticle,
QaReport,
} from "../domain/types";
import type {
CalibrationContext,
CalibrationDirection,
@@ -56,16 +60,59 @@ export const GEO_RUBRIC_V1: RubricVersion = {
],
};
export const HUMAN_COPY_RUBRIC_V1: RubricVersion = {
id: "rubric_human_copy_v1",
version: "v1",
name: "人味文案优化评分口径",
dimensions: [
{
id: "restraint",
label: "改动克制",
weight: 0.2,
description: "减少机械润色,不把短文案扩写成宣传稿。",
},
{
id: "meaning_fidelity",
label: "原意保真",
weight: 0.25,
description: "保留原文意图、事实和表达边界。",
},
{
id: "natural_tone",
label: "语气自然度",
weight: 0.25,
description: "读起来像真人表达,少套路句和格式痕迹。",
},
{
id: "goal_fit",
label: "目标匹配",
weight: 0.15,
description: "符合优化目标和发布场景。",
},
{
id: "ai_taste_risk",
label: "AI味风险",
weight: 0.15,
description: "宣传腔、套话、聊天痕迹和填充词风险低。",
},
],
formula: "weighted_average_0_to_10",
is_active: true,
created_at: "2026-07-08T00:00:00.000Z",
};
interface ScoreOptimizedArticleInput {
jobId: string;
article: OptimizedArticle;
qaReport: QaReport;
resultVersionId?: string | null;
}
export function scoreOptimizedArticle({
jobId,
article,
qaReport,
resultVersionId = null,
}: ScoreOptimizedArticleInput): ScoringRun {
const combined = `${article.title}\n${article.summary}\n${article.body_markdown}`;
const dimensionScores = {
@@ -79,6 +126,8 @@ export function scoreOptimizedArticle({
return {
id: `score_${nanoid(10)}`,
result_version_id: resultVersionId,
case_type: "article",
job_id: jobId,
revision: article.revision ?? 1,
rubric_version_id: GEO_RUBRIC_V1.id,
@@ -89,6 +138,43 @@ export function scoreOptimizedArticle({
};
}
export function scoreHumanCopyResult({
resultVersionId,
result,
}: {
resultVersionId: string;
result: CopyOptimizationResult;
}): ScoringRun {
const warningsPenalty = Math.min(result.warnings.length, 3) * 0.5;
const aiTasteWarnings = result.ai_taste_checks.filter(
(check) => check.status === "warn",
).length;
const dimensionScores = {
restraint: result.optimized_text.length > 280 ? 3 : 5,
meaning_fidelity: result.change_notes.some(
(note) => note.confidence === "uncertain",
)
? 3.5
: 5,
natural_tone: Math.max(2, 5 - aiTasteWarnings * 0.75),
goal_fit: 4.5,
ai_taste_risk: Math.max(1, 5 - aiTasteWarnings - warningsPenalty),
};
return {
id: `score_${nanoid(10)}`,
result_version_id: resultVersionId,
case_type: "human_copy",
job_id: null,
revision: null,
rubric_version_id: HUMAN_COPY_RUBRIC_V1.id,
dimension_scores: dimensionScores,
composite_score: weightedAverage0To10(HUMAN_COPY_RUBRIC_V1, dimensionScores),
rationale: "基于改动克制、原意保真、自然度、目标匹配和AI味风险生成评分。",
created_at: new Date().toISOString(),
};
}
function scoreFactIntegrity(report: QaReport) {
const hardRules = ["company_name_integrity", "claim_consistency", "hallucination_risk"];
const statuses = report.checks
@@ -143,11 +229,15 @@ function scoreReadability(article: OptimizedArticle) {
}
function weightedComposite(scores: Record<string, number>) {
const totalWeight = GEO_RUBRIC_V1.dimensions.reduce(
return weightedAverage0To10(GEO_RUBRIC_V1, scores);
}
function weightedAverage0To10(rubric: RubricVersion, scores: Record<string, number>) {
const totalWeight = rubric.dimensions.reduce(
(sum, dimension) => sum + dimension.weight,
0,
);
const weighted = GEO_RUBRIC_V1.dimensions.reduce(
const weighted = rubric.dimensions.reduce(
(sum, dimension) => sum + (scores[dimension.id] ?? 0) * dimension.weight,
0,
);
+10 -5
View File
@@ -1,4 +1,5 @@
import type { PublishPlatform, QaReport } from "../domain/types";
import type { OptimizationCaseType } from "../cases/types";
export type CalibrationDirection =
| "better_than_expected"
@@ -27,8 +28,10 @@ export interface RubricVersion {
export interface ScoringRun {
id: string;
job_id: string;
revision: number;
result_version_id?: string | null;
case_type?: OptimizationCaseType;
job_id: string | null;
revision: number | null;
rubric_version_id: string;
dimension_scores: Record<string, number>;
composite_score: number;
@@ -38,9 +41,11 @@ export interface ScoringRun {
export interface PublicationRecord {
id: string;
job_id: string;
revision: number;
platform: PublishPlatform;
result_version_id: string | null;
job_id: string | null;
revision: number | null;
publish_target: string;
platform?: PublishPlatform;
url: string;
published_at: string;
status: "draft" | "published" | "archived";
+18 -7
View File
@@ -44,11 +44,18 @@ export const performanceMetricsSchema = z
);
export const publicationInputSchema = z.object({
platform: publishPlatformSchema,
platform: publishPlatformSchema.optional(),
publish_target: z.string().trim().min(1).optional(),
url: z.string().trim().url(),
published_at: z.string().datetime(),
notes: optionalTextSchema.default(""),
});
}).transform((input) => ({
platform: input.platform,
publish_target: input.publish_target ?? input.platform ?? "未指定",
url: input.url,
published_at: input.published_at,
notes: input.notes,
}));
export const manualPerformanceInputSchema = z
.object({
@@ -100,8 +107,10 @@ export const rubricVersionSchema = z.object({
export const scoringRunSchema = z.object({
id: z.string().trim().min(1),
job_id: z.string().trim().min(1),
revision: z.number().int().positive(),
result_version_id: z.string().trim().min(1).nullable().optional(),
case_type: z.enum(["article", "human_copy"]).optional(),
job_id: z.string().trim().min(1).nullable(),
revision: z.number().int().positive().nullable(),
rubric_version_id: z.string().trim().min(1),
dimension_scores: z.record(z.string(), z.number().min(0).max(5)),
composite_score: z.number().min(0).max(10),
@@ -111,9 +120,11 @@ export const scoringRunSchema = z.object({
export const publicationRecordSchema = z.object({
id: z.string().trim().min(1),
job_id: z.string().trim().min(1),
revision: z.number().int().positive(),
platform: publishPlatformSchema,
result_version_id: z.string().trim().min(1).nullable(),
job_id: z.string().trim().min(1).nullable(),
revision: z.number().int().positive().nullable(),
publish_target: z.string().trim().min(1),
platform: publishPlatformSchema.optional(),
url: z.string().trim().url(),
published_at: z.string().datetime(),
status: z.enum(["draft", "published", "archived"]),
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
buildArticleCaseSummary,
buildHumanCopyCaseSummary,
createProcessStep,
excerpt,
} from "../summaries";
describe("case summaries", () => {
it("creates compact source excerpts", () => {
expect(excerpt("第一段。\n\n第二段内容很长".repeat(20), 20)).toHaveLength(21);
expect(excerpt(" 一段文案 ", 20)).toBe("一段文案");
});
it("builds article case title, summary, and publish target", () => {
expect(
buildArticleCaseSummary({
source_title: "IPMS 推荐机构文章",
source_body: "正文内容",
publish_platform: "media_article",
}),
).toEqual({
title: "IPMS 推荐机构文章",
summary: "正文内容",
publish_target: "media_article",
source_excerpt: "正文内容",
});
});
it("builds human-copy case summary from source and publish target", () => {
expect(
buildHumanCopyCaseSummary({
source_text: "帮客户解释智能体授课的价值。",
goal: "自然一点",
intensity: "light",
user_instructions: "",
publish_target: "朋友圈",
}),
).toMatchObject({
title: "人味文案优化:朋友圈",
publish_target: "朋友圈",
source_excerpt: "帮客户解释智能体授课的价值。",
});
});
it("creates a process summary step without draft text", () => {
expect(
createProcessStep({
stage: "draft",
startedAt: 100,
endedAt: 250,
status: "success",
producedResultVersion: false,
}),
).toEqual({
stage: "draft",
started_at: expect.any(String),
ended_at: expect.any(String),
duration_ms: 150,
status: "success",
produced_result_version: false,
});
});
});
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
caseListFiltersSchema,
caseMetadataPatchSchema,
caseTypeSchema,
resultVersionStatusSchema,
} from "../validation";
describe("case validation", () => {
it("accepts the supported case types and result-version statuses", () => {
expect(caseTypeSchema.parse("article")).toBe("article");
expect(caseTypeSchema.parse("human_copy")).toBe("human_copy");
expect(resultVersionStatusSchema.parse("optimized")).toBe("optimized");
expect(resultVersionStatusSchema.parse("failed")).toBe("failed");
});
it("keeps optional ownership metadata empty and editable", () => {
expect(caseMetadataPatchSchema.parse({})).toEqual({});
expect(
caseMetadataPatchSchema.parse({
customer_name: "伟思德鲁",
brand_name: "IPMS",
project_tags: ["推荐榜单", "2026"],
notes: "客户偏好保留专业语气。",
}),
).toEqual({
customer_name: "伟思德鲁",
brand_name: "IPMS",
project_tags: ["推荐榜单", "2026"],
notes: "客户偏好保留专业语气。",
});
});
it("parses default list filters without archived cases", () => {
expect(caseListFiltersSchema.parse({})).toEqual({
include_archived: false,
});
expect(
caseListFiltersSchema.parse({
case_type: "human_copy",
status: "optimized",
project_tag: "朋友圈",
q: "自然表达",
include_archived: "true",
}),
).toMatchObject({
case_type: "human_copy",
status: "optimized",
project_tag: "朋友圈",
q: "自然表达",
include_archived: true,
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import type {
ArticleCaseInputPayload,
HumanCopyCaseInputPayload,
ProcessSummaryStep,
} from "./types";
export function excerpt(value: string, maxLength = 120) {
const compact = value.replace(/\s+/g, " ").trim();
return compact.length > maxLength
? `${compact.slice(0, maxLength)}`
: compact;
}
export function buildArticleCaseSummary(
input: Pick<
ArticleCaseInputPayload,
"source_title" | "source_body" | "publish_platform"
>,
) {
const sourceExcerpt = excerpt(input.source_body);
return {
title: input.source_title.trim() || excerpt(input.source_body, 32),
summary: sourceExcerpt,
publish_target: input.publish_platform,
source_excerpt: sourceExcerpt,
};
}
export function buildHumanCopyCaseSummary(input: HumanCopyCaseInputPayload) {
const publishTarget = input.publish_target.trim() || "未指定";
const sourceExcerpt = excerpt(input.source_text);
return {
title: `人味文案优化:${publishTarget}`,
summary: sourceExcerpt,
publish_target: publishTarget,
source_excerpt: sourceExcerpt,
};
}
export function createProcessStep({
stage,
startedAt,
endedAt,
status,
errorSummary,
rewriteRound,
producedResultVersion,
}: {
stage: string;
startedAt: number;
endedAt: number;
status: ProcessSummaryStep["status"];
errorSummary?: string;
rewriteRound?: number;
producedResultVersion: boolean;
}): ProcessSummaryStep {
return {
stage,
started_at: new Date(startedAt).toISOString(),
ended_at: new Date(endedAt).toISOString(),
duration_ms: Math.max(0, endedAt - startedAt),
status,
...(errorSummary ? { error_summary: errorSummary } : {}),
...(rewriteRound ? { rewrite_round: rewriteRound } : {}),
produced_result_version: producedResultVersion,
};
}
+118
View File
@@ -0,0 +1,118 @@
import type {
CopyOptimizationRequest,
CopyOptimizationResult,
OptimizedArticle,
QaReport,
} from "../domain/types";
import type { LlmAuditSummary } from "../llm/audit";
export type OptimizationCaseType = "article" | "human_copy";
export type OptimizationCaseStatus =
| "running"
| "optimized"
| "failed"
| "archived";
export type ResultVersionStatus = "optimized" | "failed";
export interface ProcessSummaryStep {
stage: string;
started_at: string;
ended_at: string;
duration_ms: number;
status: "success" | "failed";
error_summary?: string;
rewrite_round?: number;
produced_result_version: boolean;
}
export interface OptimizationCase {
id: string;
case_type: OptimizationCaseType;
title: string;
summary: string;
status: OptimizationCaseStatus;
customer_name: string;
brand_name: string;
project_tags: string[];
notes: string;
publish_target: string;
source_excerpt: string;
result_excerpt: string;
latest_result_version_id: string | null;
latest_version_number: number | null;
last_error_stage: string | null;
last_error_summary: string | null;
archived_at: string | null;
created_at: string;
updated_at: string;
}
export interface CaseInput {
case_id: string;
case_type: OptimizationCaseType;
article_job_id: string | null;
payload: ArticleCaseInputPayload | HumanCopyCaseInputPayload;
created_at: string;
updated_at: string;
}
export interface ArticleCaseInputPayload {
source_title: string;
source_body: string;
image_inputs: Array<{ type: "description" | "link"; content: string }>;
publish_platform: string;
user_instructions: string;
fact_card?: unknown;
}
export type HumanCopyCaseInputPayload = CopyOptimizationRequest;
export interface ArticleResultVersionPayload {
article: OptimizedArticle;
qa_report: QaReport;
export_paths: Record<string, string>;
}
export type HumanCopyResultVersionPayload = CopyOptimizationResult;
export interface OptimizationResultVersion {
id: string;
case_id: string;
case_type: OptimizationCaseType;
version: number;
status: ResultVersionStatus;
article_job_id: string | null;
article_revision: number | null;
result_summary: string;
payload: ArticleResultVersionPayload | HumanCopyResultVersionPayload | null;
process_summary: ProcessSummaryStep[];
llm_audit_summary: LlmAuditSummary[];
error_stage: string | null;
error_summary: string | null;
created_at: string;
}
export interface OptimizationCaseDetail {
case: OptimizationCase;
input: CaseInput | null;
versions: OptimizationResultVersion[];
}
export interface CaseListFilters {
q?: string;
case_type?: OptimizationCaseType;
status?: OptimizationCaseStatus;
publish_target?: string;
project_tag?: string;
created_from?: string;
created_to?: string;
include_archived: boolean;
}
export interface CaseMetadataPatch {
title?: string;
customer_name?: string;
brand_name?: string;
project_tags?: string[];
notes?: string;
}
+62
View File
@@ -0,0 +1,62 @@
import { z } from "zod";
import type {
CaseListFilters,
CaseMetadataPatch,
OptimizationCaseStatus,
OptimizationCaseType,
ResultVersionStatus,
} from "./types";
export const caseTypeSchema = z.enum([
"article",
"human_copy",
]) satisfies z.ZodType<OptimizationCaseType>;
export const caseStatusSchema = z.enum([
"running",
"optimized",
"failed",
"archived",
]) satisfies z.ZodType<OptimizationCaseStatus>;
export const resultVersionStatusSchema = z.enum([
"optimized",
"failed",
]) satisfies z.ZodType<ResultVersionStatus>;
const optionalTrimmedText = z
.preprocess((value) => (value == null ? undefined : value), z.string().trim())
.optional();
function booleanQuery(value: unknown) {
if (value === true || value === "true") return true;
if (value === false || value === "false") return false;
return value;
}
export const caseMetadataPatchSchema = z.object({
title: optionalTrimmedText,
customer_name: optionalTrimmedText,
brand_name: optionalTrimmedText,
project_tags: z.array(z.string().trim().min(1)).optional(),
notes: optionalTrimmedText,
}) satisfies z.ZodType<CaseMetadataPatch>;
export const caseListFiltersSchema = z.object({
q: optionalTrimmedText,
case_type: caseTypeSchema.optional(),
status: caseStatusSchema.optional(),
publish_target: optionalTrimmedText,
project_tag: optionalTrimmedText,
created_from: optionalTrimmedText,
created_to: optionalTrimmedText,
include_archived: z.preprocess(booleanQuery, z.boolean().default(false)),
}) satisfies z.ZodType<CaseListFilters>;
export const resultVersionPublicationInputSchema = z.object({
publish_target: z.string().trim().min(1),
url: z.string().trim().url(),
published_at: z.string().datetime(),
notes: z.string().trim().default(""),
});
@@ -24,6 +24,7 @@ describe("createD1Repository", () => {
expect(bind).toHaveBeenCalledWith(
job.id,
null,
null,
"Title",
"Body",
"[]",
@@ -40,6 +41,7 @@ describe("createD1Repository", () => {
const first = vi.fn().mockResolvedValue({
id: "job_123",
brand_template_id: null,
case_id: null,
source_title: "Title",
source_body: "Body",
image_inputs: "[]",
+20
View File
@@ -50,7 +50,10 @@ describe("sqlite repositories", () => {
"article_jobs",
"brand_templates",
"calibration_events",
"case_inputs",
"fact_cards",
"optimization_cases",
"optimization_result_versions",
"optimized_articles",
"performance_snapshots",
"publication_records",
@@ -60,6 +63,23 @@ describe("sqlite repositories", () => {
]);
});
it("adds case references to article jobs and result-version publications", () => {
const db = createDatabase(dbPath);
const articleJobColumns = db
.prepare("pragma table_info(article_jobs)")
.all()
.map((row) => (row as { name: string }).name);
const publicationColumns = db
.prepare("pragma table_info(publication_records)")
.all()
.map((row) => (row as { name: string }).name);
db.close();
expect(articleJobColumns).toContain("case_id");
expect(publicationColumns).toContain("result_version_id");
expect(publicationColumns).toContain("publish_target");
});
it("inserts and fetches a brand template", () => {
const created = createBrandTemplate(dbPath, {
brand_name: "Example",
+118
View File
@@ -111,4 +111,122 @@ describe("createSqliteRepository", () => {
]);
expect(event.observations).toEqual(["表现高于预期"]);
});
test("creates, lists, updates, archives, and restores optimization cases", async () => {
const repository = createSqliteRepository(dbPath);
const created = await repository.createOptimizationCase({
case_type: "human_copy",
title: "人味文案优化:朋友圈",
summary: "原文摘要",
publish_target: "朋友圈",
source_excerpt: "原文摘要",
});
await repository.saveCaseInput({
case_id: created.id,
case_type: "human_copy",
article_job_id: null,
payload: {
source_text: "原文摘要",
goal: "自然一点",
intensity: "light",
user_instructions: "",
publish_target: "朋友圈",
},
});
await expect(
repository.listOptimizationCases({ include_archived: false }),
).resolves.toEqual([expect.objectContaining({ id: created.id })]);
await expect(
repository.updateOptimizationCaseMetadata(created.id, {
customer_name: "客户A",
brand_name: "品牌B",
project_tags: ["朋友圈"],
notes: "保留口语。",
}),
).resolves.toMatchObject({
customer_name: "客户A",
project_tags: ["朋友圈"],
});
await repository.archiveOptimizationCase(created.id);
await expect(
repository.listOptimizationCases({ include_archived: false }),
).resolves.toHaveLength(0);
await repository.restoreOptimizationCase(created.id);
await expect(repository.getOptimizationCaseDetail(created.id)).resolves.toMatchObject({
case: { id: created.id, status: "running" },
input: expect.objectContaining({ case_id: created.id }),
});
});
test("creates multiple result versions and binds publication to a version", async () => {
const repository = createSqliteRepository(dbPath);
const optimizationCase = await repository.createOptimizationCase({
case_type: "human_copy",
title: "人味文案优化:私域",
summary: "原文",
publish_target: "私域",
source_excerpt: "原文",
});
const first = await repository.createOptimizationResultVersion({
case_id: optimizationCase.id,
case_type: "human_copy",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "第一版",
payload: {
optimized_text: "第一版文案",
change_notes: [],
ai_taste_checks: [],
warnings: [],
},
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
});
const second = await repository.createOptimizationResultVersion({
case_id: optimizationCase.id,
case_type: "human_copy",
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "第二版",
payload: {
optimized_text: "第二版文案",
change_notes: [],
ai_taste_checks: [],
warnings: [],
},
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
});
expect(first.version).toBe(1);
expect(second.version).toBe(2);
const publication = await repository.createPublicationRecord({
result_version_id: second.id,
job_id: null,
revision: null,
publish_target: "私域",
url: "https://example.com/private",
published_at: "2026-07-08T12:00:00.000Z",
status: "published",
notes: "客户私域发布",
});
await expect(
repository.listPublicationRecordsForResultVersion(second.id),
).resolves.toEqual([expect.objectContaining({ id: publication.id })]);
});
});
+495 -17
View File
@@ -6,7 +6,15 @@ import type {
ScoringRun,
} from "../calibration/types";
import type {
ConfirmedFactCard,
CaseInput,
OptimizationCase,
OptimizationCaseStatus,
OptimizationCaseType,
OptimizationResultVersion,
ResultVersionStatus,
} from "../cases/types";
import type {
OptimizationFactCard,
ImageInput,
OptimizedArticle,
PublishPlatform,
@@ -35,6 +43,7 @@ interface BrandTemplateRow {
interface ArticleJobRow {
id: string;
brand_template_id: string | null;
case_id: string | null;
source_title: string;
source_body: string;
image_inputs: string;
@@ -65,8 +74,10 @@ interface QaReportRow {
interface ScoringRunRow {
id: string;
job_id: string;
revision: number;
result_version_id: string | null;
case_type: OptimizationCaseType;
job_id: string | null;
revision: number | null;
rubric_version_id: string;
dimension_scores: string;
composite_score: number;
@@ -76,9 +87,10 @@ interface ScoringRunRow {
interface PublicationRecordRow {
id: string;
job_id: string;
revision: number;
platform: PublishPlatform;
result_version_id: string | null;
job_id: string | null;
revision: number | null;
publish_target: string;
url: string;
published_at: string;
status: "draft" | "published" | "archived";
@@ -98,6 +110,54 @@ interface PerformanceSnapshotRow {
snapshot_at: string;
}
interface OptimizationCaseRow {
id: string;
case_type: OptimizationCaseType;
title: string;
summary: string;
status: OptimizationCaseStatus;
customer_name: string;
brand_name: string;
project_tags: string;
notes: string;
publish_target: string;
source_excerpt: string;
result_excerpt: string;
latest_result_version_id: string | null;
latest_version_number: number | null;
last_error_stage: string | null;
last_error_summary: string | null;
archived_at: string | null;
created_at: string;
updated_at: string;
}
interface CaseInputRow {
case_id: string;
case_type: OptimizationCaseType;
article_job_id: string | null;
payload: string;
created_at: string;
updated_at: string;
}
interface OptimizationResultVersionRow {
id: string;
case_id: string;
case_type: OptimizationCaseType;
version: number;
status: ResultVersionStatus;
article_job_id: string | null;
article_revision: number | null;
result_summary: string;
payload: string;
process_summary: string;
llm_audit_summary: string;
error_stage: string | null;
error_summary: string | null;
created_at: string;
}
function nowIso() {
return new Date().toISOString();
}
@@ -138,6 +198,15 @@ function toScoringRun(row: ScoringRunRow): ScoringRun {
};
}
function toPublicationRecord(row: PublicationRecordRow): PublicationRecord {
return {
...row,
platform: isPublishPlatform(row.publish_target)
? row.publish_target
: undefined,
};
}
function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot {
return {
...row,
@@ -146,6 +215,86 @@ function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot
};
}
function toOptimizationCase(row: OptimizationCaseRow): OptimizationCase {
return {
...row,
project_tags: parseJson<string[]>(row.project_tags),
};
}
function toCaseInput(row: CaseInputRow): CaseInput {
return {
...row,
payload: parseJson<CaseInput["payload"]>(row.payload),
};
}
function toOptimizationResultVersion(
row: OptimizationResultVersionRow,
): OptimizationResultVersion {
return {
...row,
payload: row.payload
? parseJson<OptimizationResultVersion["payload"]>(row.payload)
: null,
process_summary: parseJson<OptimizationResultVersion["process_summary"]>(
row.process_summary,
),
llm_audit_summary: parseJson<OptimizationResultVersion["llm_audit_summary"]>(
row.llm_audit_summary,
),
};
}
function isPublishPlatform(value: string): value is PublishPlatform {
return [
"official_site",
"media_article",
"comparison_review",
"recommendation_list",
].includes(value);
}
async function updateD1CaseArchiveState(
db: D1Database,
caseId: string,
archived: boolean,
) {
const existing = await db
.prepare("select * from optimization_cases where id = ?")
.bind(caseId)
.first<OptimizationCaseRow>();
if (!existing) return null;
const timestamp = nowIso();
const restoredStatus = existing.latest_result_version_id
? "optimized"
: existing.last_error_summary
? "failed"
: "running";
await db
.prepare(
`update optimization_cases set
status = ?,
archived_at = ?,
updated_at = ?
where id = ?`,
)
.bind(
archived ? "archived" : restoredStatus,
archived ? timestamp : null,
timestamp,
caseId,
)
.run();
const row = await db
.prepare("select * from optimization_cases where id = ?")
.bind(caseId)
.first<OptimizationCaseRow>();
return row ? toOptimizationCase(row) : null;
}
export function createD1Repository(db: D1Database): AppRepository {
return {
async createBrandTemplate(input) {
@@ -201,6 +350,7 @@ export function createD1Repository(db: D1Database): AppRepository {
const job: ArticleJob = {
id: `job_${nanoid(10)}`,
brand_template_id: input.brand_template_id ?? null,
case_id: input.case_id ?? null,
source_title: input.source_title,
source_body: input.source_body,
image_inputs: input.image_inputs,
@@ -215,13 +365,14 @@ export function createD1Repository(db: D1Database): AppRepository {
await db
.prepare(
`insert into article_jobs (
id, brand_template_id, source_title, source_body, image_inputs,
id, brand_template_id, case_id, source_title, source_body, image_inputs,
publish_platform, user_instructions, status, export_paths, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
job.id,
job.brand_template_id,
job.case_id,
job.source_title,
job.source_body,
serialize(job.image_inputs),
@@ -285,7 +436,9 @@ export function createD1Repository(db: D1Database): AppRepository {
)
.bind(
jobId,
"auto_extract_then_user_confirmed",
factCard.confirmed_by_user
? "auto_extract_then_user_confirmed"
: "auto_extract_for_optimization",
serialize(factCard),
factCard.confirmed_by_user ? 1 : 0,
timestamp,
@@ -300,7 +453,7 @@ export function createD1Repository(db: D1Database): AppRepository {
.bind(jobId)
.first<FactCardRow>();
return row
? { job_id: row.job_id, ...parseJson<ConfirmedFactCard>(row.fact_card) }
? { job_id: row.job_id, ...parseJson<OptimizationFactCard>(row.fact_card) }
: null;
},
async saveOptimizedArticle(jobId, article) {
@@ -354,6 +507,302 @@ export function createD1Repository(db: D1Database): AppRepository {
.first<QaReportRow>();
return row ? parseJson<QaReport>(row.report) : null;
},
async createOptimizationCase(input) {
const timestamp = nowIso();
const optimizationCase: OptimizationCase = {
id: `case_${nanoid(10)}`,
case_type: input.case_type,
title: input.title,
summary: input.summary,
status: "running",
customer_name: "",
brand_name: "",
project_tags: [],
notes: "",
publish_target: input.publish_target,
source_excerpt: input.source_excerpt,
result_excerpt: "",
latest_result_version_id: null,
latest_version_number: null,
last_error_stage: null,
last_error_summary: null,
archived_at: null,
created_at: timestamp,
updated_at: timestamp,
};
await db
.prepare(
`insert into optimization_cases (
id, case_type, title, summary, status, customer_name, brand_name,
project_tags, notes, publish_target, source_excerpt, result_excerpt,
latest_result_version_id, latest_version_number, last_error_stage,
last_error_summary, archived_at, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
optimizationCase.id,
optimizationCase.case_type,
optimizationCase.title,
optimizationCase.summary,
optimizationCase.status,
optimizationCase.customer_name,
optimizationCase.brand_name,
serialize(optimizationCase.project_tags),
optimizationCase.notes,
optimizationCase.publish_target,
optimizationCase.source_excerpt,
optimizationCase.result_excerpt,
optimizationCase.latest_result_version_id,
optimizationCase.latest_version_number,
optimizationCase.last_error_stage,
optimizationCase.last_error_summary,
optimizationCase.archived_at,
optimizationCase.created_at,
optimizationCase.updated_at,
)
.run();
return optimizationCase;
},
async saveCaseInput(input) {
const timestamp = nowIso();
const saved: CaseInput = {
...input,
created_at: timestamp,
updated_at: timestamp,
};
await db
.prepare(
`insert into case_inputs (
case_id, case_type, article_job_id, payload, created_at, updated_at
) values (?, ?, ?, ?, ?, ?)
on conflict(case_id) do update set
case_type = excluded.case_type,
article_job_id = excluded.article_job_id,
payload = excluded.payload,
updated_at = excluded.updated_at`,
)
.bind(
saved.case_id,
saved.case_type,
saved.article_job_id,
serialize(saved.payload),
saved.created_at,
saved.updated_at,
)
.run();
return saved;
},
async listOptimizationCases(filters) {
const where: string[] = [];
const values: unknown[] = [];
if (!filters.include_archived) where.push("archived_at is null");
if (filters.case_type) {
where.push("case_type = ?");
values.push(filters.case_type);
}
if (filters.status) {
where.push("status = ?");
values.push(filters.status);
}
if (filters.publish_target) {
where.push("publish_target = ?");
values.push(filters.publish_target);
}
if (filters.project_tag) {
where.push("project_tags like ?");
values.push(`%"${filters.project_tag}"%`);
}
if (filters.created_from) {
where.push("created_at >= ?");
values.push(filters.created_from);
}
if (filters.created_to) {
where.push("created_at <= ?");
values.push(filters.created_to);
}
if (filters.q) {
where.push(
"(title like ? or summary like ? or source_excerpt like ? or result_excerpt like ? or customer_name like ? or brand_name like ? or notes like ?)",
);
const keyword = `%${filters.q}%`;
values.push(keyword, keyword, keyword, keyword, keyword, keyword, keyword);
}
const clause = where.length > 0 ? `where ${where.join(" and ")}` : "";
const prepared = db.prepare(
`select * from optimization_cases ${clause} order by updated_at desc`,
);
const result =
values.length > 0
? await prepared.bind(...values).all<OptimizationCaseRow>()
: await prepared.all<OptimizationCaseRow>();
return result.results.map(toOptimizationCase);
},
async getOptimizationCaseDetail(caseId) {
const caseRow = await db
.prepare("select * from optimization_cases where id = ?")
.bind(caseId)
.first<OptimizationCaseRow>();
if (!caseRow) return null;
const inputRow = await db
.prepare("select * from case_inputs where case_id = ?")
.bind(caseId)
.first<CaseInputRow>();
const versionRows = await db
.prepare(
`select * from optimization_result_versions
where case_id = ?
order by version desc`,
)
.bind(caseId)
.all<OptimizationResultVersionRow>();
return {
case: toOptimizationCase(caseRow),
input: inputRow ? toCaseInput(inputRow) : null,
versions: versionRows.results.map(toOptimizationResultVersion),
};
},
async updateOptimizationCaseMetadata(caseId, changes) {
const existing = await db
.prepare("select * from optimization_cases where id = ?")
.bind(caseId)
.first<OptimizationCaseRow>();
if (!existing) return null;
await db
.prepare(
`update optimization_cases set
title = ?,
customer_name = ?,
brand_name = ?,
project_tags = ?,
notes = ?,
updated_at = ?
where id = ?`,
)
.bind(
changes.title ?? existing.title,
changes.customer_name ?? existing.customer_name,
changes.brand_name ?? existing.brand_name,
changes.project_tags === undefined
? existing.project_tags
: serialize(changes.project_tags),
changes.notes ?? existing.notes,
nowIso(),
caseId,
)
.run();
const row = await db
.prepare("select * from optimization_cases where id = ?")
.bind(caseId)
.first<OptimizationCaseRow>();
return row ? toOptimizationCase(row) : null;
},
async archiveOptimizationCase(caseId) {
return updateD1CaseArchiveState(db, caseId, true);
},
async restoreOptimizationCase(caseId) {
return updateD1CaseArchiveState(db, caseId, false);
},
async markOptimizationCaseFailed(caseId, input) {
await db
.prepare(
`update optimization_cases set
status = 'failed',
last_error_stage = ?,
last_error_summary = ?,
updated_at = ?
where id = ?`,
)
.bind(input.error_stage, input.error_summary, nowIso(), caseId)
.run();
const row = await db
.prepare("select * from optimization_cases where id = ?")
.bind(caseId)
.first<OptimizationCaseRow>();
return row ? toOptimizationCase(row) : null;
},
async createOptimizationResultVersion(input) {
const latest = await db
.prepare(
"select max(version) as version from optimization_result_versions where case_id = ?",
)
.bind(input.case_id)
.first<{ version: number | null }>();
const createdAt = nowIso();
const resultVersion: OptimizationResultVersion = {
id: `ver_${nanoid(10)}`,
...input,
version: (latest?.version ?? 0) + 1,
created_at: createdAt,
};
await db
.prepare(
`insert into optimization_result_versions (
id, case_id, case_type, version, status, article_job_id, article_revision,
result_summary, payload, process_summary, llm_audit_summary,
error_stage, error_summary, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
resultVersion.id,
resultVersion.case_id,
resultVersion.case_type,
resultVersion.version,
resultVersion.status,
resultVersion.article_job_id,
resultVersion.article_revision,
resultVersion.result_summary,
resultVersion.payload ? serialize(resultVersion.payload) : "",
serialize(resultVersion.process_summary),
serialize(resultVersion.llm_audit_summary),
resultVersion.error_stage,
resultVersion.error_summary,
resultVersion.created_at,
)
.run();
await db
.prepare(
`update optimization_cases set
status = ?,
result_excerpt = ?,
latest_result_version_id = ?,
latest_version_number = ?,
last_error_stage = ?,
last_error_summary = ?,
updated_at = ?
where id = ?`,
)
.bind(
input.status === "optimized" ? "optimized" : "failed",
input.result_summary,
resultVersion.id,
resultVersion.version,
input.error_stage,
input.error_summary,
createdAt,
input.case_id,
)
.run();
return resultVersion;
},
async getOptimizationResultVersion(versionId) {
const row = await db
.prepare("select * from optimization_result_versions where id = ?")
.bind(versionId)
.first<OptimizationResultVersionRow>();
return row ? toOptimizationResultVersion(row) : null;
},
async findResultVersionForArticleRevision(jobId, revision) {
const row = await db
.prepare(
`select * from optimization_result_versions
where article_job_id = ? and article_revision = ?
order by version desc
limit 1`,
)
.bind(jobId, revision)
.first<OptimizationResultVersionRow>();
return row ? toOptimizationResultVersion(row) : null;
},
async saveRubricVersion(rubric) {
await db
.prepare(
@@ -383,12 +832,14 @@ export function createD1Repository(db: D1Database): AppRepository {
await db
.prepare(
`insert into scoring_runs (
id, job_id, revision, rubric_version_id, dimension_scores,
id, result_version_id, case_type, job_id, revision, rubric_version_id, dimension_scores,
composite_score, rationale, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
run.id,
run.result_version_id ?? null,
run.case_type ?? "article",
run.job_id,
run.revision,
run.rubric_version_id,
@@ -412,26 +863,43 @@ export function createD1Repository(db: D1Database): AppRepository {
.first<ScoringRunRow>();
return row ? toScoringRun(row) : null;
},
async getLatestScoringRunForResultVersion(resultVersionId) {
const row = await db
.prepare(
`select * from scoring_runs
where result_version_id = ?
order by created_at desc
limit 1`,
)
.bind(resultVersionId)
.first<ScoringRunRow>();
return row ? toScoringRun(row) : null;
},
async createPublicationRecord(input) {
const timestamp = nowIso();
const publishTarget = input.publish_target ?? input.platform ?? "未指定";
const record: PublicationRecord = {
id: `pub_${nanoid(10)}`,
...input,
result_version_id: input.result_version_id ?? null,
publish_target: publishTarget,
platform: isPublishPlatform(publishTarget) ? publishTarget : input.platform,
created_at: timestamp,
updated_at: timestamp,
};
await db
.prepare(
`insert into publication_records (
id, job_id, revision, platform, url, published_at, status,
id, result_version_id, job_id, revision, publish_target, url, published_at, status,
notes, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
record.id,
record.result_version_id,
record.job_id,
record.revision,
record.platform,
record.publish_target,
record.url,
record.published_at,
record.status,
@@ -449,13 +917,23 @@ export function createD1Repository(db: D1Database): AppRepository {
)
.bind(jobId)
.all<PublicationRecordRow>();
return result.results;
return result.results.map(toPublicationRecord);
},
async listPublicationRecordsForResultVersion(resultVersionId) {
const result = await db
.prepare(
"select * from publication_records where result_version_id = ? order by published_at desc",
)
.bind(resultVersionId)
.all<PublicationRecordRow>();
return result.results.map(toPublicationRecord);
},
async getPublicationRecord(id) {
return db
const row = await db
.prepare("select * from publication_records where id = ?")
.bind(id)
.first<PublicationRecordRow>();
return row ? toPublicationRecord(row) : null;
},
async savePerformanceSnapshot(snapshot) {
await db
+552 -19
View File
@@ -8,7 +8,17 @@ import type {
ScoringRun,
} from "../calibration/types";
import type {
ConfirmedFactCard,
CaseInput,
CaseListFilters,
CaseMetadataPatch,
OptimizationCase,
OptimizationCaseStatus,
OptimizationCaseType,
OptimizationResultVersion,
ResultVersionStatus,
} from "../cases/types";
import type {
OptimizationFactCard,
ImageInput,
OptimizedArticle,
PublishPlatform,
@@ -43,6 +53,7 @@ export type NewBrandTemplate = Omit<
export interface ArticleJob {
id: string;
brand_template_id: string | null;
case_id: string | null;
source_title: string;
source_body: string;
image_inputs: ImageInput[];
@@ -56,6 +67,7 @@ export interface ArticleJob {
export interface NewArticleJob {
brand_template_id?: string | null;
case_id?: string | null;
source_title: string;
source_body: string;
image_inputs: ImageInput[];
@@ -81,6 +93,7 @@ interface BrandTemplateRow {
interface ArticleJobRow {
id: string;
brand_template_id: string | null;
case_id: string | null;
source_title: string;
source_body: string;
image_inputs: string;
@@ -111,8 +124,10 @@ interface QaReportRow {
interface ScoringRunRow {
id: string;
job_id: string;
revision: number;
result_version_id: string | null;
case_type: OptimizationCaseType;
job_id: string | null;
revision: number | null;
rubric_version_id: string;
dimension_scores: string;
composite_score: number;
@@ -122,9 +137,10 @@ interface ScoringRunRow {
interface PublicationRecordRow {
id: string;
job_id: string;
revision: number;
platform: PublishPlatform;
result_version_id: string | null;
job_id: string | null;
revision: number | null;
publish_target: string;
url: string;
published_at: string;
status: "draft" | "published" | "archived";
@@ -144,6 +160,54 @@ interface PerformanceSnapshotRow {
snapshot_at: string;
}
interface OptimizationCaseRow {
id: string;
case_type: OptimizationCaseType;
title: string;
summary: string;
status: OptimizationCaseStatus;
customer_name: string;
brand_name: string;
project_tags: string;
notes: string;
publish_target: string;
source_excerpt: string;
result_excerpt: string;
latest_result_version_id: string | null;
latest_version_number: number | null;
last_error_stage: string | null;
last_error_summary: string | null;
archived_at: string | null;
created_at: string;
updated_at: string;
}
interface CaseInputRow {
case_id: string;
case_type: OptimizationCaseType;
article_job_id: string | null;
payload: string;
created_at: string;
updated_at: string;
}
interface OptimizationResultVersionRow {
id: string;
case_id: string;
case_type: OptimizationCaseType;
version: number;
status: ResultVersionStatus;
article_job_id: string | null;
article_revision: number | null;
result_summary: string;
payload: string;
process_summary: string;
llm_audit_summary: string;
error_stage: string | null;
error_summary: string | null;
created_at: string;
}
function nowIso() {
return new Date().toISOString();
}
@@ -194,6 +258,15 @@ function toScoringRun(row: ScoringRunRow): ScoringRun {
};
}
function toPublicationRecord(row: PublicationRecordRow): PublicationRecord {
return {
...row,
platform: isPublishPlatform(row.publish_target)
? row.publish_target
: undefined,
};
}
function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot {
return {
...row,
@@ -202,6 +275,46 @@ function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot
};
}
function toOptimizationCase(row: OptimizationCaseRow): OptimizationCase {
return {
...row,
project_tags: parseJson<string[]>(row.project_tags),
};
}
function toCaseInput(row: CaseInputRow): CaseInput {
return {
...row,
payload: parseJson<CaseInput["payload"]>(row.payload),
};
}
function toOptimizationResultVersion(
row: OptimizationResultVersionRow,
): OptimizationResultVersion {
return {
...row,
payload: row.payload
? parseJson<OptimizationResultVersion["payload"]>(row.payload)
: null,
process_summary: parseJson<OptimizationResultVersion["process_summary"]>(
row.process_summary,
),
llm_audit_summary: parseJson<OptimizationResultVersion["llm_audit_summary"]>(
row.llm_audit_summary,
),
};
}
function isPublishPlatform(value: string): value is PublishPlatform {
return [
"official_site",
"media_article",
"comparison_review",
"recommendation_list",
].includes(value);
}
export function createBrandTemplate(
dbPath: string | undefined,
input: NewBrandTemplate,
@@ -264,6 +377,7 @@ export function createArticleJob(dbPath: string | undefined, input: NewArticleJo
const job: ArticleJob = {
id: `job_${nanoid(10)}`,
brand_template_id: input.brand_template_id ?? null,
case_id: input.case_id ?? null,
source_title: input.source_title,
source_body: input.source_body,
image_inputs: input.image_inputs,
@@ -277,10 +391,10 @@ export function createArticleJob(dbPath: string | undefined, input: NewArticleJo
db.prepare(
`insert into article_jobs (
id, brand_template_id, source_title, source_body, image_inputs,
id, brand_template_id, case_id, source_title, source_body, image_inputs,
publish_platform, user_instructions, status, export_paths, created_at, updated_at
) values (
@id, @brand_template_id, @source_title, @source_body, @image_inputs,
@id, @brand_template_id, @case_id, @source_title, @source_body, @image_inputs,
@publish_platform, @user_instructions, @status, @export_paths, @created_at, @updated_at
)`,
).run({
@@ -335,7 +449,7 @@ export function updateArticleJob(
export function saveFactCard(
dbPath: string | undefined,
jobId: string,
factCard: ConfirmedFactCard,
factCard: OptimizationFactCard,
) {
return withDb(dbPath, (db) => {
const timestamp = nowIso();
@@ -351,7 +465,9 @@ export function saveFactCard(
updated_at = excluded.updated_at`,
).run({
job_id: jobId,
source: "auto_extract_then_user_confirmed",
source: factCard.confirmed_by_user
? "auto_extract_then_user_confirmed"
: "auto_extract_for_optimization",
fact_card: serialize(factCard),
confirmed_by_user: factCard.confirmed_by_user ? 1 : 0,
created_at: timestamp,
@@ -368,7 +484,7 @@ export function getFactCard(dbPath: string | undefined, jobId: string) {
.prepare("select job_id, fact_card from fact_cards where job_id = ?")
.get(jobId) as FactCardRow | undefined;
return row
? { job_id: row.job_id, ...parseJson<ConfirmedFactCard>(row.fact_card) }
? { job_id: row.job_id, ...parseJson<OptimizationFactCard>(row.fact_card) }
: null;
});
}
@@ -438,6 +554,379 @@ export function getLatestQaReport(dbPath: string | undefined, jobId: string) {
});
}
export function createOptimizationCase(
dbPath: string | undefined,
input: {
case_type: OptimizationCaseType;
title: string;
summary: string;
publish_target: string;
source_excerpt: string;
},
) {
return withDb(dbPath, (db) => {
const timestamp = nowIso();
const optimizationCase: OptimizationCase = {
id: `case_${nanoid(10)}`,
case_type: input.case_type,
title: input.title,
summary: input.summary,
status: "running",
customer_name: "",
brand_name: "",
project_tags: [],
notes: "",
publish_target: input.publish_target,
source_excerpt: input.source_excerpt,
result_excerpt: "",
latest_result_version_id: null,
latest_version_number: null,
last_error_stage: null,
last_error_summary: null,
archived_at: null,
created_at: timestamp,
updated_at: timestamp,
};
db.prepare(
`insert into optimization_cases (
id, case_type, title, summary, status, customer_name, brand_name,
project_tags, notes, publish_target, source_excerpt, result_excerpt,
latest_result_version_id, latest_version_number, last_error_stage,
last_error_summary, archived_at, created_at, updated_at
) values (
@id, @case_type, @title, @summary, @status, @customer_name, @brand_name,
@project_tags, @notes, @publish_target, @source_excerpt, @result_excerpt,
@latest_result_version_id, @latest_version_number, @last_error_stage,
@last_error_summary, @archived_at, @created_at, @updated_at
)`,
).run({
...optimizationCase,
project_tags: serialize(optimizationCase.project_tags),
});
return optimizationCase;
});
}
export function saveCaseInput(
dbPath: string | undefined,
input: Omit<CaseInput, "created_at" | "updated_at">,
) {
return withDb(dbPath, (db) => {
const timestamp = nowIso();
const saved: CaseInput = {
...input,
created_at: timestamp,
updated_at: timestamp,
};
db.prepare(
`insert into case_inputs (
case_id, case_type, article_job_id, payload, created_at, updated_at
) values (
@case_id, @case_type, @article_job_id, @payload, @created_at, @updated_at
)
on conflict(case_id) do update set
case_type = excluded.case_type,
article_job_id = excluded.article_job_id,
payload = excluded.payload,
updated_at = excluded.updated_at`,
).run({
...saved,
payload: serialize(saved.payload),
});
return saved;
});
}
export function listOptimizationCases(
dbPath: string | undefined,
filters: CaseListFilters,
) {
return withDb(dbPath, (db) => {
const where: string[] = [];
const values: unknown[] = [];
if (!filters.include_archived) {
where.push("archived_at is null");
}
if (filters.case_type) {
where.push("case_type = ?");
values.push(filters.case_type);
}
if (filters.status) {
where.push("status = ?");
values.push(filters.status);
}
if (filters.publish_target) {
where.push("publish_target = ?");
values.push(filters.publish_target);
}
if (filters.project_tag) {
where.push("project_tags like ?");
values.push(`%"${filters.project_tag}"%`);
}
if (filters.created_from) {
where.push("created_at >= ?");
values.push(filters.created_from);
}
if (filters.created_to) {
where.push("created_at <= ?");
values.push(filters.created_to);
}
if (filters.q) {
where.push(
"(title like ? or summary like ? or source_excerpt like ? or result_excerpt like ? or customer_name like ? or brand_name like ? or notes like ?)",
);
const keyword = `%${filters.q}%`;
values.push(keyword, keyword, keyword, keyword, keyword, keyword, keyword);
}
const clause = where.length > 0 ? `where ${where.join(" and ")}` : "";
return db
.prepare(`select * from optimization_cases ${clause} order by updated_at desc`)
.all(...values)
.map((row) => toOptimizationCase(row as OptimizationCaseRow));
});
}
export function getOptimizationCaseDetail(
dbPath: string | undefined,
caseId: string,
) {
return withDb(dbPath, (db) => {
const caseRow = db
.prepare("select * from optimization_cases where id = ?")
.get(caseId) as OptimizationCaseRow | undefined;
if (!caseRow) return null;
const inputRow = db
.prepare("select * from case_inputs where case_id = ?")
.get(caseId) as CaseInputRow | undefined;
const versionRows = db
.prepare(
`select * from optimization_result_versions
where case_id = ?
order by version desc`,
)
.all(caseId) as OptimizationResultVersionRow[];
return {
case: toOptimizationCase(caseRow),
input: inputRow ? toCaseInput(inputRow) : null,
versions: versionRows.map(toOptimizationResultVersion),
};
});
}
export function updateOptimizationCaseMetadata(
dbPath: string | undefined,
caseId: string,
changes: CaseMetadataPatch,
) {
return withDb(dbPath, (db) => {
const existing = db
.prepare("select * from optimization_cases where id = ?")
.get(caseId) as OptimizationCaseRow | undefined;
if (!existing) return null;
const updated = {
title: changes.title ?? existing.title,
customer_name: changes.customer_name ?? existing.customer_name,
brand_name: changes.brand_name ?? existing.brand_name,
project_tags:
changes.project_tags === undefined
? existing.project_tags
: serialize(changes.project_tags),
notes: changes.notes ?? existing.notes,
updated_at: nowIso(),
};
db.prepare(
`update optimization_cases set
title = @title,
customer_name = @customer_name,
brand_name = @brand_name,
project_tags = @project_tags,
notes = @notes,
updated_at = @updated_at
where id = @id`,
).run({ id: caseId, ...updated });
const row = db
.prepare("select * from optimization_cases where id = ?")
.get(caseId) as OptimizationCaseRow;
return toOptimizationCase(row);
});
}
export function archiveOptimizationCase(
dbPath: string | undefined,
caseId: string,
) {
return updateCaseArchiveState(dbPath, caseId, true);
}
export function restoreOptimizationCase(
dbPath: string | undefined,
caseId: string,
) {
return updateCaseArchiveState(dbPath, caseId, false);
}
function updateCaseArchiveState(
dbPath: string | undefined,
caseId: string,
archived: boolean,
) {
return withDb(dbPath, (db) => {
const existing = db
.prepare("select * from optimization_cases where id = ?")
.get(caseId) as OptimizationCaseRow | undefined;
if (!existing) return null;
const timestamp = nowIso();
const restoredStatus = existing.latest_result_version_id
? "optimized"
: existing.last_error_summary
? "failed"
: "running";
db.prepare(
`update optimization_cases set
status = ?,
archived_at = ?,
updated_at = ?
where id = ?`,
).run(
archived ? "archived" : restoredStatus,
archived ? timestamp : null,
timestamp,
caseId,
);
const row = db
.prepare("select * from optimization_cases where id = ?")
.get(caseId) as OptimizationCaseRow;
return toOptimizationCase(row);
});
}
export function markOptimizationCaseFailed(
dbPath: string | undefined,
caseId: string,
input: { error_stage: string; error_summary: string },
) {
return withDb(dbPath, (db) => {
const timestamp = nowIso();
db.prepare(
`update optimization_cases set
status = 'failed',
last_error_stage = ?,
last_error_summary = ?,
updated_at = ?
where id = ?`,
).run(input.error_stage, input.error_summary, timestamp, caseId);
const row = db
.prepare("select * from optimization_cases where id = ?")
.get(caseId) as OptimizationCaseRow | undefined;
return row ? toOptimizationCase(row) : null;
});
}
export function createOptimizationResultVersion(
dbPath: string | undefined,
input: Omit<OptimizationResultVersion, "id" | "version" | "created_at">,
) {
return withDb(dbPath, (db) => {
const nextVersion =
((db
.prepare(
"select max(version) as version from optimization_result_versions where case_id = ?",
)
.get(input.case_id) as { version: number | null }).version ?? 0) + 1;
const createdAt = nowIso();
const resultVersion: OptimizationResultVersion = {
id: `ver_${nanoid(10)}`,
...input,
version: nextVersion,
created_at: createdAt,
};
db.prepare(
`insert into optimization_result_versions (
id, case_id, case_type, version, status, article_job_id, article_revision,
result_summary, payload, process_summary, llm_audit_summary,
error_stage, error_summary, created_at
) values (
@id, @case_id, @case_type, @version, @status, @article_job_id, @article_revision,
@result_summary, @payload, @process_summary, @llm_audit_summary,
@error_stage, @error_summary, @created_at
)`,
).run({
...resultVersion,
payload: resultVersion.payload ? serialize(resultVersion.payload) : "",
process_summary: serialize(resultVersion.process_summary),
llm_audit_summary: serialize(resultVersion.llm_audit_summary),
});
db.prepare(
`update optimization_cases set
status = @status,
result_excerpt = @result_excerpt,
latest_result_version_id = @latest_result_version_id,
latest_version_number = @latest_version_number,
last_error_stage = @last_error_stage,
last_error_summary = @last_error_summary,
updated_at = @updated_at
where id = @case_id`,
).run({
case_id: input.case_id,
status: input.status === "optimized" ? "optimized" : "failed",
result_excerpt: input.result_summary,
latest_result_version_id: resultVersion.id,
latest_version_number: resultVersion.version,
last_error_stage: input.error_stage,
last_error_summary: input.error_summary,
updated_at: createdAt,
});
return resultVersion;
});
}
export function getOptimizationResultVersion(
dbPath: string | undefined,
versionId: string,
) {
return withDb(dbPath, (db) => {
const row = db
.prepare("select * from optimization_result_versions where id = ?")
.get(versionId) as OptimizationResultVersionRow | undefined;
return row ? toOptimizationResultVersion(row) : null;
});
}
export function findResultVersionForArticleRevision(
dbPath: string | undefined,
jobId: string,
revision: number,
) {
return withDb(dbPath, (db) => {
const row = db
.prepare(
`select * from optimization_result_versions
where article_job_id = ? and article_revision = ?
order by version desc
limit 1`,
)
.get(jobId, revision) as OptimizationResultVersionRow | undefined;
return row ? toOptimizationResultVersion(row) : null;
});
}
export function saveRubricVersion(
dbPath: string | undefined,
rubric: RubricVersion,
@@ -470,11 +959,13 @@ export function saveScoringRun(dbPath: string | undefined, run: ScoringRun) {
return withDb(dbPath, (db) => {
db.prepare(
`insert into scoring_runs (
id, job_id, revision, rubric_version_id, dimension_scores,
id, result_version_id, case_type, job_id, revision, rubric_version_id, dimension_scores,
composite_score, rationale, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
run.id,
run.result_version_id ?? null,
run.case_type ?? "article",
run.job_id,
run.revision,
run.rubric_version_id,
@@ -505,28 +996,56 @@ export function getLatestScoringRun(
});
}
export function getLatestScoringRunForResultVersion(
dbPath: string | undefined,
resultVersionId: string,
) {
return withDb(dbPath, (db) => {
const row = db
.prepare(
`select * from scoring_runs
where result_version_id = ?
order by created_at desc
limit 1`,
)
.get(resultVersionId) as ScoringRunRow | undefined;
return row ? toScoringRun(row) : null;
});
}
export function createPublicationRecord(
dbPath: string | undefined,
input: Omit<PublicationRecord, "id" | "created_at" | "updated_at">,
input: Omit<
PublicationRecord,
"id" | "created_at" | "updated_at" | "result_version_id" | "publish_target"
> & {
result_version_id?: string | null;
publish_target?: string;
},
) {
return withDb(dbPath, (db) => {
const timestamp = nowIso();
const publishTarget = input.publish_target ?? input.platform ?? "未指定";
const record: PublicationRecord = {
id: `pub_${nanoid(10)}`,
...input,
result_version_id: input.result_version_id ?? null,
publish_target: publishTarget,
platform: isPublishPlatform(publishTarget) ? publishTarget : input.platform,
created_at: timestamp,
updated_at: timestamp,
};
db.prepare(
`insert into publication_records (
id, job_id, revision, platform, url, published_at, status,
id, result_version_id, job_id, revision, publish_target, url, published_at, status,
notes, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
record.id,
record.result_version_id,
record.job_id,
record.revision,
record.platform,
record.publish_target,
record.url,
record.published_at,
record.status,
@@ -543,7 +1062,21 @@ export function listPublicationRecords(dbPath: string | undefined, jobId: string
db
.prepare("select * from publication_records where job_id = ? order by published_at desc")
.all(jobId)
.map((row) => row as PublicationRecordRow),
.map((row) => toPublicationRecord(row as PublicationRecordRow)),
);
}
export function listPublicationRecordsForResultVersion(
dbPath: string | undefined,
resultVersionId: string,
) {
return withDb(dbPath, (db) =>
db
.prepare(
"select * from publication_records where result_version_id = ? order by published_at desc",
)
.all(resultVersionId)
.map((row) => toPublicationRecord(row as PublicationRecordRow)),
);
}
@@ -552,7 +1085,7 @@ export function getPublicationRecord(dbPath: string | undefined, id: string) {
const row = db
.prepare("select * from publication_records where id = ?")
.get(id) as PublicationRecordRow | undefined;
return row ?? null;
return row ? toPublicationRecord(row) : null;
});
}
+53 -5
View File
@@ -5,7 +5,16 @@ import type {
RubricVersion,
ScoringRun,
} from "../calibration/types";
import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types";
import type {
CaseInput,
CaseListFilters,
CaseMetadataPatch,
OptimizationCase,
OptimizationCaseDetail,
OptimizationCaseType,
OptimizationResultVersion,
} from "../cases/types";
import type { OptimizationFactCard, OptimizedArticle, QaReport } from "../domain/types";
import type {
ArticleJob,
BrandTemplate,
@@ -28,9 +37,9 @@ export interface AppRepository {
): Promise<ArticleJob | null>;
saveFactCard(
jobId: string,
factCard: ConfirmedFactCard,
): Promise<{ job_id: string } & ConfirmedFactCard>;
getFactCard(jobId: string): Promise<({ job_id: string } & ConfirmedFactCard) | null>;
factCard: OptimizationFactCard,
): Promise<{ job_id: string } & OptimizationFactCard>;
getFactCard(jobId: string): Promise<({ job_id: string } & OptimizationFactCard) | null>;
saveOptimizedArticle(jobId: string, article: OptimizedArticle): Promise<OptimizedArticle>;
getLatestOptimizedArticle(jobId: string): Promise<OptimizedArticle | null>;
saveQaReport(jobId: string, revision: number, report: QaReport): Promise<QaReport>;
@@ -38,16 +47,55 @@ export interface AppRepository {
saveRubricVersion(rubric: RubricVersion): Promise<RubricVersion>;
saveScoringRun(run: ScoringRun): Promise<ScoringRun>;
getLatestScoringRun(jobId: string, revision: number): Promise<ScoringRun | null>;
getLatestScoringRunForResultVersion(resultVersionId: string): Promise<ScoringRun | null>;
createPublicationRecord(
input: Omit<PublicationRecord, "id" | "created_at" | "updated_at">,
input: NewPublicationRecord,
): Promise<PublicationRecord>;
listPublicationRecords(jobId: string): Promise<PublicationRecord[]>;
listPublicationRecordsForResultVersion(resultVersionId: string): Promise<PublicationRecord[]>;
getPublicationRecord(id: string): Promise<PublicationRecord | null>;
savePerformanceSnapshot(snapshot: PerformanceSnapshot): Promise<PerformanceSnapshot>;
listPerformanceSnapshots(publicationId: string): Promise<PerformanceSnapshot[]>;
saveCalibrationEvent(event: CalibrationEvent): Promise<CalibrationEvent>;
createOptimizationCase(input: {
case_type: OptimizationCaseType;
title: string;
summary: string;
publish_target: string;
source_excerpt: string;
}): Promise<OptimizationCase>;
saveCaseInput(input: Omit<CaseInput, "created_at" | "updated_at">): Promise<CaseInput>;
listOptimizationCases(filters: CaseListFilters): Promise<OptimizationCase[]>;
getOptimizationCaseDetail(caseId: string): Promise<OptimizationCaseDetail | null>;
updateOptimizationCaseMetadata(
caseId: string,
changes: CaseMetadataPatch,
): Promise<OptimizationCase | null>;
archiveOptimizationCase(caseId: string): Promise<OptimizationCase | null>;
restoreOptimizationCase(caseId: string): Promise<OptimizationCase | null>;
markOptimizationCaseFailed(
caseId: string,
input: { error_stage: string; error_summary: string },
): Promise<OptimizationCase | null>;
createOptimizationResultVersion(
input: Omit<OptimizationResultVersion, "id" | "version" | "created_at">,
): Promise<OptimizationResultVersion>;
getOptimizationResultVersion(versionId: string): Promise<OptimizationResultVersion | null>;
findResultVersionForArticleRevision(
jobId: string,
revision: number,
): Promise<OptimizationResultVersion | null>;
}
export type NewPublicationRecord = Omit<
PublicationRecord,
"id" | "created_at" | "updated_at" | "result_version_id" | "publish_target" | "platform"
> & {
result_version_id?: string | null;
publish_target?: string;
platform?: PublicationRecord["platform"];
};
interface RuntimeRepositoryOptions {
appRuntime?: string;
dbPath?: string;
+182 -9
View File
@@ -20,6 +20,7 @@ export function initializeSchema(db: Database.Database) {
create table if not exists article_jobs (
id text primary key,
brand_template_id text,
case_id text,
source_title text not null,
source_body text not null,
image_inputs text not null,
@@ -32,6 +33,39 @@ export function initializeSchema(db: Database.Database) {
foreign key (brand_template_id) references brand_templates(id)
);
create table if not exists optimization_cases (
id text primary key,
case_type text not null,
title text not null,
summary text not null,
status text not null,
customer_name text not null default '',
brand_name text not null default '',
project_tags text not null default '[]',
notes text not null default '',
publish_target text not null default '',
source_excerpt text not null default '',
result_excerpt text not null default '',
latest_result_version_id text,
latest_version_number integer,
last_error_stage text,
last_error_summary text,
archived_at text,
created_at text not null,
updated_at text not null
);
create table if not exists case_inputs (
case_id text primary key,
case_type text not null,
article_job_id text,
payload text not null,
created_at text not null,
updated_at text not null,
foreign key (case_id) references optimization_cases(id) on delete cascade,
foreign key (article_job_id) references article_jobs(id) on delete set null
);
create table if not exists fact_cards (
job_id text primary key,
source text not null,
@@ -61,6 +95,26 @@ export function initializeSchema(db: Database.Database) {
references optimized_articles(job_id, revision) on delete cascade
);
create table if not exists optimization_result_versions (
id text primary key,
case_id text not null,
case_type text not null,
version integer not null,
status text not null,
article_job_id text,
article_revision integer,
result_summary text not null,
payload text not null,
process_summary text not null,
llm_audit_summary text not null,
error_stage text,
error_summary text,
created_at text not null,
unique (case_id, version),
foreign key (case_id) references optimization_cases(id) on delete cascade,
foreign key (article_job_id) references article_jobs(id) on delete set null
);
create table if not exists rubric_versions (
id text primary key,
version text not null,
@@ -73,31 +127,34 @@ export function initializeSchema(db: Database.Database) {
create table if not exists scoring_runs (
id text primary key,
job_id text not null,
revision integer not null,
result_version_id text,
case_type text not null default 'article',
job_id text,
revision integer,
rubric_version_id text not null,
dimension_scores text not null,
composite_score real not null,
rationale text not null,
created_at text not null,
foreign key (job_id, revision)
references optimized_articles(job_id, revision) on delete cascade,
foreign key (result_version_id)
references optimization_result_versions(id) on delete cascade,
foreign key (rubric_version_id) references rubric_versions(id)
);
create table if not exists publication_records (
id text primary key,
job_id text not null,
revision integer not null,
platform text not null,
result_version_id text,
job_id text,
revision integer,
publish_target text not null,
url text not null,
published_at text not null,
status text not null,
notes text not null,
created_at text not null,
updated_at text not null,
foreign key (job_id, revision)
references optimized_articles(job_id, revision) on delete cascade
foreign key (result_version_id)
references optimization_result_versions(id) on delete cascade
);
create table if not exists performance_snapshots (
@@ -130,10 +187,126 @@ export function initializeSchema(db: Database.Database) {
create index if not exists idx_scoring_runs_job_revision
on scoring_runs(job_id, revision);
create index if not exists idx_optimization_cases_updated_at
on optimization_cases(updated_at);
create index if not exists idx_optimization_cases_case_type
on optimization_cases(case_type);
create index if not exists idx_optimization_cases_status
on optimization_cases(status);
create index if not exists idx_optimization_result_versions_case
on optimization_result_versions(case_id, version desc);
create index if not exists idx_publication_records_job_revision
on publication_records(job_id, revision);
create index if not exists idx_performance_snapshots_publication
on performance_snapshots(publication_id);
`);
ensureColumn(db, "article_jobs", "case_id", "text");
migrateScoringRunsForCases(db);
ensureColumn(db, "publication_records", "result_version_id", "text");
ensureColumn(
db,
"publication_records",
"publish_target",
"text not null default ''",
);
const publicationColumns = getTableColumns(db, "publication_records");
if (publicationColumns.includes("platform")) {
db.exec(
"update publication_records set publish_target = platform where publish_target = ''",
);
}
db.exec(`
create index if not exists idx_scoring_runs_result_version
on scoring_runs(result_version_id);
create index if not exists idx_publication_records_result_version
on publication_records(result_version_id);
`);
}
function getTableColumns(db: Database.Database, tableName: string) {
return getTableColumnInfo(db, tableName).map((row) => row.name);
}
function getTableColumnInfo(db: Database.Database, tableName: string) {
return db
.prepare(`pragma table_info(${tableName})`)
.all()
.map((row) => row as { name: string; notnull: number });
}
function migrateScoringRunsForCases(db: Database.Database) {
const columns = getTableColumnInfo(db, "scoring_runs");
const columnNames = columns.map((column) => column.name);
const jobIdColumn = columns.find((column) => column.name === "job_id");
const revisionColumn = columns.find((column) => column.name === "revision");
const needsRebuild =
!columnNames.includes("result_version_id") ||
!columnNames.includes("case_type") ||
jobIdColumn?.notnull === 1 ||
revisionColumn?.notnull === 1;
if (!needsRebuild) return;
const resultVersionSelect = columnNames.includes("result_version_id")
? "result_version_id"
: "NULL";
const caseTypeSelect = columnNames.includes("case_type")
? "case_type"
: "'article'";
db.pragma("foreign_keys = OFF");
try {
db.exec(`
drop table if exists scoring_runs_next;
create table scoring_runs_next (
id text primary key,
result_version_id text,
case_type text not null default 'article',
job_id text,
revision integer,
rubric_version_id text not null,
dimension_scores text not null,
composite_score real not null,
rationale text not null,
created_at text not null,
foreign key (result_version_id)
references optimization_result_versions(id) on delete cascade,
foreign key (rubric_version_id) references rubric_versions(id)
);
insert into scoring_runs_next (
id, result_version_id, case_type, job_id, revision, rubric_version_id,
dimension_scores, composite_score, rationale, created_at
)
select
id, ${resultVersionSelect}, ${caseTypeSelect}, job_id, revision,
rubric_version_id, dimension_scores, composite_score, rationale, created_at
from scoring_runs;
drop table scoring_runs;
alter table scoring_runs_next rename to scoring_runs;
`);
} finally {
db.pragma("foreign_keys = ON");
}
}
function ensureColumn(
db: Database.Database,
tableName: string,
columnName: string,
definition: string,
) {
if (getTableColumns(db, tableName).includes(columnName)) return;
db.exec(`alter table ${tableName} add column ${columnName} ${definition}`);
}
+54 -2
View File
@@ -1,8 +1,11 @@
import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types";
import type { OptimizationFactCard, OptimizedArticle, QaReport } from "../domain/types";
import type { AppRepository } from "./repository";
import {
archiveOptimizationCase,
createArticleJob,
createBrandTemplate,
createOptimizationCase,
createOptimizationResultVersion,
createPublicationRecord,
getArticleJob,
getBrandTemplate,
@@ -10,10 +13,19 @@ import {
getLatestOptimizedArticle,
getLatestQaReport,
getLatestScoringRun,
getLatestScoringRunForResultVersion,
getOptimizationCaseDetail,
getOptimizationResultVersion,
getPublicationRecord,
findResultVersionForArticleRevision,
listBrandTemplates,
listOptimizationCases,
listPerformanceSnapshots,
listPublicationRecords,
listPublicationRecordsForResultVersion,
markOptimizationCaseFailed,
restoreOptimizationCase,
saveCaseInput,
saveCalibrationEvent,
saveFactCard,
saveOptimizedArticle,
@@ -21,6 +33,7 @@ import {
saveQaReport,
saveRubricVersion,
saveScoringRun,
updateOptimizationCaseMetadata,
updateArticleJob,
type ArticleJob,
type NewArticleJob,
@@ -50,7 +63,7 @@ export function createSqliteRepository(dbPath?: string): AppRepository {
) {
return Promise.resolve(updateArticleJob(dbPath, id, changes));
},
saveFactCard(jobId: string, factCard: ConfirmedFactCard) {
saveFactCard(jobId: string, factCard: OptimizationFactCard) {
return Promise.resolve(saveFactCard(dbPath, jobId, factCard));
},
getFactCard(jobId: string) {
@@ -77,12 +90,18 @@ export function createSqliteRepository(dbPath?: string): AppRepository {
getLatestScoringRun(jobId, revision) {
return Promise.resolve(getLatestScoringRun(dbPath, jobId, revision));
},
getLatestScoringRunForResultVersion(resultVersionId) {
return Promise.resolve(getLatestScoringRunForResultVersion(dbPath, resultVersionId));
},
createPublicationRecord(input) {
return Promise.resolve(createPublicationRecord(dbPath, input));
},
listPublicationRecords(jobId) {
return Promise.resolve(listPublicationRecords(dbPath, jobId));
},
listPublicationRecordsForResultVersion(resultVersionId) {
return Promise.resolve(listPublicationRecordsForResultVersion(dbPath, resultVersionId));
},
getPublicationRecord(id) {
return Promise.resolve(getPublicationRecord(dbPath, id));
},
@@ -95,5 +114,38 @@ export function createSqliteRepository(dbPath?: string): AppRepository {
saveCalibrationEvent(event) {
return Promise.resolve(saveCalibrationEvent(dbPath, event));
},
createOptimizationCase(input) {
return Promise.resolve(createOptimizationCase(dbPath, input));
},
saveCaseInput(input) {
return Promise.resolve(saveCaseInput(dbPath, input));
},
listOptimizationCases(filters) {
return Promise.resolve(listOptimizationCases(dbPath, filters));
},
getOptimizationCaseDetail(caseId) {
return Promise.resolve(getOptimizationCaseDetail(dbPath, caseId));
},
updateOptimizationCaseMetadata(caseId, changes) {
return Promise.resolve(updateOptimizationCaseMetadata(dbPath, caseId, changes));
},
archiveOptimizationCase(caseId) {
return Promise.resolve(archiveOptimizationCase(dbPath, caseId));
},
restoreOptimizationCase(caseId) {
return Promise.resolve(restoreOptimizationCase(dbPath, caseId));
},
markOptimizationCaseFailed(caseId, input) {
return Promise.resolve(markOptimizationCaseFailed(dbPath, caseId, input));
},
createOptimizationResultVersion(input) {
return Promise.resolve(createOptimizationResultVersion(dbPath, input));
},
getOptimizationResultVersion(versionId) {
return Promise.resolve(getOptimizationResultVersion(dbPath, versionId));
},
findResultVersionForArticleRevision(jobId, revision) {
return Promise.resolve(findResultVersionForArticleRevision(dbPath, jobId, revision));
},
};
}
+120
View File
@@ -4,6 +4,9 @@ import {
articleInputSchema,
confirmedFactCardSchema,
candidateFactCardSchema,
copyOptimizationRequestSchema,
copyOptimizationResultSchema,
optimizationFactCardSchema,
optimizedArticleSchema,
qaReportSchema,
} from "../validation";
@@ -25,6 +28,123 @@ describe("domain validation", () => {
expect(parsed.platform).toBe("official_site");
});
it("accepts article input with an empty optional title", () => {
const parsed = articleInputSchema.parse({
title: " ",
body: "完整文章正文可以直接粘贴在这里。",
images: [],
platform: "official_site",
user_instructions: "",
});
expect(parsed.title).toBe("");
expect(parsed.body).toBe("完整文章正文可以直接粘贴在这里。");
});
it("still rejects article input with an empty body", () => {
expect(() =>
articleInputSchema.parse({
title: "",
body: " ",
images: [],
platform: "official_site",
user_instructions: "",
}),
).toThrow();
});
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: "保留口语感",
publish_target: "未指定",
});
});
it("validates human-copy publish target", () => {
expect(
copyOptimizationRequestSchema.parse({
source_text: "这是一段普通文案。",
goal: "",
intensity: "light",
user_instructions: "",
publish_target: "朋友圈",
}),
).toMatchObject({
goal: "保留原意,减少 AI 味",
publish_target: "朋友圈",
});
});
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");
});
it("accepts an unconfirmed optimization fact card with unresolved items", () => {
const parsed = optimizationFactCardSchema.parse({
company_full_name: "",
company_short_names: ["示例科技"],
brand_names: [],
product_names: ["GEO内容优化平台"],
target_industry: "",
target_audience: "市场团队",
experience_years: "",
core_claims: ["提供GEO内容优化服务"],
forbidden_claims: [],
image_topics: [],
uncertain_items: ["客户案例需要确认"],
confirmed_by_user: false,
});
expect(parsed.company_full_name).toBe("");
expect(parsed.experience_years).toBeNull();
expect(parsed.confirmed_by_user).toBe(false);
expect(parsed.is_ready_for_optimization).toBe(false);
expect(parsed.uncertain_items).toEqual(["客户案例需要确认"]);
});
it("marks a fact card with unresolved uncertain items as not ready for optimization", () => {
const parsed = candidateFactCardSchema.parse({
company_full_name: "Example Technology Co., Ltd.",
+49 -1
View File
@@ -46,7 +46,11 @@ export interface CandidateFactCard {
is_ready_for_optimization: boolean;
}
export interface ConfirmedFactCard extends CandidateFactCard {
export interface OptimizationFactCard extends CandidateFactCard {
confirmed_by_user: boolean;
}
export interface ConfirmedFactCard extends OptimizationFactCard {
confirmed_by_user: true;
is_ready_for_optimization: true;
}
@@ -82,3 +86,47 @@ export interface QaReport {
overall_status: CheckStatus;
checks: QaCheck[];
}
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;
publish_target: 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[];
}
+72 -1
View File
@@ -5,7 +5,12 @@ import type {
CandidateFactCard,
CheckStatus,
ConfirmedFactCard,
CopyAiTasteCheck,
CopyChangeNote,
CopyOptimizationRequest,
CopyOptimizationResult,
ImageInput,
OptimizationFactCard,
OptimizedArticle,
PublishPlatform,
QaCheck,
@@ -110,7 +115,7 @@ export const imageInputSchema = z.object({
}) satisfies z.ZodType<ImageInput>;
export const articleInputSchema = z.object({
title: z.string().trim().min(1),
title: z.string().trim().default(""),
body: z.string().trim().min(1),
images: z.array(imageInputSchema).default([]),
platform: publishPlatformSchema,
@@ -280,6 +285,17 @@ export const candidateFactCardSchema = factCardBaseSchema
is_ready_for_optimization: card.uncertain_items.length === 0,
})) satisfies z.ZodType<CandidateFactCard>;
export const optimizationFactCardSchema = factCardBaseSchema
.extend({
confirmed_by_user: z.boolean().optional().default(false),
is_ready_for_optimization: z.boolean().optional(),
})
.transform((card) => ({
...card,
confirmed_by_user: card.confirmed_by_user,
is_ready_for_optimization: card.uncertain_items.length === 0,
})) satisfies z.ZodType<OptimizationFactCard>;
export const confirmedFactCardSchema = factCardBaseSchema
.extend({
company_full_name: z.string().trim().min(1),
@@ -419,3 +435,58 @@ export const qaReportSchema = z.object({
overall_status: checkStatusSchema,
checks: z.array(qaCheckSchema).default([]),
}) satisfies z.ZodType<QaReport>;
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(""),
publish_target: z
.preprocess((value) => {
if (typeof value !== "string") return value;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : "未指定";
}, z.string().trim().min(1))
.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>;
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { createLlmAuditSummary, hashContent } from "../audit";
describe("LLM audit summary", () => {
it("hashes content deterministically", async () => {
await expect(hashContent("abc")).resolves.toBe(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
);
});
it("does not retain raw prompt or response", async () => {
const summary = await createLlmAuditSummary({
provider: "deepseek",
model: "deepseek-v4-pro",
task: "renwei_copy_optimizer",
duration_ms: 12,
schema_valid: true,
prompt: "完整 prompt 不应长期保存",
output: "完整 response 不应长期保存",
error_summary: null,
});
expect(summary).toMatchObject({
provider: "deepseek",
model: "deepseek-v4-pro",
task: "renwei_copy_optimizer",
duration_ms: 12,
schema_valid: true,
error_summary: null,
});
expect(JSON.stringify(summary)).not.toContain("完整 prompt");
expect(JSON.stringify(summary)).not.toContain("完整 response");
expect(summary.input_hash).toHaveLength(64);
expect(summary.output_hash).toHaveLength(64);
});
});
+26
View File
@@ -41,6 +41,32 @@ describe("generateValidatedJson", () => {
expect(result).toEqual({ value: "from-llm" });
});
it("emits an audit summary without raw prompt or response", async () => {
const audits: unknown[] = [];
process.env.LLM_PROVIDER = "deepseek";
process.env.DEEPSEEK_API_KEY = "test-key";
client.setGenerateJsonForValidation(async () => ({ value: "ok" }));
const result = await client.generateValidatedJson({
schema: z.object({ value: z.string() }),
prompt: "raw prompt",
task: "unknown",
onAuditSummary: (summary) => audits.push(summary),
});
expect(result).toEqual({ value: "ok" });
expect(JSON.stringify(audits)).not.toContain("raw prompt");
expect(JSON.stringify(audits)).not.toContain("ok");
expect(audits).toEqual([
expect.objectContaining({
task: "unknown",
schema_valid: true,
input_hash: expect.any(String),
output_hash: expect.any(String),
}),
]);
});
it("throws clearly when the model response fails schema validation", async () => {
process.env.LLM_PROVIDER = "deepseek";
process.env.DEEPSEEK_API_KEY = "test-key";
+37
View File
@@ -4,10 +4,12 @@ import {
ARTICLE_OPTIMIZER_SYSTEM_PROMPT,
FACT_EXTRACTOR_SYSTEM_PROMPT,
QUALITY_INSPECTOR_SYSTEM_PROMPT,
RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT,
TARGETED_REWRITER_SYSTEM_PROMPT,
buildArticleOptimizerPrompt,
buildFactExtractorPrompt,
buildQualityInspectorPrompt,
buildRenweiCopyOptimizationPrompt,
buildTargetedRewritePrompt,
} from "../prompts";
@@ -168,4 +170,39 @@ describe("LLM prompt builders", () => {
expect(prompt).toContain("body_quality");
expect(prompt).toContain("不得新增事实");
});
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");
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { LlmProviderStatus, LlmTaskName } from "./client";
export interface LlmAuditSummary {
provider: LlmProviderStatus["provider"];
model: string;
task: LlmTaskName;
duration_ms: number;
schema_valid: boolean;
error_summary: string | null;
input_hash: string;
output_hash: string | null;
}
export async function hashContent(value: string) {
const data = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export async function createLlmAuditSummary({
provider,
model,
task,
duration_ms,
schema_valid,
prompt,
output,
error_summary,
}: {
provider: LlmProviderStatus["provider"];
model: string;
task: LlmTaskName;
duration_ms: number;
schema_valid: boolean;
prompt: string;
output: string | null;
error_summary: string | null;
}): Promise<LlmAuditSummary> {
return {
provider,
model,
task,
duration_ms,
schema_valid,
error_summary,
input_hash: await hashContent(prompt),
output_hash: output == null ? null : await hashContent(output),
};
}
+48 -4
View File
@@ -1,12 +1,15 @@
import OpenAI from "openai";
import type { z } from "zod";
import { createLlmAuditSummary, type LlmAuditSummary } from "./audit";
export type LlmTaskName =
| "unknown"
| "fact_extractor"
| "article_optimizer"
| "quality_inspector"
| "targeted_rewriter";
| "targeted_rewriter"
| "renwei_copy_optimizer";
export interface GenerateInput {
system?: string;
@@ -14,6 +17,7 @@ export interface GenerateInput {
model?: string;
temperature?: number;
task?: LlmTaskName;
onAuditSummary?: (summary: LlmAuditSummary) => void | Promise<void>;
}
export interface GenerateValidatedJsonInput<T> extends GenerateInput {
@@ -227,9 +231,33 @@ export async function generateValidatedJson<T>({
const usesDefaultGenerator = generateJsonForValidation === generateJson;
const status = getLlmProviderStatus();
const startedAt = Date.now();
const effectiveModel = input.model ?? status.model;
const emitAudit = async ({
schemaValid,
output,
errorSummary,
}: {
schemaValid: boolean;
output: unknown | null;
errorSummary: string | null;
}) => {
if (!input.onAuditSummary) return;
await input.onAuditSummary(
await createLlmAuditSummary({
provider: status.provider,
model: effectiveModel,
task,
duration_ms: Date.now() - startedAt,
schema_valid: schemaValid,
prompt: input.prompt,
output: output == null ? null : stringifyForLog(output),
error_summary: errorSummary,
}),
);
};
if (!usesDefaultGenerator) {
console.info(
`[llm:start] provider=${status.provider} model=${input.model ?? status.model} task=${task}`,
`[llm:start] provider=${status.provider} model=${effectiveModel} task=${task}`,
);
}
@@ -242,14 +270,25 @@ export async function generateValidatedJson<T>({
}
const parsed = schema.safeParse(generated);
if (parsed.success) {
await emitAudit({
schemaValid: true,
output: parsed.data,
errorSummary: null,
});
console.info(`[llm:validated] task=${task} ok=true`);
return parsed.data;
}
const zodSummary = summarizeZodError(parsed.error);
console.warn(
`[llm:validated] task=${task} ok=false zod_error=${quoteLogValue(summarizeZodError(parsed.error))}`,
`[llm:validated] task=${task} ok=false zod_error=${quoteLogValue(zodSummary)}`,
);
await emitAudit({
schemaValid: false,
output: generated,
errorSummary: zodSummary,
});
throw new LlmValidationError(
`LLM response failed schema validation: ${summarizeZodError(parsed.error)}`,
`LLM response failed schema validation: ${zodSummary}`,
task,
);
} catch (error) {
@@ -258,6 +297,11 @@ export async function generateValidatedJson<T>({
}
console.info(`[llm:validated] task=${task} ok=false reason=provider_error`);
const message = error instanceof Error ? error.message : String(error);
await emitAudit({
schemaValid: false,
output: null,
errorSummary: message,
});
console.warn(
usesDefaultGenerator
? `[llm:error] task=${task} message=${quoteLogValue(message)}`
+105 -4
View File
@@ -1,6 +1,7 @@
import type {
ArticleInput,
ConfirmedFactCard,
CopyOptimizationRequest,
OptimizationFactCard,
OptimizedArticle,
PublishPlatform,
QaCheck,
@@ -97,6 +98,44 @@ const QA_OUTPUT_CONTRACT = [
),
].join("\n");
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");
function formatPlatformGuidance(selected: PublishPlatform) {
return [
"平台模板选项:",
@@ -135,6 +174,29 @@ export const TARGETED_REWRITER_SYSTEM_PROMPT = [
JSON_ONLY_PROMPT,
].join(" ");
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 buildFactExtractorPrompt(input: ArticleInput) {
return [
"Return a CandidateFactCard JSON object with these exact keys:",
@@ -157,7 +219,7 @@ export function buildFactExtractorPrompt(input: ArticleInput) {
export function buildArticleOptimizerPrompt(
input: ArticleInput,
factCard: ConfirmedFactCard,
factCard: OptimizationFactCard,
) {
return [
"Return an OptimizedArticle JSON object with these exact keys:",
@@ -187,7 +249,7 @@ export function buildArticleOptimizerPrompt(
export function buildQualityInspectorPrompt(input: {
article: OptimizedArticle;
factCard: ConfirmedFactCard;
factCard: OptimizationFactCard;
platform: PublishPlatform;
deterministicChecks: QaCheck[];
}) {
@@ -218,7 +280,7 @@ export function buildQualityInspectorPrompt(input: {
export function buildTargetedRewritePrompt(input: {
article: OptimizedArticle;
factCard: ConfirmedFactCard;
factCard: OptimizationFactCard;
failedChecks: QaCheck[];
}) {
return [
@@ -247,3 +309,42 @@ export function buildTargetedRewritePrompt(input: {
JSON.stringify(input.failedChecks, null, 2),
].join("\n");
}
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");
}
@@ -17,6 +17,7 @@ vi.mock("../../llm/client", async () => {
import { extractCandidateFactCard } from "../fact-extractor";
import { optimizeArticle } from "../article-optimizer";
import { inspectQualityWithLlm } from "../quality-inspector";
import { optimizeRenweiCopy } from "../renwei-copy-optimizer";
import { rewriteFailedSections } from "../targeted-rewriter";
describe("LLM workflow integration", () => {
@@ -356,4 +357,43 @@ describe("LLM workflow integration", () => {
expect(hallucinationCheck?.status).toBe("warn");
expect(report.overall_status).toBe("warn");
});
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,
}),
);
});
});
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
encodeOptimizationStreamEvent,
parseOptimizationStreamChunk,
type OptimizationStreamEvent,
} from "../stream-events";
describe("optimization stream events", () => {
it("encodes each event as one JSON line", () => {
const event: OptimizationStreamEvent = {
type: "draft_started",
job_id: "job_123",
message: "正在生成优化草稿",
};
expect(encodeOptimizationStreamEvent(event)).toBe(
'{"type":"draft_started","job_id":"job_123","message":"正在生成优化草稿"}\n',
);
});
it("parses chunked NDJSON while preserving incomplete lines", () => {
const first = parseOptimizationStreamChunk(
"",
'{"type":"job_created","job":{"id":"job_',
);
expect(first.events).toEqual([]);
expect(first.remainder).toBe('{"type":"job_created","job":{"id":"job_');
const second = parseOptimizationStreamChunk(
first.remainder,
'123"}}\n{"type":"draft_started","job_id":"job_123","message":"正在生成"}\n{"type":"qa_started"',
);
expect(second.events).toEqual([
{ type: "job_created", job: { id: "job_123" } },
{ type: "draft_started", job_id: "job_123", message: "正在生成" },
]);
expect(second.remainder).toBe('{"type":"qa_started"');
});
});
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import type { OptimizationStreamEvent } from "../stream-events";
import { runStreamingOptimizationWorkflow } from "../streaming-optimizer";
const workflowMocks = vi.hoisted(() => ({
optimizeArticle: vi.fn(),
inspectQualityWithLlm: vi.fn(),
rewriteFailedSections: vi.fn(),
}));
vi.mock("../article-optimizer", () => ({
optimizeArticle: workflowMocks.optimizeArticle,
}));
vi.mock("../quality-inspector", () => ({
inspectQualityWithLlm: workflowMocks.inspectQualityWithLlm,
}));
vi.mock("../targeted-rewriter", () => ({
rewriteFailedSections: workflowMocks.rewriteFailedSections,
}));
const input = {
title: "",
body: "示例科技提供GEO内容优化服务。",
images: [],
platform: "official_site" as const,
user_instructions: "",
};
const factCard = {
company_full_name: "",
company_short_names: ["示例科技"],
brand_names: [],
product_names: ["GEO内容优化平台"],
target_industry: "GEO内容优化",
target_audience: "市场团队",
experience_years: null,
core_claims: ["提供GEO内容优化服务"],
forbidden_claims: [],
image_topics: [],
uncertain_items: ["公司全称需要确认"],
is_ready_for_optimization: false,
confirmed_by_user: false,
};
const draftArticle = {
title: "示例科技 GEO 内容优化方案",
summary: "面向市场团队的GEO内容优化说明。",
body_markdown: "## 服务能力\n示例科技提供GEO内容优化服务。",
image_suggestions: [],
changed_sections: ["标题", "正文"],
requires_user_confirmation: [],
};
const failCheck = {
rule_id: "body_quality" as const,
status: "fail" as const,
evidence: "句子不够顺。",
reason: "正文需要润色。",
suggested_fix: "润色正文。",
target_agent: "body",
};
describe("runStreamingOptimizationWorkflow", () => {
it("emits draft and final QA events when no rewrite is needed", async () => {
workflowMocks.optimizeArticle.mockResolvedValueOnce(draftArticle);
workflowMocks.inspectQualityWithLlm.mockResolvedValueOnce({
overall_status: "pass",
checks: [],
});
const events: OptimizationStreamEvent[] = [];
const result = await runStreamingOptimizationWorkflow({
jobId: "job_stream",
input,
factCard,
onEvent: (event) => events.push(event),
});
expect(events.map((event) => event.type)).toEqual([
"draft_started",
"draft_ready",
"qa_started",
"qa_ready",
]);
expect(result.article.title).toBe("示例科技 GEO 内容优化方案");
expect(result.qaReport.overall_status).toBe("pass");
expect(result.rewriteRounds).toBe(0);
});
it("emits rewrite events when QA fails", async () => {
workflowMocks.optimizeArticle.mockResolvedValueOnce(draftArticle);
workflowMocks.inspectQualityWithLlm
.mockResolvedValueOnce({ overall_status: "fail", checks: [failCheck] })
.mockResolvedValueOnce({ overall_status: "pass", checks: [] });
workflowMocks.rewriteFailedSections.mockResolvedValueOnce({
...draftArticle,
body_markdown: "## 服务能力\n示例科技提供清晰的GEO内容优化服务。",
});
const events: OptimizationStreamEvent[] = [];
const result = await runStreamingOptimizationWorkflow({
jobId: "job_stream",
input,
factCard,
onEvent: (event) => events.push(event),
});
expect(events.map((event) => event.type)).toEqual([
"draft_started",
"draft_ready",
"qa_started",
"qa_ready",
"rewrite_started",
"rewrite_ready",
"qa_started",
"qa_ready",
]);
expect(result.article.body_markdown).toContain("清晰的GEO内容优化服务");
expect(result.rewriteRounds).toBe(1);
});
});
+45 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ConfirmedFactCard } from "../../domain/types";
import type { ConfirmedFactCard, OptimizationFactCard } from "../../domain/types";
import { normalizeInput } from "../input-normalizer";
import { inspectQuality } from "../quality-inspector";
@@ -41,6 +41,19 @@ describe("workflow nodes", () => {
]);
});
it("normalizes missing article titles to an empty string", () => {
const normalized = normalizeInput({
body: "只有正文也可以开始优化。",
image_lines: "",
platform: "official_site",
user_instructions: "",
});
expect(normalized.article_draft.title).toBe("");
expect(normalized.articleInput.title).toBe("");
expect(normalized.articleInput.body).toBe("只有正文也可以开始优化。");
});
it("returns the 10 required quality checks", () => {
const report = inspectQuality({
article: {
@@ -126,6 +139,37 @@ describe("workflow nodes", () => {
expect(report.overall_status).toBe("fail");
});
it("warns without blocking QA when the editable fact card has no company full name", () => {
const factCard: OptimizationFactCard = {
...confirmedFactCard,
company_full_name: "",
uncertain_items: ["公司全称需要确认"],
is_ready_for_optimization: false,
confirmed_by_user: false,
};
const report = inspectQuality({
article: {
title: "示例科技 GEO 内容优化方案",
summary: "示例科技提供GEO内容优化服务。",
body_markdown: "## 服务能力\n示例科技提供GEO内容优化服务。",
image_suggestions: [],
changed_sections: [],
requires_user_confirmation: [],
},
factCard,
platform: "official_site",
sourceImages: [],
});
const companyCheck = report.checks.find(
(check) => check.rule_id === "company_name_integrity",
);
expect(companyCheck?.status).toBe("warn");
expect(companyCheck?.evidence).toContain("公司全称");
expect(report.overall_status).toBe("warn");
});
it("does not flag numbers already present in confirmed fact card claims", () => {
const report = inspectQuality({
article: {
+6 -3
View File
@@ -1,10 +1,10 @@
import type {
ArticleInput,
ConfirmedFactCard,
OptimizationFactCard,
OptimizedArticle,
} from "../domain/types";
import { optimizedArticleSchema } from "../domain/validation";
import { generateValidatedJson } from "../llm/client";
import { generateValidatedJson, type GenerateInput } from "../llm/client";
import {
ARTICLE_OPTIMIZER_SYSTEM_PROMPT,
buildArticleOptimizerPrompt,
@@ -12,12 +12,14 @@ import {
export interface OptimizeArticleInput {
input: ArticleInput;
factCard: ConfirmedFactCard;
factCard: OptimizationFactCard;
onAuditSummary?: GenerateInput["onAuditSummary"];
}
export async function optimizeArticle({
input,
factCard,
onAuditSummary,
}: OptimizeArticleInput): Promise<OptimizedArticle> {
const llmArticle = await generateValidatedJson({
schema: optimizedArticleSchema,
@@ -25,6 +27,7 @@ export async function optimizeArticle({
prompt: buildArticleOptimizerPrompt(input, factCard),
temperature: 0.2,
task: "article_optimizer",
onAuditSummary,
});
return optimizedArticleSchema.parse({
+3 -1
View File
@@ -1,6 +1,6 @@
import type { ArticleInput, CandidateFactCard } from "../domain/types";
import { candidateFactCardSchema } from "../domain/validation";
import { generateValidatedJson } from "../llm/client";
import { generateValidatedJson, type GenerateInput } from "../llm/client";
import {
FACT_EXTRACTOR_SYSTEM_PROMPT,
buildFactExtractorPrompt,
@@ -8,6 +8,7 @@ import {
export async function extractCandidateFactCard(
input: ArticleInput,
options: { onAuditSummary?: GenerateInput["onAuditSummary"] } = {},
): Promise<CandidateFactCard> {
return generateValidatedJson({
schema: candidateFactCardSchema,
@@ -15,5 +16,6 @@ export async function extractCandidateFactCard(
prompt: buildFactExtractorPrompt(input),
temperature: 0.1,
task: "fact_extractor",
onAuditSummary: options.onAuditSummary,
});
}
+2 -2
View File
@@ -2,7 +2,7 @@ import type { ArticleInput, ImageInput, PublishPlatform } from "../domain/types"
import { articleInputSchema } from "../domain/validation";
export interface RawArticleInput {
title: string;
title?: string;
body: string;
image_lines?: string;
images?: ImageInput[];
@@ -23,7 +23,7 @@ export function normalizeInput(input: RawArticleInput) {
}));
const articleInput = articleInputSchema.parse({
title: input.title,
title: input.title ?? "",
body: input.body,
images,
platform: input.platform,
+9 -4
View File
@@ -1,4 +1,5 @@
import type { ArticleInput, ConfirmedFactCard } from "../domain/types";
import type { ArticleInput, OptimizationFactCard } from "../domain/types";
import type { GenerateInput } from "../llm/client";
import { optimizeArticle } from "./article-optimizer";
import { inspectQualityWithLlm } from "./quality-inspector";
@@ -6,8 +7,9 @@ import { rewriteFailedSections } from "./targeted-rewriter";
export interface RunOptimizationWorkflowInput {
input: ArticleInput;
factCard: ConfirmedFactCard;
factCard: OptimizationFactCard;
onProgress?: (event: WorkflowProgressEvent) => void | Promise<void>;
onAuditSummary?: GenerateInput["onAuditSummary"];
}
export interface WorkflowTimingStep {
@@ -68,13 +70,14 @@ export async function runOptimizationWorkflow({
input,
factCard,
onProgress,
onAuditSummary,
}: RunOptimizationWorkflowInput) {
const startedAt = Date.now();
const timingSteps: WorkflowTimingStep[] = [];
let article = await timedStep(
"生成优化稿",
timingSteps,
() => optimizeArticle({ input, factCard }),
() => optimizeArticle({ input, factCard, onAuditSummary }),
onProgress,
);
let qaReport = await timedStep(
@@ -86,6 +89,7 @@ export async function runOptimizationWorkflow({
factCard,
platform: input.platform,
sourceImages: input.images,
onAuditSummary,
}),
onProgress,
);
@@ -97,7 +101,7 @@ export async function runOptimizationWorkflow({
article = await timedStep(
`定向修复第 ${nextRound}`,
timingSteps,
() => rewriteFailedSections({ article, factCard, failedChecks }),
() => rewriteFailedSections({ article, factCard, failedChecks, onAuditSummary }),
onProgress,
);
rewriteRounds = nextRound;
@@ -110,6 +114,7 @@ export async function runOptimizationWorkflow({
factCard,
platform: input.platform,
sourceImages: input.images,
onAuditSummary,
}),
onProgress,
);
+21 -7
View File
@@ -1,6 +1,6 @@
import type {
CheckStatus,
ConfirmedFactCard,
OptimizationFactCard,
ImageInput,
OptimizedArticle,
PublishPlatform,
@@ -9,7 +9,7 @@ import type {
QualityRuleId,
} from "../domain/types";
import { qaCheckSchema, qaReportSchema } from "../domain/validation";
import { generateValidatedJson } from "../llm/client";
import { generateValidatedJson, type GenerateInput } from "../llm/client";
import {
QUALITY_INSPECTOR_SYSTEM_PROMPT,
buildQualityInspectorPrompt,
@@ -40,9 +40,10 @@ const llmQaPatchSchema = z.object({
export interface InspectQualityInput {
article: OptimizedArticle;
factCard: ConfirmedFactCard;
factCard: OptimizationFactCard;
platform: PublishPlatform;
sourceImages: ImageInput[];
onAuditSummary?: GenerateInput["onAuditSummary"];
}
export function inspectQuality(input: InspectQualityInput): QaReport {
@@ -71,6 +72,7 @@ export async function inspectQualityWithLlm(
}),
temperature: 0.1,
task: "quality_inspector",
onAuditSummary: input.onAuditSummary,
});
const patchedChecks = deterministicReport.checks.map((deterministicCheck) => {
@@ -169,11 +171,23 @@ function inspectRule(
}
if (ruleId === "company_name_integrity") {
const hasFullName = combined.includes(factCard.company_full_name);
const companyFullName = factCard.company_full_name.trim();
if (companyFullName.length === 0) {
return check(
ruleId,
"warn",
"事实卡尚未提供公司全称。",
"无法执行公司全称一致性硬性检查,因为事实卡中的公司全称仍待确认。",
"补充公司全称,或确认当前文案可以使用简称。",
"fact_card",
);
}
const hasFullName = combined.includes(companyFullName);
return check(
ruleId,
hasFullName ? "pass" : "fail",
hasFullName ? factCard.company_full_name : article.body_markdown,
hasFullName ? companyFullName : article.body_markdown,
hasFullName
? "文章中包含事实卡确认的公司全称。"
: "文章缺少事实卡确认的公司全称,或使用了不完整简称。",
@@ -262,7 +276,7 @@ function check(
return { rule_id, status, evidence, reason, suggested_fix, target_agent };
}
function findUnsupportedNumbers(text: string, factCard: ConfirmedFactCard) {
function findUnsupportedNumbers(text: string, factCard: OptimizationFactCard) {
const allowed = new Set(
[
factCard.experience_years,
@@ -278,7 +292,7 @@ function findUnsupportedNumbers(text: string, factCard: ConfirmedFactCard) {
.filter((number) => !allowed.has(number));
}
function extractNumbersFromFactCard(factCard: ConfirmedFactCard) {
function extractNumbersFromFactCard(factCard: OptimizationFactCard) {
return [
factCard.company_full_name,
...factCard.company_short_names,
+24
View File
@@ -0,0 +1,24 @@
import type {
CopyOptimizationRequest,
CopyOptimizationResult,
} from "../domain/types";
import { copyOptimizationResultSchema } from "../domain/validation";
import { generateValidatedJson, type GenerateInput } from "../llm/client";
import {
RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT,
buildRenweiCopyOptimizationPrompt,
} from "../llm/prompts";
export async function optimizeRenweiCopy(
input: CopyOptimizationRequest,
options: { onAuditSummary?: GenerateInput["onAuditSummary"] } = {},
): Promise<CopyOptimizationResult> {
return generateValidatedJson({
schema: copyOptimizationResultSchema,
system: RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT,
prompt: buildRenweiCopyOptimizationPrompt(input),
temperature: 0.2,
task: "renwei_copy_optimizer",
onAuditSummary: options.onAuditSummary,
});
}
+75
View File
@@ -0,0 +1,75 @@
import type {
OptimizationFactCard,
OptimizedArticle,
QaReport,
} from "../domain/types";
export type OptimizationStreamStage =
| "input"
| "job"
| "fact_card"
| "draft"
| "qa"
| "rewrite"
| "final";
export type OptimizationStreamEvent =
| {
type: "job_created";
job: { id: string };
case?: { id: string; case_type: "article" };
}
| {
type: "fact_card_ready";
job_id: string;
fact_card: OptimizationFactCard;
}
| { type: "draft_started"; job_id: string; message: string }
| { type: "draft_ready"; job_id: string; article: OptimizedArticle }
| { type: "qa_started"; job_id: string; message: string }
| { type: "qa_ready"; job_id: string; qa_report: QaReport }
| { type: "rewrite_started"; job_id: string; round: number }
| {
type: "rewrite_ready";
job_id: string;
round: number;
article: OptimizedArticle;
}
| {
type: "final_ready";
job_id: string;
case?: { id: string; case_type: "article" };
result_version?: { id: string; version: number };
optimized_article: OptimizedArticle;
qa_report: QaReport;
export_paths: Record<string, string>;
}
| {
type: "failed";
job_id?: string;
case?: { id: string; case_type: "article" };
result_version?: { id: string; version: number };
stage: OptimizationStreamStage;
error: string;
};
export function encodeOptimizationStreamEvent(
event: OptimizationStreamEvent,
) {
return `${JSON.stringify(event)}\n`;
}
export function parseOptimizationStreamChunk(
previousRemainder: string,
chunk: string,
) {
const text = previousRemainder + chunk;
const lines = text.split(/\n/);
const remainder = lines.pop() ?? "";
const events = lines
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as OptimizationStreamEvent);
return { events, remainder };
}
+138
View File
@@ -0,0 +1,138 @@
import type { ArticleInput, OptimizationFactCard } from "../domain/types";
import type { ProcessSummaryStep } from "../cases/types";
import { createProcessStep } from "../cases/summaries";
import type { GenerateInput } from "../llm/client";
import { optimizeArticle } from "./article-optimizer";
import { inspectQualityWithLlm } from "./quality-inspector";
import type { OptimizationStreamEvent } from "./stream-events";
import { rewriteFailedSections } from "./targeted-rewriter";
export interface RunStreamingOptimizationWorkflowInput {
jobId: string;
input: ArticleInput;
factCard: OptimizationFactCard;
onEvent: (event: OptimizationStreamEvent) => void | Promise<void>;
onAuditSummary?: GenerateInput["onAuditSummary"];
}
export async function runStreamingOptimizationWorkflow({
jobId,
input,
factCard,
onEvent,
onAuditSummary,
}: RunStreamingOptimizationWorkflowInput) {
const processSummary: ProcessSummaryStep[] = [];
await onEvent({
type: "draft_started",
job_id: jobId,
message: "正在生成优化草稿",
});
let stageStartedAt = Date.now();
let article = await optimizeArticle({ input, factCard, onAuditSummary });
processSummary.push(
createProcessStep({
stage: "draft",
startedAt: stageStartedAt,
endedAt: Date.now(),
status: "success",
producedResultVersion: false,
}),
);
await onEvent({ type: "draft_ready", job_id: jobId, article });
await onEvent({
type: "qa_started",
job_id: jobId,
message: "正在检查质量",
});
stageStartedAt = Date.now();
let qaReport = await inspectQualityWithLlm({
article,
factCard,
platform: input.platform,
sourceImages: input.images,
onAuditSummary,
});
processSummary.push(
createProcessStep({
stage: "qa",
startedAt: stageStartedAt,
endedAt: Date.now(),
status: "success",
producedResultVersion: false,
}),
);
await onEvent({ type: "qa_ready", job_id: jobId, qa_report: qaReport });
let rewriteRounds = 0;
while (qaReport.overall_status === "fail" && rewriteRounds < 2) {
const nextRound = rewriteRounds + 1;
const failedChecks = qaReport.checks.filter((check) => check.status === "fail");
await onEvent({
type: "rewrite_started",
job_id: jobId,
round: nextRound,
});
stageStartedAt = Date.now();
article = await rewriteFailedSections({
article,
factCard,
failedChecks,
onAuditSummary,
});
rewriteRounds = nextRound;
processSummary.push(
createProcessStep({
stage: "rewrite",
startedAt: stageStartedAt,
endedAt: Date.now(),
status: "success",
rewriteRound: nextRound,
producedResultVersion: false,
}),
);
await onEvent({
type: "rewrite_ready",
job_id: jobId,
round: nextRound,
article,
});
await onEvent({
type: "qa_started",
job_id: jobId,
message: `正在复检第 ${nextRound} 轮修复`,
});
stageStartedAt = Date.now();
qaReport = await inspectQualityWithLlm({
article,
factCard,
platform: input.platform,
sourceImages: input.images,
onAuditSummary,
});
processSummary.push(
createProcessStep({
stage: "qa",
startedAt: stageStartedAt,
endedAt: Date.now(),
status: "success",
rewriteRound: nextRound,
producedResultVersion: false,
}),
);
await onEvent({ type: "qa_ready", job_id: jobId, qa_report: qaReport });
}
return {
article,
qaReport,
rewriteRounds,
stoppedAfterMaxRewrites:
qaReport.overall_status === "fail" && rewriteRounds >= 2,
processSummary,
};
}
+6 -3
View File
@@ -1,6 +1,6 @@
import type { ConfirmedFactCard, OptimizedArticle, QaCheck } from "../domain/types";
import type { OptimizationFactCard, OptimizedArticle, QaCheck } from "../domain/types";
import { optimizedArticleSchema } from "../domain/validation";
import { generateValidatedJson } from "../llm/client";
import { generateValidatedJson, type GenerateInput } from "../llm/client";
import {
TARGETED_REWRITER_SYSTEM_PROMPT,
buildTargetedRewritePrompt,
@@ -8,14 +8,16 @@ import {
export interface RewriteFailedSectionsInput {
article: OptimizedArticle;
factCard: ConfirmedFactCard;
factCard: OptimizationFactCard;
failedChecks: QaCheck[];
onAuditSummary?: GenerateInput["onAuditSummary"];
}
export async function rewriteFailedSections({
article,
factCard,
failedChecks,
onAuditSummary,
}: RewriteFailedSectionsInput): Promise<OptimizedArticle> {
const llmArticle = await generateValidatedJson({
schema: optimizedArticleSchema,
@@ -23,6 +25,7 @@ export async function rewriteFailedSections({
prompt: buildTargetedRewritePrompt({ article, factCard, failedChecks }),
temperature: 0.15,
task: "targeted_rewriter",
onAuditSummary,
});
return optimizedArticleSchema.parse({
+218
View File
@@ -0,0 +1,218 @@
import { expect, test } from "@playwright/test";
test("案例库列表和人味文案详情可查看", async ({ page }) => {
await mockAuthenticatedSession(page, true);
await page.route("**/api/cases", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
cases: [
{
id: "case_copy_1",
case_type: "human_copy",
title: "人味文案优化:朋友圈",
summary: "原始文案摘要",
status: "optimized",
customer_name: "客户A",
brand_name: "品牌B",
project_tags: ["朋友圈"],
notes: "",
publish_target: "朋友圈",
source_excerpt: "原始文案摘要",
result_excerpt: "优化后文案摘要",
latest_result_version_id: "ver_copy_1",
latest_version_number: 1,
last_error_stage: null,
last_error_summary: null,
archived_at: null,
created_at: "2026-07-08T10:00:00.000Z",
updated_at: "2026-07-08T10:05:00.000Z",
},
],
}),
});
});
await page.route("**/api/cases/case_copy_1", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
case: {
id: "case_copy_1",
case_type: "human_copy",
title: "人味文案优化:朋友圈",
summary: "原始文案摘要",
status: "optimized",
customer_name: "客户A",
brand_name: "品牌B",
project_tags: ["朋友圈"],
notes: "",
publish_target: "朋友圈",
source_excerpt: "原始文案摘要",
result_excerpt: "优化后文案摘要",
latest_result_version_id: "ver_copy_1",
latest_version_number: 1,
last_error_stage: null,
last_error_summary: null,
archived_at: null,
created_at: "2026-07-08T10:00:00.000Z",
updated_at: "2026-07-08T10:05:00.000Z",
},
input: {
case_id: "case_copy_1",
case_type: "human_copy",
article_job_id: null,
payload: {
source_text: "原始文案",
goal: "自然一点",
intensity: "light",
user_instructions: "",
publish_target: "朋友圈",
},
created_at: "2026-07-08T10:00:00.000Z",
updated_at: "2026-07-08T10:00:00.000Z",
},
versions: [
{
id: "ver_copy_1",
case_id: "case_copy_1",
case_type: "human_copy",
version: 1,
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "优化后文案摘要",
payload: {
optimized_text: "优化后文案",
change_notes: [
{
original: "原始文案",
revised: "优化后文案",
reason: "减少书面腔。",
confidence: "confident",
revertible: false,
},
],
ai_taste_checks: [
{
rule_id: "promotion_tone",
status: "pass",
evidence: "没有新增宣传腔。",
suggestion: "",
},
],
warnings: [],
},
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
created_at: "2026-07-08T10:05:00.000Z",
},
],
}),
});
});
await page.route(
"**/api/cases/case_copy_1/versions/ver_copy_1/publications",
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ publications: [] }),
});
},
);
await page.goto("/cases");
await expect(page.getByRole("heading", { name: "案例库" })).toBeVisible();
await expect(page.getByText("人味文案优化:朋友圈")).toBeVisible();
await page.getByRole("link", { name: "人味文案优化:朋友圈" }).click();
await expect(
page.locator(".copy-result").filter({ hasText: "优化后文案" }),
).toBeVisible();
await expect(page.getByText("AI 味检查")).toBeVisible();
await expect(page.getByText("发布表现")).toBeVisible();
});
test("案例库可以通过访问密钥建立浏览器登录态", async ({ page }) => {
let authenticated = false;
await page.route("**/api/auth/session", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ authenticated }),
});
return;
}
if (request.method() === "POST") {
const payload = request.postDataJSON() as { api_access_key?: string };
authenticated = payload.api_access_key === "local-dev-key";
await route.fulfill({
status: authenticated ? 200 : 401,
contentType: "application/json",
body: JSON.stringify(
authenticated
? { authenticated: true }
: { error: "Unauthorized" },
),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/api/cases", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
cases: [
{
id: "case_article_1",
case_type: "article",
title: "伟思德鲁官网文章优化案例",
summary: "优化后摘要",
status: "optimized",
customer_name: "",
brand_name: "",
project_tags: [],
notes: "",
publish_target: "official_site",
source_excerpt: "原文摘要",
result_excerpt: "优化后摘要",
latest_result_version_id: "ver_article_1",
latest_version_number: 1,
last_error_stage: null,
last_error_summary: null,
archived_at: null,
created_at: "2026-07-08T10:00:00.000Z",
updated_at: "2026-07-08T10:05:00.000Z",
},
],
}),
});
});
await page.goto("/cases");
await expect(page.getByText("请先登录后查看案例库。")).toBeVisible();
await page.getByLabel("访问密钥").fill("local-dev-key");
await page.getByRole("button", { name: "确认登录" }).click();
await expect(page.getByText("已登录")).toBeVisible();
await expect(page.getByText("伟思德鲁官网文章优化案例")).toBeVisible();
});
async function mockAuthenticatedSession(
page: Parameters<Parameters<typeof test>[1]>[0]["page"],
authenticated: boolean,
) {
await page.route("**/api/auth/session", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ authenticated }),
});
});
}
+14 -9
View File
@@ -1,6 +1,8 @@
import { expect, test } from "@playwright/test";
test("中文界面可以生成优化文章和导出链接", async ({ page }) => {
test.setTimeout(180_000);
await page.goto("/");
await page.getByLabel("访问密钥").fill("local-dev-key");
@@ -14,21 +16,24 @@ test("中文界面可以生成优化文章和导出链接", async ({ page }) =>
await page.getByLabel("用户要求").fill("保持事实准确,语气自然。");
await page.getByRole("button", { name: "分析文章" }).click();
await expect(page.getByText("候选事实卡已生成")).toBeVisible();
await expect(page.getByText("候选事实卡已生成")).toBeVisible({
timeout: 70_000,
});
await page
.locator("label")
.filter({ hasText: "待确认事项" })
.locator("textarea")
.fill("");
const confirmUncertainItemButtons = page.getByRole("button", {
name: "采纳为核心事实",
});
while ((await confirmUncertainItemButtons.count()) > 0) {
await confirmUncertainItemButtons.first().click();
}
await page.getByRole("button", { name: "确认事实卡" }).click();
await expect(page.getByText("事实卡已确认。")).toBeVisible();
await page.getByRole("button", { name: "开始优化" }).click();
await expect(page.getByText("优化完成。")).toBeVisible();
await expect(page.getByRole("link", { name: "optimized.md" })).toBeVisible({
timeout: 120_000,
});
await expect(page.getByText("质量报告")).toBeVisible();
await expect(page.getByRole("link", { name: "optimized.md" })).toBeVisible();
await expect(page.getByRole("link", { name: "optimized.docx" })).toBeVisible();
await expect(page.getByRole("link", { name: "qa_report.json" })).toBeVisible();
});
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
test("普通文案优化标签页可以生成文案结果", async ({ page }) => {
await page.route("**/api/copy/renwei-optimize", async (route) => {
const payload = route.request().postDataJSON() as { publish_target?: string };
expect(payload.publish_target).toBe("朋友圈");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
case: { id: "case_copy_1", case_type: "human_copy" },
result_version: { id: "ver_copy_1", version: 1 },
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.getByLabel("发布目标").fill("朋友圈");
await page.getByRole("button", { name: "优化文案" }).click();
await expect(page.getByText("优化后文案")).toBeVisible();
await expect(page.locator(".copy-result")).toContainText(
"我观察到大家越来越难进入心流了。",
);
await expect(page.getByRole("link", { name: "查看案例详情" })).toHaveAttribute(
"href",
"/cases/case_copy_1",
);
await expect(page.getByText("改动说明")).toBeVisible();
await expect(page.getByText("AI 味检查")).toBeVisible();
});
+179
View File
@@ -0,0 +1,179 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { test } from "@playwright/test";
import { loadArticleSamples } from "./sample-flow/sample-loader";
import { runSamplePageFlow } from "./sample-flow/page-flow";
import { writeSampleResult } from "./sample-flow/reporting";
import type {
ArticleSample,
FailureCategory,
SampleResult,
} from "./sample-flow/types";
const repoRoot = process.cwd();
const samplesDir =
process.env.GEO_SAMPLE_DIR ?? join(repoRoot, "samples", "articles");
const reportDir =
process.env.GEO_SAMPLE_REPORT_DIR ??
join(repoRoot, "test-results", "geo-sample-flow", "manual");
const baseURL = process.env.GEO_SAMPLE_BASE_URL ?? "http://127.0.0.1:3000";
const apiAccessKey = process.env.API_ACCESS_KEY ?? "local-dev-key";
const timeoutMs = Number(process.env.GEO_SAMPLE_TIMEOUT_MS ?? "600000");
const filter = process.env.GEO_SAMPLE_FILTER;
const limit = Number(process.env.GEO_SAMPLE_LIMIT ?? "0");
const loaded = loadArticleSamples(samplesDir);
const selectedSamples = selectSamples(loaded.valid, filter, limit);
test.describe("GEO sample article live E2E flow", () => {
test.beforeAll(() => {
mkdirSync(reportDir, { recursive: true });
writeFileSync(
join(reportDir, "invalid-samples.json"),
`${JSON.stringify(loaded.invalid, null, 2)}\n`,
);
});
for (const sample of selectedSamples) {
test(sample.name, async ({ page, request }) => {
test.setTimeout(timeoutMs + 60_000);
const startedAt = Date.now();
const consoleLines: string[] = [];
const pageErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") {
consoleLines.push(`${message.type()}: ${message.text()}`);
}
});
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
let result: SampleResult;
try {
result = await runSamplePageFlow({
page,
request,
sample,
baseURL,
apiAccessKey,
reportDir,
timeoutMs,
});
} catch (error) {
const failureScreenshot = join(
reportDir,
"samples",
sample.slug,
"failure.png",
);
await page
.screenshot({ path: failureScreenshot, fullPage: true })
.catch(() => {});
const failedResult = buildFailureResult({
sample,
error,
startedAt,
failureScreenshot,
consoleLines,
pageErrors,
});
writeSampleResult(reportDir, failedResult);
throw error;
}
const enriched = enrichWithBrowserErrors(result, consoleLines, pageErrors);
writeSampleResult(reportDir, enriched);
if (enriched.status === "failed") {
throw new Error(enriched.failure_message ?? enriched.failure_category);
}
});
}
});
function selectSamples(
samples: ArticleSample[],
sampleFilter: string | undefined,
sampleLimit: number,
) {
const filtered = sampleFilter
? samples.filter(
(sample) =>
sample.fileName.includes(sampleFilter) ||
sample.name.includes(sampleFilter) ||
sample.slug.includes(sampleFilter),
)
: samples;
return sampleLimit > 0 ? filtered.slice(0, sampleLimit) : filtered;
}
function enrichWithBrowserErrors(
result: SampleResult,
consoleLines: string[],
pageErrors: string[],
): SampleResult {
if (consoleLines.length === 0 && pageErrors.length === 0) return result;
return {
...result,
status: "failed",
failure_category: "console_error",
failure_message: [...consoleLines, ...pageErrors].join("\n"),
};
}
function buildFailureResult({
sample,
error,
startedAt,
failureScreenshot,
consoleLines,
pageErrors,
}: {
sample: ArticleSample;
error: unknown;
startedAt: number;
failureScreenshot: string;
consoleLines: string[];
pageErrors: string[];
}): SampleResult {
const message = error instanceof Error ? error.message : String(error);
return {
file: sample.filePath,
name: sample.name,
slug: sample.slug,
status: "failed",
duration_ms: Date.now() - startedAt,
qa_fail_rules: [],
qa_warn_rules: [],
expected_hard_failures: sample.expectedHardFailures,
expected_warnings: sample.expectedWarnings,
exports: {
"optimized.md": "failed",
"optimized.docx": "failed",
"qa_report.json": "failed",
},
llm_tasks: [],
failure_category: categorizeFailure(message),
failure_message: [...consoleLines, ...pageErrors, message]
.filter(Boolean)
.join("\n"),
artifacts: {
failure_screenshot: failureScreenshot,
},
};
}
function categorizeFailure(message: string): FailureCategory {
if (/timeout/i.test(message)) return "stream_timeout";
if (/LLM|provider|schema|quota|rate/i.test(message)) return "llm_failed";
if (/export|optimized\.md|optimized\.docx|qa_report\.json/i.test(message)) {
return "export_failed";
}
if (/console|pageerror/i.test(message)) return "console_error";
if (/failed|优化失败/i.test(message)) return "stream_failed";
return "page_flow_failed";
}
+259
View File
@@ -0,0 +1,259 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
expect,
type APIRequestContext,
type Locator,
type Page,
} from "@playwright/test";
import type {
ArticleSample,
ExportValidationResult,
SampleResult,
} from "./types";
const exportFileNames = [
"optimized.md",
"optimized.docx",
"qa_report.json",
] as const;
type ExportFileName = (typeof exportFileNames)[number];
interface RunSamplePageFlowOptions {
page: Page;
request: APIRequestContext;
sample: ArticleSample;
baseURL: string;
apiAccessKey: string;
reportDir: string;
timeoutMs: number;
}
interface QaReportJson {
overall_status?: "pass" | "warn" | "fail";
checks?: Array<{ rule_id?: string; status?: "pass" | "warn" | "fail" }>;
}
export async function runSamplePageFlow({
page,
request,
sample,
baseURL,
apiAccessKey,
reportDir,
timeoutMs,
}: RunSamplePageFlowOptions): Promise<SampleResult> {
const startedAt = Date.now();
const sampleDir = join(reportDir, "samples", sample.slug);
const exportsDir = join(sampleDir, "exports");
mkdirSync(exportsDir, { recursive: true });
await page.goto(baseURL);
await fillIfVisible(page, "访问密钥", apiAccessKey);
const bodyField = await fillFirstAvailable(
page,
["文章内容", "粘贴文章", "正文"],
sample.input.body,
);
await fillIfVisible(page, "图片描述或图片链接", sample.input.image_lines);
await page.getByLabel("目标平台").selectOption(sample.input.platform);
await fillIfVisible(page, "用户要求", sample.input.user_instructions);
await ensureSubmitEnabled(page, bodyField, sample.input.body);
await page.getByRole("button", { name: "开始优化" }).click();
const factCardPanel = page.locator("section").filter({ hasText: "事实卡" });
await expect(factCardPanel.getByText(/可用|待复核/)).toBeVisible({
timeout: timeoutMs,
});
await expect(page.getByText("优化结果")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(/优化完成|质检发现需要复核的问题/)).toBeVisible({
timeout: timeoutMs,
});
await expect(page.getByText("质量报告")).toBeVisible();
for (const fileName of exportFileNames) {
await expect(page.getByRole("link", { name: fileName })).toBeVisible();
}
const jobId = await extractJobIdFromExportLink(page);
const exportResults = await validateExports({
request,
baseURL,
apiAccessKey,
jobId,
exportsDir,
});
const qa = readQaReport(exportsDir);
const finalScreenshot = join(sampleDir, "final.png");
await page.screenshot({ path: finalScreenshot, fullPage: true });
const failedExports = exportResults.filter((result) => result.status === "failed");
return {
file: sample.filePath,
name: sample.name,
slug: sample.slug,
status: failedExports.length === 0 ? "passed" : "failed",
duration_ms: Date.now() - startedAt,
job_id: jobId,
qa_status: qa.overall_status,
qa_fail_rules: rulesWithStatus(qa, "fail"),
qa_warn_rules: rulesWithStatus(qa, "warn"),
expected_hard_failures: sample.expectedHardFailures,
expected_warnings: sample.expectedWarnings,
exports: Object.fromEntries(
exportResults.map((result) => [result.fileName, result.status]),
),
llm_tasks: [],
failure_category: failedExports.length > 0 ? "export_failed" : undefined,
failure_message:
failedExports
.map((result) => `${result.fileName}: ${result.error ?? result.statusCode}`)
.join("; ") || undefined,
artifacts: {
final_screenshot: finalScreenshot,
},
};
}
async function fillFirstAvailable(page: Page, labels: string[], value: string) {
for (const label of labels) {
const locator = page.getByLabel(label);
if ((await locator.count()) > 0) {
await locator.fill(value);
return locator;
}
}
throw new Error(`none of these labels were found: ${labels.join(", ")}`);
}
async function ensureSubmitEnabled(
page: Page,
bodyField: Locator,
body: string,
) {
const submitButton = page.getByRole("button", { name: "开始优化" });
for (let attempt = 0; attempt < 4; attempt += 1) {
await bodyField.fill("");
await bodyField.fill(body);
if (await submitButton.isEnabled()) {
return;
}
await page.waitForTimeout(250);
}
await expect(bodyField).toHaveValue(body);
await expect(submitButton).toBeEnabled();
}
async function fillIfVisible(page: Page, label: string, value: string) {
const locator = page.getByLabel(label);
if ((await locator.count()) > 0) {
await locator.fill(value);
}
}
async function extractJobIdFromExportLink(page: Page) {
const href = await page
.getByRole("link", { name: "optimized.md" })
.getAttribute("href");
return extractJobIdFromExportHref(href ?? "");
}
export function extractJobIdFromExportHref(href: string) {
const match = href.match(/\/api\/jobs\/([^/]+)\/exports\//);
if (!match?.[1]) {
throw new Error(`could not extract job id from export link: ${href}`);
}
return match[1];
}
async function validateExports({
request,
baseURL,
apiAccessKey,
jobId,
exportsDir,
}: {
request: APIRequestContext;
baseURL: string;
apiAccessKey: string;
jobId: string;
exportsDir: string;
}): Promise<ExportValidationResult[]> {
const results: ExportValidationResult[] = [];
const headers: Record<string, string> | undefined = apiAccessKey
? { "x-api-key": apiAccessKey }
: undefined;
for (const fileName of exportFileNames) {
const response = await request.get(
`${baseURL}/api/jobs/${jobId}/exports/${fileName}`,
{ headers },
);
const buffer = await response.body();
const outputPath = join(exportsDir, fileName);
writeFileSync(outputPath, buffer);
if (!response.ok()) {
results.push({
fileName,
status: "failed",
statusCode: response.status(),
error: response.statusText(),
});
continue;
}
const error = validateExportBody(fileName, buffer);
results.push({
fileName,
status: error ? "failed" : "passed",
statusCode: response.status(),
error,
});
}
return results;
}
export function validateExportBody(fileName: ExportFileName, buffer: Buffer) {
if (buffer.length === 0) return "empty export body";
if (fileName === "optimized.md") {
return buffer.toString("utf8").trim().length > 0
? undefined
: "optimized.md is blank";
}
if (fileName === "optimized.docx") {
return buffer.subarray(0, 2).toString("utf8") === "PK"
? undefined
: "optimized.docx does not look like a zip-based docx";
}
try {
const parsed = JSON.parse(buffer.toString("utf8")) as QaReportJson;
if (!parsed.overall_status || !Array.isArray(parsed.checks)) {
return "qa_report.json must contain overall_status and checks";
}
return undefined;
} catch {
return "qa_report.json is not valid JSON";
}
}
function readQaReport(exportsDir: string): QaReportJson {
const text = readFileSync(join(exportsDir, "qa_report.json"), "utf8");
return JSON.parse(text) as QaReportJson;
}
function rulesWithStatus(report: QaReportJson, status: "warn" | "fail") {
return (report.checks ?? [])
.filter((check) => check.status === status && check.rule_id)
.map((check) => check.rule_id as string);
}
+130
View File
@@ -0,0 +1,130 @@
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";
import type { InvalidArticleSample, RunSummary, SampleResult } from "./types";
interface AggregateInput {
startedAt: string;
finishedAt: string;
provider: string;
model: string;
baseURL: string;
reportDir: string;
samples: SampleResult[];
invalidSamples: InvalidArticleSample[];
}
export function writeSampleResult(reportDir: string, result: SampleResult) {
const sampleDir = join(reportDir, "samples", result.slug);
mkdirSync(sampleDir, { recursive: true });
writeFileSync(
join(sampleDir, "result.json"),
`${JSON.stringify(result, null, 2)}\n`,
);
}
export function readSampleResults(reportDir: string): SampleResult[] {
const samplesDir = join(reportDir, "samples");
try {
return readdirSync(samplesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(samplesDir, entry.name, "result.json"))
.map(
(filePath) => JSON.parse(readFileSync(filePath, "utf8")) as SampleResult,
)
.sort((left, right) => left.file.localeCompare(right.file));
} catch {
return [];
}
}
export function aggregateSummary(input: AggregateInput): RunSummary {
const passed = input.samples.filter((sample) => sample.status === "passed").length;
const failed = input.samples.filter((sample) => sample.status === "failed").length;
const skipped =
input.samples.filter((sample) => sample.status === "skipped").length +
input.invalidSamples.length;
return {
started_at: input.startedAt,
finished_at: input.finishedAt,
mode: "live",
provider: input.provider,
model: input.model,
base_url: input.baseURL,
report_dir: input.reportDir,
totals: { passed, failed, skipped },
samples: input.samples,
invalid_samples: input.invalidSamples,
};
}
export function writeSummaryFiles(reportDir: string, summary: RunSummary) {
mkdirSync(reportDir, { recursive: true });
writeFileSync(
join(reportDir, "summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
);
writeFileSync(join(reportDir, "summary.md"), renderSummaryMarkdown(summary));
}
export function redactSensitiveText(
text: string,
secrets: Array<string | undefined>,
) {
let redacted = text
.replace(/(x-api-key:\s*)([^\s]+)/gi, "$1[REDACTED]")
.replace(/(authorization:\s*bearer\s+)([^\s]+)/gi, "$1[REDACTED]")
.replace(
/((?:DEEPSEEK|OPENAI|API)_?[A-Z_]*KEY=)"?([^"\s]+)"?/g,
'$1"[REDACTED]"',
);
for (const secret of secrets) {
if (!secret) continue;
redacted = redacted.split(secret).join("[REDACTED]");
}
return redacted;
}
function renderSummaryMarkdown(summary: RunSummary) {
const lines = [
"# GEO 样例文章 E2E 测试报告",
"",
`- 模式:${summary.mode}`,
`- Provider${summary.provider}`,
`- Model${summary.model}`,
`- Base URL${summary.base_url}`,
`- 报告目录:${summary.report_dir}`,
`- 结果:通过 ${summary.totals.passed},失败 ${summary.totals.failed},跳过 ${summary.totals.skipped}`,
"",
"| 样例 | 状态 | QA | 耗时 | 失败分类 |",
"| --- | --- | --- | ---: | --- |",
...summary.samples
.map((sample) =>
[
sample.name,
sample.status,
sample.qa_status ?? "",
`${Math.round(sample.duration_ms / 1000)}s`,
sample.failure_category ?? "",
].join(" | "),
)
.map((row) => `| ${row} |`),
];
if (summary.invalid_samples.length > 0) {
lines.push("", "## 无效样例", "");
for (const sample of summary.invalid_samples) {
lines.push(`- ${sample.fileName}: ${sample.reason}`);
}
}
lines.push("");
return lines.join("\n");
}
export function relativeArtifactPath(reportDir: string, artifactPath: string) {
return relative(reportDir, artifactPath);
}

Some files were not shown because too many files have changed in this diff Show More