mirror of
https://github.com/zvx-echo6/recon.git
synced 2026-06-10 17:04:39 +02:00
Compare commits
No commits in common. "master" and "feature/scraper" have entirely different histories.
master
...
feature/sc
16 changed files with 9 additions and 1073 deletions
|
|
@ -1,18 +0,0 @@
|
|||
# RECON Address Book — saved locations for navigation shortcuts.
|
||||
# Entries are matched by name and aliases (case-insensitive).
|
||||
# Add new entries by appending to the list below.
|
||||
|
||||
entries:
|
||||
- id: home
|
||||
name: Home
|
||||
aliases:
|
||||
- home
|
||||
- matt's house
|
||||
- 214 north st
|
||||
- 214 north street
|
||||
address: "214 North St, Filer, ID 83328"
|
||||
lat: 42.5735833
|
||||
lon: -114.6066389
|
||||
tags:
|
||||
- residence
|
||||
- primary
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
# Deployment profile: Home (VM 1130)
|
||||
# Active on the main Echo6 deployment. Full stack with planet-scale NA tiles.
|
||||
# Override via RECON_PROFILE env var in /etc/systemd/system/recon.service
|
||||
|
||||
profile: home
|
||||
region_name: "North America"
|
||||
|
||||
tileset:
|
||||
url: "/tiles/planet/current.pmtiles"
|
||||
bounds: [-168, 14, -52, 72]
|
||||
max_zoom: 15
|
||||
attribution: "Protomaps © OSM"
|
||||
|
||||
tileset_hillshade:
|
||||
url: "/tiles/planet-dem.pmtiles"
|
||||
encoding: "terrarium"
|
||||
max_zoom: 12
|
||||
|
||||
traffic:
|
||||
provider: "tomtom"
|
||||
proxy_url: "/api/traffic/flow/{z}/{x}/{y}.png"
|
||||
|
||||
place_details:
|
||||
local_source: "nominatim"
|
||||
local_bbox: [-125.0, 31.3, -104.0, 49.0]
|
||||
fallback_source: "overpass"
|
||||
|
||||
services:
|
||||
geocode: "/api/geocode"
|
||||
reverse: "/api/reverse"
|
||||
address_book: "/api/address_book"
|
||||
valhalla: "/valhalla"
|
||||
|
||||
auth:
|
||||
login_url: "/outpost.goauthentik.io/start?rd=%2F"
|
||||
logout_url: "https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/"
|
||||
|
||||
features:
|
||||
has_nominatim_details: true
|
||||
has_kiwix_wiki: true
|
||||
has_hillshade: true
|
||||
has_3d_terrain: false
|
||||
has_traffic_overlay: true
|
||||
has_landclass: true
|
||||
has_public_lands_layer: true
|
||||
has_contours: true
|
||||
has_contours_test: false
|
||||
has_contours_test_10ft: false
|
||||
has_address_book_write: false
|
||||
has_overture_enrichment: true
|
||||
has_google_places_enrichment: true
|
||||
has_contacts: true
|
||||
has_wiki_rewriting: true
|
||||
has_wiki_discovery: false
|
||||
has_usfs_trails: true
|
||||
has_blm_trails: true
|
||||
|
||||
defaults:
|
||||
center: [42.5736, -114.6066]
|
||||
zoom: 10
|
||||
|
||||
# Offroute wilderness routing
|
||||
offroute:
|
||||
osm_pbf_path: "/mnt/nav/sources/idaho-latest.osm.pbf"
|
||||
densify_interval_m: 100
|
||||
postgis_dsn: "dbname=padus"
|
||||
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# Deployment profile: Minimal Pi (single-state pocket deployment)
|
||||
# Template for the lightest possible field kit — Idaho only.
|
||||
# Override via RECON_PROFILE env var.
|
||||
|
||||
profile: minimal_pi
|
||||
region_name: "Idaho"
|
||||
|
||||
tileset:
|
||||
url: "/tiles/idaho.pmtiles"
|
||||
bounds: [-117.5, 42.0, -111.0, 49.0]
|
||||
max_zoom: 15
|
||||
attribution: "Protomaps © OSM"
|
||||
|
||||
tileset_hillshade:
|
||||
url: "/tiles/hillshade-idaho.pmtiles"
|
||||
encoding: "terrarium"
|
||||
max_zoom: 12
|
||||
|
||||
traffic:
|
||||
provider: "tomtom"
|
||||
proxy_url: "/api/traffic/flow/{z}/{x}/{y}.png"
|
||||
|
||||
services:
|
||||
geocode: "/api/geocode"
|
||||
reverse: "/api/reverse"
|
||||
address_book: "/api/address_book"
|
||||
valhalla: "/valhalla"
|
||||
|
||||
# TODO(matt): confirm logout next= host for this profile
|
||||
auth:
|
||||
login_url: "/outpost.goauthentik.io/start?rd=%2F"
|
||||
logout_url: "https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/"
|
||||
|
||||
features:
|
||||
has_nominatim_details: false
|
||||
has_kiwix_wiki: false
|
||||
has_hillshade: false
|
||||
has_3d_terrain: false
|
||||
has_traffic_overlay: false
|
||||
has_landclass: false
|
||||
has_public_lands_layer: false
|
||||
has_address_book_write: true
|
||||
has_overture_enrichment: false
|
||||
has_google_places_enrichment: false
|
||||
has_contacts: false
|
||||
has_wiki_rewriting: false
|
||||
has_wiki_discovery: false
|
||||
|
||||
defaults:
|
||||
center: [44.0, -114.0]
|
||||
zoom: 7
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
# Deployment profile: Regional Pi (multi-state field kit)
|
||||
# Template for a Raspberry Pi covering Idaho + surrounding states.
|
||||
# Override via RECON_PROFILE env var.
|
||||
|
||||
profile: regional_pi
|
||||
region_name: "Idaho + Neighbors"
|
||||
|
||||
tileset:
|
||||
url: "/tiles/regional.pmtiles"
|
||||
bounds: [-125, 40, -104, 49]
|
||||
max_zoom: 15
|
||||
attribution: "Protomaps © OSM"
|
||||
|
||||
tileset_hillshade:
|
||||
url: "/tiles/hillshade-regional.pmtiles"
|
||||
encoding: "terrarium"
|
||||
max_zoom: 12
|
||||
|
||||
traffic:
|
||||
provider: "tomtom"
|
||||
proxy_url: "/api/traffic/flow/{z}/{x}/{y}.png"
|
||||
|
||||
place_details:
|
||||
local_source: "nominatim"
|
||||
local_bbox: [-125.0, 40.0, -104.0, 49.0]
|
||||
fallback_source: "overpass"
|
||||
|
||||
services:
|
||||
geocode: "/api/geocode"
|
||||
reverse: "/api/reverse"
|
||||
address_book: "/api/address_book"
|
||||
valhalla: "/valhalla"
|
||||
|
||||
# TODO(matt): confirm logout next= host for this profile
|
||||
auth:
|
||||
login_url: "/outpost.goauthentik.io/start?rd=%2F"
|
||||
logout_url: "https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/"
|
||||
|
||||
features:
|
||||
has_nominatim_details: true
|
||||
has_kiwix_wiki: false
|
||||
has_hillshade: true
|
||||
has_3d_terrain: false
|
||||
has_traffic_overlay: true
|
||||
has_landclass: true
|
||||
has_public_lands_layer: true
|
||||
has_contours: true
|
||||
has_contours_test: true
|
||||
has_contours_test_10ft: true
|
||||
has_address_book_write: true
|
||||
has_overture_enrichment: false
|
||||
has_google_places_enrichment: false
|
||||
has_contacts: false
|
||||
has_wiki_rewriting: true
|
||||
has_wiki_discovery: false
|
||||
|
||||
defaults:
|
||||
center: [44.0, -114.0]
|
||||
zoom: 7
|
||||
|
|
@ -57,10 +57,6 @@ class _LargeZimRequest(_FlaskRequest):
|
|||
return super()._get_file_stream(total_content_length, content_type, filename, content_length)
|
||||
|
||||
app.request_class = _LargeZimRequest
|
||||
# ── Netsyms Blueprint ──
|
||||
from .netsyms_api import netsyms_bp
|
||||
app.register_blueprint(netsyms_bp)
|
||||
|
||||
|
||||
# ── Navigation Constants ──
|
||||
|
||||
|
|
@ -1319,9 +1315,6 @@ def api_keys_reload():
|
|||
return jsonify({'count': count})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ── YouTube Cookie Management ──
|
||||
|
||||
PEERTUBE_HOST = '192.168.1.170'
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
"""
|
||||
title: Navigation
|
||||
author: Echo6
|
||||
version: 1.1.0
|
||||
description: Turn-by-turn directions and geocoding via Photon + Valhalla on recon-vm. Supports driving, walking, cycling, and truck routing with worldwide coverage (281M places).
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import requests
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
_COORD_RE = re.compile(r'^(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)$')
|
||||
|
||||
|
||||
class Tools:
|
||||
class Valves(BaseModel):
|
||||
photon_url: str = Field(
|
||||
default="http://100.64.0.24:2322",
|
||||
description="Photon geocoding service URL (recon-vm)",
|
||||
)
|
||||
valhalla_url: str = Field(
|
||||
default="http://100.64.0.24:8002",
|
||||
description="Valhalla routing service URL (recon-vm)",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _geocode(self, query: str):
|
||||
m = _COORD_RE.match(query.strip())
|
||||
if m:
|
||||
lat, lon = float(m.group(1)), float(m.group(2))
|
||||
return lat, lon, query
|
||||
resp = requests.get(
|
||||
f"{self.valves.photon_url}/api",
|
||||
params={"q": query, "limit": 1},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
features = resp.json().get("features", [])
|
||||
if not features:
|
||||
return None, None, None
|
||||
props = features[0]["properties"]
|
||||
coords = features[0]["geometry"]["coordinates"]
|
||||
parts = [props.get("name", "")]
|
||||
for key in ("city", "state", "country"):
|
||||
v = props.get(key)
|
||||
if v and v != parts[-1]:
|
||||
parts.append(v)
|
||||
return coords[1], coords[0], ", ".join(p for p in parts if p)
|
||||
|
||||
def get_directions(
|
||||
self,
|
||||
origin: str,
|
||||
destination: str,
|
||||
mode: str = "auto",
|
||||
) -> str:
|
||||
"""
|
||||
Get turn-by-turn directions between two locations. When this tool returns results, present the directions exactly as returned — do not summarize or rephrase. Include all steps.
|
||||
|
||||
:param origin: Starting location — address, place name, or lat,lon coordinates
|
||||
:param destination: Destination — address, place name, or lat,lon coordinates
|
||||
:param mode: Travel mode: auto, pedestrian, bicycle, or truck (default: auto)
|
||||
:return: Formatted turn-by-turn directions
|
||||
"""
|
||||
if mode not in ("auto", "pedestrian", "bicycle", "truck"):
|
||||
mode = "auto"
|
||||
|
||||
orig_lat, orig_lon, orig_name = self._geocode(origin)
|
||||
if orig_lat is None:
|
||||
return f"Could not find location: {origin}"
|
||||
|
||||
dest_lat, dest_lon, dest_name = self._geocode(destination)
|
||||
if dest_lat is None:
|
||||
return f"Could not find location: {destination}"
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.valves.valhalla_url}/route",
|
||||
json={
|
||||
"locations": [
|
||||
{"lat": orig_lat, "lon": orig_lon},
|
||||
{"lat": dest_lat, "lon": dest_lon},
|
||||
],
|
||||
"costing": mode,
|
||||
"directions_options": {"units": "miles"},
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException:
|
||||
return "Navigation service unavailable"
|
||||
|
||||
if resp.status_code != 200:
|
||||
return "No route found between locations"
|
||||
|
||||
trip = resp.json()["trip"]
|
||||
summary = trip["summary"]
|
||||
legs = trip["legs"][0]["maneuvers"]
|
||||
|
||||
miles = round(summary["length"], 1)
|
||||
minutes = round(summary["time"] / 60, 1)
|
||||
|
||||
lines = [
|
||||
f"Directions from {orig_name} to {dest_name} ({mode}):",
|
||||
f"Distance: {miles} miles | Time: {minutes} minutes",
|
||||
"",
|
||||
]
|
||||
for i, m in enumerate(legs, 1):
|
||||
inst = m["instruction"]
|
||||
dist = m.get("length", 0)
|
||||
if dist > 0:
|
||||
lines.append(f"{i}. {inst} — {round(dist, 1)} mi")
|
||||
else:
|
||||
lines.append(f"{i}. {inst}")
|
||||
|
||||
return "\n".join(lines)
|
||||
22
lib/auth.py
22
lib/auth.py
|
|
@ -1,22 +0,0 @@
|
|||
"""
|
||||
RECON Auth Helper — extract user identity from Authentik forward-auth headers.
|
||||
"""
|
||||
from functools import wraps
|
||||
from flask import request, jsonify
|
||||
|
||||
|
||||
def get_user_id():
|
||||
"""Return X-Authentik-Username or None."""
|
||||
return request.headers.get('X-Authentik-Username')
|
||||
|
||||
|
||||
def require_auth(f):
|
||||
"""Decorator: 401 if no Authentik auth header."""
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
user_id = get_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
request.user_id = user_id
|
||||
return f(*args, **kwargs)
|
||||
return wrapper
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
"""
|
||||
Deployment profile loader.
|
||||
|
||||
Reads RECON_PROFILE env var (default: "home"), loads the matching YAML
|
||||
from config/profiles/<profile>.yaml, and caches the parsed dict in memory.
|
||||
|
||||
Exposes get_deployment_config() as the in-process accessor for the profile.
|
||||
|
||||
Note: its former consumers (the /api/landclass gate, google_places,
|
||||
place_detail, offroute/router) were all extracted to navi-* services or removed
|
||||
across cleanups #4–#6/#27 — recon has no remaining caller of
|
||||
get_deployment_config() today; the module is retained per cleanup #1.
|
||||
(The former /api/config HTTP endpoint that served this dict to the frontend was
|
||||
removed once navi-config (:8422) took over that route.)
|
||||
"""
|
||||
import os
|
||||
import yaml
|
||||
from .utils import setup_logging
|
||||
|
||||
logger = setup_logging('recon.deployment_config')
|
||||
|
||||
_config_cache = None
|
||||
|
||||
|
||||
def load_deployment_config():
|
||||
"""Load and cache the deployment profile. Called once at import time."""
|
||||
global _config_cache
|
||||
|
||||
profile = os.environ.get('RECON_PROFILE', 'home')
|
||||
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config', 'profiles')
|
||||
config_path = os.path.join(config_dir, f'{profile}.yaml')
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
raise FileNotFoundError(
|
||||
f"Deployment profile '{profile}' not found at {config_path}. "
|
||||
f"Available profiles: {', '.join(f.replace('.yaml','') for f in os.listdir(config_dir) if f.endswith('.yaml'))}"
|
||||
)
|
||||
|
||||
with open(config_path, 'r') as f:
|
||||
_config_cache = yaml.safe_load(f)
|
||||
|
||||
logger.info(f"Loaded deployment profile: {profile} ({_config_cache.get('region_name', 'unknown')})")
|
||||
return _config_cache
|
||||
|
||||
|
||||
def get_deployment_config():
|
||||
"""Return the cached deployment config dict."""
|
||||
if _config_cache is None:
|
||||
load_deployment_config()
|
||||
return _config_cache
|
||||
|
||||
|
||||
# Load on import so startup fails fast if profile is missing
|
||||
load_deployment_config()
|
||||
|
|
@ -21,7 +21,6 @@ Config: processing.extract_workers, processing.max_pdf_size_mb,
|
|||
processing.extract_timeout, processing.page_timeout
|
||||
"""
|
||||
import base64
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
|
|
@ -100,40 +99,6 @@ def _is_transient(error_str):
|
|||
return any(sig in s for sig in transient_signals)
|
||||
|
||||
|
||||
def _text_quality_ok(text, min_length=50):
|
||||
"""Check if extracted text meets quality thresholds.
|
||||
|
||||
Beyond the basic length check, validates:
|
||||
- Word-boundary ratio: at least 60% of tokens should be real words (2+ alpha chars)
|
||||
- Concatenation ratio: lowercase-immediately-followed-by-uppercase shouldn't exceed 10% of word count
|
||||
|
||||
Returns True if text passes all checks.
|
||||
"""
|
||||
text = text.strip()
|
||||
if len(text) < min_length:
|
||||
return False
|
||||
|
||||
words = text.split()
|
||||
if not words:
|
||||
return False
|
||||
|
||||
# Word-like ratio: tokens with 2+ alphabetic characters
|
||||
word_like = sum(1 for w in words if len(re.findall(r'[a-zA-Z]', w)) >= 2)
|
||||
word_ratio = word_like / len(words)
|
||||
if word_ratio < 0.60:
|
||||
return False
|
||||
|
||||
# Concatenation detector: lowercase immediately followed by uppercase
|
||||
# Filter out common camelCase patterns in code (short tokens)
|
||||
concat_hits = len(re.findall(r'[a-z][A-Z]', text))
|
||||
concat_ratio = concat_hits / len(words) if words else 0
|
||||
if concat_ratio > 0.10:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def _render_page_to_png(pdf_path, page_num_1indexed, dpi=200, timeout=30):
|
||||
"""Render a single PDF page to PNG bytes using pdftoppm.
|
||||
|
||||
|
|
@ -259,7 +224,7 @@ def _extract_page_without_reader(pdf_path, page_num_0indexed, page_timeout=30):
|
|||
# Method 1: pdftotext (poppler)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pdftotext', '-layout', '-f', str(page_num_0indexed + 1),
|
||||
['pdftotext', '-f', str(page_num_0indexed + 1),
|
||||
'-l', str(page_num_0indexed + 1), pdf_path, '-'],
|
||||
capture_output=True, text=True, timeout=page_timeout
|
||||
)
|
||||
|
|
@ -268,7 +233,7 @@ def _extract_page_without_reader(pdf_path, page_num_0indexed, page_timeout=30):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if _text_quality_ok(text):
|
||||
if len(text.strip()) >= 50:
|
||||
return text, 'pdftotext'
|
||||
|
||||
# Method 2: pdftoppm + Tesseract OCR
|
||||
|
|
@ -293,7 +258,7 @@ def _extract_page_without_reader(pdf_path, page_num_0indexed, page_timeout=30):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if _text_quality_ok(text):
|
||||
if len(text.strip()) >= 50:
|
||||
return text, 'tesseract'
|
||||
|
||||
# Method 3: Gemini Vision (last resort)
|
||||
|
|
@ -311,26 +276,8 @@ def _extract_page_without_reader(pdf_path, page_num_0indexed, page_timeout=30):
|
|||
# ── Core extraction functions ──
|
||||
|
||||
def _pypdf2_extract(reader, page_num):
|
||||
"""Extract text from a PyPDF2 page object. Runs inside a thread for timeout.
|
||||
|
||||
Tries default extraction first (space_width=200). If quality check fails,
|
||||
retries with space_width=100 which better detects word boundaries in
|
||||
tightly-kerned PDFs (common in Haynes/workshop manuals).
|
||||
|
||||
Note: PyPDF2 3.0.1 does not support layout=True. The space_width parameter
|
||||
controls word-boundary detection tolerance. Lower values = more aggressive
|
||||
space insertion between characters.
|
||||
"""
|
||||
text = reader.pages[page_num].extract_text() or ''
|
||||
if _text_quality_ok(text):
|
||||
return text
|
||||
|
||||
# Retry with tighter word-boundary detection
|
||||
text_tight = reader.pages[page_num].extract_text(space_width=100.0) or ''
|
||||
if len(text_tight.strip()) >= len(text.strip()):
|
||||
return text_tight
|
||||
|
||||
return text
|
||||
"""Extract text from a PyPDF2 page object. Runs inside a thread for timeout."""
|
||||
return reader.pages[page_num].extract_text() or ''
|
||||
|
||||
|
||||
def extract_text_from_page(reader, page_num, pdf_path, page_timeout=30):
|
||||
|
|
@ -355,13 +302,13 @@ def extract_text_from_page(reader, page_num, pdf_path, page_timeout=30):
|
|||
except Exception:
|
||||
text = ''
|
||||
|
||||
if _text_quality_ok(text):
|
||||
if len(text.strip()) >= 50:
|
||||
return text, 'pypdf2'
|
||||
|
||||
# Method 2: pdftotext via subprocess (inherently timeout-safe)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pdftotext', '-layout', '-f', str(page_num + 1), '-l', str(page_num + 1), pdf_path, '-'],
|
||||
['pdftotext', '-f', str(page_num + 1), '-l', str(page_num + 1), pdf_path, '-'],
|
||||
capture_output=True, text=True, timeout=page_timeout
|
||||
)
|
||||
if result.returncode == 0 and len(result.stdout.strip()) > len(text.strip()):
|
||||
|
|
@ -369,7 +316,7 @@ def extract_text_from_page(reader, page_num, pdf_path, page_timeout=30):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if _text_quality_ok(text):
|
||||
if len(text.strip()) >= 50:
|
||||
return text, 'pdftotext'
|
||||
|
||||
# Method 3: pdftoppm + Tesseract OCR
|
||||
|
|
@ -393,7 +340,7 @@ def extract_text_from_page(reader, page_num, pdf_path, page_timeout=30):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if _text_quality_ok(text):
|
||||
if len(text.strip()) >= 50:
|
||||
return text, 'tesseract'
|
||||
|
||||
# Method 4: Gemini Vision (last resort — costs API calls but handles scanned docs)
|
||||
|
|
|
|||
228
lib/netsyms.py
228
lib/netsyms.py
|
|
@ -1,228 +0,0 @@
|
|||
"""
|
||||
RECON Netsyms AddressDatabase2025 — SQLite-backed US+CA address lookup.
|
||||
|
||||
Provides 159.78M geocoded addresses as tier-2 between address book
|
||||
(exact named locations) and Photon (full-text global geocoding).
|
||||
|
||||
Database: /mnt/nav/addresses/AddressDatabase2025.sqlite (read-only)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
from .utils import setup_logging
|
||||
|
||||
logger = setup_logging('recon.netsyms')
|
||||
|
||||
_DB_PATH = '/mnt/nav/addresses/AddressDatabase2025.sqlite'
|
||||
|
||||
_conn = None
|
||||
_lock = threading.Lock()
|
||||
_cached_row_count = None
|
||||
|
||||
# US states + DC + territories, CA provinces, for free-text parsing
|
||||
_STATE_CODES = {
|
||||
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
|
||||
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
|
||||
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
|
||||
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
|
||||
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY',
|
||||
'DC', 'PR', 'VI', 'GU', 'AS', 'MP',
|
||||
# Canadian provinces
|
||||
'AB', 'BC', 'MB', 'NB', 'NL', 'NS', 'NT', 'NU', 'ON', 'PE',
|
||||
'QC', 'SK', 'YT',
|
||||
}
|
||||
|
||||
_NUMBER_RE = re.compile(r'^(\d+[\w-]*)(.*)$')
|
||||
|
||||
|
||||
def _get_conn():
|
||||
"""Lazy-open a read-only SQLite connection."""
|
||||
global _conn
|
||||
if _conn is not None:
|
||||
return _conn
|
||||
with _lock:
|
||||
if _conn is not None:
|
||||
return _conn
|
||||
uri = f'file:{_DB_PATH}?mode=ro'
|
||||
_conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
|
||||
_conn.row_factory = sqlite3.Row
|
||||
logger.info("Netsyms DB opened: %s", _DB_PATH)
|
||||
return _conn
|
||||
|
||||
|
||||
def _row_to_dict(row):
|
||||
"""Convert a sqlite3.Row to a plain dict with lat/lon keys."""
|
||||
return {
|
||||
'zipcode': row['zipcode'],
|
||||
'number': row['number'],
|
||||
'street': row['street'],
|
||||
'street2': row['street2'],
|
||||
'city': row['city'],
|
||||
'state': row['state'],
|
||||
'plus4': row['plus4'],
|
||||
'country': row['country'],
|
||||
'lat': float(row['latitude']),
|
||||
'lon': float(row['longitude']),
|
||||
'source': row['source'],
|
||||
}
|
||||
|
||||
|
||||
def lookup_by_street(number, street, city=None, state=None,
|
||||
zipcode=None, country=None, limit=20):
|
||||
"""Match on number + street, with optional qualifiers."""
|
||||
conn = _get_conn()
|
||||
clauses = ['number = ?', 'street = ?']
|
||||
params = [str(number).strip().upper(), street.strip().upper()]
|
||||
|
||||
if city:
|
||||
clauses.append('city = ?')
|
||||
params.append(city.strip().upper())
|
||||
if state:
|
||||
clauses.append('state = ?')
|
||||
params.append(state.strip().upper())
|
||||
if zipcode:
|
||||
clauses.append('zipcode = ?')
|
||||
params.append(zipcode.strip())
|
||||
if country:
|
||||
clauses.append('country = ?')
|
||||
params.append(country.strip().upper())
|
||||
|
||||
sql = f"SELECT * FROM addresses WHERE {' AND '.join(clauses)} LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
with _lock:
|
||||
try:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
except sqlite3.Error as e:
|
||||
logger.warning("Netsyms lookup_by_street error: %s", e)
|
||||
return []
|
||||
|
||||
results = [_row_to_dict(r) for r in rows]
|
||||
logger.debug("lookup_by_street(%s, %s, city=%s, state=%s) → %d results",
|
||||
number, street, city, state, len(results))
|
||||
return results
|
||||
|
||||
|
||||
def lookup_free_text(query, country_hint=None):
|
||||
"""Parse a free-text address and look it up."""
|
||||
q = query.strip()
|
||||
if not q:
|
||||
return []
|
||||
|
||||
# Strip trailing zipcode if present
|
||||
zipcode = None
|
||||
zip_match = re.search(r'\b(\d{5})\s*$', q)
|
||||
if zip_match:
|
||||
zipcode = zip_match.group(1)
|
||||
q = q[:zip_match.start()].strip().rstrip(',').strip()
|
||||
|
||||
# Strip trailing state
|
||||
tokens = re.split(r'[,\s]+', q)
|
||||
tokens = [t for t in tokens if t]
|
||||
if not tokens:
|
||||
return []
|
||||
|
||||
state = None
|
||||
if len(tokens) >= 2 and tokens[-1].upper() in _STATE_CODES:
|
||||
state = tokens[-1].upper()
|
||||
tokens = tokens[:-1]
|
||||
|
||||
# Leading digits → number
|
||||
number = None
|
||||
if tokens and re.match(r'^\d', tokens[0]):
|
||||
number = tokens[0]
|
||||
tokens = tokens[1:]
|
||||
|
||||
if not tokens:
|
||||
# Only a number, or empty — try zipcode if we have one
|
||||
if zipcode:
|
||||
return lookup_by_zipcode(zipcode, limit=20)
|
||||
return []
|
||||
|
||||
# If state was found and we have 2+ tokens remaining, last token is city
|
||||
city = None
|
||||
if state and len(tokens) >= 2:
|
||||
city = tokens[-1]
|
||||
tokens = tokens[:-1]
|
||||
|
||||
street = ' '.join(tokens)
|
||||
|
||||
if number:
|
||||
results = lookup_by_street(number, street, city=city, state=state,
|
||||
zipcode=zipcode, country=country_hint)
|
||||
if results:
|
||||
logger.debug("lookup_free_text(%r) → %d results via street match",
|
||||
query, len(results))
|
||||
return results
|
||||
|
||||
# Fallback: try zipcode only if available
|
||||
if zipcode:
|
||||
return lookup_by_zipcode(zipcode, limit=20)
|
||||
|
||||
logger.debug("lookup_free_text(%r) → 0 results", query)
|
||||
return []
|
||||
|
||||
|
||||
def lookup_by_zipcode(zipcode, limit=100):
|
||||
"""Direct zipcode lookup."""
|
||||
conn = _get_conn()
|
||||
sql = "SELECT * FROM addresses WHERE zipcode = ? LIMIT ?"
|
||||
params = [zipcode.strip(), limit]
|
||||
|
||||
with _lock:
|
||||
try:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
except sqlite3.Error as e:
|
||||
logger.warning("Netsyms lookup_by_zipcode error: %s", e)
|
||||
return []
|
||||
|
||||
results = [_row_to_dict(r) for r in rows]
|
||||
logger.debug("lookup_by_zipcode(%s) → %d results", zipcode, len(results))
|
||||
return results
|
||||
|
||||
|
||||
def health():
|
||||
"""Health check with cached row count."""
|
||||
global _cached_row_count
|
||||
|
||||
try:
|
||||
file_size = os.path.getsize(_DB_PATH)
|
||||
except OSError:
|
||||
return {'ok': False, 'row_count': 0, 'file_size_bytes': 0,
|
||||
'indexed_countries': []}
|
||||
|
||||
try:
|
||||
conn = _get_conn()
|
||||
except Exception:
|
||||
return {'ok': False, 'row_count': 0, 'file_size_bytes': file_size,
|
||||
'indexed_countries': []}
|
||||
|
||||
if _cached_row_count is None:
|
||||
with _lock:
|
||||
if _cached_row_count is None:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM addresses"
|
||||
).fetchone()
|
||||
_cached_row_count = row['cnt']
|
||||
except sqlite3.Error:
|
||||
_cached_row_count = 0
|
||||
|
||||
with _lock:
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT country FROM addresses"
|
||||
).fetchall()
|
||||
countries = sorted(r['country'] for r in rows)
|
||||
except sqlite3.Error:
|
||||
countries = []
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'row_count': _cached_row_count,
|
||||
'file_size_bytes': file_size,
|
||||
'indexed_countries': countries,
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
"""
|
||||
RECON Netsyms API — Flask Blueprint.
|
||||
|
||||
GET /api/netsyms/lookup?q=<free text>&country=<optional>
|
||||
GET /api/netsyms/health
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from . import netsyms
|
||||
from .utils import setup_logging
|
||||
|
||||
logger = setup_logging('recon.netsyms_api')
|
||||
|
||||
netsyms_bp = Blueprint('netsyms', __name__)
|
||||
|
||||
|
||||
@netsyms_bp.route('/api/netsyms/lookup')
|
||||
def api_netsyms_lookup():
|
||||
q = request.args.get('q', '').strip()
|
||||
if not q:
|
||||
return jsonify({'error': 'Missing q parameter'}), 400
|
||||
|
||||
country = request.args.get('country', '').strip() or None
|
||||
results = netsyms.lookup_free_text(q, country_hint=country)
|
||||
return jsonify({'results': results, 'count': len(results), 'query': q})
|
||||
|
||||
|
||||
@netsyms_bp.route('/api/netsyms/health')
|
||||
def api_netsyms_health():
|
||||
return jsonify(netsyms.health())
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for Netsyms address database module."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure the lib directory is importable
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from lib import netsyms
|
||||
|
||||
|
||||
def test_lookup_by_street_lowercase():
|
||||
results = netsyms.lookup_by_street("214", "North St", city="Filer", state="ID")
|
||||
assert len(results) >= 1, f"Expected at least 1 result, got {len(results)}"
|
||||
r = results[0]
|
||||
assert abs(r['lat'] - 42.5736) < 0.01, f"Lat mismatch: {r['lat']}"
|
||||
assert abs(r['lon'] - (-114.6066)) < 0.01, f"Lon mismatch: {r['lon']}"
|
||||
print(" PASS: lookup_by_street (lowercase)")
|
||||
|
||||
|
||||
def test_lookup_by_street_uppercase():
|
||||
results = netsyms.lookup_by_street("214", "NORTH ST", city="FILER", state="ID")
|
||||
assert len(results) >= 1, f"Expected at least 1 result, got {len(results)}"
|
||||
r = results[0]
|
||||
assert abs(r['lat'] - 42.5736) < 0.01, f"Lat mismatch: {r['lat']}"
|
||||
print(" PASS: lookup_by_street (uppercase)")
|
||||
|
||||
|
||||
def test_lookup_nonexistent():
|
||||
results = netsyms.lookup_by_street("999999", "Nonexistent Rd",
|
||||
city="Filer", state="ID")
|
||||
assert results == [], f"Expected empty list, got {len(results)} results"
|
||||
print(" PASS: lookup_by_street (nonexistent)")
|
||||
|
||||
|
||||
def test_free_text_with_commas():
|
||||
results = netsyms.lookup_free_text("214 North St, Filer, ID")
|
||||
assert len(results) >= 1, f"Expected at least 1 result, got {len(results)}"
|
||||
r = results[0]
|
||||
assert r['city'] == 'FILER', f"City mismatch: {r['city']}"
|
||||
assert r['state'] == 'ID', f"State mismatch: {r['state']}"
|
||||
print(" PASS: lookup_free_text (commas)")
|
||||
|
||||
|
||||
def test_free_text_no_commas():
|
||||
results = netsyms.lookup_free_text("214 North St Filer ID")
|
||||
assert len(results) >= 1, f"Expected at least 1 result, got {len(results)}"
|
||||
r = results[0]
|
||||
assert r['state'] == 'ID', f"State mismatch: {r['state']}"
|
||||
print(" PASS: lookup_free_text (no commas)")
|
||||
|
||||
|
||||
def test_lookup_by_zipcode():
|
||||
results = netsyms.lookup_by_zipcode("83328", limit=5)
|
||||
assert len(results) == 5, f"Expected 5 results, got {len(results)}"
|
||||
for r in results:
|
||||
assert r['zipcode'] == '83328', f"Zipcode mismatch: {r['zipcode']}"
|
||||
print(" PASS: lookup_by_zipcode")
|
||||
|
||||
|
||||
def test_health():
|
||||
h = netsyms.health()
|
||||
assert h['ok'] is True, f"Health not OK: {h}"
|
||||
assert h['row_count'] >= 159_000_000, f"Row count too low: {h['row_count']}"
|
||||
assert 'US' in h['indexed_countries'], f"US not in countries: {h['indexed_countries']}"
|
||||
assert 'CA' in h['indexed_countries'], f"CA not in countries: {h['indexed_countries']}"
|
||||
print(" PASS: health")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("Running Netsyms tests...")
|
||||
test_lookup_by_street_lowercase()
|
||||
test_lookup_by_street_uppercase()
|
||||
test_lookup_nonexistent()
|
||||
test_free_text_with_commas()
|
||||
test_free_text_no_commas()
|
||||
test_lookup_by_zipcode()
|
||||
test_health()
|
||||
print("All tests passed.")
|
||||
|
|
@ -77,73 +77,10 @@ def _text_hash(text):
|
|||
return hashlib.md5(text.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _flatten_table(table_el):
|
||||
"""Convert a <table> element to pipe-delimited text.
|
||||
|
||||
Each <tr> becomes a row with cells joined by ' | '.
|
||||
Returns the formatted table as a string with blank lines around it.
|
||||
"""
|
||||
rows = []
|
||||
for tr in table_el.iter('tr'):
|
||||
cells = []
|
||||
for cell in tr:
|
||||
if cell.tag in ('td', 'th'):
|
||||
cell_text = (cell.text_content() or '').strip()
|
||||
# Collapse internal whitespace in each cell
|
||||
cell_text = re.sub(r'\s+', ' ', cell_text)
|
||||
if cell_text:
|
||||
cells.append(cell_text)
|
||||
if cells:
|
||||
rows.append(' | '.join(cells))
|
||||
if not rows:
|
||||
return ''
|
||||
return '\n'.join(rows)
|
||||
|
||||
|
||||
def _preprocess_tree(doc):
|
||||
"""Pre-process HTML tree to add delimiters before text_content() flattens it.
|
||||
|
||||
Handles: <table>, <br>, <li>, <dt>, <dd> -- elements that lxml's
|
||||
text_content() would concatenate without any separators.
|
||||
"""
|
||||
from lxml import etree
|
||||
|
||||
# 1. Replace <table> elements with their pipe-delimited text
|
||||
for table in list(doc.iter('table')):
|
||||
formatted = _flatten_table(table)
|
||||
if formatted:
|
||||
replacement = etree.Element('div')
|
||||
replacement.text = '\n\n' + formatted + '\n\n'
|
||||
parent = table.getparent()
|
||||
if parent is not None:
|
||||
parent.replace(table, replacement)
|
||||
else:
|
||||
table.drop_tree()
|
||||
|
||||
# 2. <br> -> inject newline
|
||||
for br in list(doc.iter('br')):
|
||||
br.tail = '\n' + (br.tail or '')
|
||||
|
||||
# 3. <li> -> inject newline + "- " prefix
|
||||
for li in list(doc.iter('li')):
|
||||
li.text = '- ' + (li.text or '')
|
||||
li.tail = '\n' + (li.tail or '')
|
||||
|
||||
# 4. <dt> -> inject newline before
|
||||
for dt in list(doc.iter('dt')):
|
||||
dt.tail = '\n' + (dt.tail or '')
|
||||
|
||||
# 5. <dd> -> inject newline + indent
|
||||
for dd in list(doc.iter('dd')):
|
||||
dd.text = ' ' + (dd.text or '')
|
||||
dd.tail = '\n' + (dd.tail or '')
|
||||
|
||||
|
||||
def _html_to_text(html_bytes):
|
||||
"""Convert HTML bytes to clean text via lxml.
|
||||
|
||||
Strips nav, footer, script, style elements. Decodes entities.
|
||||
Pre-processes tables, lists, and line breaks for proper delimiters.
|
||||
Normalizes whitespace.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -156,9 +93,6 @@ def _html_to_text(html_bytes):
|
|||
for el in doc.iter(tag):
|
||||
el.drop_tree()
|
||||
|
||||
# Pre-process tree: tables -> pipe-delimited, br -> newlines, li -> dashes
|
||||
_preprocess_tree(doc)
|
||||
|
||||
# Extract text
|
||||
text = doc.text_content()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
"""Semantic query router for Aurora.
|
||||
|
||||
Classifies user queries into routes (nav_route, nav_reverse_geocode,
|
||||
direct_answer, rag_search) by comparing query embeddings against
|
||||
pre-computed route centroids from example queries.
|
||||
|
||||
TEI endpoint: http://100.64.0.14:8090/embed (cortex via Tailscale)
|
||||
"""
|
||||
|
||||
import math
|
||||
import threading
|
||||
import requests
|
||||
|
||||
# ── Route examples ────────────────────────────────────────────────────────────
|
||||
ROUTE_EXAMPLES = {
|
||||
"nav_route": [
|
||||
"how do I get to Boise",
|
||||
"directions to Twin Falls",
|
||||
"how do I get from Buhl to Boise",
|
||||
"drive from Jerome to Sun Valley",
|
||||
"route from Boise to McCall",
|
||||
"what's the fastest way to Sun Valley",
|
||||
"how far is it to Twin Falls",
|
||||
"take me to Shoshone",
|
||||
"navigate to the airport",
|
||||
"how do I drive to Salt Lake City",
|
||||
"walking directions to the park",
|
||||
"bike route to downtown",
|
||||
],
|
||||
"nav_reverse_geocode": [
|
||||
"what town is at 42.5, -114.7",
|
||||
"where am I right now",
|
||||
"what is at coordinates 43.6, -116.2",
|
||||
"what location is 42.574, -114.607",
|
||||
"where is this place 44.0, -114.3",
|
||||
"what city is near 42.7, -114.5",
|
||||
"reverse geocode 43.0, -115.0",
|
||||
"what's at this location 42.9, -114.8",
|
||||
],
|
||||
"direct_answer": [
|
||||
"hello",
|
||||
"hey aurora",
|
||||
"good morning",
|
||||
"thanks",
|
||||
"thank you",
|
||||
"what's your name",
|
||||
"who are you",
|
||||
"tell me a joke",
|
||||
"how are you",
|
||||
"hi there",
|
||||
],
|
||||
"rag_search": [
|
||||
"what does the survival manual say about water",
|
||||
"how to purify water in the field",
|
||||
"how to treat a gunshot wound",
|
||||
"what is the ranger handbook chapter on patrolling",
|
||||
"field manual water purification",
|
||||
"how to build a shelter in the wilderness",
|
||||
"tactical combat casualty care procedures",
|
||||
"what does FM 21-76 say about fire starting",
|
||||
],
|
||||
}
|
||||
|
||||
# ── Module-level cache ────────────────────────────────────────────────────────
|
||||
_ROUTE_CENTROIDS: dict | None = None
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _embed_batch(texts: list[str], tei_url: str) -> list[list[float]]:
|
||||
"""Embed a batch of texts via TEI."""
|
||||
resp = requests.post(tei_url, json={"inputs": texts}, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _compute_centroid(vectors: list[list[float]]) -> list[float]:
|
||||
"""Element-wise mean of vectors."""
|
||||
n = len(vectors)
|
||||
dim = len(vectors[0])
|
||||
centroid = [0.0] * dim
|
||||
for vec in vectors:
|
||||
for i in range(dim):
|
||||
centroid[i] += vec[i]
|
||||
for i in range(dim):
|
||||
centroid[i] /= n
|
||||
return centroid
|
||||
|
||||
|
||||
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
"""Cosine similarity between two vectors (pure Python)."""
|
||||
dot = 0.0
|
||||
norm_a = 0.0
|
||||
norm_b = 0.0
|
||||
for i in range(len(a)):
|
||||
dot += a[i] * b[i]
|
||||
norm_a += a[i] * a[i]
|
||||
norm_b += b[i] * b[i]
|
||||
denom = math.sqrt(norm_a) * math.sqrt(norm_b)
|
||||
if denom == 0:
|
||||
return 0.0
|
||||
return dot / denom
|
||||
|
||||
|
||||
def _ensure_centroids(tei_url: str) -> dict[str, list[float]]:
|
||||
"""Lazy-init: embed all examples in one batch, compute centroids, cache."""
|
||||
global _ROUTE_CENTROIDS
|
||||
if _ROUTE_CENTROIDS is not None:
|
||||
return _ROUTE_CENTROIDS
|
||||
|
||||
with _LOCK:
|
||||
if _ROUTE_CENTROIDS is not None:
|
||||
return _ROUTE_CENTROIDS
|
||||
|
||||
# Flatten all examples into one batch
|
||||
all_texts = []
|
||||
route_ranges: dict[str, tuple[int, int]] = {}
|
||||
offset = 0
|
||||
for route, examples in ROUTE_EXAMPLES.items():
|
||||
route_ranges[route] = (offset, offset + len(examples))
|
||||
all_texts.extend(examples)
|
||||
offset += len(examples)
|
||||
|
||||
all_vectors = _embed_batch(all_texts, tei_url)
|
||||
|
||||
centroids = {}
|
||||
for route, (start, end) in route_ranges.items():
|
||||
centroids[route] = _compute_centroid(all_vectors[start:end])
|
||||
|
||||
_ROUTE_CENTROIDS = centroids
|
||||
return _ROUTE_CENTROIDS
|
||||
|
||||
|
||||
def classify(
|
||||
query: str,
|
||||
tei_url: str = "http://100.64.0.14:8090/embed",
|
||||
threshold: float = 0.45,
|
||||
) -> tuple[str, float]:
|
||||
"""Classify a query into a route.
|
||||
|
||||
Returns (route_name, confidence). If no route exceeds the threshold,
|
||||
returns ("rag_search", best_score) as the safe default.
|
||||
"""
|
||||
centroids = _ensure_centroids(tei_url)
|
||||
|
||||
# Embed the query
|
||||
vecs = _embed_batch([query], tei_url)
|
||||
query_vec = vecs[0]
|
||||
|
||||
# Compare against all centroids
|
||||
best_route = "rag_search"
|
||||
best_score = 0.0
|
||||
for route, centroid in centroids.items():
|
||||
sim = _cosine_similarity(query_vec, centroid)
|
||||
if sim > best_score:
|
||||
best_score = sim
|
||||
best_route = route
|
||||
|
||||
if best_score < threshold:
|
||||
return ("rag_search", best_score)
|
||||
|
||||
return (best_route, best_score)
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test suite for the semantic query router."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from lib.query_router import classify
|
||||
|
||||
TEST_QUERIES = [
|
||||
("how do I get from Buhl to Boise", "nav_route"),
|
||||
("what does the survival manual say about water", "rag_search"),
|
||||
("what town is at 42.5, -114.7", "nav_reverse_geocode"),
|
||||
("hey aurora", "direct_answer"),
|
||||
("what's the fastest way to Sun Valley", "nav_route"),
|
||||
("how to purify water in the field", "rag_search"),
|
||||
("good morning", "direct_answer"),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
print("Query Router Test Suite")
|
||||
print("=" * 70)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for query, expected in TEST_QUERIES:
|
||||
route, confidence = classify(query)
|
||||
status = "PASS" if route == expected else "FAIL"
|
||||
if status == "PASS":
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(f" [{status}] {query!r}")
|
||||
print(f" → {route} ({confidence:.3f}) expected={expected}")
|
||||
|
||||
print("=" * 70)
|
||||
print(f"Results: {passed}/{passed + failed} passed")
|
||||
if failed:
|
||||
print(f" {failed} FAILED")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(" All tests passed!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -3,7 +3,6 @@ anyio==4.12.1
|
|||
babel==2.18.0
|
||||
beautifulsoup4==4.14.3
|
||||
blinker==1.9.0
|
||||
cachetools==7.1.3
|
||||
certifi==2026.1.4
|
||||
cffi==2.0.0
|
||||
charset-normalizer==3.4.4
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue