Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
721589d251 | ||
|
|
3184c39b79 | ||
|
|
99803f5ff8 | ||
|
|
6b18d81421 |
@@ -50,19 +50,13 @@ jobs:
|
|||||||
REGISTRY_USER: ${{ secrets.FORGEJO_REGISTRY_USER }}
|
REGISTRY_USER: ${{ secrets.FORGEJO_REGISTRY_USER }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build and push Docker image (SHA tag only)
|
||||||
run: |
|
run: |
|
||||||
docker build --no-cache --progress=plain \
|
docker build --no-cache --progress=plain \
|
||||||
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
||||||
-t "${IMAGE}:latest" \
|
|
||||||
-f Dockerfile .
|
-f Dockerfile .
|
||||||
|
|
||||||
- name: Push Docker image
|
|
||||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
|
||||||
run: |
|
|
||||||
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||||
docker push "${IMAGE}:latest"
|
echo "Pushed: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||||
echo "✓ Pushed: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
|
||||||
|
|
||||||
- name: Prune unused images
|
- name: Prune unused images
|
||||||
run: docker image prune -a --force 2>&1 | tail -3 || true
|
run: docker image prune -a --force 2>&1 | tail -3 || true
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: forgejo.riotpiao.com
|
||||||
|
IMAGE: forgejo.riotpiao.com/riotpiao-poimen/poimen-memory
|
||||||
|
DOCKER_HOST: tcp://localhost:2375
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Tag & Push Latest
|
||||||
|
runs-on: rust
|
||||||
|
steps:
|
||||||
|
- name: Install Docker
|
||||||
|
run: apt-get update && apt-get install -y docker.io
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get short SHA
|
||||||
|
id: sha
|
||||||
|
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Registry login
|
||||||
|
run: |
|
||||||
|
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" \
|
||||||
|
--username "${REGISTRY_USER}" --password-stdin
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.FORGEJO_REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Pull SHA image and tag as latest
|
||||||
|
run: |
|
||||||
|
docker pull "${IMAGE}:${{ steps.sha.outputs.short_sha }}" && \
|
||||||
|
docker tag "${IMAGE}:${{ steps.sha.outputs.short_sha }}" "${IMAGE}:latest" && \
|
||||||
|
docker push "${IMAGE}:latest" && \
|
||||||
|
echo "Tagged and pushed: ${IMAGE}:latest (from ${{ steps.sha.outputs.short_sha }})"
|
||||||
|
|
||||||
|
- name: Prune images
|
||||||
|
run: docker image prune -a --force 2>&1 | tail -3 || true
|
||||||
@@ -2,8 +2,8 @@ use anyhow::Result;
|
|||||||
use mem_store::{MemoryL1, VectorStore, ChunkL0, EntityRepoOps, EdgeRepoOps};
|
use mem_store::{MemoryL1, VectorStore, ChunkL0, EntityRepoOps, EdgeRepoOps};
|
||||||
use mem_llm::EmbeddingsClient;
|
use mem_llm::EmbeddingsClient;
|
||||||
use mem_ingest::ingest_pipeline::{IngestPipeline, Episode};
|
use mem_ingest::ingest_pipeline::{IngestPipeline, Episode};
|
||||||
use mem_ingest::entity_extractor::WikiLinkFallbackExtractor;
|
use mem_ingest::entity_extractor::{WikiLinkFallbackExtractor, LlmEntityExtractor};
|
||||||
use mem_ingest::fact_extractor::SimpleFactExtractor;
|
use mem_ingest::fact_extractor::{SimpleFactExtractor, LlmFactExtractor};
|
||||||
use mem_ingest::contradiction_detector::ContradictionHandler;
|
use mem_ingest::contradiction_detector::ContradictionHandler;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -26,11 +26,25 @@ impl IngestWorker {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
let vector_store = Arc::new(VectorStore::new(pool.clone()));
|
let vector_store = Arc::new(VectorStore::new(pool.clone()));
|
||||||
|
|
||||||
// Initialize extraction pipeline
|
// Initialize extraction pipeline — use LLM if LLM_ENDPOINT is set, else fallback to wiki links
|
||||||
let entity_extractor: Arc<dyn mem_ingest::entity_extractor::EntityExtractor> =
|
let entity_extractor: Arc<dyn mem_ingest::entity_extractor::EntityExtractor> =
|
||||||
Arc::new(WikiLinkFallbackExtractor);
|
if std::env::var("LLM_ENDPOINT").is_ok() {
|
||||||
|
let model = std::env::var("LLM_MODEL").unwrap_or_else(|_| "qwen2.5:3b-instruct".to_string());
|
||||||
|
tracing::info!("Using LLM entity extractor: model={}", model);
|
||||||
|
Arc::new(LlmEntityExtractor::new(&model))
|
||||||
|
} else {
|
||||||
|
tracing::info!("LLM_ENDPOINT not set, using WikiLink fallback extractor");
|
||||||
|
Arc::new(WikiLinkFallbackExtractor)
|
||||||
|
};
|
||||||
let fact_extractor: Arc<dyn mem_ingest::fact_extractor::FactExtractor> =
|
let fact_extractor: Arc<dyn mem_ingest::fact_extractor::FactExtractor> =
|
||||||
Arc::new(SimpleFactExtractor);
|
if std::env::var("LLM_ENDPOINT").is_ok() {
|
||||||
|
let model = std::env::var("LLM_MODEL").unwrap_or_else(|_| "qwen2.5:3b-instruct".to_string());
|
||||||
|
tracing::info!("Using LLM fact extractor: model={}", model);
|
||||||
|
Arc::new(LlmFactExtractor::new(&model))
|
||||||
|
} else {
|
||||||
|
tracing::info!("LLM_ENDPOINT not set, using simple pattern fact extractor");
|
||||||
|
Arc::new(SimpleFactExtractor)
|
||||||
|
};
|
||||||
let contradiction_detector = Arc::new(ContradictionHandler::default());
|
let contradiction_detector = Arc::new(ContradictionHandler::default());
|
||||||
let pipeline = Arc::new(IngestPipeline::new(
|
let pipeline = Arc::new(IngestPipeline::new(
|
||||||
entity_extractor,
|
entity_extractor,
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
/// Agent-specific entity metadata for Phase 3 Agent Self-Awareness.
|
||||||
|
///
|
||||||
|
/// These structures attach to Entity via entity_type discriminator.
|
||||||
|
/// AgentPrompt, AgentSkill, AgentDecision each carry domain-specific
|
||||||
|
/// fields that enable the agent to learn from its own behavior.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
use crate::entity::{Entity, EntityType};
|
||||||
|
|
||||||
|
/// Metadata for an AgentPrompt entity.
|
||||||
|
/// Tracks prompt templates, their usage frequency, and effectiveness.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentPromptMeta {
|
||||||
|
/// The prompt template text (may contain {{placeholders}}).
|
||||||
|
pub template: String,
|
||||||
|
/// Which LLM model this prompt targets (e.g. "claude-3-sonnet").
|
||||||
|
pub target_model: Option<String>,
|
||||||
|
/// Task category this prompt is designed for.
|
||||||
|
pub task_category: String,
|
||||||
|
/// Number of times this prompt has been used.
|
||||||
|
pub usage_count: u64,
|
||||||
|
/// Average quality score from outcomes (0.0-1.0).
|
||||||
|
pub avg_quality: f32,
|
||||||
|
/// Last time this prompt was used.
|
||||||
|
#[serde(with = "time::serde::rfc3339::option")]
|
||||||
|
pub last_used: Option<OffsetDateTime>,
|
||||||
|
/// Whether this prompt is currently active (not deprecated).
|
||||||
|
pub active: bool,
|
||||||
|
/// Version for tracking prompt evolution.
|
||||||
|
pub version: u32,
|
||||||
|
/// Tags for categorization.
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for an AgentSkill entity.
|
||||||
|
/// Tracks learned capabilities and their effectiveness.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentSkillMeta {
|
||||||
|
/// Description of what this skill does.
|
||||||
|
pub description: String,
|
||||||
|
/// Trigger conditions that activate this skill.
|
||||||
|
pub trigger_patterns: Vec<String>,
|
||||||
|
/// Success rate over all invocations (0.0-1.0).
|
||||||
|
pub success_rate: f32,
|
||||||
|
/// Number of times this skill was invoked.
|
||||||
|
pub invocation_count: u64,
|
||||||
|
/// Average latency in milliseconds.
|
||||||
|
pub avg_latency_ms: u64,
|
||||||
|
/// Linked prompt entity IDs that this skill uses.
|
||||||
|
pub linked_prompts: Vec<String>,
|
||||||
|
/// Whether this skill is currently enabled.
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for an AgentDecision entity.
|
||||||
|
/// Records a decision the agent made, including reasoning and outcome.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentDecisionMeta {
|
||||||
|
/// What the agent decided to do.
|
||||||
|
pub action: String,
|
||||||
|
/// Why the agent chose this action.
|
||||||
|
pub reasoning: String,
|
||||||
|
/// Available alternatives that were considered.
|
||||||
|
pub alternatives: Vec<String>,
|
||||||
|
/// Confidence in the decision (0.0-1.0).
|
||||||
|
pub confidence: f32,
|
||||||
|
/// Outcome of the decision (set after execution).
|
||||||
|
pub outcome: Option<DecisionOutcome>,
|
||||||
|
/// Context that informed the decision (entity IDs).
|
||||||
|
pub context_entities: Vec<String>,
|
||||||
|
/// The tool/task context when decision was made.
|
||||||
|
pub tool: Option<String>,
|
||||||
|
pub task: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of an agent decision.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DecisionOutcome {
|
||||||
|
/// Whether the decision led to success.
|
||||||
|
pub success: bool,
|
||||||
|
/// Quality score of the outcome (0.0-1.0).
|
||||||
|
pub quality: f32,
|
||||||
|
/// Feedback or error message.
|
||||||
|
pub feedback: Option<String>,
|
||||||
|
/// When the outcome was recorded.
|
||||||
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
pub recorded_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Factory functions ---
|
||||||
|
|
||||||
|
/// Create a new AgentPrompt entity.
|
||||||
|
pub fn new_agent_prompt(
|
||||||
|
project_id: &str,
|
||||||
|
name: &str,
|
||||||
|
template: &str,
|
||||||
|
task_category: &str,
|
||||||
|
) -> (Entity, AgentPromptMeta) {
|
||||||
|
let entity = Entity::new(project_id, name, EntityType::AgentPrompt);
|
||||||
|
let meta = AgentPromptMeta {
|
||||||
|
template: template.to_string(),
|
||||||
|
target_model: None,
|
||||||
|
task_category: task_category.to_string(),
|
||||||
|
usage_count: 0,
|
||||||
|
avg_quality: 0.0,
|
||||||
|
last_used: None,
|
||||||
|
active: true,
|
||||||
|
version: 1,
|
||||||
|
tags: vec![],
|
||||||
|
};
|
||||||
|
(entity, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new AgentSkill entity.
|
||||||
|
pub fn new_agent_skill(
|
||||||
|
project_id: &str,
|
||||||
|
name: &str,
|
||||||
|
description: &str,
|
||||||
|
) -> (Entity, AgentSkillMeta) {
|
||||||
|
let entity = Entity::new(project_id, name, EntityType::AgentSkill);
|
||||||
|
let meta = AgentSkillMeta {
|
||||||
|
description: description.to_string(),
|
||||||
|
trigger_patterns: vec![],
|
||||||
|
success_rate: 0.0,
|
||||||
|
invocation_count: 0,
|
||||||
|
avg_latency_ms: 0,
|
||||||
|
linked_prompts: vec![],
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
(entity, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new AgentDecision entity.
|
||||||
|
pub fn new_agent_decision(
|
||||||
|
project_id: &str,
|
||||||
|
action: &str,
|
||||||
|
reasoning: &str,
|
||||||
|
confidence: f32,
|
||||||
|
) -> (Entity, AgentDecisionMeta) {
|
||||||
|
let entity = Entity::new(project_id, action, EntityType::AgentDecision);
|
||||||
|
let meta = AgentDecisionMeta {
|
||||||
|
action: action.to_string(),
|
||||||
|
reasoning: reasoning.to_string(),
|
||||||
|
alternatives: vec![],
|
||||||
|
confidence,
|
||||||
|
outcome: None,
|
||||||
|
context_entities: vec![],
|
||||||
|
tool: None,
|
||||||
|
task: None,
|
||||||
|
};
|
||||||
|
(entity, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record outcome for a decision.
|
||||||
|
pub fn record_decision_outcome(
|
||||||
|
meta: &mut AgentDecisionMeta,
|
||||||
|
success: bool,
|
||||||
|
quality: f32,
|
||||||
|
feedback: Option<&str>,
|
||||||
|
) {
|
||||||
|
meta.outcome = Some(DecisionOutcome {
|
||||||
|
success,
|
||||||
|
quality,
|
||||||
|
feedback: feedback.map(|s| s.to_string()),
|
||||||
|
recorded_at: OffsetDateTime::now_utc(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update prompt usage statistics.
|
||||||
|
pub fn record_prompt_usage(meta: &mut AgentPromptMeta, quality: f32) {
|
||||||
|
let total = meta.avg_quality * meta.usage_count as f32 + quality;
|
||||||
|
meta.usage_count += 1;
|
||||||
|
meta.avg_quality = total / meta.usage_count as f32;
|
||||||
|
meta.last_used = Some(OffsetDateTime::now_utc());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update skill invocation statistics.
|
||||||
|
pub fn record_skill_invocation(meta: &mut AgentSkillMeta, success: bool, latency_ms: u64) {
|
||||||
|
let total_success = meta.success_rate * meta.invocation_count as f32
|
||||||
|
+ if success { 1.0 } else { 0.0 };
|
||||||
|
let total_latency = meta.avg_latency_ms * meta.invocation_count + latency_ms;
|
||||||
|
meta.invocation_count += 1;
|
||||||
|
meta.success_rate = total_success / meta.invocation_count as f32;
|
||||||
|
meta.avg_latency_ms = total_latency / meta.invocation_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_agent_prompt() {
|
||||||
|
let (entity, meta) = new_agent_prompt(
|
||||||
|
"poimen",
|
||||||
|
"extract-entities",
|
||||||
|
"Extract entities from: {{text}}",
|
||||||
|
"extraction",
|
||||||
|
);
|
||||||
|
assert_eq!(entity.entity_type, EntityType::AgentPrompt);
|
||||||
|
assert_eq!(entity.name, "extract-entities");
|
||||||
|
assert_eq!(meta.template, "Extract entities from: {{text}}");
|
||||||
|
assert_eq!(meta.task_category, "extraction");
|
||||||
|
assert_eq!(meta.usage_count, 0);
|
||||||
|
assert!(meta.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_agent_skill() {
|
||||||
|
let (entity, meta) = new_agent_skill(
|
||||||
|
"poimen",
|
||||||
|
"diagnose-pod-failure",
|
||||||
|
"Diagnose Kubernetes pod CrashLoopBackOff",
|
||||||
|
);
|
||||||
|
assert_eq!(entity.entity_type, EntityType::AgentSkill);
|
||||||
|
assert_eq!(meta.description, "Diagnose Kubernetes pod CrashLoopBackOff");
|
||||||
|
assert!(meta.enabled);
|
||||||
|
assert_eq!(meta.invocation_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_agent_decision() {
|
||||||
|
let (entity, meta) = new_agent_decision(
|
||||||
|
"poimen",
|
||||||
|
"restart-pod",
|
||||||
|
"Pod stuck in CrashLoopBackOff for 10 minutes",
|
||||||
|
0.85,
|
||||||
|
);
|
||||||
|
assert_eq!(entity.entity_type, EntityType::AgentDecision);
|
||||||
|
assert_eq!(meta.action, "restart-pod");
|
||||||
|
assert_eq!(meta.confidence, 0.85);
|
||||||
|
assert!(meta.outcome.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_record_decision_outcome() {
|
||||||
|
let (_, mut meta) = new_agent_decision("p", "act", "reason", 0.9);
|
||||||
|
assert!(meta.outcome.is_none());
|
||||||
|
|
||||||
|
record_decision_outcome(&mut meta, true, 0.95, Some("Pod recovered"));
|
||||||
|
assert!(meta.outcome.is_some());
|
||||||
|
let outcome = meta.outcome.unwrap();
|
||||||
|
assert!(outcome.success);
|
||||||
|
assert_eq!(outcome.quality, 0.95);
|
||||||
|
assert_eq!(outcome.feedback, Some("Pod recovered".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_record_prompt_usage() {
|
||||||
|
let (_, mut meta) = new_agent_prompt("p", "test", "tmpl", "cat");
|
||||||
|
assert_eq!(meta.usage_count, 0);
|
||||||
|
assert_eq!(meta.avg_quality, 0.0);
|
||||||
|
|
||||||
|
record_prompt_usage(&mut meta, 0.8);
|
||||||
|
assert_eq!(meta.usage_count, 1);
|
||||||
|
assert_eq!(meta.avg_quality, 0.8);
|
||||||
|
|
||||||
|
record_prompt_usage(&mut meta, 1.0);
|
||||||
|
assert_eq!(meta.usage_count, 2);
|
||||||
|
assert!((meta.avg_quality - 0.9).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_record_skill_invocation() {
|
||||||
|
let (_, mut meta) = new_agent_skill("p", "skill", "desc");
|
||||||
|
assert_eq!(meta.invocation_count, 0);
|
||||||
|
|
||||||
|
record_skill_invocation(&mut meta, true, 100);
|
||||||
|
assert_eq!(meta.invocation_count, 1);
|
||||||
|
assert_eq!(meta.success_rate, 1.0);
|
||||||
|
assert_eq!(meta.avg_latency_ms, 100);
|
||||||
|
|
||||||
|
record_skill_invocation(&mut meta, false, 200);
|
||||||
|
assert_eq!(meta.invocation_count, 2);
|
||||||
|
assert_eq!(meta.success_rate, 0.5);
|
||||||
|
assert_eq!(meta.avg_latency_ms, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_entity_type_round_trip_agent_types() {
|
||||||
|
for ty in &[
|
||||||
|
EntityType::AgentPrompt,
|
||||||
|
EntityType::AgentSkill,
|
||||||
|
EntityType::AgentDecision,
|
||||||
|
] {
|
||||||
|
let s = ty.as_str();
|
||||||
|
assert_eq!(EntityType::from_str(s), *ty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_agent_prompt_serialization() {
|
||||||
|
let (_, meta) = new_agent_prompt("p", "test", "tmpl {{x}}", "cat");
|
||||||
|
let json = serde_json::to_string(&meta).unwrap();
|
||||||
|
let deserialized: AgentPromptMeta = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.template, "tmpl {{x}}");
|
||||||
|
assert_eq!(deserialized.task_category, "cat");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ use time::OffsetDateTime;
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
/// Entity type classification (extensible enum).
|
/// Entity type classification (extensible enum).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Hash)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum EntityType {
|
pub enum EntityType {
|
||||||
Person,
|
Person,
|
||||||
@@ -17,6 +17,13 @@ pub enum EntityType {
|
|||||||
Location,
|
Location,
|
||||||
Event,
|
Event,
|
||||||
Organization,
|
Organization,
|
||||||
|
/// Agent prompt template tracked as a first-class entity.
|
||||||
|
/// Enables the agent to learn which prompts produce good results.
|
||||||
|
AgentPrompt,
|
||||||
|
/// Agent skill — a reusable capability the agent has learned.
|
||||||
|
AgentSkill,
|
||||||
|
/// Agent decision — a recorded choice with reasoning and outcome.
|
||||||
|
AgentDecision,
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +36,9 @@ impl EntityType {
|
|||||||
Self::Location => "location",
|
Self::Location => "location",
|
||||||
Self::Event => "event",
|
Self::Event => "event",
|
||||||
Self::Organization => "organization",
|
Self::Organization => "organization",
|
||||||
|
Self::AgentPrompt => "agent_prompt",
|
||||||
|
Self::AgentSkill => "agent_skill",
|
||||||
|
Self::AgentDecision => "agent_decision",
|
||||||
Self::Unknown => "unknown",
|
Self::Unknown => "unknown",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,11 +51,24 @@ impl EntityType {
|
|||||||
"location" => Self::Location,
|
"location" => Self::Location,
|
||||||
"event" => Self::Event,
|
"event" => Self::Event,
|
||||||
"organization" => Self::Organization,
|
"organization" => Self::Organization,
|
||||||
|
"agent_prompt" => Self::AgentPrompt,
|
||||||
|
"agent_skill" => Self::AgentSkill,
|
||||||
|
"agent_decision" => Self::AgentDecision,
|
||||||
_ => Self::Unknown,
|
_ => Self::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'de> serde::Deserialize<'de> for EntityType {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Ok(Self::from_str(&s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl fmt::Display for EntityType {
|
impl fmt::Display for EntityType {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}", self.as_str())
|
write!(f, "{}", self.as_str())
|
||||||
@@ -175,6 +198,9 @@ mod tests {
|
|||||||
EntityType::Person,
|
EntityType::Person,
|
||||||
EntityType::Tool,
|
EntityType::Tool,
|
||||||
EntityType::Concept,
|
EntityType::Concept,
|
||||||
|
EntityType::AgentPrompt,
|
||||||
|
EntityType::AgentSkill,
|
||||||
|
EntityType::AgentDecision,
|
||||||
] {
|
] {
|
||||||
let s = ty.as_str();
|
let s = ty.as_str();
|
||||||
assert_eq!(EntityType::from_str(s), *ty);
|
assert_eq!(EntityType::from_str(s), *ty);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod scoring;
|
|||||||
pub mod entity;
|
pub mod entity;
|
||||||
pub mod edge;
|
pub mod edge;
|
||||||
pub mod community;
|
pub mod community;
|
||||||
|
pub mod agent_entity;
|
||||||
|
|
||||||
pub use gate_parser::{GateResponse, ParseError, parse_gate_response};
|
pub use gate_parser::{GateResponse, ParseError, parse_gate_response};
|
||||||
|
|
||||||
@@ -30,3 +31,4 @@ pub use scoring::{DocumentScorer, ScoringPipeline, GlobalTfIdfScorer, ProjectTfI
|
|||||||
pub use entity::{Entity, EntityType};
|
pub use entity::{Entity, EntityType};
|
||||||
pub use edge::{Edge, ContradictionStatus};
|
pub use edge::{Edge, ContradictionStatus};
|
||||||
pub use community::Community;
|
pub use community::Community;
|
||||||
|
pub use agent_entity::{AgentPromptMeta, AgentSkillMeta, AgentDecisionMeta, DecisionOutcome};
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ use tokio::sync::Mutex;
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ExtractedEntity {
|
pub struct ExtractedEntity {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[serde(alias = "type")]
|
||||||
pub entity_type: EntityType,
|
pub entity_type: EntityType,
|
||||||
pub summary: String,
|
pub summary: String,
|
||||||
|
#[serde(default = "default_confidence")]
|
||||||
pub confidence: f32,
|
pub confidence: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_confidence() -> f32 { 0.8 }
|
||||||
|
|
||||||
impl ExtractedEntity {
|
impl ExtractedEntity {
|
||||||
/// Convert to domain model (Phase 1 type)
|
/// Convert to domain model (Phase 1 type)
|
||||||
pub fn to_domain(&self, project_id: &str) -> Entity {
|
pub fn to_domain(&self, project_id: &str) -> Entity {
|
||||||
@@ -62,6 +66,35 @@ impl LlmEntityExtractor {
|
|||||||
|
|
||||||
/// Parse extraction response JSON
|
/// Parse extraction response JSON
|
||||||
/// Format: { "entities": [{ "name": "...", "type": "...", "summary": "..." }, ...] }
|
/// Format: { "entities": [{ "name": "...", "type": "...", "summary": "..." }, ...] }
|
||||||
|
/// Clean LLM response: strip thinking tags, markdown fences, extract JSON
|
||||||
|
fn clean_llm_response(text: &str) -> String {
|
||||||
|
let mut result = text.to_string();
|
||||||
|
// Remove <think>...</think> blocks
|
||||||
|
while let Some(start) = result.find("<think>") {
|
||||||
|
if let Some(end) = result.find("</think>") {
|
||||||
|
result = format!("{}{}", &result[..start], &result[end + 8..]);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove markdown code fences
|
||||||
|
result = result.replace("```json", "").replace("```", "");
|
||||||
|
// Find JSON object
|
||||||
|
let trimmed = result.trim();
|
||||||
|
if let Some(start) = trimmed.find('{') {
|
||||||
|
if let Some(end) = trimmed.rfind('}') {
|
||||||
|
return trimmed[start..=end].to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Maybe it's a JSON array — wrap in object
|
||||||
|
if let Some(start) = trimmed.find('[') {
|
||||||
|
if let Some(end) = trimmed.rfind(']') {
|
||||||
|
return format!("{{\"entities\": {}}}", &trimmed[start..=end]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_extraction(response: &str) -> Result<Vec<ExtractedEntity>> {
|
fn parse_extraction(response: &str) -> Result<Vec<ExtractedEntity>> {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Response {
|
struct Response {
|
||||||
@@ -123,7 +156,7 @@ impl LlmEntityExtractor {
|
|||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt}
|
||||||
],
|
],
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
"max_tokens": 500
|
"max_tokens": 1500
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
@@ -131,7 +164,7 @@ impl LlmEntityExtractor {
|
|||||||
.header("Authorization", auth_header)
|
.header("Authorization", auth_header)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.json(&payload)
|
.json(&payload)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(90))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -146,12 +179,16 @@ impl LlmEntityExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let data: serde_json::Value = response.json().await?;
|
let data: serde_json::Value = response.json().await?;
|
||||||
let content = data["choices"][0]["message"]["content"]
|
let raw_content = data["choices"][0]["message"]["content"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("{}")
|
.unwrap_or("{}")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
tracing::debug!("LLM response (via Authentik JWT): {}", content);
|
// Strip <think>...</think> tags from reasoning models
|
||||||
|
let content = Self::clean_llm_response(&raw_content);
|
||||||
|
|
||||||
|
tracing::debug!("LLM raw response length={}, cleaned length={}", raw_content.len(), content.len());
|
||||||
|
tracing::debug!("LLM cleaned content: {}", content);
|
||||||
Ok(content)
|
Ok(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,14 +270,27 @@ Respond in JSON:
|
|||||||
);
|
);
|
||||||
|
|
||||||
let reflection = if std::env::var("LLM_ENDPOINT").is_ok() {
|
let reflection = if std::env::var("LLM_ENDPOINT").is_ok() {
|
||||||
self.call_llm_endpoint(&reflection_prompt).await.unwrap_or_else(|_| self.simulate_llm(&reflection_prompt).unwrap_or_default())
|
self.call_llm_endpoint(&reflection_prompt).await.unwrap_or_else(|e| {
|
||||||
|
tracing::warn!("Reflection LLM call failed: {}, skipping verification", e);
|
||||||
|
String::new()
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
self.simulate_llm(&reflection_prompt)?
|
self.simulate_llm(&reflection_prompt)?
|
||||||
};
|
};
|
||||||
let verified = Self::parse_reflection(&reflection)?;
|
|
||||||
|
|
||||||
// Filter: keep only entities marked present
|
// If reflection succeeded, filter entities; otherwise keep all
|
||||||
|
if !reflection.is_empty() {
|
||||||
|
match Self::parse_reflection(&reflection) {
|
||||||
|
Ok(verified) => {
|
||||||
entities.retain(|e| verified.iter().any(|(name, present)| name == &e.name && *present));
|
entities.retain(|e| verified.iter().any(|(name, present)| name == &e.name && *present));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Reflection parse failed: {}, keeping all entities", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::info!("Reflection skipped, keeping {} unverified entities", entities.len());
|
||||||
|
}
|
||||||
|
|
||||||
// Adjust confidence for reflected entities (slight penalty for needing verification)
|
// Adjust confidence for reflected entities (slight penalty for needing verification)
|
||||||
for entity in &mut entities {
|
for entity in &mut entities {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
//! Fact extraction: Identify relationships between entities
|
//! Fact extraction: Identify relationships between entities
|
||||||
//!
|
//!
|
||||||
//! Two implementations:
|
//! Three implementations:
|
||||||
//! 1. SimpleFactExtractor: Pattern-based (verbs + wiki links)
|
//! 1. SimpleFactExtractor: Pattern-based (verbs + wiki links)
|
||||||
//! 2. LlmFactExtractor: LLM-based (placeholder for production)
|
//! 2. LlmFactExtractor: LLM-based extraction with entity context
|
||||||
|
//! 3. Fallback chain: LLM → Simple pattern matching
|
||||||
//!
|
//!
|
||||||
//! CRAP: 12 (Simple pattern matching + LLM placeholder)
|
//! Aligned with Zep paper §2.2.2: Facts as edges between entity pairs,
|
||||||
//! SOLID: Trait-based (Open/Closed)
|
//! with temporal extraction and dedup against existing edges.
|
||||||
//! DRY: Reuses EntityExtractor pattern
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -27,20 +27,18 @@ pub struct ExtractedFact {
|
|||||||
pub trait FactExtractor: Send + Sync {
|
pub trait FactExtractor: Send + Sync {
|
||||||
async fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>>;
|
async fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>>;
|
||||||
|
|
||||||
/// Extract facts with GRM context (optional, defaults to extract())
|
/// Extract facts with entity context (Zep §2.2.2: facts between known entities)
|
||||||
async fn extract_with_context(
|
async fn extract_with_context(
|
||||||
&self,
|
&self,
|
||||||
text: &str,
|
text: &str,
|
||||||
_entity_contexts: &[crate::grm_retriever::EntityContext],
|
_entity_contexts: &[crate::grm_retriever::EntityContext],
|
||||||
) -> Result<Vec<ExtractedFact>> {
|
) -> Result<Vec<ExtractedFact>> {
|
||||||
// Default: ignore context, use plain extraction
|
|
||||||
self.extract(text).await
|
self.extract(text).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple fact extractor based on verb patterns
|
/// Simple fact extractor based on verb patterns
|
||||||
/// Pattern: [[Entity1]] verb [[Entity2]]
|
/// Pattern: [[Entity1]] verb [[Entity2]]
|
||||||
/// Common verbs: uses, manages, runs, deployed_to, works_with
|
|
||||||
pub struct SimpleFactExtractor;
|
pub struct SimpleFactExtractor;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -48,17 +46,15 @@ impl FactExtractor for SimpleFactExtractor {
|
|||||||
async fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>> {
|
async fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>> {
|
||||||
let mut facts = vec![];
|
let mut facts = vec![];
|
||||||
|
|
||||||
// Extract [[Entity]] patterns
|
|
||||||
let entity_pattern = Regex::new(r"\[\[([^\]]+)\]\]")?;
|
let entity_pattern = Regex::new(r"\[\[([^\]]+)\]\]")?;
|
||||||
let entities: Vec<String> = entity_pattern
|
let _entities: Vec<String> = entity_pattern
|
||||||
.captures_iter(text)
|
.captures_iter(text)
|
||||||
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
|
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Common relationship verbs
|
let verbs = ["uses", "manages", "runs", "deployed_to", "works_with",
|
||||||
let verbs = ["uses", "manages", "runs", "deployed_to", "works_with"];
|
"depends_on", "contains", "extends", "implements", "connects_to"];
|
||||||
|
|
||||||
// Simple heuristic: if two entities appear close together with a verb between them
|
|
||||||
for verb in &verbs {
|
for verb in &verbs {
|
||||||
let pattern = format!(
|
let pattern = format!(
|
||||||
r"\[\[([^\]]+)\]\].*?{}.*?\[\[([^\]]+)\]\]",
|
r"\[\[([^\]]+)\]\].*?{}.*?\[\[([^\]]+)\]\]",
|
||||||
@@ -71,12 +67,7 @@ impl FactExtractor for SimpleFactExtractor {
|
|||||||
source_entity_id: src.as_str().to_string(),
|
source_entity_id: src.as_str().to_string(),
|
||||||
target_entity_id: tgt.as_str().to_string(),
|
target_entity_id: tgt.as_str().to_string(),
|
||||||
relation_type: verb.to_uppercase(),
|
relation_type: verb.to_uppercase(),
|
||||||
fact: format!(
|
fact: format!("{} {} {}", src.as_str(), verb, tgt.as_str()),
|
||||||
"{} {} {}",
|
|
||||||
src.as_str(),
|
|
||||||
verb,
|
|
||||||
tgt.as_str()
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,19 +78,194 @@ impl FactExtractor for SimpleFactExtractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LLM-based fact extractor (placeholder for production)
|
/// LLM-based fact extractor (Zep §2.2.2 alignment)
|
||||||
/// TODO (Phase 2.6): Implement with real LLM API
|
/// Extracts relationships between entity pairs using LLM
|
||||||
/// TODO (Phase 2.6): Support complex relationships (3-way, temporal, conditional)
|
pub struct LlmFactExtractor {
|
||||||
pub struct LlmFactExtractor;
|
model_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LlmFactExtractor {
|
||||||
|
pub fn new(model_name: &str) -> Self {
|
||||||
|
Self { model_name: model_name.to_string() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean LLM response: strip thinking tags, markdown fences, extract JSON
|
||||||
|
fn clean_llm_response(text: &str) -> String {
|
||||||
|
let mut result = text.to_string();
|
||||||
|
while let Some(start) = result.find("<think>") {
|
||||||
|
if let Some(end) = result.find("</think>") {
|
||||||
|
result = format!("{}{}", &result[..start], &result[end + 8..]);
|
||||||
|
} else { break; }
|
||||||
|
}
|
||||||
|
result = result.replace("```json", "").replace("```", "");
|
||||||
|
let trimmed = result.trim();
|
||||||
|
if let Some(start) = trimmed.find('{') {
|
||||||
|
if let Some(end) = trimmed.rfind('}') {
|
||||||
|
return trimmed[start..=end].to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(start) = trimmed.find('[') {
|
||||||
|
if let Some(end) = trimmed.rfind(']') {
|
||||||
|
return format!("{{\"facts\": {}}}", &trimmed[start..=end]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_llm(&self, prompt: &str) -> Result<String> {
|
||||||
|
let endpoint = std::env::var("LLM_ENDPOINT")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:8081/v1/chat/completions".to_string());
|
||||||
|
|
||||||
|
let api_key = std::env::var("LLM_API_KEY")
|
||||||
|
.or_else(|_| std::env::var("MEM_API_KEY"))
|
||||||
|
.unwrap_or_else(|_| "default-key".to_string());
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"model": self.model_name,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are a fact extraction specialist. Extract relationships between entities from text. Output ONLY valid JSON."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
"max_tokens": 1500,
|
||||||
|
"temperature": 0.1
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&endpoint)
|
||||||
|
.header("Authorization", format!("Bearer {}", api_key))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&payload)
|
||||||
|
.timeout(std::time::Duration::from_secs(90))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!("Fact extraction LLM error: {} - {}", status, body);
|
||||||
|
return Err(anyhow::anyhow!("LLM API error: {}", status));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = response.json().await?;
|
||||||
|
let raw = data["choices"][0]["message"]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("{}")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let cleaned = Self::clean_llm_response(&raw);
|
||||||
|
tracing::debug!("Fact LLM response: raw_len={}, cleaned_len={}", raw.len(), cleaned.len());
|
||||||
|
Ok(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FactExtractor for LlmFactExtractor {
|
impl FactExtractor for LlmFactExtractor {
|
||||||
async fn extract(&self, _text: &str) -> Result<Vec<ExtractedFact>> {
|
async fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>> {
|
||||||
// TODO (Phase 2.6): Implement LLM-based extraction
|
self.extract_with_context(text, &[]).await
|
||||||
// Pattern: Send text to api.riotpiao.com with prompt
|
}
|
||||||
// Parse response for [source, relation, target] tuples
|
|
||||||
|
async fn extract_with_context(
|
||||||
|
&self,
|
||||||
|
text: &str,
|
||||||
|
entity_contexts: &[crate::grm_retriever::EntityContext],
|
||||||
|
) -> Result<Vec<ExtractedFact>> {
|
||||||
|
// Build entity list for prompt
|
||||||
|
let entity_names: Vec<&str> = entity_contexts
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.entity_name.as_str())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if entity_names.is_empty() {
|
||||||
|
tracing::debug!("No entities provided, skipping fact extraction");
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = format!(
|
||||||
|
r#"Extract relationships (facts) between these entities from the text.
|
||||||
|
|
||||||
|
Entities: {:?}
|
||||||
|
|
||||||
|
Text:
|
||||||
|
"{}"
|
||||||
|
|
||||||
|
For each relationship provide:
|
||||||
|
- source: Entity name (must be from the list above)
|
||||||
|
- target: Entity name (must be from the list above)
|
||||||
|
- relation: Verb/predicate describing the relationship (e.g., "uses", "manages", "is_part_of", "deployed_on")
|
||||||
|
- fact: One-sentence natural language description
|
||||||
|
|
||||||
|
CRITICAL: Only extract relationships EXPLICITLY stated or strongly implied. Source and target must both be from the entity list.
|
||||||
|
|
||||||
|
Respond in JSON:
|
||||||
|
{{"facts": [{{"source": "...", "target": "...", "relation": "...", "fact": "..."}}, ...]}}
|
||||||
|
"#,
|
||||||
|
entity_names, text
|
||||||
|
);
|
||||||
|
|
||||||
|
let llm_ok = std::env::var("LLM_ENDPOINT").is_ok();
|
||||||
|
let response = if llm_ok {
|
||||||
|
match self.call_llm(&prompt).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Fact extraction LLM failed: {}, returning empty", e);
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::debug!("LLM_ENDPOINT not set, skipping LLM fact extraction");
|
||||||
|
return Ok(vec![]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FactResponse {
|
||||||
|
facts: Vec<RawFact>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RawFact {
|
||||||
|
source: String,
|
||||||
|
target: String,
|
||||||
|
relation: String,
|
||||||
|
fact: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
match serde_json::from_str::<FactResponse>(&response) {
|
||||||
|
Ok(parsed) => {
|
||||||
|
let facts: Vec<ExtractedFact> = parsed.facts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| {
|
||||||
|
// Validate source and target are known entities
|
||||||
|
let src_ok = entity_names.iter().any(|e| e.eq_ignore_ascii_case(&f.source));
|
||||||
|
let tgt_ok = entity_names.iter().any(|e| e.eq_ignore_ascii_case(&f.target));
|
||||||
|
if !src_ok || !tgt_ok {
|
||||||
|
tracing::debug!(
|
||||||
|
"Dropping fact with unknown entity: {} -> {}",
|
||||||
|
f.source, f.target
|
||||||
|
);
|
||||||
|
}
|
||||||
|
src_ok && tgt_ok && f.source != f.target
|
||||||
|
})
|
||||||
|
.map(|f| ExtractedFact {
|
||||||
|
source_entity_id: f.source,
|
||||||
|
target_entity_id: f.target,
|
||||||
|
relation_type: f.relation.to_uppercase(),
|
||||||
|
fact: f.fact,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"LLM fact extraction: {} facts from {} entities",
|
||||||
|
facts.len(), entity_names.len()
|
||||||
|
);
|
||||||
|
Ok(facts)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Fact extraction JSON parse failed: {}, response: {}", e, &response[..response.len().min(200)]);
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -110,9 +276,38 @@ mod tests {
|
|||||||
async fn test_simple_fact_extraction() {
|
async fn test_simple_fact_extraction() {
|
||||||
let extractor = SimpleFactExtractor;
|
let extractor = SimpleFactExtractor;
|
||||||
let text = "[[Rock]] uses [[Kubernetes]] and [[ArgoCD]]";
|
let text = "[[Rock]] uses [[Kubernetes]] and [[ArgoCD]]";
|
||||||
|
|
||||||
let facts = extractor.extract(text).await.unwrap();
|
let facts = extractor.extract(text).await.unwrap();
|
||||||
assert!(facts.len() > 0);
|
assert!(!facts.is_empty());
|
||||||
assert!(facts.iter().any(|f| f.relation_type == "USES"));
|
assert!(facts.iter().any(|f| f.relation_type == "USES"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_simple_no_wiki_links() {
|
||||||
|
let extractor = SimpleFactExtractor;
|
||||||
|
let text = "Kubernetes uses etcd for storage";
|
||||||
|
let facts = extractor.extract(text).await.unwrap();
|
||||||
|
assert!(facts.is_empty()); // No [[wiki links]]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clean_llm_response() {
|
||||||
|
let input = r#"<think>reasoning here</think>{"facts": [{"source": "A", "target": "B", "relation": "uses", "fact": "A uses B"}]}"#;
|
||||||
|
let cleaned = LlmFactExtractor::clean_llm_response(input);
|
||||||
|
assert!(cleaned.starts_with("{"));
|
||||||
|
assert!(cleaned.contains("facts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_thinking_no_tags() {
|
||||||
|
let input = r#"{"facts": []}"#;
|
||||||
|
let cleaned = LlmFactExtractor::clean_llm_response(input);
|
||||||
|
assert_eq!(cleaned, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_llm_fact_no_entities_returns_empty() {
|
||||||
|
let extractor = LlmFactExtractor::new("test");
|
||||||
|
let facts = extractor.extract_with_context("some text", &[]).await.unwrap();
|
||||||
|
assert!(facts.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: poimen-memory-secrets
|
name: poimen-memory-secrets
|
||||||
key: llm-api-key
|
key: llm-api-key
|
||||||
|
# LLM config (in-cluster, no auth needed)
|
||||||
|
- name: LLM_ENDPOINT
|
||||||
|
value: "http://reasoning-predictor.llm-serving.svc.cluster.local/v1/chat/completions"
|
||||||
|
- name: LLM_API_BASE
|
||||||
|
value: "http://reasoning-predictor.llm-serving.svc.cluster.local/v1"
|
||||||
|
- name: LLM_MODEL
|
||||||
|
value: "reasoning"
|
||||||
# Server config (from ConfigMap)
|
# Server config (from ConfigMap)
|
||||||
- name: MEM_PORT
|
- name: MEM_PORT
|
||||||
value: "8080"
|
value: "8080"
|
||||||
@@ -78,6 +85,7 @@ spec:
|
|||||||
name: poimen-memory-auth
|
name: poimen-memory-auth
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: poimen-memory-secrets
|
name: poimen-memory-secrets
|
||||||
|
command: ["/app/mem"]
|
||||||
args:
|
args:
|
||||||
- serve
|
- serve
|
||||||
- --port
|
- --port
|
||||||
|
|||||||
Reference in New Issue
Block a user