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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Augur OS attacks all three problems at once: it does the research fast, it groun

- **Reviewable evidence.** A dedicated verifier stage reviews every finding the research agents produce, checks its supplied URL and evidence metadata, weighs confidence and freshness, and rejects or weakens unsupported, stale, or contradicted findings. The verifier is bounded and does not independently fetch every URL; open important citations before acting on them.

- **Enforced citation contract.** Standard and Deep runs must produce a strict verifier ledger. The Rust completion gate refuses malformed ledgers and will not accept a `verified` claim without a credential-free HTTPS URL, a substantive evidence quote, a bounded confidence score, and a valid date. Accepted receipts are persisted separately from the generated profile for audit. This validates the evidence contract; it still does not independently prove the source content.

- **Signal-based lead scoring.** Define your ideal customer once as a rubric. Augur grades every company from 0 to 100 against that rubric and shows the full reasoning behind the number, so the score is something you can defend in a pipeline review rather than a black box.

- **Buying-committee discovery.** Augur finds the people who actually matter at each target, with their roles and the context for why each one is worth reaching, instead of dumping a flat list of names.
Expand Down
16 changes: 16 additions & 0 deletions docs/citation-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Citation contract gateway

Standard and Deep research jobs do not become successful merely because the Claude process exits zero. Before profile parsing, Augur requires `outputs/specialists/verifier.json` to match a strict Rust schema.

For every `verified` claim the gateway requires:

- non-empty claim and source-agent identity;
- a credential-free HTTPS URL with a host;
- a substantive evidence quote;
- finite confidence from 0 through 1;
- `YYYY-MM-DD` or null for the evidence date;
- a unique SHA-256 receipt over the normalized claim, source-agent identity, and source URL.

Weak and conflicting claims may omit evidence, but any URL they do retain must pass the same credential-free HTTPS boundary. Rejected claims must retain a reason. A malformed ledger fails the job, marks the entity failed, and prevents generated output from reaching the database.

Accepted claim receipts are stored in SQLite's `evidence_receipts` table with the job and entity ids. This makes the prompt contract application-enforced and auditable. It does **not** independently fetch the URL or prove that the quote occurs on the source page; source snapshotting and quote-span verification remain future work.
9 changes: 9 additions & 0 deletions docs/research-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Content-addressed research cache

Standard and Deep runs can reuse fresh specialist JSON artifacts from a prior equivalent run. The cache key includes normalized company identity, research depth, fixed model and browser-tool profile, the complete job prompt, and a digest of every agent template. A prompt, model, tool profile, depth, company, or template change therefore produces a cold miss.

Only specialist JSON objects are cached. Malformed JSON, stream logs, credentials, Apollo contact results, verifier output, and synthesizer output are excluded. The verifier and synthesizer always rerun over the selected specialist set.

Entries expire after seven days. Restoration rejects malformed keys, schema mismatches, stale or future-dated manifests, symlinks, non-files, logs, malformed JSON, and derived-agent files. Cache failure never converts a failed research job to success.

