feat: ship v0.8.0 with writing logs and AI knowledge extraction
Deliver P5.2 writing day logs, cockpit heatmap, streak tracking, and chapter knowledge auto-extraction with merge review. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+11
-1
@@ -5,8 +5,9 @@ import schemaV3 from './schema-v3.sql?raw'
|
||||
import schemaV4 from './schema-v4.sql?raw'
|
||||
import schemaV5 from './schema-v5.sql?raw'
|
||||
import schemaV6 from './schema-v6.sql?raw'
|
||||
import schemaV7 from './schema-v7.sql?raw'
|
||||
|
||||
const CURRENT_VERSION = 6
|
||||
const CURRENT_VERSION = 7
|
||||
|
||||
function tryAlter(db: SqliteDb, sql: string): void {
|
||||
try {
|
||||
@@ -71,5 +72,14 @@ export function migrate(db: SqliteDb): void {
|
||||
if (current < 6) {
|
||||
db.exec(schemaV6)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(6, 'P5 knowledge/cockpit schema')
|
||||
current = 6
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { SqliteDb } from '../types'
|
||||
|
||||
export interface ChapterWritingSnapshot {
|
||||
loggedWordCount: number
|
||||
finishSupplemented: boolean
|
||||
}
|
||||
|
||||
export class ChapterWritingSnapshotRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
get(chapterId: string): ChapterWritingSnapshot | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
'SELECT logged_word_count, finish_supplemented FROM chapter_writing_snapshots WHERE chapter_id = ?'
|
||||
)
|
||||
.get(chapterId) as { logged_word_count: number; finish_supplemented: number } | undefined
|
||||
if (!row) return null
|
||||
return {
|
||||
loggedWordCount: row.logged_word_count,
|
||||
finishSupplemented: row.finish_supplemented === 1
|
||||
}
|
||||
}
|
||||
|
||||
upsert(chapterId: string, loggedWordCount: number, finishSupplemented = false): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO chapter_writing_snapshots (chapter_id, logged_word_count, finish_supplemented)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(chapter_id) DO UPDATE SET
|
||||
logged_word_count = excluded.logged_word_count,
|
||||
finish_supplemented = excluded.finish_supplemented`
|
||||
)
|
||||
.run(chapterId, loggedWordCount, finishSupplemented ? 1 : 0)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ function mapRow(row: Record<string, unknown>): KnowledgeEntry {
|
||||
sourceChapterId: (row.source_chapter_id as string) || undefined,
|
||||
lastMentionChapterId: (row.last_mention_chapter_id as string) || undefined,
|
||||
linkedBookmarkId: (row.linked_bookmark_id as string) || undefined,
|
||||
extractionConfidence: (row.extraction_confidence as number) ?? undefined,
|
||||
mergeTargetId: (row.merge_target_id as string) || undefined,
|
||||
extractionBatchId: (row.extraction_batch_id as string) || undefined,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string
|
||||
}
|
||||
@@ -53,6 +56,41 @@ export class KnowledgeRepository {
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
createExtracted(
|
||||
input: CreateKnowledgeInput & {
|
||||
extractionConfidence?: number
|
||||
mergeTargetId?: string
|
||||
extractionBatchId?: string
|
||||
}
|
||||
): KnowledgeEntry {
|
||||
const id = randomUUID()
|
||||
const status = input.status ?? 'pending'
|
||||
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,
|
||||
status,
|
||||
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)!
|
||||
}
|
||||
|
||||
get(id: string): KnowledgeEntry | null {
|
||||
const row = this.db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
@@ -101,6 +139,15 @@ export class KnowledgeRepository {
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
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)!
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(id)
|
||||
}
|
||||
@@ -111,4 +158,20 @@ export class KnowledgeRepository {
|
||||
.get(status) as { c: number }
|
||||
return row.c
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type { WritingDayLog } from '../../../shared/types'
|
||||
import { addDays, localDateString } from '../../services/writing-date.util'
|
||||
|
||||
export class WritingLogRepository {
|
||||
private db: DatabaseSync
|
||||
|
||||
constructor(userDataDir: string) {
|
||||
if (!existsSync(userDataDir)) mkdirSync(userDataDir, { recursive: true })
|
||||
this.db = new DatabaseSync(join(userDataDir, 'writing_logs.sqlite'))
|
||||
this.migrate()
|
||||
}
|
||||
|
||||
private migrate(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS writing_logs (
|
||||
date TEXT NOT NULL,
|
||||
book_id TEXT NOT NULL,
|
||||
word_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, book_id)
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
addToday(bookId: string, delta: number, date = localDateString()): number {
|
||||
const row = this.db
|
||||
.prepare('SELECT word_count FROM writing_logs WHERE date = ? AND book_id = ?')
|
||||
.get(date, bookId) as { word_count: number } | undefined
|
||||
const next = Math.max(0, (row?.word_count ?? 0) + delta)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO writing_logs (date, book_id, word_count) VALUES (?, ?, ?)
|
||||
ON CONFLICT(date, book_id) DO UPDATE SET word_count = excluded.word_count`
|
||||
)
|
||||
.run(date, bookId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
getToday(bookId: string, date = localDateString()): number {
|
||||
const row = this.db
|
||||
.prepare('SELECT word_count FROM writing_logs WHERE date = ? AND book_id = ?')
|
||||
.get(date, bookId) as { word_count: number } | undefined
|
||||
return row?.word_count ?? 0
|
||||
}
|
||||
|
||||
getDay(bookId: string, date: string): number {
|
||||
return this.getToday(bookId, date)
|
||||
}
|
||||
|
||||
listRange(bookId: string, fromDate: string, toDate: string): WritingDayLog[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT date, book_id, word_count FROM writing_logs
|
||||
WHERE book_id = ? AND date >= ? AND date <= ?
|
||||
ORDER BY date ASC`
|
||||
)
|
||||
.all(bookId, fromDate, toDate) as Array<{ date: string; book_id: string; word_count: number }>
|
||||
return rows.map((r) => ({ date: r.date, bookId: r.book_id, wordCount: r.word_count }))
|
||||
}
|
||||
|
||||
buildHeatmap(bookId: string, days: number): WritingDayLog[] {
|
||||
const today = localDateString()
|
||||
const from = addDays(today, -(days - 1))
|
||||
const existing = new Map(this.listRange(bookId, from, today).map((l) => [l.date, l.wordCount]))
|
||||
const result: WritingDayLog[] = []
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = addDays(from, i)
|
||||
result.push({ date, bookId, wordCount: existing.get(date) ?? 0 })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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
|
||||
);
|
||||
Reference in New Issue
Block a user