docs: add P5.2 writing logs and AI extraction implementation plan for v0.8.0
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,764 @@
|
|||||||
|
# 笔临 P5.2 写作日志 + AI 知识抽取 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 交付 v0.8.0:`writing_logs` 日更追踪(保存差分 + 成章补计)、驾驶舱热力图/连更、状态栏日更进度,以及 AI 章节知识自动抽取 + 合并审核增强,与 P6 注入形成上下游闭环。
|
||||||
|
|
||||||
|
**Architecture:** 全局 `userData/writing_logs.sqlite` 存 `(date, bookId)` 日字数;每书 schema v7 增加 `chapter_writing_snapshots` 与 `knowledge_entries` 抽取元数据列;`ChapterWritingTracker` 挂 `CHAPTER_UPDATE` / `finishChapter`;`KnowledgeExtractionService` 异步调用 `AiClientService` 落库 `pending`。测试 **禁止 Mock** AI 集成/E2E。
|
||||||
|
|
||||||
|
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Zustand、i18next、Vitest、Playwright
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-07-bilin-p52-writing-logs-extraction-design.md`
|
||||||
|
|
||||||
|
**LM Studio 默认:** `baseUrl=http://127.0.0.1:1234`,`model=gemma-4-e4b-it`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/shared/types.ts` | `WritingStats`、`ExtractedKnowledgeItem`、`KnowledgeExtractionResult`;扩展 `KnowledgeEntry`、`CockpitSummary`、`GlobalSettings` |
|
||||||
|
| `src/shared/ipc-channels.ts` | `WRITING_GET_STATS`、`KNOWLEDGE_EXTRACT_CHAPTER`、`KNOWLEDGE_APPROVE_MERGE`、`KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE` |
|
||||||
|
| `src/main/db/schema-v7.sql` | `chapter_writing_snapshots` + knowledge ALTER |
|
||||||
|
| `src/main/db/migrate.ts` | v7 迁移 |
|
||||||
|
| `src/main/db/repositories/writing-log.repo.ts` | 全局 writing_logs |
|
||||||
|
| `src/main/db/repositories/chapter-writing-snapshot.repo.ts` | 每书 snapshot |
|
||||||
|
| `src/main/db/repositories/knowledge.repo.ts` | 抽取列读写、`createExtracted`、`batchApproveHighConfidence` |
|
||||||
|
| `src/main/services/writing-log.service.ts` | 日更、连更、热力图 |
|
||||||
|
| `src/main/services/chapter-writing-tracker.service.ts` | `recordDelta` / `supplementOnFinish` |
|
||||||
|
| `src/main/services/knowledge-extraction-prompt.ts` | prompt + `parseExtractionJson` |
|
||||||
|
| `src/main/services/knowledge-extraction.service.ts` | `extractForChapter` |
|
||||||
|
| `src/main/services/knowledge.service.ts` | `approveMerge`、`clearMergeTarget` |
|
||||||
|
| `src/main/services/cockpit.service.ts` | 附加 `writingStats` |
|
||||||
|
| `src/main/services/global-settings.ts` | 新设置默认值 |
|
||||||
|
| `src/main/ipc/handlers/writing.handler.ts` | `writing:getStats` |
|
||||||
|
| `src/main/ipc/handlers/knowledge.handler.ts` | 抽取/合并 IPC |
|
||||||
|
| `src/main/ipc/handlers/chapter.handler.ts` | 挂钩 tracker |
|
||||||
|
| `src/main/ipc/handlers/interactive.handler.ts` | finish 后 supplement + 异步抽取 |
|
||||||
|
| `src/main/ipc/handlers/auto.handler.ts` | 同上 |
|
||||||
|
| `src/main/ipc/register.ts` | 注入 `WritingLogService` |
|
||||||
|
| `src/preload/index.ts` + `electron-api.d.ts` | 新 API |
|
||||||
|
| `src/renderer/components/cockpit/WritingHeatmap.tsx` | Canvas 热力图 |
|
||||||
|
| `src/renderer/components/cockpit/CockpitModal.tsx` | 今日进度/连更/热力图 |
|
||||||
|
| `src/renderer/components/layout/EditorLayout.tsx` | 状态栏日更进度 |
|
||||||
|
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 批次/合并/批量采纳 |
|
||||||
|
| `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` | 合并预填模式 |
|
||||||
|
| `src/renderer/components/settings/SettingsPage.tsx` | 抽取设置 |
|
||||||
|
| `src/renderer/components/layout/ChapterList.tsx` 或编辑器菜单 | 抽取本章入口 |
|
||||||
|
| `public/locales/*/translation.json` | P5.2 文案 |
|
||||||
|
| `tests/main/writing-log.test.ts` | UT-LOG-* / IT-LOG-01 |
|
||||||
|
| `tests/main/knowledge-extraction.test.ts` | UT-EXT-* / IT-EXT-* |
|
||||||
|
| `e2e/writing-logs.spec.ts` | E2E-LOG-* |
|
||||||
|
| `e2e/knowledge-extraction.spec.ts` | E2E-EXT-* |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 类型 + IPC 通道 + GlobalSettings
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/shared/types.ts`
|
||||||
|
- Modify: `src/shared/ipc-channels.ts`
|
||||||
|
- Modify: `src/main/services/global-settings.ts`
|
||||||
|
- Modify: `src/renderer/stores/useSettingsStore.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 扩展 types**
|
||||||
|
|
||||||
|
`src/shared/types.ts` 追加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface WritingDayLog {
|
||||||
|
date: string
|
||||||
|
bookId: string
|
||||||
|
wordCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WritingStats {
|
||||||
|
todayWords: number
|
||||||
|
dailyGoal: number
|
||||||
|
goalProgress: number
|
||||||
|
streakDays: number
|
||||||
|
heatmap: WritingDayLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractedKnowledgeItem {
|
||||||
|
type: KnowledgeType
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
importance?: number
|
||||||
|
confidence: number
|
||||||
|
foreshadowStatus?: ForeshadowStatus
|
||||||
|
suggestedMergeId?: string
|
||||||
|
proposedPatch?: Partial<CreateKnowledgeInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeExtractionResult {
|
||||||
|
batchId: string
|
||||||
|
chapterId: string
|
||||||
|
items: ExtractedKnowledgeItem[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展 `KnowledgeEntry`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
extractionConfidence?: number
|
||||||
|
mergeTargetId?: string
|
||||||
|
extractionBatchId?: string
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展 `CockpitSummary`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
writingStats?: WritingStats
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展 `GlobalSettings`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
knowledgeAutoExtract?: boolean
|
||||||
|
knowledgeExtractConfidenceThreshold?: number
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: IPC 通道**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
WRITING_GET_STATS: 'writing:getStats',
|
||||||
|
KNOWLEDGE_EXTRACT_CHAPTER: 'knowledge:extractChapter',
|
||||||
|
KNOWLEDGE_APPROVE_MERGE: 'knowledge:approveMerge',
|
||||||
|
KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE: 'knowledge:batchApproveHighConfidence',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: global-settings 默认值**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
knowledgeAutoExtract: true,
|
||||||
|
knowledgeExtractConfidenceThreshold: 0.8,
|
||||||
|
```
|
||||||
|
|
||||||
|
`get()` merge 补默认。
|
||||||
|
|
||||||
|
- [ ] **Step 4: useSettingsStore 同步默认**
|
||||||
|
|
||||||
|
- [ ] **Step 5: build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: schema v7 + KnowledgeRepository 扩展
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/db/schema-v7.sql`
|
||||||
|
- Modify: `src/main/db/migrate.ts`
|
||||||
|
- Modify: `src/main/db/repositories/knowledge.repo.ts`
|
||||||
|
- Create: `tests/main/migrate-v7.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: schema-v7.sql**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS chapter_writing_snapshots (
|
||||||
|
chapter_id TEXT PRIMARY KEY,
|
||||||
|
logged_word_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
finish_supplemented INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: migrate.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import schemaV7 from './schema-v7.sql?raw'
|
||||||
|
const CURRENT_VERSION = 7
|
||||||
|
|
||||||
|
// in migrate(), after v6:
|
||||||
|
if (current < 7) {
|
||||||
|
db.exec(schemaV7)
|
||||||
|
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN extraction_confidence REAL')
|
||||||
|
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN merge_target_id TEXT')
|
||||||
|
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN extraction_batch_id TEXT')
|
||||||
|
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(7, 'P5.2 writing logs extraction')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: knowledge.repo mapRow + createExtracted**
|
||||||
|
|
||||||
|
更新 `mapRow` 读取三列;新增:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
createExtracted(input: CreateKnowledgeInput & {
|
||||||
|
extractionConfidence?: number
|
||||||
|
mergeTargetId?: string
|
||||||
|
extractionBatchId?: string
|
||||||
|
}): KnowledgeEntry {
|
||||||
|
const id = randomUUID()
|
||||||
|
this.db.prepare(
|
||||||
|
`INSERT INTO knowledge_entries (
|
||||||
|
id, type, title, content, importance, status, foreshadow_status,
|
||||||
|
source_chapter_id, last_mention_chapter_id, linked_bookmark_id,
|
||||||
|
extraction_confidence, merge_target_id, extraction_batch_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
id, input.type, input.title, input.content ?? '', input.importance ?? 3,
|
||||||
|
input.status ?? 'pending', input.foreshadowStatus ?? null,
|
||||||
|
input.sourceChapterId ?? null, input.lastMentionChapterId ?? null, input.linkedBookmarkId ?? null,
|
||||||
|
input.extractionConfidence ?? null, input.mergeTargetId ?? null, input.extractionBatchId ?? null
|
||||||
|
)
|
||||||
|
return this.get(id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
batchApproveHighConfidence(threshold: number): number {
|
||||||
|
const rows = this.db.prepare(
|
||||||
|
`SELECT id FROM knowledge_entries
|
||||||
|
WHERE status = 'pending' AND merge_target_id IS NULL
|
||||||
|
AND extraction_confidence IS NOT NULL AND extraction_confidence >= ?`
|
||||||
|
).all(threshold) as { id: string }[]
|
||||||
|
let count = 0
|
||||||
|
for (const { id } of rows) {
|
||||||
|
this.update(id, { status: 'approved' })
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMergeTarget(id: string): KnowledgeEntry {
|
||||||
|
this.db.prepare('UPDATE knowledge_entries SET merge_target_id = NULL, updated_at = datetime(\'now\') WHERE id = ?').run(id)
|
||||||
|
return this.get(id)!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: migrate-v7 测试**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('UT-MIG-07: v6 database migrates to v7 with snapshot table and knowledge columns', () => {
|
||||||
|
const db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db) // fresh → v7
|
||||||
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'").get()
|
||||||
|
expect(tables).toBeTruthy()
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/migrate-v7.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: WritingLogService(全局)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/db/repositories/writing-log.repo.ts`
|
||||||
|
- Create: `src/main/services/writing-log.service.ts`
|
||||||
|
- Create: `tests/main/writing-log.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: WritingLogRepository**
|
||||||
|
|
||||||
|
全局 sqlite 路径 `join(userDataDir, 'writing_logs.sqlite')`;`migrate()` 建表;方法:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
addToday(bookId: string, delta: number, date?: string): number
|
||||||
|
getToday(bookId: string, date?: string): number
|
||||||
|
listRange(bookId: string, fromDate: string, toDate: string): WritingDayLog[]
|
||||||
|
```
|
||||||
|
|
||||||
|
`addToday`:`INSERT ... ON CONFLICT DO UPDATE SET word_count = MAX(0, word_count + excluded.word_count)`。
|
||||||
|
|
||||||
|
日期工具:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function localDateString(d = new Date()): string {
|
||||||
|
return d.toLocaleDateString('sv-SE') // YYYY-MM-DD
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: WritingLogService**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class WritingLogService {
|
||||||
|
constructor(private repo: WritingLogRepository, private settings: GlobalSettingsService) {}
|
||||||
|
|
||||||
|
addToday(bookId: string, delta: number): number {
|
||||||
|
return this.repo.addToday(bookId, delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats(bookId: string): WritingStats {
|
||||||
|
const dailyGoal = this.settings.get().dailyWordGoal ?? 0
|
||||||
|
const today = localDateString()
|
||||||
|
const todayWords = this.repo.getToday(bookId, today)
|
||||||
|
const goalProgress = dailyGoal > 0 ? todayWords / dailyGoal : 0
|
||||||
|
const heatmap = this.buildHeatmap(bookId, 365)
|
||||||
|
const streakDays = dailyGoal > 0 ? this.calcStreak(bookId, dailyGoal) : 0
|
||||||
|
return { todayWords, dailyGoal, goalProgress, streakDays, heatmap }
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHeatmap(bookId: string, days: number): WritingDayLog[] { /* 填充 0 */ }
|
||||||
|
private calcStreak(bookId: string, goal: number): number { /* 从昨日向前 */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: UT-LOG-01~04**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('UT-LOG-01: addToday accumulates +200', () => { /* ... */ })
|
||||||
|
it('UT-LOG-02: negative delta clamps at 0', () => { /* ... */ })
|
||||||
|
it('UT-LOG-04: streak counts consecutive goal-met days', () => { /* seed 3 days */ })
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/writing-log.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: ChapterWritingTracker + Snapshot Repo
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/db/repositories/chapter-writing-snapshot.repo.ts`
|
||||||
|
- Create: `src/main/services/chapter-writing-tracker.service.ts`
|
||||||
|
- Modify: `tests/main/writing-log.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: ChapterWritingSnapshotRepository**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
get(chapterId: string): { loggedWordCount: number; finishSupplemented: boolean } | null
|
||||||
|
upsert(chapterId: string, loggedWordCount: number, finishSupplemented?: boolean): void
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: ChapterWritingTracker**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class ChapterWritingTracker {
|
||||||
|
constructor(
|
||||||
|
private bookDb: SqliteDb,
|
||||||
|
private bookId: string,
|
||||||
|
private writingLogs: WritingLogService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
recordDelta(chapterId: string, newWordCount: number): void {
|
||||||
|
const snap = this.snapRepo.get(chapterId)
|
||||||
|
const logged = snap?.loggedWordCount ?? 0
|
||||||
|
const delta = newWordCount - logged
|
||||||
|
if (delta !== 0) this.writingLogs.addToday(this.bookId, delta)
|
||||||
|
this.snapRepo.upsert(chapterId, newWordCount, snap?.finishSupplemented ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
supplementOnFinish(chapterId: string, wordCount: number): void {
|
||||||
|
const snap = this.snapRepo.get(chapterId)
|
||||||
|
const logged = snap?.loggedWordCount ?? 0
|
||||||
|
const gap = wordCount - logged
|
||||||
|
if (gap > 0) this.writingLogs.addToday(this.bookId, gap)
|
||||||
|
this.snapRepo.upsert(chapterId, wordCount, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: UT-LOG-03 supplementOnFinish**
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/writing-log.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 挂钩 CHAPTER_UPDATE + finishChapter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/ipc/handlers/chapter.handler.ts`
|
||||||
|
- Modify: `src/main/ipc/handlers/interactive.handler.ts`
|
||||||
|
- Modify: `src/main/ipc/handlers/auto.handler.ts`
|
||||||
|
- Modify: `src/main/ipc/register.ts`
|
||||||
|
- Modify: `tests/main/writing-log.test.ts`(IT-LOG-01)
|
||||||
|
|
||||||
|
- [ ] **Step 1: register.ts 创建 WritingLogService 单例**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const writingLogs = new WritingLogService(new WritingLogRepository(userData), settings)
|
||||||
|
registerChapterHandlers(books, writingLogs)
|
||||||
|
registerInteractiveHandlers(registry, settings, aiClient, books, writingLogs, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
工厂函数:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function trackerFor(registry: BookRegistryService, bookId: string, writingLogs: WritingLogService) {
|
||||||
|
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: chapter.handler CHAPTER_UPDATE 后**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
wrap(() => {
|
||||||
|
const chapter = registry.getChapterRepo(bookId).update(...)
|
||||||
|
ftsSync.upsert(...)
|
||||||
|
trackerFor(registry, bookId, writingLogs).recordDelta(chapterId, chapter.wordCount)
|
||||||
|
registry.updateMeta(bookId, { lastChapterId: chapterId })
|
||||||
|
return chapter
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: interactive/auto finishChapter 后**
|
||||||
|
|
||||||
|
`finishChapter` 返回 `{ chapterId }` 后:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ch = registry.getChapterRepo(bookId).get(chapterId)!
|
||||||
|
trackerFor(registry, bookId, writingLogs).supplementOnFinish(chapterId, ch.wordCount)
|
||||||
|
if (settings.get().knowledgeAutoExtract !== false) {
|
||||||
|
setImmediate(() => void extractKnowledge(registry, bookId, chapterId, aiClient, settings))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
抽取辅助放在 `src/main/services/knowledge-extraction-runner.ts`(避免 handler 膨胀)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: IT-LOG-01**
|
||||||
|
|
||||||
|
两次 `CHAPTER_UPDATE` 模拟,断言 `writing_logs` 累计。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/writing-log.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Cockpit 扩展 + writing IPC + preload
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/services/cockpit.service.ts`
|
||||||
|
- Create: `src/main/ipc/handlers/writing.handler.ts`
|
||||||
|
- Modify: `src/main/ipc/register.ts`
|
||||||
|
- Modify: `src/preload/index.ts`
|
||||||
|
- Modify: `src/shared/electron-api.d.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: CockpitService 注入 WritingLogService**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
getSummary(activeVolumeId?: string, bookId?: string): CockpitSummary {
|
||||||
|
const base = { /* existing */ }
|
||||||
|
const writingStats = bookId ? this.writingLogs.getStats(bookId) : undefined
|
||||||
|
return { ...base, writingStats }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
更新 `cockpit.handler.ts` 传 `bookId`。
|
||||||
|
|
||||||
|
- [ ] **Step 2: writing.handler**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ipcMain.handle(IPC.WRITING_GET_STATS, (_e, { bookId }: { bookId: string }) =>
|
||||||
|
wrap(() => writingLogs.getStats(bookId))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: preload**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
writing: {
|
||||||
|
getStats: (bookId: string): Promise<IpcResult<WritingStats>> =>
|
||||||
|
ipcRenderer.invoke(IPC.WRITING_GET_STATS, { bookId })
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: KnowledgeExtraction prompt + parse + service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/services/knowledge-extraction-prompt.ts`
|
||||||
|
- Create: `src/main/services/knowledge-extraction.service.ts`
|
||||||
|
- Create: `tests/main/knowledge-extraction.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: knowledge-extraction-prompt.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function buildExtractionPrompt(
|
||||||
|
chapterTitle: string,
|
||||||
|
plainText: string,
|
||||||
|
approvedTitles: Array<{ id: string; title: string }>
|
||||||
|
): string { /* 中文指令 + JSON schema 说明 */ }
|
||||||
|
|
||||||
|
export function parseExtractionJson(raw: string): ExtractedKnowledgeItem[] {
|
||||||
|
const cleaned = raw.replace(/```json\n?|\n?```/g, '').trim()
|
||||||
|
const arr = JSON.parse(cleaned) as ExtractedKnowledgeItem[]
|
||||||
|
if (!Array.isArray(arr)) throw new Error('expected array')
|
||||||
|
return arr.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
title: String(item.title).trim(),
|
||||||
|
content: String(item.content ?? '').trim(),
|
||||||
|
importance: item.importance ?? 3,
|
||||||
|
confidence: Number(item.confidence) || 0.5,
|
||||||
|
foreshadowStatus: item.foreshadowStatus,
|
||||||
|
suggestedMergeId: item.suggestedMergeId ?? undefined,
|
||||||
|
proposedPatch: item.proposedPatch
|
||||||
|
})).filter((x) => x.title.length > 0)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: KnowledgeExtractionService**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class KnowledgeExtractionService {
|
||||||
|
async extractForChapter(chapterId: string): Promise<KnowledgeExtractionResult> {
|
||||||
|
const chapter = this.chapterRepo.get(chapterId)
|
||||||
|
if (!chapter) throw new Error('Chapter not found')
|
||||||
|
const plain = stripHtml(chapter.content).slice(0, 12000)
|
||||||
|
if (!plain.trim()) throw new Error('empty chapter')
|
||||||
|
const approved = this.knowledgeRepo.list({ status: 'approved' }).slice(0, 30)
|
||||||
|
const prompt = buildExtractionPrompt(chapter.title, plain, approved.map((e) => ({ id: e.id, title: e.title })))
|
||||||
|
let response = await this.aiClient.chat([{ role: 'user', content: prompt }])
|
||||||
|
let items: ExtractedKnowledgeItem[]
|
||||||
|
try {
|
||||||
|
items = parseExtractionJson(response)
|
||||||
|
} catch {
|
||||||
|
response = await this.aiClient.chat([
|
||||||
|
{ role: 'user', content: prompt },
|
||||||
|
{ role: 'user', content: '仅返回 JSON 数组,不要 markdown 或其它说明。' }
|
||||||
|
])
|
||||||
|
items = parseExtractionJson(response)
|
||||||
|
}
|
||||||
|
const batchId = randomUUID()
|
||||||
|
const saved: ExtractedKnowledgeItem[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
const mergeTargetId = item.suggestedMergeId && this.knowledgeRepo.get(item.suggestedMergeId) ? item.suggestedMergeId : undefined
|
||||||
|
this.knowledgeRepo.createExtracted({
|
||||||
|
type: item.type,
|
||||||
|
title: item.title,
|
||||||
|
content: item.content,
|
||||||
|
importance: item.importance,
|
||||||
|
status: 'pending',
|
||||||
|
foreshadowStatus: item.type === 'foreshadow' ? (item.foreshadowStatus ?? 'buried') : undefined,
|
||||||
|
sourceChapterId: chapterId,
|
||||||
|
extractionConfidence: item.confidence,
|
||||||
|
mergeTargetId,
|
||||||
|
extractionBatchId: batchId
|
||||||
|
})
|
||||||
|
saved.push({ ...item, suggestedMergeId: mergeTargetId })
|
||||||
|
}
|
||||||
|
return { batchId, chapterId, items: saved }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: UT-EXT-01 / UT-EXT-02**
|
||||||
|
|
||||||
|
对 `parseExtractionJson` 测合法 JSON 与 `suggestedMergeId` 字段。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/knowledge-extraction.test.ts -t "UT-EXT"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Knowledge 合并 IPC + KnowledgeService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/services/knowledge.service.ts`
|
||||||
|
- Modify: `src/main/ipc/handlers/knowledge.handler.ts`
|
||||||
|
- Modify: `src/preload/index.ts`
|
||||||
|
- Modify: `tests/main/knowledge-extraction.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: KnowledgeService.approveMerge**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
approveMerge(pendingId: string): KnowledgeEntry {
|
||||||
|
const pending = this.repo.get(pendingId)
|
||||||
|
if (!pending?.mergeTargetId) throw new Error('not a merge suggestion')
|
||||||
|
const target = this.repo.get(pending.mergeTargetId)
|
||||||
|
if (!target || target.status !== 'approved') throw new Error('merge target missing')
|
||||||
|
const patch: Partial<CreateKnowledgeInput> = {
|
||||||
|
content: pending.content,
|
||||||
|
importance: pending.importance,
|
||||||
|
lastMentionChapterId: pending.lastMentionChapterId ?? pending.sourceChapterId
|
||||||
|
}
|
||||||
|
const updated = this.repo.update(target.id, patch)
|
||||||
|
this.repo.update(pendingId, { status: 'rejected' })
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMergeAsNew(pendingId: string): KnowledgeEntry {
|
||||||
|
return this.repo.clearMergeTarget(pendingId)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: knowledge.handler IPC**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ipcMain.handle(IPC.KNOWLEDGE_EXTRACT_CHAPTER, (_e, { bookId, chapterId }) =>
|
||||||
|
wrap(() => new KnowledgeExtractionService(registry.getDb(bookId), aiClient).extractForChapter(chapterId))
|
||||||
|
)
|
||||||
|
ipcMain.handle(IPC.KNOWLEDGE_APPROVE_MERGE, (_e, { bookId, pendingId }) =>
|
||||||
|
wrap(() => new KnowledgeService(registry.getDb(bookId)).approveMerge(pendingId))
|
||||||
|
)
|
||||||
|
ipcMain.handle(IPC.KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE, (_e, { bookId, threshold }) =>
|
||||||
|
wrap(() => {
|
||||||
|
const t = threshold ?? settings.get().knowledgeExtractConfidenceThreshold ?? 0.8
|
||||||
|
return new KnowledgeRepository(registry.getDb(bookId)).batchApproveHighConfidence(t)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: IT-EXT-01 / IT-EXT-02**(240s)
|
||||||
|
|
||||||
|
- [ ] **Step 4: IT-EXT-03** — 抽取 → approve → `KnowledgeRetrievalService.suggest` 含新条目
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: 驾驶舱 UI + 状态栏日更进度
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/renderer/components/cockpit/WritingHeatmap.tsx`
|
||||||
|
- Modify: `src/renderer/components/cockpit/CockpitModal.tsx`
|
||||||
|
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
|
||||||
|
- Modify: `src/renderer/styles/layout.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: WritingHeatmap 组件**
|
||||||
|
|
||||||
|
Props:`heatmap: WritingDayLog[]`;Canvas 13×7 格(52 周近似 365 天);四档颜色;`data-testid="cockpit-heatmap"`。
|
||||||
|
|
||||||
|
- [ ] **Step 2: CockpitModal 扩展**
|
||||||
|
|
||||||
|
在 `cockpit-grid` 下方:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{summary.writingStats && summary.writingStats.dailyGoal > 0 && (
|
||||||
|
<div data-testid="cockpit-today-progress">...</div>
|
||||||
|
)}
|
||||||
|
{summary.writingStats && summary.writingStats.streakDays > 0 && (
|
||||||
|
<div data-testid="cockpit-streak">{t('cockpit.streak', { days: summary.writingStats.streakDays })}</div>
|
||||||
|
)}
|
||||||
|
<WritingHeatmap heatmap={summary.writingStats?.heatmap ?? []} />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: EditorLayout 状态栏**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const writingStats = useWritingStatsStore() // 或 cockpit summary 缓存
|
||||||
|
{dailyGoal > 0 && (
|
||||||
|
<span className={`status-daily-goal ${progress >= 1 ? 'goal-met' : progress >= 0.8 ? 'goal-near' : ''}`} data-testid="status-daily-goal">
|
||||||
|
{t('status.dailyGoal', { current: todayWords, goal: dailyGoal })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
章节保存后 `writing.getStats` 刷新(监听 save 或轮询 `useBookStore` wordCount)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: build 验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: 知识库 UI + 设置 + 抽取入口
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/renderer/components/knowledge/KnowledgePanel.tsx`
|
||||||
|
- Modify: `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx`
|
||||||
|
- Modify: `src/renderer/stores/useKnowledgeStore.ts`
|
||||||
|
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
|
||||||
|
- Modify: `src/renderer/components/layout/ChapterList.tsx`(或 `EditorLayout` 章节菜单)
|
||||||
|
- Modify: `public/locales/zh-CN/translation.json`
|
||||||
|
- Modify: `public/locales/en/translation.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: KnowledgePanel 待审核增强**
|
||||||
|
|
||||||
|
- 卡片显示 `extractionConfidence` 百分比徽章
|
||||||
|
- `mergeTargetId` 时显示 `t('knowledge.mergeSuggest')` + `data-testid="knowledge-merge-review-{id}"`
|
||||||
|
- 工具栏按钮 `knowledge-batch-high-confidence`
|
||||||
|
- 工具栏「从当前章抽取」`knowledge-extract-current`(需 `selectedChapterId`)
|
||||||
|
|
||||||
|
- [ ] **Step 2: KnowledgeEditorDialog 合并模式**
|
||||||
|
|
||||||
|
props:`mergeTarget?: KnowledgeEntry`、`initialPatch?: Partial<CreateKnowledgeInput>`
|
||||||
|
|
||||||
|
底部按钮:「确认更新」→ `approveMerge`;「作为新条目」→ `saveMergeAsNew` 后常规编辑。
|
||||||
|
|
||||||
|
- [ ] **Step 3: SettingsPage**
|
||||||
|
|
||||||
|
`settings-knowledge-auto-extract` 开关;`settings-extract-threshold` 数字输入。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 章节抽取入口**
|
||||||
|
|
||||||
|
章节列表项或编辑器右键:`data-testid="chapter-extract-knowledge"` → `knowledge.extractChapter`。
|
||||||
|
|
||||||
|
- [ ] **Step 5: i18n** — spec §9 全部键
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: E2E + v0.8.0 交付
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `e2e/writing-logs.spec.ts`
|
||||||
|
- Create: `e2e/knowledge-extraction.spec.ts`
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `src/renderer/components/settings/SettingsPage.tsx`(about 版本)
|
||||||
|
- Modify: `docs/superpowers/plans/2026-07-07-bilin-p52-writing-logs-extraction.md`(勾选完成项)
|
||||||
|
|
||||||
|
- [ ] **Step 1: E2E-LOG-01 / E2E-LOG-02**
|
||||||
|
|
||||||
|
写 100 字保存 → 打开驾驶舱 → `cockpit-today-progress` 更新;`cockpit-heatmap` 可见。
|
||||||
|
|
||||||
|
- [ ] **Step 2: E2E-EXT-01 / E2E-EXT-02 / E2E-EXT-03**(300s timeout on AI tests)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 全量测试**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run test
|
||||||
|
npm run test:e2e -- e2e/writing-logs.spec.ts e2e/knowledge-extraction.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 版本 0.8.0**
|
||||||
|
|
||||||
|
`package.json`、`README.md` 追加:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- 写作日志与热力图、AI 章节知识自动抽取(P5.2)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat: ship v0.8.0 with writing logs and AI knowledge extraction"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec Coverage Checklist
|
||||||
|
|
||||||
|
| Spec 要求 | Task |
|
||||||
|
|-----------|------|
|
||||||
|
| writing_logs 全局表 | Task 3 |
|
||||||
|
| chapter_writing_snapshots v7 | Task 2, 4 |
|
||||||
|
| 保存差分 + 成章补计 | Task 4, 5 |
|
||||||
|
| 连更 + 热力图 | Task 3, 9 |
|
||||||
|
| 状态栏日更进度 | Task 9 |
|
||||||
|
| knowledgeAutoExtract 设置 | Task 1, 5, 10 |
|
||||||
|
| AI 抽取五类 + JSON | Task 7 |
|
||||||
|
| 合并建议 approveMerge | Task 8, 10 |
|
||||||
|
| 批量高置信度 | Task 2, 8, 10 |
|
||||||
|
| P6 suggestContext 联动 | Task 8 IT-EXT-03 |
|
||||||
|
| UT-LOG / UT-EXT / IT / E2E | Task 3–8, 11 |
|
||||||
|
| v0.8.0 DoD | Task 11 |
|
||||||
Reference in New Issue
Block a user