Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions prisma/hyperdrive.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ model sr_user_bookmark_tag {
tag_id Int @default(0)
created_at DateTime @default(now())
is_deleted Boolean @default(false)
// who attached this tag: "user" | "ai"; "" for rows created before the column existed
source String @default("")

bookmark sr_bookmark? @relation(fields: [bookmark_id], references: [id])
user_bookmark sr_user_bookmark? @relation(fields: [user_id, bookmark_id], references: [user_id, bookmark_id])
Expand Down Expand Up @@ -169,7 +171,14 @@ model sr_user_tag {
display Boolean @default(true)
created_at DateTime @default(now())

// "auto": in the vocabulary but never confirmed by the user
// "mine": confirmed by the user, AI picks these first
source String @default("auto")
// last time the user (not AI) attached this tag to a bookmark
last_used_at DateTime?

@@unique([user_id, tag_name])
@@index([user_id, display, source])
}

model sr_bookmark_comment {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- sr_user_tag: vocabulary ownership + recency
ALTER TABLE "sr_user_tag" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto';
ALTER TABLE "sr_user_tag" ADD COLUMN "last_used_at" TIMESTAMP(3);

-- sr_user_bookmark_tag: who attached the tag ("user" | "ai"), "" for history
ALTER TABLE "sr_user_bookmark_tag" ADD COLUMN "source" TEXT NOT NULL DEFAULT '';

CREATE INDEX "sr_user_tag_user_id_display_source_idx" ON "sr_user_tag"("user_id", "display", "source");

-- Backfill last_used_at from the newest live link. History cannot tell user from AI,
-- so every existing link counts once.
UPDATE "sr_user_tag" t SET "last_used_at" = (
SELECT MAX(bt."created_at") FROM "sr_user_bookmark_tag" bt
WHERE bt."tag_id" = t."id" AND bt."user_id" = t."user_id" AND bt."is_deleted" = false
);
16 changes: 13 additions & 3 deletions src/const/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,15 @@ ${byline}
${content}`
}

export const generateOverviewTagsUserPrompt = function (userLang: string, tags: string[]) {
export interface TagVocabularyPrompt {
/** confirmed by the user: pick these first */
mine: string[]
/** in the vocabulary but never confirmed */
auto: string[]
}

export const generateOverviewTagsUserPrompt = function (userLang: string, tags: string[] | TagVocabularyPrompt) {
const vocabulary: TagVocabularyPrompt = Array.isArray(tags) ? { mine: [], auto: tags } : tags
return `## 你需要输出tags
- 从提供的标签列表中选择最符合文章内容的标签,数量可以是0~3个
- 宁缺毋滥:如果列表中没有与文章核心内容真正匹配的标签,就一个都不选,输出空数组 []。勉强选择一个沾边的标签,比不选择更糟糕
Expand All @@ -141,8 +149,10 @@ export const generateOverviewTagsUserPrompt = function (userLang: string, tags:
- 标签选择要基于文章实际内容,避免主观臆测
- 不要因为标签描述的是读者可能的兴趣而选择它,标签必须描述文章本身的内容
- 生成标签列表时,语言则只能跟随用户的标签列表,不可以擅自翻译
- 标签的列表:
${tags.join(',')}
- 我的标签(能对上就必须优先用):
${vocabulary.mine.join(',')}
- 备选标签(我的标签都对不上时才用):
${vocabulary.auto.join(',')}

## 你需要输出overview
- 概述文章的核心主题和主要内容,overview的内容包括
Expand Down
8 changes: 7 additions & 1 deletion src/di/generated/dependency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ import { ReportRepo } from '../../infra/repository/dbReport'
import { BookmarkService } from '../../domain/bookmark'
import { TagService } from '../../domain/tag'
import { MarkService } from '../../domain/mark'
import { UserService } from '../../domain/user'
import { ImportService } from '../../domain/import'
import { UrlParserHandler } from '../../domain/orchestrator/urlParser'
import { NotificationService } from '../../domain/notification'
import { ShareService } from '../../domain/share'
import { UserService } from '../../domain/user'
import { DBSyncBatchOperation } from '../../infra/repository/dbSyncBatch'
import { QueueClient } from '../../infra/queue/queueClient'
import { AigcService } from '../../domain/aigc'
Expand All @@ -39,6 +39,7 @@ import { ShareOrchestrator } from '../../domain/orchestrator/share'
import { SyncOrchestrator } from '../../domain/orchestrator/sync'
import { ImportOrchestrator } from '../../domain/orchestrator/import'
import { EmailService } from '../../domain/email'
import { ContentOrchestrator } from '../../domain/orchestrator/content'
import { BookmarkJob } from '../../handler/cron/bookmarkJob'
import { BookmarkConsumer } from '../../handler/queue/bookmarkConsumer'
import { BucketClient } from '../../infra/repository/bucketClient'
Expand Down Expand Up @@ -124,6 +125,11 @@ container.register(BookmarkOrchestrator, {
useFactory: container => new BookmarkOrchestrator(container.resolve(BookmarkService), container.resolve(TagService), container.resolve(MarkService))
})

container.register(ContentOrchestrator, {
useFactory: container =>
new ContentOrchestrator(container.resolve(BookmarkService), container.resolve(UserService), container.resolve(TagService), container.resolve(MarkService))
})

container.register(ImportOrchestrator, {
useFactory: container =>
new ImportOrchestrator(
Expand Down
12 changes: 12 additions & 0 deletions src/di/generated/readerRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,18 @@ export function getRouter(container: Container) {
const controller = container.resolve(TagController)
return await controller.handleCreateTagRequest(ctx, req)
})
router.post('/v1/tag/promote', async (req: Request, ctx: ContextManager) => {
const controller = container.resolve(TagController)
return await controller.handlePromoteTagRequest(ctx, req)
})
router.post('/v1/tag/demote', async (req: Request, ctx: ContextManager) => {
const controller = container.resolve(TagController)
return await controller.handleDemoteTagRequest(ctx, req)
})
router.post('/v1/tag/delete', async (req: Request, ctx: ContextManager) => {
const controller = container.resolve(TagController)
return await controller.handleDeleteTagRequest(ctx, req)
})
router.post('/v1/user/login', async (req: Request, ctx: ContextManager) => {
const controller = container.resolve(UserController)
return await controller.handleUserLoginRequest(ctx, req)
Expand Down
9 changes: 8 additions & 1 deletion src/domain/aigc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
buildChatSystemInstruction,
buildChatUserMessage
} from '../const/prompt'
import type { TagVocabularyPrompt } from '../const/prompt'
import { ContextManager } from '../utils/context'
import { ContentParser } from '../utils/parser'
import { inject, injectable } from '../decorators/di'
Expand Down Expand Up @@ -388,7 +389,13 @@ export class AigcService {
}

// Generate tags from user tags
public async generateOverviewTags(ctx: ContextManager, bmTitle: string, bmContent: string, byline: string, userTags: string[]): Promise<MixTagsOverviewResult> {
public async generateOverviewTags(
ctx: ContextManager,
bmTitle: string,
bmContent: string,
byline: string,
userTags: string[] | TagVocabularyPrompt
): Promise<MixTagsOverviewResult> {
const userLang = ctx.get('ai_lang') || 'EN'

const contents: Content[] = [
Expand Down
71 changes: 39 additions & 32 deletions src/domain/bookmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import { VectorizeRepo } from '../infra/repository/dbVectorize'
import { MarkRepo } from '../infra/repository/dbMark'
import { UserRepo } from '../infra/repository/dbUser'
import type { bookmarkParsePO, bookmarkPO } from '../infra/repository/dbBookmark'
import type { Prisma } from '@prisma/hyperdrive-client'

type UserBookmarkListRow = Prisma.sr_user_bookmarkGetPayload<{ include: { bookmark: true; sr_user_bookmark_tag: true } }>
import { MultiLangError } from '../utils/multiLangError'
import { authToken } from '../middleware/auth'
import { randomUUID } from 'crypto'
Expand Down Expand Up @@ -264,12 +267,12 @@ export class BookmarkService {
return null
}

// 创建标签
// 创建标签:导入来的词进自动标签,用户在标签页确认后才算我的标签
for (const tag of item.tags) {
const tagRes = await this.bookmarkRepo.createUserTag(ctx.getUserId(), tag)
const tagRes = await this.bookmarkRepo.createUserTag(ctx.getUserId(), tag, 'auto')
console.log(`create tag: ${JSON.stringify(tagRes)}`)
if (!tagRes) continue
await this.bookmarkRepo.createBookmarkTag(bmInfo.id, ctx.getUserId(), tagRes.id, tag)
await this.bookmarkRepo.createBookmarkTag(bmInfo.id, ctx.getUserId(), tagRes.id, tag, 'user')
}

return {
Expand Down Expand Up @@ -498,11 +501,11 @@ export class BookmarkService {
}
}

/** 获取收藏列表 */
public async bookmarkList(ctx: ContextManager, page: number, size: number, filter: string) {
return (await this.bookmarkRepo.listUserBookmarks(ctx.getUserId(), (page - 1) * size, size, filter))
/** one list row for the client: bookmark fields + user state + live tag chips */
private mapUserBookmarkRows(ctx: ContextManager, rows: UserBookmarkListRow[]) {
return rows
.filter(({ bookmark }) => bookmark !== null)
.map(({ uuid, bookmark, alias_title, archive_status, is_starred, deleted_at, type, created_at, updated_at }) => {
.map(({ uuid, bookmark, alias_title, archive_status, is_starred, deleted_at, type, created_at, updated_at, sr_user_bookmark_tag }) => {
const { private_user, content_md_key, content_key, ...bookmarkWithout } = bookmark!
return {
...bookmarkWithout,
Expand All @@ -514,28 +517,30 @@ export class BookmarkService {
trashed_at: !!deleted_at ? deleted_at : undefined,
type: type === 1 ? 'shortcut' : 'article',
created_at,
updated_at
updated_at,
tags: (sr_user_bookmark_tag || []).map(t => ({
id: ctx.hashIds.encodeId(t.tag_id),
name: t.tag_name,
show_name: t.tag_name,
added_by: t.source
}))
}
})
}

/** 根据标签ID获取收藏列表 */
/** 获取收藏列表 */
public async bookmarkList(ctx: ContextManager, page: number, size: number, filter: string) {
return this.mapUserBookmarkRows(ctx, await this.bookmarkRepo.listUserBookmarks(ctx.getUserId(), (page - 1) * size, size, filter))
}

/** 按标签交集获取收藏列表 */
public async bookmarkListByTopics(ctx: ContextManager, page: number, size: number, tagIds: number[]): Promise<bookmarkPO[]> {
return this.mapUserBookmarkRows(ctx, await this.bookmarkRepo.listUserBookmarksByTagIds(ctx.getUserId(), tagIds, (page - 1) * size, size))
}

/** 根据标签ID获取收藏列表(单标签,保留给旧调用方) */
public async bookmarkListByTopic(ctx: ContextManager, page: number, size: number, tagId: number): Promise<bookmarkPO[]> {
return (await this.bookmarkRepo.listUserBookmarksByTagId(ctx.getUserId(), tagId, (page - 1) * size, size))
.filter(({ bookmark }) => bookmark !== null)
.map(({ user_bookmark, bookmark }) => {
const { private_user, content_md_key, content_key, ...bookmarkWithout } = bookmark!
return {
...bookmarkWithout!,
bookmark_user_uuid: user_bookmark!.uuid,
alias_title: user_bookmark!.alias_title,
id: ctx.hashIds.encodeId(user_bookmark!.bookmark_id),
archived: user_bookmark!.archive_status === 1 ? 'archive' : user_bookmark!.archive_status === 2 ? 'later' : 'inbox',
starred: user_bookmark!.is_starred ? 'star' : 'unstar',
created_at: user_bookmark!.created_at,
updated_at: user_bookmark!.updated_at
}
})
return this.bookmarkListByTopics(ctx, page, size, [tagId])
}

public async getBookmarkContent(bmKey: string) {
Expand Down Expand Up @@ -635,15 +640,17 @@ export class BookmarkService {
return 0
}

/** 书签添加标签 */
/**
* AI attaches vocabulary words to a bookmark. Names are resolved without touching
* ownership or display, links are written with source "ai", last_used_at stays put.
*/
public async tagBookmark(ctx: ContextManager, userId: number, bmId: number, tags: string[]) {
const bookmarkRepo = this.bookmarkRepo

for (const tag of tags) {
const repoTag = await bookmarkRepo.createUserTag(userId, tag)
if (!repoTag) continue
await bookmarkRepo.createBookmarkTag(bmId, userId, repoTag.id, repoTag.tag_name)
}
if (tags.length < 1) return
// a plain lookup: the bookmark may belong to several users and a name that is not in
// this user's live vocabulary must be dropped, never created
const rows = await this.bookmarkRepo.getUserTagsByNames(userId, tags)
if (rows.length < 1) return
await this.bookmarkRepo.upsertBookmarkTags(bmId, userId, rows, 'ai')
}

/** 创建书签概述 */
Expand Down
9 changes: 5 additions & 4 deletions src/domain/orchestrator/urlParser.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { groupVocabulary, pickTagsForBookmark } from '../../utils/tags'
import { inject, injectable } from '../../decorators/di'
import { ContextManager } from '../../utils/context'
import { BookmarkService } from '../bookmark'
Expand Down Expand Up @@ -166,21 +167,21 @@ export class UrlParserHandler {
console.log(`bookmark ${info.bookmarkId} url is prohibited content, skip tags and overview generation`)
return
}
// get user setting tags list
const userTags = (await this.tagService.listUserTags(ctx)).map(item => item.name)
// the live vocabulary, split so the prompt can prefer the user's own words
const vocabulary = await this.tagService.listUserTags(ctx)
const { overview, key_takeaways, tags } = await this.aigcService.generateOverviewTags(
ctx,
meta.parseRes.title || '',
meta.parseRes.textContent,
meta.parseRes.byline || '',
userTags
groupVocabulary(vocabulary)
)

if (overview.length > 0) {
await Promise.all(info.userIds.map(userId => this.bookmarkService.createBookmarkOverview(userId, info.bookmarkId, '', JSON.stringify({ overview, key_takeaways }))))
}

const filteredTags = tags.filter(tag => userTags.includes(tag))
const filteredTags = pickTagsForBookmark(tags, vocabulary).map(t => t.name)

await Promise.all(info.userIds.map(userId => this.bookmarkService.tagBookmark(ctx, userId, info.bookmarkId, filteredTags)))
}
Expand Down
Loading
Loading