The unit fixture covers deterministic normalization, material-input invalidation, TTL expiry, and derived-output exclusion. Operational targets for a representative unchanged Standard rerun are at least 70% fewer specialist model calls and p95 under 30 seconds; measure those in release telemetry before making a public performance claim.
278 changes: 278 additions & 0 deletions src-tauri/src/citation_integrity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
use chrono::NaiveDate;
use rusqlite::{params, Connection};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs;
use std::path::Path;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Ledger {
agent: String,
summary: String,
verified_claims: Vec<Claim>,
rejected_claims: Vec<RejectedClaim>,
conflicts: Vec<serde_json::Value>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Claim {
claim: String,
source_agent: String,
evidence_url: Option<String>,
evidence_quote: Option<String>,
confidence: f64,
as_of_date: Option<String>,
verdict: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RejectedClaim {
claim: String,
source_agent: String,
reason: String,
}

#[derive(Debug, Clone, PartialEq)]
pub struct EvidenceReceipt {
pub claim_hash: String,
pub claim: String,
pub source_agent: String,
pub evidence_url: Option<String>,
pub evidence_quote: Option<String>,
pub confidence: f64,
pub as_of_date: Option<String>,
pub verdict: String,
}

#[derive(Debug, Clone, PartialEq)]
pub struct IntegrityReport {
pub receipts: Vec<EvidenceReceipt>,
pub rejected_count: usize,
pub conflict_count: usize,
}

pub fn validate_ledger_file(path: &Path) -> Result<IntegrityReport, String> {
let bytes = fs::read(path).map_err(|e| format!("cannot read verifier ledger: {e}"))?;
validate_ledger(&bytes)
}

pub fn validate_ledger(bytes: &[u8]) -> Result<IntegrityReport, String> {
let ledger: Ledger = serde_json::from_slice(bytes)
.map_err(|e| format!("verifier ledger does not match the strict schema: {e}"))?;
if ledger.agent != "verifier" || ledger.summary.trim().is_empty() {
return Err("verifier ledger must identify the verifier and include a summary".into());
}
let mut receipts = Vec::with_capacity(ledger.verified_claims.len());
let mut unique = HashSet::new();
for (index, claim) in ledger.verified_claims.into_iter().enumerate() {
let label = format!("verified_claims[{index}]");
if claim.claim.trim().is_empty() || claim.source_agent.trim().is_empty() {
return Err(format!("{label} must include a claim and source_agent"));
}
if !claim.confidence.is_finite() || !(0.0..=1.0).contains(&claim.confidence) {
return Err(format!("{label}.confidence must be between 0 and 1"));
}
if !matches!(claim.verdict.as_str(), "verified" | "weak" | "conflicting") {
return Err(format!("{label}.verdict is not supported"));
}
if let Some(ref date) = claim.as_of_date {
NaiveDate::parse_from_str(date, "%Y-%m-%d")
.map_err(|_| format!("{label}.as_of_date must be YYYY-MM-DD or null"))?;
}
if let Some(url) = claim.evidence_url.as_deref() {
let parsed = reqwest::Url::parse(url)
.map_err(|_| format!("{label}.evidence_url is not a valid URL"))?;
if parsed.scheme() != "https"
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
{
return Err(format!("{label}.evidence_url must be a credential-free HTTPS URL"));
}
}
if claim.verdict == "verified" && claim.evidence_url.is_none() {
return Err(format!("{label} cannot be verified without an evidence_url"));
}
if claim.verdict == "verified"
&& claim.evidence_quote.as_deref().map(str::trim).unwrap_or("").len() < 8
{
return Err(format!("{label} cannot be verified without a substantive evidence_quote"));
}

let mut digest = Sha256::new();
digest.update(claim.claim.trim().as_bytes());
digest.update(b"\0");
digest.update(claim.source_agent.trim().as_bytes());
digest.update(b"\0");
digest.update(claim.evidence_url.as_deref().unwrap_or("").as_bytes());
let claim_hash = format!("{:x}", digest.finalize());
if !unique.insert(claim_hash.clone()) {
return Err(format!("{label} duplicates an earlier claim/source receipt"));
}
receipts.push(EvidenceReceipt {
claim_hash,
claim: claim.claim.trim().to_string(),
source_agent: claim.source_agent.trim().to_string(),
evidence_url: claim.evidence_url,
evidence_quote: claim.evidence_quote,
confidence: claim.confidence,
as_of_date: claim.as_of_date,
verdict: claim.verdict,
});
}
for (index, claim) in ledger.rejected_claims.iter().enumerate() {
if claim.claim.trim().is_empty() || claim.source_agent.trim().is_empty() || claim.reason.trim().is_empty() {
return Err(format!("rejected_claims[{index}] must include claim, source_agent, and reason"));
}
}
Ok(IntegrityReport {
receipts,
rejected_count: ledger.rejected_claims.len(),
conflict_count: ledger.conflicts.len(),
})
}

pub fn persist_receipts(
conn: &mut Connection,
job_id: &str,
entity_id: i64,
report: &IntegrityReport,
) -> Result<(), String> {
let transaction = conn.transaction().map_err(|e| e.to_string())?;
transaction
.execute("DELETE FROM evidence_receipts WHERE job_id = ?1", [job_id])
.map_err(|e| e.to_string())?;
for receipt in &report.receipts {
transaction
.execute(
"INSERT INTO evidence_receipts (
job_id, entity_id, claim_hash, claim, source_agent, evidence_url,
evidence_quote, confidence, as_of_date, verdict, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
job_id,
entity_id,
receipt.claim_hash,
receipt.claim,
receipt.source_agent,
receipt.evidence_url,
receipt.evidence_quote,
receipt.confidence,
receipt.as_of_date,
receipt.verdict,
chrono::Utc::now().timestamp_millis(),
],
)
.map_err(|e| e.to_string())?;
}
transaction.commit().map_err(|e| e.to_string())
}

#[cfg(test)]
mod tests {
use super::*;

fn ledger(claim: serde_json::Value) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"agent": "verifier",
"summary": "bounded review",
"verified_claims": [claim],
"rejected_claims": [],
"conflicts": []
})).unwrap()
}

