build(agent): c3po#b883b4 iteration
This commit is contained in:
parent
ae59f6d0f1
commit
fb30fcac0e
|
|
@ -0,0 +1,21 @@
|
||||||
|
node_modules/
|
||||||
|
.npmrc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
__tests__/
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.cache/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
tmp/
|
||||||
|
.tmp/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
READY_TO_PUBLISH
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
# CivicSwarm Agent Guide
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `civicswarm/db.py` defines the SQLite schema and engine setup.
|
||||||
|
- `civicswarm/analysis.py` contains TF-IDF clustering, extractive summarization, language detection, and lightweight sentiment heuristics.
|
||||||
|
- `civicswarm/service.py` is the domain layer for proposals, comments, routing, privacy-preserving preference aggregation, the deliberation ledger, dashboard metrics, and civic brief export.
|
||||||
|
- `civicswarm/api.py` exposes the service through FastAPI.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- Python 3.11
|
||||||
|
- FastAPI for the HTTP surface
|
||||||
|
- SQLAlchemy Core with SQLite for persistence
|
||||||
|
- scikit-learn and numpy for topic clustering and routing similarity
|
||||||
|
- langdetect for best-effort multilingual handling
|
||||||
|
|
||||||
|
## Working Rules
|
||||||
|
|
||||||
|
- Keep resident identity pseudonymous. Use `resident_key` values only; do not add raw identity storage.
|
||||||
|
- Prefer the smallest correct change.
|
||||||
|
- Keep logic deterministic where possible so tests remain stable.
|
||||||
|
- Add or update tests for every behavior change.
|
||||||
|
- Do not remove or rewrite unrelated files.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Install dependencies and run the suite: `bash test.sh`
|
||||||
|
- `test.sh` must succeed with `pytest` and `python3 -m build`.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
- Distribution name: `idea198-civicswarm-privacy-preserving`
|
||||||
|
- README must stay current because it is wired into package metadata.
|
||||||
70
README.md
70
README.md
|
|
@ -1,3 +1,69 @@
|
||||||
# idea198-civicswarm-privacy-preserving
|
# CivicSwarm
|
||||||
|
|
||||||
Source logic for Idea #198
|
Privacy-preserving neighborhood deliberation router for participatory politics.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
CivicSwarm stores proposals, comments, resident profiles, preference signals, and deliberation ledger entries in SQLite. It then:
|
||||||
|
|
||||||
|
- clusters comments with TF-IDF + KMeans
|
||||||
|
- summarizes discussion by detected language
|
||||||
|
- routes proposal fragments to relevant residents by geography, interests, experience, and language
|
||||||
|
- aggregates preferences with optional differential-privacy noise
|
||||||
|
- exports a civic brief with provenance and audit trail
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Python 3.11
|
||||||
|
- FastAPI
|
||||||
|
- SQLAlchemy Core + SQLite
|
||||||
|
- scikit-learn
|
||||||
|
- langdetect
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
For development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m civicswarm
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
|
||||||
|
- `CIVICSWARM_DB_PATH` defaults to `civicswarm.sqlite`
|
||||||
|
- `CIVICSWARM_HOST` defaults to `127.0.0.1`
|
||||||
|
- `CIVICSWARM_PORT` defaults to `8000`
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `POST /proposals`
|
||||||
|
- `POST /residents`
|
||||||
|
- `POST /proposals/{proposal_id}/comments`
|
||||||
|
- `POST /proposals/{proposal_id}/preferences`
|
||||||
|
- `GET /proposals/{proposal_id}/route`
|
||||||
|
- `GET /proposals/{proposal_id}/dashboard`
|
||||||
|
- `GET /proposals/{proposal_id}/brief`
|
||||||
|
- `GET /proposals/{proposal_id}/ledger`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
That runs `pytest` and `python3 -m build`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Resident identity is kept pseudonymous through `resident_key`.
|
||||||
|
- The current codebase is backend-first and designed for mobile, SMS, and field-capture integrations.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""CivicSwarm package."""
|
||||||
|
|
||||||
|
from .api import create_app
|
||||||
|
from .service import CivicSwarmService
|
||||||
|
|
||||||
|
__all__ = ["CivicSwarmService", "create_app"]
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from .api import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
db_path = os.environ.get("CIVICSWARM_DB_PATH", "civicswarm.sqlite")
|
||||||
|
app = create_app(db_path)
|
||||||
|
uvicorn.run(app, host=os.environ.get("CIVICSWARM_HOST", "127.0.0.1"), port=int(os.environ.get("CIVICSWARM_PORT", "8000")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from langdetect import DetectorFactory, detect
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||||
|
|
||||||
|
|
||||||
|
DetectorFactory.seed = 0
|
||||||
|
|
||||||
|
SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+|\n+")
|
||||||
|
WORD_RE = re.compile(r"[A-Za-zÀ-ÿ0-9']+")
|
||||||
|
|
||||||
|
POSITIVE_WORDS = {
|
||||||
|
"support",
|
||||||
|
"approve",
|
||||||
|
"agree",
|
||||||
|
"welcome",
|
||||||
|
"benefit",
|
||||||
|
"safe",
|
||||||
|
"fair",
|
||||||
|
"help",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
|
|
||||||
|
NEGATIVE_WORDS = {
|
||||||
|
"oppose",
|
||||||
|
"reject",
|
||||||
|
"harm",
|
||||||
|
"unsafe",
|
||||||
|
"unfair",
|
||||||
|
"no",
|
||||||
|
"risk",
|
||||||
|
"delay",
|
||||||
|
"concern",
|
||||||
|
"object",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_language(text: str) -> str:
|
||||||
|
sample = text.strip()
|
||||||
|
if len(sample) < 10:
|
||||||
|
return "und"
|
||||||
|
try:
|
||||||
|
return detect(sample)
|
||||||
|
except Exception:
|
||||||
|
return "und"
|
||||||
|
|
||||||
|
|
||||||
|
def tokenize(text: str) -> list[str]:
|
||||||
|
return [token.lower() for token in WORD_RE.findall(text)]
|
||||||
|
|
||||||
|
|
||||||
|
def split_sentences(text: str) -> list[str]:
|
||||||
|
parts = [part.strip() for part in SENTENCE_SPLIT.split(text) if part.strip()]
|
||||||
|
return parts or ([text.strip()] if text.strip() else [])
|
||||||
|
|
||||||
|
|
||||||
|
def sentence_centrality_summary(texts: Iterable[str], max_sentences: int = 3) -> list[str]:
|
||||||
|
sentences: list[str] = []
|
||||||
|
for text in texts:
|
||||||
|
sentences.extend(split_sentences(text))
|
||||||
|
|
||||||
|
unique_sentences: list[str] = []
|
||||||
|
seen = set()
|
||||||
|
for sentence in sentences:
|
||||||
|
key = sentence.lower()
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
unique_sentences.append(sentence)
|
||||||
|
|
||||||
|
if not unique_sentences:
|
||||||
|
return []
|
||||||
|
if len(unique_sentences) <= max_sentences:
|
||||||
|
return unique_sentences
|
||||||
|
|
||||||
|
vectorizer = TfidfVectorizer(stop_words="english")
|
||||||
|
matrix = vectorizer.fit_transform(unique_sentences)
|
||||||
|
similarity = (matrix * matrix.T).toarray()
|
||||||
|
scores = similarity.sum(axis=1)
|
||||||
|
ranked = sorted(range(len(unique_sentences)), key=lambda idx: (-scores[idx], idx))[:max_sentences]
|
||||||
|
return [unique_sentences[idx] for idx in sorted(ranked)]
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_multilingual_comments(comments: list[dict], max_sentences: int = 3) -> dict[str, list[str]]:
|
||||||
|
by_language: dict[str, list[str]] = defaultdict(list)
|
||||||
|
for comment in comments:
|
||||||
|
language = comment.get("language") or detect_language(comment.get("text", ""))
|
||||||
|
by_language[language].append(comment.get("text", ""))
|
||||||
|
|
||||||
|
return {
|
||||||
|
language: sentence_centrality_summary(texts, max_sentences=max_sentences)
|
||||||
|
for language, texts in sorted(by_language.items())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cluster_texts(texts: list[str]) -> list[dict]:
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
if len(texts) == 1:
|
||||||
|
return [{"cluster": 0, "items": [texts[0]], "top_terms": tokenize(texts[0])[:5]}]
|
||||||
|
|
||||||
|
vectorizer = TfidfVectorizer(stop_words="english")
|
||||||
|
matrix = vectorizer.fit_transform(texts)
|
||||||
|
n_clusters = max(1, min(int(math.sqrt(len(texts))) or 1, 4))
|
||||||
|
if n_clusters == 1:
|
||||||
|
return [{"cluster": 0, "items": texts, "top_terms": _top_terms(matrix, vectorizer)}]
|
||||||
|
|
||||||
|
model = KMeans(n_clusters=n_clusters, n_init=10, random_state=0)
|
||||||
|
labels = model.fit_predict(matrix)
|
||||||
|
clusters: dict[int, list[str]] = defaultdict(list)
|
||||||
|
for label, text in zip(labels, texts):
|
||||||
|
clusters[int(label)].append(text)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"cluster": cluster_id, "items": items, "top_terms": _top_terms(matrix[[i for i, label in enumerate(labels) if label == cluster_id]], vectorizer)}
|
||||||
|
for cluster_id, items in sorted(clusters.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _top_terms(matrix, vectorizer: TfidfVectorizer, limit: int = 5) -> list[str]:
|
||||||
|
if matrix.shape[0] == 0:
|
||||||
|
return []
|
||||||
|
averaged = np.asarray(matrix.mean(axis=0)).ravel()
|
||||||
|
terms = np.array(vectorizer.get_feature_names_out())
|
||||||
|
top_indices = averaged.argsort()[::-1][:limit]
|
||||||
|
return [str(terms[index]) for index in top_indices if averaged[index] > 0]
|
||||||
|
|
||||||
|
|
||||||
|
def sentiment_score(text: str) -> float:
|
||||||
|
tokens = tokenize(text)
|
||||||
|
if not tokens:
|
||||||
|
return 0.0
|
||||||
|
positive = sum(1 for token in tokens if token in POSITIVE_WORDS)
|
||||||
|
negative = sum(1 for token in tokens if token in NEGATIVE_WORDS)
|
||||||
|
return (positive - negative) / max(1, len(tokens))
|
||||||
|
|
||||||
|
|
||||||
|
def laplace_noise(scale: float, seed: int | None = None) -> float:
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
return float(rng.laplace(0.0, scale))
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .service import CivicSwarmService
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalCreate(BaseModel):
|
||||||
|
city: str
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
geography: str = ""
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ResidentCreate(BaseModel):
|
||||||
|
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 CommentCreate(BaseModel):
|
||||||
|
text: str
|
||||||
|
channel: str = "mobile"
|
||||||
|
resident_key: str | None = None
|
||||||
|
geography: str = ""
|
||||||
|
metadata: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PreferenceCreate(BaseModel):
|
||||||
|
resident_key: str
|
||||||
|
score: float
|
||||||
|
channel: str = "mobile"
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(db_path: str | Path = ":memory:") -> FastAPI:
|
||||||
|
service = CivicSwarmService.from_path(db_path)
|
||||||
|
app = FastAPI(title="CivicSwarm", version="0.1.0")
|
||||||
|
app.state.service = service
|
||||||
|
|
||||||
|
@app.post("/proposals")
|
||||||
|
def create_proposal(payload: ProposalCreate):
|
||||||
|
return service.create_proposal(payload.city, payload.title, payload.body, payload.geography, payload.tags)
|
||||||
|
|
||||||
|
@app.post("/residents")
|
||||||
|
def create_resident(payload: ResidentCreate):
|
||||||
|
return service.register_resident(payload.resident_key, payload.geography, payload.interests, payload.lived_experience, payload.languages)
|
||||||
|
|
||||||
|
@app.post("/proposals/{proposal_id}/comments")
|
||||||
|
def create_comment(proposal_id: int, payload: CommentCreate):
|
||||||
|
try:
|
||||||
|
return service.add_comment(proposal_id, payload.text, payload.channel, payload.resident_key, payload.geography, payload.metadata)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
@app.post("/proposals/{proposal_id}/preferences")
|
||||||
|
def create_preference(proposal_id: int, payload: PreferenceCreate):
|
||||||
|
return service.submit_preference(proposal_id, payload.resident_key, payload.score, payload.channel)
|
||||||
|
|
||||||
|
@app.get("/proposals/{proposal_id}/route")
|
||||||
|
def route_proposal(proposal_id: int, top_n: int = 3):
|
||||||
|
return service.route_proposal(proposal_id, top_n=top_n)
|
||||||
|
|
||||||
|
@app.get("/proposals/{proposal_id}/dashboard")
|
||||||
|
def dashboard(proposal_id: int):
|
||||||
|
return service.build_dashboard(proposal_id)
|
||||||
|
|
||||||
|
@app.get("/proposals/{proposal_id}/brief")
|
||||||
|
def brief(proposal_id: int):
|
||||||
|
return service.export_brief(proposal_id)
|
||||||
|
|
||||||
|
@app.get("/proposals/{proposal_id}/ledger")
|
||||||
|
def ledger(proposal_id: int):
|
||||||
|
return service.list_ledger(proposal_id)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
Integer,
|
||||||
|
MetaData,
|
||||||
|
String,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
create_engine,
|
||||||
|
inspect,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
metadata = MetaData()
|
||||||
|
|
||||||
|
proposals = Table(
|
||||||
|
"proposals",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("city", String, nullable=False),
|
||||||
|
Column("title", String, nullable=False),
|
||||||
|
Column("body", Text, nullable=False),
|
||||||
|
Column("status", String, nullable=False, default="draft"),
|
||||||
|
Column("geography", String, nullable=False, default=""),
|
||||||
|
Column("tags_json", Text, nullable=False, default="[]"),
|
||||||
|
Column("version", Integer, nullable=False, default=1),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, default=utcnow),
|
||||||
|
Column("updated_at", DateTime(timezone=True), nullable=False, default=utcnow),
|
||||||
|
)
|
||||||
|
|
||||||
|
proposal_versions = Table(
|
||||||
|
"proposal_versions",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("proposal_id", Integer, nullable=False),
|
||||||
|
Column("version", Integer, nullable=False),
|
||||||
|
Column("rationale", Text, nullable=False, default=""),
|
||||||
|
Column("objections_json", Text, nullable=False, default="[]"),
|
||||||
|
Column("resolution_status", String, nullable=False, default="open"),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, default=utcnow),
|
||||||
|
)
|
||||||
|
|
||||||
|
residents = Table(
|
||||||
|
"residents",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("resident_key", String, nullable=False, unique=True),
|
||||||
|
Column("geography", String, nullable=False, default=""),
|
||||||
|
Column("interests_json", Text, nullable=False, default="[]"),
|
||||||
|
Column("lived_experience_json", Text, nullable=False, default="[]"),
|
||||||
|
Column("languages_json", Text, nullable=False, default="[]"),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, default=utcnow),
|
||||||
|
Column("updated_at", DateTime(timezone=True), nullable=False, default=utcnow),
|
||||||
|
)
|
||||||
|
|
||||||
|
comments = Table(
|
||||||
|
"comments",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("proposal_id", Integer, nullable=False),
|
||||||
|
Column("resident_key", String, nullable=True),
|
||||||
|
Column("channel", String, nullable=False),
|
||||||
|
Column("geography", String, nullable=False, default=""),
|
||||||
|
Column("language", String, nullable=False, default="und"),
|
||||||
|
Column("text", Text, nullable=False),
|
||||||
|
Column("metadata_json", Text, nullable=False, default="{}"),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, default=utcnow),
|
||||||
|
)
|
||||||
|
|
||||||
|
preferences = Table(
|
||||||
|
"preferences",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("proposal_id", Integer, nullable=False),
|
||||||
|
Column("resident_key", String, nullable=False),
|
||||||
|
Column("score", Float, nullable=False),
|
||||||
|
Column("channel", String, nullable=False),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, default=utcnow),
|
||||||
|
)
|
||||||
|
|
||||||
|
routes = Table(
|
||||||
|
"routes",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("proposal_id", Integer, nullable=False),
|
||||||
|
Column("fragment_index", Integer, nullable=False),
|
||||||
|
Column("resident_key", String, nullable=False),
|
||||||
|
Column("score", Float, nullable=False),
|
||||||
|
Column("rationale_json", Text, nullable=False, default="[]"),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, default=utcnow),
|
||||||
|
)
|
||||||
|
|
||||||
|
ledger_entries = Table(
|
||||||
|
"ledger_entries",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("proposal_id", Integer, nullable=False),
|
||||||
|
Column("kind", String, nullable=False),
|
||||||
|
Column("payload_json", Text, nullable=False),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, default=utcnow),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Database:
|
||||||
|
engine: object
|
||||||
|
|
||||||
|
|
||||||
|
def create_database(db_url: str):
|
||||||
|
engine = create_engine(db_url, future=True)
|
||||||
|
metadata.create_all(engine)
|
||||||
|
return Database(engine=engine)
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_path_url(path: str | Path) -> str:
|
||||||
|
p = Path(path)
|
||||||
|
if str(p) == ":memory:":
|
||||||
|
return "sqlite+pysqlite:///:memory:"
|
||||||
|
return f"sqlite+pysqlite:///{p}"
|
||||||
|
|
@ -0,0 +1,505 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from sqlalchemy import delete, func, insert, select, update
|
||||||
|
|
||||||
|
from .analysis import (
|
||||||
|
cluster_texts,
|
||||||
|
detect_language,
|
||||||
|
laplace_noise,
|
||||||
|
sentiment_score,
|
||||||
|
summarize_multilingual_comments,
|
||||||
|
tokenize,
|
||||||
|
)
|
||||||
|
from .db import (
|
||||||
|
comments,
|
||||||
|
create_database,
|
||||||
|
ledger_entries,
|
||||||
|
preferences,
|
||||||
|
proposal_versions,
|
||||||
|
proposals,
|
||||||
|
residents,
|
||||||
|
routes,
|
||||||
|
sqlite_path_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _json(value: Any) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json(value: str | None, default: Any) -> Any:
|
||||||
|
if not value:
|
||||||
|
return default
|
||||||
|
return json.loads(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_terms(values: Iterable[str]) -> set[str]:
|
||||||
|
terms: set[str] = set()
|
||||||
|
for value in values:
|
||||||
|
terms.update(tokenize(value))
|
||||||
|
return terms
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CivicSwarmService:
|
||||||
|
db_url: str
|
||||||
|
secret: str = "civicswarm"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.db = create_database(self.db_url)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_path(cls, path: str | Path, secret: str = "civicswarm") -> "CivicSwarmService":
|
||||||
|
return cls(db_url=sqlite_path_url(path), secret=secret)
|
||||||
|
|
||||||
|
def _token(self, resident_key: str | None) -> str | None:
|
||||||
|
if resident_key is None:
|
||||||
|
return None
|
||||||
|
return hmac.new(self.secret.encode(), resident_key.encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
def create_proposal(self, city: str, title: str, body: str, geography: str = "", tags: list[str] | None = None) -> dict[str, Any]:
|
||||||
|
tags = tags or []
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
result = conn.execute(
|
||||||
|
insert(proposals).values(
|
||||||
|
city=city,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
geography=geography,
|
||||||
|
tags_json=_json(tags),
|
||||||
|
status="draft",
|
||||||
|
version=1,
|
||||||
|
created_at=_now(),
|
||||||
|
updated_at=_now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
proposal_id = result.inserted_primary_key[0]
|
||||||
|
conn.execute(
|
||||||
|
insert(ledger_entries).values(
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
kind="proposal_created",
|
||||||
|
payload_json=_json({"title": title, "tags": tags, "geography": geography}),
|
||||||
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
insert(proposal_versions).values(
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
version=1,
|
||||||
|
rationale="initial proposal",
|
||||||
|
objections_json=_json([]),
|
||||||
|
resolution_status="open",
|
||||||
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return self.get_proposal(proposal_id)
|
||||||
|
|
||||||
|
def get_proposal(self, proposal_id: int) -> dict[str, Any]:
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
row = conn.execute(select(proposals).where(proposals.c.id == proposal_id)).mappings().first()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(f"proposal {proposal_id} not found")
|
||||||
|
return self._proposal_dict(row)
|
||||||
|
|
||||||
|
def list_comments(self, proposal_id: int) -> list[dict[str, Any]]:
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
rows = conn.execute(select(comments).where(comments.c.proposal_id == proposal_id).order_by(comments.c.id.asc())).mappings().all()
|
||||||
|
return [self._comment_dict(row) for row in rows]
|
||||||
|
|
||||||
|
def register_resident(
|
||||||
|
self,
|
||||||
|
resident_key: str,
|
||||||
|
geography: str = "",
|
||||||
|
interests: list[str] | None = None,
|
||||||
|
lived_experience: list[str] | None = None,
|
||||||
|
languages: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
interests = interests or []
|
||||||
|
lived_experience = lived_experience or []
|
||||||
|
languages = languages or []
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
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 self.get_resident(resident_key)
|
||||||
|
|
||||||
|
def get_resident(self, resident_key: str) -> dict[str, Any]:
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
row = conn.execute(select(residents).where(residents.c.resident_key == resident_key)).mappings().first()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(f"resident {resident_key} not found")
|
||||||
|
return self._resident_dict(row)
|
||||||
|
|
||||||
|
def add_comment(
|
||||||
|
self,
|
||||||
|
proposal_id: int,
|
||||||
|
text: str,
|
||||||
|
channel: str = "mobile",
|
||||||
|
resident_key: str | None = None,
|
||||||
|
geography: str = "",
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
metadata = metadata or {}
|
||||||
|
language = detect_language(text)
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
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]
|
||||||
|
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)
|
||||||
|
|
||||||
|
def get_comment(self, comment_id: int) -> dict[str, Any]:
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
row = conn.execute(select(comments).where(comments.c.id == comment_id)).mappings().first()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(f"comment {comment_id} not found")
|
||||||
|
return self._comment_dict(row)
|
||||||
|
|
||||||
|
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:
|
||||||
|
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 = 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}
|
||||||
|
|
||||||
|
def aggregate_preferences(self, proposal_id: int, epsilon: float | None = None, seed: int = 0) -> dict[str, Any]:
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
rows = conn.execute(select(preferences.c.score).where(preferences.c.proposal_id == proposal_id)).all()
|
||||||
|
scores = [float(row[0]) for row in rows]
|
||||||
|
count = len(scores)
|
||||||
|
total = float(sum(scores))
|
||||||
|
mean = total / count if count else 0.0
|
||||||
|
if epsilon and epsilon > 0:
|
||||||
|
scale = 1.0 / float(epsilon)
|
||||||
|
total += laplace_noise(scale, seed=seed)
|
||||||
|
count = max(1, int(round(count + laplace_noise(scale / 2.0, seed=seed + 1))))
|
||||||
|
mean = total / count
|
||||||
|
return {"proposal_id": proposal_id, "count": count, "mean_score": mean, "raw_total": float(sum(scores))}
|
||||||
|
|
||||||
|
def cluster_comments(self, proposal_id: int) -> list[dict[str, Any]]:
|
||||||
|
comments_data = self.list_comments(proposal_id)
|
||||||
|
return cluster_texts([comment["text"] for comment in comments_data])
|
||||||
|
|
||||||
|
def summarize_comments(self, proposal_id: int, max_sentences: int = 3) -> dict[str, list[str]]:
|
||||||
|
comments_data = self.list_comments(proposal_id)
|
||||||
|
return summarize_multilingual_comments(comments_data, max_sentences=max_sentences)
|
||||||
|
|
||||||
|
def route_proposal(self, proposal_id: int, top_n: int = 3) -> dict[str, Any]:
|
||||||
|
proposal = self.get_proposal(proposal_id)
|
||||||
|
fragments = self._proposal_fragments(proposal["body"])
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
resident_rows = conn.execute(select(residents)).mappings().all()
|
||||||
|
|
||||||
|
routed_fragments = []
|
||||||
|
for index, fragment in enumerate(fragments):
|
||||||
|
ranked = self._rank_residents_for_fragment(proposal, fragment, resident_rows)
|
||||||
|
top_matches = ranked[:top_n]
|
||||||
|
routed_fragments.append({"fragment_index": index, "fragment": fragment, "matches": top_matches})
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
conn.execute(delete(routes).where(routes.c.proposal_id == proposal_id, routes.c.fragment_index == index))
|
||||||
|
for match in top_matches:
|
||||||
|
conn.execute(
|
||||||
|
insert(routes).values(
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
fragment_index=index,
|
||||||
|
resident_key=match["resident_key"],
|
||||||
|
score=match["score"],
|
||||||
|
rationale_json=_json(match["reasons"]),
|
||||||
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {"proposal_id": proposal_id, "fragments": routed_fragments}
|
||||||
|
|
||||||
|
def build_dashboard(self, proposal_id: int) -> dict[str, Any]:
|
||||||
|
proposal = self.get_proposal(proposal_id)
|
||||||
|
comments_data = self.list_comments(proposal_id)
|
||||||
|
summaries = self.summarize_comments(proposal_id)
|
||||||
|
preferences_summary = self.aggregate_preferences(proposal_id)
|
||||||
|
routes_summary = self.route_proposal(proposal_id)
|
||||||
|
|
||||||
|
consensus_pockets = []
|
||||||
|
unresolved_conflicts = []
|
||||||
|
outreach_gaps = []
|
||||||
|
|
||||||
|
for fragment in routes_summary["fragments"]:
|
||||||
|
fragment_text = fragment["fragment"]
|
||||||
|
fragment_comments = [comment for comment in comments_data if any(term in comment["text"].lower() for term in tokenize(fragment_text))]
|
||||||
|
sentiment_values = [sentiment_score(comment["text"]) for comment in fragment_comments]
|
||||||
|
if sentiment_values and np.mean(sentiment_values) > 0:
|
||||||
|
consensus_pockets.append({"fragment_index": fragment["fragment_index"], "fragment": fragment_text, "support": float(np.mean(sentiment_values))})
|
||||||
|
if sentiment_values and min(sentiment_values) < 0 < max(sentiment_values):
|
||||||
|
unresolved_conflicts.append({"fragment_index": fragment["fragment_index"], "fragment": fragment_text, "support": float(np.mean(sentiment_values)), "spread": float(max(sentiment_values) - min(sentiment_values))})
|
||||||
|
if not fragment["matches"]:
|
||||||
|
outreach_gaps.append({"fragment_index": fragment["fragment_index"], "fragment": fragment_text})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"proposal": proposal,
|
||||||
|
"summaries": summaries,
|
||||||
|
"preference_aggregate": preferences_summary,
|
||||||
|
"consensus_pockets": consensus_pockets,
|
||||||
|
"unresolved_conflicts": unresolved_conflicts,
|
||||||
|
"outreach_gaps": outreach_gaps,
|
||||||
|
}
|
||||||
|
|
||||||
|
def record_ledger_entry(self, proposal_id: int, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
result = conn.execute(
|
||||||
|
insert(ledger_entries).values(
|
||||||
|
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]:
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
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]]:
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
select(proposal_versions).where(proposal_versions.c.proposal_id == proposal_id).order_by(proposal_versions.c.version.asc())
|
||||||
|
).mappings().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"proposal_id": row["proposal_id"],
|
||||||
|
"version": row["version"],
|
||||||
|
"rationale": row["rationale"],
|
||||||
|
"objections": _parse_json(row["objections_json"], []),
|
||||||
|
"resolution_status": row["resolution_status"],
|
||||||
|
"created_at": row["created_at"].isoformat(),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def list_ledger(self, proposal_id: int) -> list[dict[str, Any]]:
|
||||||
|
with self.db.engine.begin() as conn:
|
||||||
|
rows = conn.execute(select(ledger_entries).where(ledger_entries.c.proposal_id == proposal_id).order_by(ledger_entries.c.id.asc())).mappings().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"proposal_id": row["proposal_id"],
|
||||||
|
"kind": row["kind"],
|
||||||
|
"payload": _parse_json(row["payload_json"], {}),
|
||||||
|
"created_at": row["created_at"].isoformat(),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def export_brief(self, proposal_id: int) -> dict[str, Any]:
|
||||||
|
proposal = self.get_proposal(proposal_id)
|
||||||
|
comments_data = self.list_comments(proposal_id)
|
||||||
|
dashboard = self.build_dashboard(proposal_id)
|
||||||
|
ledger = self.list_ledger(proposal_id)
|
||||||
|
clusters = self.cluster_comments(proposal_id)
|
||||||
|
versions = self.list_proposal_versions(proposal_id)
|
||||||
|
|
||||||
|
provenance = [
|
||||||
|
{"comment_id": comment["id"], "language": comment["language"], "channel": comment["channel"]}
|
||||||
|
for comment in comments_data
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"proposal": proposal,
|
||||||
|
"headline": proposal["title"],
|
||||||
|
"summary": dashboard["summaries"],
|
||||||
|
"topic_clusters": clusters,
|
||||||
|
"preference_aggregate": dashboard["preference_aggregate"],
|
||||||
|
"consensus_pockets": dashboard["consensus_pockets"],
|
||||||
|
"unresolved_conflicts": dashboard["unresolved_conflicts"],
|
||||||
|
"outreach_gaps": dashboard["outreach_gaps"],
|
||||||
|
"ledger": ledger,
|
||||||
|
"proposal_versions": versions,
|
||||||
|
"provenance": provenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _proposal_fragments(self, body: str) -> list[str]:
|
||||||
|
fragments = []
|
||||||
|
for chunk in body.split("\n"):
|
||||||
|
chunk = chunk.strip(" -\t")
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
fragments.extend([part.strip() for part in re.split(r"(?<=[.!?])\s+", chunk) if part.strip()])
|
||||||
|
return fragments or ([body.strip()] if body.strip() else [])
|
||||||
|
|
||||||
|
def _rank_residents_for_fragment(self, proposal: dict[str, Any], fragment: str, resident_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
proposal_tags = set(proposal.get("tags", []))
|
||||||
|
fragment_terms = _normalize_terms([fragment])
|
||||||
|
ranked = []
|
||||||
|
for resident in resident_rows:
|
||||||
|
interests = set(_parse_json(resident["interests_json"], []))
|
||||||
|
experience = set(_parse_json(resident["lived_experience_json"], []))
|
||||||
|
languages = set(_parse_json(resident["languages_json"], []))
|
||||||
|
resident_geo = (resident["geography"] or "").lower()
|
||||||
|
score = 0.0
|
||||||
|
reasons = []
|
||||||
|
|
||||||
|
if resident_geo and resident_geo in (proposal["geography"] or "").lower():
|
||||||
|
score += 0.25
|
||||||
|
reasons.append("geography_match")
|
||||||
|
|
||||||
|
overlap = len(_normalize_terms(proposal_tags) & interests)
|
||||||
|
if overlap:
|
||||||
|
score += min(0.3, 0.1 * overlap)
|
||||||
|
reasons.append("interest_overlap")
|
||||||
|
|
||||||
|
experience_overlap = len(fragment_terms & _normalize_terms(experience))
|
||||||
|
if experience_overlap:
|
||||||
|
score += min(0.25, 0.12 * experience_overlap)
|
||||||
|
reasons.append("lived_experience_match")
|
||||||
|
|
||||||
|
fragment_language = detect_language(fragment)
|
||||||
|
if fragment_language in languages:
|
||||||
|
score += 0.1
|
||||||
|
reasons.append("language_match")
|
||||||
|
|
||||||
|
profile_text = " ".join([resident["geography"], " ".join(sorted(interests)), " ".join(sorted(experience)), " ".join(sorted(languages))])
|
||||||
|
lexical = self._similarity(fragment, profile_text)
|
||||||
|
score += 0.35 * lexical
|
||||||
|
if lexical > 0:
|
||||||
|
reasons.append("lexical_similarity")
|
||||||
|
|
||||||
|
ranked.append(
|
||||||
|
{
|
||||||
|
"resident_key": resident["resident_key"],
|
||||||
|
"score": round(float(score), 4),
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ranked.sort(key=lambda item: (-item["score"], item["resident_key"]))
|
||||||
|
return [item for item in ranked if item["score"] > 0]
|
||||||
|
|
||||||
|
def _similarity(self, left: str, right: str) -> float:
|
||||||
|
left_tokens = _normalize_terms([left])
|
||||||
|
right_tokens = _normalize_terms([right])
|
||||||
|
if not left_tokens or not right_tokens:
|
||||||
|
return 0.0
|
||||||
|
overlap = len(left_tokens & right_tokens)
|
||||||
|
return overlap / math.sqrt(len(left_tokens) * len(right_tokens))
|
||||||
|
|
||||||
|
def _proposal_dict(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"city": row["city"],
|
||||||
|
"title": row["title"],
|
||||||
|
"body": row["body"],
|
||||||
|
"status": row["status"],
|
||||||
|
"geography": row["geography"],
|
||||||
|
"tags": _parse_json(row["tags_json"], []),
|
||||||
|
"version": row["version"],
|
||||||
|
"created_at": row["created_at"].isoformat(),
|
||||||
|
"updated_at": row["updated_at"].isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resident_dict(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"resident_key": row["resident_key"],
|
||||||
|
"geography": row["geography"],
|
||||||
|
"interests": _parse_json(row["interests_json"], []),
|
||||||
|
"lived_experience": _parse_json(row["lived_experience_json"], []),
|
||||||
|
"languages": _parse_json(row["languages_json"], []),
|
||||||
|
"created_at": row["created_at"].isoformat(),
|
||||||
|
"updated_at": row["updated_at"].isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _comment_dict(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"proposal_id": row["proposal_id"],
|
||||||
|
"resident_key": row["resident_key"],
|
||||||
|
"channel": row["channel"],
|
||||||
|
"geography": row["geography"],
|
||||||
|
"language": row["language"],
|
||||||
|
"text": row["text"],
|
||||||
|
"metadata": _parse_json(row["metadata_json"], {}),
|
||||||
|
"created_at": row["created_at"].isoformat(),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "idea198-civicswarm-privacy-preserving"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Privacy-preserving neighborhood deliberation router for participatory politics"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = "MIT"
|
||||||
|
authors = [{name = "OpenCode"}]
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"pydantic>=2.7",
|
||||||
|
"sqlalchemy>=2.0",
|
||||||
|
"numpy>=1.26",
|
||||||
|
"scikit-learn>=1.5",
|
||||||
|
"langdetect>=1.0.9",
|
||||||
|
"uvicorn>=0.30",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"build>=1.2",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://example.invalid/civicswarm"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
include-package-data = true
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["civicswarm*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
python3 -m pip install -e ".[dev]"
|
||||||
|
pytest
|
||||||
|
python3 -m build
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from civicswarm.api import create_app
|
||||||
|
from civicswarm.service import CivicSwarmService
|
||||||
|
|
||||||
|
|
||||||
|
def build_service(tmp_path: Path) -> CivicSwarmService:
|
||||||
|
return CivicSwarmService.from_path(tmp_path / "civicswarm.sqlite")
|
||||||
|
|
||||||
|
|
||||||
|
def seed_service(service: CivicSwarmService) -> int:
|
||||||
|
proposal = service.create_proposal(
|
||||||
|
city="Springfield",
|
||||||
|
title="School street safety",
|
||||||
|
body=(
|
||||||
|
"Add protected bike lanes near schools.\n"
|
||||||
|
"Install safer crossings for pedestrians.\n"
|
||||||
|
"Preserve bus access for students and elders."
|
||||||
|
),
|
||||||
|
geography="ward-3",
|
||||||
|
tags=["transport", "schools", "safety"],
|
||||||
|
)
|
||||||
|
service.register_resident("resident-a", geography="ward-3", interests=["transport", "safety"], lived_experience=["parent"], languages=["en"])
|
||||||
|
service.register_resident("resident-b", geography="ward-7", interests=["schools"], lived_experience=["bus rider"], languages=["es", "en"])
|
||||||
|
service.register_resident("resident-c", geography="ward-3", interests=["parks"], lived_experience=["elder"], languages=["en"])
|
||||||
|
service.add_comment(proposal["id"], "I support safer crossings for children and elders.", resident_key="resident-a", geography="ward-3")
|
||||||
|
service.add_comment(proposal["id"], "We need bus access and safer sidewalks.", resident_key="resident-b", geography="ward-7")
|
||||||
|
service.add_comment(proposal["id"], "I worry about delayed bus service.", resident_key="resident-c", geography="ward-3")
|
||||||
|
service.submit_preference(proposal["id"], "resident-a", 0.9)
|
||||||
|
service.submit_preference(proposal["id"], "resident-b", 0.4)
|
||||||
|
service.submit_preference(proposal["id"], "resident-c", 0.1)
|
||||||
|
return proposal["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_routes_comments_and_exports_brief(tmp_path: Path):
|
||||||
|
service = build_service(tmp_path)
|
||||||
|
proposal_id = seed_service(service)
|
||||||
|
|
||||||
|
summary = service.summarize_comments(proposal_id)
|
||||||
|
assert summary
|
||||||
|
assert "en" in summary
|
||||||
|
|
||||||
|
routed = service.route_proposal(proposal_id, top_n=2)
|
||||||
|
assert routed["fragments"]
|
||||||
|
assert routed["fragments"][0]["matches"]
|
||||||
|
|
||||||
|
dashboard = service.build_dashboard(proposal_id)
|
||||||
|
assert dashboard["preference_aggregate"]["count"] == 3
|
||||||
|
assert dashboard["consensus_pockets"]
|
||||||
|
|
||||||
|
brief = service.export_brief(proposal_id)
|
||||||
|
assert brief["proposal"]["title"] == "School street safety"
|
||||||
|
assert brief["provenance"]
|
||||||
|
assert brief["ledger"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_surface_exposes_api(tmp_path: Path):
|
||||||
|
app = create_app(tmp_path / "api.sqlite")
|
||||||
|
client = __import__("fastapi.testclient", fromlist=["TestClient"]).TestClient(app)
|
||||||
|
|
||||||
|
proposal = client.post(
|
||||||
|
"/proposals",
|
||||||
|
json={
|
||||||
|
"city": "Springfield",
|
||||||
|
"title": "Library weekend hours",
|
||||||
|
"body": "Keep the library open later on weekends for students and families.",
|
||||||
|
"geography": "ward-1",
|
||||||
|
"tags": ["libraries", "education"],
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
client.post("/residents", json={"resident_key": "resident-x", "geography": "ward-1", "interests": ["libraries"], "lived_experience": ["student"], "languages": ["en"]})
|
||||||
|
client.post(f"/proposals/{proposal['id']}/comments", json={"text": "I support later hours.", "resident_key": "resident-x", "channel": "sms"})
|
||||||
|
brief = client.get(f"/proposals/{proposal['id']}/brief").json()
|
||||||
|
|
||||||
|
assert brief["proposal"]["title"] == "Library weekend hours"
|
||||||
|
assert brief["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_preference_aggregation_supports_noise(tmp_path: Path):
|
||||||
|
service = build_service(tmp_path)
|
||||||
|
proposal_id = seed_service(service)
|
||||||
|
|
||||||
|
clean = service.aggregate_preferences(proposal_id)
|
||||||
|
noisy = service.aggregate_preferences(proposal_id, epsilon=0.75, seed=42)
|
||||||
|
|
||||||
|
assert clean["count"] == 3
|
||||||
|
assert noisy["count"] >= 1
|
||||||
|
assert noisy["mean_score"] != clean["mean_score"] or noisy["count"] != clean["count"]
|
||||||
Loading…
Reference in New Issue