Migrate AI Hint feed generation

Bring dynamic hint generation into the account service while preserving the legacy key.osglab.com deployment for existing clients.
This commit is contained in:
Rocky
2026-08-21 15:17:15 +08:00
parent d0abe27623
commit 454ba8ddc5
40 changed files with 2838 additions and 10 deletions
@@ -44,6 +44,9 @@ class DeploymentConsistencyTest : FunSpec({
val migration = root.read(
"src/main/resources/db/migration/V22__official_content_management.sql",
)
val hintFeedMigration = root.read(
"src/main/resources/db/migration/V24__hint_feed_generation.sql",
)
val privileges = root.read("docs/mysql-minimum-privileges.sql")
val smokePrivileges = root.read("deploy/smoke/runtime-grants.sql")
@@ -52,6 +55,9 @@ class DeploymentConsistencyTest : FunSpec({
migration shouldContain "CREATE TABLE official_skill_localizations"
migration shouldContain "CREATE TABLE official_hint_packs"
migration shouldContain "locale IN ('zh', 'en')"
hintFeedMigration shouldContain "CREATE TABLE hint_feed_settings"
hintFeedMigration shouldContain "CREATE TABLE hint_feed_generation_state"
hintFeedMigration shouldContain "generation_interval_hours BETWEEN 1 AND 168"
openApi shouldContain "schemaVersion: { type: integer, const: 1 }"
openApi shouldContain "pattern: \"^official\\\\."
openApi shouldContain "Cache-Control: { schema: { type: string, const: \"public,max-age=300\" } }"
@@ -70,6 +76,8 @@ class DeploymentConsistencyTest : FunSpec({
grants shouldContain "official_skills"
grants shouldContain "official_skill_localizations"
grants shouldContain "official_hint_packs"
grants shouldContain "hint_feed_settings"
grants shouldContain "hint_feed_generation_state"
}
}
@@ -387,6 +395,9 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/admin/content/skills/{id}",
"/v1/admin/content/skills/{id}/enable",
"/v1/admin/content/skills/{id}/disable",
"/v1/admin/content/hints/generation/settings",
"/v1/admin/content/hints/generation/status",
"/v1/admin/content/hints/generation/regenerate",
"/v1/admin/content/hints/{locale}",
"/v1/admin/auth/session",
"/v1/admin/auth/login",
@@ -77,6 +77,19 @@ internal class InMemoryContentRepository : ContentRepository {
return stored
}
override suspend fun putHintPacks(
packs: List<HintPackRecord>,
now: Instant,
audit: NewAdminAuditEvent,
): List<HintPackRecord> {
val stored = packs.sortedBy(HintPackRecord::locale).map { pack ->
pack.copy(version = (hints[pack.locale]?.version ?: 0) + 1)
}
stored.forEach { hints[it.locale] = it }
audits += audit
return stored
}
private fun publish(now: Instant, audit: NewAdminAuditEvent) {
revision += 1
generatedAt = now
@@ -0,0 +1,77 @@
package com.osglab.account.features.content.feed
import com.osglab.account.features.content.feed.sources.BaselineHintSource
import com.osglab.account.features.content.feed.sources.HintFeedGenerationContext
import com.osglab.account.features.content.models.AIHintCardDto
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.time.LocalDate
class HintFeedPolicyTest : FunSpec({
test("filter blocks explicit unsafe phrases without matching TCP") {
HintCardPolicy.isBlocked("how to make a bomb") shouldBe true
HintCardPolicy.isBlocked("制作 炸弹") shouldBe true
HintCardPolicy.isBlocked("TCP congestion control") shouldBe false
}
test("clean title normalizes whitespace and truncates by Unicode code point") {
HintCardPolicy.cleanTitle(" A\n B\tC ") shouldBe "A B C"
HintCardPolicy.cleanTitle("天气很好🌤️适合散步", 6) shouldBe "天气很好🌤…"
}
test("merger sorts deduplicates text and caps the pack at forty") {
val cards = (0 until 45).map { index ->
hint(id = "id-$index", text = "text-$index", priority = index)
} + hint(id = "duplicate", text = "TEXT-44", priority = 100)
val merged = HintFeedMerger.merge(cards)
merged.size shouldBe 40
merged.first().id shouldBe "duplicate"
merged.count { it.text.equals("text-44", ignoreCase = true) } shouldBe 1
}
test("baseline preserves the four legacy cards for each locale") {
val source = BaselineHintSource()
val context = HintFeedGenerationContext(
generatedAt = Instant.parse("2026-08-21T00:00:00Z"),
localDate = LocalDate.parse("2026-08-21"),
)
val settings = settings()
source.fetch("zh", context, settings).map(AIHintCardDto::id) shouldContainExactly listOf(
"cap-zh-encyclopedia",
"cap-zh-stocks",
"cap-zh-clipboard-reply",
"cap-zh-clipboard-translate",
)
source.fetch("en", context, settings).map(AIHintCardDto::id) shouldContainExactly listOf(
"cap-en-encyclopedia",
"cap-en-stocks",
"cap-en-clipboard-reply",
"cap-en-clipboard-translate",
)
}
})
private fun hint(id: String, text: String, priority: Int) =
AIHintCardDto(
id = id,
text = text,
prompt = "prompt",
category = "general",
priority = priority,
source = "test",
locale = "en",
)
private fun settings() = HintFeedSettings(
generationIntervalHours = 12,
holidayCountriesZh = "CN",
holidayCountriesEn = "US,GB",
weatherCitiesZh = "北京:39.90,116.40",
weatherCitiesEn = "London:51.51,-0.13",
googleTrendsGeos = "US,GB",
)
@@ -0,0 +1,166 @@
package com.osglab.account.features.content.feed
import com.osglab.account.config.HintFeedConfig
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.content.InMemoryContentRepository
import com.osglab.account.features.content.feed.sources.BaselineHintSource
import com.osglab.account.features.content.feed.sources.HintFeedGenerationContext
import com.osglab.account.features.content.feed.sources.HintFeedSource
import com.osglab.account.features.content.services.ContentService
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.Clock
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
class HintFeedServiceTest : FunSpec({
val now = Instant.parse("2026-08-21T06:00:00Z")
val clock = Clock.fixed(now, ZoneOffset.UTC)
test("source failure is isolated and both baseline packs publish atomically") {
val contentRepository = InMemoryContentRepository()
val feedRepository = InMemoryHintFeedRepository()
val service = service(
contentRepository = contentRepository,
feedRepository = feedRepository,
clock = clock,
sources = listOf(BaselineHintSource(), FailingHintSource),
)
val result = service.regenerate(SUPER_ADMIN, "request-12345678")
result.zh.cardCount shouldBe 4
result.en.cardCount shouldBe 4
result.zh.version shouldBe 1
result.en.version shouldBe 1
contentRepository.getHintPack("zh")?.version shouldBe 1
contentRepository.getHintPack("en")?.version shouldBe 1
feedRepository.state.outcome shouldBe HintFeedGenerationOutcome.SUCCEEDED
}
test("scheduled replay inside the interval does not publish a second version") {
val contentRepository = InMemoryContentRepository()
val feedRepository = InMemoryHintFeedRepository()
val service = service(contentRepository, feedRepository, clock)
service.generateIfDue()
service.generateIfDue()
contentRepository.getHintPack("zh")?.version shouldBe 1
contentRepository.getHintPack("en")?.version shouldBe 1
}
test("invalid settings are rejected before persistence") {
val feedRepository = InMemoryHintFeedRepository()
val service = service(InMemoryContentRepository(), feedRepository, clock)
val exception = shouldThrow<HintFeedException> {
service.updateSettings(
SUPER_ADMIN,
UpdateHintFeedSettingsRequest(
generationIntervalHours = 0,
holidayCountriesZh = "CN",
holidayCountriesEn = "US",
weatherCitiesZh = "北京:39.90,116.40",
weatherCitiesEn = "London:51.51,-0.13",
googleTrendsGeos = "US",
),
"request-12345678",
)
}
exception.code shouldBe HintFeedErrorCode.HINT_FEED_SETTINGS_INVALID
feedRepository.updated shouldBe false
}
})
private fun service(
contentRepository: InMemoryContentRepository,
feedRepository: InMemoryHintFeedRepository,
clock: Clock,
sources: List<HintFeedSource> = listOf(BaselineHintSource()),
) = HintFeedService(
repository = feedRepository,
contentService = ContentService(contentRepository, clock),
generationLock = DirectHintFeedGenerationLock,
sources = sources,
config = HintFeedConfig(enabled = true, zoneId = ZoneId.of("UTC")),
clock = clock,
)
private object DirectHintFeedGenerationLock : HintFeedGenerationLock {
override suspend fun <T> withLock(block: suspend () -> T): T = block()
}
private object FailingHintSource : HintFeedSource {
override val id: String = "failing"
override val locales: Set<String> = setOf("zh", "en")
override suspend fun fetch(
locale: String,
context: HintFeedGenerationContext,
settings: HintFeedSettings,
) = error("upstream unavailable")
}
private class InMemoryHintFeedRepository : HintFeedRepository {
var settings = HintFeedSettings(
generationIntervalHours = 12,
holidayCountriesZh = "CN",
holidayCountriesEn = "US,GB",
weatherCitiesZh = "北京:39.90,116.40",
weatherCitiesEn = "London:51.51,-0.13",
googleTrendsGeos = "US,GB",
)
var state = HintFeedGenerationState(HintFeedGenerationOutcome.IDLE, null, null, null)
var updated = false
override suspend fun getSettings(): HintFeedSettings = settings
override suspend fun updateSettings(
settings: HintFeedSettings,
now: Instant,
audit: NewAdminAuditEvent,
) {
this.settings = settings
updated = true
}
override suspend fun getGenerationState(): HintFeedGenerationState = state
override suspend fun markGenerationRunning(now: Instant) {
state = state.copy(
outcome = HintFeedGenerationOutcome.RUNNING,
lastStartedAt = now,
lastErrorCode = null,
)
}
override suspend fun markGenerationSucceeded(now: Instant) {
state = state.copy(
outcome = HintFeedGenerationOutcome.SUCCEEDED,
lastCompletedAt = now,
lastErrorCode = null,
)
}
override suspend fun markGenerationFailed(now: Instant, errorCode: String) {
state = state.copy(
outcome = HintFeedGenerationOutcome.FAILED,
lastCompletedAt = now,
lastErrorCode = errorCode,
)
}
}
private val SUPER_ADMIN = AdminPrincipal(
operatorId = UUID.fromString("00000000-0000-0000-0000-000000000001"),
sessionId = UUID.fromString("00000000-0000-0000-0000-000000000002"),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
)
@@ -0,0 +1,105 @@
package com.osglab.account.features.content.feed.sources
import com.osglab.account.features.content.feed.HintFeedSettings
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.shouldBe
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import java.time.Instant
import java.time.LocalDate
class HintFeedSourcesTest : FunSpec({
val context = HintFeedGenerationContext(
generatedAt = Instant.parse("2026-08-21T06:00:00Z"),
localDate = LocalDate.parse("2026-08-21"),
)
test("TopHub parses daily and open hot with deterministic identifiers") {
val client = jsonClient { path ->
if (path.endsWith("/daily")) {
"""{"data":{"date":"2026-08-21","day":"2026-08-21","news":[{"title":"A useful headline","url":"https://example.com"}]}}"""
} else {
"""{"data":[{"title":"A public hot topic","url":"https://example.com","sitename":"Example"}]}"""
}
}
val source = TopHubHintSource(client, null)
val first = source.fetch("zh", context, SETTINGS)
val second = source.fetch("zh", context, SETTINGS)
first.map { it.id } shouldBe second.map { it.id }
first.map { it.source }.toSet() shouldBe setOf("tophub-daily", "tophub-open-hot")
client.close()
}
test("Google feeds parse trends and strip the news source suffix") {
val client = HttpClient(
MockEngine { request ->
val title = if (request.url.host == "trends.google.com") {
"Useful Trend"
} else {
"Important News - Example"
}
respond(
content = "<rss><channel><item><title>$title</title></item></channel></rss>",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/rss+xml"),
)
},
)
val cards = GoogleFeedHintSource(client).fetch("en", context, SETTINGS)
cards.map { it.text.orEmpty() } shouldContain "Trending: Useful Trend"
cards.map { it.text.orEmpty() } shouldContain "News: Important News"
client.close()
}
test("holiday source creates today's localized Chinese cards") {
val client = jsonClient {
"""[{"date":"2026-08-21","name":"National Day"}]"""
}
val cards = HolidayHintSource(client).fetch("zh", context, SETTINGS)
cards.size shouldBe 2
cards.map { it.text.orEmpty() } shouldContain "今天是国庆节,写一句祝福"
cards.all { it.conditions == listOf("holiday_today") } shouldBe true
client.close()
}
test("weather source validates coordinates and creates one card") {
val client = jsonClient {
"""{"current":{"temperature_2m":26.5,"weather_code":1,"precipitation":0.0}}"""
}
val cards = WeatherHintSource(client).fetch("en", context, SETTINGS)
cards.size shouldBe 1
cards.single().id shouldBe "weather-en-london"
cards.single().source shouldBe "open-meteo"
client.close()
}
})
private fun jsonClient(content: (String) -> String): HttpClient =
HttpClient(
MockEngine { request ->
respond(
content = content(request.url.encodedPath),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
},
)
private val SETTINGS = HintFeedSettings(
generationIntervalHours = 12,
holidayCountriesZh = "CN",
holidayCountriesEn = "US",
weatherCitiesZh = "北京:39.90,116.40",
weatherCitiesEn = "London:51.51,-0.13",
googleTrendsGeos = "US",
)
@@ -82,6 +82,31 @@ class ContentRepositoryIntegrationTest : FunSpec({
first.getHintPack("zh")?.version shouldBe 2
}
}
test("generated locale packs publish in one versioned transaction") {
withContentRepositories { first, _, admin ->
val now = Instant.parse("2026-08-21T06:00:00Z")
val stored = first.putHintPacks(
packs = listOf("zh", "en").map { locale ->
HintPackRecord(
locale = locale,
generatedAt = now,
expiresAt = now.plusSeconds(43_200),
intervalHours = 12,
version = 0,
cardsJson = "[]",
)
},
now = now,
audit = audit(AdminAuditAction.CONTENT_HINT_FEED_GENERATED, "generation-1", now),
)
stored.map(HintPackRecord::version) shouldBe listOf(1, 1)
first.getHintPack("zh")?.version shouldBe 1
first.getHintPack("en")?.version shouldBe 1
admin.listAudit(10).single().action shouldBe AdminAuditAction.CONTENT_HINT_FEED_GENERATED
}
}
})
private suspend fun withContentRepositories(
@@ -145,10 +170,10 @@ private fun audit(
actorOperatorId = null,
action = action,
outcome = AdminAuditOutcome.SUCCESS,
targetType = if (action == AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED) {
"OFFICIAL_HINT_PACK"
} else {
"OFFICIAL_SKILL"
targetType = when (action) {
AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED -> "OFFICIAL_HINT_PACK"
AdminAuditAction.CONTENT_HINT_FEED_GENERATED -> "OFFICIAL_HINT_FEED"
else -> "OFFICIAL_SKILL"
},
targetId = targetId,
occurredAt = now,
@@ -7,6 +7,10 @@ import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.routes.adminContentRoutes
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.content.InMemoryContentRepository
import com.osglab.account.features.content.feed.HintFeedGenerationResponse
import com.osglab.account.features.content.feed.HintFeedGenerationStatusResponse
import com.osglab.account.features.content.feed.HintFeedPackGenerationResult
import com.osglab.account.features.content.feed.HintFeedService
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.SkillLocalizationDto
@@ -169,6 +173,72 @@ class ContentRoutesTest {
response.bodyAsText() shouldContain """"enabled":false"""
repository.revision shouldBe 1
}
@Test
fun `support can read generation status but cannot regenerate`() = testApplication {
val feed = mockk<HintFeedService>()
coEvery { feed.status() } returns HintFeedGenerationStatusResponse(
enabled = true,
outcome = "SUCCEEDED",
intervalHours = 12,
topHubApiKeyConfigured = false,
)
application {
installContentJson()
routing {
route("/v1/admin") {
adminContentRoutes(
adminConfig(),
sessionService(AdminRole.SUPPORT),
ContentService(InMemoryContentRepository()),
feed,
)
}
}
}
client.get("/v1/admin/content/hints/generation/status") {
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}.status shouldBe HttpStatusCode.OK
client.post("/v1/admin/content/hints/generation/regenerate") {
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
}.status shouldBe HttpStatusCode.Forbidden
}
@Test
fun `super admin can regenerate both hint packs`() = testApplication {
val feed = mockk<HintFeedService>()
coEvery { feed.regenerate(any(), any()) } returns HintFeedGenerationResponse(
generationId = "33333333-3333-4333-8333-333333333333",
generatedAt = "2026-08-21T06:00:00Z",
zh = HintFeedPackGenerationResult(version = 1, cardCount = 20),
en = HintFeedPackGenerationResult(version = 1, cardCount = 25),
)
application {
installContentJson()
routing {
route("/v1/admin") {
adminContentRoutes(
adminConfig(),
sessionService(AdminRole.SUPER_ADMIN),
ContentService(InMemoryContentRepository()),
feed,
)
}
}
}
val response = client.post("/v1/admin/content/hints/generation/regenerate") {
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
}
response.status shouldBe HttpStatusCode.OK
response.bodyAsText() shouldContain """"cardCount":25"""
}
}
private fun io.ktor.server.application.Application.installContentJson() {