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
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,50 @@
All notable changes to bicameral-mcp are tracked here. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## 0.4.14 — 2026-04-15 — Source Excerpt + Meeting Date in Read Responses

The "tie meeting context to code" value prop only worked at write time.
At read time, the brief / drift / search responses returned the
decision text and the source_ref string but stripped the raw source
passage that produced the decision — even though `source_span.text`
was sitting in the ledger waiting to be surfaced. Surfaced during
demo gallery work when the visual was forced to either invent
context or look thin.

### Added

- **`DecisionMatch.source_excerpt` + `meeting_date`** (search responses)
- **`DriftEntry.source_excerpt` + `meeting_date`** (drift responses)
- **`BriefDecision.source_excerpt` + `meeting_date`** (brief responses)
- **`search_by_bm25`** now pulls source_span.text + meeting_date via
`<-yields<-source_span.{text, meeting_date}` reverse traversal in
the same query — no extra DB roundtrip.
- **`get_decisions_for_file`** does a follow-up batched query against
the matched intent IDs to backfill the same fields. Single round-trip
regardless of how many intents touch the file.
- **Synthetic-span filter**: `_reground_ungrounded` writes
placeholder source_spans where `text == intent.description` to
trigger lazy grounding. Both query paths filter those out so the
excerpt always reflects the original meeting passage, never the
bookkeeping placeholder.

### Tests

- 4 new cases in `tests/test_v0414_source_excerpt.py`:
- search response surfaces source_excerpt + meeting_date
- brief response surfaces source_excerpt + meeting_date
- drift response surfaces source_excerpt + meeting_date (via the
follow-up batched query)
- empty source_span text → empty source_excerpt (graceful, no
leak from synthetic reground spans)

### Migration

No schema changes. `source_span.text` and `source_span.meeting_date`
were already stored at ingest. v0.4.14 just plumbs them through to
the read responses. Pre-v0.4.14 clients that ignore `source_excerpt`
and `meeting_date` see no change.

## 0.4.13 — 2026-04-14 — Content-Addressable Dedup (Team Mode Hardening)

Closes the team-mode dedup gap. Previously, when two developers
Expand Down
12 changes: 12 additions & 0 deletions contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ class DecisionMatch(BaseModel):
code_regions: list[CodeRegionSummary]
drift_evidence: str = ""
related_constraints: list[str] = []
# v0.4.14: meeting context — the raw passage from the source that
# produced this decision, plus the meeting date if known. Pulled
# from source_span.text via the yields reverse edge. Empty when
# the source_span has no text or no link to this intent.
source_excerpt: str = ""
meeting_date: str = ""


class LinkCommitResponse(BaseModel):
Expand Down Expand Up @@ -157,6 +163,9 @@ class DriftEntry(BaseModel):
lines: tuple[int, int]
drift_evidence: str = ""
source_ref: str
# v0.4.14: meeting context tied to this decision (see DecisionMatch).
source_excerpt: str = ""
meeting_date: str = ""


class DetectDriftResponse(BaseModel):
Expand Down Expand Up @@ -285,6 +294,9 @@ class BriefDecision(BaseModel):
code_regions: list[CodeRegionSummary] = []
severity_tier: int = 1 # 1=L1, 2=L2, 3=L3 — populated by v0.4.7 severity config
drift_evidence: str = ""
# v0.4.14: meeting context tied to this decision (see DecisionMatch).
source_excerpt: str = ""
meeting_date: str = ""


class BriefGap(BaseModel):
Expand Down
3 changes: 3 additions & 0 deletions handlers/brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ def _to_brief_decision(m: DecisionMatch) -> BriefDecision:
],
severity_tier=1, # v0.4.6: no severity config, all decisions default L1
drift_evidence=m.drift_evidence,
# v0.4.14: forward meeting context from the matched DecisionMatch
source_excerpt=m.source_excerpt,
meeting_date=m.meeting_date,
)


Expand Down
2 changes: 2 additions & 0 deletions handlers/detect_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ async def handle_detect_drift(
lines=tuple(region.get("lines", (0, 0))),
drift_evidence=drift_evidence,
source_ref=d.get("source_ref", ""),
source_excerpt=d.get("source_excerpt", ""),
meeting_date=d.get("meeting_date", ""),
))

source = "working_tree" if use_working_tree else "HEAD"
Expand Down
2 changes: 2 additions & 0 deletions handlers/search_decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ async def handle_search_decisions(
code_regions=regions,
drift_evidence=m.get("drift_evidence", ""),
related_constraints=m.get("related_constraints", []),
source_excerpt=m.get("source_excerpt", ""),
meeting_date=m.get("meeting_date", ""),
))

