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(), }