Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions docs/spec/system_elf_memory_service_v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -926,15 +926,55 @@ Body:
"importance": 0.0,
"confidence": 0.0,
"ttl_days": 180,
"structured": {
"summary": "string|null",
"facts": "string[]|null",
"concepts": "string[]|null",
"entities": [
{
"canonical": "string|null",
"kind": "string|null",
"aliases": "string[]|null"
}
]|null,
"relations": [
{
"subject": {
"canonical": "string|null",
"kind": "string|null",
"aliases": "string[]|null"
},
"predicate": "string",
"object": {
"entity": {
"canonical": "string|null",
"kind": "string|null",
"aliases": "string[]|null"
}|null,
"value": "string|null"
},
"valid_from": "ISO8601 datetime|null",
"valid_to": "ISO8601 datetime|null"
}
]|null
}|null,
"source_ref": { ... }
}
]
}

Notes:
- Exactly one of object.entity and object.value must be non-null.

Response:
{
"results": [
{ "note_id": "uuid|null", "op": "ADD|UPDATE|NONE|DELETE|REJECTED", "reason_code": "optional" }
{
"note_id": "uuid|null",
"op": "ADD|UPDATE|NONE|DELETE|REJECTED",
"reason_code": "optional",
"field_path": "optional"
}
]
}

Expand All @@ -959,7 +999,13 @@ Response:
{
"extracted": { ...extractor output... },
"results": [
{ "note_id": "uuid|null", "op": "ADD|UPDATE|NONE|DELETE|REJECTED", "reason_code": "optional", "reason": "optional" }
{
"note_id": "uuid|null",
"op": "ADD|UPDATE|NONE|DELETE|REJECTED",
"reason_code": "optional",
"reason": "optional",
"field_path": "optional"
}
]
}

Expand Down Expand Up @@ -1187,6 +1233,38 @@ Schema:
"importance": 0.0,
"confidence": 0.0,
"ttl_days": number|null,
"structured": {
"summary": "string|null",
"facts": "string[]|null",
"concepts": "string[]|null",
"entities": [
{
"canonical": "string|null",
"kind": "string|null",
"aliases": "string[]|null"
}
]|null,
"relations": [
{
"subject": {
"canonical": "string|null",
"kind": "string|null",
"aliases": "string[]|null"
},
"predicate": "string",
"object": {
"entity": {
"canonical": "string|null",
"kind": "string|null",
"aliases": "string[]|null"
}|null,
"value": "string|null"
},
"valid_from": "ISO8601 datetime|null",
"valid_to": "ISO8601 datetime|null"
}
]|null
}|null,
"scope_suggestion": "agent_private|project_shared|org_shared|null",
"evidence": [
{ "message_index": number, "quote": "string" }
Expand All @@ -1196,6 +1274,9 @@ Schema:
]
}

Notes:
- Exactly one of object.entity and object.value must be non-null.

Hard rules:
- notes.length <= MAX_NOTES
- text must contain no CJK
Expand Down
1 change: 1 addition & 0 deletions docs/spec/system_graph_memory_postgres_v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Indexes:
- lowercase
- An active fact is defined by: `valid_from <= now AND (valid_to IS NULL OR valid_to > now)`.
- Active duplicate prevention is enforced by partial unique indexes.
- When ingestion reintroduces a note equivalent to an existing active fact, the system reuses the existing fact row and appends additional evidence rows for the new note instead of creating another active duplicate fact row.

