From 8b54cc912c1f4b9b9170523880f317e3264faeda Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 24 Apr 2026 17:29:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8B=BC=E5=9B=BEanchor=20pa?= =?UTF-8?q?ck=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._AGENT_LLM_REPLY_INTEGRATION_2026-04-23.md | 14 ++ .../api-server/src/puzzle_agent_turn.rs | 134 +++++++++++++++++- 2 files changed, 141 insertions(+), 7 deletions(-) diff --git a/docs/technical/PUZZLE_AGENT_LLM_REPLY_INTEGRATION_2026-04-23.md b/docs/technical/PUZZLE_AGENT_LLM_REPLY_INTEGRATION_2026-04-23.md index 1894198a..cac2e6d3 100644 --- a/docs/technical/PUZZLE_AGENT_LLM_REPLY_INTEGRATION_2026-04-23.md +++ b/docs/technical/PUZZLE_AGENT_LLM_REPLY_INTEGRATION_2026-04-23.md @@ -205,6 +205,20 @@ - SSE 返回 `error` - 以前端重新拉 session 为准 +## 8.1 anchor pack 解析边界 + +`nextAnchorPack` 是 LLM 输出契约的一部分,字段名固定采用前端与 prompt 侧一致的 `camelCase`: + +1. `themePromise` +2. `visualSubject` +3. `visualMood` +4. `compositionHooks` +5. `tagsAndForbidden` + +Rust 领域模型 `PuzzleAnchorPack` 仍保持 `theme_promise / visual_subject / visual_mood / composition_hooks / tags_and_forbidden`,不把 LLM 契约命名扩散到 `module-puzzle`。因此 `api-server` 的 `puzzle_agent_turn.rs` 必须在 LLM 边界显式把 `camelCase` anchor pack 翻译为 Rust 领域结构,再序列化给 SpacetimeDB finalize。 + +验收补充:真实 LLM 返回符合本文 `nextAnchorPack` 示例时,不应再触发“拼图 anchor pack 解析失败,请稍后重试。”。 + ## 9. 实现清单 1. `module-puzzle` diff --git a/server-rs/crates/api-server/src/puzzle_agent_turn.rs b/server-rs/crates/api-server/src/puzzle_agent_turn.rs index 72d8f0c4..e8090ee2 100644 --- a/server-rs/crates/api-server/src/puzzle_agent_turn.rs +++ b/server-rs/crates/api-server/src/puzzle_agent_turn.rs @@ -251,14 +251,11 @@ fn parse_model_output(parsed: &JsonValue) -> Result(value) - .map_err(|_| PuzzleAgentTurnError::new("拼图 anchor pack 解析失败,请稍后重试。")) - })?; + .ok_or_else(|| PuzzleAgentTurnError::new("拼图聊天结果缺少 nextAnchorPack。"))?; + let next_anchor_pack = parse_model_anchor_pack(&next_anchor_pack_value)?; Ok(PuzzleAgentModelOutput { reply_text, progress_percent, @@ -266,6 +263,70 @@ fn parse_model_output(parsed: &JsonValue) -> Result Result { + Ok(PuzzleAnchorPack { + // LLM 输出契约面向前端与 prompt,使用 camelCase;Rust 领域模型仍保持 snake_case, + // 因此这里显式做边界翻译,避免把 JSON 命名差异扩散到领域 crate。 + theme_promise: parse_model_anchor_item(value, "themePromise")?, + visual_subject: parse_model_anchor_item(value, "visualSubject")?, + visual_mood: parse_model_anchor_item(value, "visualMood")?, + composition_hooks: parse_model_anchor_item(value, "compositionHooks")?, + tags_and_forbidden: parse_model_anchor_item(value, "tagsAndForbidden")?, + }) +} + +fn parse_model_anchor_item( + pack: &JsonValue, + field_name: &str, +) -> Result { + let value = pack.get(field_name).ok_or_else(|| { + PuzzleAgentTurnError::new(format!("拼图 anchor pack 缺少 {field_name}。")) + })?; + let key = value + .get("key") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .unwrap_or(field_name) + .to_string(); + let label = value + .get("label") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| default_puzzle_anchor_label(field_name)) + .to_string(); + let item_value = value + .get("value") + .and_then(JsonValue::as_str) + .map(str::trim) + .unwrap_or_default() + .to_string(); + let status = value + .get("status") + .and_then(JsonValue::as_str) + .map(parse_anchor_status) + .unwrap_or(PuzzleAnchorStatus::Missing); + + Ok(module_puzzle::PuzzleAnchorItem { + key, + label, + value: item_value, + status, + }) +} + +fn default_puzzle_anchor_label(field_name: &str) -> &'static str { + match field_name { + "themePromise" => "题材承诺", + "visualSubject" => "画面主体", + "visualMood" => "视觉气质", + "compositionHooks" => "拼图记忆点", + "tagsAndForbidden" => "标签与禁忌", + _ => "拼图锚点", + } +} + fn resolve_puzzle_agent_stage(progress_percent: u32) -> PuzzleAgentStage { if progress_percent >= 85 { PuzzleAgentStage::DraftReady @@ -366,7 +427,10 @@ fn extract_reply_text_from_partial_json(text: &str) -> Option { #[cfg(test)] mod tests { - use super::extract_reply_text_from_partial_json; + use module_puzzle::PuzzleAnchorStatus; + use serde_json::json; + + use super::{extract_reply_text_from_partial_json, parse_model_output}; #[test] fn extract_reply_text_from_partial_json_preserves_chinese_characters() { @@ -376,4 +440,60 @@ mod tests { assert_eq!(extracted.as_deref(), Some("夜雨猫咪遗迹")); } + + #[test] + fn parse_model_output_accepts_camel_case_anchor_pack_contract() { + let model_output = json!({ + "replyText": "我先把雨夜猫咪的方向收住。", + "progressPercent": 46, + "nextAnchorPack": { + "themePromise": { + "key": "themePromise", + "label": "题材承诺", + "value": "雨夜中的奇幻探索", + "status": "confirmed" + }, + "visualSubject": { + "key": "visualSubject", + "label": "画面主体", + "value": "发光猫咪站在遗迹台阶上", + "status": "confirmed" + }, + "visualMood": { + "key": "visualMood", + "label": "视觉气质", + "value": "潮湿、梦幻、带轻微悬疑", + "status": "inferred" + }, + "compositionHooks": { + "key": "compositionHooks", + "label": "拼图记忆点", + "value": "台阶透视、倒影、远处遗迹门洞", + "status": "inferred" + }, + "tagsAndForbidden": { + "key": "tagsAndForbidden", + "label": "标签与禁忌", + "value": "雨夜、猫咪、神庙遗迹;禁止文字水印", + "status": "inferred" + } + } + }); + + let parsed = parse_model_output(&model_output).expect("camelCase 契约应能解析"); + + assert_eq!(parsed.progress_percent, 46); + assert_eq!( + parsed.next_anchor_pack.theme_promise.value, + "雨夜中的奇幻探索" + ); + assert_eq!( + parsed.next_anchor_pack.theme_promise.status, + PuzzleAnchorStatus::Confirmed + ); + assert_eq!( + parsed.next_anchor_pack.tags_and_forbidden.value, + "雨夜、猫咪、神庙遗迹;禁止文字水印" + ); + } }