ungrounded_count = sum(1 for m in matches if m.status == "ungrounded")
Expand Down
84 changes: 78 additions & 6 deletions ledger/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,14 @@ async def search_by_bm25(
max_results: int = 10,
min_confidence: float = 0.5,
) -> list[dict]:
"""BM25 search on intent.description."""
"""BM25 search on intent.description.

v0.4.14: also pulls ``source_span.text`` (raw passage) + ``meeting_date``
via the ``yields`` reverse edge so callers can render the meeting
excerpt that produced the decision. The first source_span linked to
the intent is used; multiple spans (same decision mentioned in
multiple meetings) collapse to the earliest one for stability.
"""
rows = await client.query(
"""
SELECT
Expand All @@ -193,16 +200,14 @@ async def search_by_bm25(
end_line,
purpose,
content_hash
} AS code_regions
} AS code_regions,
<-yields<-source_span.{text, meeting_date} AS source_spans
FROM intent
WHERE description @0@ $query
LIMIT $n
""",
{"query": query, "n": max_results},
)
# @0@ already filtered to matching documents.
# Assign position-based confidence (1.0 for first match, decreasing).
# Note: embedded SurrealDB v2 always returns search::score=0.0 — use count instead.
total = len(rows)
for i, row in enumerate(rows):
ca = row.pop("created_at", None)
Expand All @@ -211,6 +216,21 @@ async def search_by_bm25(
for region in (row.get("code_regions") or []):
if region and "symbol_name" in region:
region["symbol"] = region.pop("symbol_name")
# v0.4.14: collapse source_spans → top-level source_excerpt + meeting_date.
# Filter out:
# - empty-text spans (placeholder rows from ingests with no passage)
# - synthetic spans created by _reground_ungrounded, which writes
# text=description as a placeholder. Those aren't real meeting
# context; the original ingest's span is.
spans = row.pop("source_spans", None) or []
description = row.get("description", "")
real_spans = [
s for s in spans
if s and s.get("text") and s.get("text") != description
]
first_span = real_spans[0] if real_spans else None
row["source_excerpt"] = (first_span.get("text") if first_span else "") or ""
row["meeting_date"] = (first_span.get("meeting_date") if first_span else "") or ""
return _normalize_decisions(rows)


Expand Down Expand Up @@ -294,7 +314,13 @@ async def get_decisions_for_file(
client: LedgerClient,
file_path: str,
) -> list[dict]:
"""Reverse traversal: code_region → symbol → intent for a given file."""
"""Reverse traversal: code_region → symbol → intent for a given file.

v0.4.14: also pulls ``source_excerpt`` + ``meeting_date`` per intent
via a follow-up batch query against the ``yields`` reverse edge so
the drift handler can render the meeting passage that produced
each decision.
"""
rows = await client.query(
"""
SELECT
Expand Down Expand Up @@ -322,6 +348,7 @@ async def get_decisions_for_file(
# Flatten: one row per (region, intent) pair
results = []
seen_intent_ids: set[str] = set()
intent_id_set: set[str] = set()
for region_row in rows:
region = {
"file_path": region_row.get("file_path", ""),
Expand All @@ -337,16 +364,61 @@ async def get_decisions_for_file(
if iid in seen_intent_ids:
continue
seen_intent_ids.add(iid)
intent_id_set.add(iid)
results.append({
"intent_id": iid,
"description": intent.get("description", ""),
"source_type": intent.get("source_type", ""),
"source_ref": intent.get("source_ref", ""),
"source_excerpt": "",
"meeting_date": "",
"speaker": "",
"ingested_at": str(intent.get("created_at", "")),
"status": intent.get("status", "ungrounded"),
"code_region": region,
})

# v0.4.14: backfill source_excerpt + meeting_date via yields reverse edge.
# Single batched query keeps this O(1) extra DB roundtrip regardless of
# how many intents matched the file. Compares stringified IDs because
# SurrealDB's bind for record-ref IN clause is finicky in embedded mode.
if intent_id_set:
excerpt_rows = await client.query(
"""
SELECT
type::string(id) AS intent_id,
<-yields<-source_span.{text, meeting_date} AS source_spans
FROM intent
WHERE type::string(id) IN $ids
""",
{"ids": list(intent_id_set)},
)
excerpt_by_intent: dict[str, tuple[str, str]] = {}
# Build a description lookup so we can filter synthetic
# _reground_ungrounded spans (text == description) the same way
# search_by_bm25 does.
desc_by_intent = {e["intent_id"]: e.get("description", "") for e in results}
for r in (excerpt_rows or []):
iid = str(r.get("intent_id", ""))
desc = desc_by_intent.get(iid, "")
spans = r.get("source_spans") or []
real_spans = [
s for s in spans
if s and s.get("text") and s.get("text") != desc
]
first = real_spans[0] if real_spans else None
if first:
excerpt_by_intent[iid] = (
str(first.get("text") or ""),
str(first.get("meeting_date") or ""),
)
for entry in results:
iid = entry["intent_id"]
if iid in excerpt_by_intent:
excerpt, mdate = excerpt_by_intent[iid]
entry["source_excerpt"] = excerpt
entry["meeting_date"] = mdate

return results


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "bicameral-mcp"
version = "0.4.13"
version = "0.4.14"
description = "Decision ledger MCP server — ingests meeting transcripts, maps decisions to code, tracks drift"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 2 additions & 0 deletions tests/test_v0413_canonical_dedup.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ async def test_upsert_intent_collapses_whitespace_variant(monkeypatch, surreal_u
intent row, dedupe via canonical_id."""
monkeypatch.setenv("USE_REAL_LEDGER", "1")
monkeypatch.setenv("SURREAL_URL", surreal_url)
reset_ledger_singleton()

ledger = get_ledger()
if hasattr(ledger, "connect"):
Expand Down Expand Up @@ -252,6 +253,7 @@ async def test_upsert_intent_distinguishes_real_differences(monkeypatch, surreal
"""Different decisions on the same source produce different rows."""
monkeypatch.setenv("USE_REAL_LEDGER", "1")
monkeypatch.setenv("SURREAL_URL", surreal_url)
reset_ledger_singleton()

ledger = get_ledger()
if hasattr(ledger, "connect"):
Expand Down
Loading