Target reader: backend engineer / Codex / Claude Code / team manager
Date: 2026-04-11 Hackathon: NVIDIA Spark Hack NYC 2026 Hardware target: Acer Veriton GN100 / DGX Spark / NVIDIA GB10 Grace Blackwell / 128GB unified memory Runtime target: 100% local, offline-capable Team scope owned by us: backend analysis engine, overview precompute service, detail realtime analysis, data-provider abstraction, evidence, brief generation, batch/watchlist, backend-only smoke tests Not our scope: final frontend implementation, final dataset selection, final Skill implementation details owned by teammates
Urban Dossier NYC v3.6.1 is a fully local civic analysis backend designed for two user-visible product states and one batch mode:
- a whole-map overview state
- a clicked-point detail state
- a batch watchlist state
The backend is not just a data display layer. It is designed to:
- expose city-scale overview layers
- recompute local priorities after a click
- explain why those priorities rose to the top
- attach traceable evidence
- produce a concise local brief
- pass a real Pensar/Apex security review without hiding or disabling the real product surface
- local data assets in the workspace
- a legacy direct-query backend path that already works on parquet/demo assets
- real remote machine access and validation over SSH
- RAPIDS benchmark evidence on the official machine
- a frontend prototype already oriented around overview + click + detail
v3.6 is the merged version of:
- v3
- deterministic trend / priority / watchlist code skeletons
- action-first reporting
- explicit evidence verification
- v3.5.2
- correct overview/detail product split
- detail-only user ranking
- v3.5.3
- Skill interface preserved
- direct-query fallback made first-class
- remote SSH reality incorporated
- v3.6.1
- Pensar/Apex hardening requirements
- no-cheating security gate
- security-focused API constraints for the prize path
Technical Execution & Completeness
- overview precompute + detail realtime + batch reuse
- provider abstraction for Skill vs direct mode
- deterministic ranking and evidence
- frontend-ready API contract
NVIDIA Ecosystem & Spark Utility
- accommodates NemoClaw / OpenClaw / Nemotron
- RAPIDS/cuDF for overview precompute and heavy aggregations
- DuckDB/parquet for local realtime filtering
- remote benchmark path already proven
Value & Impact
- overview helps users orient at map scale
- detail gives local prioritized issues after click
- user preference changes actual local ranking
- watchlist supports civic monitoring workflows
Frontier Factor
- same city data consumed in overview, detail, and watchlist modes
- overview is fixed and fast; detail is personalized and realtime
- provider abstraction keeps the system runnable before Skill is ready
Least Likely to get Hacked
- the same real backend must be testable by Pensar/Apex
- no fake-safe test mode is allowed
- every future backend change must pass a standing security gate
- All AI inference and core data processing must run locally.
- Primary data source must be NYC Open Data.
- The demo target environment is the Acer GN100 / DGX Spark class machine.
- We own backend only.
- Frontend and final dataset selection are handled by teammates.
- The official rules include a bounty for "Least Likely to get Hacked" that explicitly requires running Pensar and fixing the issues it surfaces.
- NemoClaw / OpenClaw / a local model runtime will likely exist on the official machine, but backend must degrade cleanly if not.
- Skill outputs may exist later, but must not be a blocker for backend testing now.
- The first frontend release wants:
- one overall overview layer
- one single-category layer per button
- click-to-detail
- detail-only drag ranking
- The prize-evaluation path should prefer
directmode untilSkillDataProvideris hardened and tested under the same security gate.
This document does not finalize:
- the UI presentation details
- the final list of all datasets
- the final Skill implementation internals
- the final deck phrasing
- any "special test-only" behavior that differs from the real backend exposed to the frontend
This document is about the backend system we should actually build.
Current split:
- backend: us
- frontend interaction: teammates
- final dataset curation / Skill: teammates
- benchmark work: teammates
Therefore, the backend must:
- expose stable contracts
- be runnable without waiting on other workstreams
- preserve future integration paths
The local workspace already contains:
- engineering docs from previous iterations
- a legacy
urban_dossierbackend prototype - local processed/parquet/demo assets
- older score-first report logic
311,EMS, andFireraw/parquet assets already present locally
That means we already have enough material to test backend logic now.
The official-machine remote environment already has:
~/nyc_open_datawith real NYC data- parquet conversion scripts
- a minimal DuckDB pipeline
- RAPIDS benchmark scripts
- a lightweight
~/urban_dossierexperimental directory
But it does not yet have a complete service implementing:
- overview API
- detail API
- trend engine
- priority engine
- watchlist API
- frontend-ready contract
We already confirmed over SSH that local-detail-style real data queries are feasible against real remote assets.
Examples already observed in rough smoke tests near a Manhattan point:
- non-empty collision counts
- non-empty rodent counts
- non-empty 311 counts
- non-empty public toilet nearby results
That proves we are not blocked on backend feasibility.
Urban Dossier is a fully local civic analysis backend with three consumption modes:
- Overview mode
- whole-map, pre-rendered, fixed layers
- Detail mode
- clicked-point, radius-based, realtime, user-priority-aware
- Watchlist mode
- batch reuse of detail logic with fixed platform ordering
It is not:
- a simple score dashboard
- a single-address due diligence tool
- a frontend-owned ranking system
- a Skill-dependent prototype that cannot run without teammates finishing their part
- User opens the map
- User sees an overall map or one category map
- User clicks a point
- User enters detail mode
- User may reorder categories
- Backend recomputes local priorities in realtime
- UI shows local actions, why-now signals, evidence, and a brief
Overview should only support:
- one overall map
- one single-category map per visible category button
Overview should not:
- accept user drag order
- recompute custom weighted citywide maps for each user ranking
- emit fully personalized local recommendations
Overview is a navigation layer.
Detail is where user preference matters.
After a click:
- frontend sends
priority_order - backend converts order to weights
- backend recomputes local priority actions in realtime
This is where the product earns most of its value.
Watchlist is a batch consumer of the same analysis core.
It should:
- not use per-user ordering
- use a fixed platform/default priority configuration
- reuse the same detail logic on many seeds / areas / cells
All judgment should be deterministic wherever possible:
- retrieval
- trends
- ranking
- pattern detection
- evidence checks
The LLM should only organize the final expression.
Frontend should not care whether backend is currently using:
- Skill-curated inputs
- direct hardcoded retrieval
Overview, detail, and watchlist should reuse one shared analysis core rather than each inventing separate business logic.
If a dataset or provider is missing:
- return coverage gaps
- return empty cells or empty sections honestly
- do not fabricate results
- do not replace missing data with reassuring scores
The backend must not treat security as a post-hoc polish task.
At minimum:
- heavy endpoints must be bounded
- overview category selection must be whitelisted
- local-only model endpoints must be allowlisted
- cross-origin access must be restricted to known frontend origins
- the same core backend used in the demo must also be the backend tested by Pensar
For the Pensar/Apex prize path, the backend is not allowed to:
- disable risky endpoints only during the security scan while keeping them enabled in the demo
- swap in fake static responses only for pentest runs
- detect Pensar/Apex and return safer behavior only to that tool
- hide watchlist/detail functionality from the scanner if those features exist in the product
- claim Skill mode is secure if the award path actually runs only in
directmode
+----------------------------------+
| API Layer |
| overview / detail / coverage / |
| categories / watchlist / health |
+----------------+-----------------+
|
v
+----------------------------------+
| Shared Analysis Core |
| current state / trends / |
| patterns / priority / evidence / |
| scoring / report |
+----------------+-----------------+
|
+----------------+-----------------+
| Data Provider Layer |
+-----------+----------------------+
|
+--------------+--------------+
| |
v v
+---------------------------+ +---------------------------+
| SkillDataProvider | | DirectQueryDataProvider |
| future preferred path | | current test default |
+---------------------------+ +---------------------------+
Key consequence:
- product behavior lives above the provider layer
- source-specific logic lives below it
Add:
URBAN_DOSSIER_DATA_MODE = auto | skill | direct
direct- use hardcoded direct data retrieval only
- current default for testing
skill- require Skill outputs
- fail clearly if unavailable
auto- use Skill if available
- otherwise fall back to direct query
For current backend testing:
URBAN_DOSSIER_DATA_MODE=direct
When Skill is stable:
URBAN_DOSSIER_DATA_MODE=auto
Purpose:
- initial map load
- button-based switching between overall and single-category layers
Properties:
- pre-rendered
- broad
- fast
- not user-ranked
- no long-form local recommendation logic
Purpose:
- clicked-point analysis
- realtime local ranking
Properties:
- point + radius input
- drag-order aware
- action-first output
- evidence-backed
- optional brief generation
Purpose:
- overnight / scheduled / manual batch analysis
Properties:
- fixed default platform ordering
- batch seeds or areas
- reuses detail logic
The backend must be config-driven.
Do not hardcode category names inside ranking logic.
Recommended first-release config shape:
categories:
safety:
label: "Safety"
map_driving: true
detail_rankable: true
signals:
- collisions
- rodent
- ems_response
- fire_dispatch
- sanitation_311
traffic:
label: "Traffic"
map_driving: true
detail_rankable: true
signals:
- collisions
- transit_access
- bike_routes
- open_streets
facilities:
label: "Facilities"
map_driving: true
detail_rankable: true
signals:
- parks_access
- public_toilets
- linknyc
- civic_facilities
- restaurant_contextThis lets the frontend later:
- rename categories
- merge or split categories
- choose which are visible as overview buttons
without forcing us to rewrite the ranking engine.
Overview requests should accept:
view_mode = overall | categorycategory_idviewportzoom- optional
render_mode
Overview should not accept priority_order.
Detail requests should accept:
latitudelongituderadius_mpriority_ordertime_window_days
This is the only place where drag order should matter.
This is the only split that simultaneously gives:
- frontend clarity
- manageable overview precompute cost
- meaningful local personalization
Everything above the provider layer should consume the same interface.
class DataProvider:
def get_overview_layer(self, view_mode: str, category_id: str | None, viewport: dict | None, zoom: int | None) -> dict: ...
def get_point_signals(self, latitude: float, longitude: float, radius_m: int, time_window_days: int) -> dict: ...
def get_local_timeseries(self, latitude: float, longitude: float, radius_m: int, time_window_days: int) -> dict: ...
def get_baselines(self) -> dict: ...
def get_context_items(self, latitude: float, longitude: float, radius_m: int) -> dict: ...
def get_coverage(self) -> dict: ...If this contract stays stable:
- frontend API stays stable
- detail logic stays stable
- Skill arrival later only changes the provider implementation
This is the preferred future path.
It should read from:
- standardized signal tables
- precomputed overview layers
- area timeseries
- baselines
- manifest files
It assumes teammate-produced Skill outputs are ready.
This is the currently enabled test path.
It should directly read from:
- processed parquet
- cached precomputed JSON
- cached reports
- local CSVs when unavoidable
- demo fixtures only as honest fallback
This provider should be built by refactoring the older backend retrieval logic, not by inventing a second ad hoc app.
Every provider's get_coverage() must include provider_ready: bool. The auto-mode selector uses this field to decide whether to use the provider or fall back:
# SkillDataProvider
def get_coverage(self):
return {"provider_ready": False, ...} # until Skill outputs are ready
# DirectQueryDataProvider
def get_coverage(self):
return {"provider_ready": True, ...} # always operationalDirect mode is not temporary trash code.
It is the operational fallback path for:
- current testing
- teammate delays
- on-site failures
The future Skill/data side should still target standardized outputs.
point_eventsarea_timeseriesbuilding_signalscontext_placesbaselinesmanifest
source_datasetevent_typesubtypelatitudelongitudeh3_r8h3_r9event_dateboroughzip- optional
bbl - optional
bin
h3_r10 is optional, not required in first release.
source_datasetmetric_namearea_typearea_idperiod_startperiod_endperiod_labelmetric_value
source_datasetbblbinlatitudelongitudestatus_datesignal_typesignal_value
source_datasetplace_typelatitudelongitudeh3_r8boroughzip
signal_namearea_typearea_scopep25p50p75updated_at
- dataset freshness
- coverage notes
- missing-field warnings
- source-to-standard mapping
Direct mode is allowed to assemble these shapes from whatever local assets exist, as long as the shapes exposed upward are stable.
Overview should only expose:
overall- one layer per single category button
For a first release with three categories:
overallsafetytrafficfacilities
Overview should not:
- use user drag order
- produce
priority_actions - generate local narrative conclusions
- call the LLM on map-switch interactions
Preferred first implementation:
- H3 cells at resolution
r8
Recommended precompute outputs:
overview_overall_h3_r8.parquetoverview_safety_h3_r8.parquetoverview_traffic_h3_r8.parquetoverview_facilities_h3_r8.parquetoverview_manifest.json
overview_overall should not be guessed ad hoc at implementation time.
For the first release, compute it as a fixed platform-weighted combination of category layers:
OVERVIEW_DEFAULT_WEIGHTS = {
"safety": 0.40,
"traffic": 0.30,
"facilities": 0.30,
}
def build_overall_overview(cell_scores_by_category):
"""
cell_scores_by_category shape:
{
"892a1072d6fffff": {"safety": 0.82, "traffic": 0.55, "facilities": 0.61},
...
}
"""
overall = {}
for h3_cell, categories in cell_scores_by_category.items():
weighted_sum = 0.0
total_weight = 0.0
for category_id, weight in OVERVIEW_DEFAULT_WEIGHTS.items():
if category_id in categories and categories[category_id] is not None:
weighted_sum += categories[category_id] * weight
total_weight += weight
overall[h3_cell] = None if total_weight == 0 else round(weighted_sum / total_weight, 4)
return overallImportant rule:
- overview overall uses fixed platform weights
- detail ranking uses user weights
- these two systems should stay separate
If overview layers are missing, /api/overview must still return a valid response:
{
"schema_version": "v3.6",
"mode": "overview",
"view_mode": "overall",
"category_id": null,
"cells": [],
"coverage": {
"overview_ready": false,
"missing_categories": ["overall", "safety", "traffic", "facilities"]
},
"ui_message": "Overview not yet available. Click a point for realtime detail analysis.",
"data_mode": "direct"
}Detail mode should:
- resolve clicked point
- query local signals in radius
- query local or area-linked timeseries
- compute trends
- detect patterns
- apply ranking weights
- generate local action priorities
- build evidence and gaps
- optionally generate a brief
Required:
latitudelongituderadius_mpriority_order
Optional:
time_window_dayscategory_subset
{
"schema_version": "v3.6",
"mode": "detail",
"data_mode": "direct",
"target": {},
"priority_profile": {
"order": ["safety", "traffic", "facilities"],
"weights": {
"safety": 1.0,
"traffic": 0.72,
"facilities": 0.52
}
},
"priority_actions": [],
"why_now": [],
"current_state": {},
"detail_items": {
"map_points": [],
"nearby_facilities": [],
"building_flags": [],
"recent_incidents": []
},
"trends": {},
"patterns": [],
"evidence_table": [],
"data_gaps": [],
"scores": {},
"report_summary": "",
"report_markdown": ""
}detail_items is for concrete UI objects, not for aggregate reasoning.
Suggested subgroups:
map_pointsnearby_facilitiesbuilding_flagsrecent_incidents
Difference from current_state:
current_state= aggregate metricsdetail_items= concrete rows / objects displayed in UI
Only detail mode should use user drag order.
Use a simple exponential decay:
weight(rank) = decay ^ (rank - 1)
Recommended default:
decay = 0.72
Approximate weights:
- rank 1 ->
1.00 - rank 2 ->
0.72 - rank 3 ->
0.52 - rank 4 ->
0.37 - rank 5 ->
0.27
Weights should affect:
- local priority ranking
- tie-breaking
- report emphasis
Weights should not affect:
- raw retrieval counts
- evidence truth
- overview layer selection
These should drive the first ranking version:
- 311
- collisions
- rodent
- EMS
- Fire
- housing violations
- AEP
- PLUTO / HPD building anchors
These enrich current_state and detail_items, but should not dominate ranking:
- parks
- public toilets
- LinkNYC
- civic facilities
- restaurant context
- bike routes / open streets if available
Overview should prefer signals that are:
- broad in coverage
- aggregatable
- stable enough for map rendering
Detail ranking needs trend signals, even if overview is simpler.
Each major signal should compute:
recent_deltaseasonal_deltabaseline_gappersistence
def compute_recent_delta(values_by_period):
recent = values_by_period.get("last_30d", 0)
previous = values_by_period.get("prev_30d", 0)
if previous == 0:
return {"change_pct": None, "label": "no_baseline"}
pct = (recent - previous) / previous * 100
return {
"change_pct": round(pct, 1),
"label": "rising" if pct > 10 else "falling" if pct < -10 else "stable",
}
def compute_seasonal_delta(values_by_period):
current = values_by_period.get("last_90d", 0)
same_last_year = values_by_period.get("same_90d_last_year", 0)
if same_last_year == 0:
return {"change_pct": None, "label": "no_baseline"}
pct = (current - same_last_year) / same_last_year * 100
return {
"change_pct": round(pct, 1),
"label": "rising" if pct > 15 else "falling" if pct < -15 else "stable",
}
def compute_baseline_gap(current_value, baseline_dist):
if current_value is None:
return {"gap_pct": None, "percentile_label": "unknown"}
p50 = baseline_dist.get("p50", current_value)
if p50 == 0:
return {"gap_pct": None, "percentile_label": "unknown"}
gap = (current_value - p50) / p50 * 100
if current_value <= baseline_dist.get("p25", 0):
label = "below_average"
elif current_value <= baseline_dist.get("p50", 0):
label = "average"
elif current_value <= baseline_dist.get("p75", 0):
label = "above_average"
else:
label = "high"
return {"gap_pct": round(gap, 1), "percentile_label": label}
def compute_persistence(values_by_period, threshold=None):
periods = values_by_period.get("quarterly_values", [])
if not periods or threshold is None:
return {"consecutive_above": 0}
count = 0
for v in reversed(periods):
if v > threshold:
count += 1
else:
break
return {"consecutive_above": count}In direct mode, timeseries may be assembled more roughly from available local tables.
That is acceptable for testing if:
- schema is stable
- evidence is honest
- caveats are explicit
Pattern detection should be rule-based and conservative.
Recommended first rules:
- sanitation complaints rising + rodent rising
- collision pressure rising + EMS response worsening
- housing violations unresolved + AEP/building risk present
def detect_patterns(trends):
patterns = []
sanitation = trends.get("sanitation_311", {})
rodent = trends.get("rodent", {})
collision = trends.get("collision", {})
ems = trends.get("ems_response", {})
housing = trends.get("housing_violations", {})
aep = trends.get("aep", {})
if sanitation.get("direction") == "worsening" and rodent.get("direction") == "worsening":
patterns.append({
"pattern_id": "sanitation_rodent_pressure",
"label": "Sanitation and rodent pressure are worsening together",
"severity": "high",
"evidence_ids": ["trend_311_sanitation", "trend_rodent"],
})
if collision.get("direction") == "worsening" and ems.get("direction") == "worsening":
patterns.append({
"pattern_id": "safety_emergency_pressure",
"label": "Street safety pressure and emergency response pressure are worsening together",
"severity": "high",
"evidence_ids": ["trend_collision", "trend_ems_response"],
})
if housing.get("direction") == "worsening" and aep.get("direction") in {"worsening", "high"}:
patterns.append({
"pattern_id": "building_stress_pattern",
"label": "Building-level housing stress remains elevated",
"severity": "medium",
"evidence_ids": ["trend_housing_violations", "aep_building_signal"],
})
return patternsUse:
- "worsening together"
- "worth prioritizing"
- "may indicate"
- "public records suggest"
Do not overstate causality.
This remains the heart of detail mode.
priority_score =
category_weight
* severity
* momentum
* confidence
* actionability
def compute_priority_actions(current_state, trends, baselines, priority_weights, signal_to_category):
candidates = []
for signal_name, trend in trends.items():
config = PRIORITY_SIGNALS.get(signal_name)
if not config:
continue
category_id = signal_to_category.get(signal_name, "other")
category_weight = priority_weights.get(category_id, 0.30)
severity = compute_severity(current_state, signal_name, baselines)
momentum = compute_momentum(trend)
confidence = compute_confidence(trend, current_state, signal_name)
actionability = config.get("actionability", 0.5)
priority_score = category_weight * severity * momentum * confidence * actionability
if priority_score > 0.05:
candidates.append({
"signal": signal_name,
"category_id": category_id,
"action": config["action_template"],
"severity": round(severity, 2),
"momentum": round(momentum, 2),
"confidence": round(confidence, 2),
"actionability": round(actionability, 2),
"category_weight": round(category_weight, 2),
"priority_score": round(priority_score, 3),
"evidence_ids": config["evidence_ids"],
})
candidates.sort(key=lambda x: x["priority_score"], reverse=True)
for i, c in enumerate(candidates, start=1):
c["rank"] = i
return candidates[:5]
def compute_severity(current_state, signal_name, baselines):
"""
0-1 scale based on current value vs baseline p75.
"""
mapping = {
"rodent": ("safety", "rodent_positive_500m"),
"collision": ("safety", "collision_count_500m"),
"ems_response": ("safety", "ems_median_response_seconds"),
"311_sanitation": ("safety", "safety_311_recent_count"),
"housing_violations": ("housing", "open_class_c"),
}
if signal_name not in mapping:
return 0.5
module, key = mapping[signal_name]
value = current_state.get(module, {}).get(key, 0) or 0
baseline = baselines.get(signal_name, {})
p75 = baseline.get("p75", max(value * 1.5, 1))
return min(value / p75, 1.0)
def compute_momentum(trend):
"""
0-1 scale based on rate of change and persistence.
"""
score = 0.0
rd = trend.get("recent_delta", {}).get("pct") or trend.get("recent_delta", {}).get("change_pct")
sd = trend.get("seasonal_delta", {}).get("pct") or trend.get("seasonal_delta", {}).get("change_pct")
persistence = trend.get("persistence", {}).get("consecutive") or trend.get("persistence", {}).get("consecutive_above", 0)
if rd is not None and rd > 0:
score += min(rd / 50, 0.4)
if sd is not None and sd > 0:
score += min(sd / 50, 0.3)
score += min(persistence * 0.1, 0.3)
return min(score, 1.0)
def compute_confidence(trend, current_state, signal_name):
"""
0-1 scale based on data completeness.
"""
mapping = {
"rodent": ("safety", "rodent_positive_500m"),
"collision": ("safety", "collision_count_500m"),
"ems_response": ("safety", "ems_median_response_seconds"),
"311_sanitation": ("safety", "safety_311_recent_count"),
"housing_violations": ("housing", "open_class_c"),
}
module, key = mapping.get(signal_name, ("safety", signal_name))
has_current = current_state.get(module, {}).get(key) is not None
has_trend = trend.get("direction") not in (None, "insufficient_data")
has_baseline = trend.get("baseline_gap", {}).get("gap_pct") is not None
return (0.4 if has_current else 0) + (0.35 if has_trend else 0) + (0.25 if has_baseline else 0)
PRIORITY_SIGNALS = {
"rodent": {
"action_template": "Allocate additional pest control resources to this area",
"evidence_ids": ["rodent_500m", "trend_rodent"],
"actionability": 0.80,
},
"collision": {
"action_template": "Review traffic safety measures in high-incident corridors",
"evidence_ids": ["collisions_500m", "trend_collision"],
"actionability": 0.75,
},
"ems_response": {
"action_template": "Review EMS coverage and response capacity",
"evidence_ids": ["ems_response", "trend_ems"],
"actionability": 0.70,
},
"311_sanitation": {
"action_template": "Increase sanitation enforcement and collection frequency",
"evidence_ids": ["311_sanitation", "trend_311"],
"actionability": 0.85,
},
"housing_violations": {
"action_template": "Prioritize HPD inspection for buildings with open violations",
"evidence_ids": ["hv_open_c", "trend_housing"],
"actionability": 0.65,
},
}This is the correct place for personalization because it changes interpretation and ranking, not raw facts.
Scores still have value, but only as secondary output.
They can support:
- normalization
- compatibility with older UI expectations
- secondary display blocks
If a dimension has no usable data, return None, not a reassuring number.
This fixes the earlier risk where absent data looked like a good score.
Each scoring module must check whether its required inputs are present before computing a score. If all key inputs are missing or None, the function must return None instead of a number.
def _has_any_data(module: dict, keys: list[str]) -> bool:
"""Return True if at least one key has a non-None value."""
return any(module.get(k) is not None for k in keys)Example for safety:
if not _has_any_data(safety, ["rodent_positive_500m", "sanitation_311_recent_count",
"ems_avg_response_seconds", "fire_avg_response_seconds"]):
safety_score = None # do NOT return 100The overall score must use weighted average of available (non-None) scores only, re-normalizing the weights.
Every surfaced issue should remain traceable.
Evidence must support:
priority_actionswhy_nowpatternsreport_markdown
def build_evidence(query_evidence, trends, patterns):
"""
`query_evidence` should be produced by each provider/query function.
This function aggregates and enriches those entries with trend and
pattern evidence so downstream verification has concrete evidence_ids.
"""
evidence = list(query_evidence or [])
seen_ids = {e["evidence_id"] for e in evidence if e.get("evidence_id")}
for signal_name, trend in trends.items():
direction = trend.get("direction")
recent = trend.get("recent_delta", {}).get("pct") or trend.get("recent_delta", {}).get("change_pct")
seasonal = trend.get("seasonal_delta", {}).get("pct") or trend.get("seasonal_delta", {}).get("change_pct")
persistence = trend.get("persistence", {}).get("consecutive") or trend.get("persistence", {}).get("consecutive_above", 0)
gap = trend.get("baseline_gap", {}).get("gap_pct")
trend_id = f"trend_{signal_name}"
if trend_id not in seen_ids and direction not in (None, "insufficient_data"):
summary_bits = [f"direction={direction}"]
if recent is not None:
summary_bits.append(f"recent_delta={recent}%")
if seasonal is not None:
summary_bits.append(f"seasonal_delta={seasonal}%")
if gap is not None:
summary_bits.append(f"baseline_gap={gap}%")
if persistence:
summary_bits.append(f"persistence={persistence}")
evidence.append({
"evidence_id": trend_id,
"source": "computed_trend",
"date": "computed",
"summary": f"{signal_name} trend: " + ", ".join(summary_bits),
})
seen_ids.add(trend_id)
for pattern in patterns:
pattern_id = pattern.get("pattern_id")
evidence_id = f"pattern_{pattern_id}"
if pattern_id and evidence_id not in seen_ids:
evidence.append({
"evidence_id": evidence_id,
"source": "computed_pattern",
"date": "computed",
"summary": pattern.get("label") or pattern.get("description") or pattern_id,
})
seen_ids.add(evidence_id)
return evidence
def verify_priority_actions(priority_actions, evidence_table):
valid_ids = {e["evidence_id"] for e in evidence_table}
verified = []
for item in priority_actions:
if all(eid in valid_ids for eid in item.get("evidence_ids", [])):
verified.append(item)
return verifiedThe provider/query layer must already emit raw evidence entries during retrieval, for example:
{
"evidence_id": "collisions_500m",
"source": "h9gi-nx95",
"date": "2024-2026 local extract",
"summary": "244 collision records within 500m in the selected window",
}build_evidence() is responsible for:
- aggregating provider-emitted evidence
- adding computed trend evidence
- adding computed pattern evidence
- returning the final
evidence_tableused by verification and reporting
Because direct retrieval may be rougher than future Skill outputs, evidence and caveat honesty are especially important in direct mode.
The model remains a writer, not the judge.
The model does not:
- discover issues
- override rankings
- invent causes
The model does:
- write a concise brief from verified structured data
- priority actions
- why these issues now
- current local state
- evidence-backed patterns
- data gaps
If the model endpoint is unavailable, detail mode should still return:
priority_actionswhy_nowcurrent_stateevidence_tabledata_gaps
and a deterministic fallback brief string.
To reduce avoidable prompt/data injection risk:
- the prompt should prefer structured values, evidence ids, and bounded summaries
- raw external free-text fields should be minimized, truncated, or sanitized before entering the prompt
- local model endpoints should be allowlisted to local addresses only for the prize path
- remote hosted model endpoints should not be used for the "Least Likely to get Hacked" evaluation path
- if model configuration is invalid or unavailable, the backend must fall back rather than retrying indefinitely or leaking configuration detail
The FastAPI app must include CORSMiddleware, but must not use allow_origins=["*"] in the prize-ready build.
For the Pensar-hardened path:
- allow only the known frontend origins used by the real demo
- load them from env, for example
URBAN_DOSSIER_ALLOWED_ORIGINS - keep
healthreadable, but do not make heavy endpoints universally cross-origin callable
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173",
"http://127.0.0.1:5173",
],
allow_methods=["GET", "POST"],
allow_headers=["Content-Type", "X-Urban-Dossier-Token"],
)For the prize-ready build:
GET /api/healthmay remain openGET /api/categoriesandGET /api/coveragemay remain open if they expose no sensitive internal pathsPOST /api/overview,POST /api/analyze-point, andPOST /api/watchlist/runshould require a simple local demo token such asX-Urban-Dossier-Token- watchlist must be treated as a protected heavy endpoint, not a casual public endpoint
These are required so Pensar does not surface obvious resource-exhaustion findings:
category_idmust be whitelisted tooverallor known map-driving categoriesradius_mshould be capped to a demo-safe maximum, recommended1000or1500time_window_daysshould be capped to a demo-safe maximum, recommended730watchlist.seedsmust have a hard upper bound, recommended10- duplicate watchlist seeds should be deduplicated before analysis
- heavy requests should have a timeout budget and fail closed with a structured error
Requires:
X-Urban-Dossier-Tokencategory_idomitted foroverallcategory_idrestricted to known map-driving categories forview_mode="category"
Request:
{
"view_mode": "overall",
"category_id": null,
"viewport": {
"north": 40.92,
"south": 40.49,
"east": -73.68,
"west": -74.27
},
"zoom": 11,
"render_mode": "h3_cells"
}Response:
{
"schema_version": "v3.6",
"mode": "overview",
"view_mode": "overall",
"category_id": null,
"layer_mode": "h3_r8",
"cells": [],
"coverage": {
"overview_ready": true,
"available_categories": ["overall", "safety", "traffic", "facilities"],
"missing_categories": []
},
"data_mode": "direct",
"freshness": {
"generated_at": "2026-04-11T07:30:00Z"
}
}Requires:
X-Urban-Dossier-Token- bounded
radius_m - bounded
time_window_days priority_orderfiltered to known detail-rankable categories
Request:
{
"latitude": 40.7579,
"longitude": -73.9999,
"radius_m": 500,
"priority_order": ["safety", "traffic", "facilities"],
"time_window_days": 365
}Response:
{
"schema_version": "v3.6",
"mode": "detail",
"data_mode": "direct",
"target": {},
"priority_profile": {
"order": ["safety", "traffic", "facilities"],
"weights": {
"safety": 1.0,
"traffic": 0.72,
"facilities": 0.52
}
},
"priority_actions": [],
"why_now": [],
"current_state": {},
"detail_items": {
"map_points": [],
"nearby_facilities": [],
"building_flags": [],
"recent_incidents": []
},
"trends": {},
"patterns": [],
"evidence_table": [],
"data_gaps": [],
"scores": {},
"report_summary": "",
"report_markdown": ""
}Returns:
- category labels
- default order
- map-driving flags
- detail-rankable flags
Returns:
- current
data_mode - provider availability
- overview readiness
- signal availability by category
Runs batch analysis with fixed platform ordering.
Security rules:
- require
X-Urban-Dossier-Token - reject requests above the seed cap
- apply timeout / cancellation budget
- reuse detail logic, but do not allow unbounded synchronous fan-out
Recommended to return:
- data provider status
- model endpoint status
- overview cache readiness
- report fallback readiness
Watchlist should not be its own logic tree.
It should reuse the detail engine on multiple seeds.
- choose seed cells / areas / ZIPs
- apply fixed default ordering
- run detail analysis in batch
- collect top issues and top areas
- emit watchlist JSON and optional markdown summaries
def run_watchlist(area_seeds, default_priority_order):
results = []
for seed in area_seeds:
result = run_detail_analysis(
latitude=seed["latitude"],
longitude=seed["longitude"],
radius_m=seed.get("radius_m", 500),
priority_order=default_priority_order,
time_window_days=365,
)
results.append(result)
return resultsThe older local backend already contains directly useful ideas:
- address resolution
- parquet filtering
- radius-based direct metric retrieval
- evidence creation
- fallback report generation
v3.6.1 should not discard those.
Instead:
- refactor them into
DirectQueryDataProvider - preserve deterministic pieces where still valid
- replace only what is score-first or due-diligence-specific
This is crucial because v3.6.1 must be runnable before Skill is ready.
In direct mode:
- use precomputed overview artifacts if they exist
- use processed parquet / local extracts
- use demo fixtures only if no real source exists
- bbox + haversine radius filtering
- direct DuckDB parquet queries
- ZIP-level aggregate lookup for EMS / Fire
- cached precomputed JSON where appropriate
If direct mode uses rough proxies:
- say so in
data_gaps - do not imply precision that does not exist
We should explicitly support backend-only smoke testing on the official machine.
- real point
- radius 500m
- one ranking order
Expect:
- valid JSON
- non-empty
current_state - non-empty
priority_actions
Same point, same radius, two different ranking orders.
Expect:
- different weights
- meaningfully different top-ranked actions
No precompute ready.
Expect:
- valid overview response
overview_ready = false
At least one overview artifact exists.
Expect:
- non-empty
cells - correct
category_id
The SSH tests already prove direct mode is viable enough to start backend implementation and smoke testing immediately.
The teammate-owned NVIDIA ecosystem work should be easy to plug in, not blocked by our backend design.
- cuDF / RAPIDS for overview precompute and large aggregations
- DuckDB/parquet for detail realtime queries
- local model endpoint abstraction
- batch watchlist runs
- cuDF / RAPIDS
- overview precompute
- heavy groupbys
- baseline generation
- batch pre-aggregation
- DuckDB
- realtime point/radius filtering
- lightweight local queries
- LLM runtime
- short brief generation only
We do not need to own benchmark work ourselves. We only need to ensure our logic can benefit from those choices.
Teammates are already running benchmark work, but we should design the backend so their numbers matter.
The remote benchmark path already showed strong cuDF wins on real data for:
- load
- string filter
- sort
Therefore:
- overview precompute should be the first place to exploit GPU aggregation wins
- detail mode should remain simpler and latency-friendly
These benchmark numbers should appear in final slides, but the backend architecture must already reflect them.
The local apex folder is useful for architecture thinking, not as a core product dependency.
- API-first organization
- shared core, multiple consumers
- runtime/provider abstraction
- a repeatable operator-facing security gate
In Urban Dossier:
- overview
- detail
- watchlist
should all consume the same core analysis capability.
And:
SkillDataProviderDirectQueryDataProvider
should be swappable runtime providers.
Do not make Apex or pentest tooling the main product story. It should remain a quality gate, but a real one that can block unsafe merges.
src/
api.py
config.py
categories.py
providers/
base.py
skill_provider.py
direct_provider.py
overview.py
detail.py
metrics.py
trend_engine.py
pattern_detector.py
priority_engine.py
evidence.py
scoring.py
report.py
watchlist.py
providers/base.py- provider interface
providers/skill_provider.py- Skill-backed source reads
providers/direct_provider.py- legacy hardcoded retrieval refactor
overview.py- overview orchestration + fallback
detail.py- realtime detail orchestration
trend_engine.py- trend computations
pattern_detector.py- multi-signal patterns
priority_engine.py- ranking logic
evidence.py- evidence table + verification
report.py- brief generation
watchlist.py- batch wrapper around detail
- add provider abstraction
- implement
DirectQueryDataProvider - set
URBAN_DOSSIER_DATA_MODE=direct - implement
/api/analyze-pointminimal path
- implement
/api/overviewfallback - add first
overalland one category precompute layer - expose
data_modein responses
- wire trend engine
- wire pattern detection
- wire priority engine
- wire evidence and fallback brief
- add watchlist batch wrapper
- add
SkillDataProviderskeleton - keep method signatures stable
- switch default to
autowhen Skill is ready - refine coverage/manifest integration
- harden the prize path for Pensar/Apex
- lock the award path to restricted CORS + token-protected heavy endpoints
- enforce request caps and timeout budgets
- run the standing Pensar/security gate before each demo or merge
These do not block us from writing backend skeletons, but they do affect final integration:
- final first-pass category list
- exact frontend expectation for overview rendering payload
- radius options to expose in UI
- first standardized Skill/data outputs that will really ship
- final local model endpoint choice on the official machine
None of these should block direct-mode backend testing.
The rules include a bounty for:
- Least Likely to get Hacked
- requirement: run Pensar on the build and fix the issues it surfaces
This means the backend should be designed so the prize path is:
- real
- repeatable
- local
- measurable
- identical to the real demo behavior
It does not mean:
- writing a fake-safe build just for the scan
- temporarily removing risky endpoints only for judging
- claiming security from architecture slides alone
The correct target for this prize is:
- a local hackathon demo backend
- running on the same machine family and same codebase as the demo
- with the same real endpoints the frontend consumes
- with bounded request cost
- with no obvious cross-origin abuse path
- with no unprotected batch-amplification path
Before hardening, our own backend testing already demonstrated these real risks:
- wildcard CORS on heavy endpoints
- no token/auth on heavy endpoints
watchlistresource amplification- very large legal request windows for detail mode
- missing category whitelist for overview selection
Those must be treated as real findings, not hypothetical concerns.
To make the backend realistically defensible for the prize path, v3.6.1 requires:
GET /api/healthmay remain openGET /api/categoriesandGET /api/coveragemay remain open only if they do not expose filesystem paths, secrets, or privileged internalsPOST /api/overview,POST /api/analyze-point, andPOST /api/watchlist/runmust require a local demo tokenwatchlistmust be treated as a privileged/internal endpoint even in demo mode
DetailRequest.radius_mshould be capped to a safe range; recommended max1000or1500DetailRequest.time_window_daysshould be capped to a safe range; recommended max730WatchlistRequest.seedsmust have a hard max; recommended10- watchlist seeds should be deduplicated before analysis
- any request exceeding budget should return a structured error instead of being silently truncated
category_idmust be restricted tooverallor known map-driving categories- no user input may influence arbitrary file paths
- overview file names must be derived from whitelisted category ids only
- provider mode must remain a backend/server decision, not a user-controlled request parameter
- local model base URLs must be restricted to local/allowlisted addresses
- the report path must tolerate model failure cleanly
- the fallback brief must remain a first-class output path
- prompt construction should prefer structured data and bounded summaries
For the actual security-award path, the recommended mode is:
URBAN_DOSSIER_DATA_MODE=direct- restricted CORS
- token-protected heavy endpoints
- Skill support compiled in but not enabled unless it has passed the same gate
This is not cheating because it is the real runtime path we currently ship and test.
Pensar/Apex should be treated as a standing gate, not a one-time demo rehearsal.
Before running Pensar:
- start the real backend service
- use the same build and same config that the frontend uses for the demo
- ensure heavy endpoints are exposed exactly as they will be shown
- ensure the direct-mode data path is populated with real or realistic local data
Operational note:
- launch Apex with
pensar - run the security review against the live local backend, not a mock or reduced endpoint surface
Before or alongside Pensar, we should manually verify:
- a hostile browser origin cannot call heavy endpoints
- watchlist rejects over-limit seed counts
- extreme detail requests are rejected or bounded
- unknown
category_idvalues are rejected cleanly - model outage still yields a safe deterministic response
Pensar should be used against:
- the running backend as a blackbox target
- the local backend codebase as a whitebox review target
The review should explicitly check for:
- authentication gaps
- resource exhaustion
- prompt/data injection opportunities
- unsafe path handling
- overly broad CORS
- missing timeout/limit enforcement
We only claim the prize path is ready if:
- Pensar findings are reviewed
- actionable findings are fixed in code
- the fixes are re-tested on the same real endpoints
- no special "scan-only" build is introduced
To keep this prize attempt credible, the following are forbidden:
- branching the code just for Pensar while demoing a different build
- disabling watchlist or detail only during the scan
- returning pre-canned safe data to Pensar while serving live logic to the frontend
- adding Pensar-specific user-agent or source checks
- claiming Skill mode is secure without testing Skill mode
These are allowed because they reflect real deployment choices, not fake-safe tricks:
- running only
directmode in the award path if Skill is not ready - using a local demo token for protected endpoints
- limiting seed count, radius, and time window to a safe demo budget
- falling back from LLM output to deterministic markdown if the local model is unavailable
Every backend change after v3.6.1 must pass a standing security gate.
The gate must run whenever we change:
- API request models
- provider implementations
- overview file-loading logic
- detail query logic
- watchlist behavior
- model prompt construction
- auth/CORS/config behavior
For each qualifying change:
- run backend smoke tests
- run manual abuse tests
- run Pensar/Apex review
- fix actionable issues
- re-run smoke tests and Pensar
- record what changed and what was fixed
For each security-relevant change, keep:
- commit hash
- runtime mode used
- endpoints tested
- Pensar finding summary
- fixes applied
- retest result
This creates an honest record that the backend keeps earning the prize claim over time.
Urban Dossier v3.6.1 should be understood as:
- a complete backend architecture, not just a concept note
- one that merges:
- v3 deterministic analysis and code skeletons
- v3.5.x overview/detail split
- v3.5.3 provider abstraction and direct-query fallback
- a real Pensar/Apex hardening gate
The correct final interpretation is:
- overview is fixed and pre-rendered
- overall + one map per single category
- detail is personalized and realtime
- click + radius + drag order -> local ranked priorities
- watchlist is batch reuse of detail
- fixed platform ordering
- Skill is preferred when available
- direct query is the current test path and permanent fallback
- the security-award path uses the same real backend, not a special fake-safe variant
That is the most complete, practical, and review-ready version of the backend engineering plan.