#[test]
fn verified_claim_requires_https_url_and_quote() {
let base = serde_json::json!({
"claim": "Acme launched a product",
"source_agent": "trigger-signal-analyst",
"evidence_url": "https://example.com/launch",
"evidence_quote": "Acme today announced its new product.",
"confidence": 0.9,
"as_of_date": "2026-08-24",
"verdict": "verified"
});
assert_eq!(validate_ledger(&ledger(base.clone())).unwrap().receipts.len(), 1);
let mut missing_quote = base;
missing_quote["evidence_quote"] = serde_json::Value::Null;
assert!(validate_ledger(&ledger(missing_quote)).unwrap_err().contains("evidence_quote"));
}

#[test]
fn weak_claim_can_preserve_uncertain_evidence() {
let report = validate_ledger(&ledger(serde_json::json!({
"claim": "Acme may be hiring",
"source_agent": "trigger-signal-analyst",
"evidence_url": null,
"evidence_quote": null,
"confidence": 0.4,
"as_of_date": null,
"verdict": "weak"
}))).unwrap();
assert_eq!(report.receipts[0].verdict, "weak");
}

#[test]
fn optional_evidence_must_still_be_safe_and_sources_remain_distinct() {
let unsafe_report = ledger(serde_json::json!({
"claim": "Acme may be hiring",
"source_agent": "trigger-signal-analyst",
"evidence_url": "https://user:secret@example.com/jobs",
"evidence_quote": null,
"confidence": 0.4,
"as_of_date": null,
"verdict": "weak"
}));
assert!(validate_ledger(&unsafe_report)
.unwrap_err()
.contains("credential-free HTTPS URL"));

let mut value: serde_json::Value = serde_json::from_slice(&ledger(serde_json::json!({
"claim": "Acme may be hiring",
"source_agent": "trigger-signal-analyst",
"evidence_url": "https://example.com/jobs",
"evidence_quote": null,
"confidence": 0.4,
"as_of_date": null,
"verdict": "weak"
}))).unwrap();
let mut second = value["verified_claims"][0].clone();
second["source_agent"] = serde_json::json!("people-finder");
value["verified_claims"].as_array_mut().unwrap().push(second);
assert_eq!(validate_ledger(&serde_json::to_vec(&value).unwrap()).unwrap().receipts.len(), 2);
}

#[test]
fn persists_a_deduplicated_job_receipt() {
let report = validate_ledger(&ledger(serde_json::json!({
"claim": "Acme launched a product",
"source_agent": "trigger-signal-analyst",
"evidence_url": "https://example.com/launch",
"evidence_quote": "Acme today announced its new product.",
"confidence": 0.9,
"as_of_date": "2026-08-24",
"verdict": "verified"
}))).unwrap();
let mut conn = Connection::open_in_memory().unwrap();
conn.execute_batch(r#"
CREATE TABLE evidence_receipts (
id INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT NOT NULL,
entity_id INTEGER NOT NULL, claim_hash TEXT NOT NULL, claim TEXT NOT NULL,
source_agent TEXT NOT NULL, evidence_url TEXT, evidence_quote TEXT,
confidence REAL NOT NULL, as_of_date TEXT, verdict TEXT NOT NULL,
created_at INTEGER NOT NULL, UNIQUE(job_id, claim_hash)
);
"#).unwrap();
persist_receipts(&mut conn, "job-1", 42, &report).unwrap();
persist_receipts(&mut conn, "job-1", 42, &report).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM evidence_receipts WHERE job_id = 'job-1'", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1);
}
}
20 changes: 20 additions & 0 deletions src-tauri/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,26 @@ fn init_schema(conn: &Connection) -> SqliteResult<()> {
CREATE INDEX IF NOT EXISTS idx_job_logs_job_id ON job_logs(job_id);
CREATE INDEX IF NOT EXISTS idx_job_logs_sequence ON job_logs(job_id, sequence);

-- Machine-validated receipts from orchestrated verifier ledgers.
CREATE TABLE IF NOT EXISTS evidence_receipts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
entity_id INTEGER NOT NULL,
claim_hash TEXT NOT NULL,
claim TEXT NOT NULL,
source_agent TEXT NOT NULL,
evidence_url TEXT,
evidence_quote TEXT,
confidence REAL NOT NULL,
as_of_date TEXT,
verdict TEXT NOT NULL CHECK (verdict IN ('verified', 'weak', 'conflicting')),
created_at INTEGER NOT NULL,
UNIQUE(job_id, claim_hash)
);

CREATE INDEX IF NOT EXISTS idx_evidence_receipts_entity ON evidence_receipts(entity_id);
CREATE INDEX IF NOT EXISTS idx_evidence_receipts_job ON evidence_receipts(job_id);

CREATE TABLE IF NOT EXISTS apollo_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT,
Expand Down
Loading