diff --git a/db.sqlite b/db.sqlite index 1e4bd58..ce2ac57 100644 Binary files a/db.sqlite and b/db.sqlite differ diff --git a/note/用户画像设计方案.md b/note/用户画像设计方案.md new file mode 100644 index 0000000..a168c51 --- /dev/null +++ b/note/用户画像设计方案.md @@ -0,0 +1,158 @@ +## 如何设计离线阅读画像系统 + +### 1. 第一阶段:画像模型设计 + +画像不是简单的计数,而是将原始数据(书名、字数、关键词)转化为认知模型。我们定义以下五个核心指标: + +认知深度 (Cognition):通过统计关键词(Keywords)的重复频次和专业程度。 + +知识广度 (Breadth):统计不同书籍领域的分布(基于书名聚类或人工分类)。 + +实践应用 (Practicality):识别 occupation 字段,职业相关(Professional)的心得占比。 + +产出效率 (Output):计算总生成字数与任务完成率。 + +国际视野 (Global):统计英文(en)与中文(zh)任务的比例。 + +### 2. 第二阶段:数据库实体定义 (Prisma/TypeORM) + +假设你使用常用的本地 ORM。我们需要在数据库中增加一个画像缓存表,避免每次打开页面都进行全量计算。 + +```typescript +/** + * 阅读画像缓存实体 + * 存储计算后的分值,避免高频计算 + */ +export interface IReadingPersona { + id: string; // 固定 ID 如 'current_user_persona' + cognition: number; // 认知深度分 (0-100) + breadth: number; // 知识广度分 (0-100) + practicality: number; // 实践应用分 (0-100) + output: number; // 产出效率分 (0-100) + global: number; // 国际视野分 (0-100) + topKeywords: string; // 存储 Top 10 关键词的 JSON 字符串 + updatedAt: Date; // 最后计算时间 +} +``` + +### 3. 第三阶段:核心计算逻辑 (Main Process) + +这是画像系统的“大脑”,负责执行 SQL 并转换数据。 + +```typescript +import { IReadingReflectionTaskItem, IReadingReflectionTaskBatch } from '../types' + +export class PersonaService { + /** + * 从数据库聚合数据并计算画像分值 + */ + async calculatePersona(items: IReadingReflectionTaskItem[], batches: IReadingReflectionTaskBatch[]) { + // 1. 计算认知深度:根据关键词频次 + const allKeywords = items.flatMap(i => i.keywords || []) + const keywordMap = new Map() + allKeywords.forEach(k => keywordMap.set(k, (keywordMap.get(k) || 0) + 1)) + + // 逻辑:去重后的关键词越多且重复越高,分值越高 (示例算法) + const cognitionScore = Math.min(100, (keywordMap.size * 2) + (allKeywords.length / 5)) + + // 2. 计算知识广度:根据书籍数量 + const breadthScore = Math.min(100, batches.length * 10) + + // 3. 计算产出效率:根据总字数 + const totalWords = items.reduce((sum, i) => sum + (i.content?.length || 0), 0) + const outputScore = Math.min(100, totalWords / 500) // 每 5万字满分 + + // 4. 计算 Top 10 关键词 + const sortedKeywords = [...keywordMap.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(entry => entry[0]) + + return { + cognition: Math.round(cognitionScore), + breadth: Math.round(breadthScore), + output: Math.round(outputScore), + practicality: 75, // 可根据 occupation 比例动态计算 + global: 60, // 可根据 language 比例动态计算 + topKeywords: sortedKeywords + } + } +} +``` + +### 4. 第四阶段:前端可视化集成 (Statistics.vue) + +使用 ECharts 渲染雷达图,将计算结果展现给用户。 + +```vue + + + + +``` +## 🎨 设计心得:为什么这么做? +数据解耦:原始任务数据(Items/Batches)与画像数据分开存储。即使画像计算逻辑升级(比如你想改变评分算法),也不需要修改历史心得数据。 + +缓存策略:不要在用户每次切换页面时都去扫全表。建议在任务完成时触发一次增量计算,或者每天用户第一次打开应用时刷新一次。 + +可视化反馈:雷达图的面积代表了用户的“知识疆域”。当用户看着面积一点点变大时,这种离线的成就感是留存的关键。 diff --git a/package.json b/package.json index ccd2b98..552cff3 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "electron-store": "^6.0.1", "electron-trpc": "^0.7.1", "electron-updater": "^6.3.9", + "flexsearch": "^0.8.212", "html2canvas": "^1.4.1", "langchain": "^1.2.4", "mitt": "^3.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4007f74..6e619bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: electron-updater: specifier: ^6.3.9 version: 6.6.2 + flexsearch: + specifier: ^0.8.212 + version: 0.8.212 html2canvas: specifier: ^1.4.1 version: 1.4.1 @@ -2246,6 +2249,9 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flexsearch@0.8.212: + resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -6408,6 +6414,8 @@ snapshots: flatted@3.3.3: {} + flexsearch@0.8.212: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 diff --git a/src/main/db/data-source.ts b/src/main/db/data-source.ts index 2aa702d..ea260c6 100644 --- a/src/main/db/data-source.ts +++ b/src/main/db/data-source.ts @@ -2,29 +2,34 @@ import 'reflect-metadata' import { DataSource } from 'typeorm' import { app } from 'electron' import path from 'path' -import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch' -import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem' +import { ReadingReflectionTaskBatch } from './entities/ReadingReflectionTaskBatch' +import { ReadingReflectionTaskItem } from './entities/ReadingReflectionTaskItem' +import { ReadingPersona } from './entities/ReadingPersona' // 必须导入 const dbPath = app.isPackaged ? path.join(app.getPath('userData'), 'reflections.db') : path.join(process.cwd(), 'db.sqlite') -console.log('--- 数据库存储绝对路径 ---') -console.log(dbPath) -console.log('-----------------------') + export const AppDataSource = new DataSource({ - type: 'better-sqlite3', + type: 'better-sqlite3', // better-sqlite3 性能优于 sqlite3 database: dbPath, - synchronize: true, // 开发环境下自动同步表结构 - logging: true, - entities: [ReadingReflectionTaskBatch, ReadingReflectionTaskItem], - migrations: [], - subscribers: [] + synchronize: true, + logging: process.env.NODE_ENV === 'development', // 仅开发环境开启日志 + entities: [ + ReadingReflectionTaskBatch, + ReadingReflectionTaskItem, + ReadingPersona // 注册实体 + ] }) -// 初始化方法,在 Electron app.whenReady() 中调用 export const initDB = async () => { if (!AppDataSource.isInitialized) { - await AppDataSource.initialize() - console.log('TypeORM SQLite Data Source has been initialized!') + try { + await AppDataSource.initialize() + console.log('Database initialized successfully at:', dbPath) + } catch (err) { + console.error('Error during Data Source initialization', err) + } } + return AppDataSource } diff --git a/src/main/db/entities/ReadingPersona.ts b/src/main/db/entities/ReadingPersona.ts new file mode 100644 index 0000000..df45807 --- /dev/null +++ b/src/main/db/entities/ReadingPersona.ts @@ -0,0 +1,32 @@ +import { Entity, Column, PrimaryColumn, UpdateDateColumn } from 'typeorm' + +@Entity('reading_personas') +export class ReadingPersona { + @PrimaryColumn({ type: 'varchar', default: 'current_user_persona' }) + id: string + + @Column({ type: 'int', default: 0 }) + cognition: number + + @Column({ type: 'int', default: 0 }) + breadth: number + + @Column({ type: 'int', default: 0 }) + practicality: number + + @Column({ type: 'int', default: 0 }) + output: number + + @Column({ type: 'int', default: 0 }) + global: number + + @Column({ type: 'text', nullable: true }) + topKeywords: string + + // SQLite 推荐使用 simple-json 来处理对象映射 + @Column({ type: 'simple-json', nullable: true }) + rawStats: any + + @UpdateDateColumn() + updatedAt: Date +} diff --git a/src/main/services/persona.service.ts b/src/main/services/persona.service.ts new file mode 100644 index 0000000..e044fbd --- /dev/null +++ b/src/main/services/persona.service.ts @@ -0,0 +1,100 @@ +import { Repository } from 'typeorm' +import { ReadingPersona } from '@main/db/entities/ReadingPersona' +import { + IReadingReflectionTaskBatch, + IReadingReflectionTaskItem +} from '@shared/types/IReadingReflectionTask' +import { IUserReadingPersona } from '@shared/types/IUserReadingPersona' + +export class PersonaService { + constructor(private personaRepo: Repository) {} + + /** + * 刷新画像并保存到数据库 + */ + async refreshPersona( + items: IReadingReflectionTaskItem[], + batches: IReadingReflectionTaskBatch[] + ) { + const rawResult = await this.calculatePersona(items, batches) // 调用你原来的计算逻辑 + + const persona = new ReadingPersona() + persona.id = 'current_user_persona' + persona.cognition = rawResult.cognition + persona.breadth = rawResult.breadth + persona.practicality = rawResult.practicality + persona.output = rawResult.output + persona.global = rawResult.global + persona.topKeywords = JSON.stringify(rawResult.topKeywords) + + // 存储完整的 stats 结构以便前端适配 + persona.rawStats = { + totalWords: items.reduce((sum, i) => sum + (i.content?.length || 0), 0), + totalBooks: batches.length, + topKeywords: rawResult.topKeywords + } + + return await this.personaRepo.save(persona) + } + /** + * 从数据库聚合数据并计算画像分值 + */ + async calculatePersona( + items: IReadingReflectionTaskItem[], + batches: IReadingReflectionTaskBatch[] + ) { + // 1. 计算认知深度:根据关键词频次 + const allKeywords = items.flatMap((i) => i.keywords || []) + const keywordMap = new Map() + allKeywords.forEach((k) => keywordMap.set(k, (keywordMap.get(k) || 0) + 1)) + + // 逻辑:去重后的关键词越多且重复越高,分值越高 (示例算法) + const cognitionScore = Math.min(100, keywordMap.size * 2 + allKeywords.length / 5) + + // 2. 计算知识广度:根据书籍数量 + const breadthScore = Math.min(100, batches.length * 10) + + // 3. 计算产出效率:根据总字数 + const totalWords = items.reduce((sum, i) => sum + (i.content?.length || 0), 0) + const outputScore = Math.min(100, totalWords / 500) // 每 5万字满分 + + // 4. 计算 Top 10 关键词 + const sortedKeywords = [...keywordMap.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map((entry) => entry[0]) + + return { + cognition: Math.round(cognitionScore), + breadth: Math.round(breadthScore), + output: Math.round(outputScore), + practicality: 75, // 可根据 occupation 比例动态计算 + global: 60, // 可根据 language 比例动态计算 + topKeywords: sortedKeywords + } + } +} + +/** + * 实体转化为用户阅读画像 + * @param entity 实体 + * */ +export function entityToUserReadingPersona(entity: ReadingPersona): IUserReadingPersona { + return { + domainDepth: JSON.parse(entity.topKeywords || '[]').map((name: string) => ({ + name, + score: entity.cognition, // 简易算法:共用认知深度分 + bookCount: 1 // 可根据数据库详细统计进一步细化 + })), + breadthScore: entity.breadth, + efficiencyScore: entity.output, + maturityScore: entity.practicality, + languageScore: entity.global, + stats: entity.rawStats || { + totalWords: 0, + totalBooks: 0, + topKeywords: [], + mostUsedOccupation: 'other' + } + } +} diff --git a/src/main/services/search.service.ts b/src/main/services/search.service.ts new file mode 100644 index 0000000..353f93e --- /dev/null +++ b/src/main/services/search.service.ts @@ -0,0 +1,104 @@ +import { Document } from 'flexsearch' +import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem' +import { ISearch, HighlightInfo } from '@shared/types/ISearch' + +export class SearchService { + private index: any + + constructor() { + // 初始化 FlexSearch 文档索引 + this.index = new Document({ + document: { + id: 'id', + index: ['title', 'content', 'bookName'], + store: true // 存储原始数据以便快速返回 + }, + tokenize: 'forward', // 适配中文的简易分词 + context: true, + cache: true + }) + } + + /** + * 初始化索引:从数据库加载历史数据 + */ + async initIndex(items: ReadingReflectionTaskItem[]) { + items.forEach((item) => { + this.index.add({ + id: item.id, + title: item.title, + content: item.content, + bookName: (item as any).bookName || '未知书籍' + }) + }) + } + + /** + * 全局搜索核心方法 + */ + async search(query: string): Promise { + if (!query) return [] + + // 执行搜索 + const results = await this.index.search(query, { + limit: 20, + enrich: true, // 返回 store 的数据 + suggest: true + }) + + const searchResults: ISearch[] = [] + + // 扁平化 FlexSearch 的结果并计算高亮坐标 + results.forEach((category: any) => { + category.result.forEach((item: any) => { + const doc = item.doc + // 避免重复项(因为一个文档可能在多个字段命中) + if (searchResults.find((r) => r.id === doc.id)) return + + searchResults.push({ + id: doc.id, + title: doc.title, + content: doc.content, + bookName: doc.bookName, + createdAt: new Date().toISOString(), // 实际应从数据库字段获取 + // 计算高亮坐标 + titleHighlights: this.calculateHighlights(doc.title, query), + contentHighlights: this.calculateHighlights(doc.content, query), + bookHighlights: this.calculateHighlights(doc.bookName, query), + contentSnippet: this.createSnippet(doc.content, query) + }) + }) + }) + + return searchResults + } + + /** + * 简单的坐标计算逻辑 + */ + private calculateHighlights(text: string, query: string): HighlightInfo[] { + const highlights: HighlightInfo[] = [] + if (!text || !query) return highlights + + let index = text.indexOf(query) + while (index !== -1) { + highlights.push({ start: index, length: query.length }) + index = text.indexOf(query, index + query.length) + } + return highlights + } + + /** + * 生成正文摘要预览 + */ + private createSnippet(content: string, query: string): string { + const index = content.indexOf(query) + if (index === -1) return content.substring(0, 100) + + const start = Math.max(0, index - 40) + const end = Math.min(content.length, index + 60) + return ( + (start > 0 ? '...' : '') + content.substring(start, end) + (end < content.length ? '...' : '') + ) + } +} diff --git a/src/renderer/src/hooks/useLoading.ts b/src/renderer/src/hooks/useLoading.ts new file mode 100644 index 0000000..c0f9809 --- /dev/null +++ b/src/renderer/src/hooks/useLoading.ts @@ -0,0 +1,19 @@ +import { ref } from 'vue'; + +/** + * 加载状态Hook + */ +export default function useLoading(initValue = false) { + const loading = ref(initValue); + const setLoading = (value: boolean) => { + loading.value = value; + }; + const toggle = () => { + loading.value = !loading.value; + }; + return { + loading, + setLoading, + toggle, + }; +} diff --git a/src/renderer/src/pages/menus/data/MenusData.ts b/src/renderer/src/pages/menus/data/MenusData.ts index 0785aac..b826b55 100644 --- a/src/renderer/src/pages/menus/data/MenusData.ts +++ b/src/renderer/src/pages/menus/data/MenusData.ts @@ -21,12 +21,12 @@ export const features = [ path: 'search' }, { - id: 'statistics', + id: 'userPersona', title: '阅读画像', desc: '可视化你的知识边界与偏好', icon: TrendTwo, color: '#00b42a', - path: 'statistics' + path: 'userPersona' } ] }, diff --git a/src/renderer/src/pages/menus/views/Developing.vue b/src/renderer/src/pages/menus/views/Developing.vue index ef99733..d936489 100644 --- a/src/renderer/src/pages/menus/views/Developing.vue +++ b/src/renderer/src/pages/menus/views/Developing.vue @@ -101,10 +101,6 @@ const { title } = toRefs(props) transform: translateY(-10px); } } -.animate-float { - animation: float 3s ease-in-out infinite; -} - @keyframes shine { from { transform: translateX(-100%); @@ -130,4 +126,15 @@ const { title } = toRefs(props) .animate-in { animation: fadeIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; } + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/renderer/src/pages/menus/views/search/components/HighlightText.vue b/src/renderer/src/pages/menus/views/search/components/HighlightText.vue new file mode 100644 index 0000000..0d0b824 --- /dev/null +++ b/src/renderer/src/pages/menus/views/search/components/HighlightText.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/src/renderer/src/pages/menus/views/search/index.vue b/src/renderer/src/pages/menus/views/search/index.vue new file mode 100644 index 0000000..410904e --- /dev/null +++ b/src/renderer/src/pages/menus/views/search/index.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/src/renderer/src/pages/menus/views/search/utils/highlighter.ts b/src/renderer/src/pages/menus/views/search/utils/highlighter.ts new file mode 100644 index 0000000..44555c6 --- /dev/null +++ b/src/renderer/src/pages/menus/views/search/utils/highlighter.ts @@ -0,0 +1,51 @@ +// 定义高亮片段的结构 +export interface HighlightSegment { + text: string + isHighlight: boolean +} + +/** + * 将原始文本根据高亮索引转化为分段数组 + */ +export function getHighlightedSegments( + text: string, + highlights: { start: number; length: number }[] +): HighlightSegment[] { + if (!highlights || highlights.length === 0) { + return [{ text, isHighlight: false }] + } + + // 1. 按照起始位置排序,防止索引乱序导致切片失败 + const sortedHighlights = [...highlights].sort((a, b) => a.start - b.start) + + const segments: HighlightSegment[] = [] + let lastIndex = 0 + + for (const { start, length } of sortedHighlights) { + // 处理匹配项之前的普通文本 + if (start > lastIndex) { + segments.push({ + text: text.substring(lastIndex, start), + isHighlight: false + }) + } + + // 处理匹配到的高亮文本 + segments.push({ + text: text.substring(start, start + length), + isHighlight: true + }) + + lastIndex = start + length + } + + // 处理剩余的尾部文本 + if (lastIndex < text.length) { + segments.push({ + text: text.substring(lastIndex), + isHighlight: false + }) + } + + return segments +} diff --git a/src/renderer/src/pages/menus/views/statistics/index.vue b/src/renderer/src/pages/menus/views/statistics/index.vue deleted file mode 100644 index f414385..0000000 --- a/src/renderer/src/pages/menus/views/statistics/index.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - diff --git a/src/renderer/src/pages/menus/views/userPersona/config/chart.config.ts b/src/renderer/src/pages/menus/views/userPersona/config/chart.config.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/renderer/src/pages/menus/views/userPersona/index.vue b/src/renderer/src/pages/menus/views/userPersona/index.vue new file mode 100644 index 0000000..fe5ab0e --- /dev/null +++ b/src/renderer/src/pages/menus/views/userPersona/index.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/src/renderer/src/router/index.ts b/src/renderer/src/router/index.ts index 89f9fb0..45c6464 100644 --- a/src/renderer/src/router/index.ts +++ b/src/renderer/src/router/index.ts @@ -26,9 +26,14 @@ const routes: RouteRecordRaw[] = [ component: () => import('@renderer/pages/reflection/views/poster/index.vue') }, { - path: '/menus/statistics', - name: 'ReadingStatistics', - component: () => import('@renderer/pages/menus/views/statistics/index.vue') + path: '/menus/userPersona', + name: 'ReadingUserPersona', + component: () => import('@renderer/pages/menus/views/userPersona/index.vue') + }, + { + path: '/menus/search', + name: 'ReadingSearch', + component: () => import('@renderer/pages/menus/views/search/index.vue') }, { path: '/menus/:pathMatch(.*)*', diff --git a/src/rpc/context.ts b/src/rpc/context.ts index 2b1b124..bc662c9 100644 --- a/src/rpc/context.ts +++ b/src/rpc/context.ts @@ -1,5 +1,7 @@ import { BrowserWindow } from 'electron' import type { CreateContextOptions } from 'electron-trpc/main' +import { AppDataSource } from '@main/db/data-source' +import { DataSource } from 'typeorm' /** * 1. 定义 Context 的结构 @@ -11,6 +13,7 @@ export interface AppContext { webContentsId: number // 可以在这里扩展用户信息,例如从 Session 中获取 user?: { id: string; role: string } + db: DataSource } /** @@ -27,7 +30,8 @@ export const createContext = async (opts: CreateContextOptions): Promise { + const entity = await ctx.db.getRepository(ReadingPersona).findOneBy({ + id: 'current_user_persona' + }) + + if (!entity) return null + + // 将数据库扁平实体映射为前端需要的结构化 IUserReadingPersona + return entityToUserReadingPersona(entity) + }), + + // 手动强制重新刷新画像 + forceRefreshUserPersona: t.procedure.mutation(async ({ ctx }) => { + const items = await ctx.db + .getRepository(ReadingReflectionTaskItem) + .find({ where: { status: 'COMPLETED' } }) + const batches = await ctx.db.getRepository(ReadingReflectionTaskBatch).find() + + const personaService = new PersonaService(ctx.db.getRepository(ReadingPersona)) + return await personaService.refreshPersona(items, batches) + }) +}) diff --git a/src/rpc/router/search.router.ts b/src/rpc/router/search.router.ts new file mode 100644 index 0000000..3a46321 --- /dev/null +++ b/src/rpc/router/search.router.ts @@ -0,0 +1,19 @@ +import { router, t } from '@rpc/index' +import { z } from 'zod' +import { SearchService } from '@main/services/search.service' +import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem' + +// 在主进程启动时实例化并初始化 +let searchService: SearchService | null = null + +export const searchRouter = router({ + search: t.procedure.input(z.string()).query(async ({ input, ctx }) => { + if (!searchService) { + searchService = new SearchService() + // 从数据库加载一次 + const items = await ctx.db.getRepository(ReadingReflectionTaskItem).find() + await searchService.initIndex(items) + } + return await searchService.search(input) + }) +}) diff --git a/src/shared/types/IReadingReflectionTask.ts b/src/shared/types/IReadingReflectionTask.ts index 4b4cf5a..e3d64f6 100644 --- a/src/shared/types/IReadingReflectionTask.ts +++ b/src/shared/types/IReadingReflectionTask.ts @@ -21,41 +21,27 @@ export const IReadingReflectionsTaskSchema = z.object({ }) export type IReadingReflectionsTask = z.infer -/** - * 任务响应结果模型 - */ -export interface IReadingReflectionsResponse { - taskId: string - reflections: Array<{ - title: string // 心得标题 - content: string // 心得正文 - keywords: string[] // 提取的关键词 - summary: string // 内容摘要 - }> - status: 'pending' | 'completed' | 'failed' -} - /** * 对应数据库中的批次记录 (主任务) */ export interface IReadingReflectionTaskBatch { - id: string - bookName: string - totalCount: number - status: string - progress: number - createdAt: Date - items?: IReadingReflectionTaskItem[] + id: string //任务ID + bookName: string // 书籍名称 + totalCount: number // 生成数量 + status: string // 生成状态 + progress: number // 进度 + createdAt: Date // 创建时间 + items?: IReadingReflectionTaskItem[] // 子任务模块 } /** * 对应数据库中的具体任务项 (子任务) */ export interface IReadingReflectionTaskItem { - id: string - status: 'PENDING' | 'WRITING' | 'COMPLETED' | 'FAILED' - progress: number - content?: string - title?: string - summary?: string - keywords?: string[] + id: string // 子任务ID + status: 'PENDING' | 'WRITING' | 'COMPLETED' | 'FAILED' // 任务状态 + progress: number // 进度 + content?: string // 生成内容 + title?: string // 生成标题 + summary?: string // 生成摘要 + keywords?: string[] // 关键词 } diff --git a/src/shared/types/ISearch.ts b/src/shared/types/ISearch.ts new file mode 100644 index 0000000..f0ba061 --- /dev/null +++ b/src/shared/types/ISearch.ts @@ -0,0 +1,24 @@ +/** + * 高亮坐标信息 + */ +export interface HighlightInfo { + start: number // 关键词在原始字符串中的起始索引(从 0 开始) + length: number // 关键词的长度 +} + +/** + * 完整的搜索结果接口 + */ +export interface ISearch { + id: string + bookName: string + title: string + content: string + contentSnippet: string // 截取的正文片段 + createdAt: string + + // 对应的坐标数组 + titleHighlights: HighlightInfo[] + contentHighlights: HighlightInfo[] + bookHighlights: HighlightInfo[] +} diff --git a/src/shared/types/IUserReadingPersona.ts b/src/shared/types/IUserReadingPersona.ts new file mode 100644 index 0000000..84cb67e --- /dev/null +++ b/src/shared/types/IUserReadingPersona.ts @@ -0,0 +1,36 @@ +/** + * 用户阅读画像统计模型 + */ +export interface IUserReadingPersona { + // 维度 1: 领域深度 (Domain Depth) + // 根据关键词(keywords)的聚合频次计算,反映用户在特定领域的钻研程度 + domainDepth: { + name: string // 领域名称 (如:认知心理学、前端技术) + score: number // 0-100 的得分 + bookCount: number // 该领域下的书籍数量 + }[] + + // 维度 2: 知识广度 (Knowledge Breadth) + // 根据书名(bookName)和关键词的跨度计算,反映阅读类别的多样性 + breadthScore: number + + // 维度 3: 产出效率 (Output Efficiency) + // 基于 wordCount(要求字数) 与实际 content(生成内容) 的比例,以及完成频率 + efficiencyScore: number + + // 维度 4: 角色成熟度 (Persona Maturity) + // 基于 occupation(职业) 字段。例如 student 偏向基础吸收,researcher 偏向深度批判 + maturityScore: number + + // 维度 5: 语言能力 (Language Versatility) + // 基于 language(zh/en) 的分布比例 + languageScore: number + + // 原始统计辅助数据 + stats: { + totalWords: number // 累计生成的心得总字数 + totalBooks: number // 累计阅读并生成过心得的书籍总数 + topKeywords: string[] // 出现频率最高的 Top 10 关键词 + mostUsedOccupation: string // 最常使用的阅读者身份 + } +}