Sync ingestion baseline and noise suppression

This commit is contained in:
Admin 2026-09-29 06:39:43 +00:00
parent 2459d72420
commit 2888ec1f34
3 changed files with 147 additions and 21 deletions

132
src/db.ts
View File

@ -26,6 +26,7 @@ export async function migrate() {
enabled BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_synced_at TIMESTAMPTZ, last_synced_at TIMESTAMPTZ,
last_error TEXT, last_error TEXT,
baseline_complete BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@ -50,6 +51,7 @@ export async function migrate() {
published_at TIMESTAMPTZ, published_at TIMESTAMPTZ,
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
content_hash TEXT NOT NULL, content_hash TEXT NOT NULL,
change_kind TEXT NOT NULL DEFAULT 'new',
topics JSONB NOT NULL DEFAULT '[]'::jsonb, topics JSONB NOT NULL DEFAULT '[]'::jsonb,
raw_json JSONB NOT NULL, raw_json JSONB NOT NULL,
UNIQUE(source_id, external_id, content_hash) UNIQUE(source_id, external_id, content_hash)
@ -107,6 +109,12 @@ export async function migrate() {
CREATE INDEX IF NOT EXISTS idx_agent_messages_unread CREATE INDEX IF NOT EXISTS idx_agent_messages_unread
ON agent_messages(acknowledged_at, created_at DESC); ON agent_messages(acknowledged_at, created_at DESC);
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS audit_events ( CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
event_type TEXT NOT NULL, event_type TEXT NOT NULL,
@ -116,6 +124,26 @@ export async function migrate() {
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
`); `);
await pool.query("ALTER TABLE sources ADD COLUMN IF NOT EXISTS baseline_complete BOOLEAN NOT NULL DEFAULT FALSE");
await pool.query("ALTER TABLE signals ADD COLUMN IF NOT EXISTS change_kind TEXT NOT NULL DEFAULT 'new'");
const marker = await pool.query(
`INSERT INTO app_meta(key,value)
VALUES('noise_baseline_v1','{"applied":true}'::jsonb)
ON CONFLICT(key) DO NOTHING
RETURNING key`
);
if (marker.rowCount) {
await pool.query(
`UPDATE opportunities
SET status='closed',
reasons = reasons || '["Archived during initial baseline cleanup"]'::jsonb,
updated_at=NOW()
WHERE status='new'`
);
}
} }
export async function seedSources(sources: SourceDefinition[]) { export async function seedSources(sources: SourceDefinition[]) {
@ -127,6 +155,13 @@ export async function seedSources(sources: SourceDefinition[]) {
[source.id, source.name, source.type, source.url] [source.id, source.name, source.type, source.url]
); );
} }
await pool.query(
`UPDATE sources s
SET baseline_complete=TRUE
WHERE baseline_complete=FALSE
AND EXISTS (SELECT 1 FROM signals sig WHERE sig.source_id=s.id)`
);
} }
export function contentHash(item: NormalizedItem) { export function contentHash(item: NormalizedItem) {
@ -141,35 +176,88 @@ export function contentHash(item: NormalizedItem) {
.digest("hex"); .digest("hex");
} }
export async function storeItem(item: NormalizedItem, scored: ScoredItem): Promise<{ created: boolean; signalId?: string; opportunityId?: string }> { export async function storeItem(
item: NormalizedItem,
scored: ScoredItem,
options: { baseline?: boolean } = {}
): Promise<{
created: boolean;
signalId?: string;
opportunityId?: string;
changeKind?: "baseline" | "new" | "updated";
}> {
const hash = contentHash(item); const hash = contentHash(item);
const existing = await pool.query( const previous = await pool.query(
"SELECT id FROM signals WHERE source_id=$1 AND external_id=$2 AND content_hash=$3 LIMIT 1", `SELECT id,content_hash
[item.sourceId, item.externalId, hash] FROM signals
WHERE source_id=$1 AND external_id=$2
ORDER BY detected_at DESC
LIMIT 1`,
[item.sourceId, item.externalId]
); );
if (existing.rowCount) return { created: false };
if (previous.rowCount && previous.rows[0].content_hash === hash) {
return { created: false };
}
const changeKind: "baseline" | "new" | "updated" =
options.baseline ? "baseline" : previous.rowCount ? "updated" : "new";
const signalId = randomUUID(); const signalId = randomUUID();
const opportunityId = randomUUID(); const opportunityId = randomUUID();
const evidenceId = randomUUID(); const evidenceId = randomUUID();
const minScore = Number(process.env.OPPORTUNITY_MIN_SCORE ?? 2);
const maxAgeDays = Math.max(Number(process.env.MAX_OPPORTUNITY_AGE_DAYS ?? 14), 1);
let recentEnough = true;
if (item.publishedAt) {
const published = new Date(item.publishedAt).getTime();
if (!Number.isNaN(published)) {
recentEnough = Date.now() - published <= maxAgeDays * 86_400_000;
}
}
const shouldCreateOpportunity =
!options.baseline &&
recentEnough &&
scored.score >= minScore;
await pool.query("BEGIN"); await pool.query("BEGIN");
try { try {
await pool.query( await pool.query(
`INSERT INTO signals(id,source_id,external_id,title,summary,url,published_at,content_hash,topics,raw_json) `INSERT INTO signals(
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb)`, id,source_id,external_id,title,summary,url,published_at,content_hash,change_kind,topics,raw_json
)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb)`,
[ [
signalId, item.sourceId, item.externalId, item.title, item.summary, item.url, signalId,
item.publishedAt, hash, JSON.stringify(item.topics), JSON.stringify(item.raw) item.sourceId,
item.externalId,
item.title,
item.summary,
item.url,
item.publishedAt,
hash,
changeKind,
JSON.stringify(item.topics),
JSON.stringify(item.raw)
] ]
); );
await pool.query( await pool.query(
`INSERT INTO evidence(id,signal_id,source_url,content_hash,excerpt) `INSERT INTO evidence(id,signal_id,source_url,content_hash,excerpt)
VALUES($1,$2,$3,$4,$5)`, VALUES($1,$2,$3,$4,$5)`,
[evidenceId, signalId, item.url, hash, `${item.title}\n${item.summary}`.slice(0, 2000)] [
evidenceId,
signalId,
item.url,
hash,
`${item.title}\n${item.summary}`.slice(0, 2000)
]
); );
if (scored.score >= Number(process.env.OPPORTUNITY_MIN_SCORE ?? 1)) { if (shouldCreateOpportunity) {
await pool.query( await pool.query(
`INSERT INTO opportunities(id,signal_id,status,score,reasons) `INSERT INTO opportunities(id,signal_id,status,score,reasons)
VALUES($1,$2,'new',$3,$4::jsonb)`, VALUES($1,$2,'new',$3,$4::jsonb)`,
@ -180,10 +268,28 @@ export async function storeItem(item: NormalizedItem, scored: ScoredItem): Promi
await pool.query( await pool.query(
`INSERT INTO audit_events(id,event_type,object_type,object_id,payload) `INSERT INTO audit_events(id,event_type,object_type,object_id,payload)
VALUES($1,'signal.ingested','signal',$2,$3::jsonb)`, VALUES($1,'signal.ingested','signal',$2,$3::jsonb)`,
[randomUUID(), signalId, JSON.stringify({ sourceId: item.sourceId, externalId: item.externalId, score: scored.score })] [
randomUUID(),
signalId,
JSON.stringify({
sourceId: item.sourceId,
externalId: item.externalId,
score: scored.score,
changeKind,
opportunityCreated: shouldCreateOpportunity,
recentEnough
})
]
); );
await pool.query("COMMIT"); await pool.query("COMMIT");
return { created: true, signalId, opportunityId: scored.score >= Number(process.env.OPPORTUNITY_MIN_SCORE ?? 1) ? opportunityId : undefined };
return {
created: true,
signalId,
opportunityId: shouldCreateOpportunity ? opportunityId : undefined,
changeKind
};
} catch (error) { } catch (error) {
await pool.query("ROLLBACK"); await pool.query("ROLLBACK");
throw error; throw error;

View File

@ -61,7 +61,7 @@ app.get("/ready", async (_req, res) => {
COUNT(sig.id)::int AS signal_count COUNT(sig.id)::int AS signal_count
FROM sources s FROM sources s
LEFT JOIN signals sig ON sig.source_id=s.id LEFT JOIN signals sig ON sig.source_id=s.id
GROUP BY s.id,s.name,s.last_synced_at,s.last_error GROUP BY s.id,s.name,s.last_synced_at,s.last_error,s.baseline_complete
ORDER BY s.id` ORDER BY s.id`
); );
const missing = result.rows const missing = result.rows
@ -114,7 +114,7 @@ app.post("/messages/:id/ack", requireWebAuth, async (req, res) => {
app.get("/", requireWebAuth, async (req, res) => { app.get("/", requireWebAuth, async (req, res) => {
const sourceRows = await pool.query( const sourceRows = await pool.query(
`SELECT s.id,s.name,s.last_synced_at,s.last_error, `SELECT s.id,s.name,s.last_synced_at,s.last_error,s.baseline_complete,
COUNT(sig.id)::int AS signal_count COUNT(sig.id)::int AS signal_count
FROM sources s FROM sources s
LEFT JOIN signals sig ON sig.source_id=s.id LEFT JOIN signals sig ON sig.source_id=s.id
@ -124,7 +124,7 @@ app.get("/", requireWebAuth, async (req, res) => {
const counts = await pool.query(` const counts = await pool.query(`
SELECT SELECT
(SELECT COUNT(*) FROM signals) AS signals, (SELECT COUNT(*) FROM signals) AS signals,
(SELECT COUNT(*) FROM opportunities) AS opportunities, (SELECT COUNT(*) FROM opportunities WHERE status <> 'closed') AS opportunities,
(SELECT COUNT(*) FROM opportunities WHERE status='new') AS new_opportunities, (SELECT COUNT(*) FROM opportunities WHERE status='new') AS new_opportunities,
(SELECT COUNT(*) FROM agent_messages WHERE acknowledged_at IS NULL) AS unread_messages (SELECT COUNT(*) FROM agent_messages WHERE acknowledged_at IS NULL) AS unread_messages
`); `);
@ -175,7 +175,7 @@ ${messages.length
</div>`).join("") </div>`).join("")
: '<div class="card"><span class="meta">No unread agent messages.</span></div>'} : '<div class="card"><span class="meta">No unread agent messages.</span></div>'}
<h2>Sources</h2> <h2>Sources</h2>
${sourceRows.rows.map((s:any)=>`<div class="card"><strong>${s.name}</strong><br><code>${s.id}</code><br>${s.signal_count} stored signals<br><span class="${s.last_error?'bad':'ok'}">${s.last_error ? 'Error: '+s.last_error : (s.last_synced_at ? 'Last sync '+new Date(s.last_synced_at).toLocaleString() : 'Waiting for first sync')}</span></div>`).join("")} ${sourceRows.rows.map((s:any)=>`<div class="card"><strong>${s.name}</strong><br><code>${s.id}</code> · ${s.baseline_complete ? 'baseline ready' : 'awaiting baseline'}<br>${s.signal_count} stored signals<br><span class="${s.last_error?'bad':'ok'}">${s.last_error ? 'Error: '+s.last_error : (s.last_synced_at ? 'Last sync '+new Date(s.last_synced_at).toLocaleString() : 'Waiting for first sync')}</span></div>`).join("")}
<p>MCP endpoint: <code>/api/mcp</code> — OAuth protected separately from this browser session.</p> <p>MCP endpoint: <code>/api/mcp</code> — OAuth protected separately from this browser session.</p>
</body></html>`); </body></html>`);
}); });

View File

@ -15,21 +15,41 @@ export async function syncSource(sourceId: SourceId) {
[runId, sourceId] [runId, sourceId]
); );
try { try {
const sourceState = await pool.query(
"SELECT baseline_complete FROM sources WHERE id=$1",
[sourceId]
);
const baseline = !Boolean(sourceState.rows[0]?.baseline_complete);
const items = await fetchSource(sourceId); const items = await fetchSource(sourceId);
let created = 0; let created = 0;
let opportunitiesCreated = 0;
for (const item of items) { for (const item of items) {
const result = await storeItem(item, scoreItem(item)); const result = await storeItem(item, scoreItem(item), { baseline });
if (result.created) created++; if (result.created) created++;
if (result.opportunityId) opportunitiesCreated++;
} }
await pool.query( await pool.query(
"UPDATE source_runs SET status='ok',finished_at=NOW(),item_count=$2,new_signal_count=$3 WHERE id=$1", "UPDATE source_runs SET status='ok',finished_at=NOW(),item_count=$2,new_signal_count=$3 WHERE id=$1",
[runId, items.length, created] [runId, items.length, created]
); );
await pool.query( await pool.query(
"UPDATE sources SET last_synced_at=NOW(),last_error=NULL WHERE id=$1", `UPDATE sources
[sourceId] SET last_synced_at=NOW(),
last_error=NULL,
baseline_complete=CASE WHEN $2 THEN TRUE ELSE baseline_complete END
WHERE id=$1`,
[sourceId, baseline && items.length > 0]
); );
return { sourceId, items: items.length, newSignals: created };
return {
sourceId,
mode: baseline ? "baseline" : "live",
items: items.length,
newSignals: created,
opportunitiesCreated
};
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
await pool.query( await pool.query(