build(agent): c3po#b883b4 iteration

This commit is contained in:
agent-b883b4bc188823a2 2026-04-26 22:37:08 +02:00
parent fb30fcac0e
commit 360cd063b6
5 changed files with 391 additions and 78 deletions

View File

@ -10,6 +10,7 @@ CivicSwarm stores proposals, comments, resident profiles, preference signals, an
- summarizes discussion by detected language - summarizes discussion by detected language
- routes proposal fragments to relevant residents by geography, interests, experience, and language - routes proposal fragments to relevant residents by geography, interests, experience, and language
- aggregates preferences with optional differential-privacy noise - aggregates preferences with optional differential-privacy noise
- ingests offline capture batches from mobile, SMS, and field canvassing with audit-friendly idempotency
- exports a civic brief with provenance and audit trail - exports a civic brief with provenance and audit trail
## Stack ## Stack
@ -50,10 +51,12 @@ Environment variables:
- `POST /residents` - `POST /residents`
- `POST /proposals/{proposal_id}/comments` - `POST /proposals/{proposal_id}/comments`
- `POST /proposals/{proposal_id}/preferences` - `POST /proposals/{proposal_id}/preferences`
- `POST /capture-batches`
- `GET /proposals/{proposal_id}/route` - `GET /proposals/{proposal_id}/route`
- `GET /proposals/{proposal_id}/dashboard` - `GET /proposals/{proposal_id}/dashboard`
- `GET /proposals/{proposal_id}/brief` - `GET /proposals/{proposal_id}/brief`
- `GET /proposals/{proposal_id}/ledger` - `GET /proposals/{proposal_id}/ledger`
- `GET /capture-batches/{batch_key}`
## Testing ## Testing

View File

@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated, Literal
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -38,6 +40,61 @@ class PreferenceCreate(BaseModel):
channel: str = "mobile" channel: str = "mobile"
class BatchResident(BaseModel):
kind: Literal["resident"] = "resident"
resident_key: str
geography: str = ""
interests: list[str] = Field(default_factory=list)
lived_experience: list[str] = Field(default_factory=list)
languages: list[str] = Field(default_factory=list)
class BatchComment(BaseModel):
kind: Literal["comment"] = "comment"
proposal_id: int
text: str
resident_key: str | None = None
channel: str = "mobile"
geography: str = ""
metadata: dict = Field(default_factory=dict)
class BatchPreference(BaseModel):
kind: Literal["preference"] = "preference"
proposal_id: int
resident_key: str
score: float
channel: str = "mobile"
class BatchLedger(BaseModel):
kind: Literal["ledger"] = "ledger"
proposal_id: int
entry_kind: str
payload: dict = Field(default_factory=dict)
class BatchProposalVersion(BaseModel):
kind: Literal["proposal_version"] = "proposal_version"
proposal_id: int
rationale: str = ""
objections: list[str] = Field(default_factory=list)
resolution_status: str = "open"
CaptureItem = Annotated[
BatchResident | BatchComment | BatchPreference | BatchLedger | BatchProposalVersion,
Field(discriminator="kind"),
]
class CaptureBatchCreate(BaseModel):
batch_key: str
source: str = "mobile"
captured_at: datetime | None = None
items: list[CaptureItem] = Field(default_factory=list)
def create_app(db_path: str | Path = ":memory:") -> FastAPI: def create_app(db_path: str | Path = ":memory:") -> FastAPI:
service = CivicSwarmService.from_path(db_path) service = CivicSwarmService.from_path(db_path)
app = FastAPI(title="CivicSwarm", version="0.1.0") app = FastAPI(title="CivicSwarm", version="0.1.0")
@ -62,6 +119,17 @@ def create_app(db_path: str | Path = ":memory:") -> FastAPI:
def create_preference(proposal_id: int, payload: PreferenceCreate): def create_preference(proposal_id: int, payload: PreferenceCreate):
return service.submit_preference(proposal_id, payload.resident_key, payload.score, payload.channel) return service.submit_preference(proposal_id, payload.resident_key, payload.score, payload.channel)
@app.post("/capture-batches")
def ingest_capture_batch(payload: CaptureBatchCreate):
return service.ingest_capture_batch(payload.batch_key, payload.source, [item.model_dump() for item in payload.items], payload.captured_at)
@app.get("/capture-batches/{batch_key}")
def get_capture_batch(batch_key: str):
try:
return service.get_capture_batch(batch_key)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get("/proposals/{proposal_id}/route") @app.get("/proposals/{proposal_id}/route")
def route_proposal(proposal_id: int, top_n: int = 3): def route_proposal(proposal_id: int, top_n: int = 3):
return service.route_proposal(proposal_id, top_n=top_n) return service.route_proposal(proposal_id, top_n=top_n)

