feat: ship v0.7.0 with AI knowledge context integration
Add semi-automatic knowledge suggestions and user-confirmed injection across chat, interactive, auto, and wizard modes; fix writing flows to persist full systemPrompt in contextJson. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { migrate } from '../../src/main/db/migrate'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
||||
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||
import { BookRegistryService } from '../../src/main/services/book-registry'
|
||||
import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
@@ -38,7 +39,7 @@ describe('AiContextBuilderService', () => {
|
||||
const chapter = chapters.create(volId, '长章节', 0, longHtml)
|
||||
|
||||
const result = aiContextBuilder.build(
|
||||
{ chapterIds: [chapter.id], outlineIds: [], settingIds: [], inspirationIds: [] },
|
||||
{ chapterIds: [chapter.id], outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: [] },
|
||||
registry,
|
||||
bookId,
|
||||
'作者'
|
||||
@@ -58,7 +59,7 @@ describe('AiContextBuilderService', () => {
|
||||
}
|
||||
|
||||
const result = aiContextBuilder.build(
|
||||
{ chapterIds: ids, outlineIds: [], settingIds: [], inspirationIds: [] },
|
||||
{ chapterIds: ids, outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: [] },
|
||||
registry,
|
||||
bookId,
|
||||
'作者'
|
||||
@@ -74,7 +75,7 @@ describe('AiContextBuilderService', () => {
|
||||
const setting = settings.create('character', '主角林远', '少年修士')
|
||||
|
||||
const result = aiContextBuilder.build(
|
||||
{ chapterIds: [], outlineIds: [], settingIds: [setting.id], inspirationIds: [] },
|
||||
{ chapterIds: [], outlineIds: [], settingIds: [setting.id], inspirationIds: [], knowledgeIds: [] },
|
||||
registry,
|
||||
bookId,
|
||||
'作者'
|
||||
@@ -83,4 +84,60 @@ describe('AiContextBuilderService', () => {
|
||||
expect(result.preview[0].title).toBe('主角林远')
|
||||
expect(result.systemPrompt).toContain('主角林远')
|
||||
})
|
||||
|
||||
it('UT-CTX-02: build includes knowledge preview items', () => {
|
||||
const know = new KnowledgeRepository(db)
|
||||
const entry = know.create({
|
||||
type: 'foreshadow',
|
||||
title: '测灵石',
|
||||
content: '石头发热',
|
||||
status: 'approved'
|
||||
})
|
||||
const result = aiContextBuilder.build(
|
||||
{
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: [],
|
||||
knowledgeIds: [entry.id]
|
||||
},
|
||||
registry,
|
||||
bookId,
|
||||
'作者'
|
||||
)
|
||||
expect(result.preview.some((p) => p.kind === 'knowledge')).toBe(true)
|
||||
expect(result.systemPrompt).toContain('【知识·伏笔】')
|
||||
expect(result.systemPrompt).toContain('测灵石')
|
||||
})
|
||||
|
||||
it('UT-CTX-03: caps knowledge section at 4000 chars', () => {
|
||||
const know = new KnowledgeRepository(db)
|
||||
const ids: string[] = []
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const e = know.create({
|
||||
type: 'fact',
|
||||
title: `条目${i}`,
|
||||
content: '长'.repeat(400),
|
||||
importance: 5,
|
||||
status: 'approved'
|
||||
})
|
||||
ids.push(e.id)
|
||||
}
|
||||
const result = aiContextBuilder.build(
|
||||
{
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: [],
|
||||
knowledgeIds: ids
|
||||
},
|
||||
registry,
|
||||
bookId,
|
||||
'作者'
|
||||
)
|
||||
const knowledgeChars = result.preview
|
||||
.filter((p) => p.kind === 'knowledge')
|
||||
.reduce((s, p) => s + p.charCount, 0)
|
||||
expect(knowledgeChars).toBeLessThanOrEqual(4000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
|
||||
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||
import { KnowledgeRetrievalService } from '../../src/main/services/knowledge-retrieval.service'
|
||||
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
|
||||
import { AiClientService } from '../../src/main/services/ai-client.service'
|
||||
import { BookRegistryService } from '../../src/main/services/book-registry'
|
||||
import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service'
|
||||
import { DEFAULT_AI_CONFIG, type AiContextBinding } from '../../src/shared/types'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
|
||||
|
||||
const EMPTY_BINDING: AiContextBinding = {
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: [],
|
||||
knowledgeIds: []
|
||||
}
|
||||
|
||||
describe('AI context integration', () => {
|
||||
it('IT-CTX-01: suggestContext returns entries when approved exist', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
new KnowledgeRepository(db).create({
|
||||
type: 'fact',
|
||||
title: '测灵石异常',
|
||||
content: '石头发热',
|
||||
status: 'approved',
|
||||
importance: 4
|
||||
})
|
||||
const result = new KnowledgeRetrievalService(db).suggest({ topN: 5 })
|
||||
expect(result.length).toBeGreaterThanOrEqual(1)
|
||||
expect(result[0].entry.title).toBe('测灵石异常')
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('IT-CTX-02: build with knowledgeIds includes knowledge block in systemPrompt', () => {
|
||||
const userDir = mkdtempSync(join(tmpdir(), 'bilin-ctx-it-'))
|
||||
const registry = new BookRegistryService(userDir)
|
||||
const meta = registry.create({ name: '测试书', category: '玄幻' })
|
||||
const bookDb = registry.getDb(meta.id)
|
||||
const entry = new KnowledgeRepository(bookDb).create({
|
||||
type: 'foreshadow',
|
||||
title: '测灵石',
|
||||
content: '石头发热',
|
||||
status: 'approved'
|
||||
})
|
||||
const result = aiContextBuilder.build(
|
||||
{ ...EMPTY_BINDING, knowledgeIds: [entry.id] },
|
||||
registry,
|
||||
meta.id,
|
||||
'作者'
|
||||
)
|
||||
expect(result.preview.some((p) => p.kind === 'knowledge')).toBe(true)
|
||||
expect(result.systemPrompt).toContain('【知识·')
|
||||
bookDb.close()
|
||||
rmSync(userDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('IT-CTX-03: interactive confirm then suggestPlots uses knowledge context', async () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model)
|
||||
const know = new KnowledgeRepository(db).create({
|
||||
type: 'foreshadow',
|
||||
title: '测灵石异常',
|
||||
content: '入门考核时测灵石发热,暗示主角特殊灵根',
|
||||
status: 'approved',
|
||||
importance: 5
|
||||
})
|
||||
const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||
const flow = service.start(session.id)
|
||||
service.confirmContext(flow.id, {
|
||||
binding: { ...EMPTY_BINDING, knowledgeIds: [know.id] },
|
||||
systemPrompt: `【知识·伏笔·测灵石异常】入门考核时测灵石发热,暗示主角特殊灵根\n\n林远参加宗门入门考核,演武场测灵石前众人围观。`,
|
||||
contextSummary: '0章·0纲·0设·0感·1知'
|
||||
})
|
||||
const plots = await service.suggestPlots(flow.id)
|
||||
expect(plots).toHaveLength(3)
|
||||
db.close()
|
||||
}, 240_000)
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { BookRegistryService } from '../../src/main/services/book-registry'
|
||||
import { buildFlowContextPayload } from '../../src/main/services/flow-context.util'
|
||||
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||
|
||||
describe('flow-context.util', () => {
|
||||
let userDir: string
|
||||
let registry: BookRegistryService
|
||||
let bookId: string
|
||||
|
||||
beforeEach(() => {
|
||||
userDir = mkdtempSync(join(tmpdir(), 'bilin-flow-ctx-'))
|
||||
registry = new BookRegistryService(userDir)
|
||||
bookId = registry.create({ name: '书', category: '玄幻' }).id
|
||||
})
|
||||
|
||||
afterEach(() => rmSync(userDir, { recursive: true, force: true }))
|
||||
|
||||
it('buildFlowContextPayload stores systemPrompt and summary', () => {
|
||||
const db = registry.getDb(bookId)
|
||||
const entry = new KnowledgeRepository(db).create({
|
||||
type: 'fact',
|
||||
title: '测试知识',
|
||||
content: '内容',
|
||||
status: 'approved'
|
||||
})
|
||||
const payload = buildFlowContextPayload(
|
||||
{
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: [],
|
||||
knowledgeIds: [entry.id]
|
||||
},
|
||||
registry,
|
||||
bookId,
|
||||
'作者'
|
||||
)
|
||||
expect(payload.systemPrompt).toContain('测试知识')
|
||||
expect(payload.contextSummary).toContain('1')
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
@@ -15,11 +15,17 @@ describe('interactive plots integration', () => {
|
||||
const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model)
|
||||
const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||
const flow = service.start(session.id)
|
||||
service.confirmContext(
|
||||
flow.id,
|
||||
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
||||
'林远参加宗门入门考核,演武场测灵石前众人围观。'
|
||||
)
|
||||
service.confirmContext(flow.id, {
|
||||
binding: {
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: [],
|
||||
knowledgeIds: []
|
||||
},
|
||||
systemPrompt: '林远参加宗门入门考核,演武场测灵石前众人围观。',
|
||||
contextSummary: '0章·0纲·0设·0感·0知'
|
||||
})
|
||||
const plots = await service.suggestPlots(flow.id)
|
||||
expect(plots).toHaveLength(3)
|
||||
db.close()
|
||||
|
||||
@@ -18,11 +18,17 @@ describe('interactive scene integration', () => {
|
||||
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
|
||||
const svc = service(db)
|
||||
const flow = svc.start(session.id)
|
||||
svc.confirmContext(
|
||||
flow.id,
|
||||
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
||||
'林远在演武场测试灵根,众人围观。'
|
||||
)
|
||||
svc.confirmContext(flow.id, {
|
||||
binding: {
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: [],
|
||||
knowledgeIds: []
|
||||
},
|
||||
systemPrompt: '林远在演武场测试灵根,众人围观。',
|
||||
contextSummary: '0章·0纲·0设·0感·0知'
|
||||
})
|
||||
const plots = await svc.suggestPlots(flow.id)
|
||||
svc.selectPlot(flow.id, { optionIds: [plots[0].id] })
|
||||
const afterGen = await svc.generateScene(flow.id, () => {})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||
import { KnowledgeRetrievalService } from '../../src/main/services/knowledge-retrieval.service'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('KnowledgeRetrievalService', () => {
|
||||
let db: SqliteDb
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('UT-CTX-01: ranks by importance + chapter proximity + goal match', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const chapterRepo = new ChapterRepository(db)
|
||||
const cur = chapterRepo.create(volId, '当前章', 5, '<p>x</p>')
|
||||
const near = chapterRepo.create(volId, '近章', 4, '<p>x</p>')
|
||||
chapterRepo.create(volId, '远章', 0, '<p>x</p>')
|
||||
const repo = new KnowledgeRepository(db)
|
||||
const low = repo.create({
|
||||
type: 'fact',
|
||||
title: '无关事实',
|
||||
content: '普通内容',
|
||||
importance: 2,
|
||||
status: 'approved'
|
||||
})
|
||||
const high = repo.create({
|
||||
type: 'foreshadow',
|
||||
title: '测灵石异常',
|
||||
content: '测灵石在入门考核中发热',
|
||||
importance: 5,
|
||||
status: 'approved',
|
||||
foreshadowStatus: 'buried',
|
||||
sourceChapterId: near.id
|
||||
})
|
||||
const service = new KnowledgeRetrievalService(db)
|
||||
const result = service.suggest({
|
||||
currentChapterId: cur.id,
|
||||
goalText: '测灵石入门考核',
|
||||
topN: 5
|
||||
})
|
||||
expect(result[0].entry.id).toBe(high.id)
|
||||
expect(result[0].score).toBeGreaterThan(
|
||||
result.find((r) => r.entry.id === low.id)!.score
|
||||
)
|
||||
expect(result[0].reasons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('UT-CTX-04: resolved foreshadow scores lower than buried', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
const chId = new ChapterRepository(db).create(volId, '章', 0).id
|
||||
const repo = new KnowledgeRepository(db)
|
||||
const buried = repo.create({
|
||||
type: 'foreshadow',
|
||||
title: '埋设伏笔',
|
||||
importance: 3,
|
||||
status: 'approved',
|
||||
foreshadowStatus: 'buried',
|
||||
sourceChapterId: chId
|
||||
})
|
||||
const resolved = repo.create({
|
||||
type: 'foreshadow',
|
||||
title: '已回收伏笔',
|
||||
importance: 3,
|
||||
status: 'approved',
|
||||
foreshadowStatus: 'resolved',
|
||||
sourceChapterId: chId
|
||||
})
|
||||
const service = new KnowledgeRetrievalService(db)
|
||||
const scores = service.suggest({ currentChapterId: chId, topN: 10 })
|
||||
const buriedScore = scores.find((s) => s.entry.id === buried.id)!.score
|
||||
const resolvedScore = scores.find((s) => s.entry.id === resolved.id)!.score
|
||||
expect(buriedScore).toBeGreaterThan(resolvedScore)
|
||||
})
|
||||
})
|
||||
@@ -26,11 +26,17 @@ describe('WizardWritingService.startGenerate', () => {
|
||||
})
|
||||
flow = svc.setRhythm(flow.id, 'tense')
|
||||
flow = svc.setPov(flow.id)
|
||||
flow = svc.confirmContext(
|
||||
flow.id,
|
||||
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
||||
CONTEXT
|
||||
)
|
||||
flow = svc.confirmContext(flow.id, {
|
||||
binding: {
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: [],
|
||||
knowledgeIds: []
|
||||
},
|
||||
systemPrompt: CONTEXT,
|
||||
contextSummary: '0章·0纲·0设·0感·0知'
|
||||
})
|
||||
return flow.id
|
||||
}
|
||||
|
||||
|
||||
@@ -26,11 +26,17 @@ describe('WizardWritingService.buildPreview', () => {
|
||||
})
|
||||
flow = svc.setRhythm(flow.id, 'mixed')
|
||||
flow = svc.setPov(flow.id)
|
||||
flow = svc.confirmContext(
|
||||
flow.id,
|
||||
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
||||
CONTEXT
|
||||
)
|
||||
flow = svc.confirmContext(flow.id, {
|
||||
binding: {
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: [],
|
||||
knowledgeIds: []
|
||||
},
|
||||
systemPrompt: CONTEXT,
|
||||
contextSummary: '0章·0纲·0设·0感·0知'
|
||||
})
|
||||
|
||||
const preview = await svc.buildPreview(flow.id, CONTEXT, '林远')
|
||||
expect(preview).toMatch(/<p>/)
|
||||
|
||||
Reference in New Issue
Block a user