memory: implement flat extract staging

This commit is contained in:
2026-07-18 05:46:56 +09:00
parent 444e6bee42
commit 2352f34c4e
14 changed files with 560 additions and 424 deletions
+49 -23
View File
@@ -206,7 +206,10 @@ pub fn render_tidy_hints(tidy: &TidyHints) -> String {
mod tests {
use super::*;
use crate::consolidate::tidy::{SimilarSlugCluster, SourcesOverflow};
use crate::extract::{DecisionEntry, ExtractedPayload, write_staging};
use crate::extract::{
CandidateKind, ExtractedCandidate, ExtractedPayload, STAGING_SCHEMA_VERSION,
StagingEvidence, StagingRecord, write_staging,
};
use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
use chrono::Utc;
use std::path::Path;
@@ -222,6 +225,18 @@ mod tests {
std::fs::write(p, content).unwrap();
}
fn candidate_payload(kind: CandidateKind, claim: &str) -> ExtractedPayload {
ExtractedPayload {
candidates: vec![ExtractedCandidate {
kind,
claim: claim.into(),
why_useful: "useful for consolidation".into(),
staleness: None,
evidence_ids: Vec::new(),
}],
}
}
#[test]
fn build_includes_all_sections_when_populated() {
let dir = tempfile::TempDir::new().unwrap();
@@ -238,13 +253,13 @@ mod tests {
n = now()
),
);
let (_id, _) = write_staging(
let _written = write_staging(
&layout,
SourceRef {
segment_id: "s".into(),
range: [0, 1],
},
ExtractedPayload::default(),
candidate_payload(CandidateKind::Preference, "Prefer concise tickets"),
)
.unwrap();
let staging = crate::consolidate::staging::list_staging_entries(&layout);
@@ -281,29 +296,40 @@ mod tests {
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 {
std::fs::create_dir_all(layout.staging_dir()).unwrap();
let id = uuid::Uuid::now_v7();
let record = StagingRecord {
schema_version: STAGING_SCHEMA_VERSION,
id: id.to_string(),
extract_run_id: "run-1".into(),
source: 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()
},
kind: CandidateKind::Decision,
claim: "Keep flat staging records".into(),
why_useful: "consolidation input is lossless JSON".into(),
staleness: None,
evidence: vec![StagingEvidence {
id: "ev-1".into(),
kind: EvidenceKind::new(EvidenceKind::MESSAGE),
entry_range: Some([3, 4]),
excerpt: Some("bounded excerpt".into()),
summary: Some("bounded summary".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()),
}],
};
std::fs::write(
layout.staging_dir().join(format!("{id}.json")),
serde_json::to_string_pretty(&record).unwrap(),
)
.unwrap();
let staging = crate::consolidate::staging::list_staging_entries(&layout);
+23 -7
View File
@@ -200,7 +200,7 @@ fn pid_is_alive(_pid: u32) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{ExtractedPayload, write_staging};
use crate::extract::{CandidateKind, ExtractedCandidate, ExtractedPayload, write_staging};
use crate::schema::SourceRef;
fn make_layout() -> (tempfile::TempDir, WorkspaceLayout) {
@@ -210,6 +210,18 @@ mod tests {
(dir, layout)
}
fn candidate_payload(claim: &str) -> ExtractedPayload {
ExtractedPayload {
candidates: vec![ExtractedCandidate {
kind: CandidateKind::Lesson,
claim: claim.into(),
why_useful: "useful for test".into(),
staleness: None,
evidence_ids: Vec::new(),
}],
}
}
#[test]
fn acquire_writes_lock_file() {
let (_dir, layout) = make_layout();
@@ -257,24 +269,28 @@ mod tests {
#[test]
fn release_drops_consumed_entries_and_unlinks_lock() {
let (_dir, layout) = make_layout();
let (id_a, _) = write_staging(
let id_a = write_staging(
&layout,
SourceRef {
segment_id: "s".into(),
range: [0, 0],
},
ExtractedPayload::default(),
candidate_payload("a"),
)
.unwrap();
let (id_b, _) = write_staging(
.unwrap()
.remove(0)
.id;
let id_b = write_staging(
&layout,
SourceRef {
segment_id: "s".into(),
range: [1, 1],
},
ExtractedPayload::default(),
candidate_payload("b"),
)
.unwrap();
.unwrap()
.remove(0)
.id;
let lock = StagingLock::acquire(&layout, std::process::id(), "worker", vec![id_a]).unwrap();
let lock_path = lock.path().to_path_buf();
+33 -19
View File
@@ -116,11 +116,19 @@ pub fn list_staging_entries_snapshot(layout: &WorkspaceLayout) -> StagingEntries
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{ExtractedPayload, write_staging};
use crate::extract::{CandidateKind, ExtractedCandidate, ExtractedPayload, write_staging};
use crate::schema::SourceRef;
fn empty_payload() -> ExtractedPayload {
ExtractedPayload::default()
fn candidate_payload(claim: &str) -> ExtractedPayload {
ExtractedPayload {
candidates: vec![ExtractedCandidate {
kind: CandidateKind::Lesson,
claim: claim.into(),
why_useful: "useful for later consolidation".into(),
staleness: None,
evidence_ids: Vec::new(),
}],
}
}
fn source(segment_id: &str, range: [u64; 2]) -> SourceRef {
@@ -135,9 +143,18 @@ mod tests {
let tmp = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
let (id1, _) = write_staging(&layout, source("s", [0, 1]), empty_payload()).unwrap();
let (id2, _) = write_staging(&layout, source("s", [2, 3]), empty_payload()).unwrap();
let (id3, _) = write_staging(&layout, source("s", [4, 5]), empty_payload()).unwrap();
let id1 = write_staging(&layout, source("s", [0, 1]), candidate_payload("one"))
.unwrap()
.remove(0)
.id;
let id2 = write_staging(&layout, source("s", [2, 3]), candidate_payload("two"))
.unwrap()
.remove(0)
.id;
let id3 = write_staging(&layout, source("s", [4, 5]), candidate_payload("three"))
.unwrap()
.remove(0)
.id;
let entries = list_staging_entries(&layout);
let ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
@@ -148,13 +165,15 @@ mod tests {
fn skips_lock_file_and_counts_invalid_json() {
let tmp = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
let (_id, _) = write_staging(&layout, source("s", [0, 1]), empty_payload()).unwrap();
let _id = write_staging(&layout, source("s", [0, 1]), candidate_payload("kept"))
.unwrap()
.remove(0)
.id;
// 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.
// Drop a non-UUID json file, an unparsable UUID-named json file, an
// old batch-schema 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();
@@ -174,17 +193,12 @@ mod tests {
std::fs::write(layout.staging_dir().join(".consolidation.lock"), "{}").unwrap();
let entries = list_staging_entries(&layout);
assert_eq!(entries.len(), 2);
assert_eq!(entries.len(), 1);
let snapshot = list_staging_entries_snapshot(&layout);
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")
);
assert_eq!(snapshot.entries.len(), 1);
assert_eq!(snapshot.invalid_count, 3);
assert_eq!(snapshot.entries[0].record.claim, "kept");
}
#[test]
+2 -2
View File
@@ -11,8 +11,8 @@ use llm_engine::Item;
pub fn build_extract_input(items: &[Item]) -> String {
let mut out = String::new();
out.push_str(
"Extract activity logs from the conversation slice below. \
Follow the system prompt's schema strictly and call `write_extracted` once.\n\n",
"Extract memory candidates from the conversation slice below. \
Follow the system prompt's candidate schema strictly and call `write_extracted` once.\n\n",
);
out.push_str("## Conversation slice\n");
out.push_str(&render_items(items));
+4 -3
View File
@@ -1,4 +1,4 @@
//! extract: 活動抽出。
//! extract: memory candidate extraction.
//!
//! 通常 Worker の post-run hook で発火する disposable Engine と、その
//! 出力を `<workspace>/.yoi/memory/_staging/<id>.json` に書き出す
@@ -24,10 +24,11 @@ mod tool;
pub use input::build_extract_input;
pub use payload::{
AttemptEntry, DecisionEntry, DiscussionEntry, ExtractedPayload, RequestEntry, StagingRecord,
CandidateKind, ExtractedCandidate, ExtractedPayload, STAGING_SCHEMA_VERSION, StagingEvidence,
StagingRecord,
};
pub use pointer::{ExtractPointerPayload, fold_pointer};
pub use staging::{StagingError, write_staging};
pub use staging::{StagingWriteResult, write_staging};
pub use tool::{ExtractWorkerContext, write_extracted_tool};
/// session-store `LogEntry::Extension` で使う domain 名。
+167 -211
View File
@@ -1,254 +1,210 @@
//! extract 抽出の出力 schema
//! extract staging schema.
//!
//! LLM は [`ExtractedPayload`] そのもの(record-level source 抜き)を返し、Worker 側
//! ラッパーが [`StagingRecord`] に組み立てて staging へ書き出す。
//! source は機械付与する契約 (`docs/plan/memory.md` §Extract)。
//! Extract produces memory-candidate records, not activity-log batches. During
//! the transitional `write_extracted` path the model submits an
//! [`ExtractedPayload`] containing `candidates[]`; the host expands each
//! candidate into one [`StagingRecord`] and attaches record ids, extract-run ids,
//! record-level source, evidence snippets, and source anchors mechanically.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::schema::{SourceEvidenceRef, SourceRef};
use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
/// LLM が返す活動ログ候補の集合。すべて optional(空配列は許容)。
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct ExtractedPayload {
#[serde(default)]
pub decisions: Vec<DecisionEntry>,
#[serde(default)]
pub discussions: Vec<DiscussionEntry>,
#[serde(default)]
pub attempts: Vec<AttemptEntry>,
#[serde(default)]
pub requests: Vec<RequestEntry>,
/// Current flat staging schema version.
pub const STAGING_SCHEMA_VERSION: u32 = 2;
/// Candidate kinds that extract is allowed to stage.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CandidateKind {
Preference,
WorkingAssumption,
Constraint,
Decision,
OpenQuestion,
Lesson,
}
impl ExtractedPayload {
/// すべての配列が空であれば true。空ペイロードは
/// "Nothing to save" 扱いで staging への書き込みを省いてよい。
pub fn is_empty(&self) -> bool {
self.decisions.is_empty()
&& self.discussions.is_empty()
&& self.attempts.is_empty()
&& self.requests.is_empty()
impl CandidateKind {
pub fn as_str(&self) -> &'static str {
match self {
CandidateKind::Preference => "preference",
CandidateKind::WorkingAssumption => "working_assumption",
CandidateKind::Constraint => "constraint",
CandidateKind::Decision => "decision",
CandidateKind::OpenQuestion => "open_question",
CandidateKind::Lesson => "lesson",
}
}
}
/// 判断したこと(選択肢 + 選んだ + 根拠)。
/// Model-submitted candidate before host-side staging metadata is attached.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DecisionEntry {
/// 検討された選択肢の列挙。
pub options: Vec<String>,
/// 採用された選択肢。
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>,
}
/// 議論したこと(トピック + 論点)。結論が出ていなくてもよい。
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
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>,
}
/// 試したこと(試行 + 結果 + 成否)。
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AttemptEntry {
/// 何を試したか。
pub action: String,
/// 試した結果。
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 の構造化要約。
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RequestEntry {
/// ユーザーの意図 / ゴール。
pub intent: String,
/// 対象ファイル / モジュール / 機能(任意)。
pub struct ExtractedCandidate {
/// Kind of candidate. This is intentionally a narrow taxonomy, not an
/// activity-log category.
pub kind: CandidateKind,
/// Concise candidate claim.
pub claim: String,
/// Why this may be useful for future work or consolidation.
pub why_useful: String,
/// Optional invalidation / staleness hint.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
/// 一文サマリ。
pub summary: String,
/// Host-resolved anchors backing this individual claim.
pub staleness: Option<String>,
/// Evidence ids selected by the extract worker. The transitional
/// `write_extracted` path may leave this empty until `session-explore`
/// provides host-issued evidence ids.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[schemars(skip)]
pub source_refs: Vec<SourceEvidenceRef>,
pub evidence_ids: Vec<String>,
}
/// staging に書き出される 1 ファイル分のレコード。
///
/// `source` は Worker 側ラッパーが segment_id と log entry range を
/// 機械付与する。LLM はこのフィールドを見ない / 推論しない。
/// Transitional extract output: zero or more flat candidates.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct ExtractedPayload {
#[serde(default)]
pub candidates: Vec<ExtractedCandidate>,
}
impl ExtractedPayload {
pub fn is_empty(&self) -> bool {
self.candidates.is_empty()
}
}
/// Bounded evidence snippet copied into a flat staging record.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StagingEvidence {
pub id: String,
pub kind: EvidenceKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_range: Option<[u64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub excerpt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
/// One flat staging record. One record is one consolidation decision unit.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StagingRecord {
pub schema_version: u32,
pub id: String,
pub extract_run_id: String,
pub source: SourceRef,
#[serde(flatten)]
pub payload: ExtractedPayload,
pub kind: CandidateKind,
pub claim: String,
pub why_useful: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub staleness: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<StagingEvidence>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_refs: Vec<SourceEvidenceRef>,
}
impl StagingRecord {
pub fn from_candidate(
id: impl Into<String>,
extract_run_id: impl Into<String>,
source: SourceRef,
candidate: ExtractedCandidate,
evidence: Vec<StagingEvidence>,
source_refs: Vec<SourceEvidenceRef>,
) -> Self {
Self {
schema_version: STAGING_SCHEMA_VERSION,
id: id.into(),
extract_run_id: extract_run_id.into(),
source,
kind: candidate.kind,
claim: candidate.claim,
why_useful: candidate.why_useful,
staleness: candidate.staleness,
evidence,
source_refs,
}
}
}
#[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]
}
})
fn source() -> SourceRef {
SourceRef {
segment_id: "segment-record".into(),
range: [0, 20],
}
}
#[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\""));
fn extracted_payload_empty_when_no_candidates() {
assert!(ExtractedPayload::default().is_empty());
let payload = ExtractedPayload {
candidates: vec![ExtractedCandidate {
kind: CandidateKind::Decision,
claim: "Use flat staging records".into(),
why_useful: "Consolidation can resolve candidates independently".into(),
staleness: None,
evidence_ids: Vec::new(),
}],
};
assert!(!payload.is_empty());
}
#[test]
fn new_staging_json_roundtrips_entry_source_refs() {
let evidence = SourceEvidenceRef {
session_id: Some("session-1".into()),
fn flat_staging_record_roundtrips() {
let evidence = StagingEvidence {
id: "E001".into(),
kind: EvidenceKind::new(EvidenceKind::MESSAGE),
entry_range: Some([10, 12]),
excerpt: Some("extract candidate taxonomy".into()),
summary: Some("User and assistant discussed staging kinds".into()),
};
let source_ref = SourceEvidenceRef {
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()),
evidence_id: Some("E001".into()),
evidence_kind: Some(EvidenceKind::new(EvidenceKind::MESSAGE)),
label: Some("design discussion".into()),
summary: Some("bounded host summary".into()),
..Default::default()
};
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 candidate = ExtractedCandidate {
kind: CandidateKind::Constraint,
claim: "Extract stages candidates but does not write Memory".into(),
why_useful: "Preserves extract/consolidation boundary".into(),
staleness: Some("Revisit if the execution boundary changes".into()),
evidence_ids: vec!["E001".into()],
};
let record = StagingRecord::from_candidate(
"stg-1",
"run-1",
source(),
candidate,
vec![evidence],
vec![source_ref],
);
let json = serde_json::to_string_pretty(&record).unwrap();
assert!(json.contains("\"schema_version\": 2"));
assert!(json.contains("\"kind\": \"constraint\""));
assert!(json.contains("source_refs"));
assert!(json.contains("tool_result"));
assert!(json.contains("entry_range"));
assert!(json.contains("evidence"));
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)
);
assert_eq!(parsed.schema_version, STAGING_SCHEMA_VERSION);
assert_eq!(parsed.kind, CandidateKind::Constraint);
assert_eq!(parsed.evidence[0].id, "E001");
assert_eq!(parsed.source_refs[0].evidence_id.as_deref(), Some("E001"));
}
#[test]
fn candidate_kind_serializes_as_snake_case() {
let json = serde_json::to_string(&CandidateKind::WorkingAssumption).unwrap();
assert_eq!(json, "\"working_assumption\"");
let parsed: CandidateKind = serde_json::from_str("\"open_question\"").unwrap();
assert_eq!(parsed, CandidateKind::OpenQuestion);
}
}
+88 -81
View File
@@ -1,9 +1,12 @@
//! `<workspace>/.yoi/memory/_staging/<id>.json` への書き出しヘルパー。
//! extract staging writer.
//!
//! 1 件 1 ファイル、UUIDv7 命名(短命なので衝突回避と順序を兼ねる)。
//! `source` を機械付与した [`StagingRecord`] 形式で保存する。
//! Staging is flat: one file is one candidate and one consolidation decision
//! unit. The transitional extract tool still submits `ExtractedPayload` with
//! `candidates[]`; this writer expands it into one [`StagingRecord`] per
//! candidate.
use std::fs;
use std::io;
use std::path::PathBuf;
use uuid::Uuid;
@@ -12,104 +15,108 @@ use crate::extract::payload::{ExtractedPayload, StagingRecord};
use crate::schema::SourceRef;
use crate::workspace::WorkspaceLayout;
/// staging 書き出し時のエラー。
#[derive(Debug, thiserror::Error)]
pub enum StagingError {
#[error("failed to create staging dir {}: {source}", .path.display())]
CreateDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write staging file {}: {source}", .path.display())]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to serialize staging record: {0}")]
Serialize(#[from] serde_json::Error),
/// Filesystem result for a single staged candidate.
#[derive(Debug, Clone)]
pub struct StagingWriteResult {
pub id: Uuid,
pub path: PathBuf,
}
/// `payload` を `source` で wrap して staging に書き出す。
/// Write one flat staging JSON file per extracted candidate.
///
/// 戻り値は割り当てられた staging file の (id, path)。`payload` が
/// 完全に空の場合は呼び出し側が事前に `is_empty()` で skip 推奨だが、
/// この関数は空でも正規に書き出す(仕様 §Extract で空配列許容と
/// 明記されており、書く / 書かないの判断は呼び出し側に委ねる)。
/// Returns an empty vector when `payload` has no candidates.
pub fn write_staging(
layout: &WorkspaceLayout,
source: SourceRef,
payload: ExtractedPayload,
) -> Result<(Uuid, PathBuf), StagingError> {
let staging_dir = layout.staging_dir();
fs::create_dir_all(&staging_dir).map_err(|source| StagingError::CreateDir {
path: staging_dir.clone(),
source,
})?;
) -> io::Result<Vec<StagingWriteResult>> {
if payload.candidates.is_empty() {
return Ok(Vec::new());
}
let id = Uuid::now_v7();
let path = staging_dir.join(format!("{id}.json"));
let record = StagingRecord { source, payload };
let json = serde_json::to_string_pretty(&record)?;
fs::write(&path, json).map_err(|source| StagingError::Write {
path: path.clone(),
source,
})?;
Ok((id, path))
let dir = layout.staging_dir();
fs::create_dir_all(&dir)?;
let extract_run_id = Uuid::now_v7().to_string();
let mut written = Vec::with_capacity(payload.candidates.len());
for candidate in payload.candidates {
let id = Uuid::now_v7();
let record = StagingRecord::from_candidate(
id.to_string(),
extract_run_id.clone(),
source.clone(),
candidate,
Vec::new(),
Vec::new(),
);
let path = dir.join(format!("{}.json", id));
let bytes = serde_json::to_vec_pretty(&record).map_err(io::Error::other)?;
fs::write(&path, bytes)?;
written.push(StagingWriteResult { id, path });
}
Ok(written)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::payload::{DecisionEntry, ExtractedPayload};
use crate::extract::payload::{CandidateKind, ExtractedCandidate};
#[test]
fn writes_record_with_machine_attached_source() {
let tmp = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
fn layout() -> WorkspaceLayout {
let dir = tempfile::tempdir().unwrap();
// leak tempdir for the duration of the test process; sufficient for unit tests
let path = dir.keep();
WorkspaceLayout::new(path)
}
let source = SourceRef {
segment_id: "sess-1".into(),
range: [3, 7],
};
let payload = ExtractedPayload {
decisions: vec![DecisionEntry {
options: vec!["a".into(), "b".into()],
chosen: "a".into(),
rationale: "shorter".into(),
source_refs: Vec::new(),
}],
..Default::default()
};
let (id, path) = write_staging(&layout, source.clone(), payload).unwrap();
assert_eq!(path.parent().unwrap(), layout.staging_dir());
assert!(
path.file_name()
.unwrap()
.to_string_lossy()
.contains(&id.to_string())
);
fn source() -> SourceRef {
SourceRef {
segment_id: "segment-1".into(),
range: [1, 3],
}
}
let written: StagingRecord =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(written.source.segment_id, "sess-1");
assert_eq!(written.source.range, [3, 7]);
assert_eq!(written.payload.decisions.len(), 1);
fn candidate(kind: CandidateKind, claim: &str) -> ExtractedCandidate {
ExtractedCandidate {
kind,
claim: claim.into(),
why_useful: "useful for future consolidation".into(),
staleness: None,
evidence_ids: Vec::new(),
}
}
#[test]
fn empty_payload_is_written_verbatim() {
let tmp = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
let source = SourceRef {
segment_id: "sess".into(),
range: [0, 0],
fn writes_one_file_per_candidate() {
let layout = layout();
let payload = ExtractedPayload {
candidates: vec![
candidate(CandidateKind::Preference, "Prefer implementation tickets"),
candidate(CandidateKind::Decision, "Use flat staging records"),
],
};
let (_, path) = write_staging(&layout, source, ExtractedPayload::default()).unwrap();
let written: StagingRecord =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(written.payload.is_empty());
let results = write_staging(&layout, source(), payload).unwrap();
assert_eq!(results.len(), 2);
assert_ne!(results[0].id, results[1].id);
let first = fs::read_to_string(&results[0].path).unwrap();
let second = fs::read_to_string(&results[1].path).unwrap();
let first_record: StagingRecord = serde_json::from_str(&first).unwrap();
let second_record: StagingRecord = serde_json::from_str(&second).unwrap();
assert_eq!(first_record.kind, CandidateKind::Preference);
assert_eq!(second_record.kind, CandidateKind::Decision);
assert_eq!(first_record.extract_run_id, second_record.extract_run_id);
assert_eq!(first_record.source.segment_id, "segment-1");
}
#[test]
fn empty_payload_writes_nothing() {
let layout = layout();
let results = write_staging(&layout, source(), ExtractedPayload::default()).unwrap();
assert!(results.is_empty());
assert!(!layout.staging_dir().exists());
}
}
+20 -27
View File
@@ -12,10 +12,10 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use crate::extract::payload::ExtractedPayload;
const WRITE_EXTRACTED_DESCRIPTION: &str = "Submit the final activity-log JSON for this slice. \
Pass an object with `decisions`, `discussions`, `attempts`, and `requests` arrays (any may be empty). \
Call this exactly once and end the turn. Do not include `source`, session metadata, or free-form prose — \
the wrapper attaches provenance mechanically.";
const WRITE_EXTRACTED_DESCRIPTION: &str = "Submit extracted memory-candidate JSON for this slice. \
Pass an object with a `candidates` array. Each candidate must have `kind`, `claim`, and `why_useful`; \
`staleness` and `evidence_ids` are optional. Call this exactly once and end the turn. Do not include \
record ids, source anchors, session metadata, or free-form prose — the wrapper attaches staging metadata mechanically.";
/// extract sub-Engine の出力受け口。`ExtractedPayload` 1 件をホストする。
#[derive(Debug, Default)]
@@ -63,11 +63,8 @@ impl Tool for WriteExtractedTool {
ToolError::InvalidArgument(format!("invalid write_extracted input: {e}"))
})?;
let summary = format!(
"Recorded activity log: decisions={} discussions={} attempts={} requests={}",
payload.decisions.len(),
payload.discussions.len(),
payload.attempts.len(),
payload.requests.len(),
"Recorded memory candidates: candidates={}",
payload.candidates.len(),
);
{
let mut guard = self
@@ -116,20 +113,17 @@ mod tests {
let ctx = Arc::new(ExtractWorkerContext::new());
let tool: Arc<dyn Tool> = Arc::new(WriteExtractedTool { ctx: ctx.clone() });
let input = serde_json::json!({
"decisions": [{
"options": ["a", "b"],
"chosen": "a",
"rationale": "test"
}],
"discussions": [],
"attempts": [],
"requests": []
"candidates": [{
"kind": "decision",
"claim": "Use flat staging",
"why_useful": "Consolidation can resolve candidates independently"
}]
})
.to_string();
let out = tool.execute(&input, Default::default()).await.unwrap();
assert!(out.summary.contains("decisions=1"));
assert!(out.summary.contains("candidates=1"));
let payload = ctx.take_payload().unwrap();
assert_eq!(payload.decisions.len(), 1);
assert_eq!(payload.candidates.len(), 1);
assert_eq!(ctx.call_count(), 1);
}
@@ -138,22 +132,21 @@ mod tests {
let ctx = Arc::new(ExtractWorkerContext::new());
let tool: Arc<dyn Tool> = Arc::new(WriteExtractedTool { ctx: ctx.clone() });
let first =
serde_json::json!({"decisions": [], "discussions": [], "attempts": [], "requests": []})
.to_string();
let first = serde_json::json!({"candidates": []}).to_string();
tool.execute(&first, Default::default()).await.unwrap();
let second = serde_json::json!({
"decisions": [],
"discussions": [],
"attempts": [{"action": "x", "result": "ok", "succeeded": true}],
"requests": []
"candidates": [{
"kind": "lesson",
"claim": "Validation should use Nix build",
"why_useful": "Packaging can fail independently"
}]
})
.to_string();
tool.execute(&second, Default::default()).await.unwrap();
let payload = ctx.take_payload().unwrap();
assert_eq!(payload.attempts.len(), 1);
assert_eq!(payload.candidates.len(), 1);
assert_eq!(ctx.call_count(), 2);
}