============================================================
5. CALL EXAMPLES
Expand Down
118 changes: 116 additions & 2 deletions packages/elf-service/src/add_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub struct AddEventResult {
pub op: NoteOp,
pub reason_code: Option<String>,
pub reason: Option<String>,
pub field_path: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
Expand Down Expand Up @@ -206,14 +207,24 @@ impl ElfService {
let (note_id, op) = match decision {
UpdateDecision::Add { note_id } => (Some(note_id), NoteOp::Add),
UpdateDecision::Update { note_id } => (Some(note_id), NoteOp::Update),
UpdateDecision::None { note_id } => (Some(note_id), NoteOp::None),
UpdateDecision::None { note_id } => {
let op = if structured.as_ref().is_some_and(StructuredFields::has_graph_fields)
{
NoteOp::Update
} else {
NoteOp::None
};

(Some(note_id), op)
},
};

return Ok(AddEventResult {
note_id,
op,
reason_code: None,
reason: note.reason.clone(),
field_path: None,
});
}

Expand Down Expand Up @@ -317,11 +328,28 @@ impl ElfService {

upsert_structured_fields_tx(tx, args.structured, memory_note.note_id, args.now).await?;

if let Some(structured) = args.structured
&& structured.has_graph_fields()
{
crate::graph_ingestion::persist_graph_fields_tx(
tx,
args.req.tenant_id.as_str(),
args.req.project_id.as_str(),
args.req.agent_id.as_str(),
args.scope,
memory_note.note_id,
structured,
args.now,
)
.await?;
}

Ok(AddEventResult {
note_id: Some(note_id),
op: NoteOp::Add,
reason_code: None,
reason: args.reason.cloned(),
field_path: None,
})
}

Expand Down Expand Up @@ -373,11 +401,28 @@ impl ElfService {

upsert_structured_fields_tx(tx, args.structured, existing.note_id, args.now).await?;

if let Some(structured) = args.structured
&& structured.has_graph_fields()
{
crate::graph_ingestion::persist_graph_fields_tx(
tx,
args.req.tenant_id.as_str(),
args.req.project_id.as_str(),
args.req.agent_id.as_str(),
args.scope,
existing.note_id,
structured,
args.now,
)
.await?;
}

Ok(AddEventResult {
note_id: Some(note_id),
op: NoteOp::Update,
reason_code: None,
reason: args.reason.cloned(),
field_path: None,
})
}

Expand All @@ -387,6 +432,8 @@ impl ElfService {
args: PersistExtractedNoteArgs<'_>,
note_id: Uuid,
) -> Result<AddEventResult> {
let mut did_update = false;

if let Some(structured) = args.structured
&& !structured.is_effectively_empty()
{
Expand All @@ -397,11 +444,33 @@ impl ElfService {
crate::enqueue_outbox_tx(&mut **tx, note_id, "UPSERT", args.embed_version, args.now)
.await?;

did_update = true;
}
if let Some(structured) = args.structured
&& structured.has_graph_fields()
Comment on lines +449 to +450

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate graph-only structured notes before persisting

This new persistence path runs for any extracted note with entities/relations, but reject_extracted_note_if_structured_invalid still skips validation when structured.is_effectively_empty() is true (and that helper only considers summary/facts/concepts). In the add_event flow, a graph-only note can therefore bypass evidence-binding checks and write unsupported relations to graph_facts instead of being rejected with REJECT_STRUCTURED_INVALID, which corrupts graph quality whenever the extractor emits only graph fields.

Useful? React with 👍 / 👎.

{
crate::graph_ingestion::persist_graph_fields_tx(
tx,
args.req.tenant_id.as_str(),
args.req.project_id.as_str(),
args.req.agent_id.as_str(),
args.scope,
note_id,
structured,
args.now,
)
.await?;

did_update = true;
}

if did_update {
return Ok(AddEventResult {
note_id: Some(note_id),
op: NoteOp::Update,
reason_code: None,
reason: args.reason.cloned(),
field_path: None,
});
}

Expand All @@ -410,6 +479,7 @@ impl ElfService {
op: NoteOp::None,
reason_code: None,
reason: args.reason.cloned(),
field_path: None,
})
}
}
Expand Down Expand Up @@ -459,6 +529,7 @@ fn reject_extracted_note_if_evidence_invalid(
op: NoteOp::Rejected,
reason_code: Some(REJECT_EVIDENCE_MISMATCH.to_string()),
reason: reason.cloned(),
field_path: None,
});
}

Expand All @@ -469,6 +540,7 @@ fn reject_extracted_note_if_evidence_invalid(
op: NoteOp::Rejected,
reason_code: Some(REJECT_EVIDENCE_MISMATCH.to_string()),
reason: reason.cloned(),
field_path: None,
});
}
if !evidence::evidence_matches(message_texts, quote.message_index, quote.quote.as_str()) {
Expand All @@ -477,6 +549,7 @@ fn reject_extracted_note_if_evidence_invalid(
op: NoteOp::Rejected,
reason_code: Some(REJECT_EVIDENCE_MISMATCH.to_string()),
reason: reason.cloned(),
field_path: None,
});
}
}
Expand Down Expand Up @@ -507,11 +580,14 @@ fn reject_extracted_note_if_structured_invalid(
) {
tracing::info!(error = %err, "Rejecting extracted note due to invalid structured fields.");

let field_path = extract_structured_rejection_field_path(&err);

return Some(AddEventResult {
note_id: None,
op: NoteOp::Rejected,
reason_code: Some(REJECT_STRUCTURED_INVALID.to_string()),
reason: reason.cloned(),
field_path,
});
}

Expand All @@ -537,12 +613,22 @@ fn reject_extracted_note_if_writegate_rejects(
op: NoteOp::Rejected,
reason_code: Some(crate::writegate_reason_code(code).to_string()),
reason: reason.cloned(),
field_path: None,
});
}

None
}

fn extract_structured_rejection_field_path(err: &Error) -> Option<String> {
match err {
Error::NonEnglishInput { field } => Some(field.clone()),
Error::InvalidRequest { message } if message.starts_with("structured.") =>
message.split_whitespace().next().map(ToString::to_string),
_ => None,
}
}

fn build_extractor_messages(
messages: &[EventMessage],
max_notes: u32,
Expand All @@ -557,7 +643,34 @@ fn build_extractor_messages(
"structured": {
"summary": "string|null",
"facts": "string[]|null",
"concepts": "string[]|null"
"concepts": "string[]|null",
"entities": [
{
"canonical": "string|null",
"kind": "string|null",
"aliases": "string[]|null"
}
],
"relations": [
{
"subject": {
"canonical": "string|null",
"kind": "string|null",
"aliases": "string[]|null"
},
"predicate": "string",
"object": {
"entity": {
"canonical": "string|null",
"kind": "string|null",
"aliases": "string[]|null"
},
"value": "string|null"
},
"valid_from": "string|null",
"valid_to": "string|null"
}
]
},
"importance": 0.0,
"confidence": 0.0,
Expand All @@ -575,6 +688,7 @@ Output must be valid JSON only and must match the provided schema exactly. \
Extract at most MAX_NOTES high-signal, cross-session reusable memory notes from the given messages. \
Each note must be one English sentence and must not contain any CJK characters. \
The structured field is optional. If present, summary must be short, facts must be short sentences supported by the evidence quotes, and concepts must be short phrases. \
structured.entities and structured.relations should mirror the structured schema with optional entity and relation metadata and relation timestamps. \
Preserve numbers, dates, percentages, currency amounts, tickers, URLs, and code snippets exactly. \
Never store secrets or PII: API keys, tokens, private keys, seed phrases, passwords, bank IDs, personal addresses. \
For every note, provide 1 to 2 evidence quotes copied verbatim from the input messages and include the message_index. \
Expand Down
Loading
Loading