merge: staging source anchors
This commit is contained in:
commit
9a1a75de48
|
|
@ -206,8 +206,8 @@ pub fn render_tidy_hints(tidy: &TidyHints) -> String {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::consolidate::tidy::{SimilarSlugCluster, SourcesOverflow};
|
||||
use crate::extract::{ExtractedPayload, write_staging};
|
||||
use crate::schema::SourceRef;
|
||||
use crate::extract::{DecisionEntry, ExtractedPayload, write_staging};
|
||||
use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
|
||||
|
|
@ -277,6 +277,46 @@ mod tests {
|
|||
assert!(out.contains("no explicit memory usage events"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_render_preserves_entry_source_refs() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
let (_id, _) = write_staging(
|
||||
&layout,
|
||||
SourceRef {
|
||||
segment_id: "segment-record".into(),
|
||||
range: [0, 10],
|
||||
},
|
||||
ExtractedPayload {
|
||||
decisions: vec![DecisionEntry {
|
||||
options: vec!["preserve".into()],
|
||||
chosen: "preserve".into(),
|
||||
rationale: "consolidation input is lossless JSON".into(),
|
||||
source_refs: vec![SourceEvidenceRef {
|
||||
session_id: Some("session-1".into()),
|
||||
segment_id: Some("segment-1".into()),
|
||||
entry_range: Some([3, 4]),
|
||||
evidence_id: Some("ev-1".into()),
|
||||
evidence_kind: Some(EvidenceKind::new(EvidenceKind::MESSAGE)),
|
||||
label: Some("user message".into()),
|
||||
summary: Some("bounded summary".into()),
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let staging = crate::consolidate::staging::list_staging_entries(&layout);
|
||||
|
||||
let out = render_staging_records(&staging);
|
||||
|
||||
assert!(out.contains("source_refs"));
|
||||
assert!(out.contains("session-1"));
|
||||
assert!(out.contains("entry_range"));
|
||||
assert!(out.contains("ev-1"));
|
||||
assert!(out.contains("message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_inputs_render_placeholders() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
|
|
|||
|
|
@ -150,10 +150,11 @@ mod tests {
|
|||
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
|
||||
let (_id, _) = write_staging(&layout, source("s", [0, 1]), empty_payload()).unwrap();
|
||||
|
||||
// Drop a non-UUID json file, an unparsable UUID-named json file, an
|
||||
// old-schema UUID-named json file, and a bare lock file alongside.
|
||||
// Drop a non-UUID json file, an unparsable UUID-named json file, a
|
||||
// legacy source UUID-named json file, and a bare lock file alongside.
|
||||
// Lock files are not `.json`; invalid `.json` files are surfaced
|
||||
// separately instead of being mistaken for an empty staging directory.
|
||||
// Legacy `source.session_id` staging remains readable for compatibility.
|
||||
std::fs::write(layout.staging_dir().join("not-a-uuid.json"), "{}").unwrap();
|
||||
let bad_id = Uuid::now_v7();
|
||||
std::fs::write(layout.staging_dir().join(format!("{bad_id}.json")), "{").unwrap();
|
||||
|
|
@ -173,11 +174,17 @@ mod tests {
|
|||
std::fs::write(layout.staging_dir().join(".consolidation.lock"), "{}").unwrap();
|
||||
|
||||
let entries = list_staging_entries(&layout);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries.len(), 2);
|
||||
|
||||
let snapshot = list_staging_entries_snapshot(&layout);
|
||||
assert_eq!(snapshot.entries.len(), 1);
|
||||
assert_eq!(snapshot.invalid_count, 3);
|
||||
assert_eq!(snapshot.entries.len(), 2);
|
||||
assert_eq!(snapshot.invalid_count, 2);
|
||||
assert!(
|
||||
snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.record.source.segment_id == "legacy-session")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Common frontmatter helpers and shared types.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::LintError;
|
||||
|
|
@ -8,13 +9,94 @@ pub use lint_common::Frontmatter;
|
|||
|
||||
/// Reference to a session-store entry range. Stored in `sources` /
|
||||
/// `last_sources` arrays for traceability back to raw session logs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct SourceRef {
|
||||
pub segment_id: String,
|
||||
/// `[start_entry, end_entry]` inclusive range of session-store entry indices.
|
||||
pub range: [u64; 2],
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SourceRef {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
struct RawSourceRef {
|
||||
#[serde(default)]
|
||||
segment_id: Option<String>,
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
range: [u64; 2],
|
||||
}
|
||||
|
||||
let raw = RawSourceRef::deserialize(deserializer)?;
|
||||
let segment_id = raw
|
||||
.segment_id
|
||||
.or(raw.session_id)
|
||||
.ok_or_else(|| serde::de::Error::missing_field("segment_id"))?;
|
||||
Ok(SourceRef {
|
||||
segment_id,
|
||||
range: raw.range,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensible evidence kind tag used by staging source anchors.
|
||||
///
|
||||
/// Known values include `message`, `tool_call`, `tool_result`, `file_ref`,
|
||||
/// `ticket_ref`, and `objective_ref`, but callers may use newer bounded tags
|
||||
/// without changing the schema.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct EvidenceKind(pub String);
|
||||
|
||||
impl EvidenceKind {
|
||||
pub const MESSAGE: &'static str = "message";
|
||||
pub const TOOL_CALL: &'static str = "tool_call";
|
||||
pub const TOOL_RESULT: &'static str = "tool_result";
|
||||
pub const FILE_REF: &'static str = "file_ref";
|
||||
pub const TICKET_REF: &'static str = "ticket_ref";
|
||||
pub const OBJECTIVE_REF: &'static str = "objective_ref";
|
||||
|
||||
pub fn new(kind: impl Into<String>) -> Self {
|
||||
Self(kind.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Host-resolved source/evidence metadata for an individual staging claim.
|
||||
///
|
||||
/// This deliberately stores only bounded anchor metadata: stable ids, entry
|
||||
/// ranges, and short labels/summaries. It must not carry raw message bodies or
|
||||
/// full tool result content.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SourceEvidenceRef {
|
||||
/// Stable session id when the anchor crosses or disambiguates segments.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
/// Session segment id containing the anchored log entries.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub segment_id: Option<String>,
|
||||
/// `[start_entry, end_entry]` inclusive range of session-store entry indices.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub entry_range: Option<[u64; 2]>,
|
||||
/// Host-assigned evidence id within the referenced evidence set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub evidence_id: Option<String>,
|
||||
/// Extensible evidence kind tag.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub evidence_kind: Option<EvidenceKind>,
|
||||
/// Short host-provided display label, not raw evidence content.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
/// Short host-provided summary, not raw evidence content.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
/// Split a markdown document into `(yaml_frontmatter, body)`.
|
||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), LintError> {
|
||||
lint_common::split_frontmatter(content).map_err(Into::into)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ mod decision;
|
|||
mod request;
|
||||
mod summary;
|
||||
|
||||
pub use common::{Frontmatter, SourceRef, split_frontmatter};
|
||||
pub use common::{EvidenceKind, Frontmatter, SourceEvidenceRef, SourceRef, split_frontmatter};
|
||||
pub use decision::{DecisionFrontmatter, DecisionStatus};
|
||||
pub use request::RequestFrontmatter;
|
||||
pub use summary::SummaryFrontmatter;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user