Repository navigation
story/9.4 + 9.5: Embed promises, and emit promise.extracted (closes #60, #61) - #182
Merged
Merged
Conversation
…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>
This was referenced Oct 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.extractedtells 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.pycloses 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/objectare 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.extractedper 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_storereturns 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 thevector_id9.4's criterion requires cannot be read off the write — it is recovered by listing the namespace and matching thepromise_idthis module itself wrote into the metadata.bge-m3at 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 avector_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 onlyconfidence/testableupdates the same row, and a better extractor trimming a quote span to the exact committing words would leave avector_idpointing at a vector of the old, longer text. Only the document check catches that.2.
cycleis derived, because no model carries it.Electionhaselection_date, its id embeds the same date, andCandidacy/Promisehave no cycle field. It is read from the candidacy'selection_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_idis likewise present-and-null, not omitted: afilter_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.embeddedrequires the vector stored, its id read back and written to the row.report.events_emittedrequirescreate_eventto have returned. Everything else lands inreport.failureswith 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.referencesnamescandidacy_id, so thevector_idwrite-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 bytest_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 checkandruff format --checkclean.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-runagainst all 6 livepromise_sourcesrows, 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 tosecure.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 livepromisestable is empty after this run, exactly as before it (verified:promises0,peopleback 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: nullintact, that the row really carries thevector_id, thatpromise.extractedreally 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.pyalso 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
promisestable is empty and will stay empty until a real extractor exists (ANTHROPIC_API_KEY+ an implementation of 9.3'sPromiseExtractorseam). This module needs no change when that lands. Note for whoever reads 13.1's open question: it records "3promise_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.electjon.comno longer serves a campaign page — it 302s to ActBlue. Three of the six livepromise_sourcesrows 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.PromiseEmbedderis a one-liner to add toapp.scheduling.jobs.default_jobs().HouseRollCallLoader._emit_ingesteddocuments. An outbox belongs with the durable scheduler this codebase does not have yet./database/events/{event_id}isgetonly and the sole event DELETE is for subscriptions. Recorded in datamodel.md with an escalation — relevant before epic 11 verifiesverdict.publishedthe same way.🤖 Generated with Claude Code