big ui and intel updates
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
"""
|
||||
Gemini-powered intelligence extraction from call transcripts.
|
||||
GPT-4o-mini intelligence extraction from call transcripts.
|
||||
|
||||
Sends the transcript to Gemini Flash with a tight JSON schema prompt.
|
||||
Sends the transcript to GPT-4o mini with a tight JSON schema prompt.
|
||||
Returns structured data: incident type, tags, location, vehicles, units, severity.
|
||||
|
||||
Falls back gracefully if Gemini is unavailable or returns malformed output.
|
||||
Falls back gracefully if the API is unavailable or returns malformed output.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
@@ -36,6 +36,9 @@ System: {system_id}
|
||||
Talkgroup: {talkgroup_name}
|
||||
{transcript_block}"""
|
||||
|
||||
# Nominatim viewbox half-width in degrees (~35 km at mid-latitudes)
|
||||
_GEO_DELTA = 0.3
|
||||
|
||||
|
||||
async def extract_tags(
|
||||
call_id: str,
|
||||
@@ -44,38 +47,52 @@ async def extract_tags(
|
||||
talkgroup_id: Optional[int] = None,
|
||||
system_id: Optional[str] = None,
|
||||
segments: Optional[list[dict]] = None,
|
||||
) -> tuple[list[str], Optional[str], Optional[str]]:
|
||||
node_id: Optional[str] = None,
|
||||
) -> tuple[list[str], Optional[str], Optional[str], Optional[dict]]:
|
||||
"""
|
||||
Extract incident tags, type, location, and corrected transcript via Gemini.
|
||||
Extract incident tags, type, location, and corrected transcript via GPT-4o mini.
|
||||
Geocodes the extracted location string via Nominatim using the node's position as bias.
|
||||
|
||||
Returns:
|
||||
(tags, primary_type, location)
|
||||
(tags, primary_type, location_str, location_coords)
|
||||
where location_coords is {"lat": float, "lng": float} or None.
|
||||
|
||||
Side-effect: updates calls/{call_id} in Firestore with tags, location,
|
||||
vehicles, units, severity, transcript_corrected; also stores the call embedding.
|
||||
location_coords, vehicles, units, severity, transcript_corrected; also stores embedding.
|
||||
"""
|
||||
result = await asyncio.to_thread(_sync_extract, transcript, talkgroup_name, talkgroup_id, system_id, segments)
|
||||
result = await asyncio.to_thread(
|
||||
_sync_extract, transcript, talkgroup_name, talkgroup_id, system_id, segments
|
||||
)
|
||||
|
||||
tags: list[str] = result.get("tags") or []
|
||||
tags: list[str] = result.get("tags") or []
|
||||
incident_type: Optional[str] = result.get("incident_type") or None
|
||||
location: Optional[str] = result.get("location") or None
|
||||
vehicles: list[str] = result.get("vehicles") or []
|
||||
units: list[str] = result.get("units") or []
|
||||
severity: str = result.get("severity") or "unknown"
|
||||
location: Optional[str] = result.get("location") or None
|
||||
vehicles: list[str] = result.get("vehicles") or []
|
||||
units: list[str] = result.get("units") or []
|
||||
severity: str = result.get("severity") or "unknown"
|
||||
transcript_corrected: Optional[str] = result.get("transcript_corrected") or None
|
||||
|
||||
if incident_type in ("unknown", "other", ""):
|
||||
incident_type = None
|
||||
|
||||
# Geocode the location string if we have one and a node to bias toward
|
||||
location_coords: Optional[dict] = None
|
||||
if location and node_id:
|
||||
node_doc = await fstore.doc_get("nodes", node_id)
|
||||
if node_doc:
|
||||
node_lat = node_doc.get("lat")
|
||||
node_lon = node_doc.get("lon")
|
||||
if node_lat is not None and node_lon is not None:
|
||||
location_coords = await _geocode_location(location, node_lat, node_lon)
|
||||
|
||||
# Store embedding alongside structured data
|
||||
embedding = await asyncio.to_thread(_sync_embed, _embed_text(transcript, incident_type))
|
||||
|
||||
updates: dict = {
|
||||
"tags": tags,
|
||||
"severity": severity,
|
||||
}
|
||||
updates: dict = {"tags": tags, "severity": severity}
|
||||
if location:
|
||||
updates["location"] = location
|
||||
if location_coords:
|
||||
updates["location_coords"] = location_coords
|
||||
if vehicles:
|
||||
updates["vehicles"] = vehicles
|
||||
if units:
|
||||
@@ -92,10 +109,49 @@ async def extract_tags(
|
||||
|
||||
logger.info(
|
||||
f"Intelligence: call {call_id} → type={incident_type}, "
|
||||
f"tags={tags}, location={location!r}, severity={severity}, "
|
||||
f"tags={tags}, location={location!r}, coords={location_coords}, severity={severity}, "
|
||||
f"corrected={transcript_corrected is not None}"
|
||||
)
|
||||
return tags, incident_type, location
|
||||
return tags, incident_type, location, location_coords
|
||||
|
||||
|
||||
async def _geocode_location(
|
||||
location_str: str, node_lat: float, node_lon: float
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Geocode a location string using Nominatim, biased toward the node's area.
|
||||
Returns {"lat": float, "lng": float} or None if geocoding fails.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
viewbox = (
|
||||
f"{node_lon - _GEO_DELTA},{node_lat - _GEO_DELTA},"
|
||||
f"{node_lon + _GEO_DELTA},{node_lat + _GEO_DELTA}"
|
||||
)
|
||||
params = {
|
||||
"q": location_str,
|
||||
"format": "json",
|
||||
"limit": 1,
|
||||
"viewbox": viewbox,
|
||||
"bounded": 1,
|
||||
}
|
||||
headers = {"User-Agent": "DRB-Dispatch/1.0 (public-safety radio monitor)"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(
|
||||
"https://nominatim.openstreetmap.org/search",
|
||||
params=params,
|
||||
headers=headers,
|
||||
)
|
||||
r.raise_for_status()
|
||||
results = r.json()
|
||||
if results:
|
||||
coords = {"lat": float(results[0]["lat"]), "lng": float(results[0]["lon"])}
|
||||
logger.info(f"Geocoded '{location_str}' → {coords}")
|
||||
return coords
|
||||
except Exception as e:
|
||||
logger.warning(f"Geocoding failed for '{location_str}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str:
|
||||
@@ -154,10 +210,7 @@ def _sync_embed(text: str) -> Optional[list[float]]:
|
||||
|
||||
try:
|
||||
client = OpenAI(api_key=settings.openai_api_key)
|
||||
result = client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input=text,
|
||||
)
|
||||
result = client.embeddings.create(model="text-embedding-3-small", input=text)
|
||||
return result.data[0].embedding
|
||||
except Exception as e:
|
||||
logger.warning(f"Embedding generation failed: {e}")
|
||||
@@ -165,6 +218,5 @@ def _sync_embed(text: str) -> Optional[list[float]]:
|
||||
|
||||
|
||||
def _embed_text(transcript: str, incident_type: Optional[str]) -> str:
|
||||
"""Build the text string to embed — transcript + type context."""
|
||||
prefix = f"[{incident_type}] " if incident_type else ""
|
||||
return f"{prefix}{transcript}"
|
||||
|
||||
Reference in New Issue
Block a user