修复拼图anchor pack解析失败
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -251,14 +251,11 @@ fn parse_model_output(parsed: &JsonValue) -> Result<PuzzleAgentModelOutput, Puzz
|
||||
.and_then(JsonValue::as_u64)
|
||||
.map(|value| value.min(100) as u32)
|
||||
.unwrap_or(0);
|
||||
let next_anchor_pack = parsed
|
||||
let next_anchor_pack_value = parsed
|
||||
.get("nextAnchorPack")
|
||||
.cloned()
|
||||
.ok_or_else(|| PuzzleAgentTurnError::new("拼图聊天结果缺少 nextAnchorPack。"))
|
||||
.and_then(|value| {
|
||||
serde_json::from_value::<PuzzleAnchorPack>(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<PuzzleAgentModelOutput, Puzz
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_model_anchor_pack(value: &JsonValue) -> Result<PuzzleAnchorPack, PuzzleAgentTurnError> {
|
||||
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<module_puzzle::PuzzleAnchorItem, PuzzleAgentTurnError> {
|
||||
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<String> {
|
||||
|
||||
#[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,
|
||||
"雨夜、猫咪、神庙遗迹;禁止文字水印"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user