feat: workerize external generation

This commit is contained in:
2026-06-05 17:29:08 +08:00
parent 5150925947
commit 8d54ea3374
60 changed files with 5285 additions and 700 deletions

View File

@@ -12,16 +12,17 @@ use module_puzzle::{
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput,
PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput,
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus,
PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput,
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput,
PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput,
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed,
build_result_preview, compile_result_draft_from_seed, create_work_profile, infer_anchor_pack,
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleLevelGenerationFailureInput,
PuzzlePublicationStatus, PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft,
PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput,
PuzzleRunProcedureResult, PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput,
PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput,
PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput,
PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput,
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput,
PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft,
apply_selected_candidate, build_form_draft_from_seed, build_result_preview,
compile_result_draft_from_seed, create_work_profile, infer_anchor_pack,
mark_failed_puzzle_result_draft_generation, normalize_puzzle_draft, normalize_puzzle_levels,
normalize_theme_tags, publish_work_profile, replace_puzzle_level, select_next_profiles,
selected_profile_level_after_runtime_level, selected_puzzle_level, tag_similarity_score,
@@ -36,9 +37,14 @@ use spacetimedb::{
};
use crate::auth::user_account;
use crate::validate_external_generation_job_lease_for_tx;
const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0;
const WORK_VISIBLE_DEFAULT: bool = true;
const PUZZLE_EXTERNAL_GENERATION_SOURCE_MODULE: &str = "puzzle";
const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft";
const PUZZLE_GENERATE_IMAGES_JOB_KIND: &str = "puzzle_generate_images";
const PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND: &str = "puzzle_generate_ui_background";
/// 拼图 Agent session 真相表。
/// 当前只保存结构化字段与 JSON 草稿,不提前拆出更多编辑态子表。
@@ -388,6 +394,25 @@ pub fn mark_puzzle_draft_generation_failed(
}
}
#[spacetimedb::procedure]
pub fn mark_puzzle_level_generation_failed(
ctx: &mut ProcedureContext,
input: PuzzleLevelGenerationFailureInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| mark_puzzle_level_generation_failed_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
/// 保存拼图入口表单草稿。
/// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。
#[spacetimedb::procedure]
@@ -978,6 +1003,26 @@ fn compile_puzzle_agent_draft_tx(
ctx: &TxContext,
input: PuzzleDraftCompileInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
match (
input.external_generation_job_id.as_deref(),
input.external_generation_worker_id.as_deref(),
input.external_generation_lease_token.as_deref(),
) {
(Some(job_id), Some(worker_id), Some(lease_token)) => {
validate_puzzle_external_generation_write_guard(
ctx,
job_id,
worker_id,
lease_token,
&[PUZZLE_COMPILE_DRAFT_JOB_KIND],
&input.session_id,
&input.owner_user_id,
None,
)?;
}
(None, None, None) => {}
_ => return Err("拼图草稿编译外部生成 guard 不完整".to_string()),
}
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
if row.seed_text.trim().is_empty() {
return Err("请先填写拼图作品信息".to_string());
@@ -1028,6 +1073,16 @@ fn mark_puzzle_draft_generation_failed_tx(
ctx: &TxContext,
input: PuzzleDraftCompileFailureInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
validate_puzzle_external_generation_write_guard(
ctx,
&input.external_generation_job_id,
&input.external_generation_worker_id,
&input.external_generation_lease_token,
&[PUZZLE_COMPILE_DRAFT_JOB_KIND],
&input.session_id,
&input.owner_user_id,
None,
)?;
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros);
let draft = match deserialize_optional_draft(&row.draft_json)? {
@@ -1079,6 +1134,88 @@ fn mark_puzzle_draft_generation_failed_tx(
)
}
fn mark_puzzle_level_generation_failed_tx(
ctx: &TxContext,
input: PuzzleLevelGenerationFailureInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
validate_puzzle_external_generation_write_guard(
ctx,
&input.external_generation_job_id,
&input.external_generation_worker_id,
&input.external_generation_lease_token,
&[
PUZZLE_GENERATE_IMAGES_JOB_KIND,
PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND,
],
&input.session_id,
&input.owner_user_id,
input.level_id.as_deref(),
)?;
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros);
let mut draft = match deserialize_optional_draft(&row.draft_json)? {
Some(draft) => draft,
None => {
let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?;
let messages = list_session_messages(ctx, &row.session_id);
compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text))
}
};
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
// 中文注释:新增关卡可能还没完成自动保存,失败回写必须以本次 action 快照作为目标集合。
draft.levels = levels;
}
draft = mark_puzzle_level_generation_failed_draft(draft, input.level_id.as_deref())?;
let next_stage = resolve_failed_puzzle_agent_stage(row.stage, &draft);
upsert_puzzle_draft_work_profile(
ctx,
&row.session_id,
&row.owner_user_id,
&draft,
input.failed_at_micros,
)?;
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: row.progress_percent.max(94),
stage: next_stage,
anchor_pack_json: row.anchor_pack_json.clone(),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: Some(input.error_message),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at,
},
);
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn mark_puzzle_level_generation_failed_draft(
draft: PuzzleResultDraft,
level_id: Option<&str>,
) -> Result<PuzzleResultDraft, String> {
let target_level =
selected_puzzle_level(&draft, level_id).ok_or_else(|| "拼图关卡不存在".to_string())?;
let mut next_level = target_level;
next_level.generation_status = "failed".to_string();
let mut draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
module_puzzle::sync_primary_level_fields(&mut draft);
Ok(draft)
}
fn resolve_failed_puzzle_agent_stage(
current_stage: PuzzleAgentStage,
draft: &PuzzleResultDraft,
@@ -1094,6 +1231,34 @@ fn resolve_failed_puzzle_agent_stage(
}
}
fn validate_puzzle_external_generation_write_guard(
ctx: &TxContext,
job_id: &str,
worker_id: &str,
lease_token: &str,
expected_job_kinds: &[&str],
session_id: &str,
owner_user_id: &str,
level_id: Option<&str>,
) -> Result<(), String> {
let session_entity_id = session_id.trim().to_string();
let mut source_entity_ids = vec![session_entity_id.clone()];
if let Some(level_id) = level_id.map(str::trim).filter(|value| !value.is_empty()) {
source_entity_ids.push(format!("{session_entity_id}:{level_id}"));
}
validate_external_generation_job_lease_for_tx(
ctx.as_ref(),
job_id,
worker_id,
lease_token,
expected_job_kinds,
owner_user_id,
PUZZLE_EXTERNAL_GENERATION_SOURCE_MODULE,
&source_entity_ids,
)
}
fn save_puzzle_form_draft_tx(
ctx: &TxContext,
input: PuzzleFormDraftSaveInput,
@@ -1151,6 +1316,19 @@ fn save_puzzle_generated_images_tx(
ctx: &TxContext,
input: PuzzleGeneratedImagesSaveInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
validate_puzzle_external_generation_write_guard(
ctx,
&input.external_generation_job_id,
&input.external_generation_worker_id,
&input.external_generation_lease_token,
&[
PUZZLE_COMPILE_DRAFT_JOB_KIND,
PUZZLE_GENERATE_IMAGES_JOB_KIND,
],
&input.session_id,
&input.owner_user_id,
input.level_id.as_deref(),
)?;
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let mut draft = deserialize_draft_required(&row.draft_json)?;
let previous_primary_level_name = draft.level_name.clone();
@@ -1235,6 +1413,16 @@ fn save_puzzle_ui_background_tx(
ctx: &TxContext,
input: PuzzleUiBackgroundSaveInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
validate_puzzle_external_generation_write_guard(
ctx,
&input.external_generation_job_id,
&input.external_generation_worker_id,
&input.external_generation_lease_token,
&[PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND],
&input.session_id,
&input.owner_user_id,
input.level_id.as_deref(),
)?;
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let mut draft = deserialize_draft_required(&row.draft_json)?;
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
@@ -3897,6 +4085,39 @@ mod tests {
);
}
#[test]
fn level_generation_failure_only_marks_target_level_failed() {
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
let mut draft = compile_result_draft_from_seed(
&anchor_pack,
&[],
Some("画面描述:一只猫在雨夜灯牌下回头。"),
);
draft.levels[0].generation_status = "ready".to_string();
draft.levels[0].cover_image_src = Some("/generated-puzzle-assets/first.png".to_string());
let mut second_level = draft.levels[0].clone();
second_level.level_id = "puzzle-level-2".to_string();
second_level.level_name = "第二关".to_string();
second_level.picture_description = "第二关画面".to_string();
second_level.cover_image_src = None;
second_level.cover_asset_id = None;
second_level.candidates = Vec::new();
second_level.selected_candidate_id = None;
second_level.generation_status = "generating".to_string();
draft.levels.push(second_level);
let failed = mark_puzzle_level_generation_failed_draft(draft, Some("puzzle-level-2"))
.expect("target level should be marked failed");
assert_eq!(failed.levels[0].generation_status, "ready");
assert_eq!(
failed.levels[0].cover_image_src.as_deref(),
Some("/generated-puzzle-assets/first.png")
);
assert_eq!(failed.levels[1].generation_status, "failed");
assert_eq!(failed.generation_status, "ready");
}
#[test]
fn puzzle_recommendation_score_prefers_same_author_weight() {
let left = PuzzleWorkProfile {