Skip to content

story/9.4 + 9.5: Embed promises, and emit promise.extracted (closes #60, #61) - #182

Merged
developer-ainative merged 4 commits into
mainfrom
story/9.4-9.5-embed-promises-event
Oct 20, 2026
Merged

developer-ainative merged 4 commits into
mainfrom
story/9.4-9.5-embed-promises-event

Conversation

@developer-ainative

Copy link
Copy Markdown
Contributor

Closes #60 (9.4 Embed promises, 2 pts) and #61 (9.5 Promise extracted event, 1 pt).

Feature file: backend/tests/features/promise_embedding.feature — 18 scenarios.

Why the two stories are one branch

9.5's event is the completion signal of 9.4's embedding step, not an independent write. promise.extracted tells story 10.1's mapping agent that a promise is ready to be matched to bills; a promise is not ready until it has a vector to be matched with, because 10.1's whole mechanism is a semantic search of this namespace. Emitting first would wake a consumer that finds nothing searchable — so the order is embed-then-announce, and the pair is verified as one transition.

What it does

app/ingest/promise_embedding.py closes the two hooks story 9.3 left behind ("vector_id": None, # story 9.4 embeds and fills this, and no event at all).

9.4 — the embedded document is the promise's own quote_span, verbatim and nothing else. actor/action/object are deliberately not appended: those are the extractor's reading of the sentence, and embedding them would make a bill match a promise partly on wording nobody said (with 9.3's stand-in extractor still wired, partly on regex output). Metadata is exactly datamodel.md's five fields — promise_id, person_id, topic_id, cycle, testable.

9.5 — one promise.extracted per promise, payload exactly {promise_id, person_id}, emitted only after the vector exists and the row carries its id.

Live findings that shaped the design (measured, not assumed)

embed_and_store returns no vector id. The live response is {success, vectors_stored, embeddings_generated, model: "bge-m3", dimensions: 1024, target_column, namespace, project_id, processing_time_ms}. So the vector_id 9.4's criterion requires cannot be read off the write — it is recovered by listing the namespace and matching the promise_id this module itself wrote into the metadata. bge-m3 at 1024 dims re-confirmed off the live response (datamodel.md's earlier 768 correction holds). Both recorded in datamodel.md's open questions with an escalation.

No vector upsert, no in-place update, so a rebuild is delete-then-insert.

Two judgment calls worth review

1. The staleness check is the stored document vs. the row's current quote_span — not "does the row have a vector_id". 9.3's ids are content-addressed over (source_id, quote_span, actor, action, object, direction), so a changed quote makes a new row — but a re-extraction revising only confidence/testable updates the same row, and a better extractor trimming a quote span to the exact committing words would leave a vector_id pointing at a vector of the old, longer text. Only the document check catches that.

2. cycle is derived, because no model carries it. Election has election_date, its id embeds the same date, and Candidacy/Promise have no cycle field. It is read from the candidacy's election_id, and is None rather than a guess when no plausible year is there — a wrong cycle filters the mapping agent to the wrong election's bills, which is worse than no filter. topic_id is likewise present-and-null, not omitted: a filter_metadata={"topic_id": …} search against vectors that never declared the field cannot match, a gap that would only surface once 10.1 shipped and would read as a mapping bug.

Nothing is reported that did not happen

The PR #166 lesson, applied before it could become the same bug here. report.embedded requires the vector stored, its id read back and written to the row. report.events_emitted requires create_event to have returned. Everything else lands in report.failures with a reason. A failed delete of a stale vector refuses to embed over it rather than leaving two vectors the mapping agent would match twice. An unreadable namespace up front aborts the source rather than embedding blindly into a duplicate per promise.

One docstring claim I wrote was wrong and was corrected by its own test rather than left standing: "a missing candidacy costs the cycle, not the embedding" is false — Promise.references names candidacy_id, so the vector_id write-back fails its reference check and the promise is reported as a failure. That is the right outcome (an unattributable promise must not become searchable evidence) and is now pinned by test_a_candidacy_that_does_not_resolve_is_reported_as_a_failure.

Tests

47 new tests, 100% coverage on app/ingest/promise_embedding.py. Full suite 3965 passed, 4 xfailed (was 3918 before this branch — no regressions). ruff check and ruff format --check clean.

Live verification — the subject is SYNTHETIC, and that is the honest part

scripts/verify_promise_embedding_live.py — 9 checks, all passing, cleanup confirmed by live re-read.

I ran the real extractor for real first rather than skipping it. scripts/extract_promises.py --dry-run against all 6 live promise_sources rows, real HTTP fetches: 264 candidate passages → 8 "commitments", 0 testable, all 8 visibly false positives — worse than 9.3's original 0-for-7, because electjon.com now 302-redirects to secure.actblue.com/donate/ossoff-tv-hit, so two "promises" are fundraising legal boilerplate ("Americans living abroad may only contribute…"), two are a Pelosi sentence criticising opponents read as Congress's own commitment, and two are a Collins biography line.

Writing those would place eight fabricated promises attributed to three real, named public figures — two of them sitting members of Congress — into the system of record. Story 9.3 refused and the reasoning has not changed, so I did not either, and did not pick the least-bad one to make the live run look fuller.

So the live subject is person:TEST-EMBED-9-4 / "SYNTHETIC TEST CANDIDATE (not a real person)" with a made-up quote about a fictional state's ferry toll — story 6.2's labelling pattern — and every row plus its vector is deleted and confirmed gone. The live promises table is empty after this run, exactly as before it (verified: promises 0, people back to 542, namespace holding only story 1.3's bootstrap marker).

What that proves: every live API behaviour this code is built on, including that the five-field metadata round-trips verbatim with topic_id: null intact, that the row really carries the vector_id, that promise.extracted really publishes and reads back with exactly the documented payload, that a second run embeds and emits nothing, and that a changed quote rebuilds to exactly one vector. What it does not prove: anything about whether real promise text embeds well — blocked on the same missing LLM credential 9.3 documented, and it becomes real with no change to this module.

scripts/embed_promises.py also run live: 6 sources, 0 promises, exit 0, reporting plainly that there is nothing to embed rather than a vacuous success.

Open questions for human review

  1. Nothing real is embedded yet, and that is upstream of this story. The live promises table is empty and will stay empty until a real extractor exists (ANTHROPIC_API_KEY + an implementation of 9.3's PromiseExtractor seam). This module needs no change when that lands. Note for whoever reads 13.1's open question: it records "3 promise_sources" from its own run; there are 6 today (3 story 9.1 captures + 3 re-captures), and I left its historical measurement alone rather than rewriting another story's record.
  2. electjon.com no longer serves a campaign page — it 302s to ActBlue. Three of the six live promise_sources rows therefore now cite a donation form. That is a data problem for epic 9, not for this story, but it will corrupt extraction the moment a real extractor runs. Worth a re-capture pass.
  3. No scheduled job registered, deliberately. Embedding's only producer (9.3's extraction) is not scheduled either, and registering a consumer ahead of its producer would be a job that is an honest no-op by construction. When extraction gets a cadence, PromiseEmbedder is a one-liner to add to app.scheduling.jobs.default_jobs().
  4. No durable outbox. The event is emitted only when a promise is newly embedded, so a publish outage means that promise's event is never retried — a later run finds its vector current, skips it, and emits nothing. The promise stays stored, embedded and searchable; only the automatic mapping trigger is lost, and 10.1 is callable directly for a backfill. Same accepted trade HouseRollCallLoader._emit_ingested documents. An outbox belongs with the durable scheduler this codebase does not have yet.
  5. Synthetic events are permanent in the live log (2, naming a now-deleted promise id). Verified against the live OpenAPI schema, not assumed: /database/events/{event_id} is get only and the sole event DELETE is for subscriptions. Recorded in datamodel.md with an escalation — relevant before epic 11 verifies verdict.published the same way.

🤖 Generated with Claude Code

AINative Admin and others added 4 commits October 20, 2026 03:25
…dding (red)

Gherkin first, per CLAUDE.md. One feature file for both stories because
9.5's promise.extracted event is the completion signal of 9.4's embedding
step: a promise is not ready for the mapping agent until it has a vector to
be matched with, so announcing it before the vector exists would wake a
consumer that finds nothing searchable.

What the scenarios pin:
  * 9.4: every promise row ends with a vector_id that names a vector that
    really exists; the document is the promise's own verbatim quote; the
    metadata carries datamodel.md's promise_id, person_id, topic_id, cycle
    and testable; re-running is idempotent; a changed quote re-embeds.
  * 9.5: exactly one promise.extracted event per promise, payload
    promise_id + person_id, no second event on a re-run.
  * Both: nothing is ever REPORTED as done that did not happen -- a failed
    embed leaves no vector_id and emits no event, a vector_id that cannot be
    read back is not counted as embedded, and a failed publish is not
    counted as emitted (the PR #166 lesson applied here).

Red: ModuleNotFoundError for app.ingest.promise_embedding, which does not
exist yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
app/ingest/promise_embedding.py closes the two hooks story 9.3 left:
promises.vector_id ("story 9.4 embeds and fills this") and the missing
promise.extracted event. 47 tests, 100% coverage on the new module.

9.4 — the document is the promise's own verbatim quote_span and nothing
else; actor/action/object are the EXTRACTOR's reading, and embedding them
would make a bill match a promise partly on wording nobody said (with 9.3's
stand-in extractor still wired, partly on regex output). Metadata is exactly
datamodel.md's five fields: promise_id, person_id, topic_id, cycle, testable.
topic_id is present-and-null rather than omitted, so story 10.1's
filter_metadata search can match it once 10.1 fills it. cycle is derived
from the candidacy's election_id because no model carries it, and is None
rather than a guess when no year is readable.

Two live facts measured against the real project today, which shape the
design rather than being footnotes:
  * embed_and_store returns NO vector ids -- the live response is
    {success, vectors_stored, embeddings_generated, model: "bge-m3",
    dimensions: 1024, target_column, namespace, project_id,
    processing_time_ms}. So the vector_id 9.4 requires cannot be read off
    the write; it is recovered by listing the namespace and matching on the
    metadata this module wrote. bge-m3 at 1024 dims confirmed, not assumed.
  * no vector upsert and no in-place update, so re-embedding is
    delete-then-insert and idempotency is decided from what is stored.

The staleness check is the stored document vs. the row's current quote_span,
NOT "does the row have a vector_id": a re-extraction that trims a quote span
leaves a vector_id pointing at the old text, and only the document check
catches it.

9.5 — one promise.extracted per promise, payload promise_id + person_id,
emitted only AFTER the vector exists and the row carries its id. Announcing
first would wake the mapping agent to nothing searchable.

Nothing is reported that did not happen (the PR #166 lesson, applied before
it could become the same bug): report.embedded requires the vector stored,
its id read back AND written to the row; report.events_emitted requires
create_event to have returned; everything else lands in report.failures with
a reason. A failed delete of a stale vector refuses to embed over it rather
than leaving two vectors the mapping agent matches twice.

One docstring claim was written wrong and corrected by its own test rather
than left standing: "a missing candidacy costs the cycle, not the embedding"
is false -- Promise.references names candidacy_id, so the vector_id
write-back fails its reference check and the promise is reported as a
failure. That is the right outcome (an unattributable promise must not
become searchable evidence) and is now pinned by
test_a_candidacy_that_does_not_resolve_is_reported_as_a_failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eroDB

scripts/embed_promises.py -- thin CLI over PromiseEmbedder, no logic of its
own, paced for the ~60 req/min ceiling. Run live: 6 sources, 0 promises,
exit 0, and it says plainly that there is nothing to embed rather than
reporting a vacuous success.

scripts/verify_promise_embedding_live.py -- 9 checks against the real
project, all passing, with cleanup confirmed by a live re-read. What it
measured rather than assumed:

  * embed_and_store returns NO vector id. Full live body printed on every
    run: {dimensions: 1024, embeddings_generated: 1, model: "bge-m3",
    namespace, processing_time_ms, project_id, success, target_column,
    vectors_stored}. This is why 9.4 needs a second read.
  * bge-m3 / 1024 dims re-confirmed (datamodel.md's earlier 768 correction
    holds).
  * the five-field metadata round-trips verbatim INCLUDING topic_id: null --
    the null-but-present key story 10.1 depends on, which a store could
    silently drop.
  * the row carries the vector_id, re-read from the live table, not from the
    report.
  * promise.extracted publishes with exactly {promise_id, person_id} and is
    readable back from the live event log.
  * a second run embeds nothing, emits nothing, and the live event count is
    unchanged (9.5's "once per promise", against the real store).
  * a changed quote_span deletes the stale vector and stores a fresh one --
    still exactly one vector, and the row repoints.

THE SUBJECT IS SYNTHETIC, AND THAT IS STATED EVERYWHERE RATHER THAN BURIED.
The live promises table is empty (0 rows against 6 real promise_sources).
The real extractor was run for real first rather than skipped:
`extract_promises.py --dry-run`, real fetches of all six live urls, 264
passages -> 8 "commitments", 0 testable, all 8 visibly false positives --
worse than 9.3's 0-for-7, because electjon.com now 302-redirects to an
ActBlue donation page so two "promises" are fundraising legal boilerplate,
two are a Pelosi sentence criticising opponents read as Congress's own
commitment, and two are a Collins biography line. Writing those would put
eight fabricated promises attributed to three real named public figures into
the system of record. So the live subject is person:TEST-EMBED-9-4 /
"SYNTHETIC TEST CANDIDATE (not a real person)" with a made-up quote about a
fictional state's ferry toll, following story 6.2's labelling pattern, and
every row plus its vector is deleted and confirmed gone. The live promises
table is empty after this run, exactly as before it.

Known residue, verified rather than guessed: the two synthetic
promise.extracted events stay in the live log. Checked against the live
OpenAPI schema -- /database/events/{event_id} exposes `get` only, and the
only event DELETE is for subscriptions. So there is no way to remove a
published event, and the script's cleanup docstring says so instead of
implying a clean log.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…uestions

Both measured against the real project today, not inferred:

  * embed_and_store returns no vector id, so satisfying 9.4's "every promise
    has a vector_id" costs a list-and-match read per vector -- three live
    calls per promise where one would do, against a ~60 req/min ceiling.
    Story 3.4 hit the same wall from the other side. Escalation: return the
    created ids, or accept a caller-supplied one (which would also make
    vectors idempotently upsertable and retire the delete-then-insert
    rebuild both modules are forced into).
  * a published event cannot be deleted -- /database/events/{event_id} is
    `get` only and the sole event DELETE is for subscriptions, checked
    against the live OpenAPI schema. So every live verification of an
    event-emitting story permanently adds noise to the event log. Worth
    knowing before epic 11's verdict.published.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@developer-ainative
developer-ainative merged commit 7bf2a8d into main Oct 20, 2026
6 checks passed
@developer-ainative
developer-ainative deleted the story/9.4-9.5-embed-promises-event branch October 20, 2026 10:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9.4 Embed promises (2 pts)

1 participant