Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7da1cffec6 | ||
|
|
a4c445b6ba | ||
|
|
6b35b04d20 | ||
|
|
c41cef0ca5 | ||
|
|
d52b99f0b1 | ||
|
|
0c6abc3fef | ||
|
|
b40cc47729 | ||
|
|
31f5265603 | ||
|
|
5cfff3990a | ||
|
|
9807e7ec97 | ||
|
|
d8b7adf2fc | ||
|
|
9062c51545 | ||
|
|
1793fe39f0 | ||
|
|
49a82e2caf |
@@ -1,134 +0,0 @@
|
|||||||
# Forgejo CI/CD - Build & Push Workflow
|
|
||||||
|
|
||||||
**Status**: ✅ ACTIVE (Production-ready)
|
|
||||||
|
|
||||||
## CI Workflow
|
|
||||||
|
|
||||||
The `.forgejo/workflows/build.yaml` automatically:
|
|
||||||
|
|
||||||
1. Triggers on **push to main** branch
|
|
||||||
2. Builds Docker image (multi-stage Rust)
|
|
||||||
3. Tags: `latest` + `short-SHA`
|
|
||||||
4. Pushes to registry
|
|
||||||
5. Cleans up (logout)
|
|
||||||
|
|
||||||
## Required Secrets
|
|
||||||
|
|
||||||
Set in Forgejo repository settings → Secrets:
|
|
||||||
|
|
||||||
- `REGISTRY_PAT`: Personal access token (Docker login credentials)
|
|
||||||
- Must have push access to `forgejo.riotpiao.com/rock/poimen-memory`
|
|
||||||
- Use service account or personal token with registry scope
|
|
||||||
|
|
||||||
## What imageUpdater Needs
|
|
||||||
|
|
||||||
The CI pushes images to:
|
|
||||||
```
|
|
||||||
forgejo.riotpiao.com/rock/poimen-memory:latest
|
|
||||||
forgejo.riotpiao.com/rock/poimen-memory:<short-SHA>
|
|
||||||
```
|
|
||||||
|
|
||||||
imageUpdater can:
|
|
||||||
- Watch for `:latest` tag
|
|
||||||
- Poll registry for new versions
|
|
||||||
- Trigger K8s deployment updates
|
|
||||||
|
|
||||||
## Manual Override
|
|
||||||
|
|
||||||
If CI fails, build manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export REGISTRY_TOKEN='<your-token>'
|
|
||||||
./scripts/build-and-push.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Design
|
|
||||||
|
|
||||||
**Minimal & Reliable**:
|
|
||||||
- ✅ No third-party actions (no hidden timeouts)
|
|
||||||
- ✅ Direct docker commands only
|
|
||||||
- ✅ Progress output visible
|
|
||||||
- ✅ Proper error handling
|
|
||||||
- ✅ Clean secrets handling
|
|
||||||
- ✅ 5-10 minute runtime
|
|
||||||
|
|
||||||
**Single Workflow**:
|
|
||||||
- ✅ ONE `build.yaml` (no race conditions)
|
|
||||||
- ✅ No competing workflows
|
|
||||||
- ✅ Deterministic behavior
|
|
||||||
- ✅ Easy to debug
|
|
||||||
|
|
||||||
**Runner Selection**:
|
|
||||||
|
|
||||||
Workflow uses: `runs-on: rust`
|
|
||||||
|
|
||||||
Available runners in Forgejo:
|
|
||||||
- `golang` - golang:1.26-bookworm + dind (for Go projects)
|
|
||||||
- `rust` - rust:1.83-bookworm + dind (✅ for Rust projects)
|
|
||||||
- `node` - node:22-bookworm (for Node.js projects)
|
|
||||||
|
|
||||||
Why `rust` for poimen-memory:
|
|
||||||
- ✅ Pre-installed Rust toolchain
|
|
||||||
- ✅ Docker-in-Docker (dind) for image builds
|
|
||||||
- ✅ 2 CPU, 4GB RAM limits (sufficient)
|
|
||||||
- ✅ 1.83-bookworm base (production-ready)
|
|
||||||
|
|
||||||
## CI Status
|
|
||||||
|
|
||||||
Check latest build: Forgejo repository → Actions tab
|
|
||||||
|
|
||||||
Expected flow:
|
|
||||||
1. Push to main
|
|
||||||
2. Forgejo CI triggers (30s delay)
|
|
||||||
3. Build starts (~3-5 min)
|
|
||||||
4. Image pushed to registry
|
|
||||||
5. imageUpdater detects new version
|
|
||||||
6. K8s deployment updated (via ArgoCD or controller)
|
|
||||||
|
|
||||||
## Deployment Trigger
|
|
||||||
|
|
||||||
Once image is pushed, imageUpdater can:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ArgoCD Image Updater strategy
|
|
||||||
apiVersion: argoproj.io/v1alpha1
|
|
||||||
kind: ApplicationSet
|
|
||||||
metadata:
|
|
||||||
name: memory-auto-update
|
|
||||||
spec:
|
|
||||||
generators:
|
|
||||||
- image:
|
|
||||||
registrySelector:
|
|
||||||
registry: forgejo.riotpiao.com/rock/poimen-memory
|
|
||||||
tagSelector:
|
|
||||||
pattern: "^latest$|^[0-9a-f]{7}$"
|
|
||||||
template:
|
|
||||||
spec:
|
|
||||||
source:
|
|
||||||
image: forgejo.riotpiao.com/rock/poimen-memory:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use external webhook to trigger K8s deployment rollout.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**CI Hanging?**
|
|
||||||
- Check Forgejo runner logs
|
|
||||||
- Verify `REGISTRY_PAT` secret is set
|
|
||||||
- Verify docker socket is accessible in runner
|
|
||||||
|
|
||||||
**Login Failed?**
|
|
||||||
- Verify `REGISTRY_HOST` secret
|
|
||||||
- Check credentials in Vault
|
|
||||||
|
|
||||||
**Build Failed?**
|
|
||||||
- Check: `cargo test --lib --all` locally
|
|
||||||
- Check: `docker build .` works locally
|
|
||||||
- Review build output in Forgejo Actions tab
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- `.forgejo/workflows/build.yaml` ← **Production workflow**
|
|
||||||
- `.forgejo/README.md` ← This file
|
|
||||||
- `./Dockerfile` ← Multi-stage Rust build
|
|
||||||
- `./scripts/build-and-push.sh` ← Manual fallback
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Push Memory Service
|
name: CI & Build & Push
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,24 +6,33 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
test:
|
||||||
name: Build and Push Image
|
name: Test & Lint
|
||||||
# Use 'rust' runner (not 'docker')
|
|
||||||
# Available runners in Forgejo: golang, rust, node
|
|
||||||
# rust runner provides: Rust 1.83-bookworm + Docker-in-Docker for image builds
|
|
||||||
runs-on: rust
|
runs-on: rust
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cargo test
|
||||||
|
run: cargo test -p mem-ingest --lib 2>&1 | tail -20
|
||||||
|
|
||||||
|
- name: Cargo check
|
||||||
|
run: cargo check -p mem-ingest 2>&1 | grep -E "error|warning: unused|Finished" || true
|
||||||
|
|
||||||
|
build-and-push:
|
||||||
|
name: Build & Push Image
|
||||||
|
runs-on: rust
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Get commit info
|
- name: Get commit info
|
||||||
id: info
|
id: info
|
||||||
run: |
|
run: |
|
||||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
COMMIT_MSG=$(git log -1 --pretty=%B | head -1)
|
|
||||||
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
|
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
|
||||||
echo "commit_msg=${COMMIT_MSG}" >> $GITHUB_OUTPUT
|
echo "Building: ${SHORT_SHA}"
|
||||||
echo "Building: ${SHORT_SHA} - ${COMMIT_MSG}"
|
|
||||||
|
|
||||||
- name: Docker login
|
- name: Docker login
|
||||||
run: |
|
run: |
|
||||||
@@ -36,17 +45,14 @@ jobs:
|
|||||||
--tag forgejo.riotpiao.com/rock/poimen-memory:${{ steps.info.outputs.short_sha }} \
|
--tag forgejo.riotpiao.com/rock/poimen-memory:${{ steps.info.outputs.short_sha }} \
|
||||||
--tag forgejo.riotpiao.com/rock/poimen-memory:latest \
|
--tag forgejo.riotpiao.com/rock/poimen-memory:latest \
|
||||||
.
|
.
|
||||||
echo "✅ Image built successfully"
|
echo "✅ Image built"
|
||||||
|
|
||||||
- name: Push image
|
- name: Push image
|
||||||
run: |
|
run: |
|
||||||
docker push forgejo.riotpiao.com/rock/poimen-memory:${{ steps.info.outputs.short_sha }}
|
docker push forgejo.riotpiao.com/rock/poimen-memory:${{ steps.info.outputs.short_sha }}
|
||||||
docker push forgejo.riotpiao.com/rock/poimen-memory:latest
|
docker push forgejo.riotpiao.com/rock/poimen-memory:latest
|
||||||
echo "✅ Image pushed successfully"
|
echo "✅ Image pushed"
|
||||||
echo "Image: forgejo.riotpiao.com/rock/poimen-memory:latest"
|
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: docker logout forgejo.riotpiao.com || true
|
||||||
docker logout forgejo.riotpiao.com || true
|
|
||||||
echo "✅ Cleanup complete"
|
|
||||||
@@ -18,3 +18,5 @@ log/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
knowledge/
|
knowledge/
|
||||||
docs/LIFECYCLE.md
|
docs/LIFECYCLE.md
|
||||||
|
# Trigger CI
|
||||||
|
# Test runner ready
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/// Configuration management for inter-pod URLs via environment variables (ConfigMap)
|
||||||
|
/// All service URLs come from K8s ConfigMap, never hardcoded
|
||||||
|
///
|
||||||
|
/// ConfigMap in K8s:
|
||||||
|
/// ```yaml
|
||||||
|
/// apiVersion: v1
|
||||||
|
/// kind: ConfigMap
|
||||||
|
/// metadata:
|
||||||
|
/// name: memory-service-config
|
||||||
|
/// namespace: poimen
|
||||||
|
/// data:
|
||||||
|
/// MEMORY_SERVICE_ADDR: "http://memory-service.poimen.svc.cluster.local:8080"
|
||||||
|
/// AUTHENTIK_ISSUER: "http://authentik.iam.svc.cluster.local/application/o/poimen-memory/"
|
||||||
|
/// WEBHOOK_URL: "http://temporal-webhook.temporal.svc.cluster.local:9000/webhook"
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ServiceConfig {
|
||||||
|
/// Memory service address (this service itself)
|
||||||
|
pub memory_service_addr: String,
|
||||||
|
|
||||||
|
/// Authentik OIDC issuer endpoint
|
||||||
|
pub authentik_issuer: String,
|
||||||
|
|
||||||
|
/// Temporal webhook callback URL
|
||||||
|
pub webhook_url: String,
|
||||||
|
|
||||||
|
/// OpenSearch cluster endpoint
|
||||||
|
pub opensearch_url: String,
|
||||||
|
|
||||||
|
/// PostgreSQL connection string
|
||||||
|
pub database_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceConfig {
|
||||||
|
/// Load configuration from environment variables (set by K8s ConfigMap)
|
||||||
|
/// Fails if required env vars are missing
|
||||||
|
pub fn from_env() -> Result<Self> {
|
||||||
|
let memory_service_addr = std::env::var("MEMORY_SERVICE_ADDR")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||||
|
|
||||||
|
let authentik_issuer = std::env::var("AUTHENTIK_ISSUER")
|
||||||
|
.map_err(|_| anyhow!("AUTHENTIK_ISSUER env var not set (configure in ConfigMap)"))?;
|
||||||
|
|
||||||
|
let webhook_url = std::env::var("WEBHOOK_URL")
|
||||||
|
.map_err(|_| anyhow!("WEBHOOK_URL env var not set (configure in ConfigMap)"))?;
|
||||||
|
|
||||||
|
let opensearch_url = std::env::var("OPENSEARCH_URL")
|
||||||
|
.map_err(|_| anyhow!("OPENSEARCH_URL env var not set (configure in ConfigMap)"))?;
|
||||||
|
|
||||||
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
|
.map_err(|_| anyhow!("DATABASE_URL env var not set (configure Secret or ConfigMap)"))?;
|
||||||
|
|
||||||
|
Ok(ServiceConfig {
|
||||||
|
memory_service_addr,
|
||||||
|
authentik_issuer,
|
||||||
|
webhook_url,
|
||||||
|
opensearch_url,
|
||||||
|
database_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load with defaults for development (localhost only)
|
||||||
|
pub fn from_env_dev() -> Self {
|
||||||
|
ServiceConfig {
|
||||||
|
memory_service_addr: std::env::var("MEMORY_SERVICE_ADDR")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:8080".to_string()),
|
||||||
|
authentik_issuer: std::env::var("AUTHENTIK_ISSUER")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:8080/application/o/poimen-memory/".to_string()),
|
||||||
|
webhook_url: std::env::var("WEBHOOK_URL")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:9000/webhook".to_string()),
|
||||||
|
opensearch_url: std::env::var("OPENSEARCH_URL")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:9200".to_string()),
|
||||||
|
database_url: std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "postgres://localhost/memory".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_from_env_dev() {
|
||||||
|
let config = ServiceConfig::from_env_dev();
|
||||||
|
assert_eq!(config.memory_service_addr, "http://localhost:8080");
|
||||||
|
assert!(config.authentik_issuer.contains("localhost"));
|
||||||
|
assert!(config.webhook_url.contains("localhost"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod config;
|
||||||
pub mod endpoints;
|
pub mod endpoints;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod http_server;
|
pub mod http_server;
|
||||||
|
|||||||
@@ -448,8 +448,10 @@ async fn cmd_learn(
|
|||||||
all_files.sort();
|
all_files.sort();
|
||||||
println!("Found {} markdown files", all_files.len());
|
println!("Found {} markdown files", all_files.len());
|
||||||
|
|
||||||
|
// Load configuration from environment (set by K8s ConfigMap)
|
||||||
|
let config = mem_cli::config::ServiceConfig::from_env_dev();
|
||||||
let api_url = std::env::var("MEM_API_URL")
|
let api_url = std::env::var("MEM_API_URL")
|
||||||
.unwrap_or_else(|_| "http://localhost:8080".to_string());
|
.unwrap_or_else(|_| config.memory_service_addr.clone());
|
||||||
let api_token = std::env::var("MEM_API_TOKEN").ok();
|
let api_token = std::env::var("MEM_API_TOKEN").ok();
|
||||||
let http = reqwest::Client::builder()
|
let http = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
//! Answer Validation & Confidence Scoring
|
||||||
|
//!
|
||||||
|
//! Validate query answers and assign confidence scores.
|
||||||
|
//! Multi-signal confidence aggregation (Zep alignment).
|
||||||
|
//!
|
||||||
|
//! CRAP: 15 (Multiple confidence signals)
|
||||||
|
//! SOLID: Single responsibility (answer validation)
|
||||||
|
//! DRY: Reuses score types from mem_core
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
/// Answer validation configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AnswerValidationConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub min_confidence_threshold: f32, // Minimum confidence to accept answer
|
||||||
|
pub require_evidence: bool, // Must have supporting facts
|
||||||
|
pub evidence_threshold: usize, // Minimum number of supporting facts
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AnswerValidationConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
min_confidence_threshold: 0.6,
|
||||||
|
require_evidence: true,
|
||||||
|
evidence_threshold: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer confidence signals
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConfidenceSignals {
|
||||||
|
/// Base search score (semantic + lexical combined)
|
||||||
|
pub search_score: f32,
|
||||||
|
/// Number of supporting facts
|
||||||
|
pub evidence_count: usize,
|
||||||
|
/// Average evidence confidence
|
||||||
|
pub evidence_confidence: f32,
|
||||||
|
/// Temporal consistency (0-1: higher = more recent)
|
||||||
|
pub temporal_score: f32,
|
||||||
|
/// Entity coverage (0-1: higher = all entities found)
|
||||||
|
pub entity_coverage: f32,
|
||||||
|
/// Contradiction score (0-1: higher = fewer contradictions)
|
||||||
|
pub contradiction_score: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer validation result
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ValidatedAnswer {
|
||||||
|
pub answer: String,
|
||||||
|
pub overall_confidence: f32, // 0-1
|
||||||
|
pub signals: ConfidenceSignals,
|
||||||
|
pub is_valid: bool, // Passes validation threshold
|
||||||
|
pub reasoning: String,
|
||||||
|
pub warning: Option<String>, // Low confidence or missing evidence
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer Validator
|
||||||
|
pub struct AnswerValidator {
|
||||||
|
config: AnswerValidationConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnswerValidator {
|
||||||
|
pub fn new(config: AnswerValidationConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute overall confidence from multiple signals
|
||||||
|
fn compute_confidence(&self, signals: &ConfidenceSignals) -> f32 {
|
||||||
|
if !self.config.enabled {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut weighted_sum = 0.0;
|
||||||
|
let mut weight_sum = 0.0;
|
||||||
|
|
||||||
|
// Search score: 0.4 weight
|
||||||
|
weighted_sum += signals.search_score * 0.4;
|
||||||
|
weight_sum += 0.4;
|
||||||
|
|
||||||
|
// Evidence: 0.25 weight
|
||||||
|
let evidence_score = (signals.evidence_count as f32 / 5.0).min(1.0) * signals.evidence_confidence;
|
||||||
|
weighted_sum += evidence_score * 0.25;
|
||||||
|
weight_sum += 0.25;
|
||||||
|
|
||||||
|
// Temporal recency: 0.15 weight
|
||||||
|
weighted_sum += signals.temporal_score * 0.15;
|
||||||
|
weight_sum += 0.15;
|
||||||
|
|
||||||
|
// Entity coverage: 0.1 weight
|
||||||
|
weighted_sum += signals.entity_coverage * 0.1;
|
||||||
|
weight_sum += 0.1;
|
||||||
|
|
||||||
|
// Contradiction: 0.1 weight
|
||||||
|
weighted_sum += signals.contradiction_score * 0.1;
|
||||||
|
weight_sum += 0.1;
|
||||||
|
|
||||||
|
(weighted_sum / weight_sum).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate answer based on configuration
|
||||||
|
pub fn validate(
|
||||||
|
&self,
|
||||||
|
answer: &str,
|
||||||
|
signals: &ConfidenceSignals,
|
||||||
|
) -> ValidatedAnswer {
|
||||||
|
if !self.config.enabled {
|
||||||
|
return ValidatedAnswer {
|
||||||
|
answer: answer.to_string(),
|
||||||
|
overall_confidence: 1.0,
|
||||||
|
signals: signals.clone(),
|
||||||
|
is_valid: true,
|
||||||
|
reasoning: "Validation disabled".to_string(),
|
||||||
|
warning: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let overall_confidence = self.compute_confidence(signals);
|
||||||
|
|
||||||
|
let mut warning = None;
|
||||||
|
let mut reasoning = String::new();
|
||||||
|
|
||||||
|
// Check confidence threshold
|
||||||
|
if overall_confidence < self.config.min_confidence_threshold {
|
||||||
|
warning = Some(format!(
|
||||||
|
"Low confidence: {:.2} (threshold: {:.2})",
|
||||||
|
overall_confidence, self.config.min_confidence_threshold
|
||||||
|
));
|
||||||
|
reasoning.push_str(&format!("Low confidence ({:.2}). ", overall_confidence));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check evidence
|
||||||
|
if self.config.require_evidence && signals.evidence_count < self.config.evidence_threshold {
|
||||||
|
warning = Some(format!(
|
||||||
|
"Insufficient evidence: {} facts (required: {})",
|
||||||
|
signals.evidence_count, self.config.evidence_threshold
|
||||||
|
));
|
||||||
|
reasoning.push_str(&format!(
|
||||||
|
"Insufficient evidence ({} facts). ",
|
||||||
|
signals.evidence_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for contradictions
|
||||||
|
if signals.contradiction_score < 0.5 {
|
||||||
|
warning = Some("Multiple contradictions detected in evidence".to_string());
|
||||||
|
reasoning.push_str("High contradiction risk. ");
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_valid = overall_confidence >= self.config.min_confidence_threshold
|
||||||
|
&& (!self.config.require_evidence
|
||||||
|
|| signals.evidence_count >= self.config.evidence_threshold);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Answer validation: confidence={:.2}, valid={}, evidence={}",
|
||||||
|
overall_confidence, is_valid, signals.evidence_count
|
||||||
|
);
|
||||||
|
|
||||||
|
ValidatedAnswer {
|
||||||
|
answer: answer.to_string(),
|
||||||
|
overall_confidence,
|
||||||
|
signals: signals.clone(),
|
||||||
|
is_valid,
|
||||||
|
reasoning: if reasoning.is_empty() {
|
||||||
|
format!("Valid answer (confidence: {:.2})", overall_confidence)
|
||||||
|
} else {
|
||||||
|
reasoning.trim_end().to_string()
|
||||||
|
},
|
||||||
|
warning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Batch validate multiple answers
|
||||||
|
pub fn validate_batch(
|
||||||
|
&self,
|
||||||
|
answers: &[(&str, &ConfidenceSignals)],
|
||||||
|
) -> Vec<ValidatedAnswer> {
|
||||||
|
answers
|
||||||
|
.iter()
|
||||||
|
.map(|(answer, signals)| self.validate(answer, signals))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_signals(
|
||||||
|
search: f32,
|
||||||
|
evidence: usize,
|
||||||
|
temporal: f32,
|
||||||
|
entity_cov: f32,
|
||||||
|
contra: f32,
|
||||||
|
) -> ConfidenceSignals {
|
||||||
|
ConfidenceSignals {
|
||||||
|
search_score: search,
|
||||||
|
evidence_count: evidence,
|
||||||
|
evidence_confidence: 0.8,
|
||||||
|
temporal_score: temporal,
|
||||||
|
entity_coverage: entity_cov,
|
||||||
|
contradiction_score: contra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validator_config_defaults() {
|
||||||
|
let config = AnswerValidationConfig::default();
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert_eq!(config.min_confidence_threshold, 0.6);
|
||||||
|
assert!(config.require_evidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_high_confidence() {
|
||||||
|
let config = AnswerValidationConfig::default();
|
||||||
|
let validator = AnswerValidator::new(config);
|
||||||
|
|
||||||
|
let signals = make_signals(0.9, 3, 0.9, 1.0, 1.0);
|
||||||
|
let result = validator.validate("High confidence answer", &signals);
|
||||||
|
|
||||||
|
assert!(result.is_valid);
|
||||||
|
assert!(result.overall_confidence > 0.8);
|
||||||
|
assert!(result.warning.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_low_confidence() {
|
||||||
|
let config = AnswerValidationConfig::default();
|
||||||
|
let validator = AnswerValidator::new(config);
|
||||||
|
|
||||||
|
let signals = make_signals(0.3, 0, 0.2, 0.2, 0.5);
|
||||||
|
let result = validator.validate("Low confidence answer", &signals);
|
||||||
|
|
||||||
|
assert!(!result.is_valid);
|
||||||
|
assert!(result.overall_confidence < 0.6);
|
||||||
|
assert!(result.warning.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_insufficient_evidence() {
|
||||||
|
let config = AnswerValidationConfig {
|
||||||
|
require_evidence: true,
|
||||||
|
evidence_threshold: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let validator = AnswerValidator::new(config);
|
||||||
|
|
||||||
|
let signals = make_signals(0.8, 1, 0.8, 1.0, 1.0); // Only 1 fact
|
||||||
|
let result = validator.validate("Answer with low evidence", &signals);
|
||||||
|
|
||||||
|
assert!(!result.is_valid);
|
||||||
|
assert!(result.warning.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_disabled() {
|
||||||
|
let config = AnswerValidationConfig {
|
||||||
|
enabled: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let validator = AnswerValidator::new(config);
|
||||||
|
|
||||||
|
let signals = make_signals(0.1, 0, 0.1, 0.0, 0.0);
|
||||||
|
let result = validator.validate("Any answer", &signals);
|
||||||
|
|
||||||
|
assert!(result.is_valid);
|
||||||
|
assert_eq!(result.overall_confidence, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_confidence_scoring() {
|
||||||
|
let config = AnswerValidationConfig::default();
|
||||||
|
let validator = AnswerValidator::new(config);
|
||||||
|
|
||||||
|
let signals = make_signals(0.8, 2, 0.9, 0.9, 0.9);
|
||||||
|
let result = validator.validate("Test", &signals);
|
||||||
|
|
||||||
|
// Check that overall confidence is computed reasonably
|
||||||
|
assert!(result.overall_confidence > 0.7);
|
||||||
|
assert!(result.overall_confidence <= 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_contradiction_warning() {
|
||||||
|
let config = AnswerValidationConfig::default();
|
||||||
|
let validator = AnswerValidator::new(config);
|
||||||
|
|
||||||
|
let signals = make_signals(0.8, 3, 0.8, 0.9, 0.3); // Low contradiction score
|
||||||
|
let result = validator.validate("Contradictory answer", &signals);
|
||||||
|
|
||||||
|
assert!(result.warning.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_batch_validate() {
|
||||||
|
let config = AnswerValidationConfig::default();
|
||||||
|
let validator = AnswerValidator::new(config);
|
||||||
|
|
||||||
|
let signals1 = make_signals(0.9, 3, 0.9, 1.0, 1.0);
|
||||||
|
let signals2 = make_signals(0.2, 0, 0.2, 0.0, 0.5);
|
||||||
|
|
||||||
|
let answers = vec![
|
||||||
|
("Good answer", &signals1),
|
||||||
|
("Bad answer", &signals2),
|
||||||
|
];
|
||||||
|
|
||||||
|
let results = validator.validate_batch(&answers);
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert!(results[0].is_valid);
|
||||||
|
assert!(!results[1].is_valid);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
//! Community Detection Metrics & Statistics
|
||||||
|
//!
|
||||||
|
//! Compute statistics for detected communities (Zep alignment).
|
||||||
|
//! Modularity, density, cohesion metrics.
|
||||||
|
//!
|
||||||
|
//! CRAP: 14 (Graph metric calculations)
|
||||||
|
//! SOLID: Single responsibility (metrics computation)
|
||||||
|
//! DRY: Reuses community types from queries
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Community metrics configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MetricsConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub compute_modularity: bool,
|
||||||
|
pub compute_density: bool,
|
||||||
|
pub compute_cohesion: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MetricsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
compute_modularity: true,
|
||||||
|
compute_density: true,
|
||||||
|
compute_cohesion: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Community statistics
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CommunityMetrics {
|
||||||
|
pub community_id: String,
|
||||||
|
pub member_count: usize,
|
||||||
|
pub edge_count: usize,
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
pub modularity: Option<f32>, // 0-1: higher = more cohesive
|
||||||
|
pub density: Option<f32>, // 0-1: higher = more interconnected
|
||||||
|
pub cohesion: Option<f32>, // 0-1: higher = stronger connections
|
||||||
|
pub average_degree: f32, // Avg edges per node
|
||||||
|
pub diameter: Option<usize>, // Max shortest path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Community metrics calculator
|
||||||
|
pub struct CommunityMetricsCalculator {
|
||||||
|
config: MetricsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommunityMetricsCalculator {
|
||||||
|
pub fn new(config: MetricsConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate modularity (range: -1 to 1, higher = better community structure)
|
||||||
|
/// Simplified: how many edges are within community vs expected
|
||||||
|
fn calculate_modularity(
|
||||||
|
&self,
|
||||||
|
members: &[String],
|
||||||
|
edges: &[(String, String)],
|
||||||
|
) -> Option<f32> {
|
||||||
|
if !self.config.compute_modularity || members.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let member_set: HashSet<_> = members.iter().cloned().collect();
|
||||||
|
let member_count = members.len() as f32;
|
||||||
|
|
||||||
|
// Count internal edges
|
||||||
|
let internal_edges = edges
|
||||||
|
.iter()
|
||||||
|
.filter(|(a, b)| member_set.contains(a) && member_set.contains(b))
|
||||||
|
.count() as f32;
|
||||||
|
|
||||||
|
// Expected edges in random network
|
||||||
|
let total_possible = member_count * (member_count - 1.0) / 2.0;
|
||||||
|
let edge_density = edges.len() as f32 / total_possible.max(1.0);
|
||||||
|
|
||||||
|
// Modularity = (actual - expected) / total
|
||||||
|
let expected_internal = edge_density * total_possible;
|
||||||
|
let modularity = if total_possible > 0.0 {
|
||||||
|
(internal_edges - expected_internal) / total_possible.max(1.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(modularity.clamp(-1.0, 1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate density (range: 0-1, ratio of edges to possible edges)
|
||||||
|
fn calculate_density(
|
||||||
|
&self,
|
||||||
|
members: &[String],
|
||||||
|
edges: &[(String, String)],
|
||||||
|
) -> Option<f32> {
|
||||||
|
if !self.config.compute_density || members.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let member_set: HashSet<_> = members.iter().cloned().collect();
|
||||||
|
let member_count = members.len() as f32;
|
||||||
|
|
||||||
|
// Count internal edges
|
||||||
|
let internal_edges = edges
|
||||||
|
.iter()
|
||||||
|
.filter(|(a, b)| member_set.contains(a) && member_set.contains(b))
|
||||||
|
.count() as f32;
|
||||||
|
|
||||||
|
// Max possible edges for undirected graph
|
||||||
|
let max_edges = member_count * (member_count - 1.0) / 2.0;
|
||||||
|
|
||||||
|
if max_edges > 0.0 {
|
||||||
|
Some((internal_edges / max_edges).clamp(0.0, 1.0))
|
||||||
|
} else {
|
||||||
|
Some(0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate cohesion (average edge weight/strength)
|
||||||
|
fn calculate_cohesion(
|
||||||
|
&self,
|
||||||
|
members: &[String],
|
||||||
|
edges: &[(String, String)],
|
||||||
|
edge_strengths: &[(String, String, f32)],
|
||||||
|
) -> Option<f32> {
|
||||||
|
if !self.config.compute_cohesion || edges.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let member_set: HashSet<_> = members.iter().cloned().collect();
|
||||||
|
|
||||||
|
// Average strength of internal edges
|
||||||
|
let internal_strengths: Vec<f32> = edge_strengths
|
||||||
|
.iter()
|
||||||
|
.filter(|(a, b, _)| member_set.contains(a) && member_set.contains(b))
|
||||||
|
.map(|(_, _, strength)| *strength)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if internal_strengths.is_empty() {
|
||||||
|
return Some(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let avg_strength = internal_strengths.iter().sum::<f32>() / internal_strengths.len() as f32;
|
||||||
|
Some(avg_strength.clamp(0.0, 1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate average degree
|
||||||
|
fn calculate_average_degree(
|
||||||
|
&self,
|
||||||
|
members: &[String],
|
||||||
|
edges: &[(String, String)],
|
||||||
|
) -> f32 {
|
||||||
|
if members.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let member_set: HashSet<_> = members.iter().cloned().collect();
|
||||||
|
|
||||||
|
let mut degree_map: HashMap<String, usize> = members.iter().cloned().map(|m| (m, 0)).collect();
|
||||||
|
|
||||||
|
for (a, b) in edges {
|
||||||
|
if member_set.contains(a) && member_set.contains(b) {
|
||||||
|
*degree_map.entry(a.clone()).or_insert(0) += 1;
|
||||||
|
*degree_map.entry(b.clone()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_degree: usize = degree_map.values().sum();
|
||||||
|
total_degree as f32 / members.len() as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute all metrics for a community
|
||||||
|
pub fn compute(
|
||||||
|
&self,
|
||||||
|
community_id: &str,
|
||||||
|
members: &[String],
|
||||||
|
edges: &[(String, String)],
|
||||||
|
edge_strengths: Option<&[(String, String, f32)]>,
|
||||||
|
) -> CommunityMetrics {
|
||||||
|
debug!("Computing metrics for community: {} ({} members)", community_id, members.len());
|
||||||
|
|
||||||
|
let edge_count = edges.len();
|
||||||
|
let average_degree = self.calculate_average_degree(members, edges);
|
||||||
|
let modularity = self.calculate_modularity(members, edges);
|
||||||
|
let density = self.calculate_density(members, edges);
|
||||||
|
let cohesion = edge_strengths.and_then(|es| self.calculate_cohesion(members, edges, es));
|
||||||
|
|
||||||
|
CommunityMetrics {
|
||||||
|
community_id: community_id.to_string(),
|
||||||
|
member_count: members.len(),
|
||||||
|
edge_count,
|
||||||
|
modularity,
|
||||||
|
density,
|
||||||
|
cohesion,
|
||||||
|
average_degree,
|
||||||
|
diameter: None, // TODO: implement BFS shortest path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rank communities by metric
|
||||||
|
pub fn rank_by_metric(
|
||||||
|
metrics: &[CommunityMetrics],
|
||||||
|
metric: &str,
|
||||||
|
) -> Vec<&CommunityMetrics> {
|
||||||
|
let mut sorted = metrics.iter().collect::<Vec<_>>();
|
||||||
|
|
||||||
|
match metric {
|
||||||
|
"modularity" => sorted.sort_by(|a, b| {
|
||||||
|
b.modularity
|
||||||
|
.partial_cmp(&a.modularity)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
}),
|
||||||
|
"density" => sorted.sort_by(|a, b| {
|
||||||
|
b.density
|
||||||
|
.partial_cmp(&a.density)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
}),
|
||||||
|
"cohesion" => sorted.sort_by(|a, b| {
|
||||||
|
b.cohesion
|
||||||
|
.partial_cmp(&a.cohesion)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
}),
|
||||||
|
"size" => sorted.sort_by(|a, b| b.member_count.cmp(&a.member_count)),
|
||||||
|
"degree" => sorted.sort_by(|a, b| {
|
||||||
|
b.average_degree
|
||||||
|
.partial_cmp(&a.average_degree)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
}),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metrics_config_defaults() {
|
||||||
|
let config = MetricsConfig::default();
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert!(config.compute_modularity);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_density_full() {
|
||||||
|
let config = MetricsConfig::default();
|
||||||
|
let calc = CommunityMetricsCalculator::new(config);
|
||||||
|
|
||||||
|
let members = vec!["A".to_string(), "B".to_string(), "C".to_string()];
|
||||||
|
let edges = vec![
|
||||||
|
("A".to_string(), "B".to_string()),
|
||||||
|
("B".to_string(), "C".to_string()),
|
||||||
|
("C".to_string(), "A".to_string()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let density = calc.calculate_density(&members, &edges);
|
||||||
|
assert!(density.is_some());
|
||||||
|
// Full graph: 3 edges / 3 possible = 1.0
|
||||||
|
assert_eq!(density.unwrap(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_density_sparse() {
|
||||||
|
let config = MetricsConfig::default();
|
||||||
|
let calc = CommunityMetricsCalculator::new(config);
|
||||||
|
|
||||||
|
let members = vec!["A".to_string(), "B".to_string(), "C".to_string()];
|
||||||
|
let edges = vec![("A".to_string(), "B".to_string())]; // Only 1 edge
|
||||||
|
|
||||||
|
let density = calc.calculate_density(&members, &edges);
|
||||||
|
assert!(density.is_some());
|
||||||
|
// Sparse graph: 1 edge / 3 possible = 0.333...
|
||||||
|
assert!(density.unwrap() < 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_average_degree() {
|
||||||
|
let config = MetricsConfig::default();
|
||||||
|
let calc = CommunityMetricsCalculator::new(config);
|
||||||
|
|
||||||
|
let members = vec!["A".to_string(), "B".to_string(), "C".to_string()];
|
||||||
|
let edges = vec![
|
||||||
|
("A".to_string(), "B".to_string()),
|
||||||
|
("B".to_string(), "C".to_string()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let avg_degree = calc.calculate_average_degree(&members, &edges);
|
||||||
|
// A: 1, B: 2, C: 1 → avg = 4/3 ≈ 1.33
|
||||||
|
assert!(avg_degree > 1.0 && avg_degree < 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compute_metrics() {
|
||||||
|
let config = MetricsConfig::default();
|
||||||
|
let calc = CommunityMetricsCalculator::new(config);
|
||||||
|
|
||||||
|
let members = vec!["A".to_string(), "B".to_string(), "C".to_string()];
|
||||||
|
let edges = vec![
|
||||||
|
("A".to_string(), "B".to_string()),
|
||||||
|
("B".to_string(), "C".to_string()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let metrics = calc.compute("community-1", &members, &edges, None);
|
||||||
|
|
||||||
|
assert_eq!(metrics.community_id, "community-1");
|
||||||
|
assert_eq!(metrics.member_count, 3);
|
||||||
|
assert_eq!(metrics.edge_count, 2);
|
||||||
|
assert!(metrics.modularity.is_some());
|
||||||
|
assert!(metrics.density.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rank_by_size() {
|
||||||
|
let metrics = vec![
|
||||||
|
CommunityMetrics {
|
||||||
|
community_id: "c1".to_string(),
|
||||||
|
member_count: 5,
|
||||||
|
edge_count: 0,
|
||||||
|
modularity: None,
|
||||||
|
density: None,
|
||||||
|
cohesion: None,
|
||||||
|
average_degree: 0.0,
|
||||||
|
diameter: None,
|
||||||
|
},
|
||||||
|
CommunityMetrics {
|
||||||
|
community_id: "c2".to_string(),
|
||||||
|
member_count: 10,
|
||||||
|
edge_count: 0,
|
||||||
|
modularity: None,
|
||||||
|
density: None,
|
||||||
|
cohesion: None,
|
||||||
|
average_degree: 0.0,
|
||||||
|
diameter: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let ranked = CommunityMetricsCalculator::rank_by_metric(&metrics, "size");
|
||||||
|
|
||||||
|
assert_eq!(ranked[0].community_id, "c2"); // Largest first
|
||||||
|
assert_eq!(ranked[1].community_id, "c1");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ pub mod inference_engine;
|
|||||||
pub mod query_reasoner;
|
pub mod query_reasoner;
|
||||||
pub mod summarizer;
|
pub mod summarizer;
|
||||||
pub mod zep_prompts;
|
pub mod zep_prompts;
|
||||||
|
pub mod temporal_query;
|
||||||
|
pub mod answer_validator;
|
||||||
|
pub mod community_metrics;
|
||||||
|
|
||||||
pub use pagination::{PaginationParams, PaginationMeta};
|
pub use pagination::{PaginationParams, PaginationMeta};
|
||||||
pub use bfs_graph_traversal::{BfsGraphTraversal, GraphData, DepthBreakdown};
|
pub use bfs_graph_traversal::{BfsGraphTraversal, GraphData, DepthBreakdown};
|
||||||
@@ -35,3 +38,6 @@ pub use zep_prompts::{
|
|||||||
ENTITY_EXTRACTION_PROMPT, ENTITY_RESOLUTION_PROMPT, FACT_EXTRACTION_PROMPT,
|
ENTITY_EXTRACTION_PROMPT, ENTITY_RESOLUTION_PROMPT, FACT_EXTRACTION_PROMPT,
|
||||||
FACT_RESOLUTION_PROMPT, TEMPORAL_EXTRACTION_PROMPT,
|
FACT_RESOLUTION_PROMPT, TEMPORAL_EXTRACTION_PROMPT,
|
||||||
};
|
};
|
||||||
|
pub use temporal_query::{TemporalQuery, TemporalQueryConfig, TemporalQueryResult, TemporalFilter};
|
||||||
|
pub use answer_validator::{AnswerValidator, AnswerValidationConfig, ConfidenceSignals, ValidatedAnswer};
|
||||||
|
pub use community_metrics::{CommunityMetricsCalculator, CommunityMetrics, MetricsConfig};
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
//! Temporal Query Support: As-Of-Date Queries
|
||||||
|
//!
|
||||||
|
//! Query memory state at a specific point in time.
|
||||||
|
//! Essential for reconstructing historical knowledge state (Zep alignment).
|
||||||
|
//!
|
||||||
|
//! CRAP: 12 (Temporal filtering logic)
|
||||||
|
//! SOLID: Single responsibility (temporal queries)
|
||||||
|
//! DRY: Reuses query types from mem_core
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
/// Temporal query configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TemporalQueryConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub allow_future_dates: bool, // Allow querying past future dates
|
||||||
|
pub default_to_now: bool, // If no time specified, use NOW()
|
||||||
|
pub max_lookback_days: Option<i64>, // Limit how far back to query
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TemporalQueryConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
allow_future_dates: false,
|
||||||
|
default_to_now: true,
|
||||||
|
max_lookback_days: Some(365 * 5), // 5 years
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Temporal query specification
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TemporalQuery {
|
||||||
|
/// Base query text
|
||||||
|
pub query: String,
|
||||||
|
/// Point in time to query at
|
||||||
|
pub as_of_time: DateTime<Utc>,
|
||||||
|
/// Optional: time range for temporal search
|
||||||
|
pub time_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Temporal query result
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TemporalQueryResult {
|
||||||
|
pub query: String,
|
||||||
|
pub as_of_time: DateTime<Utc>,
|
||||||
|
pub num_facts: usize,
|
||||||
|
pub valid_facts: usize, // Facts valid at as_of_time
|
||||||
|
pub invalid_facts: usize, // Facts invalid at as_of_time
|
||||||
|
pub note: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Temporal filter for edges
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TemporalFilter {
|
||||||
|
config: TemporalQueryConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TemporalFilter {
|
||||||
|
pub fn new(config: TemporalQueryConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate query time
|
||||||
|
pub fn validate_query_time(&self, time: DateTime<Utc>) -> Result<(), String> {
|
||||||
|
if !self.config.enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
// Check if querying future
|
||||||
|
if !self.config.allow_future_dates && time > now {
|
||||||
|
return Err(format!(
|
||||||
|
"Cannot query future time: {} (now: {})",
|
||||||
|
time, now
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check lookback limit
|
||||||
|
if let Some(max_days) = self.config.max_lookback_days {
|
||||||
|
let cutoff = now - chrono::Duration::days(max_days);
|
||||||
|
if time < cutoff {
|
||||||
|
return Err(format!(
|
||||||
|
"Query time {} exceeds max lookback of {} days",
|
||||||
|
time, max_days
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if edge is valid at point in time
|
||||||
|
/// Returns: (is_valid_at_time, is_expired_at_time)
|
||||||
|
pub fn is_edge_valid_at_time(
|
||||||
|
&self,
|
||||||
|
t_valid: Option<DateTime<Utc>>,
|
||||||
|
t_invalid: Option<DateTime<Utc>>,
|
||||||
|
query_time: DateTime<Utc>,
|
||||||
|
) -> (bool, bool) {
|
||||||
|
if !self.config.enabled {
|
||||||
|
return (true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge is valid if:
|
||||||
|
// - t_valid is None or <= query_time (became true at/before query time)
|
||||||
|
// - t_invalid is None or > query_time (didn't become false before query time)
|
||||||
|
let is_valid = (t_valid.is_none() || t_valid.unwrap() <= query_time)
|
||||||
|
&& (t_invalid.is_none() || t_invalid.unwrap() > query_time);
|
||||||
|
|
||||||
|
let is_expired = t_invalid.is_some() && t_invalid.unwrap() <= query_time;
|
||||||
|
|
||||||
|
(is_valid, is_expired)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get SQL WHERE clause for temporal filtering
|
||||||
|
pub fn sql_where_clause(
|
||||||
|
&self,
|
||||||
|
query_time: DateTime<Utc>,
|
||||||
|
table_prefix: &str,
|
||||||
|
) -> String {
|
||||||
|
if !self.config.enabled {
|
||||||
|
return format!("{}.t_expired IS NULL", table_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"({p}.t_valid IS NULL OR {p}.t_valid <= '{time}') AND \
|
||||||
|
({p}.t_invalid IS NULL OR {p}.t_invalid > '{time}') AND \
|
||||||
|
{p}.t_expired IS NULL",
|
||||||
|
p = table_prefix,
|
||||||
|
time = query_time.to_rfc3339()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_temporal_config_defaults() {
|
||||||
|
let config = TemporalQueryConfig::default();
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert!(!config.allow_future_dates);
|
||||||
|
assert!(config.default_to_now);
|
||||||
|
assert_eq!(config.max_lookback_days, Some(365 * 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_query_time_now() {
|
||||||
|
let config = TemporalQueryConfig::default();
|
||||||
|
let filter = TemporalFilter::new(config);
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
assert!(filter.validate_query_time(now).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_query_time_past() {
|
||||||
|
let config = TemporalQueryConfig::default();
|
||||||
|
let filter = TemporalFilter::new(config);
|
||||||
|
|
||||||
|
let past = Utc::now() - chrono::Duration::days(30);
|
||||||
|
assert!(filter.validate_query_time(past).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_query_time_future_disallowed() {
|
||||||
|
let config = TemporalQueryConfig {
|
||||||
|
allow_future_dates: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let filter = TemporalFilter::new(config);
|
||||||
|
|
||||||
|
let future = Utc::now() + chrono::Duration::days(30);
|
||||||
|
assert!(filter.validate_query_time(future).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_query_time_future_allowed() {
|
||||||
|
let config = TemporalQueryConfig {
|
||||||
|
allow_future_dates: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let filter = TemporalFilter::new(config);
|
||||||
|
|
||||||
|
let future = Utc::now() + chrono::Duration::days(30);
|
||||||
|
assert!(filter.validate_query_time(future).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_edge_valid_at_time_current() {
|
||||||
|
let config = TemporalQueryConfig::default();
|
||||||
|
let filter = TemporalFilter::new(config);
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let past = now - chrono::Duration::days(10);
|
||||||
|
|
||||||
|
// Edge valid from past, still active
|
||||||
|
let (is_valid, is_expired) = filter.is_edge_valid_at_time(Some(past), None, now);
|
||||||
|
assert!(is_valid);
|
||||||
|
assert!(!is_expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_edge_valid_at_time_expired() {
|
||||||
|
let config = TemporalQueryConfig::default();
|
||||||
|
let filter = TemporalFilter::new(config);
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let past = now - chrono::Duration::days(10);
|
||||||
|
let future = now + chrono::Duration::days(10);
|
||||||
|
|
||||||
|
// Edge valid from past, became invalid before now
|
||||||
|
let (is_valid, is_expired) = filter.is_edge_valid_at_time(Some(past), Some(now - chrono::Duration::days(1)), now);
|
||||||
|
assert!(!is_valid);
|
||||||
|
assert!(is_expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_edge_valid_at_time_historical() {
|
||||||
|
let config = TemporalQueryConfig::default();
|
||||||
|
let filter = TemporalFilter::new(config);
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let past_30 = now - chrono::Duration::days(30);
|
||||||
|
let past_10 = now - chrono::Duration::days(10);
|
||||||
|
let past_5 = now - chrono::Duration::days(5);
|
||||||
|
|
||||||
|
// Query at 30 days ago: edge didn't exist yet
|
||||||
|
let (is_valid, _) = filter.is_edge_valid_at_time(Some(past_10), Some(past_5), past_30);
|
||||||
|
assert!(!is_valid);
|
||||||
|
|
||||||
|
// Query at 8 days ago: edge was valid
|
||||||
|
let (is_valid, _) = filter.is_edge_valid_at_time(Some(past_10), Some(past_5), now - chrono::Duration::days(8));
|
||||||
|
assert!(is_valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sql_where_clause() {
|
||||||
|
let config = TemporalQueryConfig::default();
|
||||||
|
let filter = TemporalFilter::new(config);
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let clause = filter.sql_where_clause(now, "e");
|
||||||
|
|
||||||
|
assert!(clause.contains("e.t_valid IS NULL OR e.t_valid <="));
|
||||||
|
assert!(clause.contains("e.t_invalid IS NULL OR e.t_invalid >"));
|
||||||
|
assert!(clause.contains("e.t_expired IS NULL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sql_where_clause_disabled() {
|
||||||
|
let config = TemporalQueryConfig {
|
||||||
|
enabled: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let filter = TemporalFilter::new(config);
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let clause = filter.sql_where_clause(now, "e");
|
||||||
|
|
||||||
|
// When disabled, only check t_expired
|
||||||
|
assert_eq!(clause, "e.t_expired IS NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ pub struct RoutedResult {
|
|||||||
pub prefilter_size: usize,
|
pub prefilter_size: usize,
|
||||||
pub metrics: SelectionMetrics,
|
pub metrics: SelectionMetrics,
|
||||||
pub latency_ms: u64,
|
pub latency_ms: u64,
|
||||||
|
pub confidence_score: f32, // Multi-signal confidence (0-1)
|
||||||
|
pub is_valid: bool, // Passes validation gate
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selected chunk with all scores
|
/// Selected chunk with all scores
|
||||||
@@ -164,6 +166,21 @@ impl QueryRouter {
|
|||||||
|
|
||||||
let latency_ms = start.elapsed().as_millis() as u64;
|
let latency_ms = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
// Phase 8: Answer Validation (confidence scoring)
|
||||||
|
use crate::answer_validator::{AnswerValidator, AnswerValidationConfig, ConfidenceSignals};
|
||||||
|
let validator = AnswerValidator::new(AnswerValidationConfig::default());
|
||||||
|
let avg_score = selected_chunks.iter().map(|c| c.final_score).sum::<f32>()
|
||||||
|
/ (selected_chunks.len() as f32).max(1.0);
|
||||||
|
let signals = ConfidenceSignals {
|
||||||
|
search_score: avg_score,
|
||||||
|
evidence_count: selected_chunks.len(),
|
||||||
|
evidence_confidence: avg_score,
|
||||||
|
temporal_score: 0.9, // Assume recent chunks
|
||||||
|
entity_coverage: 0.85,
|
||||||
|
contradiction_score: 1.0, // No contradictions by default
|
||||||
|
};
|
||||||
|
let validated = validator.validate("", &signals);
|
||||||
|
|
||||||
Ok(RoutedResult {
|
Ok(RoutedResult {
|
||||||
selected_chunks,
|
selected_chunks,
|
||||||
route,
|
route,
|
||||||
@@ -171,6 +188,8 @@ impl QueryRouter {
|
|||||||
prefilter_size,
|
prefilter_size,
|
||||||
metrics,
|
metrics,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
|
confidence_score: validated.overall_confidence,
|
||||||
|
is_valid: validated.is_valid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use anyhow::Result;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use mem_core::entity::{Entity, EntityType};
|
use mem_core::entity::{Entity, EntityType};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::speaker_extractor::SpeakerExtractor;
|
||||||
|
|
||||||
/// Extracted entity from LLM (intermediate representation)
|
/// Extracted entity from LLM (intermediate representation)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -98,6 +99,21 @@ impl LlmEntityExtractor {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl EntityExtractor for LlmEntityExtractor {
|
impl EntityExtractor for LlmEntityExtractor {
|
||||||
async fn extract(&self, text: &str) -> Result<Vec<ExtractedEntity>> {
|
async fn extract(&self, text: &str) -> Result<Vec<ExtractedEntity>> {
|
||||||
|
let mut entities = vec![];
|
||||||
|
|
||||||
|
// Stage 0: Extract speaker (first entity - Zep alignment)
|
||||||
|
use crate::speaker_extractor::{HeuristicSpeakerExtractor, SpeakerConfig};
|
||||||
|
if let Ok(speaker_extractor) = HeuristicSpeakerExtractor::new(SpeakerConfig::default()) {
|
||||||
|
if let Ok(Some(speaker)) = speaker_extractor.extract_speaker(text).await {
|
||||||
|
entities.push(ExtractedEntity {
|
||||||
|
name: speaker.name,
|
||||||
|
entity_type: mem_core::entity::EntityType::Person,
|
||||||
|
summary: "Speaker in this episode".to_string(),
|
||||||
|
confidence: speaker.confidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stage 1: Extract entities
|
// Stage 1: Extract entities
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
r#"Extract named entities from this text.
|
r#"Extract named entities from this text.
|
||||||
@@ -119,7 +135,8 @@ Respond in JSON:
|
|||||||
);
|
);
|
||||||
|
|
||||||
let extraction_response = self.simulate_llm(&prompt).await?;
|
let extraction_response = self.simulate_llm(&prompt).await?;
|
||||||
let mut entities = Self::parse_extraction(&extraction_response)?;
|
let extracted = Self::parse_extraction(&extraction_response)?;
|
||||||
|
entities.extend(extracted); // Add LLM-extracted entities after speaker
|
||||||
|
|
||||||
// Stage 2: Reflection verification (filter hallucinations)
|
// Stage 2: Reflection verification (filter hallucinations)
|
||||||
if self.enable_reflection {
|
if self.enable_reflection {
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ pub struct ExtractedFact {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
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())
|
||||||
|
async fn extract_with_context(
|
||||||
|
&self,
|
||||||
|
text: &str,
|
||||||
|
_entity_contexts: &[crate::grm_retriever::EntityContext],
|
||||||
|
) -> Result<Vec<ExtractedFact>> {
|
||||||
|
// Default: ignore context, use plain extraction
|
||||||
|
self.extract(text).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple fact extractor based on verb patterns
|
/// Simple fact extractor based on verb patterns
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
//! Graph Retrieval Memory (GRM) Context Retriever
|
||||||
|
//!
|
||||||
|
//! Query existing graph to validate & enrich entity/fact extraction.
|
||||||
|
//! Confirms "memorability" before committing to storage.
|
||||||
|
//!
|
||||||
|
//! CRAP: 18 (Database queries + scoring logic)
|
||||||
|
//! SOLID: Single responsibility (retrieve context), delegates scoring
|
||||||
|
//! DRY: Reuses entity/edge types from mem_core
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
use mem_core::entity::Entity;
|
||||||
|
use mem_core::edge::Edge;
|
||||||
|
|
||||||
|
/// Memorability decision for entity or fact
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum MemorabilityDecision {
|
||||||
|
/// Entity/fact already exists, merge with it
|
||||||
|
Merge,
|
||||||
|
/// New entity/fact, worth storing
|
||||||
|
Keep,
|
||||||
|
/// Noise or irrelevant, skip
|
||||||
|
Drop,
|
||||||
|
/// Low confidence, queue for human review
|
||||||
|
ReviewQueue,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context about an entity from the graph
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EntityContext {
|
||||||
|
pub entity_name: String,
|
||||||
|
pub matched_entity_id: Option<String>, // If found in graph
|
||||||
|
pub related_entities: Vec<(String, String)>, // (id, name)
|
||||||
|
pub related_edges_count: usize,
|
||||||
|
pub summary: String, // "Rock: DevOps expert with K8s/ArgoCD expertise"
|
||||||
|
pub memorability_score: f32, // 0-1
|
||||||
|
pub decision: MemorabilityDecision,
|
||||||
|
pub reasoning: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context about a fact from the graph
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FactContext {
|
||||||
|
pub similar_facts_found: usize,
|
||||||
|
pub contradictory_facts_found: usize,
|
||||||
|
pub related_entities_coverage: f32, // Fraction of entities that exist
|
||||||
|
pub memorability_score: f32, // 0-1
|
||||||
|
pub decision: MemorabilityDecision,
|
||||||
|
pub reasoning: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Graph Retrieval Memory configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GrmConfig {
|
||||||
|
pub enabled: bool, // Enable/disable GRM gate
|
||||||
|
pub entity_similarity_threshold: f32, // Default: 0.7
|
||||||
|
pub max_entity_context_size: usize, // Default: 10
|
||||||
|
pub max_related_edges: usize, // Default: 20
|
||||||
|
pub entity_memorability_threshold: f32, // Default: 0.75 (>= continue, < review)
|
||||||
|
pub fact_memorability_threshold: f32, // Default: 0.75
|
||||||
|
pub fact_drop_threshold: f32, // Default: 0.50 (< drop)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GrmConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false, // Disabled by default (Phase 2.5 TBD)
|
||||||
|
entity_similarity_threshold: 0.7,
|
||||||
|
max_entity_context_size: 10,
|
||||||
|
max_related_edges: 20,
|
||||||
|
entity_memorability_threshold: 0.75,
|
||||||
|
fact_memorability_threshold: 0.75,
|
||||||
|
fact_drop_threshold: 0.50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Graph Context Retriever trait
|
||||||
|
#[async_trait]
|
||||||
|
pub trait GraphContextRetriever: Send + Sync {
|
||||||
|
/// Get context for an entity from the graph
|
||||||
|
async fn get_entity_context(
|
||||||
|
&self,
|
||||||
|
entity_name: &str,
|
||||||
|
) -> Result<EntityContext>;
|
||||||
|
|
||||||
|
/// Get context for a fact from the graph
|
||||||
|
async fn get_fact_context(
|
||||||
|
&self,
|
||||||
|
source_entity_id: &str,
|
||||||
|
target_entity_id: &str,
|
||||||
|
relation_type: &str,
|
||||||
|
fact_text: &str,
|
||||||
|
) -> Result<FactContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mock GRM Retriever for testing (always returns KEEP)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MockGrmRetriever;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl GraphContextRetriever for MockGrmRetriever {
|
||||||
|
async fn get_entity_context(&self, entity_name: &str) -> Result<EntityContext> {
|
||||||
|
debug!("MockGrmRetriever: get_entity_context({})", entity_name);
|
||||||
|
|
||||||
|
Ok(EntityContext {
|
||||||
|
entity_name: entity_name.to_string(),
|
||||||
|
matched_entity_id: None,
|
||||||
|
related_entities: vec![],
|
||||||
|
related_edges_count: 0,
|
||||||
|
summary: format!("Mock entity: {}", entity_name),
|
||||||
|
memorability_score: 0.95,
|
||||||
|
decision: MemorabilityDecision::Keep,
|
||||||
|
reasoning: "Mock: no graph available".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_fact_context(
|
||||||
|
&self,
|
||||||
|
_source: &str,
|
||||||
|
_target: &str,
|
||||||
|
_relation: &str,
|
||||||
|
fact_text: &str,
|
||||||
|
) -> Result<FactContext> {
|
||||||
|
debug!("MockGrmRetriever: get_fact_context({})", fact_text);
|
||||||
|
|
||||||
|
Ok(FactContext {
|
||||||
|
similar_facts_found: 0,
|
||||||
|
contradictory_facts_found: 0,
|
||||||
|
related_entities_coverage: 1.0,
|
||||||
|
memorability_score: 0.95,
|
||||||
|
decision: MemorabilityDecision::Keep,
|
||||||
|
reasoning: "Mock: no graph available".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Postgres-backed GRM Retriever (to be implemented in Phase 2.5)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PostgresGrmRetriever {
|
||||||
|
config: GrmConfig,
|
||||||
|
// pool: PgPool, // TODO (Phase 2.5): Add database connection
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresGrmRetriever {
|
||||||
|
pub fn new(config: GrmConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score entity memorability (0-1)
|
||||||
|
/// Higher = more memorable (more related facts, exact match, etc.)
|
||||||
|
fn score_entity_memorability(
|
||||||
|
&self,
|
||||||
|
matched: bool,
|
||||||
|
related_edges_count: usize,
|
||||||
|
) -> f32 {
|
||||||
|
if matched {
|
||||||
|
// Existing entity: very memorable
|
||||||
|
// Bonus: more related edges = more established
|
||||||
|
let edge_bonus = (related_edges_count as f32 / 10.0).min(0.2);
|
||||||
|
0.8 + edge_bonus // 0.8-1.0
|
||||||
|
} else {
|
||||||
|
// New entity: less memorable unless connecting to existing graph
|
||||||
|
if related_edges_count > 0 {
|
||||||
|
0.6 + (related_edges_count as f32 / 20.0).min(0.2) // 0.6-0.8
|
||||||
|
} else {
|
||||||
|
0.5 // Isolated entity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score fact memorability (0-1)
|
||||||
|
/// Higher = more memorable (novel fact, no contradictions, etc.)
|
||||||
|
fn score_fact_memorability(
|
||||||
|
&self,
|
||||||
|
similar_facts: usize,
|
||||||
|
contradictions: usize,
|
||||||
|
entity_coverage: f32,
|
||||||
|
extraction_confidence: Option<f32>,
|
||||||
|
) -> f32 {
|
||||||
|
let mut score = 0.5;
|
||||||
|
|
||||||
|
// Novel fact: +0.3 (no similar facts)
|
||||||
|
score += if similar_facts == 0 { 0.3 } else { -0.1 * (similar_facts as f32).min(3.0) };
|
||||||
|
|
||||||
|
// No contradictions: +0.2
|
||||||
|
score += if contradictions == 0 { 0.2 } else { -0.15 * (contradictions as f32) };
|
||||||
|
|
||||||
|
// Entity coverage: +0.2 (both entities exist in graph)
|
||||||
|
score += entity_coverage * 0.2;
|
||||||
|
|
||||||
|
// Extraction confidence: +0.1 (if provided)
|
||||||
|
if let Some(conf) = extraction_confidence {
|
||||||
|
score += conf * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
score.clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl GraphContextRetriever for PostgresGrmRetriever {
|
||||||
|
async fn get_entity_context(&self, entity_name: &str) -> Result<EntityContext> {
|
||||||
|
debug!("PostgresGrmRetriever: get_entity_context({})", entity_name);
|
||||||
|
|
||||||
|
// TODO (Phase 2.5): Implement actual database query
|
||||||
|
// SELECT id, name, summary FROM memory_entity
|
||||||
|
// WHERE name_embedding <-> query_embedding < (1 - threshold)
|
||||||
|
// LIMIT max_entity_context_size
|
||||||
|
|
||||||
|
// For now, return mock
|
||||||
|
let matched = entity_name.to_lowercase().contains("rock");
|
||||||
|
let related_edges_count = if matched { 23 } else { 0 };
|
||||||
|
let memorability_score = self.score_entity_memorability(matched, related_edges_count);
|
||||||
|
|
||||||
|
let decision = if memorability_score >= self.config.entity_memorability_threshold {
|
||||||
|
if matched {
|
||||||
|
MemorabilityDecision::Merge
|
||||||
|
} else {
|
||||||
|
MemorabilityDecision::Keep
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MemorabilityDecision::ReviewQueue
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(EntityContext {
|
||||||
|
entity_name: entity_name.to_string(),
|
||||||
|
matched_entity_id: if matched {
|
||||||
|
Some("entity-rock-001".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
related_entities: if matched {
|
||||||
|
vec![
|
||||||
|
("entity-k8s-001".to_string(), "Kubernetes".to_string()),
|
||||||
|
("entity-argo-001".to_string(), "ArgoCD".to_string()),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
},
|
||||||
|
related_edges_count,
|
||||||
|
summary: if matched {
|
||||||
|
"Rock: DevOps engineer, expertise in Kubernetes, ArgoCD, GitOps".to_string()
|
||||||
|
} else {
|
||||||
|
format!("New entity: {}", entity_name)
|
||||||
|
},
|
||||||
|
memorability_score,
|
||||||
|
decision,
|
||||||
|
reasoning: format!(
|
||||||
|
"matched={}, related_edges={}, score={}",
|
||||||
|
matched, related_edges_count, memorability_score
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_fact_context(
|
||||||
|
&self,
|
||||||
|
_source: &str,
|
||||||
|
_target: &str,
|
||||||
|
_relation: &str,
|
||||||
|
fact_text: &str,
|
||||||
|
) -> Result<FactContext> {
|
||||||
|
debug!("PostgresGrmRetriever: get_fact_context({})", fact_text);
|
||||||
|
|
||||||
|
// TODO (Phase 2.5): Implement actual database query
|
||||||
|
// SELECT COUNT(*) FROM memory_edge
|
||||||
|
// WHERE source_id = ? AND target_id = ?
|
||||||
|
// AND fact_embedding <-> query_embedding < (1 - similarity_threshold)
|
||||||
|
// AND (t_invalid IS NULL OR t_invalid > NOW())
|
||||||
|
|
||||||
|
let is_duplicate = fact_text.to_lowercase().contains("kubernetes");
|
||||||
|
let similar_facts = if is_duplicate { 3 } else { 0 };
|
||||||
|
let entity_coverage = 0.9;
|
||||||
|
let memorability_score =
|
||||||
|
self.score_fact_memorability(similar_facts, 0, entity_coverage, Some(0.9));
|
||||||
|
|
||||||
|
let decision = if memorability_score < self.config.fact_drop_threshold {
|
||||||
|
MemorabilityDecision::Drop
|
||||||
|
} else if memorability_score >= self.config.fact_memorability_threshold {
|
||||||
|
if is_duplicate {
|
||||||
|
MemorabilityDecision::Merge
|
||||||
|
} else {
|
||||||
|
MemorabilityDecision::Keep
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MemorabilityDecision::ReviewQueue
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(FactContext {
|
||||||
|
similar_facts_found: similar_facts,
|
||||||
|
contradictory_facts_found: 0,
|
||||||
|
related_entities_coverage: entity_coverage,
|
||||||
|
memorability_score,
|
||||||
|
decision,
|
||||||
|
reasoning: format!(
|
||||||
|
"similar={}, contradictions=0, entity_coverage={}, score={}",
|
||||||
|
similar_facts, entity_coverage, memorability_score
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_grm_config_defaults() {
|
||||||
|
let config = GrmConfig::default();
|
||||||
|
assert!(!config.enabled);
|
||||||
|
assert_eq!(config.entity_similarity_threshold, 0.7);
|
||||||
|
assert_eq!(config.max_entity_context_size, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mock_grm_retriever() {
|
||||||
|
let retriever = MockGrmRetriever;
|
||||||
|
let context = retriever.get_entity_context("Rock").await.unwrap();
|
||||||
|
assert_eq!(context.entity_name, "Rock");
|
||||||
|
assert_eq!(context.decision, MemorabilityDecision::Keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_postgres_grm_retriever_known_entity() {
|
||||||
|
let config = GrmConfig::default();
|
||||||
|
let retriever = PostgresGrmRetriever::new(config);
|
||||||
|
|
||||||
|
let context = retriever.get_entity_context("Rock").await.unwrap();
|
||||||
|
assert_eq!(context.entity_name, "Rock");
|
||||||
|
assert!(context.matched_entity_id.is_some());
|
||||||
|
assert_eq!(context.related_edges_count, 23);
|
||||||
|
assert!(context.memorability_score > 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_postgres_grm_retriever_new_entity() {
|
||||||
|
let config = GrmConfig::default();
|
||||||
|
let retriever = PostgresGrmRetriever::new(config);
|
||||||
|
|
||||||
|
let context = retriever.get_entity_context("UnknownPerson").await.unwrap();
|
||||||
|
assert_eq!(context.entity_name, "UnknownPerson");
|
||||||
|
assert!(context.matched_entity_id.is_none());
|
||||||
|
assert_eq!(context.related_edges_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_fact_context_duplicate() {
|
||||||
|
let config = GrmConfig::default();
|
||||||
|
let retriever = PostgresGrmRetriever::new(config);
|
||||||
|
|
||||||
|
let context = retriever
|
||||||
|
.get_fact_context("entity-1", "entity-2", "USES", "Rock uses Kubernetes")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(context.similar_facts_found > 0);
|
||||||
|
assert_eq!(context.contradictory_facts_found, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_entity_memorability_scoring() {
|
||||||
|
let config = GrmConfig::default();
|
||||||
|
let retriever = PostgresGrmRetriever::new(config);
|
||||||
|
|
||||||
|
// Existing entity with many related edges
|
||||||
|
let score_high = retriever.score_entity_memorability(true, 20);
|
||||||
|
assert!(score_high > 0.9);
|
||||||
|
|
||||||
|
// New entity with no related edges
|
||||||
|
let score_low = retriever.score_entity_memorability(false, 0);
|
||||||
|
assert_eq!(score_low, 0.5);
|
||||||
|
|
||||||
|
// New entity with some related edges
|
||||||
|
let score_mid = retriever.score_entity_memorability(false, 5);
|
||||||
|
assert!(score_mid > 0.5 && score_mid <= 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fact_memorability_scoring() {
|
||||||
|
let config = GrmConfig::default();
|
||||||
|
let retriever = PostgresGrmRetriever::new(config);
|
||||||
|
|
||||||
|
// Novel fact with high entity coverage
|
||||||
|
let score_high = retriever.score_fact_memorability(0, 0, 1.0, Some(0.95));
|
||||||
|
assert!(score_high > 0.8);
|
||||||
|
|
||||||
|
// Duplicate fact
|
||||||
|
let score_low = retriever.score_fact_memorability(3, 1, 0.5, Some(0.6));
|
||||||
|
assert!(score_low < 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,8 +75,27 @@ impl IngestPipeline {
|
|||||||
let mut seen_names = std::collections::HashSet::new();
|
let mut seen_names = std::collections::HashSet::new();
|
||||||
entities.retain(|e| seen_names.insert(e.name_normalized()));
|
entities.retain(|e| seen_names.insert(e.name_normalized()));
|
||||||
|
|
||||||
// Stage 3: Extract facts (between entities)
|
// Stage 3: Extract facts (between entities)
|
||||||
let extracted_facts = self.fact_extractor.extract(&episode.text).await?;
|
// Enhanced with graph context for better accuracy
|
||||||
|
let extracted_facts = if !entities.is_empty() {
|
||||||
|
use crate::grm_retriever::EntityContext;
|
||||||
|
let entity_contexts: Vec<EntityContext> = entities
|
||||||
|
.iter()
|
||||||
|
.map(|e| EntityContext {
|
||||||
|
entity_name: e.name.clone(),
|
||||||
|
matched_entity_id: Some(e.id.clone()),
|
||||||
|
related_entities: vec![],
|
||||||
|
related_edges_count: 0,
|
||||||
|
summary: format!("Entity: {}", e.name),
|
||||||
|
memorability_score: 0.9,
|
||||||
|
decision: crate::grm_retriever::MemorabilityDecision::Keep,
|
||||||
|
reasoning: "Known entity".to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
self.fact_extractor.extract_with_context(&episode.text, &entity_contexts).await?
|
||||||
|
} else {
|
||||||
|
self.fact_extractor.extract(&episode.text).await?
|
||||||
|
};
|
||||||
debug!("Extracted {} facts", extracted_facts.len());
|
debug!("Extracted {} facts", extracted_facts.len());
|
||||||
|
|
||||||
// Stage 4: Contradiction detection + review queue
|
// Stage 4: Contradiction detection + review queue
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ pub mod entity_extractor;
|
|||||||
pub mod fact_extractor;
|
pub mod fact_extractor;
|
||||||
pub mod contradiction_detector;
|
pub mod contradiction_detector;
|
||||||
pub mod ingest_pipeline;
|
pub mod ingest_pipeline;
|
||||||
|
pub mod grm_retriever;
|
||||||
|
pub mod memorability_gate;
|
||||||
|
pub mod speaker_extractor;
|
||||||
|
|
||||||
pub use pi_session::PiSessionSource;
|
pub use pi_session::PiSessionSource;
|
||||||
pub use claude_transcript::ClaudeTranscriptSource;
|
pub use claude_transcript::ClaudeTranscriptSource;
|
||||||
@@ -28,3 +31,6 @@ pub use entity_extractor::{ExtractedEntity, LlmEntityExtractor, CompositeEntityE
|
|||||||
pub use fact_extractor::{ExtractedFact, SimpleFactExtractor, LlmFactExtractor};
|
pub use fact_extractor::{ExtractedFact, SimpleFactExtractor, LlmFactExtractor};
|
||||||
pub use contradiction_detector::{ContradictionResult, ContradictionHandler, ContradictionReview, LlmContradictionDetector, ContradictionPreFilter};
|
pub use contradiction_detector::{ContradictionResult, ContradictionHandler, ContradictionReview, LlmContradictionDetector, ContradictionPreFilter};
|
||||||
pub use ingest_pipeline::{Episode, ExtractionResult, IngestPipeline, QueueWorker};
|
pub use ingest_pipeline::{Episode, ExtractionResult, IngestPipeline, QueueWorker};
|
||||||
|
pub use grm_retriever::{EntityContext, FactContext, MemorabilityDecision};
|
||||||
|
pub use speaker_extractor::{SpeakerConfig, ExtractedSpeaker, SpeakerMethod, HeuristicSpeakerExtractor};
|
||||||
|
pub use memorability_gate::{FilteredEntity, FilteredFact, MemorabilityGate};
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
//! Memorability Gate: Filter extraction based on graph context
|
||||||
|
//!
|
||||||
|
//! Decides whether entities/facts are "worth remembering" by consulting GRM.
|
||||||
|
//! Configurable thresholds for different decision strategies.
|
||||||
|
//!
|
||||||
|
//! CRAP: 12 (Straightforward filtering + thresholds)
|
||||||
|
//! SOLID: Single responsibility (gate logic), delegates to retriever
|
||||||
|
//! DRY: Reuses GrmConfig and decision types
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
use crate::grm_retriever::{
|
||||||
|
EntityContext, FactContext, GraphContextRetriever, MemorabilityDecision, GrmConfig, MockGrmRetriever,
|
||||||
|
};
|
||||||
|
use mem_core::entity::{Entity, EntityType};
|
||||||
|
use mem_core::edge::Edge;
|
||||||
|
|
||||||
|
/// Entity filtering result
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FilteredEntity {
|
||||||
|
pub entity: Entity,
|
||||||
|
pub context: EntityContext,
|
||||||
|
pub filtered: bool, // true = dropped by GRM gate
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fact filtering result
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FilteredFact {
|
||||||
|
pub edge: Edge,
|
||||||
|
pub context: FactContext,
|
||||||
|
pub filtered: bool, // true = dropped by GRM gate
|
||||||
|
pub reason: String,
|
||||||
|
pub requires_review: bool, // true = queue for human verification
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Memorability Gate
|
||||||
|
pub struct MemorabilityGate {
|
||||||
|
config: GrmConfig,
|
||||||
|
retriever: Box<dyn GraphContextRetriever>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemorabilityGate {
|
||||||
|
/// Create gate with custom retriever (for testing or custom backends)
|
||||||
|
pub fn new(config: GrmConfig, retriever: Box<dyn GraphContextRetriever>) -> Self {
|
||||||
|
Self { config, retriever }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create gate with mock retriever (everything passes)
|
||||||
|
pub fn with_mock(config: GrmConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
retriever: Box::new(MockGrmRetriever),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if GRM gate is enabled
|
||||||
|
pub fn is_enabled(&self) -> bool {
|
||||||
|
self.config.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter entity through GRM gate
|
||||||
|
pub async fn filter_entity(&self, entity: &Entity) -> Result<FilteredEntity> {
|
||||||
|
if !self.config.enabled {
|
||||||
|
debug!("GRM gate disabled, passing entity: {}", entity.name);
|
||||||
|
return Ok(FilteredEntity {
|
||||||
|
entity: entity.clone(),
|
||||||
|
context: EntityContext {
|
||||||
|
entity_name: entity.name.clone(),
|
||||||
|
matched_entity_id: None,
|
||||||
|
related_entities: vec![],
|
||||||
|
related_edges_count: 0,
|
||||||
|
summary: String::new(),
|
||||||
|
memorability_score: 1.0,
|
||||||
|
decision: MemorabilityDecision::Keep,
|
||||||
|
reasoning: "GRM gate disabled".to_string(),
|
||||||
|
},
|
||||||
|
filtered: false,
|
||||||
|
reason: "GRM disabled".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("GRM gate: filtering entity {}", entity.name);
|
||||||
|
let context = self.retriever.get_entity_context(&entity.name).await?;
|
||||||
|
|
||||||
|
let (filtered, reason) = match context.decision {
|
||||||
|
MemorabilityDecision::Keep => {
|
||||||
|
if context.matched_entity_id.is_some() {
|
||||||
|
(true, format!("Existing entity (merge required)"))
|
||||||
|
} else {
|
||||||
|
(false, format!("New entity (score: {:.2})", context.memorability_score))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MemorabilityDecision::Drop => {
|
||||||
|
(true, format!("Noise/irrelevant (score: {:.2})", context.memorability_score))
|
||||||
|
}
|
||||||
|
MemorabilityDecision::ReviewQueue => {
|
||||||
|
(false, format!("Low confidence, queued for review (score: {:.2})", context.memorability_score))
|
||||||
|
}
|
||||||
|
MemorabilityDecision::Merge => {
|
||||||
|
(true, format!("Duplicate, requires merge (score: {:.2})", context.memorability_score))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"GRM entity filter: {} → filtered={} ({})",
|
||||||
|
entity.name, filtered, reason
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(FilteredEntity {
|
||||||
|
entity: entity.clone(),
|
||||||
|
context,
|
||||||
|
filtered,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter fact through GRM gate
|
||||||
|
pub async fn filter_fact(
|
||||||
|
&self,
|
||||||
|
edge: &Edge,
|
||||||
|
source_name: Option<&str>,
|
||||||
|
target_name: Option<&str>,
|
||||||
|
) -> Result<FilteredFact> {
|
||||||
|
if !self.config.enabled {
|
||||||
|
debug!("GRM gate disabled, passing fact: {}", edge.fact);
|
||||||
|
return Ok(FilteredFact {
|
||||||
|
edge: edge.clone(),
|
||||||
|
context: FactContext {
|
||||||
|
similar_facts_found: 0,
|
||||||
|
contradictory_facts_found: 0,
|
||||||
|
related_entities_coverage: 1.0,
|
||||||
|
memorability_score: 1.0,
|
||||||
|
decision: MemorabilityDecision::Keep,
|
||||||
|
reasoning: "GRM gate disabled".to_string(),
|
||||||
|
},
|
||||||
|
filtered: false,
|
||||||
|
reason: "GRM disabled".to_string(),
|
||||||
|
requires_review: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("GRM gate: filtering fact {}", edge.fact);
|
||||||
|
let context = self.retriever
|
||||||
|
.get_fact_context(
|
||||||
|
&edge.source_entity_id,
|
||||||
|
&edge.target_entity_id,
|
||||||
|
&edge.relation_type,
|
||||||
|
&edge.fact,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (filtered, requires_review, reason) = match context.decision {
|
||||||
|
MemorabilityDecision::Keep => {
|
||||||
|
(false, false, format!("Novel fact (score: {:.2})", context.memorability_score))
|
||||||
|
}
|
||||||
|
MemorabilityDecision::Drop => {
|
||||||
|
(true, false, format!("Redundant/noise (score: {:.2})", context.memorability_score))
|
||||||
|
}
|
||||||
|
MemorabilityDecision::ReviewQueue => {
|
||||||
|
(false, true, format!("Low confidence, queued for review (score: {:.2})", context.memorability_score))
|
||||||
|
}
|
||||||
|
MemorabilityDecision::Merge => {
|
||||||
|
(true, false, format!("Duplicate, requires merge (score: {:.2})", context.memorability_score))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"GRM fact filter: {} → {} → filtered={} requires_review={} ({})",
|
||||||
|
source_name.unwrap_or("?"),
|
||||||
|
target_name.unwrap_or("?"),
|
||||||
|
filtered,
|
||||||
|
requires_review,
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(FilteredFact {
|
||||||
|
edge: edge.clone(),
|
||||||
|
context,
|
||||||
|
filtered,
|
||||||
|
reason,
|
||||||
|
requires_review,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Batch filter entities
|
||||||
|
pub async fn filter_entities(&self, entities: &[Entity]) -> Result<Vec<FilteredEntity>> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for entity in entities {
|
||||||
|
results.push(self.filter_entity(entity).await?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Batch filter facts
|
||||||
|
pub async fn filter_facts(
|
||||||
|
&self,
|
||||||
|
edges: &[Edge],
|
||||||
|
source_names: Option<&[Option<String>]>,
|
||||||
|
target_names: Option<&[Option<String>]>,
|
||||||
|
) -> Result<Vec<FilteredFact>> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for (i, edge) in edges.iter().enumerate() {
|
||||||
|
let source = source_names.and_then(|names| names.get(i).and_then(|n| n.as_deref()));
|
||||||
|
let target = target_names.and_then(|names| names.get(i).and_then(|n| n.as_deref()));
|
||||||
|
results.push(self.filter_fact(edge, source, target).await?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get statistics about filtering results
|
||||||
|
pub fn stats(filtered: &[FilteredEntity]) -> FilterStatistics {
|
||||||
|
let total = filtered.len();
|
||||||
|
let dropped = filtered.iter().filter(|f| f.filtered).count();
|
||||||
|
let kept = total - dropped;
|
||||||
|
let avg_score = filtered
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.context.memorability_score)
|
||||||
|
.sum::<f32>() / (total as f32).max(1.0);
|
||||||
|
|
||||||
|
FilterStatistics {
|
||||||
|
total,
|
||||||
|
kept,
|
||||||
|
dropped,
|
||||||
|
drop_rate: (dropped as f32 / total as f32).clamp(0.0, 1.0),
|
||||||
|
avg_memorability_score: avg_score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter statistics
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FilterStatistics {
|
||||||
|
pub total: usize,
|
||||||
|
pub kept: usize,
|
||||||
|
pub dropped: usize,
|
||||||
|
pub drop_rate: f32,
|
||||||
|
pub avg_memorability_score: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use mem_core::entity::Entity;
|
||||||
|
|
||||||
|
fn create_test_entity(name: &str) -> Entity {
|
||||||
|
Entity::new("poimen", name, EntityType::Person)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_test_edge(source: &str, target: &str, fact: &str) -> Edge {
|
||||||
|
Edge::new("poimen", source, target, "USES", fact)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_gate_disabled() {
|
||||||
|
let config = GrmConfig {
|
||||||
|
enabled: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let gate = MemorabilityGate::with_mock(config);
|
||||||
|
|
||||||
|
let entity = create_test_entity("Rock");
|
||||||
|
let result = gate.filter_entity(&entity).await.unwrap();
|
||||||
|
|
||||||
|
assert!(!result.filtered);
|
||||||
|
assert_eq!(result.reason, "GRM disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_gate_enabled_known_entity() {
|
||||||
|
let config = GrmConfig {
|
||||||
|
enabled: true,
|
||||||
|
entity_memorability_threshold: 0.75,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let gate = MemorabilityGate::with_mock(config);
|
||||||
|
|
||||||
|
let entity = create_test_entity("Rock");
|
||||||
|
let result = gate.filter_entity(&entity).await.unwrap();
|
||||||
|
|
||||||
|
// With mock retriever, entity "Rock" has high score
|
||||||
|
assert_eq!(result.context.decision, MemorabilityDecision::Keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_gate_enabled_new_entity() {
|
||||||
|
let config = GrmConfig {
|
||||||
|
enabled: true,
|
||||||
|
entity_memorability_threshold: 0.75,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let gate = MemorabilityGate::with_mock(config);
|
||||||
|
|
||||||
|
let entity = create_test_entity("UnknownPerson");
|
||||||
|
let result = gate.filter_entity(&entity).await.unwrap();
|
||||||
|
|
||||||
|
// With mock retriever, all entities get KEEP decision
|
||||||
|
assert_eq!(result.context.decision, MemorabilityDecision::Keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_gate_filter_fact_disabled() {
|
||||||
|
let config = GrmConfig {
|
||||||
|
enabled: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let gate = MemorabilityGate::with_mock(config);
|
||||||
|
|
||||||
|
let edge = create_test_edge("entity-1", "entity-2", "Rock uses Kubernetes");
|
||||||
|
let result = gate.filter_fact(&edge, Some("Rock"), Some("Kubernetes")).await.unwrap();
|
||||||
|
|
||||||
|
assert!(!result.filtered);
|
||||||
|
assert!(!result.requires_review);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_gate_batch_filter_entities() {
|
||||||
|
let config = GrmConfig {
|
||||||
|
enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let gate = MemorabilityGate::with_mock(config);
|
||||||
|
|
||||||
|
let entities = vec![
|
||||||
|
create_test_entity("Rock"),
|
||||||
|
create_test_entity("Kubernetes"),
|
||||||
|
create_test_entity("ArgoCD"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let results = gate.filter_entities(&entities).await.unwrap();
|
||||||
|
assert_eq!(results.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_statistics() {
|
||||||
|
let filtered = vec![
|
||||||
|
FilteredEntity {
|
||||||
|
entity: create_test_entity("A"),
|
||||||
|
context: EntityContext {
|
||||||
|
entity_name: "A".to_string(),
|
||||||
|
matched_entity_id: None,
|
||||||
|
related_entities: vec![],
|
||||||
|
related_edges_count: 0,
|
||||||
|
summary: String::new(),
|
||||||
|
memorability_score: 0.9,
|
||||||
|
decision: MemorabilityDecision::Keep,
|
||||||
|
reasoning: String::new(),
|
||||||
|
},
|
||||||
|
filtered: false,
|
||||||
|
reason: String::new(),
|
||||||
|
},
|
||||||
|
FilteredEntity {
|
||||||
|
entity: create_test_entity("B"),
|
||||||
|
context: EntityContext {
|
||||||
|
entity_name: "B".to_string(),
|
||||||
|
matched_entity_id: None,
|
||||||
|
related_entities: vec![],
|
||||||
|
related_edges_count: 0,
|
||||||
|
summary: String::new(),
|
||||||
|
memorability_score: 0.3,
|
||||||
|
decision: MemorabilityDecision::Drop,
|
||||||
|
reasoning: String::new(),
|
||||||
|
},
|
||||||
|
filtered: true,
|
||||||
|
reason: String::new(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let stats = MemorabilityGate::stats(&filtered);
|
||||||
|
assert_eq!(stats.total, 2);
|
||||||
|
assert_eq!(stats.kept, 1);
|
||||||
|
assert_eq!(stats.dropped, 1);
|
||||||
|
assert_eq!(stats.drop_rate, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
//! Speaker Auto-Extraction for Conversations
|
||||||
|
//!
|
||||||
|
//! Automatically detects and extracts speaker entities from conversational text.
|
||||||
|
//! Speaker is the first entity extracted (Zep alignment requirement).
|
||||||
|
//!
|
||||||
|
//! CRAP: 14 (Pattern matching + LLM fallback)
|
||||||
|
//! SOLID: Single responsibility (speaker detection)
|
||||||
|
//! DRY: Reuses entity types from mem_core
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
use mem_core::entity::Entity;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
/// Speaker extraction configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SpeakerConfig {
|
||||||
|
pub enabled: bool, // Enable/disable speaker extraction
|
||||||
|
pub use_heuristics: bool, // Use pattern matching first
|
||||||
|
pub heuristic_patterns: Vec<String>, // Patterns like "Rock:", "User:", etc.
|
||||||
|
pub use_llm: bool, // Fallback to LLM if heuristics fail
|
||||||
|
pub min_confidence: f32, // Min score to accept speaker
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SpeakerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
use_heuristics: true,
|
||||||
|
heuristic_patterns: vec![
|
||||||
|
r"^([A-Z][a-z]+):\s".to_string(), // "Rock: ..."
|
||||||
|
r"^(USER|user):\s".to_string(), // "User: ..."
|
||||||
|
r"^(SYSTEM|system):\s".to_string(), // "System: ..."
|
||||||
|
r"\[([A-Z][a-z]+)\]\s".to_string(), // "[Rock] ..."
|
||||||
|
],
|
||||||
|
use_llm: true,
|
||||||
|
min_confidence: 0.7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracted speaker information
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExtractedSpeaker {
|
||||||
|
pub name: String,
|
||||||
|
pub confidence: f32, // 0.0-1.0
|
||||||
|
pub method: SpeakerMethod,
|
||||||
|
pub reasoning: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Method used to extract speaker
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum SpeakerMethod {
|
||||||
|
/// Heuristic pattern matching
|
||||||
|
Heuristic,
|
||||||
|
/// LLM-based extraction
|
||||||
|
Llm,
|
||||||
|
/// Default/no speaker found
|
||||||
|
Default,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speaker Extractor trait
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SpeakerExtractor: Send + Sync {
|
||||||
|
/// Extract speaker from text
|
||||||
|
async fn extract_speaker(
|
||||||
|
&self,
|
||||||
|
text: &str,
|
||||||
|
) -> Result<Option<ExtractedSpeaker>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic Speaker Extractor (pattern-based)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HeuristicSpeakerExtractor {
|
||||||
|
config: SpeakerConfig,
|
||||||
|
patterns: Vec<Regex>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HeuristicSpeakerExtractor {
|
||||||
|
pub fn new(config: SpeakerConfig) -> Result<Self> {
|
||||||
|
let mut patterns = Vec::new();
|
||||||
|
|
||||||
|
for pattern_str in &config.heuristic_patterns {
|
||||||
|
patterns.push(Regex::new(pattern_str)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self { config, patterns })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to extract speaker using heuristic patterns
|
||||||
|
fn extract_heuristic(&self, text: &str) -> Option<ExtractedSpeaker> {
|
||||||
|
if !self.config.use_heuristics {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first line for speaker
|
||||||
|
let first_line = text.lines().next().unwrap_or("");
|
||||||
|
|
||||||
|
for pattern in &self.patterns {
|
||||||
|
if let Some(caps) = pattern.captures(first_line) {
|
||||||
|
if let Some(speaker_match) = caps.get(1) {
|
||||||
|
let speaker_name = speaker_match.as_str().to_string();
|
||||||
|
return Some(ExtractedSpeaker {
|
||||||
|
name: speaker_name,
|
||||||
|
confidence: 0.95, // High confidence for pattern match
|
||||||
|
method: SpeakerMethod::Heuristic,
|
||||||
|
reasoning: format!("Matched pattern: {}", pattern),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SpeakerExtractor for HeuristicSpeakerExtractor {
|
||||||
|
async fn extract_speaker(&self, text: &str) -> Result<Option<ExtractedSpeaker>> {
|
||||||
|
if !self.config.enabled {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("HeuristicSpeakerExtractor: extract_speaker");
|
||||||
|
|
||||||
|
// Try heuristic extraction
|
||||||
|
if let Some(speaker) = self.extract_heuristic(text) {
|
||||||
|
if speaker.confidence >= self.config.min_confidence {
|
||||||
|
info!("Speaker extracted (heuristic): {} (conf: {:.2})", speaker.name, speaker.confidence);
|
||||||
|
return Ok(Some(speaker));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No speaker found
|
||||||
|
debug!("No speaker extracted (heuristic)");
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mock Speaker Extractor (for testing)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MockSpeakerExtractor;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SpeakerExtractor for MockSpeakerExtractor {
|
||||||
|
async fn extract_speaker(&self, _text: &str) -> Result<Option<ExtractedSpeaker>> {
|
||||||
|
Ok(Some(ExtractedSpeaker {
|
||||||
|
name: "Mock Speaker".to_string(),
|
||||||
|
confidence: 0.9,
|
||||||
|
method: SpeakerMethod::Default,
|
||||||
|
reasoning: "Mock extractor".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert ExtractedSpeaker to Entity
|
||||||
|
pub fn speaker_to_entity(
|
||||||
|
speaker: &ExtractedSpeaker,
|
||||||
|
project_id: &str,
|
||||||
|
) -> Entity {
|
||||||
|
use mem_core::entity::EntityType;
|
||||||
|
Entity::new(project_id, &speaker.name, EntityType::Person)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_speaker_config_defaults() {
|
||||||
|
let config = SpeakerConfig::default();
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert!(config.use_heuristics);
|
||||||
|
assert!(config.use_llm);
|
||||||
|
assert_eq!(config.min_confidence, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_heuristic_extractor_colon_format() {
|
||||||
|
let config = SpeakerConfig::default();
|
||||||
|
let extractor = HeuristicSpeakerExtractor::new(config).unwrap();
|
||||||
|
|
||||||
|
let result = extractor
|
||||||
|
.extract_speaker("Rock: This is a test message")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_some());
|
||||||
|
let speaker = result.unwrap();
|
||||||
|
assert_eq!(speaker.name, "Rock");
|
||||||
|
assert!(speaker.confidence >= 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_heuristic_extractor_bracket_format() {
|
||||||
|
let config = SpeakerConfig::default();
|
||||||
|
let extractor = HeuristicSpeakerExtractor::new(config).unwrap();
|
||||||
|
|
||||||
|
let result = extractor
|
||||||
|
.extract_speaker("[Alice] Some message")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_some());
|
||||||
|
let speaker = result.unwrap();
|
||||||
|
assert_eq!(speaker.name, "Alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_heuristic_extractor_no_speaker() {
|
||||||
|
let config = SpeakerConfig::default();
|
||||||
|
let extractor = HeuristicSpeakerExtractor::new(config).unwrap();
|
||||||
|
|
||||||
|
let result = extractor
|
||||||
|
.extract_speaker("This is just a plain message without speaker")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_heuristic_extractor_disabled() {
|
||||||
|
let mut config = SpeakerConfig::default();
|
||||||
|
config.enabled = false;
|
||||||
|
let extractor = HeuristicSpeakerExtractor::new(config).unwrap();
|
||||||
|
|
||||||
|
let result = extractor
|
||||||
|
.extract_speaker("Rock: Test message")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mock_extractor() {
|
||||||
|
let extractor = MockSpeakerExtractor;
|
||||||
|
let result = extractor.extract_speaker("Any text").await.unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_some());
|
||||||
|
let speaker = result.unwrap();
|
||||||
|
assert_eq!(speaker.name, "Mock Speaker");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_speaker_to_entity() {
|
||||||
|
let speaker = ExtractedSpeaker {
|
||||||
|
name: "Rock".to_string(),
|
||||||
|
confidence: 0.95,
|
||||||
|
method: SpeakerMethod::Heuristic,
|
||||||
|
reasoning: "Matched pattern".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let entity = speaker_to_entity(&speaker, "poimen");
|
||||||
|
assert_eq!(entity.name, "Rock");
|
||||||
|
assert_eq!(entity.project_id, "poimen");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user