View File

@ -18,6 +18,7 @@ from sqlalchemy import (
create_engine, create_engine,
inspect, inspect,
) )
from sqlalchemy.engine import Engine
def utcnow() -> datetime: def utcnow() -> datetime:
@ -103,6 +104,21 @@ routes = Table(
Column("created_at", DateTime(timezone=True), nullable=False, default=utcnow), Column("created_at", DateTime(timezone=True), nullable=False, default=utcnow),
) )
capture_batches = Table(
"capture_batches",
metadata,
Column("id", Integer, primary_key=True),
Column("batch_key", String, nullable=False, unique=True),
Column("source", String, nullable=False),
Column("captured_at", DateTime(timezone=True), nullable=False, default=utcnow),
Column("item_count", Integer, nullable=False, default=0),
Column("status", String, nullable=False, default="received"),
Column("payload_json", Text, nullable=False, default="{}"),
Column("results_json", Text, nullable=False, default="[]"),
Column("created_at", DateTime(timezone=True), nullable=False, default=utcnow),
Column("ingested_at", DateTime(timezone=True), nullable=True),
)
ledger_entries = Table( ledger_entries = Table(
"ledger_entries", "ledger_entries",
metadata, metadata,
@ -116,7 +132,7 @@ ledger_entries = Table(
@dataclass(frozen=True) @dataclass(frozen=True)
class Database: class Database:
engine: object engine: Engine
def create_database(db_url: str): def create_database(db_url: str):

View File

@ -23,7 +23,9 @@ from .analysis import (
tokenize, tokenize,
) )
from .db import ( from .db import (
capture_batches,
comments, comments,
Database,
create_database, create_database,
ledger_entries, ledger_entries,
preferences, preferences,
@ -60,6 +62,7 @@ def _normalize_terms(values: Iterable[str]) -> set[str]:
class CivicSwarmService: class CivicSwarmService:
db_url: str db_url: str
secret: str = "civicswarm" secret: str = "civicswarm"
db: Database | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.db = create_database(self.db_url) self.db = create_database(self.db_url)
@ -167,29 +170,8 @@ class CivicSwarmService:
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
metadata = metadata or {} metadata = metadata or {}
language = detect_language(text)
with self.db.engine.begin() as conn: with self.db.engine.begin() as conn:
result = conn.execute( comment_id = self._insert_comment_row(conn, proposal_id, text, channel, resident_key, geography, metadata)
insert(comments).values(
proposal_id=proposal_id,
resident_key=self._token(resident_key),
channel=channel,
geography=geography,
language=language,
text=text,
metadata_json=_json(metadata),
created_at=_now(),
)
)
comment_id = result.inserted_primary_key[0]
conn.execute(
insert(ledger_entries).values(
proposal_id=proposal_id,
kind="comment_received",
payload_json=_json({"comment_id": comment_id, "channel": channel, "language": language}),
created_at=_now(),
)
)
return self.get_comment(comment_id) return self.get_comment(comment_id)
def get_comment(self, comment_id: int) -> dict[str, Any]: def get_comment(self, comment_id: int) -> dict[str, Any]:
@ -201,26 +183,101 @@ class CivicSwarmService:
def submit_preference(self, proposal_id: int, resident_key: str, score: float, channel: str = "mobile") -> dict[str, Any]: def submit_preference(self, proposal_id: int, resident_key: str, score: float, channel: str = "mobile") -> dict[str, Any]:
with self.db.engine.begin() as conn: with self.db.engine.begin() as conn:
result = conn.execute( preference_id = self._insert_preference_row(conn, proposal_id, resident_key, score, channel)
insert(preferences).values(
proposal_id=proposal_id,
resident_key=self._token(resident_key) or resident_key,
score=float(score),
channel=channel,
created_at=_now(),
)
)
preference_id = result.inserted_primary_key[0]
conn.execute(
insert(ledger_entries).values(
proposal_id=proposal_id,
kind="preference_submitted",
payload_json=_json({"preference_id": preference_id, "channel": channel}),
created_at=_now(),
)
)
return {"id": preference_id, "proposal_id": proposal_id, "resident_key": resident_key, "score": float(score), "channel": channel} return {"id": preference_id, "proposal_id": proposal_id, "resident_key": resident_key, "score": float(score), "channel": channel}
def ingest_capture_batch(self, batch_key: str, source: str, items: list[dict[str, Any]], captured_at: datetime | None = None) -> dict[str, Any]:
captured_at = captured_at or _now()
payload = {"batch_key": batch_key, "source": source, "captured_at": captured_at.isoformat(), "items": items}
with self.db.engine.begin() as conn:
existing = conn.execute(select(capture_batches).where(capture_batches.c.batch_key == batch_key)).mappings().first()
if existing is not None:
return self._capture_batch_dict(existing)
result = conn.execute(
insert(capture_batches).values(
batch_key=batch_key,
source=source,
captured_at=captured_at,
item_count=len(items),
status="received",
payload_json=_json(payload),
results_json=_json([]),
created_at=_now(),
ingested_at=None,
)
)
batch_id = result.inserted_primary_key[0]
processed: list[dict[str, Any]] = []
for item in items:
kind = item.get("kind")
if kind == "resident":
self._insert_resident_row(
conn,
resident_key=item["resident_key"],
geography=item.get("geography", ""),
interests=item.get("interests", []),
lived_experience=item.get("lived_experience", []),
languages=item.get("languages", []),
)
processed.append({"kind": "resident", "resident_key": item["resident_key"]})
elif kind == "comment":
comment_id = self._insert_comment_row(
conn,
proposal_id=int(item["proposal_id"]),
text=item["text"],
channel=item.get("channel", source),
resident_key=item.get("resident_key"),
geography=item.get("geography", ""),
metadata={**item.get("metadata", {}), "batch_key": batch_key, "source": source},
)
processed.append({"kind": "comment", "comment_id": comment_id, "proposal_id": int(item["proposal_id"])})
elif kind == "preference":
preference_id = self._insert_preference_row(
conn,
proposal_id=int(item["proposal_id"]),
resident_key=item["resident_key"],
score=float(item["score"]),
channel=item.get("channel", source),
)
processed.append({"kind": "preference", "preference_id": preference_id, "proposal_id": int(item["proposal_id"])})
elif kind == "ledger":
ledger_id = self._insert_ledger_row(
conn,
proposal_id=int(item["proposal_id"]),
kind=item["entry_kind"],
payload={**item.get("payload", {}), "batch_key": batch_key, "source": source},
)
processed.append({"kind": "ledger", "ledger_id": ledger_id, "proposal_id": int(item["proposal_id"])})
elif kind == "proposal_version":
version_info = self._record_proposal_version_row(
conn,
proposal_id=int(item["proposal_id"]),
rationale=item.get("rationale", ""),
objections=item.get("objections", []),
resolution_status=item.get("resolution_status", "open"),
)
processed.append({"kind": "proposal_version", **version_info})
else:
raise ValueError(f"unsupported capture kind: {kind!r}")
conn.execute(
update(capture_batches)
.where(capture_batches.c.id == batch_id)
.values(status="processed", results_json=_json(processed), ingested_at=_now())
)
return self.get_capture_batch(batch_key)
def get_capture_batch(self, batch_key: str) -> dict[str, Any]:
with self.db.engine.begin() as conn:
row = conn.execute(select(capture_batches).where(capture_batches.c.batch_key == batch_key)).mappings().first()
if row is None:
raise KeyError(f"capture batch {batch_key} not found")
return self._capture_batch_dict(row)
def aggregate_preferences(self, proposal_id: int, epsilon: float | None = None, seed: int = 0) -> dict[str, Any]: def aggregate_preferences(self, proposal_id: int, epsilon: float | None = None, seed: int = 0) -> dict[str, Any]:
with self.db.engine.begin() as conn: with self.db.engine.begin() as conn:
rows = conn.execute(select(preferences.c.score).where(preferences.c.proposal_id == proposal_id)).all() rows = conn.execute(select(preferences.c.score).where(preferences.c.proposal_id == proposal_id)).all()
@ -302,46 +359,13 @@ class CivicSwarmService:
def record_ledger_entry(self, proposal_id: int, kind: str, payload: dict[str, Any]) -> dict[str, Any]: def record_ledger_entry(self, proposal_id: int, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
with self.db.engine.begin() as conn: with self.db.engine.begin() as conn:
result = conn.execute( result_id = self._insert_ledger_row(conn, proposal_id, kind, payload)
insert(ledger_entries).values( return {"id": result_id, "proposal_id": proposal_id, "kind": kind, "payload": payload}
proposal_id=proposal_id,
kind=kind,
payload_json=_json(payload),
created_at=_now(),
)
)
return {"id": result.inserted_primary_key[0], "proposal_id": proposal_id, "kind": kind, "payload": payload}
def record_proposal_version(self, proposal_id: int, rationale: str, objections: list[str], resolution_status: str) -> dict[str, Any]: def record_proposal_version(self, proposal_id: int, rationale: str, objections: list[str], resolution_status: str) -> dict[str, Any]:
with self.db.engine.begin() as conn: with self.db.engine.begin() as conn:
current = conn.execute( version_info = self._record_proposal_version_row(conn, proposal_id, rationale, objections, resolution_status)
select(func.max(proposal_versions.c.version)).where(proposal_versions.c.proposal_id == proposal_id) return version_info
).scalar_one_or_none()
version = int(current or 0) + 1
result = conn.execute(
insert(proposal_versions).values(
proposal_id=proposal_id,
version=version,
rationale=rationale,
objections_json=_json(objections),
resolution_status=resolution_status,
created_at=_now(),
)
)
conn.execute(
update(proposals)
.where(proposals.c.id == proposal_id)
.values(version=version, updated_at=_now(), status=resolution_status)
)
conn.execute(
insert(ledger_entries).values(
proposal_id=proposal_id,
kind="proposal_version_recorded",
payload_json=_json({"version": version, "resolution_status": resolution_status}),
created_at=_now(),
)
)
return {"id": result.inserted_primary_key[0], "proposal_id": proposal_id, "version": version, "resolution_status": resolution_status}
def list_proposal_versions(self, proposal_id: int) -> list[dict[str, Any]]: def list_proposal_versions(self, proposal_id: int) -> list[dict[str, Any]]:
with self.db.engine.begin() as conn: with self.db.engine.begin() as conn:
@ -466,6 +490,139 @@ class CivicSwarmService:
overlap = len(left_tokens & right_tokens) overlap = len(left_tokens & right_tokens)
return overlap / math.sqrt(len(left_tokens) * len(right_tokens)) return overlap / math.sqrt(len(left_tokens) * len(right_tokens))
def _insert_resident_row(
self,
conn,
resident_key: str,
geography: str = "",
interests: list[str] | None = None,
lived_experience: list[str] | None = None,
languages: list[str] | None = None,
) -> str:
interests = interests or []
lived_experience = lived_experience or []
languages = languages or []
existing = conn.execute(select(residents).where(residents.c.resident_key == resident_key)).mappings().first()
values = dict(
resident_key=resident_key,
geography=geography,
interests_json=_json(interests),
lived_experience_json=_json(lived_experience),
languages_json=_json(languages),
updated_at=_now(),
)
if existing is None:
values["created_at"] = _now()
conn.execute(insert(residents).values(**values))
else:
conn.execute(update(residents).where(residents.c.resident_key == resident_key).values(**values))
return resident_key
def _insert_comment_row(
self,
conn,
proposal_id: int,
text: str,
channel: str,
resident_key: str | None,
geography: str,
metadata: dict[str, Any],
) -> int:
language = detect_language(text)
result = conn.execute(
insert(comments).values(
proposal_id=proposal_id,
resident_key=self._token(resident_key),
channel=channel,
geography=geography,
language=language,
text=text,
metadata_json=_json(metadata),
created_at=_now(),
)
)
comment_id = result.inserted_primary_key[0]
self._insert_ledger_row(
conn,
proposal_id,
"comment_received",
{"comment_id": comment_id, "channel": channel, "language": language},
)
return int(comment_id)
def _insert_preference_row(self, conn, proposal_id: int, resident_key: str, score: float, channel: str) -> int:
result = conn.execute(
insert(preferences).values(
proposal_id=proposal_id,
resident_key=self._token(resident_key) or resident_key,
score=float(score),
channel=channel,
created_at=_now(),
)
)
preference_id = int(result.inserted_primary_key[0])
self._insert_ledger_row(
conn,
proposal_id,
"preference_submitted",
{"preference_id": preference_id, "channel": channel},
)
return preference_id
def _insert_ledger_row(self, conn, proposal_id: int, kind: str, payload: dict[str, Any]) -> int:
result = conn.execute(
insert(ledger_entries).values(
proposal_id=proposal_id,
kind=kind,
payload_json=_json(payload),
created_at=_now(),
)
)
return int(result.inserted_primary_key[0])
def _record_proposal_version_row(
self,
conn,
proposal_id: int,
rationale: str,
objections: list[str],
resolution_status: str,
) -> dict[str, Any]:
current = conn.execute(select(func.max(proposal_versions.c.version)).where(proposal_versions.c.proposal_id == proposal_id)).scalar_one_or_none()
version = int(current or 0) + 1
result = conn.execute(
insert(proposal_versions).values(
proposal_id=proposal_id,
version=version,
rationale=rationale,
objections_json=_json(objections),
resolution_status=resolution_status,
created_at=_now(),
)
)
conn.execute(update(proposals).where(proposals.c.id == proposal_id).values(version=version, updated_at=_now(), status=resolution_status))
self._insert_ledger_row(
conn,
proposal_id,
"proposal_version_recorded",
{"version": version, "resolution_status": resolution_status},
)
return {"id": int(result.inserted_primary_key[0]), "proposal_id": proposal_id, "version": version, "resolution_status": resolution_status}
def _capture_batch_dict(self, row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row["id"],
"batch_key": row["batch_key"],
"source": row["source"],
"captured_at": row["captured_at"].isoformat(),
"item_count": row["item_count"],
"status": row["status"],
"payload": _parse_json(row["payload_json"], {}),
"results": _parse_json(row["results_json"], []),
"created_at": row["created_at"].isoformat(),
"ingested_at": row["ingested_at"].isoformat() if row["ingested_at"] else None,
}
def _proposal_dict(self, row: dict[str, Any]) -> dict[str, Any]: def _proposal_dict(self, row: dict[str, Any]) -> dict[str, Any]:
return { return {
"id": row["id"], "id": row["id"],

View File

@ -2,6 +2,8 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from fastapi.testclient import TestClient
from civicswarm.api import create_app from civicswarm.api import create_app
from civicswarm.service import CivicSwarmService from civicswarm.service import CivicSwarmService
@ -89,3 +91,70 @@ def test_private_preference_aggregation_supports_noise(tmp_path: Path):
assert clean["count"] == 3 assert clean["count"] == 3
assert noisy["count"] >= 1 assert noisy["count"] >= 1
assert noisy["mean_score"] != clean["mean_score"] or noisy["count"] != clean["count"] assert noisy["mean_score"] != clean["mean_score"] or noisy["count"] != clean["count"]
def test_capture_batch_ingest_is_atomic_and_idempotent(tmp_path: Path):
service = build_service(tmp_path)
proposal_id = seed_service(service)
batch = service.ingest_capture_batch(
"ward-3-mobile-001",
"sms",
[
{"kind": "resident", "resident_key": "resident-d", "geography": "ward-3", "interests": ["schools"], "languages": ["en"]},
{"kind": "comment", "proposal_id": proposal_id, "text": "Please keep crossings safe for elders.", "resident_key": "resident-d", "channel": "sms"},
{"kind": "preference", "proposal_id": proposal_id, "resident_key": "resident-d", "score": 0.8, "channel": "sms"},
{"kind": "ledger", "proposal_id": proposal_id, "entry_kind": "facilitator_note", "payload": {"note": "captured at block party"}},
],
)
assert batch["batch_key"] == "ward-3-mobile-001"
assert batch["status"] == "processed"
assert len(batch["results"]) == 4
assert service.get_resident("resident-d")["geography"] == "ward-3"
comments = service.list_comments(proposal_id)
assert any(comment["metadata"].get("batch_key") == "ward-3-mobile-001" for comment in comments)
assert service.aggregate_preferences(proposal_id)["count"] == 4
duplicate = service.ingest_capture_batch(
"ward-3-mobile-001",
"sms",
[],
)
assert duplicate["id"] == batch["id"]
assert duplicate["results"] == batch["results"]
assert len(service.list_comments(proposal_id)) == len(comments)
def test_capture_batch_endpoint_surfaces_audit_payload(tmp_path: Path):
app = create_app(tmp_path / "batch-api.sqlite")
client = TestClient(app)
proposal = client.post(
"/proposals",
json={
"city": "Springfield",
"title": "Crosswalk renewal",
"body": "Renew the busiest crosswalks near the library.",
"geography": "ward-4",
"tags": ["safety", "mobility"],
},
).json()
response = client.post(
"/capture-batches",
json={
"batch_key": "ward-4-townhall-01",
"source": "field",
"items": [
{"kind": "comment", "proposal_id": proposal["id"], "text": "I support the renewal.", "channel": "field"},
],
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "processed"
assert payload["results"][0]["kind"] == "comment"
fetched = client.get("/capture-batches/ward-4-townhall-01").json()
assert fetched["batch_key"] == "ward-4-townhall-01"