mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v1.0.1
This commit is contained in:
@@ -10,7 +10,6 @@
|
||||
## 文件索引
|
||||
|
||||
- `mod.rs` - 模块入口
|
||||
- `novel_service.rs` - 小说编排服务(项目/设定/章节生成/一致性检查)
|
||||
- `site_adapter_import_service.rs` - 外部适配器来源导入与 Lime 标准编译层
|
||||
- `provider_pool_service.rs` - Provider 凭证池服务(多凭证轮询)
|
||||
- `token_cache_service.rs` - Token 缓存服务
|
||||
|
||||
@@ -160,14 +160,15 @@ pub fn persist_artifact_document_from_text(
|
||||
.and_then(|relative_path| {
|
||||
read_existing_artifact_document(¶ms.workspace_root, relative_path)
|
||||
});
|
||||
let mut outcome = if let Some(ops_value) =
|
||||
crate::services::artifact_ops_service::extract_artifact_ops_candidate(raw_text)
|
||||
let mut outcome = if let Some(operation_value) =
|
||||
crate::services::artifact_ops_service::extract_artifact_operation_candidate(raw_text)
|
||||
{
|
||||
let applied = crate::services::artifact_ops_service::apply_artifact_ops_to_document(
|
||||
existing_document.as_ref(),
|
||||
&ops_value,
|
||||
&validation_context,
|
||||
);
|
||||
let applied =
|
||||
crate::services::artifact_ops_service::apply_artifact_operation_candidate_to_document(
|
||||
existing_document.as_ref(),
|
||||
&operation_value,
|
||||
&validation_context,
|
||||
);
|
||||
operation_issues = applied.issues;
|
||||
validate_or_repair_artifact_document_value(&applied.document, raw_text, &validation_context)
|
||||
} else {
|
||||
@@ -483,30 +484,75 @@ fn normalize_text(value: Option<&str>) -> Option<String> {
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn extract_block_text(block: &Map<String, Value>) -> Option<String> {
|
||||
normalize_text(block.get("markdown").and_then(Value::as_str))
|
||||
.or_else(|| normalize_text(block.get("text").and_then(Value::as_str)))
|
||||
.or_else(|| normalize_text(block.get("content").and_then(Value::as_str)))
|
||||
.or_else(|| normalize_text(block.get("summary").and_then(Value::as_str)))
|
||||
.or_else(|| {
|
||||
block.get("items").and_then(Value::as_array).map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
if let Some(text) = item.as_str() {
|
||||
return normalize_text(Some(text));
|
||||
}
|
||||
fn extract_portable_text(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) => normalize_text(Some(text)),
|
||||
Value::Array(items) => {
|
||||
let parts = items
|
||||
.iter()
|
||||
.filter_map(extract_portable_text)
|
||||
.collect::<Vec<_>>();
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
normalize_text(Some(parts.join("\n").as_str()))
|
||||
}
|
||||
}
|
||||
Value::Object(record) => {
|
||||
if let Some(text) = record.get("text").and_then(Value::as_str) {
|
||||
return normalize_text(Some(text));
|
||||
}
|
||||
if let Some(content) = record.get("content").and_then(extract_portable_text) {
|
||||
return Some(content);
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
let item = item.as_object()?;
|
||||
normalize_text(item.get("label").and_then(Value::as_str))
|
||||
.or_else(|| normalize_text(item.get("text").and_then(Value::as_str)))
|
||||
.or_else(|| normalize_text(item.get("title").and_then(Value::as_str)))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
fn extract_block_text(block: &Map<String, Value>) -> Option<String> {
|
||||
(if block
|
||||
.get("contentFormat")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
== Some("markdown")
|
||||
{
|
||||
block
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|text| normalize_text(Some(text)))
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.or_else(|| normalize_text(block.get("markdown").and_then(Value::as_str)))
|
||||
.or_else(|| normalize_text(block.get("body").and_then(Value::as_str)))
|
||||
.or_else(|| normalize_text(block.get("text").and_then(Value::as_str)))
|
||||
.or_else(|| normalize_text(block.get("content").and_then(Value::as_str)))
|
||||
.or_else(|| block.get("content").and_then(extract_portable_text))
|
||||
.or_else(|| normalize_text(block.get("summary").and_then(Value::as_str)))
|
||||
.or_else(|| normalize_text(block.get("attribution").and_then(Value::as_str)))
|
||||
.or_else(|| {
|
||||
block.get("items").and_then(Value::as_array).map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
if let Some(text) = item.as_str() {
|
||||
return normalize_text(Some(text));
|
||||
}
|
||||
|
||||
let item = item.as_object()?;
|
||||
normalize_text(item.get("label").and_then(Value::as_str))
|
||||
.or_else(|| normalize_text(item.get("text").and_then(Value::as_str)))
|
||||
.or_else(|| normalize_text(item.get("title").and_then(Value::as_str)))
|
||||
.or_else(|| normalize_text(item.get("value").and_then(Value::as_str)))
|
||||
.or_else(|| normalize_text(item.get("note").and_then(Value::as_str)))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.and_then(|value| normalize_text(Some(value.as_str())))
|
||||
})
|
||||
.and_then(|value| normalize_text(Some(value.as_str())))
|
||||
}
|
||||
|
||||
fn build_content_body_from_document(document: &Value) -> String {
|
||||
@@ -854,6 +900,15 @@ fn enrich_document_with_history(
|
||||
}
|
||||
|
||||
fn infer_source_type(source: &Map<String, Value>) -> String {
|
||||
if let Some(source_type) = source
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return source_type.to_string();
|
||||
}
|
||||
|
||||
if let Some(kind) = source
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
@@ -863,6 +918,45 @@ fn infer_source_type(source: &Map<String, Value>) -> String {
|
||||
return kind.to_string();
|
||||
}
|
||||
|
||||
if let Some(locator) = source.get("locator").and_then(Value::as_object) {
|
||||
if locator
|
||||
.get("toolCallId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
return "tool".to_string();
|
||||
}
|
||||
if locator
|
||||
.get("messageId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
return "message".to_string();
|
||||
}
|
||||
if locator
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
return "file".to_string();
|
||||
}
|
||||
if locator
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
return "web".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(url) = source
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
@@ -880,6 +974,58 @@ fn infer_source_type(source: &Map<String, Value>) -> String {
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
fn resolve_source_ref(source_id: &str, source: &Map<String, Value>) -> String {
|
||||
if let Some(locator) = source.get("locator").and_then(Value::as_object) {
|
||||
for key in ["url", "path", "toolCallId", "messageId"] {
|
||||
if let Some(value) = locator
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return value.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
source
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(source_id)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn resolve_source_link_locator(source: &Map<String, Value>) -> Option<Value> {
|
||||
if let Some(locator) = source.get("locator").and_then(Value::as_object) {
|
||||
if let Some(url) = locator
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(Value::String(url.to_string()));
|
||||
}
|
||||
if let Some(path) = locator
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(Value::String(path.to_string()));
|
||||
}
|
||||
return Some(Value::Object(locator.clone()));
|
||||
}
|
||||
|
||||
source
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| Value::String(value.to_string()))
|
||||
}
|
||||
|
||||
fn derive_source_links_from_document(document: &Value) -> Vec<Map<String, Value>> {
|
||||
let Some(record) = document.as_object() else {
|
||||
return Vec::new();
|
||||
@@ -952,33 +1098,25 @@ fn derive_source_links_from_document(document: &Value) -> Vec<Map<String, Value>
|
||||
);
|
||||
link.insert(
|
||||
"sourceRef".to_string(),
|
||||
Value::String(
|
||||
Value::String(resolve_source_ref(source_id, source)),
|
||||
);
|
||||
if let Some(label) = source
|
||||
.get("label")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
source
|
||||
.get("url")
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(source_id)
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
if let Some(label) = source
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
{
|
||||
link.insert("label".to_string(), Value::String(label.to_string()));
|
||||
}
|
||||
if let Some(locator) = source.get("locator") {
|
||||
link.insert("locator".to_string(), locator.clone());
|
||||
} else if let Some(url) = source
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
link.insert("locator".to_string(), Value::String(url.to_string()));
|
||||
if let Some(locator) = resolve_source_link_locator(source) {
|
||||
link.insert("locator".to_string(), locator);
|
||||
}
|
||||
links.push(link);
|
||||
}
|
||||
@@ -1499,6 +1637,164 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_artifact_document_from_text_should_accept_current_incremental_block_upsert() {
|
||||
let params = build_params();
|
||||
let first = serde_json::json!({
|
||||
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
|
||||
"kind": "analysis",
|
||||
"title": "结构化结论",
|
||||
"status": "ready",
|
||||
"summary": "第一版摘要",
|
||||
"blocks": [
|
||||
{ "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" },
|
||||
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" }
|
||||
],
|
||||
"sources": [],
|
||||
"metadata": {}
|
||||
})
|
||||
.to_string();
|
||||
let current_op = serde_json::json!({
|
||||
"type": "artifact.block.upsert",
|
||||
"artifactId": "artifact-document:artifact:analysis:demo",
|
||||
"block": {
|
||||
"id": "body-1",
|
||||
"type": "rich_text",
|
||||
"contentFormat": "markdown",
|
||||
"content": "通过正式单条 op 更新后的正文"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let persisted_first =
|
||||
persist_artifact_document_from_text(&first, ¶ms).expect("first persist");
|
||||
let persisted_second =
|
||||
persist_artifact_document_from_text(¤t_op, ¶ms).expect("op persist");
|
||||
|
||||
assert_eq!(persisted_first.current_version_no, 1);
|
||||
assert_eq!(persisted_second.current_version_no, 2);
|
||||
assert!(persisted_second
|
||||
.serialized_document
|
||||
.contains("通过正式单条 op 更新后的正文"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_artifact_document_from_text_should_accept_current_incremental_block_upsert_in_rewrite_mode(
|
||||
) {
|
||||
let mut params = build_params();
|
||||
params.request_metadata = Some(serde_json::json!({
|
||||
"artifact_mode": "rewrite",
|
||||
"artifact_stage": "rewrite",
|
||||
"artifact_kind": "analysis",
|
||||
"source_policy": "required",
|
||||
"artifact_request_id": "artifact:analysis:demo",
|
||||
"artifact_target_block_id": "body-1",
|
||||
"theme": "knowledge"
|
||||
}));
|
||||
|
||||
let first = serde_json::json!({
|
||||
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
|
||||
"kind": "analysis",
|
||||
"title": "结构化结论",
|
||||
"status": "ready",
|
||||
"summary": "第一版摘要",
|
||||
"blocks": [
|
||||
{ "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" },
|
||||
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 1", "sourceIds": ["source-1"] },
|
||||
{ "id": "body-2", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 2" }
|
||||
],
|
||||
"sources": [
|
||||
{ "id": "source-1", "type": "web", "label": "OpenAI", "locator": { "url": "https://openai.com" } }
|
||||
],
|
||||
"metadata": {}
|
||||
})
|
||||
.to_string();
|
||||
let current_op = serde_json::json!({
|
||||
"type": "artifact.block.upsert",
|
||||
"artifactId": "artifact-document:artifact:analysis:demo",
|
||||
"block": {
|
||||
"id": "body-1",
|
||||
"type": "rich_text",
|
||||
"contentFormat": "markdown",
|
||||
"content": "rewrite current op 改写后的正文",
|
||||
"sourceIds": ["source-1"]
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let persisted_first =
|
||||
persist_artifact_document_from_text(&first, ¶ms).expect("first persist");
|
||||
let persisted_second =
|
||||
persist_artifact_document_from_text(¤t_op, ¶ms).expect("op persist");
|
||||
|
||||
assert_eq!(persisted_first.current_version_no, 1);
|
||||
assert_eq!(persisted_second.current_version_no, 2);
|
||||
assert!(persisted_second
|
||||
.serialized_document
|
||||
.contains("rewrite current op 改写后的正文"));
|
||||
assert!(persisted_second.serialized_document.contains("source-1"));
|
||||
assert!(!persisted_second
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.contains("非目标 block")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_artifact_document_from_text_should_restrict_current_incremental_rewrite_to_target_block(
|
||||
) {
|
||||
let mut params = build_params();
|
||||
params.request_metadata = Some(serde_json::json!({
|
||||
"artifact_mode": "rewrite",
|
||||
"artifact_stage": "rewrite",
|
||||
"artifact_kind": "analysis",
|
||||
"source_policy": "required",
|
||||
"artifact_request_id": "artifact:analysis:demo",
|
||||
"artifact_target_block_id": "body-1",
|
||||
"theme": "knowledge"
|
||||
}));
|
||||
|
||||
let first = serde_json::json!({
|
||||
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
|
||||
"kind": "analysis",
|
||||
"title": "结构化结论",
|
||||
"status": "ready",
|
||||
"summary": "第一版摘要",
|
||||
"blocks": [
|
||||
{ "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" },
|
||||
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 1" },
|
||||
{ "id": "body-2", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 2" }
|
||||
],
|
||||
"sources": [],
|
||||
"metadata": {}
|
||||
})
|
||||
.to_string();
|
||||
let current_op = serde_json::json!({
|
||||
"type": "artifact.block.upsert",
|
||||
"artifactId": "artifact-document:artifact:analysis:demo",
|
||||
"block": {
|
||||
"id": "body-2",
|
||||
"type": "rich_text",
|
||||
"contentFormat": "markdown",
|
||||
"content": "不应被应用"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let persisted_first =
|
||||
persist_artifact_document_from_text(&first, ¶ms).expect("first persist");
|
||||
let persisted_second =
|
||||
persist_artifact_document_from_text(¤t_op, ¶ms).expect("op persist");
|
||||
|
||||
assert_eq!(persisted_first.current_version_no, 1);
|
||||
assert_eq!(persisted_second.current_version_no, 2);
|
||||
assert!(persisted_second.serialized_document.contains("旧正文 2"));
|
||||
assert!(!persisted_second.serialized_document.contains("不应被应用"));
|
||||
assert!(persisted_second
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.contains("非目标 block `body-2`")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_artifact_document_from_text_should_restrict_rewrite_to_target_block() {
|
||||
let mut params = build_params();
|
||||
@@ -1642,4 +1938,77 @@ mod tests {
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|summary| summary.contains("typed patch")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_artifact_document_from_text_should_accept_current_shaped_typed_rewrite_patch() {
|
||||
let mut params = build_params();
|
||||
params.request_metadata = Some(serde_json::json!({
|
||||
"artifact_mode": "rewrite",
|
||||
"artifact_stage": "rewrite",
|
||||
"artifact_kind": "analysis",
|
||||
"source_policy": "required",
|
||||
"artifact_request_id": "artifact:analysis:demo",
|
||||
"artifact_target_block_id": "body-1",
|
||||
"theme": "knowledge"
|
||||
}));
|
||||
|
||||
let first = serde_json::json!({
|
||||
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
|
||||
"kind": "analysis",
|
||||
"title": "结构化结论",
|
||||
"status": "ready",
|
||||
"summary": "第一版摘要",
|
||||
"blocks": [
|
||||
{ "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" },
|
||||
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 1", "sourceIds": ["source-1"] }
|
||||
],
|
||||
"sources": [
|
||||
{ "id": "source-1", "type": "web", "label": "OpenAI", "locator": { "url": "https://openai.com" } }
|
||||
],
|
||||
"metadata": {}
|
||||
})
|
||||
.to_string();
|
||||
let rewrite_patch = serde_json::json!({
|
||||
"type": "artifact_rewrite_patch",
|
||||
"artifactId": "artifact-document:artifact:analysis:demo",
|
||||
"targetBlockId": "body-1",
|
||||
"block": {
|
||||
"id": "body-1",
|
||||
"type": "rich_text",
|
||||
"contentFormat": "markdown",
|
||||
"content": "current shape typed patch 改写后的正文",
|
||||
"sourceIds": ["source-2"]
|
||||
},
|
||||
"source": {
|
||||
"id": "source-2",
|
||||
"type": "web",
|
||||
"label": "Anthropic",
|
||||
"locator": {
|
||||
"url": "https://anthropic.com"
|
||||
}
|
||||
},
|
||||
"summary": "通过 current shape typed patch 改写正文"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let persisted_first =
|
||||
persist_artifact_document_from_text(&first, ¶ms).expect("first persist");
|
||||
let persisted_second =
|
||||
persist_artifact_document_from_text(&rewrite_patch, ¶ms).expect("rewrite persist");
|
||||
|
||||
assert_eq!(persisted_first.current_version_no, 1);
|
||||
assert_eq!(persisted_second.current_version_no, 2);
|
||||
assert!(persisted_second
|
||||
.serialized_document
|
||||
.contains("current shape typed patch 改写后的正文"));
|
||||
assert!(persisted_second.serialized_document.contains("source-2"));
|
||||
assert!(persisted_second.serialized_document.contains("Anthropic"));
|
||||
assert!(persisted_second
|
||||
.snapshot_metadata
|
||||
.get("artifactVersion")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|record| record.get("summary"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|summary| summary.contains("current shape typed patch")));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
//! Artifact ops 应用服务
|
||||
//! Artifact 增量包络应用服务
|
||||
//!
|
||||
//! 负责解析 `artifact_ops` 包络,并把增量操作应用到现有
|
||||
//! 负责解析 current / compat Artifact 增量包络,并把它们应用到现有
|
||||
//! `ArtifactDocument v1`,为后续 rewrite / diff / 版本化打地基。
|
||||
|
||||
use crate::services::artifact_document_validator::{
|
||||
@@ -10,6 +10,16 @@ use serde_json::{Map, Value};
|
||||
|
||||
const ARTIFACT_OPS_ENVELOPE_TYPE: &str = "artifact_ops";
|
||||
const ARTIFACT_REWRITE_PATCH_ENVELOPE_TYPE: &str = "artifact_rewrite_patch";
|
||||
const ARTIFACT_INTERNAL_SOURCE_UPSERT_OP: &str = "artifact.source.upsert";
|
||||
const ARTIFACT_INCREMENTAL_ENVELOPE_TYPES: &[&str] = &[
|
||||
"artifact.begin",
|
||||
"artifact.meta.patch",
|
||||
"artifact.source.upsert",
|
||||
"artifact.block.upsert",
|
||||
"artifact.block.remove",
|
||||
"artifact.complete",
|
||||
"artifact.fail",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArtifactOpsApplyOutcome {
|
||||
@@ -17,7 +27,7 @@ pub struct ArtifactOpsApplyOutcome {
|
||||
pub issues: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn extract_artifact_ops_candidate(raw_text: &str) -> Option<Value> {
|
||||
pub fn extract_artifact_operation_candidate(raw_text: &str) -> Option<Value> {
|
||||
let trimmed = raw_text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
@@ -41,45 +51,74 @@ pub fn extract_artifact_ops_candidate(raw_text: &str) -> Option<Value> {
|
||||
if let Some(ops) = unwrap_artifact_ops_envelope(&parsed) {
|
||||
return Some(ops.clone());
|
||||
}
|
||||
if let Some(incremental) = unwrap_artifact_incremental_envelope(&parsed) {
|
||||
return Some(incremental.clone());
|
||||
}
|
||||
if let Some(rewrite_patch) = unwrap_artifact_rewrite_patch_envelope(&parsed) {
|
||||
if let Some(ops) = convert_rewrite_patch_to_artifact_ops(rewrite_patch) {
|
||||
return Some(ops);
|
||||
}
|
||||
return Some(rewrite_patch.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn apply_artifact_ops_to_document(
|
||||
pub fn apply_artifact_operation_candidate_to_document(
|
||||
base_document: Option<&Value>,
|
||||
ops_value: &Value,
|
||||
operation_value: &Value,
|
||||
context: &ArtifactDocumentValidationContext,
|
||||
) -> ArtifactOpsApplyOutcome {
|
||||
let Some(actions) = normalize_artifact_operation_candidate_to_actions(operation_value) else {
|
||||
let mut issues = Vec::new();
|
||||
let document = initialize_document(base_document, context);
|
||||
issues.push("未识别的 Artifact 增量包络,已回退到当前文档骨架。".to_string());
|
||||
return ArtifactOpsApplyOutcome { document, issues };
|
||||
};
|
||||
|
||||
apply_artifact_actions_to_document(base_document, &actions, context)
|
||||
}
|
||||
|
||||
fn apply_artifact_actions_to_document(
|
||||
base_document: Option<&Value>,
|
||||
actions: &[Value],
|
||||
context: &ArtifactDocumentValidationContext,
|
||||
) -> ArtifactOpsApplyOutcome {
|
||||
let mut issues = Vec::new();
|
||||
let mut document = initialize_document(base_document, context);
|
||||
let Some(record) = ops_value.as_object() else {
|
||||
issues.push("artifact_ops 顶层不是对象,已回退为空文档骨架。".to_string());
|
||||
return ArtifactOpsApplyOutcome { document, issues };
|
||||
};
|
||||
|
||||
let ops = record
|
||||
.get("ops")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if ops.is_empty() {
|
||||
issues.push("artifact_ops 没有提供可执行的 ops,已沿用现有文档骨架。".to_string());
|
||||
if actions.is_empty() {
|
||||
issues.push("增量包络没有提供可执行的动作,已沿用现有文档骨架。".to_string());
|
||||
return ArtifactOpsApplyOutcome { document, issues };
|
||||
}
|
||||
|
||||
for (index, op) in ops.iter().enumerate() {
|
||||
apply_single_op(&mut document, op, index, context, &mut issues);
|
||||
for (index, action) in actions.iter().enumerate() {
|
||||
apply_single_action(&mut document, action, index, context, &mut issues);
|
||||
}
|
||||
|
||||
ArtifactOpsApplyOutcome { document, issues }
|
||||
}
|
||||
|
||||
fn normalize_artifact_operation_candidate_to_actions(candidate: &Value) -> Option<Vec<Value>> {
|
||||
if let Some(ops) = unwrap_artifact_ops_envelope(candidate) {
|
||||
return Some(extract_ops_actions(ops));
|
||||
}
|
||||
if let Some(incremental) = unwrap_artifact_incremental_envelope(candidate) {
|
||||
return convert_incremental_envelope_to_artifact_action(incremental)
|
||||
.map(|action| vec![action]);
|
||||
}
|
||||
if let Some(rewrite_patch) = unwrap_artifact_rewrite_patch_envelope(candidate) {
|
||||
return convert_rewrite_patch_to_artifact_actions(rewrite_patch);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_ops_actions(value: &Value) -> Vec<Value> {
|
||||
value
|
||||
.as_object()
|
||||
.and_then(|record| record.get("ops"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn unwrap_artifact_ops_envelope(value: &Value) -> Option<&Value> {
|
||||
let record = value.as_object()?;
|
||||
let op_type = record
|
||||
@@ -98,6 +137,20 @@ fn unwrap_artifact_ops_envelope(value: &Value) -> Option<&Value> {
|
||||
None
|
||||
}
|
||||
|
||||
fn unwrap_artifact_incremental_envelope(value: &Value) -> Option<&Value> {
|
||||
let record = value.as_object()?;
|
||||
let envelope_type = record
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
if ARTIFACT_INCREMENTAL_ENVELOPE_TYPES.contains(&envelope_type) {
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_artifact_rewrite_patch_envelope(value: &Value) -> Option<&Value> {
|
||||
let record = value.as_object()?;
|
||||
let patch_type = record
|
||||
@@ -121,7 +174,7 @@ fn unwrap_artifact_rewrite_patch_envelope(value: &Value) -> Option<&Value> {
|
||||
None
|
||||
}
|
||||
|
||||
fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option<Value> {
|
||||
fn convert_rewrite_patch_to_artifact_actions(value: &Value) -> Option<Vec<Value>> {
|
||||
let record = value.as_object()?;
|
||||
let block_record = record.get("block").and_then(Value::as_object)?.clone();
|
||||
let block_id = normalize_text(block_record.get("id").and_then(Value::as_str))?;
|
||||
@@ -136,13 +189,13 @@ fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option<Value> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut ops = vec![serde_json::json!({
|
||||
let mut actions = vec![serde_json::json!({
|
||||
"op": "artifact.upsert_block",
|
||||
"block": Value::Object(block_record)
|
||||
})];
|
||||
|
||||
if let Some(source) = record.get("source") {
|
||||
ops.push(serde_json::json!({
|
||||
actions.push(serde_json::json!({
|
||||
"op": "artifact.attach_source",
|
||||
"blockId": target_block_id,
|
||||
"source": source
|
||||
@@ -150,7 +203,7 @@ fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option<Value> {
|
||||
}
|
||||
if let Some(sources) = record.get("sources").and_then(Value::as_array) {
|
||||
for source in sources {
|
||||
ops.push(serde_json::json!({
|
||||
actions.push(serde_json::json!({
|
||||
"op": "artifact.attach_source",
|
||||
"blockId": target_block_id,
|
||||
"source": source
|
||||
@@ -170,24 +223,73 @@ fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option<Value> {
|
||||
finalize_record.insert("status".to_string(), Value::String(status));
|
||||
}
|
||||
if finalize_record.len() > 1 {
|
||||
ops.push(Value::Object(finalize_record));
|
||||
actions.push(Value::Object(finalize_record));
|
||||
}
|
||||
|
||||
let mut envelope = Map::new();
|
||||
envelope.insert(
|
||||
"type".to_string(),
|
||||
Value::String(ARTIFACT_OPS_ENVELOPE_TYPE.to_string()),
|
||||
);
|
||||
if let Some(artifact_id) = normalize_text(
|
||||
record
|
||||
.get("artifactId")
|
||||
.or_else(|| record.get("artifact_id"))
|
||||
.and_then(Value::as_str),
|
||||
) {
|
||||
envelope.insert("artifactId".to_string(), Value::String(artifact_id));
|
||||
Some(actions)
|
||||
}
|
||||
|
||||
fn convert_incremental_envelope_to_artifact_action(value: &Value) -> Option<Value> {
|
||||
let record = value.as_object()?;
|
||||
let envelope_type = normalize_text(record.get("type").and_then(Value::as_str))?;
|
||||
match envelope_type.as_str() {
|
||||
"artifact.begin" => {
|
||||
let kind = normalize_text(record.get("kind").and_then(Value::as_str))?;
|
||||
let title = normalize_text(record.get("title").and_then(Value::as_str))?;
|
||||
Some(serde_json::json!({
|
||||
"op": "artifact.create",
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
}))
|
||||
}
|
||||
"artifact.meta.patch" => {
|
||||
let patch = record.get("patch").and_then(Value::as_object)?.clone();
|
||||
Some(serde_json::json!({
|
||||
"op": "artifact.set_meta",
|
||||
"metadata": Value::Object(patch),
|
||||
}))
|
||||
}
|
||||
"artifact.source.upsert" => {
|
||||
let source = record.get("source").and_then(Value::as_object)?.clone();
|
||||
Some(serde_json::json!({
|
||||
"op": ARTIFACT_INTERNAL_SOURCE_UPSERT_OP,
|
||||
"source": Value::Object(source),
|
||||
}))
|
||||
}
|
||||
"artifact.block.upsert" => {
|
||||
let block = record.get("block").and_then(Value::as_object)?.clone();
|
||||
Some(serde_json::json!({
|
||||
"op": "artifact.upsert_block",
|
||||
"block": Value::Object(block),
|
||||
}))
|
||||
}
|
||||
"artifact.block.remove" => {
|
||||
let block_id = normalize_text(record.get("blockId").and_then(Value::as_str))
|
||||
.or_else(|| normalize_text(record.get("block_id").and_then(Value::as_str)))?;
|
||||
Some(serde_json::json!({
|
||||
"op": "artifact.remove_block",
|
||||
"blockId": block_id,
|
||||
}))
|
||||
}
|
||||
"artifact.complete" => {
|
||||
let mut op = Map::from_iter([(
|
||||
"op".to_string(),
|
||||
Value::String("artifact.finalize_version".to_string()),
|
||||
)]);
|
||||
if let Some(summary) = normalize_text(record.get("summary").and_then(Value::as_str)) {
|
||||
op.insert("summary".to_string(), Value::String(summary));
|
||||
}
|
||||
Some(Value::Object(op))
|
||||
}
|
||||
"artifact.fail" => {
|
||||
let reason = normalize_text(record.get("reason").and_then(Value::as_str))?;
|
||||
Some(serde_json::json!({
|
||||
"op": "artifact.fail",
|
||||
"reason": reason,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
envelope.insert("ops".to_string(), Value::Array(ops));
|
||||
Some(Value::Object(envelope))
|
||||
}
|
||||
|
||||
fn strip_outer_code_fence(raw: &str) -> String {
|
||||
@@ -292,7 +394,7 @@ fn initialize_document(
|
||||
Value::Object(document)
|
||||
}
|
||||
|
||||
fn apply_single_op(
|
||||
fn apply_single_action(
|
||||
document: &mut Value,
|
||||
op_value: &Value,
|
||||
index: usize,
|
||||
@@ -337,6 +439,9 @@ fn apply_single_op(
|
||||
"artifact.attach_source" => {
|
||||
apply_attach_source_op(document, op_record, index, context, issues)
|
||||
}
|
||||
ARTIFACT_INTERNAL_SOURCE_UPSERT_OP => {
|
||||
apply_source_upsert_op(document, op_record, index, issues)
|
||||
}
|
||||
"artifact.finalize_version" => apply_finalize_version_op(document, op_record),
|
||||
"artifact.fail" => apply_fail_op(document, op_record),
|
||||
_ => issues.push(format!("ops[{index}].op `{op_name}` 暂不支持,已忽略。")),
|
||||
@@ -544,12 +649,103 @@ fn apply_attach_source_op(
|
||||
let Some(document_record) = document.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let next_source_id = upsert_source_record(
|
||||
document_record,
|
||||
source_record,
|
||||
op_record.get("sourceLink").and_then(Value::as_object),
|
||||
);
|
||||
|
||||
let blocks = ensure_array_field(document_record, "blocks");
|
||||
let Some(block) = blocks.iter_mut().find(|block| {
|
||||
block
|
||||
.as_object()
|
||||
.and_then(|record| record.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
== Some(block_id.as_str())
|
||||
}) else {
|
||||
issues.push(format!(
|
||||
"ops[{index}] 目标 block `{block_id}` 不存在,已保留 source 但未完成绑定。"
|
||||
));
|
||||
return;
|
||||
};
|
||||
let Some(block_record) = block.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let source_ids = ensure_array_field(block_record, "sourceIds");
|
||||
let already_exists = source_ids
|
||||
.iter()
|
||||
.any(|value| value.as_str().map(str::trim) == Some(next_source_id.as_str()));
|
||||
if !already_exists {
|
||||
source_ids.push(Value::String(next_source_id));
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_source_upsert_op(
|
||||
document: &mut Value,
|
||||
op_record: &Map<String, Value>,
|
||||
index: usize,
|
||||
issues: &mut Vec<String>,
|
||||
) {
|
||||
let Some(source_record) = op_record.get("source").and_then(Value::as_object) else {
|
||||
issues.push(format!("ops[{index}] 缺少 source 对象,已忽略来源写入。"));
|
||||
return;
|
||||
};
|
||||
let Some(document_record) = document.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
upsert_source_record(document_record, source_record, None);
|
||||
}
|
||||
|
||||
fn is_allowed_target_block(context: &ArtifactDocumentValidationContext, block_id: &str) -> bool {
|
||||
context
|
||||
.target_block_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_none_or(|target_block_id| target_block_id == block_id)
|
||||
}
|
||||
|
||||
fn apply_finalize_version_op(document: &mut Value, op_record: &Map<String, Value>) {
|
||||
let Some(document_record) = document.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(status) = normalize_text(op_record.get("status").and_then(Value::as_str)) {
|
||||
document_record.insert("status".to_string(), Value::String(status));
|
||||
}
|
||||
if let Some(summary) = normalize_text(op_record.get("summary").and_then(Value::as_str)) {
|
||||
let metadata = ensure_object_field(document_record, "metadata");
|
||||
metadata.insert("versionSummary".to_string(), Value::String(summary));
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_fail_op(document: &mut Value, op_record: &Map<String, Value>) {
|
||||
let Some(document_record) = document.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
document_record.insert("status".to_string(), Value::String("failed".to_string()));
|
||||
if let Some(reason) = normalize_text(
|
||||
op_record
|
||||
.get("reason")
|
||||
.or_else(|| op_record.get("message"))
|
||||
.and_then(Value::as_str),
|
||||
) {
|
||||
let metadata = ensure_object_field(document_record, "metadata");
|
||||
metadata.insert("failureReason".to_string(), Value::String(reason));
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_source_record(
|
||||
document_record: &mut Map<String, Value>,
|
||||
source_record: &Map<String, Value>,
|
||||
source_link: Option<&Map<String, Value>>,
|
||||
) -> String {
|
||||
let sources = ensure_array_field(document_record, "sources");
|
||||
let next_source_id = normalize_text(source_record.get("id").and_then(Value::as_str))
|
||||
.unwrap_or_else(|| format!("source-{}", sources.len() + 1));
|
||||
let mut merged_source = source_record.clone();
|
||||
merged_source.insert("id".to_string(), Value::String(next_source_id.clone()));
|
||||
if let Some(link_record) = op_record.get("sourceLink").and_then(Value::as_object) {
|
||||
if let Some(link_record) = source_link {
|
||||
if !merged_source.contains_key("locator") {
|
||||
if let Some(locator) = link_record.get("locator") {
|
||||
merged_source.insert("locator".to_string(), locator.clone());
|
||||
@@ -606,68 +802,7 @@ fn apply_attach_source_op(
|
||||
sources.push(Value::Object(merged_source));
|
||||
}
|
||||
|
||||
let blocks = ensure_array_field(document_record, "blocks");
|
||||
let Some(block) = blocks.iter_mut().find(|block| {
|
||||
block
|
||||
.as_object()
|
||||
.and_then(|record| record.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
== Some(block_id.as_str())
|
||||
}) else {
|
||||
issues.push(format!(
|
||||
"ops[{index}] 目标 block `{block_id}` 不存在,已保留 source 但未完成绑定。"
|
||||
));
|
||||
return;
|
||||
};
|
||||
let Some(block_record) = block.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let source_ids = ensure_array_field(block_record, "sourceIds");
|
||||
let already_exists = source_ids
|
||||
.iter()
|
||||
.any(|value| value.as_str().map(str::trim) == Some(next_source_id.as_str()));
|
||||
if !already_exists {
|
||||
source_ids.push(Value::String(next_source_id));
|
||||
}
|
||||
}
|
||||
|
||||
fn is_allowed_target_block(context: &ArtifactDocumentValidationContext, block_id: &str) -> bool {
|
||||
context
|
||||
.target_block_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_none_or(|target_block_id| target_block_id == block_id)
|
||||
}
|
||||
|
||||
fn apply_finalize_version_op(document: &mut Value, op_record: &Map<String, Value>) {
|
||||
let Some(document_record) = document.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(status) = normalize_text(op_record.get("status").and_then(Value::as_str)) {
|
||||
document_record.insert("status".to_string(), Value::String(status));
|
||||
}
|
||||
if let Some(summary) = normalize_text(op_record.get("summary").and_then(Value::as_str)) {
|
||||
let metadata = ensure_object_field(document_record, "metadata");
|
||||
metadata.insert("versionSummary".to_string(), Value::String(summary));
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_fail_op(document: &mut Value, op_record: &Map<String, Value>) {
|
||||
let Some(document_record) = document.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
document_record.insert("status".to_string(), Value::String("failed".to_string()));
|
||||
if let Some(reason) = normalize_text(
|
||||
op_record
|
||||
.get("reason")
|
||||
.or_else(|| op_record.get("message"))
|
||||
.and_then(Value::as_str),
|
||||
) {
|
||||
let metadata = ensure_object_field(document_record, "metadata");
|
||||
metadata.insert("failureReason".to_string(), Value::String(reason));
|
||||
}
|
||||
next_source_id
|
||||
}
|
||||
|
||||
fn merge_document_patch(document_record: &mut Map<String, Value>, patch: &Map<String, Value>) {
|
||||
@@ -805,6 +940,11 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn extract_normalized_actions(raw_text: &str) -> Vec<Value> {
|
||||
let candidate = extract_artifact_operation_candidate(raw_text).expect("operation envelope");
|
||||
normalize_artifact_operation_candidate_to_actions(&candidate).expect("normalized actions")
|
||||
}
|
||||
|
||||
fn build_context() -> ArtifactDocumentValidationContext {
|
||||
ArtifactDocumentValidationContext {
|
||||
artifact_id: "artifact-document:demo".to_string(),
|
||||
@@ -836,15 +976,73 @@ mod tests {
|
||||
```
|
||||
"#;
|
||||
|
||||
let extracted = extract_artifact_ops_candidate(raw).expect("ops envelope");
|
||||
let extracted = extract_normalized_actions(raw);
|
||||
assert_eq!(
|
||||
extracted.get("type").and_then(Value::as_str).map(str::trim),
|
||||
Some("artifact_ops")
|
||||
extracted
|
||||
.first()
|
||||
.and_then(|action| action.get("op"))
|
||||
.and_then(Value::as_str),
|
||||
Some("artifact.set_meta")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_convert_rewrite_patch_to_artifact_ops_candidate() {
|
||||
fn should_extract_current_incremental_candidate_without_normalizing_to_compat() {
|
||||
let raw = r#"
|
||||
{
|
||||
"type": "artifact.block.upsert",
|
||||
"artifactId": "artifact-document:demo",
|
||||
"block": {
|
||||
"id": "body-1",
|
||||
"type": "rich_text",
|
||||
"contentFormat": "markdown",
|
||||
"content": "增量改写后的正文"
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let extracted = extract_artifact_operation_candidate(raw).expect("operation envelope");
|
||||
|
||||
assert_eq!(
|
||||
extracted.get("type").and_then(Value::as_str).map(str::trim),
|
||||
Some("artifact.block.upsert")
|
||||
);
|
||||
assert!(extracted.get("ops").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_extract_rewrite_patch_candidate_without_normalizing_to_compat() {
|
||||
let raw = r#"
|
||||
{
|
||||
"type": "artifact_rewrite_patch",
|
||||
"artifactId": "artifact-document:demo",
|
||||
"targetBlockId": "body-1",
|
||||
"block": {
|
||||
"id": "body-1",
|
||||
"type": "rich_text",
|
||||
"contentFormat": "markdown",
|
||||
"content": "current shape 改写后的正文"
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let extracted = extract_artifact_operation_candidate(raw).expect("operation envelope");
|
||||
|
||||
assert_eq!(
|
||||
extracted.get("type").and_then(Value::as_str).map(str::trim),
|
||||
Some("artifact_rewrite_patch")
|
||||
);
|
||||
assert_eq!(
|
||||
extracted
|
||||
.get("block")
|
||||
.and_then(|block| block.get("content"))
|
||||
.and_then(Value::as_str),
|
||||
Some("current shape 改写后的正文")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_normalize_rewrite_patch_to_internal_actions() {
|
||||
let raw = r#"
|
||||
{
|
||||
"type": "artifact_rewrite_patch",
|
||||
@@ -864,37 +1062,355 @@ mod tests {
|
||||
}
|
||||
"#;
|
||||
|
||||
let extracted = extract_artifact_ops_candidate(raw).expect("ops envelope");
|
||||
let ops = extracted
|
||||
.get("ops")
|
||||
.and_then(Value::as_array)
|
||||
.expect("ops array");
|
||||
let actions = extract_normalized_actions(raw);
|
||||
|
||||
assert_eq!(actions.len(), 3);
|
||||
assert_eq!(
|
||||
extracted.get("type").and_then(Value::as_str).map(str::trim),
|
||||
Some("artifact_ops")
|
||||
);
|
||||
assert_eq!(ops.len(), 3);
|
||||
assert_eq!(
|
||||
ops.first()
|
||||
actions
|
||||
.first()
|
||||
.and_then(|op| op.get("op"))
|
||||
.and_then(Value::as_str),
|
||||
Some("artifact.upsert_block")
|
||||
);
|
||||
assert_eq!(
|
||||
ops.get(1)
|
||||
actions
|
||||
.get(1)
|
||||
.and_then(|op| op.get("op"))
|
||||
.and_then(Value::as_str),
|
||||
Some("artifact.attach_source")
|
||||
);
|
||||
assert_eq!(
|
||||
ops.get(2)
|
||||
actions
|
||||
.get(2)
|
||||
.and_then(|op| op.get("summary"))
|
||||
.and_then(Value::as_str),
|
||||
Some("仅改写目标正文")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_normalize_current_shaped_rewrite_patch_to_internal_actions() {
|
||||
let raw = r#"
|
||||
{
|
||||
"type": "artifact_rewrite_patch",
|
||||
"artifactId": "artifact-document:demo",
|
||||
"targetBlockId": "body-1",
|
||||
"block": {
|
||||
"id": "body-1",
|
||||
"type": "rich_text",
|
||||
"contentFormat": "markdown",
|
||||
"content": "current shape 改写后的正文"
|
||||
},
|
||||
"source": {
|
||||
"id": "source-1",
|
||||
"type": "web",
|
||||
"label": "OpenAI",
|
||||
"locator": {
|
||||
"url": "https://openai.com"
|
||||
}
|
||||
},
|
||||
"summary": "current shape typed patch"
|
||||
}
|
||||
"#;
|
||||
|
||||
let actions = extract_normalized_actions(raw);
|
||||
|
||||
assert_eq!(actions.len(), 3);
|
||||
assert_eq!(
|
||||
actions
|
||||
.first()
|
||||
.and_then(|op| op.get("block"))
|
||||
.and_then(|block| block.get("content"))
|
||||
.and_then(Value::as_str),
|
||||
Some("current shape 改写后的正文")
|
||||
);
|
||||
assert_eq!(
|
||||
actions
|
||||
.get(1)
|
||||
.and_then(|op| op.get("source"))
|
||||
.and_then(|source| source.get("label"))
|
||||
.and_then(Value::as_str),
|
||||
Some("OpenAI")
|
||||
);
|
||||
assert_eq!(
|
||||
actions
|
||||
.get(2)
|
||||
.and_then(|op| op.get("summary"))
|
||||
.and_then(Value::as_str),
|
||||
Some("current shape typed patch")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_normalize_current_incremental_block_upsert_to_internal_action() {
|
||||
let raw = r#"
|
||||
{
|
||||
"type": "artifact.block.upsert",
|
||||
"artifactId": "artifact-document:demo",
|
||||
"block": {
|
||||
"id": "body-1",
|
||||
"type": "rich_text",
|
||||
"contentFormat": "markdown",
|
||||
"content": "增量改写后的正文"
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let actions = extract_normalized_actions(raw);
|
||||
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert_eq!(
|
||||
actions
|
||||
.first()
|
||||
.and_then(|op| op.get("op"))
|
||||
.and_then(Value::as_str),
|
||||
Some("artifact.upsert_block")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_apply_current_incremental_candidate_without_compat_envelope() {
|
||||
let base_document = json!({
|
||||
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
|
||||
"artifactId": "artifact-document:demo",
|
||||
"kind": "analysis",
|
||||
"title": "季度结论",
|
||||
"status": "ready",
|
||||
"language": "zh-CN",
|
||||
"summary": "旧摘要",
|
||||
"blocks": [
|
||||
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" }
|
||||
],
|
||||
"sources": [],
|
||||
"metadata": {
|
||||
"generatedBy": "agent"
|
||||
}
|
||||
});
|
||||
let operation = json!({
|
||||
"type": "artifact.block.upsert",
|
||||
"artifactId": "artifact-document:demo",
|
||||
"block": {
|
||||
"id": "body-1",
|
||||
"type": "rich_text",
|
||||
"contentFormat": "markdown",
|
||||
"content": "直接通过 current 包络改写后的正文"
|
||||
}
|
||||
});
|
||||
|
||||
let outcome = apply_artifact_operation_candidate_to_document(
|
||||
Some(&base_document),
|
||||
&operation,
|
||||
&build_context(),
|
||||
);
|
||||
|
||||
assert!(outcome.issues.is_empty());
|
||||
assert_eq!(
|
||||
outcome
|
||||
.document
|
||||
.get("blocks")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|blocks| blocks.first())
|
||||
.and_then(|block| block.get("content"))
|
||||
.and_then(Value::as_str),
|
||||
Some("直接通过 current 包络改写后的正文")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_apply_rewrite_patch_candidate_without_compat_envelope() {
|
||||
let base_document = json!({
|
||||
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
|
||||
"artifactId": "artifact-document:demo",
|
||||
"kind": "analysis",
|
||||
"title": "季度结论",
|
||||
"status": "ready",
|
||||
"language": "zh-CN",
|
||||
"summary": "旧摘要",
|
||||
"blocks": [
|
||||
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" }
|
||||
],
|
||||
"sources": [],
|
||||
"metadata": {
|
||||
"generatedBy": "agent"
|
||||
}
|
||||
});
|
||||
let operation = json!({
|
||||
"type": "artifact_rewrite_patch",
|
||||
"artifactId": "artifact-document:demo",
|
||||
"targetBlockId": "body-1",
|
||||
"block": {
|
||||
"id": "body-1",
|
||||
"type": "rich_text",
|
||||
"contentFormat": "markdown",
|
||||
"content": "rewrite patch 直接改写后的正文"
|
||||
},
|
||||
"source": {
|
||||
"id": "source-1",
|
||||
"type": "web",
|
||||
"label": "OpenAI",
|
||||
"locator": {
|
||||
"url": "https://openai.com"
|
||||
}
|
||||
},
|
||||
"summary": "仅改写目标正文"
|
||||
});
|
||||
|
||||
let outcome = apply_artifact_operation_candidate_to_document(
|
||||
Some(&base_document),
|
||||
&operation,
|
||||
&build_context(),
|
||||
);
|
||||
|
||||
assert!(outcome.issues.is_empty());
|
||||
assert_eq!(
|
||||
outcome
|
||||
.document
|
||||
.get("blocks")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|blocks| blocks.first())
|
||||
.and_then(|block| block.get("content"))
|
||||
.and_then(Value::as_str),
|
||||
Some("rewrite patch 直接改写后的正文")
|
||||
);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.document
|
||||
.get("sources")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|sources| sources.first())
|
||||
.and_then(|source| source.get("label"))
|
||||
.and_then(Value::as_str),
|
||||
Some("OpenAI")
|
||||
);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.document
|
||||
.get("metadata")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get("versionSummary"))
|
||||
.and_then(Value::as_str),
|
||||
Some("仅改写目标正文")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_apply_current_incremental_source_upsert_without_block_binding() {
|
||||
let base_document = json!({
|
||||
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
|
||||
"artifactId": "artifact-document:demo",
|
||||
"kind": "analysis",
|
||||
"title": "季度结论",
|
||||
"status": "ready",
|
||||
"language": "zh-CN",
|
||||
"summary": "旧摘要",
|
||||
"blocks": [
|
||||
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" }
|
||||
],
|
||||
"sources": [],
|
||||
"metadata": {
|
||||
"generatedBy": "agent"
|
||||
}
|
||||
});
|
||||
let operation = json!({
|
||||
"type": "artifact.source.upsert",
|
||||
"artifactId": "artifact-document:demo",
|
||||
"source": {
|
||||
"id": "source-1",
|
||||
"type": "web",
|
||||
"label": "OpenAI",
|
||||
"locator": {
|
||||
"url": "https://openai.com"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let outcome = apply_artifact_operation_candidate_to_document(
|
||||
Some(&base_document),
|
||||
&operation,
|
||||
&build_context(),
|
||||
);
|
||||
|
||||
assert!(outcome.issues.is_empty());
|
||||
assert_eq!(
|
||||
outcome
|
||||
.document
|
||||
.get("sources")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.len()),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.document
|
||||
.get("blocks")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|blocks| blocks.first())
|
||||
.and_then(|block| block.get("sourceIds")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_keep_compat_artifact_ops_as_fallback_input() {
|
||||
let base_document = json!({
|
||||
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
|
||||
"artifactId": "artifact-document:demo",
|
||||
"kind": "analysis",
|
||||
"title": "季度结论",
|
||||
"status": "ready",
|
||||
"language": "zh-CN",
|
||||
"summary": "旧摘要",
|
||||
"blocks": [
|
||||
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" }
|
||||
],
|
||||
"sources": [],
|
||||
"metadata": {
|
||||
"generatedBy": "agent"
|
||||
}
|
||||
});
|
||||
let operation = json!({
|
||||
"type": "artifact_ops",
|
||||
"ops": [
|
||||
{
|
||||
"op": "artifact.source.upsert",
|
||||
"source": {
|
||||
"id": "source-1",
|
||||
"type": "web",
|
||||
"label": "OpenAI",
|
||||
"locator": {
|
||||
"url": "https://openai.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let outcome = apply_artifact_operation_candidate_to_document(
|
||||
Some(&base_document),
|
||||
&operation,
|
||||
&build_context(),
|
||||
);
|
||||
|
||||
assert!(outcome.issues.is_empty());
|
||||
assert_eq!(
|
||||
outcome
|
||||
.document
|
||||
.get("sources")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.len()),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.document
|
||||
.get("blocks")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|blocks| blocks.first())
|
||||
.and_then(|block| block.get("sourceIds")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_apply_upsert_block_and_attach_source_on_existing_document() {
|
||||
let base_document = json!({
|
||||
@@ -942,7 +1458,11 @@ mod tests {
|
||||
]
|
||||
});
|
||||
|
||||
let outcome = apply_artifact_ops_to_document(Some(&base_document), &ops, &build_context());
|
||||
let outcome = apply_artifact_operation_candidate_to_document(
|
||||
Some(&base_document),
|
||||
&ops,
|
||||
&build_context(),
|
||||
);
|
||||
|
||||
assert!(outcome.issues.is_empty());
|
||||
let body_block = outcome
|
||||
@@ -1036,7 +1556,8 @@ mod tests {
|
||||
let mut context = build_context();
|
||||
context.target_block_id = Some("body-1".to_string());
|
||||
|
||||
let outcome = apply_artifact_ops_to_document(Some(&base_document), &ops, &context);
|
||||
let outcome =
|
||||
apply_artifact_operation_candidate_to_document(Some(&base_document), &ops, &context);
|
||||
|
||||
let blocks = outcome
|
||||
.document
|
||||
|
||||
@@ -55,6 +55,21 @@ const ARTIFACT_STAGE2_OP_VALUES: &[&str] = &[
|
||||
"artifact.finalize_version",
|
||||
"artifact.fail",
|
||||
];
|
||||
const ARTIFACT_STAGE2_INCREMENTAL_OP_TYPES: &[&str] = &[
|
||||
"artifact.begin",
|
||||
"artifact.meta.patch",
|
||||
"artifact.source.upsert",
|
||||
"artifact.block.upsert",
|
||||
"artifact.block.remove",
|
||||
"artifact.complete",
|
||||
"artifact.fail",
|
||||
];
|
||||
const ARTIFACT_REWRITE_INCREMENTAL_OP_TYPES: &[&str] = &[
|
||||
"artifact.source.upsert",
|
||||
"artifact.block.upsert",
|
||||
"artifact.complete",
|
||||
"artifact.fail",
|
||||
];
|
||||
const ARTIFACT_REWRITE_OP_VALUES: &[&str] = &[
|
||||
"artifact.set_meta",
|
||||
"artifact.upsert_block",
|
||||
@@ -349,37 +364,38 @@ fn build_stage2_document_schema(context: &ArtifactOutputSchemaContext) -> Value
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": ["id"],
|
||||
"required": ["id", "type", "label"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"title": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["web", "file", "tool", "message", "search_result"]
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"snippet": {
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"reliability": {
|
||||
"type": "string"
|
||||
},
|
||||
"quote": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"publishedAt": {
|
||||
"type": "string"
|
||||
"locator": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"url": { "type": "string" },
|
||||
"path": { "type": "string" },
|
||||
"lineStart": { "type": "number" },
|
||||
"lineEnd": { "type": "number" },
|
||||
"toolCallId": { "type": "string" },
|
||||
"messageId": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{ "required": ["title"] },
|
||||
{ "required": ["url"] },
|
||||
{ "required": ["note"] },
|
||||
{ "required": ["quote"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
@@ -425,38 +441,39 @@ fn build_artifact_source_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": ["id", "type", "label"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"title": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["web", "file", "tool", "message", "search_result"]
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"snippet": {
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"type": "string"
|
||||
"reliability": {
|
||||
"type": "string",
|
||||
"enum": ["primary", "secondary", "derived"]
|
||||
},
|
||||
"quote": {
|
||||
"type": "string"
|
||||
"locator": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"url": { "type": "string" },
|
||||
"path": { "type": "string" },
|
||||
"lineStart": { "type": "number" },
|
||||
"lineEnd": { "type": "number" },
|
||||
"toolCallId": { "type": "string" },
|
||||
"messageId": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"publishedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"locator": {}
|
||||
},
|
||||
"anyOf": [
|
||||
{ "required": ["id"] },
|
||||
{ "required": ["title"] },
|
||||
{ "required": ["url"] },
|
||||
{ "required": ["note"] },
|
||||
{ "required": ["quote"] }
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -691,6 +708,144 @@ fn build_artifact_ops_item_schema(op_values: &[&str], target_block_id: Option<&s
|
||||
})
|
||||
}
|
||||
|
||||
fn build_artifact_incremental_output_schema(
|
||||
op_types: &[&str],
|
||||
target_block_id: Option<&str>,
|
||||
) -> Value {
|
||||
let mut item_schemas = Vec::new();
|
||||
|
||||
if op_types.contains(&"artifact.begin") {
|
||||
item_schemas.push(json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "artifactId", "kind", "title"],
|
||||
"properties": {
|
||||
"type": build_string_schema(op_types, Some("artifact.begin"), None),
|
||||
"artifactId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ARTIFACT_KIND_VALUES
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if op_types.contains(&"artifact.meta.patch") {
|
||||
item_schemas.push(json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "artifactId", "patch"],
|
||||
"properties": {
|
||||
"type": build_string_schema(op_types, Some("artifact.meta.patch"), None),
|
||||
"artifactId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"patch": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if op_types.contains(&"artifact.source.upsert") {
|
||||
item_schemas.push(json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "artifactId", "source"],
|
||||
"properties": {
|
||||
"type": build_string_schema(op_types, Some("artifact.source.upsert"), None),
|
||||
"artifactId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"source": build_artifact_source_schema()
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if op_types.contains(&"artifact.block.upsert") {
|
||||
item_schemas.push(json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "artifactId", "block"],
|
||||
"properties": {
|
||||
"type": build_string_schema(op_types, Some("artifact.block.upsert"), None),
|
||||
"artifactId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"block": build_artifact_block_schema_with_target_id(target_block_id)
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if op_types.contains(&"artifact.block.remove") {
|
||||
item_schemas.push(json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "artifactId", "blockId"],
|
||||
"properties": {
|
||||
"type": build_string_schema(op_types, Some("artifact.block.remove"), None),
|
||||
"artifactId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"blockId": build_block_id_constraint_schema(target_block_id)
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if op_types.contains(&"artifact.complete") {
|
||||
item_schemas.push(json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "artifactId"],
|
||||
"properties": {
|
||||
"type": build_string_schema(op_types, Some("artifact.complete"), None),
|
||||
"artifactId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if op_types.contains(&"artifact.fail") {
|
||||
item_schemas.push(json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "artifactId", "reason"],
|
||||
"properties": {
|
||||
"type": build_string_schema(op_types, Some("artifact.fail"), None),
|
||||
"artifactId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
json!({
|
||||
"title": "lime_artifact_incremental",
|
||||
"oneOf": item_schemas
|
||||
})
|
||||
}
|
||||
|
||||
fn build_artifact_ops_output_schema(op_values: &[&str], target_block_id: Option<&str>) -> Value {
|
||||
json!({
|
||||
"title": "lime_artifact_ops",
|
||||
@@ -755,6 +910,7 @@ fn build_stage2_output_schema(context: &ArtifactOutputSchemaContext) -> Value {
|
||||
"title": "lime_artifact_stage2_result",
|
||||
"oneOf": [
|
||||
build_stage2_document_envelope_schema(context),
|
||||
build_artifact_incremental_output_schema(ARTIFACT_STAGE2_INCREMENTAL_OP_TYPES, None),
|
||||
build_artifact_ops_output_schema(ARTIFACT_STAGE2_OP_VALUES, None)
|
||||
]
|
||||
})
|
||||
@@ -766,6 +922,10 @@ fn build_rewrite_output_schema(context: &ArtifactOutputSchemaContext) -> Value {
|
||||
"title": "lime_artifact_rewrite_result",
|
||||
"oneOf": [
|
||||
build_artifact_rewrite_patch_output_schema(context),
|
||||
build_artifact_incremental_output_schema(
|
||||
ARTIFACT_REWRITE_INCREMENTAL_OP_TYPES,
|
||||
context.target_block_id.as_deref()
|
||||
),
|
||||
build_artifact_ops_output_schema(
|
||||
ARTIFACT_REWRITE_OP_VALUES,
|
||||
context.target_block_id.as_deref()
|
||||
@@ -894,10 +1054,30 @@ mod tests {
|
||||
.and_then(Value::as_u64),
|
||||
Some(1)
|
||||
);
|
||||
assert!(schema
|
||||
.get("oneOf")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|items| items.iter().any(|item| {
|
||||
item.get("oneOf")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|variants| {
|
||||
variants.iter().any(|variant| {
|
||||
variant
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|properties| properties.get("type"))
|
||||
.and_then(|value| value.get("enum"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|values| values.first())
|
||||
.and_then(Value::as_str)
|
||||
== Some("artifact.block.upsert")
|
||||
})
|
||||
})
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_should_build_artifact_ops_schema() {
|
||||
fn rewrite_should_build_patch_incremental_and_ops_schema() {
|
||||
let metadata = json!({
|
||||
"artifact": {
|
||||
"artifact_mode": "rewrite",
|
||||
@@ -913,7 +1093,7 @@ mod tests {
|
||||
.get("oneOf")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.len()),
|
||||
Some(2)
|
||||
Some(3)
|
||||
);
|
||||
let rewrite_patch_schema = schema
|
||||
.get("oneOf")
|
||||
@@ -961,6 +1141,69 @@ mod tests {
|
||||
.and_then(Value::as_str),
|
||||
Some("body-1")
|
||||
);
|
||||
let incremental_schema = schema
|
||||
.get("oneOf")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.get("title").and_then(Value::as_str) == Some("lime_artifact_incremental")
|
||||
})
|
||||
})
|
||||
.expect("incremental schema");
|
||||
let incremental_upsert_schema = incremental_schema
|
||||
.get("oneOf")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|properties| properties.get("type"))
|
||||
.and_then(|op| op.get("enum"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|values| values.first())
|
||||
.and_then(Value::as_str)
|
||||
== Some("artifact.block.upsert")
|
||||
})
|
||||
})
|
||||
.expect("incremental upsert schema");
|
||||
assert_eq!(
|
||||
incremental_upsert_schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|properties| properties.get("block"))
|
||||
.and_then(|block| block.get("properties"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|properties| properties.get("id"))
|
||||
.and_then(|id| id.get("enum"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|values| values.first())
|
||||
.and_then(Value::as_str),
|
||||
Some("body-1")
|
||||
);
|
||||
let rewrite_incremental_has_type = |expected: &str| {
|
||||
incremental_schema
|
||||
.get("oneOf")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|items| {
|
||||
items.iter().any(|item| {
|
||||
item.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|properties| properties.get("type"))
|
||||
.and_then(|op| op.get("enum"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|values| values.first())
|
||||
.and_then(Value::as_str)
|
||||
== Some(expected)
|
||||
})
|
||||
})
|
||||
};
|
||||
assert!(rewrite_incremental_has_type("artifact.source.upsert"));
|
||||
assert!(rewrite_incremental_has_type("artifact.block.upsert"));
|
||||
assert!(rewrite_incremental_has_type("artifact.complete"));
|
||||
assert!(rewrite_incremental_has_type("artifact.fail"));
|
||||
assert!(!rewrite_incremental_has_type("artifact.begin"));
|
||||
assert!(!rewrite_incremental_has_type("artifact.meta.patch"));
|
||||
assert!(!rewrite_incremental_has_type("artifact.block.remove"));
|
||||
let ops_schema = schema
|
||||
.get("oneOf")
|
||||
.and_then(Value::as_array)
|
||||
|
||||
@@ -224,8 +224,7 @@ fn build_artifact_stage_prompt(context: &ArtifactPromptContext) -> Option<String
|
||||
}
|
||||
lines.push("执行要求:".to_string());
|
||||
lines.push(
|
||||
"1. 默认输出 `artifact_rewrite_patch`;只有兼容旧链路时才回退到 `artifact_ops`。"
|
||||
.to_string(),
|
||||
"1. 优先输出 `artifact_rewrite_patch`;如果更适合 current 主链,也只允许输出 rewrite 专用单条 incremental op:`artifact.source.upsert / artifact.block.upsert / artifact.complete / artifact.fail`;只有兼容旧链路时才回退到 `artifact_ops`。".to_string(),
|
||||
);
|
||||
lines.push(
|
||||
"2. 如果提供了目标 block,则不要改写其他 block,也不要借机重排整个文档结构。"
|
||||
@@ -239,7 +238,7 @@ fn build_artifact_stage_prompt(context: &ArtifactPromptContext) -> Option<String
|
||||
"{ARTIFACT_STAGE2_PROMPT_MARKER}\n\
|
||||
执行目标:\n\
|
||||
1. 输出正式结构化交付物草稿。\n\
|
||||
2. 初次生成可输出 `artifact_document_draft`;对已有文档做增量补充时优先输出 `artifact_ops`。\n\
|
||||
2. 初次生成可输出 `artifact_document_draft`;对已有文档做增量补充时优先输出正式单条 op:`artifact.begin / artifact.meta.patch / artifact.source.upsert / artifact.block.upsert / artifact.block.remove / artifact.complete / artifact.fail`;仅兼容旧链路时才回退到 `artifact_ops`。\n\
|
||||
3. 交付物必须满足 ArtifactDocument v1。\n\
|
||||
4. block 类型只能来自白名单,不要自由发明新 block。\n\
|
||||
5. 若已知 sources,应挂到 sources[] 并让 block.sourceIds 指向已有来源。"
|
||||
@@ -259,25 +258,30 @@ fn build_artifact_schema_hint_prompt(context: &ArtifactPromptContext) -> Option<
|
||||
Some("rewrite")
|
||||
);
|
||||
let output_contract = if is_rewrite {
|
||||
"本轮优先输出 `artifact_rewrite_patch`;兼容情况下也可输出 `artifact_ops`,不要返回整篇 `artifact_document_draft`。"
|
||||
"本轮优先输出 `artifact_rewrite_patch`;也允许输出 rewrite 专用正式单条 op envelope(仅 `artifact.source.upsert / artifact.block.upsert / artifact.complete / artifact.fail`);仅兼容情况下才回退到 `artifact_ops`,不要返回整篇 `artifact_document_draft`。"
|
||||
} else {
|
||||
"本轮可以输出 `artifact_document_draft`,也可以在已有文档上输出 `artifact_ops`。"
|
||||
"本轮可以输出 `artifact_document_draft`;若做增量补充,优先输出正式单条 op envelope,兼容情况下也可回退到 `artifact_ops`。"
|
||||
};
|
||||
let shape_hint = if is_rewrite {
|
||||
"`artifact_rewrite_patch` 顶层字段优先包含:type、artifactId、targetBlockId、block\n- 可选补充 `source / sources / summary / status`\n- 若需兼容旧链路,也可回退到 `artifact_ops`\n- 若存在 target block,schema 与运行时都会限制改写范围,只允许命中该 block"
|
||||
"`artifact_rewrite_patch` 顶层字段优先包含:type、artifactId、targetBlockId、block\n- 可选补充 `source / sources / summary / status`\n- 若使用正式单条 op,仅允许 `artifact.source.upsert / artifact.block.upsert / artifact.complete / artifact.fail`,顶层字段包含:type、artifactId,以及 source / block / summary / reason 中对应字段\n- 只有兼容旧链路时才回退到 `artifact_ops`\n- 若存在 target block,schema 与运行时都会限制改写范围,只允许命中该 block"
|
||||
} else {
|
||||
"顶层字段优先包含:artifactId、kind、title、status、language、summary、blocks、sources、metadata\n- 若使用 `artifact_ops`,顶层字段包含:type、artifactId、ops"
|
||||
"顶层字段优先包含:artifactId、kind、title、status、language、summary、blocks、sources、metadata\n- 若使用正式单条 op,顶层字段包含:type、artifactId,以及 block / source / patch / blockId / summary / reason 中对应字段\n- 若需兼容旧链路,也可回退到 `artifact_ops`,其顶层字段包含:type、artifactId、ops"
|
||||
};
|
||||
let example = if is_rewrite {
|
||||
"{\n \
|
||||
"Patch 示例:\n{\n \
|
||||
\"type\": \"artifact_rewrite_patch\",\n \
|
||||
\"artifactId\": \"artifact-demo\",\n \
|
||||
\"targetBlockId\": \"body-1\",\n \
|
||||
\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"markdown\": \"改写后的正文\" },\n \
|
||||
\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"contentFormat\": \"markdown\", \"content\": \"改写后的正文\" },\n \
|
||||
\"summary\": \"把正文改成更适合董事会的措辞\"\n\
|
||||
}\n\n\
|
||||
单条 op 示例:\n{\n \
|
||||
\"type\": \"artifact.block.upsert\",\n \
|
||||
\"artifactId\": \"artifact-demo\",\n \
|
||||
\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"contentFormat\": \"markdown\", \"content\": \"改写后的正文\" }\n\
|
||||
}"
|
||||
} else {
|
||||
"{\n \
|
||||
"草稿示例:\n{\n \
|
||||
\"type\": \"artifact_document_draft\",\n \
|
||||
\"document\": {\n \
|
||||
\"schemaVersion\": \"artifact_document.v1\",\n \
|
||||
@@ -288,11 +292,18 @@ fn build_artifact_schema_hint_prompt(context: &ArtifactPromptContext) -> Option<
|
||||
\"summary\": \"一句话摘要\",\n \
|
||||
\"blocks\": [\n \
|
||||
{ \"id\": \"hero-1\", \"type\": \"hero_summary\", \"summary\": \"核心结论\" },\n \
|
||||
{ \"id\": \"body-1\", \"type\": \"rich_text\", \"markdown\": \"正文内容\" }\n \
|
||||
{ \"id\": \"body-1\", \"type\": \"rich_text\", \"contentFormat\": \"markdown\", \"content\": \"正文内容\" }\n \
|
||||
],\n \
|
||||
\"sources\": [\n \
|
||||
{ \"id\": \"source-1\", \"type\": \"web\", \"label\": \"OpenAI Blog\", \"locator\": { \"url\": \"https://openai.com\" }, \"snippet\": \"来源摘录\" }\n \
|
||||
],\n \
|
||||
\"sources\": [],\n \
|
||||
\"metadata\": {}\n \
|
||||
}\n\
|
||||
}\n\n\
|
||||
增量示例:\n{\n \
|
||||
\"type\": \"artifact.block.upsert\",\n \
|
||||
\"artifactId\": \"artifact-demo\",\n \
|
||||
\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"contentFormat\": \"markdown\", \"content\": \"补充后的正文\" }\n\
|
||||
}"
|
||||
};
|
||||
|
||||
@@ -380,6 +391,7 @@ mod tests {
|
||||
assert!(merged.contains(ARTIFACT_STAGE2_PROMPT_MARKER));
|
||||
assert!(merged.contains(ARTIFACT_SCHEMA_HINT_PROMPT_MARKER));
|
||||
assert!(merged.contains("ArtifactDocument v1"));
|
||||
assert!(merged.contains("artifact.block.upsert"));
|
||||
assert!(merged.contains("artifact_ops"));
|
||||
}
|
||||
|
||||
@@ -401,7 +413,12 @@ mod tests {
|
||||
assert!(merged.contains("block-3"));
|
||||
assert!(merged.contains("更适合董事会"));
|
||||
assert!(merged.contains("artifact_rewrite_patch"));
|
||||
assert!(merged.contains("artifact.source.upsert"));
|
||||
assert!(merged.contains("artifact.block.upsert"));
|
||||
assert!(merged.contains("artifact_ops"));
|
||||
assert!(!merged.contains("artifact.begin"));
|
||||
assert!(!merged.contains("artifact.meta.patch"));
|
||||
assert!(!merged.contains("artifact.block.remove"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -28,7 +28,6 @@ pub mod memory_import_parser_service;
|
||||
pub mod memory_profile_prompt_service;
|
||||
pub mod memory_rules_loader_service;
|
||||
pub mod memory_source_resolver_service;
|
||||
pub mod novel_service;
|
||||
pub mod openclaw_service;
|
||||
pub mod runtime_agents_template_service;
|
||||
pub mod runtime_analysis_handoff_service;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user