feat: add staging entry source anchors
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
//! extract 抽出の出力 schema。
|
||||
//!
|
||||
//! LLM は [`ExtractedPayload`] そのもの(source 抜き)を返し、Worker 側
|
||||
//! LLM は [`ExtractedPayload`] そのもの(record-level source 抜き)を返し、Worker 側
|
||||
//! ラッパーが [`StagingRecord`] に組み立てて staging へ書き出す。
|
||||
//! source は機械付与する契約 (`docs/plan/memory.md` §Extract)。
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::schema::SourceRef;
|
||||
use crate::schema::{SourceEvidenceRef, SourceRef};
|
||||
|
||||
/// LLM が返す活動ログ候補の集合。すべて optional(空配列は許容)。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -42,6 +42,10 @@ pub struct DecisionEntry {
|
||||
pub chosen: String,
|
||||
/// 採用理由 / 根拠。
|
||||
pub rationale: String,
|
||||
/// Host-resolved anchors backing this individual claim.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[schemars(skip)]
|
||||
pub source_refs: Vec<SourceEvidenceRef>,
|
||||
}
|
||||
|
||||
/// 議論したこと(トピック + 論点)。結論が出ていなくてもよい。
|
||||
@@ -51,6 +55,10 @@ pub struct DiscussionEntry {
|
||||
pub topic: String,
|
||||
/// 主題の中で挙がった論点 / 観点。
|
||||
pub points: Vec<String>,
|
||||
/// Host-resolved anchors backing this individual claim.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[schemars(skip)]
|
||||
pub source_refs: Vec<SourceEvidenceRef>,
|
||||
}
|
||||
|
||||
/// 試したこと(試行 + 結果 + 成否)。
|
||||
@@ -62,6 +70,10 @@ pub struct AttemptEntry {
|
||||
pub result: String,
|
||||
/// 試行が目的に対して成功したか。失敗 / 部分成功も含めて bool で表現する。
|
||||
pub succeeded: bool,
|
||||
/// Host-resolved anchors backing this individual claim.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[schemars(skip)]
|
||||
pub source_refs: Vec<SourceEvidenceRef>,
|
||||
}
|
||||
|
||||
/// ユーザー submit の構造化要約。
|
||||
@@ -74,6 +86,10 @@ pub struct RequestEntry {
|
||||
pub target: Option<String>,
|
||||
/// 一文サマリ。
|
||||
pub summary: String,
|
||||
/// Host-resolved anchors backing this individual claim.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[schemars(skip)]
|
||||
pub source_refs: Vec<SourceEvidenceRef>,
|
||||
}
|
||||
|
||||
/// staging に書き出される 1 ファイル分のレコード。
|
||||
@@ -86,3 +102,153 @@ pub struct StagingRecord {
|
||||
#[serde(flatten)]
|
||||
pub payload: ExtractedPayload,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::schema::{EvidenceKind, SourceEvidenceRef};
|
||||
|
||||
fn record_source() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"source": {
|
||||
"segment_id": "seg-old",
|
||||
"range": [1, 5]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_staging_json_without_entry_source_refs_deserializes() {
|
||||
let mut raw = record_source();
|
||||
raw["decisions"] = serde_json::json!([
|
||||
{
|
||||
"options": ["keep", "drop"],
|
||||
"chosen": "keep",
|
||||
"rationale": "compatible"
|
||||
}
|
||||
]);
|
||||
raw["discussions"] = serde_json::json!([
|
||||
{
|
||||
"topic": "compatibility",
|
||||
"points": ["missing source_refs should default empty"]
|
||||
}
|
||||
]);
|
||||
raw["attempts"] = serde_json::json!([
|
||||
{
|
||||
"action": "deserialize old staging",
|
||||
"result": "ok",
|
||||
"succeeded": true
|
||||
}
|
||||
]);
|
||||
raw["requests"] = serde_json::json!([
|
||||
{
|
||||
"intent": "preserve old JSON",
|
||||
"summary": "old payload has no entry anchors"
|
||||
}
|
||||
]);
|
||||
|
||||
let record: StagingRecord = serde_json::from_value(raw).unwrap();
|
||||
|
||||
assert_eq!(record.source.segment_id, "seg-old");
|
||||
assert!(record.payload.decisions[0].source_refs.is_empty());
|
||||
assert!(record.payload.discussions[0].source_refs.is_empty());
|
||||
assert!(record.payload.attempts[0].source_refs.is_empty());
|
||||
assert!(record.payload.requests[0].source_refs.is_empty());
|
||||
let serialized = serde_json::to_string(&record).unwrap();
|
||||
assert!(!serialized.contains("\"source_refs\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_staging_json_roundtrips_entry_source_refs() {
|
||||
let evidence = SourceEvidenceRef {
|
||||
session_id: Some("session-1".into()),
|
||||
segment_id: Some("segment-1".into()),
|
||||
entry_range: Some([10, 12]),
|
||||
evidence_id: Some("ev-1".into()),
|
||||
evidence_kind: Some(EvidenceKind::new(EvidenceKind::TOOL_RESULT)),
|
||||
label: Some("cargo test result".into()),
|
||||
summary: Some("bounded host summary".into()),
|
||||
};
|
||||
let record = StagingRecord {
|
||||
source: SourceRef {
|
||||
segment_id: "segment-record".into(),
|
||||
range: [0, 20],
|
||||
},
|
||||
payload: ExtractedPayload {
|
||||
decisions: vec![DecisionEntry {
|
||||
options: vec!["a".into(), "b".into()],
|
||||
chosen: "a".into(),
|
||||
rationale: "evidence-backed".into(),
|
||||
source_refs: vec![evidence.clone()],
|
||||
}],
|
||||
discussions: vec![DiscussionEntry {
|
||||
topic: "anchor shape".into(),
|
||||
points: vec!["entry refs roundtrip".into()],
|
||||
source_refs: vec![SourceEvidenceRef {
|
||||
evidence_kind: Some(EvidenceKind::new(EvidenceKind::MESSAGE)),
|
||||
..Default::default()
|
||||
}],
|
||||
}],
|
||||
attempts: vec![AttemptEntry {
|
||||
action: "serialize".into(),
|
||||
result: "contains source refs".into(),
|
||||
succeeded: true,
|
||||
source_refs: vec![SourceEvidenceRef {
|
||||
evidence_kind: Some(EvidenceKind::new(EvidenceKind::FILE_REF)),
|
||||
evidence_id: Some("file:crates/memory/src/extract/payload.rs".into()),
|
||||
..Default::default()
|
||||
}],
|
||||
}],
|
||||
requests: vec![RequestEntry {
|
||||
intent: "keep anchors".into(),
|
||||
target: Some("memory staging".into()),
|
||||
summary: "entry-level anchors survive".into(),
|
||||
source_refs: vec![SourceEvidenceRef {
|
||||
evidence_kind: Some(EvidenceKind::new(EvidenceKind::TICKET_REF)),
|
||||
evidence_id: Some("00001KXNYXNM6".into()),
|
||||
..Default::default()
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&record).unwrap();
|
||||
assert!(json.contains("source_refs"));
|
||||
assert!(json.contains("tool_result"));
|
||||
assert!(json.contains("entry_range"));
|
||||
let parsed: StagingRecord = serde_json::from_str(&json).unwrap();
|
||||
|
||||
let source_ref = &parsed.payload.decisions[0].source_refs[0];
|
||||
assert_eq!(source_ref.session_id.as_deref(), Some("session-1"));
|
||||
assert_eq!(source_ref.segment_id.as_deref(), Some("segment-1"));
|
||||
assert_eq!(source_ref.entry_range, Some([10, 12]));
|
||||
assert_eq!(source_ref.evidence_id.as_deref(), Some("ev-1"));
|
||||
assert_eq!(
|
||||
source_ref.evidence_kind.as_ref().map(EvidenceKind::as_str),
|
||||
Some(EvidenceKind::TOOL_RESULT)
|
||||
);
|
||||
assert_eq!(source_ref.label.as_deref(), Some("cargo test result"));
|
||||
assert_eq!(source_ref.summary.as_deref(), Some("bounded host summary"));
|
||||
assert_eq!(
|
||||
parsed.payload.discussions[0].source_refs[0]
|
||||
.evidence_kind
|
||||
.as_ref()
|
||||
.map(EvidenceKind::as_str),
|
||||
Some(EvidenceKind::MESSAGE)
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.payload.attempts[0].source_refs[0]
|
||||
.evidence_kind
|
||||
.as_ref()
|
||||
.map(EvidenceKind::as_str),
|
||||
Some(EvidenceKind::FILE_REF)
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.payload.requests[0].source_refs[0]
|
||||
.evidence_kind
|
||||
.as_ref()
|
||||
.map(EvidenceKind::as_str),
|
||||
Some(EvidenceKind::TICKET_REF)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ mod tests {
|
||||
options: vec!["a".into(), "b".into()],
|
||||
chosen: "a".into(),
|
||||
rationale: "shorter".into(),
|
||||
source_refs: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user