merge: staging source anchors

This commit is contained in:
Keisuke Hirata 2026-07-17 04:44:20 +09:00
commit 9a1a75de48
No known key found for this signature in database
6 changed files with 307 additions and 11 deletions

View File

@ -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();

View File

@ -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]

View File

@ -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)
);
}
}

View File

@ -79,6 +79,7 @@ mod tests {
options: vec!["a".into(), "b".into()],
chosen: "a".into(),
rationale: "shorter".into(),
source_refs: Vec::new(),
}],
..Default::default()
};

View File

@ -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)

View File

@ -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;