chore: release v1.0.1

This commit is contained in:
coso
2026-04-01 07:09:52 +08:00
parent 732cf9b390
commit 0398fc8c8e
550 changed files with 18901 additions and 47208 deletions
-1
View File
@@ -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(&params.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, &params).expect("first persist");
let persisted_second =
persist_artifact_document_from_text(&current_op, &params).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, &params).expect("first persist");
let persisted_second =
persist_artifact_document_from_text(&current_op, &params).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, &params).expect("first persist");
let persisted_second =
persist_artifact_document_from_text(&current_op, &params).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, &params).expect("first persist");
let persisted_second =
persist_artifact_document_from_text(&rewrite_patch, &params).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
+645 -124
View File
@@ -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]
-1
View File
@@ -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