Skip to content

feat(cognis): FactExtractor — Runnable<FactExtractionInput, Vec<Fact>> for atomic fact extraction #40

Description

@0xvasanth

Summary

Add a FactExtractor — a Runnable<FactExtractionInput, Vec<Fact>> that distills raw agent output into atomic, reusable facts using a Cognis LLM client and StructuredOutputParser. This is the write-time extraction step that memory systems need before storing agent observations.

Motivation

Any application building long-term memory from agent output needs to reduce verbose text into atomic facts before storing. Without this step, you either store raw text (noisy retrieval) or build the extraction logic yourself in every application. This is equivalent to what mem0 and Zep provide as their core managed service. Building it once in Cognis gives all framework users the capability for free, and prompt quality improves once for everyone.

API Design

// crates/cognis/src/agent/fact_extractor.rs

use cognis_core::runnable::{Runnable, RunnableConfig};
use cognis_core::output_parsers::StructuredOutputParser;
use cognis_llm::Client;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FactKind {
    Rule,        // standing instruction that must always be respected
    Preference,  // softer guideline, follow unless there is a reason not to
    Context,     // situational information that informs decisions
    Decision,    // past choice with rationale (informative, not prescriptive)
    Observation, // ongoing state worth being aware of
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fact {
    pub content: String,   // self-contained atomic statement
    pub kind: FactKind,
    pub importance: f32,   // 0.0 – 1.0; caller uses for importance scoring on store
}

#[derive(Debug, Clone)]
pub struct FactExtractionInput {
    pub text: String,
    pub context_hints: Vec<String>, // e.g. ["project: stev API", "goal: billing v2"]
    pub max_facts: usize,           // default: 7
}

impl Default for FactExtractionInput {
    fn default() -> Self {
        Self { text: String::new(), context_hints: vec![], max_facts: 7 }
    }
}

pub struct FactExtractor {
    client: Arc<Client>,
    prompt_template: String,  // overridable via builder
}

pub struct FactExtractorBuilder {
    client: Arc<Client>,
    prompt_template: Option<String>,
    model: Option<String>,
}

impl FactExtractorBuilder {
    pub fn new(client: Arc<Client>) -> Self { ... }
    pub fn prompt_template(mut self, tpl: impl Into<String>) -> Self { ... }
    pub fn model(mut self, model: impl Into<String>) -> Self { ... }
    pub fn build(self) -> FactExtractor { ... }
}

impl Runnable<FactExtractionInput, Vec<Fact>> for FactExtractor {
    async fn invoke(
        &self,
        input: FactExtractionInput,
        config: RunnableConfig,
    ) -> cognis_core::error::Result<Vec<Fact>>;
}

Default Extraction Prompt

The built-in prompt should produce high-quality, actionable facts:

You are extracting reusable memory facts from an AI agent's completed work.

{context_section}

Agent output:
---
{text}
---

Extract up to {max_facts} atomic facts that would be useful for future agents working
on similar tasks. Each fact must be:
- Self-contained (understandable without this output)
- Specific (not "used best practices" — name which ones)
- Actionable (tells a future agent something concrete)

Classify each fact as one of:
- "rule"        — a standing decision that must always be followed
- "preference"  — a softer guideline; follow unless there is a reason not to
- "context"     — situational information that informs decisions
- "decision"    — a past choice with rationale (informative, not prescriptive for future)
- "observation" — an ongoing state worth being aware of

Return a JSON array only, no prose:
[
  { "content": "...", "kind": "rule|preference|context|decision|observation", "importance": 0.0–1.0 }
]

If the output contains no useful facts, return an empty array: []

Implementation Notes

  • Use StructuredOutputParser<Vec<Fact>> from cognis-core to parse the LLM response
  • If parsing fails, log a warning and return an empty Vec — never propagate extraction failures to the caller
  • context_section is rendered as "Context:\n- {hint}\n- {hint}" when hints are non-empty, omitted entirely when empty
  • The prompt template should be overridable via FactExtractorBuilder::prompt_template for callers who need domain-specific extraction behavior

Module Location

crates/cognis/src/agent/fact_extractor.rs

Re-export from crates/cognis/src/agent/mod.rs:

pub mod fact_extractor;
pub use fact_extractor::{Fact, FactExtractor, FactExtractorBuilder, FactExtractionInput, FactKind};

Dependencies

  • cognis-core: Runnable, StructuredOutputParser, ChatPromptTemplate (all existing)
  • cognis-llm: Client (existing)
  • serde, serde_json: already in workspace
  • No new crate dependencies required

Tests to Write

// crates/cognis/tests/fact_extractor.rs

#[tokio::test]
async fn extracts_rule_from_architectural_decision() {
    // Given: output describing a monolithic architecture choice
    // When: FactExtractor runs
    // Then: at least one Fact with kind=Rule about architecture is returned
}

#[tokio::test]
async fn returns_empty_vec_on_unparseable_output() {
    // Given: LLM returns malformed JSON
    // When: FactExtractor runs
    // Then: returns Ok(vec![]) without propagating the error
}

#[tokio::test]
async fn respects_max_facts_limit() {
    // Given: max_facts = 3 and output that could generate many facts
    // When: FactExtractor runs
    // Then: at most 3 facts are returned
}

#[tokio::test]
async fn context_hints_appear_in_prompt() {
    // Use an interceptor or mock client to verify the rendered prompt
    // contains the provided context_hints
}

Example Usage

use cognis::agent::{FactExtractor, FactExtractionInput};
use cognis_llm::{Client, ClientBuilder, Provider};
use std::sync::Arc;

let client = Arc::new(
    ClientBuilder::new()
        .provider(Provider::Anthropic)
        .api_key("sk-ant-...")
        .model("claude-3-5-haiku-20241022")
        .build()?
);

let extractor = FactExtractor::builder(client).build();

let facts = extractor.invoke(
    FactExtractionInput {
        text: "Chose monolithic architecture for the API service. Microservices would require separate deployments and inter-service networking that the team cannot operate at current headcount.".to_string(),
        context_hints: vec!["project: stev API".to_string()],
        max_facts: 5,
    },
    Default::default(),
).await?;

// facts: [Fact { content: "Monolithic architecture chosen for API service", kind: Decision, importance: 0.9 }]

Acceptance Criteria

  • FactExtractor implements Runnable<FactExtractionInput, Vec<Fact>>
  • Default prompt produces sensible facts across rule/preference/context/decision/observation kinds
  • Prompt template is overridable via builder
  • Parsing failures return Ok(vec![]) with a tracing warning, never an error
  • Re-exported from cognis::agent
  • Tests cover happy path, empty output, parse failure, and max_facts limit
  • Example added under examples/memory/fact_extraction.rs

Related

  • Tracked in stev roadmap: docs/specs/0012-memory-layer.md (Phase 2)
  • Companion issue: DedupVectorStore (#next)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions