Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d376bd0df7 | ||
|
|
b2e06a2933 | ||
|
|
adbe9d129d | ||
|
|
cd661b99c7 | ||
|
|
8a14ce8722 | ||
|
|
8e50ecef77 | ||
|
|
90de3a698c | ||
|
|
fe1e3065d6 | ||
|
|
871d0ac5cb | ||
|
|
602c0b99da | ||
|
|
a22844d2dd | ||
|
|
001f0402b2 | ||
|
|
2bf3c62dbf | ||
|
|
63509dba20 | ||
|
|
b4472fe348 | ||
|
|
fd1b792f2c | ||
|
|
cf74ad920c | ||
|
|
c9ca93faa0 | ||
|
|
1f382c280c | ||
|
|
f09fd5afe8 | ||
|
|
e867f4f07e | ||
|
|
79913ef7f7 | ||
|
|
ee98c3304b | ||
|
|
01c02049d4 | ||
|
|
de539aecb0 | ||
|
|
8b10969c49 | ||
|
|
3fb9db8474 | ||
|
|
bf7be12dad | ||
|
|
3919887a8d | ||
|
|
27d7a44ad0 | ||
|
|
c2010fa8f4 | ||
|
|
31d53d3165 | ||
|
|
e0b0e9eb8a | ||
|
|
66fee182f5 | ||
|
|
762380c937 | ||
|
|
7f6557cd4e | ||
|
|
5164eeef4a | ||
|
|
b699c227bd | ||
|
|
1781088640 | ||
|
|
34e6da23b0 | ||
|
|
28bf0b4135 | ||
|
|
016b653b76 | ||
|
|
8a1ceb3b14 | ||
|
|
a2183a34af | ||
|
|
382f468267 | ||
|
|
f9b8bae714 | ||
|
|
9dad89ae43 | ||
|
|
d5cf51f62d | ||
|
|
9dd2ad72ef | ||
|
|
5294755def | ||
|
|
c47036d28d | ||
|
|
b453800494 | ||
|
|
714eb5aba6 | ||
|
|
e2f7d909e5 |
@@ -1,4 +1,7 @@
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
AGENTS.md
|
||||
agents.md
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
|
||||
+137
@@ -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_: 手动清理, 长期保留
|
||||
@@ -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
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` 返回 200,content-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,472 @@
|
||||
# LLM 后台架构观测标签页设计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前首页提供 `GEO 文章优化` 和 `普通文案优化` 两个功能标签。GEO 主流程通过 `POST /api/jobs/optimize-stream` 返回 NDJSON 事件,前端可以看到事实卡、优化草稿、质量检查、定向修复和终稿等阶段性结果。
|
||||
|
||||
所有真实模型调用集中在 `src/lib/llm/client.ts`。该客户端目前会向服务端控制台打印 `[llm:start]`、`[llm:response]`、`[llm:validated]` 和 `[llm:error]`,并通过审计回调保存 Provider、模型、任务名、耗时、Schema 状态和输入输出哈希。现有持久化审计不保存 SDK 实际发送的完整请求对象,也不保存 SDK 收到的完整响应对象,因此前端不能准确还原一次 LLM 调用。
|
||||
|
||||
本功能增加第三个首页标签 `后台架构`,用于只读展示文章优化后台的真实执行过程,重点呈现每次 LLM 调用的完整请求、完整响应和校验结果。架构图必须由后端真实任务事件驱动,而不是前端按预估时间播放动画。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本功能需要同时服务两类受众:
|
||||
|
||||
- 客户或非技术人员通过默认摘要视图理解文章经历了哪些处理阶段。
|
||||
- 已授权的研发或运营人员查看完整的 LLM 请求、响应、耗时、Token 用量、错误和 Schema 校验结果。
|
||||
|
||||
完成后,用户应当能够:
|
||||
|
||||
1. 在首页切换到 `后台架构` 标签。
|
||||
2. 观察当前任务的架构节点随真实后台状态变化。
|
||||
3. 在没有运行中任务时查看最近一次终态任务。
|
||||
4. 按真实发生顺序选择任意一次 LLM 调用。
|
||||
5. 在授权后查看 SDK 实际发送的完整请求对象。
|
||||
6. 在授权后查看 SDK 收到的完整响应 JSON。
|
||||
7. 区分 Provider 失败、响应解析失败、Schema 校验失败和业务质检未通过。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
本次不包含:
|
||||
|
||||
- 浏览、筛选或搜索全部历史任务的完整 LLM 日志。
|
||||
- 在架构页发起、重试、取消或管理任务。
|
||||
- 接入 OpenTelemetry、Sentry、Grafana 或其他外部可观测平台。
|
||||
- 将现有优化流程改造成持久化队列、Durable Workflow 或后台任务系统。
|
||||
- 为已有历史案例补造过去没有保存的请求或响应。
|
||||
- 展示 API Key、Authorization、HTTP 请求头或底层网络数据包。
|
||||
- 改变现有文章优化、质量检查、定向修复和导出业务规则。
|
||||
|
||||
## 4. 已选方案
|
||||
|
||||
采用一等公民的 LLM 调用事件方案。
|
||||
|
||||
`src/lib/llm/client.ts` 在模型请求前后产生包含真实对象的内部追踪事件。任务追踪收集器先持久化完整正文,再通过现有 NDJSON 响应流向首页发送不含正文的状态事件。架构标签页与 GEO 标签共享当前页面的任务状态,并在用户选择某次调用后通过授权接口按需读取完整正文。页面刷新后,通过只读追踪接口恢复运行中任务已经保存的事件,或读取最近一次终态任务。
|
||||
|
||||
没有采用以下方案:
|
||||
|
||||
- 前端根据阶段事件和控制台文本日志重建调用过程。该方案无法保证完整请求和响应与真实 SDK 数据一致。
|
||||
- 外部可观测平台。该方案适合更大规模的运维场景,但超出当前产品范围。
|
||||
|
||||
## 5. 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph UI["浏览器界面"]
|
||||
GEO["GEO 文章优化标签"]
|
||||
ARCH["后台架构标签<br/>只读观察器"]
|
||||
end
|
||||
|
||||
subgraph API["Next.js / Cloudflare Worker"]
|
||||
STREAM["POST /api/jobs/optimize-stream<br/>NDJSON 实时事件"]
|
||||
TRACEAPI["只读 LLM Trace API<br/>刷新后恢复"]
|
||||
COLLECTOR["任务追踪收集器"]
|
||||
end
|
||||
|
||||
subgraph WORKFLOW["真实文章优化工作流"]
|
||||
FACT["fact_extractor<br/>事实提取"]
|
||||
DRAFT["article_optimizer<br/>生成优化稿"]
|
||||
QA["quality_inspector<br/>质量检查"]
|
||||
REWRITE["targeted_rewriter<br/>定向修复"]
|
||||
FACT --> DRAFT --> QA
|
||||
QA -->|"未通过,最多两轮"| REWRITE
|
||||
REWRITE -->|"重新检查"| QA
|
||||
end
|
||||
|
||||
CLIENT["src/lib/llm/client.ts<br/>唯一 LLM 调用边界"]
|
||||
LLM["DeepSeek / OpenAI-compatible API"]
|
||||
D1[("D1<br/>任务、调用索引、状态")]
|
||||
R2[("R2<br/>完整请求与响应 JSON")]
|
||||
AUDIT[("案例结果版本<br/>摘要与内容哈希")]
|
||||
|
||||
GEO -->|"携带访问密钥发起任务"| STREAM
|
||||
STREAM --> FACT
|
||||
FACT & DRAFT & QA & REWRITE --> CLIENT
|
||||
CLIENT -->|"SDK 实际请求对象"| LLM
|
||||
LLM -->|"SDK 完整响应对象"| CLIENT
|
||||
CLIENT -->|"started / responded / validated / failed"| COLLECTOR
|
||||
COLLECTOR -->|"实时事件"| STREAM
|
||||
STREAM -->|"同一任务流"| GEO
|
||||
GEO -.->|"切换标签,共享当前任务状态"| ARCH
|
||||
COLLECTOR --> D1
|
||||
COLLECTOR --> R2
|
||||
COLLECTOR -->|"任务结束后写入"| AUDIT
|
||||
ARCH -->|"携带访问密钥读取"| TRACEAPI
|
||||
TRACEAPI --> D1
|
||||
TRACEAPI --> R2
|
||||
```
|
||||
|
||||
### 5.1 真实请求的定义
|
||||
|
||||
“完整请求”指应用完成默认值合并后、立即传给 OpenAI-compatible SDK 的请求对象,包括:
|
||||
|
||||
- `model`
|
||||
- `temperature`
|
||||
- `response_format`
|
||||
- 按实际顺序排列的完整 `messages`
|
||||
- 后续真实加入 SDK 请求的其他非敏感参数
|
||||
|
||||
追踪对象不包含 SDK 客户端构造参数、API Key、Authorization 或 HTTP 请求头。
|
||||
|
||||
### 5.2 真实响应的定义
|
||||
|
||||
“完整响应”指 SDK 返回、但尚未提取 `choices[0].message.content`、执行 JSON 解析或 Zod 校验之前的完整可序列化响应对象。它应保留 Provider 实际返回的字段,包括存在时的:
|
||||
|
||||
- `id`
|
||||
- `object`
|
||||
- `created`
|
||||
- `model`
|
||||
- `choices`
|
||||
- `finish_reason`
|
||||
- `usage`
|
||||
- Provider 返回的其他可序列化非敏感字段
|
||||
|
||||
若 Provider 调用没有返回正常响应,则保存请求和安全化错误对象,不伪造响应。
|
||||
|
||||
## 6. 模块边界
|
||||
|
||||
### 6.1 LLM 客户端
|
||||
|
||||
`src/lib/llm/client.ts` 继续作为唯一模型调用边界,负责:
|
||||
|
||||
- 构造最终 SDK 请求对象。
|
||||
- 在调用前把最终 SDK 请求对象交给追踪收集器。
|
||||
- 保存 SDK 返回的完整响应对象。
|
||||
- 在 JSON 解析和 Schema 校验后把对应结果交给追踪收集器。
|
||||
- 将现有审计摘要从同一份追踪事实派生出来。
|
||||
|
||||
工作流节点只传递任务名、修复轮次和追踪回调,不自行拼装日志。
|
||||
|
||||
### 6.2 任务追踪收集器
|
||||
|
||||
新增独立的任务追踪收集器,负责:
|
||||
|
||||
- 为每次调用分配稳定 `call_id` 和递增 `sequence`。
|
||||
- 将 LLM 事件映射到任务、案例、工作流阶段和修复轮次。
|
||||
- 先保存请求或响应正文,再将不含完整正文的实时状态事件发送给现有优化流。
|
||||
- 增量写入 D1 索引和 R2 正文。
|
||||
- 在任务结束后执行完整日志保留清理。
|
||||
- 生成现有结果版本需要的审计摘要和哈希。
|
||||
|
||||
追踪收集器不参与 Prompt 构造、文章优化或业务质检判断。
|
||||
|
||||
### 6.3 追踪存储
|
||||
|
||||
D1 使用 `llm_trace_runs` 和 `llm_trace_calls` 两张表保存轻量、可查询的结构化数据:
|
||||
|
||||
- 追踪任务标识、任务状态和当前阶段。
|
||||
- 调用顺序、任务名、Provider、模型和耗时。
|
||||
- 请求及响应的 R2 对象键。
|
||||
- Schema 名称、校验状态和问题摘要。
|
||||
- 错误分类、错误摘要和追踪完整性状态。
|
||||
- 创建、响应、校验和完成时间。
|
||||
|
||||
R2 保存完整正文:
|
||||
|
||||
- `llm-traces/<jobId>/<callId>/request.json`
|
||||
- `llm-traces/<jobId>/<callId>/response.json`
|
||||
|
||||
R2 对象保持私有,只能通过应用 API 读取。应用不返回公开对象 URL。
|
||||
|
||||
### 6.4 架构观察组件
|
||||
|
||||
前端组件只负责:
|
||||
|
||||
- 消费当前页面已经收到的追踪事件。
|
||||
- 在刷新后读取追踪清单。
|
||||
- 按调用选择并按需读取请求或响应正文。
|
||||
- 根据后端事件映射固定架构图的节点状态。
|
||||
- 以纯文本方式渲染 JSON。
|
||||
|
||||
组件不推断不存在的阶段,不解析服务器控制台日志,也不发起业务操作。
|
||||
|
||||
## 7. 追踪数据模型
|
||||
|
||||
### 7.1 Trace Run
|
||||
|
||||
每个文章优化任务对应一个追踪运行记录,至少包含:
|
||||
|
||||
- `job_id`
|
||||
- `case_id`
|
||||
- `status`: `running | completed | failed | interrupted`
|
||||
- `current_stage`
|
||||
- `trace_completeness`: `complete | incomplete`
|
||||
- `started_at`
|
||||
- `finished_at`
|
||||
- `error_stage`
|
||||
- `error_summary`
|
||||
|
||||
### 7.2 Trace Call
|
||||
|
||||
每次 LLM 调用对应一个记录,至少包含:
|
||||
|
||||
- `call_id`
|
||||
- `job_id`
|
||||
- `sequence`
|
||||
- `task`
|
||||
- `workflow_stage`
|
||||
- `rewrite_round`
|
||||
- `provider`
|
||||
- `model`
|
||||
- `status`: `started | responded | validated | failed`
|
||||
- `request_object_key`
|
||||
- `response_object_key`
|
||||
- `schema_name`
|
||||
- `schema_valid`
|
||||
- `validation_issues`
|
||||
- `duration_ms`
|
||||
- `started_at`
|
||||
- `responded_at`
|
||||
- `validated_at`
|
||||
- `error_type`
|
||||
- `error_summary`
|
||||
|
||||
完整请求和响应不重复写入结果版本的 D1 JSON。结果版本继续保存紧凑审计摘要和哈希。
|
||||
|
||||
## 8. 事件契约
|
||||
|
||||
现有 `OptimizationStreamEvent` 联合类型增加以下事件:
|
||||
|
||||
### 8.1 `llm_call_started`
|
||||
|
||||
SDK 调用前产生。内部追踪事件包含最终请求对象;写入 R2 后,NDJSON 事件只包含调用标识、任务信息、Provider、模型、最终参数摘要和 `request_available: true`。架构页收到后立即将对应节点标记为运行中,用户打开“请求”视图时再调用授权接口读取完整对象。
|
||||
|
||||
### 8.2 `llm_call_responded`
|
||||
|
||||
SDK 正常返回后、解析前产生。内部追踪事件包含完整响应对象;写入 R2 后,NDJSON 事件只包含调用标识、耗时、Token 用量和 `response_available: true`。架构页允许立即切换到响应视图并按需读取完整对象。
|
||||
|
||||
### 8.3 `llm_call_validated`
|
||||
|
||||
JSON 解析和 Zod 校验后产生。包含 Schema 名称、校验结果和完整问题列表。业务质检结果 `pass` 或 `fail` 与 Schema 是否有效分开表达。
|
||||
|
||||
### 8.4 `llm_call_failed`
|
||||
|
||||
Provider、JSON 解析或 Schema 校验造成调用失败时产生。包含错误分类和安全化错误详情。已经保存的请求或响应继续可见。
|
||||
|
||||
### 8.5 `trace_warning`
|
||||
|
||||
请求或响应正文持久化、追踪索引更新或清理失败时产生。包含安全化错误摘要和 `trace_completeness: incomplete`,但不改变文章优化业务状态。
|
||||
|
||||
事件必须按单次调用顺序发送。不同调用通过 `call_id` 区分,通过 `sequence` 排列。顺序固定为:
|
||||
|
||||
- 成功:`started -> responded -> validated(success=true)`。
|
||||
- Provider 失败:`started -> failed(provider)`。
|
||||
- JSON 解析失败:`started -> responded -> failed(json_parse)`。
|
||||
- Schema 失败:`started -> responded -> validated(success=false) -> failed(schema_validation)`。
|
||||
|
||||
## 9. API 设计
|
||||
|
||||
### 9.1 实时事件
|
||||
|
||||
`POST /api/jobs/optimize-stream` 保持当前请求方式和 NDJSON 格式,并增加 LLM 追踪事件。现有文章结果事件保持兼容。
|
||||
|
||||
### 9.2 最近追踪
|
||||
|
||||
`GET /api/llm-traces/latest`
|
||||
|
||||
- 有运行中任务时返回运行中任务的追踪清单。
|
||||
- 没有运行中任务时返回最近一次终态任务。
|
||||
- 没有任何追踪记录时返回明确的空状态,不返回错误页面。
|
||||
|
||||
### 9.3 任务追踪清单
|
||||
|
||||
`GET /api/jobs/:jobId/llm-trace`
|
||||
|
||||
返回任务状态、当前阶段、架构节点状态和按顺序排列的调用元数据,不内嵌大体积请求或响应正文。
|
||||
|
||||
### 9.4 完整请求
|
||||
|
||||
`GET /api/jobs/:jobId/llm-trace/:callId/request`
|
||||
|
||||
返回该调用保存的完整请求 JSON。
|
||||
|
||||
### 9.5 完整响应
|
||||
|
||||
`GET /api/jobs/:jobId/llm-trace/:callId/response`
|
||||
|
||||
返回该调用保存的完整响应 JSON。调用仍在进行时返回明确的等待状态;调用失败且无响应时返回明确的无响应状态。
|
||||
|
||||
所有追踪读取接口复用现有访问密钥校验,并设置 `Cache-Control: no-store`。
|
||||
|
||||
## 10. 保留策略
|
||||
|
||||
完整日志只保留:
|
||||
|
||||
- 当前仍在运行的任务。
|
||||
- 最近一次进入终态的任务。
|
||||
|
||||
当新任务进入终态后:
|
||||
|
||||
1. 它成为最近一次终态任务。
|
||||
2. 删除更旧终态任务的请求和响应 R2 对象。
|
||||
3. 删除更旧终态任务的 `llm_trace_runs` 和 `llm_trace_calls` 记录。
|
||||
4. 保留案例结果版本中已有的审计摘要、错误摘要和输入输出哈希。
|
||||
|
||||
若存在多个并发运行任务,不删除任何运行中任务的完整记录。最近一次终态任务按完成时间确定。
|
||||
|
||||
## 11. 页面与交互设计
|
||||
|
||||
### 11.1 应用标签
|
||||
|
||||
首页导航增加第三项:
|
||||
|
||||
- `GEO 文章优化`
|
||||
- `普通文案优化`
|
||||
- `后台架构`
|
||||
|
||||
`后台架构` 是只读观察器。切换标签不取消正在读取的优化流,也不清空首页中的当前任务状态。
|
||||
|
||||
### 11.2 页面结构
|
||||
|
||||
页面按以下顺序组成:
|
||||
|
||||
1. 任务状态条:显示当前任务或最近一次任务、任务 ID、Provider、模型、状态和技术详情授权状态。
|
||||
2. 固定执行图:显示输入归一化、事实提取、生成草稿、质量检查、定向修复、保存与导出。
|
||||
3. LLM 调用轨迹:按 `sequence` 显示每次实际调用;复检和多轮修复均为独立记录。
|
||||
4. 调用详情:提供 `请求`、`响应`、`校验` 三个子视图。
|
||||
|
||||
### 11.3 架构图状态
|
||||
|
||||
固定拓扑不随调用次数改变,节点状态由真实事件更新:
|
||||
|
||||
- `waiting`: 尚未开始。
|
||||
- `running`: 当前正在处理。
|
||||
- `completed`: 已成功完成。
|
||||
- `failed`: Provider、解析或 Schema 失败。
|
||||
|
||||
业务质检未通过不是技术失败。质量检查节点应显示“检查完成,需要修复”,然后高亮定向修复节点,并在修复后回到质量检查节点。
|
||||
|
||||
### 11.4 分级展示
|
||||
|
||||
追踪页面整体复用现有访问密钥校验。访问密钥缺失或无效时,不返回任务清单、摘要或正文。授权成功后,页面默认进入客户友好视图,只展示:
|
||||
|
||||
- 阶段名称和中文说明。
|
||||
- Provider、模型、耗时和 Token 用量。
|
||||
- 请求与响应的脱敏摘要。
|
||||
- Schema 和业务结果。
|
||||
|
||||
用户切换到技术详情后,前端通过同一访问密钥按需加载:
|
||||
|
||||
- 完整 SDK 请求对象。
|
||||
- 完整 SDK 响应对象。
|
||||
- 完整 Schema 校验问题。
|
||||
- 安全化错误对象。
|
||||
|
||||
前端不把完整请求或响应内嵌在任务清单和 NDJSON 状态事件中,也不依靠 CSS 隐藏已经下载的敏感内容。
|
||||
|
||||
### 11.5 空状态和恢复
|
||||
|
||||
- 无当前或历史追踪:解释需要先在 GEO 标签运行一次优化。
|
||||
- 当前任务:实时跟随 NDJSON 事件。
|
||||
- 最近一次任务:明确标记为历史快照,避免误认为仍在运行。
|
||||
- 追踪不完整:显示已保存数据,并明确指出缺失阶段或正文。
|
||||
|
||||
## 12. 安全与隐私
|
||||
|
||||
- API Key、Authorization 和请求头永不进入追踪事件、D1、R2 或前端状态。
|
||||
- Provider 错误采用字段白名单序列化,只保留安全的状态码、错误类型、错误码和消息。
|
||||
- R2 追踪对象保持私有。
|
||||
- 所有追踪读取接口都要求访问密钥,并禁用缓存。
|
||||
- 前端通过文本节点或 JSON 文本组件展示内容,不使用 `dangerouslySetInnerHTML`。
|
||||
- 模型响应中的 HTML、Markdown 或脚本不会作为可执行页面内容渲染。
|
||||
- 完整请求和响应不进入 Git、导出文章文件或客户端持久化存储。
|
||||
|
||||
## 13. 错误处理
|
||||
|
||||
### 13.1 Provider 失败
|
||||
|
||||
保存完整请求和安全化错误,产生 `llm_call_failed`,将对应架构节点标记为失败,并保持现有优化错误上抛行为。
|
||||
|
||||
### 13.2 JSON 解析失败
|
||||
|
||||
保存完整原始响应和解析错误。响应视图仍可读取完整响应,校验视图标记为未进入 Schema 校验。
|
||||
|
||||
### 13.3 Schema 校验失败
|
||||
|
||||
保存完整响应、Schema 名称和所有 Zod 问题。架构页区分 Schema 失败与 QA 业务检查未通过。
|
||||
|
||||
### 13.4 追踪持久化失败
|
||||
|
||||
追踪系统不得使原本可以完成的文章优化失败。持久化异常产生 `trace_warning`,将 `trace_completeness` 设为 `incomplete`,主流程继续执行,架构页显示“追踪记录不完整”。
|
||||
|
||||
### 13.5 连接中断
|
||||
|
||||
切换首页标签不会中断同一页面中的流式请求。页面刷新或网络中断后,架构页可以恢复已经增量保存的事件,但本次功能不承诺现有非持久化工作流在浏览器请求断开后继续执行。
|
||||
|
||||
## 14. 测试策略
|
||||
|
||||
### 14.1 LLM 客户端单元测试
|
||||
|
||||
- 保存的请求对象与传给 SDK mock 的对象深度相等。
|
||||
- 保存的响应对象与 SDK mock 返回对象深度相等。
|
||||
- 请求事件先于 Provider 调用产生。
|
||||
- 响应事件先于正文解析和 Schema 校验产生。
|
||||
- API Key、Authorization 和请求头不出现在任何追踪对象中。
|
||||
|
||||
### 14.2 追踪存储测试
|
||||
|
||||
- D1 元数据和 R2 请求、响应对象键正确关联。
|
||||
- 调用顺序、任务名、阶段和修复轮次正确。
|
||||
- 每个事件增量持久化。
|
||||
- 并发运行任务不会被清理。
|
||||
- 新终态任务产生后,旧完整正文被删除,审计摘要和哈希仍保留。
|
||||
|
||||
### 14.3 API 测试
|
||||
|
||||
- NDJSON 事件顺序为 `started -> responded -> validated`。
|
||||
- Provider、解析和 Schema 失败产生正确的失败事件。
|
||||
- 未授权读取完整追踪返回 401。
|
||||
- 请求或响应尚不存在时返回明确状态。
|
||||
- 所有读取响应包含 `Cache-Control: no-store`。
|
||||
|
||||
### 14.4 UI 组件测试
|
||||
|
||||
- 覆盖无任务、当前任务、最近任务、成功、失败和追踪不完整。
|
||||
- 选择不同调用会同步更新架构节点和详情。
|
||||
- 请求、响应、校验子视图显示对应数据。
|
||||
- 业务质检未通过不会显示为 Provider 或 Schema 技术失败。
|
||||
- 未授权状态不下载完整正文。
|
||||
|
||||
### 14.5 浏览器端到端测试
|
||||
|
||||
- 从 GEO 标签发起任务后切换到后台架构标签,优化流继续。
|
||||
- 每次调用按真实顺序出现。
|
||||
- 修复循环在固定架构图中正确高亮。
|
||||
- 刷新后可读取已经保存的当前状态或最近终态任务。
|
||||
- 移动端和窄屏下调用清单与详情改为纵向排列,无横向溢出。
|
||||
|
||||
### 14.6 真实 Provider 冒烟测试
|
||||
|
||||
使用安全样例调用真实 DeepSeek,核对:
|
||||
|
||||
- 模型和最终参数。
|
||||
- 完整 `messages` 顺序与正文。
|
||||
- 完整响应 `choices`、`finish_reason` 和 `usage`。
|
||||
- Schema 校验结果。
|
||||
- 架构节点最终状态。
|
||||
|
||||
真实 Provider 冒烟测试不进入普通 CI,避免消耗额度并避免依赖生产密钥。
|
||||
|
||||
实现完成后运行:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 15. 验收标准
|
||||
|
||||
功能完成必须满足:
|
||||
|
||||
1. 首页出现 `后台架构` 标签,且为只读观察器。
|
||||
2. 架构节点由真实后端事件驱动,不使用模拟计时。
|
||||
3. 技术详情中的请求与传给 SDK 的对象一致。
|
||||
4. 技术详情中的响应与 SDK 返回对象一致。
|
||||
5. 完整请求、完整响应和 Schema 校验可以按调用查看。
|
||||
6. 当前任务和最近一次终态任务可被恢复。
|
||||
7. 更旧任务不保留完整正文,但现有审计摘要和哈希继续存在。
|
||||
8. API Key、Authorization 和请求头不会被记录。
|
||||
9. 追踪失败不会使文章优化失败,但会明确显示记录不完整。
|
||||
10. Provider 失败、解析失败、Schema 失败和业务质检未通过在界面中可以区分。
|
||||
11. 单元、集成、组件、端到端测试以及项目 lint、test、build 全部通过。
|
||||
@@ -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);
|
||||
@@ -0,0 +1,48 @@
|
||||
CREATE TABLE IF NOT EXISTS llm_trace_runs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
case_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
current_stage TEXT NOT NULL,
|
||||
trace_completeness TEXT NOT NULL,
|
||||
error_stage TEXT,
|
||||
error_summary TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (job_id) REFERENCES article_jobs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (case_id) REFERENCES optimization_cases(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS llm_trace_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
task TEXT NOT NULL,
|
||||
workflow_stage TEXT NOT NULL,
|
||||
rewrite_round INTEGER,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
request_object_key TEXT,
|
||||
response_object_key TEXT,
|
||||
token_usage TEXT,
|
||||
schema_name TEXT,
|
||||
schema_valid INTEGER,
|
||||
validation_issues TEXT NOT NULL DEFAULT '[]',
|
||||
business_status TEXT,
|
||||
duration_ms INTEGER,
|
||||
started_at TEXT NOT NULL,
|
||||
responded_at TEXT,
|
||||
validated_at TEXT,
|
||||
failed_at TEXT,
|
||||
error_type TEXT,
|
||||
error_summary TEXT,
|
||||
UNIQUE (job_id, sequence),
|
||||
FOREIGN KEY (job_id) REFERENCES llm_trace_runs(job_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_trace_runs_status_updated
|
||||
ON llm_trace_runs(status, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_trace_calls_job_sequence
|
||||
ON llm_trace_calls(job_id, sequence);
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,213 @@ describe("job API routes", () => {
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("streams a one-click optimization from body-only input", async () => {
|
||||
mockTrackedLlmResult(validCandidateFactCard, "llmcall_fact");
|
||||
mockTrackedLlmResult({
|
||||
title: "流式优化标题",
|
||||
summary: "流式优化摘要。",
|
||||
body_markdown:
|
||||
"## 服务能力\nExample Technology Co., Ltd. 提供 GEO optimization 服务。",
|
||||
image_suggestions: [],
|
||||
changed_sections: ["title", "body"],
|
||||
requires_user_confirmation: [],
|
||||
}, "llmcall_draft");
|
||||
mockTrackedLlmResult({ checks: [] }, "llmcall_qa");
|
||||
|
||||
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",
|
||||
"llm_call_started",
|
||||
"llm_call_responded",
|
||||
"llm_call_validated",
|
||||
"fact_card_ready",
|
||||
"draft_started",
|
||||
"llm_call_started",
|
||||
"llm_call_responded",
|
||||
"llm_call_validated",
|
||||
"draft_ready",
|
||||
"qa_started",
|
||||
"llm_call_started",
|
||||
"llm_call_responded",
|
||||
"llm_call_validated",
|
||||
"qa_ready",
|
||||
"final_ready",
|
||||
]);
|
||||
expect(JSON.stringify(events)).not.toContain("messages");
|
||||
expect(JSON.stringify(events)).not.toContain("choices");
|
||||
expect(JSON.stringify(events)).not.toContain("test-key");
|
||||
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 +473,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(
|
||||
@@ -525,6 +795,47 @@ async function createJobFixture() {
|
||||
return response.json() as Promise<{ job: { id: string } }>;
|
||||
}
|
||||
|
||||
function mockTrackedLlmResult(value: unknown, callId: string) {
|
||||
llmMocks.generateValidatedJson.mockImplementationOnce(async (input) => {
|
||||
const onTraceEvent = input.onTraceEvent as
|
||||
| ((event: Record<string, unknown>) => void | Promise<void>)
|
||||
| undefined;
|
||||
await onTraceEvent?.({
|
||||
type: "started",
|
||||
call_id: callId,
|
||||
task: input.task ?? "unknown",
|
||||
context: {
|
||||
workflow_stage: input.traceStage ?? "unknown",
|
||||
rewrite_round: input.rewriteRound,
|
||||
schema_name: input.schemaName,
|
||||
},
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
request: { model: "deepseek-v4-pro", messages: [] },
|
||||
started_at: "2026-07-16T00:00:00.000Z",
|
||||
});
|
||||
await onTraceEvent?.({
|
||||
type: "responded",
|
||||
call_id: callId,
|
||||
response: {
|
||||
choices: [{ message: { content: JSON.stringify(value) } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
|
||||
},
|
||||
duration_ms: 1200,
|
||||
responded_at: "2026-07-16T00:00:01.200Z",
|
||||
});
|
||||
await onTraceEvent?.({
|
||||
type: "validated",
|
||||
call_id: callId,
|
||||
schema_name: input.schemaName ?? "anonymousSchema",
|
||||
schema_valid: true,
|
||||
validation_issues: [],
|
||||
validated_at: "2026-07-16T00:00:01.300Z",
|
||||
});
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -539,6 +850,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) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createSqliteRepository } from "../../../lib/db/sqlite-repository";
|
||||
import { createLocalTracePayloadStore } from "../../../lib/llm/trace-payload-store";
|
||||
import { createSqliteTraceRepository } from "../../../lib/llm/sqlite-trace-repository";
|
||||
import type {
|
||||
LlmTraceCall,
|
||||
LlmTraceManifest,
|
||||
LlmTraceRun,
|
||||
} from "../../../lib/llm/trace-types";
|
||||
import { GET as getLatestTrace } from "../llm-traces/latest/route";
|
||||
import { GET as getJobTrace } from "../jobs/[jobId]/llm-trace/route";
|
||||
import { GET as getTraceRequest } from "../jobs/[jobId]/llm-trace/[callId]/request/route";
|
||||
import { GET as getTraceResponse } from "../jobs/[jobId]/llm-trace/[callId]/response/route";
|
||||
|
||||
const exactRequestFixture = {
|
||||
model: "deepseek-v4-pro",
|
||||
temperature: 0.1,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [{ role: "user", content: "完整文章正文" }],
|
||||
};
|
||||
|
||||
const exactResponseFixture = {
|
||||
id: "chatcmpl_1",
|
||||
choices: [{ message: { content: '{"ok":true}' }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
|
||||
};
|
||||
|
||||
describe("LLM trace read APIs", () => {
|
||||
let tempDir: string;
|
||||
let jobId: string;
|
||||
const originalDataDir = process.env.APP_DATA_DIR;
|
||||
const originalApiKey = process.env.API_ACCESS_KEY;
|
||||
const originalAuthDisabled = process.env.API_AUTH_DISABLED;
|
||||
const originalRuntime = process.env.APP_RUNTIME;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "geo-llm-trace-api-"));
|
||||
process.env.APP_DATA_DIR = tempDir;
|
||||
process.env.API_ACCESS_KEY = "test-key";
|
||||
process.env.API_AUTH_DISABLED = "false";
|
||||
delete process.env.APP_RUNTIME;
|
||||
|
||||
const appRepository = createSqliteRepository();
|
||||
const job = await appRepository.createArticleJob({
|
||||
source_title: "",
|
||||
source_body: "完整文章正文",
|
||||
image_inputs: [],
|
||||
publish_platform: "official_site",
|
||||
user_instructions: "",
|
||||
});
|
||||
jobId = job.id;
|
||||
|
||||
const traceRepository = createSqliteTraceRepository();
|
||||
const run: LlmTraceRun = {
|
||||
job_id: jobId,
|
||||
case_id: null,
|
||||
status: "running",
|
||||
current_stage: "draft",
|
||||
trace_completeness: "complete",
|
||||
error_stage: null,
|
||||
error_summary: null,
|
||||
started_at: "2026-07-16T00:00:00.000Z",
|
||||
finished_at: null,
|
||||
updated_at: "2026-07-16T00:00:01.000Z",
|
||||
};
|
||||
const call: LlmTraceCall = {
|
||||
call_id: "llmcall_1",
|
||||
job_id: jobId,
|
||||
sequence: 1,
|
||||
task: "article_optimizer",
|
||||
workflow_stage: "draft",
|
||||
rewrite_round: null,
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
status: "validated",
|
||||
request_object_key: `llm-traces/${jobId}/llmcall_1/request.json`,
|
||||
response_object_key: `llm-traces/${jobId}/llmcall_1/response.json`,
|
||||
token_usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
|
||||
schema_name: "optimizedArticleSchema",
|
||||
schema_valid: true,
|
||||
validation_issues: [],
|
||||
business_status: null,
|
||||
duration_ms: 1200,
|
||||
started_at: "2026-07-16T00:00:00.000Z",
|
||||
responded_at: "2026-07-16T00:00:01.000Z",
|
||||
validated_at: "2026-07-16T00:00:01.200Z",
|
||||
failed_at: null,
|
||||
error_type: null,
|
||||
error_summary: null,
|
||||
};
|
||||
await traceRepository.putRun(run);
|
||||
await traceRepository.putCall(call);
|
||||
|
||||
const payloadStore = createLocalTracePayloadStore();
|
||||
await payloadStore.putJson(call.request_object_key!, exactRequestFixture);
|
||||
await payloadStore.putJson(call.response_object_key!, exactResponseFixture);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.APP_DATA_DIR = originalDataDir;
|
||||
process.env.API_ACCESS_KEY = originalApiKey;
|
||||
process.env.API_AUTH_DISABLED = originalAuthDisabled;
|
||||
process.env.APP_RUNTIME = originalRuntime;
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects trace reads without API access", async () => {
|
||||
const response = await getLatestTrace(request(null));
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("returns a no-store latest manifest without raw bodies or object keys", async () => {
|
||||
const response = await getLatestTrace(request("test-key"));
|
||||
const body = await response.json() as LlmTraceManifest;
|
||||
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(body.run.job_id).toBe(jobId);
|
||||
expect(body.calls[0]).toMatchObject({
|
||||
call_id: "llmcall_1",
|
||||
request_available: true,
|
||||
response_available: true,
|
||||
});
|
||||
expect(body.calls[0]).not.toHaveProperty("request_object_key");
|
||||
expect(body.calls[0]).not.toHaveProperty("response_object_key");
|
||||
expect(JSON.stringify(body)).not.toContain("messages");
|
||||
expect(JSON.stringify(body)).not.toContain("choices");
|
||||
});
|
||||
|
||||
it("returns the same protected manifest for a specific job", async () => {
|
||||
const response = await getJobTrace(
|
||||
request("test-key"),
|
||||
params({ jobId }),
|
||||
);
|
||||
const body = await response.json() as LlmTraceManifest;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(body.run.job_id).toBe(jobId);
|
||||
expect(body.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns the exact stored request and response through protected routes", async () => {
|
||||
const requestResponse = await getTraceRequest(
|
||||
request("test-key"),
|
||||
params({ jobId, callId: "llmcall_1" }),
|
||||
);
|
||||
const responseResponse = await getTraceResponse(
|
||||
request("test-key"),
|
||||
params({ jobId, callId: "llmcall_1" }),
|
||||
);
|
||||
|
||||
expect(requestResponse.headers.get("cache-control")).toBe("no-store");
|
||||
await expect(requestResponse.json()).resolves.toEqual(exactRequestFixture);
|
||||
await expect(responseResponse.json()).resolves.toEqual(exactResponseFixture);
|
||||
});
|
||||
|
||||
it("returns clear waiting and unavailable states when a response is absent", async () => {
|
||||
const repository = createSqliteTraceRepository();
|
||||
const [existing] = await repository.listCalls(jobId);
|
||||
await repository.putCall({
|
||||
...existing,
|
||||
call_id: "llmcall_waiting",
|
||||
sequence: 2,
|
||||
status: "started",
|
||||
response_object_key: null,
|
||||
});
|
||||
await repository.putCall({
|
||||
...existing,
|
||||
call_id: "llmcall_failed",
|
||||
sequence: 3,
|
||||
status: "failed",
|
||||
response_object_key: null,
|
||||
failed_at: "2026-07-16T00:00:02.000Z",
|
||||
error_type: "provider",
|
||||
error_summary: "Error: timeout",
|
||||
});
|
||||
|
||||
const waiting = await getTraceResponse(
|
||||
request("test-key"),
|
||||
params({ jobId, callId: "llmcall_waiting" }),
|
||||
);
|
||||
const unavailable = await getTraceResponse(
|
||||
request("test-key"),
|
||||
params({ jobId, callId: "llmcall_failed" }),
|
||||
);
|
||||
|
||||
expect(waiting.status).toBe(202);
|
||||
await expect(waiting.json()).resolves.toEqual({ state: "waiting" });
|
||||
expect(unavailable.status).toBe(404);
|
||||
await expect(unavailable.json()).resolves.toEqual({
|
||||
error: "该调用未产生响应",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function request(apiKey: string | null) {
|
||||
return new Request("http://localhost/api/llm-traces/latest", {
|
||||
headers: apiKey ? { "x-api-key": apiKey } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function params<T extends Record<string, string>>(values: T) {
|
||||
return { params: Promise.resolve(values) };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { requireApiAccess } from "../../../../../../../lib/api/auth";
|
||||
import {
|
||||
noStoreResponse,
|
||||
readTracePayload,
|
||||
} from "../../../../../../../lib/llm/trace-http";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ jobId: string; callId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
const access = requireApiAccess(request);
|
||||
if (!access.ok) return noStoreResponse(access.response);
|
||||
|
||||
const { jobId, callId } = await context.params;
|
||||
return readTracePayload({ jobId, callId, kind: "request" });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { requireApiAccess } from "../../../../../../../lib/api/auth";
|
||||
import {
|
||||
noStoreResponse,
|
||||
readTracePayload,
|
||||
} from "../../../../../../../lib/llm/trace-http";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ jobId: string; callId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
const access = requireApiAccess(request);
|
||||
if (!access.ok) return noStoreResponse(access.response);
|
||||
|
||||
const { jobId, callId } = await context.params;
|
||||
return readTracePayload({ jobId, callId, kind: "response" });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { requireApiAccess } from "../../../../../lib/api/auth";
|
||||
import {
|
||||
getTraceManifest,
|
||||
noStoreJson,
|
||||
noStoreResponse,
|
||||
} from "../../../../../lib/llm/trace-http";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ jobId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
const access = requireApiAccess(request);
|
||||
if (!access.ok) return noStoreResponse(access.response);
|
||||
|
||||
const { jobId } = await context.params;
|
||||
const manifest = await getTraceManifest(jobId);
|
||||
return manifest
|
||||
? noStoreJson(manifest)
|
||||
: noStoreJson({ error: "追踪任务不存在" }, 404);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
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 { getLlmTracePayloadStoreFromRuntime } from "../../../../lib/llm/trace-payload-store";
|
||||
import {
|
||||
createLlmTraceRecorder,
|
||||
createNoopLlmTraceRecorder,
|
||||
safeTraceError,
|
||||
} from "../../../../lib/llm/trace-recorder";
|
||||
import { getLlmTraceRepositoryFromRuntime } from "../../../../lib/llm/trace-repository";
|
||||
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";
|
||||
let traceRecorder = createNoopLlmTraceRecorder();
|
||||
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,
|
||||
},
|
||||
});
|
||||
let traceSetupError: unknown;
|
||||
try {
|
||||
traceRecorder = await createLlmTraceRecorder({
|
||||
jobId: job.id,
|
||||
caseId: optimizationCase.id,
|
||||
repository: getLlmTraceRepositoryFromRuntime(),
|
||||
payloadStore: getLlmTracePayloadStoreFromRuntime(),
|
||||
publish: (event) => send(event),
|
||||
});
|
||||
} catch (error) {
|
||||
traceSetupError = error;
|
||||
}
|
||||
send({
|
||||
type: "job_created",
|
||||
job: { id: job.id },
|
||||
case: { id: optimizationCase.id, case_type: "article" },
|
||||
});
|
||||
if (traceSetupError) {
|
||||
send({
|
||||
type: "trace_warning",
|
||||
job_id: job.id,
|
||||
trace_completeness: "incomplete",
|
||||
error_summary: safeTraceError(traceSetupError),
|
||||
});
|
||||
}
|
||||
|
||||
stage = "fact_card";
|
||||
const factCardStartedAt = Date.now();
|
||||
const factCard = optimizationFactCardSchema.parse(
|
||||
payload.fact_card ??
|
||||
(await extractCandidateFactCard(normalized.articleInput, {
|
||||
onAuditSummary: (summary) => {
|
||||
llmAuditSummary.push(summary);
|
||||
},
|
||||
onTraceEvent: traceRecorder.onLlmEvent,
|
||||
})),
|
||||
);
|
||||
processSummary.push(
|
||||
createProcessStep({
|
||||
stage: "fact_card",
|
||||
startedAt: factCardStartedAt,
|
||||
endedAt: Date.now(),
|
||||
status: "success",
|
||||
producedResultVersion: false,
|
||||
}),
|
||||
);
|
||||
const savedFactCard = await repository.saveFactCard(job.id, factCard);
|
||||
const factCardReadyEvent: OptimizationStreamEvent = {
|
||||
type: "fact_card_ready",
|
||||
job_id: job.id,
|
||||
fact_card: savedFactCard,
|
||||
};
|
||||
await traceRecorder.onWorkflowEvent(factCardReadyEvent);
|
||||
send(factCardReadyEvent);
|
||||
|
||||
const result = await runStreamingOptimizationWorkflow({
|
||||
jobId: job.id,
|
||||
input: normalized.articleInput,
|
||||
factCard: savedFactCard,
|
||||
onEvent: async (event) => {
|
||||
stage = stageForEvent(event, stage);
|
||||
await traceRecorder.onWorkflowEvent(event);
|
||||
send(event);
|
||||
},
|
||||
onTraceEvent: traceRecorder.onLlmEvent,
|
||||
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,
|
||||
});
|
||||
const finalReadyEvent: OptimizationStreamEvent = {
|
||||
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,
|
||||
};
|
||||
await traceRecorder.onWorkflowEvent(finalReadyEvent);
|
||||
await traceRecorder.finish({ status: "completed" });
|
||||
send(finalReadyEvent);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
const failedEvent: OptimizationStreamEvent = {
|
||||
type: "failed",
|
||||
job_id: jobId,
|
||||
case: caseId ? { id: caseId, case_type: "article" } : undefined,
|
||||
result_version: failedVersion,
|
||||
stage,
|
||||
error: message,
|
||||
};
|
||||
await traceRecorder.onWorkflowEvent(failedEvent);
|
||||
await traceRecorder.finish({
|
||||
status: "failed",
|
||||
errorStage: stage,
|
||||
errorSummary: message,
|
||||
});
|
||||
send(failedEvent);
|
||||
} 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { requireApiAccess } from "../../../../lib/api/auth";
|
||||
import {
|
||||
noStoreJson,
|
||||
noStoreResponse,
|
||||
toPublicTraceCall,
|
||||
} from "../../../../lib/llm/trace-http";
|
||||
import { getLlmTraceRepositoryFromRuntime } from "../../../../lib/llm/trace-repository";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const access = requireApiAccess(request);
|
||||
if (!access.ok) return noStoreResponse(access.response);
|
||||
|
||||
const repository = getLlmTraceRepositoryFromRuntime();
|
||||
const run = await repository.getLatestRun();
|
||||
if (!run) return noStoreJson({ run: null, calls: [] });
|
||||
const calls = await repository.listCalls(run.job_id);
|
||||
return noStoreJson({ run, calls: calls.map(toPublicTraceCall) });
|
||||
}
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { CaseList } from "../../components/cases/case-list";
|
||||
|
||||
export default function CasesPage() {
|
||||
return <CaseList />;
|
||||
}
|
||||
|
||||
+794
-3
@@ -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,288 @@ h3 {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.architecture-observer {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.architecture-task-strip {
|
||||
align-items: center;
|
||||
background: #172033;
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.architecture-task-strip > div:first-child {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.architecture-task-strip strong,
|
||||
.architecture-task-strip small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.architecture-task-strip small,
|
||||
.architecture-eyebrow {
|
||||
color: #cbd3df;
|
||||
}
|
||||
|
||||
.architecture-eyebrow {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.architecture-task-meta {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.architecture-task-meta > span {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
.technical-detail-toggle {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
color: #172033;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
.technical-detail-toggle input {
|
||||
accent-color: #2f6fdd;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.technical-detail-toggle span {
|
||||
color: inherit;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.architecture-warning {
|
||||
background: #fff7e6;
|
||||
border: 1px solid #f2c879;
|
||||
border-radius: 8px;
|
||||
color: #78520a;
|
||||
margin: 0;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.architecture-flow-panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dce2eb;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.architecture-flow {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
min-width: 62rem;
|
||||
}
|
||||
|
||||
.architecture-flow-step {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.architecture-node {
|
||||
border: 1px solid #cbd3df;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
flex: 1 0 0;
|
||||
gap: 0.3rem;
|
||||
min-height: 8.5rem;
|
||||
min-width: 8.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.architecture-node > span:not(.architecture-node-index),
|
||||
.architecture-node small {
|
||||
color: #586174;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.architecture-node-index {
|
||||
align-items: center;
|
||||
background: #eef1f6;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
height: 1.5rem;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.architecture-node-running {
|
||||
background: #eef5ff;
|
||||
border-color: #2f6fdd;
|
||||
box-shadow: 0 0 0 2px rgba(47, 111, 221, 0.1);
|
||||
}
|
||||
|
||||
.architecture-node-completed {
|
||||
background: #effaf4;
|
||||
border-color: #55a47a;
|
||||
}
|
||||
|
||||
.architecture-node-failed {
|
||||
background: #fff1f1;
|
||||
border-color: #d05858;
|
||||
}
|
||||
|
||||
.architecture-node-skipped {
|
||||
background: #f6f7f9;
|
||||
border-style: dashed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.architecture-arrow {
|
||||
color: #8992a3;
|
||||
flex: 0 0 1.4rem;
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.architecture-loop-label {
|
||||
color: #586174;
|
||||
font-size: 0.78rem;
|
||||
margin: 0.75rem 0 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.architecture-workspace {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(16rem, 0.7fr) minmax(0, 1.3fr);
|
||||
}
|
||||
|
||||
.llm-call-list,
|
||||
.llm-call-detail {
|
||||
min-height: 26rem;
|
||||
}
|
||||
|
||||
.llm-call-list {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.llm-call-list .panel-heading {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.llm-call-list button {
|
||||
background: #ffffff;
|
||||
border-color: #dce2eb;
|
||||
color: #172033;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
padding: 0.7rem;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.llm-call-list button.selected {
|
||||
border-color: #2f6fdd;
|
||||
box-shadow: 0 0 0 2px rgba(47, 111, 221, 0.1);
|
||||
}
|
||||
|
||||
.llm-call-list code,
|
||||
.llm-call-list small {
|
||||
color: #586174;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.llm-call-detail {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(18rem, 1fr);
|
||||
}
|
||||
|
||||
.llm-detail-heading,
|
||||
.llm-detail-tabs {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.llm-detail-heading {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.llm-detail-heading > div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.llm-detail-heading small {
|
||||
color: #586174;
|
||||
}
|
||||
|
||||
.llm-detail-tabs {
|
||||
border-bottom: 1px solid #dce2eb;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.llm-detail-tabs button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
color: #586174;
|
||||
}
|
||||
|
||||
.llm-detail-tabs button.active-tab {
|
||||
border-bottom-color: #2f6fdd;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.llm-json-view {
|
||||
background: #111827;
|
||||
border-radius: 6px;
|
||||
color: #dbeafe;
|
||||
font-size: 0.78rem;
|
||||
margin: 0.8rem 0 0;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.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 +517,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 +586,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 +637,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 +861,256 @@ 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,
|
||||
.architecture-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.architecture-task-strip {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.architecture-task-meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
+211
-204
@@ -1,25 +1,29 @@
|
||||
"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 { ArchitectureObserverPanel } from "../components/architecture/architecture-observer-panel";
|
||||
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 +35,6 @@ const initialInput: ArticleInputPayload = {
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: string;
|
||||
timing?: TimingSummary;
|
||||
}
|
||||
|
||||
interface TimingStep {
|
||||
@@ -44,42 +47,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" | "architecture">("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 +61,8 @@ 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("");
|
||||
const [architectureEvents, setArchitectureEvents] = useState<OptimizationStreamEvent[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!busyAction) return;
|
||||
@@ -102,127 +73,108 @@ 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("正在创建任务");
|
||||
setArchitectureEvents([]);
|
||||
|
||||
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) {
|
||||
setArchitectureEvents((current) => event.type === "job_created"
|
||||
? [event]
|
||||
: [...current, event]);
|
||||
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 "llm_call_started":
|
||||
case "llm_call_responded":
|
||||
case "llm_call_validated":
|
||||
case "llm_call_failed":
|
||||
case "trace_warning":
|
||||
break;
|
||||
case "failed":
|
||||
setStreamActivity("优化失败");
|
||||
throw new Error(event.error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="topbar">
|
||||
@@ -230,58 +182,131 @@ 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>
|
||||
<button
|
||||
className={activeTab === "architecture" ? "active-tab" : undefined}
|
||||
onClick={() => setActiveTab("architecture")}
|
||||
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>
|
||||
</>
|
||||
) : activeTab === "copy" ? (
|
||||
<RenweiCopyOptimizerPanel
|
||||
apiAccessKey={apiAccessKey}
|
||||
jobId={jobId}
|
||||
key={`${jobId ?? "no-job"}-${optimizedArticle?.revision ?? "no-revision"}`}
|
||||
optimizedRevision={optimizedArticle?.revision ?? null}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ArchitectureObserverPanel
|
||||
apiAccessKey={apiAccessKey}
|
||||
currentJobId={jobId}
|
||||
liveEvents={architectureEvents}
|
||||
/>
|
||||
)}
|
||||
</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 +314,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("其他内容");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getTracePayload } from "../api-client";
|
||||
|
||||
describe("architecture trace API client", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads request bodies only when explicitly requested", async () => {
|
||||
const exactRequest = {
|
||||
model: "deepseek-v4-pro",
|
||||
messages: [{ role: "user", content: "完整原文" }],
|
||||
};
|
||||
const fetchMock = vi.fn(async () => Response.json(exactRequest));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
getTracePayload("job_1", "llmcall_1", "request", "test-key"),
|
||||
).resolves.toEqual(exactRequest);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/jobs/job_1/llm-trace/llmcall_1/request",
|
||||
expect.objectContaining({
|
||||
credentials: "same-origin",
|
||||
headers: { "x-api-key": "test-key" },
|
||||
cache: "no-store",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces protected API errors without returning partial data", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401 },
|
||||
)));
|
||||
|
||||
await expect(
|
||||
getTracePayload("job_1", "llmcall_1", "response", "wrong"),
|
||||
).rejects.toThrow("Unauthorized");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
LlmTraceCallPublic,
|
||||
LlmTraceManifest,
|
||||
LlmTraceRun,
|
||||
} from "../../../lib/llm/trace-types";
|
||||
import {
|
||||
applyLiveOptimizationEvent,
|
||||
applyLiveTraceEvent,
|
||||
deriveArchitectureNodes,
|
||||
formatCallLabel,
|
||||
formatNodeStatus,
|
||||
} from "../trace-state";
|
||||
|
||||
const run: LlmTraceRun = {
|
||||
job_id: "job_1",
|
||||
case_id: "case_1",
|
||||
status: "running",
|
||||
current_stage: "qa",
|
||||
trace_completeness: "complete",
|
||||
error_stage: null,
|
||||
error_summary: null,
|
||||
started_at: "2026-07-16T00:00:00.000Z",
|
||||
finished_at: null,
|
||||
updated_at: "2026-07-16T00:00:01.000Z",
|
||||
};
|
||||
|
||||
function call(
|
||||
overrides: Partial<LlmTraceCallPublic> = {},
|
||||
): LlmTraceCallPublic {
|
||||
return {
|
||||
call_id: "llmcall_1",
|
||||
job_id: "job_1",
|
||||
sequence: 1,
|
||||
task: "quality_inspector",
|
||||
workflow_stage: "qa",
|
||||
rewrite_round: 0,
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
status: "validated",
|
||||
token_usage: { total_tokens: 14 },
|
||||
schema_name: "llmQaPatchSchema",
|
||||
schema_valid: true,
|
||||
validation_issues: [],
|
||||
business_status: "pass",
|
||||
duration_ms: 1200,
|
||||
started_at: "2026-07-16T00:00:00.000Z",
|
||||
responded_at: "2026-07-16T00:00:01.000Z",
|
||||
validated_at: "2026-07-16T00:00:01.200Z",
|
||||
failed_at: null,
|
||||
error_type: null,
|
||||
error_summary: null,
|
||||
request_available: true,
|
||||
response_available: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("architecture trace state", () => {
|
||||
it("formats every architecture node and call label in Chinese", () => {
|
||||
expect(formatNodeStatus("running")).toBe("运行中");
|
||||
expect(formatNodeStatus("failed")).toBe("失败");
|
||||
expect(formatCallLabel(call({
|
||||
sequence: 4,
|
||||
task: "targeted_rewriter",
|
||||
rewrite_round: 1,
|
||||
duration_ms: 22600,
|
||||
}))).toBe("4 · 定向修复第 1 轮 · 22.6 秒");
|
||||
});
|
||||
|
||||
it("distinguishes QA business failure from schema failure", () => {
|
||||
const nodes = deriveArchitectureNodes(run, [
|
||||
call({ schema_valid: true, business_status: "fail" }),
|
||||
]);
|
||||
expect(nodes.qa).toMatchObject({
|
||||
status: "completed",
|
||||
detail: "检查完成,需要修复",
|
||||
});
|
||||
|
||||
const failedNodes = deriveArchitectureNodes(run, [
|
||||
call({ schema_valid: false, status: "failed" }),
|
||||
]);
|
||||
expect(failedNodes.qa.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("merges repeated live events by call id and preserves sequence order", () => {
|
||||
const initial: LlmTraceManifest = { run, calls: [] };
|
||||
const started = applyLiveTraceEvent(initial, {
|
||||
type: "llm_call_started",
|
||||
job_id: "job_1",
|
||||
call_id: "llmcall_2",
|
||||
sequence: 2,
|
||||
task: "targeted_rewriter",
|
||||
workflow_stage: "rewrite",
|
||||
rewrite_round: 1,
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
started_at: "2026-07-16T00:00:02.000Z",
|
||||
request_available: true,
|
||||
});
|
||||
const responded = applyLiveTraceEvent(started, {
|
||||
type: "llm_call_responded",
|
||||
job_id: "job_1",
|
||||
call_id: "llmcall_2",
|
||||
duration_ms: 1500,
|
||||
token_usage: { total_tokens: 20 },
|
||||
responded_at: "2026-07-16T00:00:03.500Z",
|
||||
response_available: true,
|
||||
});
|
||||
const duplicate = applyLiveTraceEvent(responded, {
|
||||
type: "llm_call_responded",
|
||||
job_id: "job_1",
|
||||
call_id: "llmcall_2",
|
||||
duration_ms: 1500,
|
||||
token_usage: { total_tokens: 20 },
|
||||
responded_at: "2026-07-16T00:00:03.500Z",
|
||||
response_available: true,
|
||||
});
|
||||
|
||||
expect(duplicate.calls).toHaveLength(1);
|
||||
expect(duplicate.calls[0]).toMatchObject({
|
||||
call_id: "llmcall_2",
|
||||
status: "responded",
|
||||
response_available: true,
|
||||
token_usage: { total_tokens: 20 },
|
||||
});
|
||||
expect(duplicate.run.current_stage).toBe("rewrite");
|
||||
});
|
||||
|
||||
it("marks only trace completeness when a trace warning arrives", () => {
|
||||
const manifest = applyLiveTraceEvent(
|
||||
{ run, calls: [call()] },
|
||||
{
|
||||
type: "trace_warning",
|
||||
job_id: "job_1",
|
||||
trace_completeness: "incomplete",
|
||||
error_summary: "Error: payload unavailable",
|
||||
},
|
||||
);
|
||||
|
||||
expect(manifest.run).toMatchObject({
|
||||
status: "running",
|
||||
trace_completeness: "incomplete",
|
||||
});
|
||||
expect(manifest.calls[0].status).toBe("validated");
|
||||
});
|
||||
|
||||
it("uses workflow events to finish the run and retain QA business status", () => {
|
||||
const withQa = applyLiveOptimizationEvent(
|
||||
{ run, calls: [call({ business_status: null })] },
|
||||
{
|
||||
type: "qa_ready",
|
||||
job_id: "job_1",
|
||||
qa_report: { overall_status: "fail", checks: [] } as never,
|
||||
},
|
||||
);
|
||||
const completed = applyLiveOptimizationEvent(withQa, {
|
||||
type: "final_ready",
|
||||
job_id: "job_1",
|
||||
optimized_article: {} as never,
|
||||
qa_report: { overall_status: "fail", checks: [] } as never,
|
||||
export_paths: {},
|
||||
});
|
||||
|
||||
expect(withQa.calls[0].business_status).toBe("fail");
|
||||
expect(completed.run).toMatchObject({
|
||||
status: "completed",
|
||||
current_stage: "final",
|
||||
});
|
||||
expect(deriveArchitectureNodes(completed.run, completed.calls).final.status)
|
||||
.toBe("completed");
|
||||
});
|
||||
|
||||
it("uses workflow failure events to mark the matching architecture stage", () => {
|
||||
const failed = applyLiveOptimizationEvent(
|
||||
{ run: { ...run, current_stage: "draft" }, calls: [] },
|
||||
{
|
||||
type: "failed",
|
||||
job_id: "job_1",
|
||||
stage: "qa",
|
||||
error: "质量检查失败",
|
||||
},
|
||||
);
|
||||
|
||||
expect(failed.run).toMatchObject({
|
||||
status: "failed",
|
||||
current_stage: "qa",
|
||||
error_stage: "qa",
|
||||
error_summary: "质量检查失败",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { LlmTraceManifest } from "../../lib/llm/trace-types";
|
||||
|
||||
function traceHeaders(apiAccessKey: string): Record<string, string> {
|
||||
return apiAccessKey ? { "x-api-key": apiAccessKey } : {};
|
||||
}
|
||||
|
||||
async function traceFetch<T>(path: string, apiAccessKey: string): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: traceHeaders(apiAccessKey),
|
||||
cache: "no-store",
|
||||
});
|
||||
const body = await response.json().catch(() => ({})) as T & {
|
||||
error?: string;
|
||||
};
|
||||
if (!response.ok) throw new Error(body.error ?? "读取后台追踪失败");
|
||||
return body;
|
||||
}
|
||||
|
||||
export function getLatestTrace(apiAccessKey: string) {
|
||||
return traceFetch<LlmTraceManifest | { run: null; calls: [] }>(
|
||||
"/api/llm-traces/latest",
|
||||
apiAccessKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function getJobTrace(jobId: string, apiAccessKey: string) {
|
||||
return traceFetch<LlmTraceManifest>(
|
||||
`/api/jobs/${encodeURIComponent(jobId)}/llm-trace`,
|
||||
apiAccessKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTracePayload(
|
||||
jobId: string,
|
||||
callId: string,
|
||||
kind: "request" | "response",
|
||||
apiAccessKey: string,
|
||||
) {
|
||||
return traceFetch<unknown>(
|
||||
`/api/jobs/${encodeURIComponent(jobId)}/llm-trace/${encodeURIComponent(callId)}/${kind}`,
|
||||
apiAccessKey,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
formatNodeStatus,
|
||||
type ArchitectureNodeView,
|
||||
} from "./trace-state";
|
||||
|
||||
export function ArchitectureFlow({
|
||||
nodes,
|
||||
}: {
|
||||
nodes: Record<ArchitectureNodeView["id"], ArchitectureNodeView>;
|
||||
}) {
|
||||
const order: ArchitectureNodeView["id"][] = [
|
||||
"input",
|
||||
"fact_card",
|
||||
"draft",
|
||||
"qa",
|
||||
"rewrite",
|
||||
"final",
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="architecture-flow-panel" aria-label="文章优化后台架构">
|
||||
<div className="architecture-flow">
|
||||
{order.map((id, index) => {
|
||||
const node = nodes[id];
|
||||
return (
|
||||
<div className="architecture-flow-step" key={node.id}>
|
||||
<article
|
||||
className={`architecture-node architecture-node-${node.status}`}
|
||||
data-node={node.id}
|
||||
>
|
||||
<span className="architecture-node-index">{index + 1}</span>
|
||||
<strong>{node.label}</strong>
|
||||
<span>{formatNodeStatus(node.status)}</span>
|
||||
<small>{node.detail}</small>
|
||||
</article>
|
||||
{index < order.length - 1 && (
|
||||
<span aria-hidden="true" className="architecture-arrow">→</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="architecture-loop-label">
|
||||
质量检查未通过时,定向修复后返回复检,最多两轮。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
LlmTraceManifest,
|
||||
} from "../../lib/llm/trace-types";
|
||||
import type { OptimizationStreamEvent } from "../../lib/workflow/stream-events";
|
||||
import { getJobTrace, getLatestTrace } from "./api-client";
|
||||
import { ArchitectureFlow } from "./architecture-flow";
|
||||
import { LlmCallDetail } from "./llm-call-detail";
|
||||
import {
|
||||
applyLiveOptimizationEvent,
|
||||
deriveArchitectureNodes,
|
||||
formatCallLabel,
|
||||
formatNodeStatus,
|
||||
} from "./trace-state";
|
||||
|
||||
interface ArchitectureObserverPanelProps {
|
||||
apiAccessKey: string;
|
||||
currentJobId: string | null;
|
||||
liveEvents: OptimizationStreamEvent[];
|
||||
}
|
||||
|
||||
interface ManifestLoadState {
|
||||
key: string;
|
||||
status: "loaded" | "empty" | "error";
|
||||
manifest: LlmTraceManifest | null;
|
||||
error: string;
|
||||
}
|
||||
|
||||
function loadError(error: unknown) {
|
||||
return error instanceof Error ? error.message : "后台追踪加载失败";
|
||||
}
|
||||
|
||||
function runStatusLabel(status: LlmTraceManifest["run"]["status"]) {
|
||||
if (status === "running") return "运行中";
|
||||
if (status === "completed") return "已完成";
|
||||
if (status === "interrupted") return "已中断";
|
||||
return "失败";
|
||||
}
|
||||
|
||||
export function ArchitectureObserverPanel({
|
||||
apiAccessKey,
|
||||
currentJobId,
|
||||
liveEvents,
|
||||
}: ArchitectureObserverPanelProps) {
|
||||
const [loadState, setLoadState] = useState<ManifestLoadState | null>(null);
|
||||
const [selectedCallId, setSelectedCallId] = useState<string | null>(null);
|
||||
const [technicalDetailsEnabled, setTechnicalDetailsEnabled] = useState(false);
|
||||
const loadKey = `${currentJobId ?? "latest"}:${apiAccessKey}`;
|
||||
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
const request = currentJobId
|
||||
? getJobTrace(currentJobId, apiAccessKey)
|
||||
: getLatestTrace(apiAccessKey);
|
||||
request
|
||||
.then((result) => {
|
||||
if (!current) return;
|
||||
if (result.run === null) {
|
||||
setLoadState({
|
||||
key: loadKey,
|
||||
status: "empty",
|
||||
manifest: null,
|
||||
error: "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLoadState({
|
||||
key: loadKey,
|
||||
status: "loaded",
|
||||
manifest: result,
|
||||
error: "",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!current) return;
|
||||
setLoadState({
|
||||
key: loadKey,
|
||||
status: "error",
|
||||
manifest: null,
|
||||
error: loadError(error),
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [apiAccessKey, currentJobId, loadKey]);
|
||||
|
||||
const manifest = useMemo(() => {
|
||||
if (loadState?.key !== loadKey || !loadState.manifest) return null;
|
||||
return liveEvents.reduce(applyLiveOptimizationEvent, loadState.manifest);
|
||||
}, [liveEvents, loadKey, loadState]);
|
||||
|
||||
if (loadState?.key !== loadKey) {
|
||||
return <section className="panel empty-panel">正在读取后台架构…</section>;
|
||||
}
|
||||
if (loadState.status === "error") {
|
||||
return (
|
||||
<section className="panel empty-panel">
|
||||
<strong>无法读取后台追踪</strong>
|
||||
<p>{loadState.error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (loadState.status === "empty" || !manifest) {
|
||||
return (
|
||||
<section className="panel empty-panel">
|
||||
暂无后台追踪。请先在“GEO 文章优化”标签运行一次优化。
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const nodes = deriveArchitectureNodes(manifest.run, manifest.calls);
|
||||
const selectedCall = manifest.calls.find(
|
||||
(call) => call.call_id === selectedCallId,
|
||||
) ?? manifest.calls[0] ?? null;
|
||||
const latestCall = manifest.calls.at(-1) ?? null;
|
||||
const isCurrentTask = currentJobId === manifest.run.job_id;
|
||||
|
||||
return (
|
||||
<section className="architecture-observer stack">
|
||||
<header className="architecture-task-strip">
|
||||
<div>
|
||||
<span className="architecture-eyebrow">
|
||||
{isCurrentTask ? "当前任务" : "最近一次任务"}
|
||||
</span>
|
||||
<strong>{manifest.run.job_id}</strong>
|
||||
<small>
|
||||
{latestCall
|
||||
? `${latestCall.provider} · ${latestCall.model}`
|
||||
: "尚未发起 LLM 调用"}
|
||||
</small>
|
||||
</div>
|
||||
<div className="architecture-task-meta">
|
||||
<span>{runStatusLabel(manifest.run.status)}</span>
|
||||
<span>{formatNodeStatus(nodes[manifest.run.current_stage === "unknown" ? "input" : manifest.run.current_stage].status)}</span>
|
||||
<label className="technical-detail-toggle">
|
||||
<input
|
||||
checked={technicalDetailsEnabled}
|
||||
onChange={(event) => setTechnicalDetailsEnabled(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>技术详情</span>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{manifest.run.trace_completeness === "incomplete" && (
|
||||
<p className="architecture-warning" role="status">
|
||||
追踪记录不完整:已保存的数据仍可查看,但部分阶段或正文可能缺失。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ArchitectureFlow nodes={nodes} />
|
||||
|
||||
<div className="architecture-workspace">
|
||||
<aside className="panel llm-call-list">
|
||||
<div className="panel-heading">LLM 调用轨迹</div>
|
||||
{manifest.calls.length === 0 ? (
|
||||
<p className="empty-panel">等待第一次 LLM 调用。</p>
|
||||
) : manifest.calls.map((call) => (
|
||||
<button
|
||||
className={selectedCall?.call_id === call.call_id ? "selected" : undefined}
|
||||
key={call.call_id}
|
||||
onClick={() => setSelectedCallId(call.call_id)}
|
||||
type="button"
|
||||
>
|
||||
<strong>{formatCallLabel(call)}</strong>
|
||||
<code>{call.task}</code>
|
||||
<small>{call.schema_valid === false ? "Schema 失败" : call.status}</small>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{selectedCall ? (
|
||||
<LlmCallDetail
|
||||
apiAccessKey={apiAccessKey}
|
||||
call={selectedCall}
|
||||
jobId={manifest.run.job_id}
|
||||
key={selectedCall.call_id}
|
||||
technicalDetailsEnabled={technicalDetailsEnabled}
|
||||
/>
|
||||
) : (
|
||||
<section className="panel empty-panel">请选择一次 LLM 调用。</section>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { LlmTraceCallPublic } from "../../lib/llm/trace-types";
|
||||
import { getTracePayload } from "./api-client";
|
||||
|
||||
type DetailTab = "request" | "response" | "validation";
|
||||
|
||||
interface LoadedPayload {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
function detailTabLabel(tab: DetailTab) {
|
||||
if (tab === "request") return "请求";
|
||||
if (tab === "response") return "响应";
|
||||
return "校验";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "追踪正文加载失败";
|
||||
}
|
||||
|
||||
function validationView(call: LlmTraceCallPublic) {
|
||||
return {
|
||||
schema_name: call.schema_name,
|
||||
schema_valid: call.schema_valid,
|
||||
validation_issues: call.validation_issues,
|
||||
business_status: call.business_status,
|
||||
error_type: call.error_type,
|
||||
error_summary: call.error_summary,
|
||||
duration_ms: call.duration_ms,
|
||||
token_usage: call.token_usage,
|
||||
};
|
||||
}
|
||||
|
||||
export function LlmCallDetail({
|
||||
jobId,
|
||||
call,
|
||||
apiAccessKey,
|
||||
technicalDetailsEnabled,
|
||||
}: {
|
||||
jobId: string;
|
||||
call: LlmTraceCallPublic;
|
||||
apiAccessKey: string;
|
||||
technicalDetailsEnabled: boolean;
|
||||
}) {
|
||||
const [tab, setTab] = useState<DetailTab>("validation");
|
||||
const [loadedPayload, setLoadedPayload] = useState<LoadedPayload | null>(null);
|
||||
const payloadKey = `${call.call_id}:${tab}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!technicalDetailsEnabled || tab === "validation") return;
|
||||
let current = true;
|
||||
getTracePayload(jobId, call.call_id, tab, apiAccessKey)
|
||||
.then((value) => {
|
||||
if (current) setLoadedPayload({ key: payloadKey, value });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (current) {
|
||||
setLoadedPayload({
|
||||
key: payloadKey,
|
||||
value: { error: errorMessage(error) },
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [apiAccessKey, call.call_id, jobId, payloadKey, tab, technicalDetailsEnabled]);
|
||||
|
||||
const displayValue = tab === "validation"
|
||||
? validationView(call)
|
||||
: !technicalDetailsEnabled
|
||||
? { state: "locked", message: "开启技术详情后按需读取完整正文。" }
|
||||
: loadedPayload?.key === payloadKey
|
||||
? loadedPayload.value
|
||||
: { state: "loading", message: "正在读取完整 JSON…" };
|
||||
|
||||
return (
|
||||
<section className="panel llm-call-detail" aria-live="polite">
|
||||
<div className="llm-detail-heading">
|
||||
<div>
|
||||
<strong>{call.task}</strong>
|
||||
<small>{call.provider} · {call.model}</small>
|
||||
</div>
|
||||
<span className={`status-pill ${call.status === "failed" ? "fail" : "pass"}`}>
|
||||
{call.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="llm-detail-tabs" role="tablist" aria-label="LLM 调用详情">
|
||||
{(["request", "response", "validation"] as const).map((value) => (
|
||||
<button
|
||||
aria-selected={tab === value}
|
||||
className={tab === value ? "active-tab" : undefined}
|
||||
key={value}
|
||||
onClick={() => setTab(value)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{detailTabLabel(value)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<pre className="llm-json-view">{JSON.stringify(displayValue, null, 2)}</pre>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import type {
|
||||
LlmTraceCallPublic,
|
||||
LlmTraceManifest,
|
||||
LlmTraceRun,
|
||||
LlmTraceStreamEvent,
|
||||
LlmTraceWorkflowStage,
|
||||
} from "../../lib/llm/trace-types";
|
||||
import type { OptimizationStreamEvent } from "../../lib/workflow/stream-events";
|
||||
|
||||
export type ArchitectureNodeStatus =
|
||||
| "waiting"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export interface ArchitectureNodeView {
|
||||
id: "input" | "fact_card" | "draft" | "qa" | "rewrite" | "final";
|
||||
label: string;
|
||||
status: ArchitectureNodeStatus;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export function formatNodeStatus(status: ArchitectureNodeStatus) {
|
||||
return {
|
||||
waiting: "等待中",
|
||||
running: "运行中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
}[status];
|
||||
}
|
||||
|
||||
export function formatTaskName(call: LlmTraceCallPublic) {
|
||||
if (call.task === "fact_extractor") return "事实提取";
|
||||
if (call.task === "article_optimizer") return "生成优化稿";
|
||||
if (call.task === "quality_inspector") {
|
||||
return call.rewrite_round && call.rewrite_round > 0
|
||||
? `质量复检第 ${call.rewrite_round} 轮`
|
||||
: "质量检查";
|
||||
}
|
||||
if (call.task === "targeted_rewriter") {
|
||||
return `定向修复第 ${call.rewrite_round ?? 1} 轮`;
|
||||
}
|
||||
if (call.task === "renwei_copy_optimizer") return "普通文案优化";
|
||||
return "未知调用";
|
||||
}
|
||||
|
||||
export function formatCallLabel(call: LlmTraceCallPublic) {
|
||||
const duration = call.duration_ms == null
|
||||
? "进行中"
|
||||
: `${(call.duration_ms / 1000).toFixed(1)} 秒`;
|
||||
return `${call.sequence} · ${formatTaskName(call)} · ${duration}`;
|
||||
}
|
||||
|
||||
const nodeDefinitions: Array<Pick<ArchitectureNodeView, "id" | "label">> = [
|
||||
{ id: "input", label: "输入归一化" },
|
||||
{ id: "fact_card", label: "事实提取" },
|
||||
{ id: "draft", label: "生成草稿" },
|
||||
{ id: "qa", label: "质量检查" },
|
||||
{ id: "rewrite", label: "定向修复" },
|
||||
{ id: "final", label: "保存与导出" },
|
||||
];
|
||||
|
||||
function waitingNodes() {
|
||||
return Object.fromEntries(nodeDefinitions.map((node) => [
|
||||
node.id,
|
||||
{ ...node, status: "waiting", detail: "等待执行" },
|
||||
])) as Record<ArchitectureNodeView["id"], ArchitectureNodeView>;
|
||||
}
|
||||
|
||||
function updateCall(
|
||||
calls: LlmTraceCallPublic[],
|
||||
callId: string,
|
||||
update: (call: LlmTraceCallPublic) => LlmTraceCallPublic,
|
||||
) {
|
||||
return calls
|
||||
.map((call) => call.call_id === callId ? update(call) : call)
|
||||
.sort((left, right) => left.sequence - right.sequence);
|
||||
}
|
||||
|
||||
export function applyLiveTraceEvent(
|
||||
state: LlmTraceManifest,
|
||||
event: LlmTraceStreamEvent,
|
||||
): LlmTraceManifest {
|
||||
if (event.job_id !== state.run.job_id) return state;
|
||||
|
||||
if (event.type === "trace_warning") {
|
||||
return {
|
||||
...state,
|
||||
run: {
|
||||
...state.run,
|
||||
trace_completeness: "incomplete",
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "llm_call_started") {
|
||||
const call: LlmTraceCallPublic = {
|
||||
call_id: event.call_id,
|
||||
job_id: event.job_id,
|
||||
sequence: event.sequence,
|
||||
task: event.task,
|
||||
workflow_stage: event.workflow_stage,
|
||||
rewrite_round: event.rewrite_round,
|
||||
provider: event.provider,
|
||||
model: event.model,
|
||||
status: "started",
|
||||
token_usage: null,
|
||||
schema_name: null,
|
||||
schema_valid: null,
|
||||
validation_issues: [],
|
||||
business_status: null,
|
||||
duration_ms: null,
|
||||
started_at: event.started_at,
|
||||
responded_at: null,
|
||||
validated_at: null,
|
||||
failed_at: null,
|
||||
error_type: null,
|
||||
error_summary: null,
|
||||
request_available: event.request_available,
|
||||
response_available: false,
|
||||
};
|
||||
const withoutExisting = state.calls.filter(
|
||||
(existing) => existing.call_id !== event.call_id,
|
||||
);
|
||||
return {
|
||||
run: {
|
||||
...state.run,
|
||||
current_stage: event.workflow_stage,
|
||||
updated_at: event.started_at,
|
||||
},
|
||||
calls: [...withoutExisting, call].sort(
|
||||
(left, right) => left.sequence - right.sequence,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "llm_call_responded") {
|
||||
return {
|
||||
...state,
|
||||
run: { ...state.run, updated_at: event.responded_at },
|
||||
calls: updateCall(state.calls, event.call_id, (call) => ({
|
||||
...call,
|
||||
status: "responded",
|
||||
duration_ms: event.duration_ms,
|
||||
token_usage: event.token_usage,
|
||||
responded_at: event.responded_at,
|
||||
response_available: event.response_available,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "llm_call_validated") {
|
||||
return {
|
||||
...state,
|
||||
run: { ...state.run, updated_at: event.validated_at },
|
||||
calls: updateCall(state.calls, event.call_id, (call) => ({
|
||||
...call,
|
||||
status: "validated",
|
||||
schema_name: event.schema_name,
|
||||
schema_valid: event.schema_valid,
|
||||
validation_issues: event.validation_issues,
|
||||
validated_at: event.validated_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
run: {
|
||||
...state.run,
|
||||
status: "failed",
|
||||
error_stage: state.run.current_stage,
|
||||
error_summary: event.error_summary,
|
||||
updated_at: event.failed_at,
|
||||
},
|
||||
calls: updateCall(state.calls, event.call_id, (call) => ({
|
||||
...call,
|
||||
status: "failed",
|
||||
failed_at: event.failed_at,
|
||||
error_type: event.error_type,
|
||||
error_summary: event.error_summary,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function isTraceEvent(
|
||||
event: OptimizationStreamEvent,
|
||||
): event is LlmTraceStreamEvent {
|
||||
return event.type === "llm_call_started"
|
||||
|| event.type === "llm_call_responded"
|
||||
|| event.type === "llm_call_validated"
|
||||
|| event.type === "llm_call_failed"
|
||||
|| event.type === "trace_warning";
|
||||
}
|
||||
|
||||
function workflowStageForEvent(
|
||||
event: OptimizationStreamEvent,
|
||||
): LlmTraceWorkflowStage | null {
|
||||
if (event.type === "fact_card_ready") return "fact_card";
|
||||
if (event.type === "draft_started" || event.type === "draft_ready") return "draft";
|
||||
if (event.type === "qa_started" || event.type === "qa_ready") return "qa";
|
||||
if (event.type === "rewrite_started" || event.type === "rewrite_ready") return "rewrite";
|
||||
if (event.type === "final_ready") return "final";
|
||||
if (event.type === "failed") {
|
||||
return event.stage === "job" ? "input" : event.stage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function applyLiveOptimizationEvent(
|
||||
state: LlmTraceManifest,
|
||||
event: OptimizationStreamEvent,
|
||||
): LlmTraceManifest {
|
||||
if (isTraceEvent(event)) return applyLiveTraceEvent(state, event);
|
||||
if (event.type === "job_created") return state;
|
||||
if (event.job_id !== state.run.job_id) return state;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (event.type === "qa_ready") {
|
||||
const qaCalls = state.calls.filter((call) => call.task === "quality_inspector");
|
||||
const latestQaCall = qaCalls.at(-1);
|
||||
return {
|
||||
run: { ...state.run, current_stage: "qa", updated_at: now },
|
||||
calls: latestQaCall
|
||||
? updateCall(state.calls, latestQaCall.call_id, (call) => ({
|
||||
...call,
|
||||
business_status: event.qa_report.overall_status,
|
||||
}))
|
||||
: state.calls,
|
||||
};
|
||||
}
|
||||
|
||||
const stage = workflowStageForEvent(event);
|
||||
if (!stage) return state;
|
||||
if (event.type === "final_ready") {
|
||||
return {
|
||||
...state,
|
||||
run: {
|
||||
...state.run,
|
||||
status: "completed",
|
||||
current_stage: "final",
|
||||
finished_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (event.type === "failed") {
|
||||
return {
|
||||
...state,
|
||||
run: {
|
||||
...state.run,
|
||||
status: "failed",
|
||||
current_stage: stage,
|
||||
error_stage: event.stage,
|
||||
error_summary: event.error,
|
||||
finished_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
run: { ...state.run, current_stage: stage, updated_at: now },
|
||||
};
|
||||
}
|
||||
|
||||
function callDetail(call: LlmTraceCallPublic) {
|
||||
if (call.status === "failed" || call.schema_valid === false) {
|
||||
if (call.error_type === "provider") return "模型服务调用失败";
|
||||
if (call.error_type === "json_parse") return "响应 JSON 解析失败";
|
||||
return "Schema 校验失败";
|
||||
}
|
||||
if (call.status === "started" || call.status === "responded") {
|
||||
return "LLM 调用进行中";
|
||||
}
|
||||
if (call.task === "quality_inspector" && call.business_status === "fail") {
|
||||
return "检查完成,需要修复";
|
||||
}
|
||||
if (call.task === "quality_inspector" && call.business_status === "warn") {
|
||||
return "检查完成,存在警告";
|
||||
}
|
||||
return "LLM 调用完成";
|
||||
}
|
||||
|
||||
function statusForCall(call: LlmTraceCallPublic): ArchitectureNodeStatus {
|
||||
if (call.status === "failed" || call.schema_valid === false) return "failed";
|
||||
if (call.status === "started" || call.status === "responded") return "running";
|
||||
return "completed";
|
||||
}
|
||||
|
||||
function isNodeStage(
|
||||
stage: LlmTraceWorkflowStage,
|
||||
): stage is ArchitectureNodeView["id"] {
|
||||
return nodeDefinitions.some((node) => node.id === stage);
|
||||
}
|
||||
|
||||
export function deriveArchitectureNodes(
|
||||
run: LlmTraceRun | null,
|
||||
calls: LlmTraceCallPublic[],
|
||||
): Record<ArchitectureNodeView["id"], ArchitectureNodeView> {
|
||||
const nodes = waitingNodes();
|
||||
if (!run) return nodes;
|
||||
|
||||
nodes.input = {
|
||||
...nodes.input,
|
||||
status: "completed",
|
||||
detail: "输入已规范化",
|
||||
};
|
||||
|
||||
const latestByStage = new Map<LlmTraceWorkflowStage, LlmTraceCallPublic>();
|
||||
for (const call of [...calls].sort((left, right) => left.sequence - right.sequence)) {
|
||||
latestByStage.set(call.workflow_stage, call);
|
||||
}
|
||||
for (const [stage, call] of latestByStage) {
|
||||
if (!isNodeStage(stage)) continue;
|
||||
nodes[stage] = {
|
||||
...nodes[stage],
|
||||
status: statusForCall(call),
|
||||
detail: callDetail(call),
|
||||
};
|
||||
}
|
||||
|
||||
if (isNodeStage(run.current_stage) && nodes[run.current_stage].status === "waiting") {
|
||||
nodes[run.current_stage] = {
|
||||
...nodes[run.current_stage],
|
||||
status: run.status === "failed" ? "failed" : "running",
|
||||
detail: run.status === "failed" ? "任务在此阶段失败" : "后台正在执行",
|
||||
};
|
||||
}
|
||||
|
||||
const progression: ArchitectureNodeView["id"][] = [
|
||||
"input",
|
||||
"fact_card",
|
||||
"draft",
|
||||
"qa",
|
||||
"rewrite",
|
||||
"final",
|
||||
];
|
||||
const currentIndex = progression.indexOf(
|
||||
run.current_stage as ArchitectureNodeView["id"],
|
||||
);
|
||||
if (currentIndex > 0) {
|
||||
for (const stage of progression.slice(0, currentIndex)) {
|
||||
if (stage === "rewrite" && !latestByStage.has("rewrite")) continue;
|
||||
if (nodes[stage].status === "waiting") {
|
||||
nodes[stage] = {
|
||||
...nodes[stage],
|
||||
status: "completed",
|
||||
detail: "阶段已完成",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (run.status === "completed") {
|
||||
nodes.final = {
|
||||
...nodes.final,
|
||||
status: "completed",
|
||||
detail: "终稿与导出已保存",
|
||||
};
|
||||
if (nodes.rewrite.status === "waiting") {
|
||||
nodes.rewrite.detail = "本次未触发修复";
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" ? "已优化" : "失败"
|
||||
}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: "[]",
|
||||
|
||||
@@ -50,7 +50,12 @@ describe("sqlite repositories", () => {
|
||||
"article_jobs",
|
||||
"brand_templates",
|
||||
"calibration_events",
|
||||
"case_inputs",
|
||||
"fact_cards",
|
||||
"llm_trace_calls",
|
||||
"llm_trace_runs",
|
||||
"optimization_cases",
|
||||
"optimization_result_versions",
|
||||
"optimized_articles",
|
||||
"performance_snapshots",
|
||||
"publication_records",
|
||||
@@ -60,6 +65,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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+231
-9
@@ -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,69 @@ 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 llm_trace_runs (
|
||||
job_id text primary key,
|
||||
case_id text,
|
||||
status text not null,
|
||||
current_stage text not null,
|
||||
trace_completeness text not null,
|
||||
error_stage text,
|
||||
error_summary text,
|
||||
started_at text not null,
|
||||
finished_at text,
|
||||
updated_at text not null,
|
||||
foreign key (job_id) references article_jobs(id) on delete cascade,
|
||||
foreign key (case_id) references optimization_cases(id) on delete set null
|
||||
);
|
||||
|
||||
create table if not exists llm_trace_calls (
|
||||
call_id text primary key,
|
||||
job_id text not null,
|
||||
sequence integer not null,
|
||||
task text not null,
|
||||
workflow_stage text not null,
|
||||
rewrite_round integer,
|
||||
provider text not null,
|
||||
model text not null,
|
||||
status text not null,
|
||||
request_object_key text,
|
||||
response_object_key text,
|
||||
token_usage text,
|
||||
schema_name text,
|
||||
schema_valid integer,
|
||||
validation_issues text not null default '[]',
|
||||
business_status text,
|
||||
duration_ms integer,
|
||||
started_at text not null,
|
||||
responded_at text,
|
||||
validated_at text,
|
||||
failed_at text,
|
||||
error_type text,
|
||||
error_summary text,
|
||||
unique (job_id, sequence),
|
||||
foreign key (job_id) references llm_trace_runs(job_id) on delete cascade
|
||||
);
|
||||
|
||||
create table if not exists rubric_versions (
|
||||
id text primary key,
|
||||
version text not null,
|
||||
@@ -73,31 +170,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 +230,132 @@ 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);
|
||||
|
||||
create index if not exists idx_llm_trace_runs_status_updated
|
||||
on llm_trace_runs(status, updated_at desc);
|
||||
|
||||
create index if not exists idx_llm_trace_calls_job_sequence
|
||||
on llm_trace_calls(job_id, sequence);
|
||||
`);
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
articleInputSchema,
|
||||
confirmedFactCardSchema,
|
||||
candidateFactCardSchema,
|
||||
copyOptimizationRequestSchema,
|
||||
copyOptimizationResultSchema,
|
||||
optimizationFactCardSchema,
|
||||
optimizedArticleSchema,
|
||||
qaReportSchema,
|
||||
} from "../validation";
|
||||
@@ -25,6 +28,141 @@ 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("normalizes empty AI taste check evidence from LLM copy results", () => {
|
||||
const parsed = copyOptimizationResultSchema.parse({
|
||||
optimized_text: "我把句子顺了一下。",
|
||||
change_notes: [],
|
||||
ai_taste_checks: [
|
||||
{
|
||||
rule_id: "promotion_tone",
|
||||
status: "pass",
|
||||
evidence: "",
|
||||
suggestion: "",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
expect(parsed.ai_taste_checks[0]?.evidence).toBe("未提供具体证据。");
|
||||
});
|
||||
|
||||
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
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -212,6 +217,14 @@ const optionalLlmStringSchema = z.preprocess((value) => {
|
||||
return normalizeStringValue(value);
|
||||
}, z.string().trim().default(""));
|
||||
|
||||
const fallbackLlmEvidenceSchema = z.preprocess((value) => {
|
||||
const normalized = normalizeStringValue(value);
|
||||
if (typeof normalized === "string" && normalized.trim().length === 0) {
|
||||
return "未提供具体证据。";
|
||||
}
|
||||
return normalized;
|
||||
}, z.string().trim().min(1));
|
||||
|
||||
const stringListSchema = z.preprocess(
|
||||
normalizeStringList,
|
||||
z.array(z.string().trim().min(1)).default([]),
|
||||
@@ -280,6 +293,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 +443,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: fallbackLlmEvidenceSchema,
|
||||
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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as client from "../client";
|
||||
import type { LlmClientTraceEvent } from "../trace-types";
|
||||
|
||||
describe("generateValidatedJson", () => {
|
||||
const originalProvider = process.env.LLM_PROVIDER;
|
||||
@@ -13,9 +14,133 @@ describe("generateValidatedJson", () => {
|
||||
delete process.env.LLM_LOG_RAW_LIMIT;
|
||||
delete process.env.DEEPSEEK_MODEL;
|
||||
client.setGenerateJsonForValidation(client.generateJson);
|
||||
client.setChatCompletionForTesting(null);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("traces the exact SDK request and full SDK response", async () => {
|
||||
process.env.LLM_PROVIDER = "deepseek";
|
||||
process.env.DEEPSEEK_API_KEY = "test-key";
|
||||
const traced: LlmClientTraceEvent[] = [];
|
||||
let sdkRequest: unknown;
|
||||
const sdkResponse = {
|
||||
id: "chatcmpl_1",
|
||||
object: "chat.completion",
|
||||
created: 1784188800,
|
||||
model: "deepseek-v4-pro",
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: '{"value":"ok"}' },
|
||||
finish_reason: "stop",
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
|
||||
};
|
||||
client.setChatCompletionForTesting(async (request) => {
|
||||
sdkRequest = request;
|
||||
return sdkResponse;
|
||||
});
|
||||
|
||||
await client.generateValidatedJson({
|
||||
schema: z.object({ value: z.string() }),
|
||||
schemaName: "valueSchema",
|
||||
prompt: "Return JSON.",
|
||||
task: "article_optimizer",
|
||||
traceStage: "draft",
|
||||
onTraceEvent: (event) => {
|
||||
traced.push(event);
|
||||
},
|
||||
});
|
||||
|
||||
expect(traced.find((event) => event.type === "started")).toMatchObject({
|
||||
type: "started",
|
||||
request: sdkRequest,
|
||||
});
|
||||
expect(traced.find((event) => event.type === "responded")).toMatchObject({
|
||||
type: "responded",
|
||||
response: sdkResponse,
|
||||
});
|
||||
expect(traced.map((event) => event.type)).toEqual([
|
||||
"started",
|
||||
"responded",
|
||||
"validated",
|
||||
]);
|
||||
expect(JSON.stringify(traced)).not.toContain("test-key");
|
||||
});
|
||||
|
||||
it("traces JSON parse and schema failures in their actual order", async () => {
|
||||
process.env.LLM_PROVIDER = "deepseek";
|
||||
process.env.DEEPSEEK_API_KEY = "test-key";
|
||||
const jsonParseEvents: LlmClientTraceEvent[] = [];
|
||||
client.setChatCompletionForTesting(async () => ({
|
||||
choices: [{ message: { content: "not json" } }],
|
||||
}));
|
||||
|
||||
await expect(client.generateValidatedJson({
|
||||
schema: z.object({ value: z.string() }),
|
||||
schemaName: "valueSchema",
|
||||
prompt: "Return JSON.",
|
||||
onTraceEvent: (event) => {
|
||||
jsonParseEvents.push(event);
|
||||
},
|
||||
})).rejects.toThrow("not valid JSON");
|
||||
expect(jsonParseEvents.map((event) => event.type)).toEqual([
|
||||
"started",
|
||||
"responded",
|
||||
"failed",
|
||||
]);
|
||||
expect(jsonParseEvents.at(-1)).toMatchObject({
|
||||
type: "failed",
|
||||
error_type: "json_parse",
|
||||
});
|
||||
|
||||
const schemaEvents: LlmClientTraceEvent[] = [];
|
||||
client.setChatCompletionForTesting(async () => ({
|
||||
choices: [{ message: { content: '{"value":42}' } }],
|
||||
}));
|
||||
await expect(client.generateValidatedJson({
|
||||
schema: z.object({ value: z.string() }),
|
||||
schemaName: "valueSchema",
|
||||
prompt: "Return JSON.",
|
||||
onTraceEvent: (event) => {
|
||||
schemaEvents.push(event);
|
||||
},
|
||||
})).rejects.toThrow("schema validation");
|
||||
expect(schemaEvents.map((event) => event.type)).toEqual([
|
||||
"started",
|
||||
"responded",
|
||||
"validated",
|
||||
"failed",
|
||||
]);
|
||||
expect(schemaEvents.at(-1)).toMatchObject({
|
||||
type: "failed",
|
||||
error_type: "schema_validation",
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts configured secrets from traced provider errors", async () => {
|
||||
process.env.LLM_PROVIDER = "deepseek";
|
||||
process.env.DEEPSEEK_API_KEY = "super-secret-key";
|
||||
const traced: LlmClientTraceEvent[] = [];
|
||||
client.setChatCompletionForTesting(async () => {
|
||||
throw new Error("request failed for super-secret-key");
|
||||
});
|
||||
|
||||
await expect(client.generateValidatedJson({
|
||||
schema: z.object({ value: z.string() }),
|
||||
prompt: "Return JSON.",
|
||||
onTraceEvent: (event) => {
|
||||
traced.push(event);
|
||||
},
|
||||
})).rejects.toThrow("request failed");
|
||||
|
||||
expect(traced.map((event) => event.type)).toEqual(["started", "failed"]);
|
||||
expect(JSON.stringify(traced)).not.toContain("super-secret-key");
|
||||
expect(traced.at(-1)).toMatchObject({
|
||||
type: "failed",
|
||||
error_type: "provider",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws clearly when no provider key is configured", async () => {
|
||||
process.env.LLM_PROVIDER = "deepseek";
|
||||
delete process.env.DEEPSEEK_API_KEY;
|
||||
@@ -41,6 +166,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";
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createD1TraceRepository } from "../d1-trace-repository";
|
||||
import type { LlmTraceCall, LlmTraceRun } from "../trace-types";
|
||||
|
||||
const run: LlmTraceRun = {
|
||||
job_id: "job_1",
|
||||
case_id: "case_1",
|
||||
status: "running",
|
||||
current_stage: "draft",
|
||||
trace_completeness: "complete",
|
||||
error_stage: null,
|
||||
error_summary: null,
|
||||
started_at: "2026-07-16T00:00:00.000Z",
|
||||
finished_at: null,
|
||||
updated_at: "2026-07-16T00:00:01.000Z",
|
||||
};
|
||||
|
||||
const call: LlmTraceCall = {
|
||||
call_id: "llmcall_1",
|
||||
job_id: "job_1",
|
||||
sequence: 1,
|
||||
task: "article_optimizer",
|
||||
workflow_stage: "draft",
|
||||
rewrite_round: null,
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
status: "validated",
|
||||
request_object_key: "llm-traces/job_1/llmcall_1/request.json",
|
||||
response_object_key: "llm-traces/job_1/llmcall_1/response.json",
|
||||
token_usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
|
||||
schema_name: "optimizedArticleSchema",
|
||||
schema_valid: true,
|
||||
validation_issues: ["title: required"],
|
||||
business_status: null,
|
||||
duration_ms: 1200,
|
||||
started_at: "2026-07-16T00:00:00.000Z",
|
||||
responded_at: "2026-07-16T00:00:01.000Z",
|
||||
validated_at: "2026-07-16T00:00:01.200Z",
|
||||
failed_at: null,
|
||||
error_type: null,
|
||||
error_summary: null,
|
||||
};
|
||||
|
||||
describe("createD1TraceRepository", () => {
|
||||
it("binds run and call snapshots with JSON fields serialized", async () => {
|
||||
const runStatement = vi.fn().mockResolvedValue({ success: true });
|
||||
const bind = vi.fn().mockReturnValue({ run: runStatement });
|
||||
const prepare = vi.fn().mockReturnValue({ bind });
|
||||
const repository = createD1TraceRepository(
|
||||
{ prepare } as unknown as D1Database,
|
||||
);
|
||||
|
||||
await repository.putRun(run);
|
||||
await repository.putCall(call);
|
||||
|
||||
expect(prepare).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining("insert into llm_trace_runs"),
|
||||
);
|
||||
expect(prepare).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining("insert into llm_trace_calls"),
|
||||
);
|
||||
expect(bind).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
call.call_id,
|
||||
call.job_id,
|
||||
call.sequence,
|
||||
call.task,
|
||||
call.workflow_stage,
|
||||
null,
|
||||
call.provider,
|
||||
call.model,
|
||||
call.status,
|
||||
call.request_object_key,
|
||||
call.response_object_key,
|
||||
JSON.stringify(call.token_usage),
|
||||
call.schema_name,
|
||||
1,
|
||||
JSON.stringify(call.validation_issues),
|
||||
null,
|
||||
call.duration_ms,
|
||||
call.started_at,
|
||||
call.responded_at,
|
||||
call.validated_at,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads calls in sequence order and parses JSON fields", async () => {
|
||||
const all = vi.fn().mockResolvedValue({
|
||||
results: [{
|
||||
...call,
|
||||
token_usage: JSON.stringify(call.token_usage),
|
||||
schema_valid: 1,
|
||||
validation_issues: JSON.stringify(call.validation_issues),
|
||||
}],
|
||||
});
|
||||
const bind = vi.fn().mockReturnValue({ all });
|
||||
const prepare = vi.fn().mockReturnValue({ bind });
|
||||
const repository = createD1TraceRepository(
|
||||
{ prepare } as unknown as D1Database,
|
||||
);
|
||||
|
||||
await expect(repository.listCalls("job_1")).resolves.toEqual([call]);
|
||||
expect(prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining("order by sequence"),
|
||||
);
|
||||
expect(bind).toHaveBeenCalledWith("job_1");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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";
|
||||
|
||||
import {
|
||||
createLocalTracePayloadStore,
|
||||
createR2TracePayloadStore,
|
||||
} from "../trace-payload-store";
|
||||
|
||||
describe("LLM trace payload stores", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "geo-llm-payloads-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("round-trips exact JSON locally and deletes one job prefix", async () => {
|
||||
const store = createLocalTracePayloadStore(tempDir);
|
||||
const payload = {
|
||||
model: "deepseek-v4-pro",
|
||||
messages: [{ role: "user", content: "原文" }],
|
||||
};
|
||||
const key = "llm-traces/job_1/llmcall_1/request.json";
|
||||
|
||||
await store.putJson(key, payload);
|
||||
await expect(store.getJson(key)).resolves.toEqual(payload);
|
||||
await store.deleteJob("job_1");
|
||||
await expect(store.getJson(key)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("stores private JSON in R2 without a public URL", async () => {
|
||||
const put = vi.fn().mockResolvedValue(undefined);
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
json: async () => ({ ok: true }),
|
||||
});
|
||||
const list = vi.fn().mockResolvedValue({ objects: [], truncated: false });
|
||||
const deleteObjects = vi.fn().mockResolvedValue(undefined);
|
||||
const bucket = {
|
||||
put,
|
||||
get,
|
||||
list,
|
||||
delete: deleteObjects,
|
||||
} as unknown as R2Bucket;
|
||||
const store = createR2TracePayloadStore(bucket);
|
||||
|
||||
await store.putJson(
|
||||
"llm-traces/job_1/llmcall_1/request.json",
|
||||
{ ok: true },
|
||||
);
|
||||
await expect(
|
||||
store.getJson("llm-traces/job_1/llmcall_1/request.json"),
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(put).toHaveBeenCalledWith(
|
||||
"llm-traces/job_1/llmcall_1/request.json",
|
||||
JSON.stringify({ ok: true }),
|
||||
{ httpMetadata: { contentType: "application/json; charset=utf-8" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes every paginated R2 object under one job prefix", async () => {
|
||||
const list = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
objects: [{ key: "llm-traces/job_1/call_1/request.json" }],
|
||||
truncated: true,
|
||||
cursor: "next-page",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
objects: [{ key: "llm-traces/job_1/call_1/response.json" }],
|
||||
truncated: false,
|
||||
});
|
||||
const deleteObjects = vi.fn().mockResolvedValue(undefined);
|
||||
const bucket = { list, delete: deleteObjects } as unknown as R2Bucket;
|
||||
|
||||
await createR2TracePayloadStore(bucket).deleteJob("job_1");
|
||||
|
||||
expect(list).toHaveBeenNthCalledWith(1, {
|
||||
prefix: "llm-traces/job_1/",
|
||||
cursor: undefined,
|
||||
});
|
||||
expect(list).toHaveBeenNthCalledWith(2, {
|
||||
prefix: "llm-traces/job_1/",
|
||||
cursor: "next-page",
|
||||
});
|
||||
expect(deleteObjects).toHaveBeenNthCalledWith(1, [
|
||||
"llm-traces/job_1/call_1/request.json",
|
||||
]);
|
||||
expect(deleteObjects).toHaveBeenNthCalledWith(2, [
|
||||
"llm-traces/job_1/call_1/response.json",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { LlmTracePayloadStore } from "../trace-payload-store";
|
||||
import {
|
||||
createLlmTraceRecorder,
|
||||
type LlmTraceRecorder,
|
||||
} from "../trace-recorder";
|
||||
import type { LlmTraceRepository } from "../trace-repository";
|
||||
import type {
|
||||
LlmTraceCall,
|
||||
LlmTraceRun,
|
||||
LlmTraceStreamEvent,
|
||||
} from "../trace-types";
|
||||
|
||||
class MemoryTraceRepository implements LlmTraceRepository {
|
||||
runs = new Map<string, LlmTraceRun>();
|
||||
calls = new Map<string, LlmTraceCall>();
|
||||
deletedRuns: string[] = [];
|
||||
failWrites = false;
|
||||
|
||||
async putRun(run: LlmTraceRun) {
|
||||
if (this.failWrites) throw new Error("index unavailable");
|
||||
this.runs.set(run.job_id, structuredClone(run));
|
||||
}
|
||||
|
||||
async putCall(call: LlmTraceCall) {
|
||||
if (this.failWrites) throw new Error("index unavailable");
|
||||
this.calls.set(call.call_id, structuredClone(call));
|
||||
}
|
||||
|
||||
async getRun(jobId: string) {
|
||||
return this.runs.get(jobId) ?? null;
|
||||
}
|
||||
|
||||
async getLatestRun() {
|
||||
return [...this.runs.values()][0] ?? null;
|
||||
}
|
||||
|
||||
async listCalls(jobId: string) {
|
||||
return [...this.calls.values()]
|
||||
.filter((call) => call.job_id === jobId)
|
||||
.sort((left, right) => left.sequence - right.sequence);
|
||||
}
|
||||
|
||||
async listTerminalRunsExcept(jobId: string) {
|
||||
return [...this.runs.values()].filter(
|
||||
(run) => run.job_id !== jobId && run.status !== "running",
|
||||
);
|
||||
}
|
||||
|
||||
async deleteRun(jobId: string) {
|
||||
this.deletedRuns.push(jobId);
|
||||
this.runs.delete(jobId);
|
||||
for (const call of this.calls.values()) {
|
||||
if (call.job_id === jobId) this.calls.delete(call.call_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryPayloadStore implements LlmTracePayloadStore {
|
||||
values = new Map<string, unknown>();
|
||||
deletedJobs: string[] = [];
|
||||
failPuts = false;
|
||||
|
||||
async putJson(key: string, value: unknown) {
|
||||
if (this.failPuts) throw new Error("payload unavailable");
|
||||
this.values.set(key, structuredClone(value));
|
||||
}
|
||||
|
||||
async getJson(key: string) {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
async deleteJob(jobId: string) {
|
||||
this.deletedJobs.push(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
describe("createLlmTraceRecorder", () => {
|
||||
let repository: MemoryTraceRepository;
|
||||
let payloadStore: MemoryPayloadStore;
|
||||
let published: LlmTraceStreamEvent[];
|
||||
let recorder: LlmTraceRecorder;
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = new MemoryTraceRepository();
|
||||
payloadStore = new MemoryPayloadStore();
|
||||
published = [];
|
||||
recorder = await createLlmTraceRecorder({
|
||||
jobId: "job_1",
|
||||
caseId: "case_1",
|
||||
repository,
|
||||
payloadStore,
|
||||
publish: (event) => {
|
||||
published.push(event);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("persists exact bodies before publishing public metadata", async () => {
|
||||
await recorder.onLlmEvent({
|
||||
type: "started",
|
||||
call_id: "llmcall_1",
|
||||
task: "quality_inspector",
|
||||
context: { workflow_stage: "qa", schema_name: "llmQaPatchSchema" },
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
request: { model: "deepseek-v4-pro", messages: [] },
|
||||
started_at: "2026-07-16T00:00:00.000Z",
|
||||
});
|
||||
await recorder.onLlmEvent({
|
||||
type: "responded",
|
||||
call_id: "llmcall_1",
|
||||
response: {
|
||||
choices: [{ message: { content: "{}" } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
|
||||
},
|
||||
duration_ms: 1200,
|
||||
responded_at: "2026-07-16T00:00:01.200Z",
|
||||
});
|
||||
|
||||
expect(payloadStore.values.get(
|
||||
"llm-traces/job_1/llmcall_1/request.json",
|
||||
)).toEqual({ model: "deepseek-v4-pro", messages: [] });
|
||||
expect(payloadStore.values.get(
|
||||
"llm-traces/job_1/llmcall_1/response.json",
|
||||
)).toEqual({
|
||||
choices: [{ message: { content: "{}" } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
|
||||
});
|
||||
expect(published[0]).toMatchObject({
|
||||
type: "llm_call_started",
|
||||
request_available: true,
|
||||
});
|
||||
expect(published[1]).toMatchObject({
|
||||
type: "llm_call_responded",
|
||||
token_usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
|
||||
response_available: true,
|
||||
});
|
||||
expect(JSON.stringify(published)).not.toContain("messages");
|
||||
expect(JSON.stringify(published)).not.toContain("choices");
|
||||
});
|
||||
|
||||
it("stores QA business failure separately from schema success", async () => {
|
||||
await recorder.onLlmEvent({
|
||||
type: "started",
|
||||
call_id: "llmcall_qa",
|
||||
task: "quality_inspector",
|
||||
context: { workflow_stage: "qa", schema_name: "llmQaPatchSchema" },
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
request: { messages: [] },
|
||||
started_at: "2026-07-16T00:00:00.000Z",
|
||||
});
|
||||
await recorder.onLlmEvent({
|
||||
type: "validated",
|
||||
call_id: "llmcall_qa",
|
||||
schema_name: "llmQaPatchSchema",
|
||||
schema_valid: true,
|
||||
validation_issues: [],
|
||||
validated_at: "2026-07-16T00:00:01.000Z",
|
||||
});
|
||||
|
||||
await recorder.onWorkflowEvent({
|
||||
type: "qa_ready",
|
||||
job_id: "job_1",
|
||||
qa_report: { overall_status: "fail", checks: [] },
|
||||
});
|
||||
|
||||
await expect(repository.listCalls("job_1")).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
task: "quality_inspector",
|
||||
schema_valid: true,
|
||||
business_status: "fail",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps running runs and only the newest terminal full trace", async () => {
|
||||
repository.runs.set("job_old_completed", {
|
||||
...repository.runs.get("job_1")!,
|
||||
job_id: "job_old_completed",
|
||||
status: "completed",
|
||||
});
|
||||
repository.runs.set("job_other_running", {
|
||||
...repository.runs.get("job_1")!,
|
||||
job_id: "job_other_running",
|
||||
status: "running",
|
||||
});
|
||||
|
||||
await recorder.finish({ status: "completed" });
|
||||
|
||||
expect(payloadStore.deletedJobs).toEqual(["job_old_completed"]);
|
||||
expect(repository.deletedRuns).toEqual(["job_old_completed"]);
|
||||
expect(repository.deletedRuns).not.toContain("job_other_running");
|
||||
});
|
||||
|
||||
it("marks the trace incomplete without throwing when payload storage fails", async () => {
|
||||
payloadStore.failPuts = true;
|
||||
|
||||
await expect(recorder.onLlmEvent({
|
||||
type: "started",
|
||||
call_id: "llmcall_failed_storage",
|
||||
task: "fact_extractor",
|
||||
context: { workflow_stage: "fact_card" },
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
request: { messages: [{ role: "user", content: "原文" }] },
|
||||
started_at: "2026-07-16T00:00:00.000Z",
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(published).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "llm_call_started",
|
||||
request_available: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "trace_warning",
|
||||
trace_completeness: "incomplete",
|
||||
}),
|
||||
]);
|
||||
await expect(repository.getRun("job_1")).resolves.toMatchObject({
|
||||
trace_completeness: "incomplete",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createSqliteRepository } from "../../db/sqlite-repository";
|
||||
import type { LlmTraceCall, LlmTraceRun } from "../trace-types";
|
||||
import { createSqliteTraceRepository } from "../sqlite-trace-repository";
|
||||
|
||||
function runFixture(jobId: string): LlmTraceRun {
|
||||
return {
|
||||
job_id: jobId,
|
||||
case_id: null,
|
||||
status: "running",
|
||||
current_stage: "fact_card",
|
||||
trace_completeness: "complete",
|
||||
error_stage: null,
|
||||
error_summary: null,
|
||||
started_at: "2026-07-16T00:00:00.000Z",
|
||||
finished_at: null,
|
||||
updated_at: "2026-07-16T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function callFixture(
|
||||
jobId: string,
|
||||
callId: string,
|
||||
sequence: number,
|
||||
): LlmTraceCall {
|
||||
return {
|
||||
call_id: callId,
|
||||
job_id: jobId,
|
||||
sequence,
|
||||
task: sequence === 1 ? "fact_extractor" : "article_optimizer",
|
||||
workflow_stage: sequence === 1 ? "fact_card" : "draft",
|
||||
rewrite_round: null,
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
status: "started",
|
||||
request_object_key: `llm-traces/${jobId}/${callId}/request.json`,
|
||||
response_object_key: null,
|
||||
token_usage: null,
|
||||
schema_name: null,
|
||||
schema_valid: null,
|
||||
validation_issues: [],
|
||||
business_status: null,
|
||||
duration_ms: null,
|
||||
started_at: `2026-07-16T00:00:0${sequence}.000Z`,
|
||||
responded_at: null,
|
||||
validated_at: null,
|
||||
failed_at: null,
|
||||
error_type: null,
|
||||
error_summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createSqliteTraceRepository", () => {
|
||||
let tempDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "geo-llm-traces-"));
|
||||
dbPath = join(tempDir, "app.db");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("stores a run and ordered calls, then deletes only trace rows", async () => {
|
||||
const appRepository = createSqliteRepository(dbPath);
|
||||
const job = await appRepository.createArticleJob({
|
||||
source_title: "Title",
|
||||
source_body: "Body",
|
||||
image_inputs: [],
|
||||
publish_platform: "official_site",
|
||||
user_instructions: "",
|
||||
});
|
||||
const repository = createSqliteTraceRepository(dbPath);
|
||||
|
||||
await repository.putRun(runFixture(job.id));
|
||||
await repository.putCall(callFixture(job.id, "llmcall_1", 1));
|
||||
await repository.putCall(callFixture(job.id, "llmcall_2", 2));
|
||||
|
||||
await expect(repository.getLatestRun()).resolves.toMatchObject({
|
||||
job_id: job.id,
|
||||
status: "running",
|
||||
});
|
||||
await expect(repository.listCalls(job.id)).resolves.toEqual([
|
||||
expect.objectContaining({ call_id: "llmcall_1", sequence: 1 }),
|
||||
expect.objectContaining({ call_id: "llmcall_2", sequence: 2 }),
|
||||
]);
|
||||
|
||||
await repository.deleteRun(job.id);
|
||||
await expect(appRepository.getArticleJob(job.id)).resolves.not.toBeNull();
|
||||
await expect(repository.getRun(job.id)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { LlmProviderStatus } from "./client";
|
||||
import type { LlmTaskName } from "./trace-types";
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
+223
-40
@@ -1,12 +1,17 @@
|
||||
import OpenAI from "openai";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { z } from "zod";
|
||||
|
||||
export type LlmTaskName =
|
||||
| "unknown"
|
||||
| "fact_extractor"
|
||||
| "article_optimizer"
|
||||
| "quality_inspector"
|
||||
| "targeted_rewriter";
|
||||
import { createLlmAuditSummary, type LlmAuditSummary } from "./audit";
|
||||
import type {
|
||||
LlmClientTraceEvent,
|
||||
LlmClientTraceHandler,
|
||||
LlmTaskName,
|
||||
LlmTraceErrorType,
|
||||
LlmTraceWorkflowStage,
|
||||
} from "./trace-types";
|
||||
|
||||
export type { LlmTaskName } from "./trace-types";
|
||||
|
||||
export interface GenerateInput {
|
||||
system?: string;
|
||||
@@ -14,6 +19,12 @@ export interface GenerateInput {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
task?: LlmTaskName;
|
||||
schemaName?: string;
|
||||
traceStage?: LlmTraceWorkflowStage;
|
||||
rewriteRound?: number;
|
||||
onTraceEvent?: LlmClientTraceHandler;
|
||||
onAuditSummary?: (summary: LlmAuditSummary) => void | Promise<void>;
|
||||
traceCallId?: string;
|
||||
}
|
||||
|
||||
export interface GenerateValidatedJsonInput<T> extends GenerateInput {
|
||||
@@ -94,6 +105,64 @@ function getTask(input: GenerateInput): LlmTaskName {
|
||||
return input.task || "unknown";
|
||||
}
|
||||
|
||||
function callIdFor(input: GenerateInput) {
|
||||
return input.traceCallId ?? `llmcall_${nanoid(12)}`;
|
||||
}
|
||||
|
||||
function redactConfiguredSecrets(value: string) {
|
||||
return [process.env.DEEPSEEK_API_KEY, process.env.OPENAI_API_KEY]
|
||||
.filter((secret): secret is string => Boolean(secret))
|
||||
.reduce(
|
||||
(redacted, secret) => redacted.split(secret).join("[redacted]"),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
function safeErrorSummary(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return `${error.name}: ${redactConfiguredSecrets(error.message)}`;
|
||||
}
|
||||
return `Error: ${redactConfiguredSecrets(String(error))}`;
|
||||
}
|
||||
|
||||
async function emitTrace(input: GenerateInput, event: LlmClientTraceEvent) {
|
||||
try {
|
||||
await input.onTraceEvent?.(event);
|
||||
} catch (error) {
|
||||
console.warn(`[llm:trace-warning] ${safeErrorSummary(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function failedEvent(
|
||||
callId: string,
|
||||
errorType: LlmTraceErrorType,
|
||||
error: unknown,
|
||||
startedAt: number,
|
||||
): Extract<LlmClientTraceEvent, { type: "failed" }> {
|
||||
return {
|
||||
type: "failed",
|
||||
call_id: callId,
|
||||
error_type: errorType,
|
||||
error_summary: safeErrorSummary(error),
|
||||
duration_ms: Date.now() - startedAt,
|
||||
failed_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildChatCompletionRequest(input: GenerateInput, model: string) {
|
||||
return {
|
||||
model,
|
||||
temperature: input.temperature ?? 0.1,
|
||||
response_format: { type: "json_object" as const },
|
||||
messages: [
|
||||
...(input.system
|
||||
? [{ role: "system" as const, content: input.system }]
|
||||
: []),
|
||||
{ role: "user" as const, content: input.prompt },
|
||||
],
|
||||
} satisfies ChatCompletionRequest;
|
||||
}
|
||||
|
||||
function getRawLogLimit() {
|
||||
const parsed = Number(process.env.LLM_LOG_RAW_LIMIT ?? "4000");
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 4000;
|
||||
@@ -167,39 +236,75 @@ export async function generateText(input: GenerateInput) {
|
||||
export async function generateJson<T>(input: GenerateInput): Promise<T> {
|
||||
const task = getTask(input);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const status = getLlmProviderStatus();
|
||||
if (!status.configured) {
|
||||
throw new Error(status.reason ?? "LLM provider is not configured");
|
||||
}
|
||||
const effectiveModel = input.model ?? status.model;
|
||||
console.info(
|
||||
`[llm:start] provider=${status.provider} model=${effectiveModel} task=${task}`,
|
||||
);
|
||||
const request = {
|
||||
model: effectiveModel,
|
||||
temperature: input.temperature ?? 0.1,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
...(input.system ? [{ role: "system" as const, content: input.system }] : []),
|
||||
{ role: "user" as const, content: input.prompt },
|
||||
],
|
||||
} satisfies ChatCompletionRequest;
|
||||
const response = chatCompletionForTesting
|
||||
? await chatCompletionForTesting(request)
|
||||
: await createClient().client.chat.completions.create(request);
|
||||
const content = response.choices[0]?.message.content ?? "{}";
|
||||
console.info(
|
||||
`[llm:response] task=${task} duration_ms=${Date.now() - startedAt} raw=${truncateRaw(content)}`,
|
||||
);
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
const callId = callIdFor(input);
|
||||
const status = getLlmProviderStatus();
|
||||
if (!status.configured) {
|
||||
const error = new Error(status.reason ?? "LLM provider is not configured");
|
||||
const normalized = normalizeLlmError(error);
|
||||
console.error(
|
||||
`[llm:error] task=${task} duration_ms=${Date.now() - startedAt} message=${quoteLogValue(normalized.message)}`,
|
||||
);
|
||||
throw normalized;
|
||||
}
|
||||
|
||||
const effectiveModel = input.model ?? status.model;
|
||||
console.info(
|
||||
`[llm:start] provider=${status.provider} model=${effectiveModel} task=${task}`,
|
||||
);
|
||||
const request = buildChatCompletionRequest(input, effectiveModel);
|
||||
await emitTrace(input, {
|
||||
type: "started",
|
||||
call_id: callId,
|
||||
task,
|
||||
context: {
|
||||
workflow_stage: input.traceStage ?? "unknown",
|
||||
rewrite_round: input.rewriteRound,
|
||||
schema_name: input.schemaName,
|
||||
},
|
||||
provider: status.provider,
|
||||
model: effectiveModel,
|
||||
request,
|
||||
started_at: new Date(startedAt).toISOString(),
|
||||
});
|
||||
|
||||
let response: ChatCompletionResult;
|
||||
try {
|
||||
response = chatCompletionForTesting
|
||||
? await chatCompletionForTesting(request)
|
||||
: await createClient().client.chat.completions.create(request);
|
||||
} catch (error) {
|
||||
await emitTrace(input, failedEvent(callId, "provider", error, startedAt));
|
||||
const normalized = normalizeLlmError(error);
|
||||
console.error(
|
||||
`[llm:error] task=${task} duration_ms=${Date.now() - startedAt} message=${quoteLogValue(normalized.message)}`,
|
||||
);
|
||||
throw normalized;
|
||||
}
|
||||
|
||||
await emitTrace(input, {
|
||||
type: "responded",
|
||||
call_id: callId,
|
||||
response,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
responded_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message.content ?? "{}";
|
||||
console.info(
|
||||
`[llm:response] task=${task} duration_ms=${Date.now() - startedAt} raw=${truncateRaw(content)}`,
|
||||
);
|
||||
try {
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
await emitTrace(input, failedEvent(callId, "json_parse", error, startedAt));
|
||||
const message = `LLM response is not valid JSON: ${redactConfiguredSecrets(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
)}`;
|
||||
console.error(
|
||||
`[llm:error] task=${task} duration_ms=${Date.now() - startedAt} message=${quoteLogValue(message)}`,
|
||||
);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export let generateJsonForValidation: <T>(input: GenerateInput) => Promise<T> =
|
||||
@@ -227,14 +332,41 @@ export async function generateValidatedJson<T>({
|
||||
const usesDefaultGenerator = generateJsonForValidation === generateJson;
|
||||
const status = getLlmProviderStatus();
|
||||
const startedAt = Date.now();
|
||||
const effectiveModel = input.model ?? status.model;
|
||||
const traceCallId = callIdFor(input);
|
||||
const tracedInput: GenerateInput = { ...input, traceCallId };
|
||||
const schemaName = input.schemaName ?? "anonymousSchema";
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const generated = await generateJsonForValidation<unknown>(input);
|
||||
const generated = await generateJsonForValidation<unknown>(tracedInput);
|
||||
if (!usesDefaultGenerator) {
|
||||
console.info(
|
||||
`[llm:response] task=${task} duration_ms=${Date.now() - startedAt} raw=${truncateRaw(stringifyForLog(generated))}`,
|
||||
@@ -242,22 +374,71 @@ export async function generateValidatedJson<T>({
|
||||
}
|
||||
const parsed = schema.safeParse(generated);
|
||||
if (parsed.success) {
|
||||
await emitTrace(tracedInput, {
|
||||
type: "validated",
|
||||
call_id: traceCallId,
|
||||
schema_name: schemaName,
|
||||
schema_valid: true,
|
||||
validation_issues: [],
|
||||
validated_at: new Date().toISOString(),
|
||||
});
|
||||
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);
|
||||
const validationIssues = parsed.error.issues.map((issue) => {
|
||||
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
|
||||
return `${path}: ${issue.message}`;
|
||||
});
|
||||
await emitTrace(tracedInput, {
|
||||
type: "validated",
|
||||
call_id: traceCallId,
|
||||
schema_name: schemaName,
|
||||
schema_valid: false,
|
||||
validation_issues: validationIssues,
|
||||
validated_at: new Date().toISOString(),
|
||||
});
|
||||
console.warn(
|
||||
`[llm:validated] task=${task} ok=false zod_error=${quoteLogValue(summarizeZodError(parsed.error))}`,
|
||||
`[llm:validated] task=${task} ok=false zod_error=${quoteLogValue(zodSummary)}`,
|
||||
);
|
||||
throw new LlmValidationError(
|
||||
`LLM response failed schema validation: ${summarizeZodError(parsed.error)}`,
|
||||
await emitAudit({
|
||||
schemaValid: false,
|
||||
output: generated,
|
||||
errorSummary: zodSummary,
|
||||
});
|
||||
const validationError = new LlmValidationError(
|
||||
`LLM response failed schema validation: ${zodSummary}`,
|
||||
task,
|
||||
);
|
||||
await emitTrace(
|
||||
tracedInput,
|
||||
failedEvent(traceCallId, "schema_validation", validationError, startedAt),
|
||||
);
|
||||
throw validationError;
|
||||
} catch (error) {
|
||||
if (error instanceof LlmValidationError) {
|
||||
throw error;
|
||||
}
|
||||
console.info(`[llm:validated] task=${task} ok=false reason=provider_error`);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const message = redactConfiguredSecrets(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
if (!usesDefaultGenerator) {
|
||||
await emitTrace(
|
||||
tracedInput,
|
||||
failedEvent(traceCallId, "provider", error, startedAt),
|
||||
);
|
||||
}
|
||||
await emitAudit({
|
||||
schemaValid: false,
|
||||
output: null,
|
||||
errorSummary: message,
|
||||
});
|
||||
console.warn(
|
||||
usesDefaultGenerator
|
||||
? `[llm:error] task=${task} message=${quoteLogValue(message)}`
|
||||
@@ -268,6 +449,8 @@ export async function generateValidatedJson<T>({
|
||||
}
|
||||
|
||||
function normalizeLlmError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown LLM error";
|
||||
const message = redactConfiguredSecrets(
|
||||
error instanceof Error ? error.message : "Unknown LLM error",
|
||||
);
|
||||
return new Error(`LLM provider error: ${message}`);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user