506 lines
21 KiB
Python
506 lines
21 KiB
Python
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(),
|
|
}
|