Skip to content

Commit 81673c6

Browse files
authored
Merge pull request #406 from OpenGeoscience/first-hf-task
Ask Qwen Task
2 parents a0383ec + 959c5ef commit 81673c6

11 files changed

Lines changed: 293 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ tasks = [
7575
"pyvips==3.1.1.8.18.1",
7676
"uvdat-flood-sim[large-image-writer]==1.0.4",
7777
"xdg-base-dirs==6.0.2",
78+
"huggingface-hub==1.14.0",
7879
]
7980

8081
[dependency-groups]

terraform/django.tf

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@ module "django" {
2020
ec2_worker_ssh_public_key = file("${path.module}/ssh-key.pub")
2121

2222
additional_django_vars = {
23-
DJANGO_UVDAT_WEB_URL = "https://www.geodatalytics.kitware.com/"
24-
DJANGO_DATABASE_POOL_MAX_SIZE = "12"
25-
DJANGO_SENTRY_DSN = "https://5302701c88f1fa6ec056e0c269071191@o267860.ingest.us.sentry.io/4510620385804288"
23+
DJANGO_UVDAT_WEB_URL = "https://www.geodatalytics.kitware.com/"
24+
DJANGO_DATABASE_POOL_MAX_SIZE = "12"
25+
DJANGO_SENTRY_DSN = "https://5302701c88f1fa6ec056e0c269071191@o267860.ingest.us.sentry.io/4510620385804288"
26+
DJANGO_UVDAT_HF_NAMESPACE = "Kitware"
27+
DJANGO_UVDAT_HF_ENDPOINT_NAMES = "qwen=qwen3-5-9b-gguf-ulh,"
28+
}
29+
additional_sensitive_django_vars = {
30+
DJANGO_UVDAT_HF_TOKEN = var.DJANGO_UVDAT_HF_TOKEN
2631
}
2732
django_cors_allowed_origins = [
2833
# Can't make this use "aws_route53_record.www.fqdn" because of a circular dependency

terraform/variables.tf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,9 @@ variable "SENTRY_AUTH_TOKEN" {
33
nullable = false
44
sensitive = true
55
}
6+
7+
variable "DJANGO_UVDAT_HF_TOKEN" {
8+
type = string
9+
nullable = true
10+
sensitive = true
11+
}

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

uvdat/core/tasks/analytics/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .flood_network_failure import FloodNetworkFailure
77
from .flood_simulation import FloodSimulation
88
from .geoai_segmentation import GeoAISegmentation
9+
from .imagery_ask_qwen import ImageryAskQwen
910
from .network_recovery import NetworkRecovery
1011
from .uncertainty_quantification import UncertaintyQuantification
1112

@@ -15,6 +16,7 @@
1516
analysis_types: list[type[AnalysisType]] = [
1617
FloodSimulation,
1718
FloodNetworkFailure,
19+
ImageryAskQwen,
1820
NetworkRecovery,
1921
UncertaintyQuantification,
2022
GeoAISegmentation,
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
5+
from celery import shared_task
6+
from django.conf import settings
7+
from django_large_image import utilities
8+
import large_image
9+
10+
from uvdat.core.models import RasterData, TaskResult
11+
12+
from .analysis_type import AnalysisInputError, AnalysisTask, AnalysisType
13+
14+
MODEL_CARD_URL = "https://huggingface.co/unsloth/Qwen3.5-9B-GGUF"
15+
SYSTEM_PROMPT = (
16+
"You are an urban planning and geospatial analysis expert specializing in "
17+
"land use patterns, hydrology, transportation networks, and municipal policy. "
18+
"Analyze the provided imagery to answer the user's question. In your answer, "
19+
"assume that the user is also a geospatial analyst with the same expertise."
20+
)
21+
TOKEN_RANGE = {"min": 1000, "max": 10000, "step": 1000}
22+
MAX_PROMPT_LENGTH = 4000
23+
THUMBNAIL_SIZE = 4000
24+
MAX_STARTUP_WAIT = 300
25+
26+
27+
class ImageryAskQwen(AnalysisType):
28+
def __init__(self):
29+
super().__init__()
30+
self.name = "Imagery: Ask Qwen"
31+
self.description = "Select an imagery layer and ask Qwen 3.5 about it."
32+
self.details = (
33+
"Inferencing with unsloth/Qwen3.5-9B-GGUF provided by a "
34+
"Kitware-hosted Huggingface Inference Endpoint. "
35+
f"See the model card at {MODEL_CARD_URL}. "
36+
"Responses may cut off mid-sentence if max_tokens is reached."
37+
)
38+
self.db_value = "imagery_ask_qwen"
39+
self.input_types = {
40+
"imagery": "RasterData",
41+
"text_prompt": "string",
42+
"max_tokens": "number",
43+
}
44+
self.output_types = {
45+
"response": "markdown",
46+
}
47+
self.attribution = "Unsloth AI, Kitware Inc."
48+
49+
@classmethod
50+
def is_enabled(cls) -> bool:
51+
return (
52+
settings.UVDAT_ENABLE_IMAGERY_ASK_QWEN
53+
and settings.UVDAT_HF_TOKEN is not None
54+
and settings.UVDAT_HF_NAMESPACE is not None
55+
and settings.UVDAT_HF_ENDPOINT_NAMES.get("qwen") is not None
56+
)
57+
58+
def get_input_options(self):
59+
return {
60+
"imagery": RasterData.objects.filter(dataset__category="imagery"),
61+
"text_prompt": [],
62+
"max_tokens": [TOKEN_RANGE],
63+
}
64+
65+
def validate_inputs(self, inputs):
66+
super().validate_inputs(inputs)
67+
try:
68+
imagery = RasterData.objects.get(id=inputs.get("imagery"))
69+
except RasterData.DoesNotExist as e:
70+
err_msg = "Imagery raster does not exist."
71+
raise AnalysisInputError(err_msg) from e
72+
if imagery.dataset.category != "imagery":
73+
err_msg = 'Selected raster is not categorized as "imagery".'
74+
raise AnalysisInputError(err_msg)
75+
text_prompt = str(inputs.get("text_prompt"))
76+
if len(text_prompt) > MAX_PROMPT_LENGTH:
77+
err_msg = f"Prompt too long. Provide a prompt with <{MAX_PROMPT_LENGTH} characters."
78+
raise AnalysisInputError(err_msg)
79+
max_tokens = int(inputs.get("max_tokens"))
80+
if max_tokens < TOKEN_RANGE["min"] or max_tokens > TOKEN_RANGE["max"]:
81+
err_msg = f"max_tokens must be between {TOKEN_RANGE['min']} and {TOKEN_RANGE['max']}."
82+
raise AnalysisInputError(err_msg)
83+
84+
def run_task(self, *, project, **inputs):
85+
text_prompt = inputs.get("text_prompt")
86+
result = TaskResult.objects.create(
87+
name=text_prompt[:250],
88+
task_type=self.db_value,
89+
inputs=inputs,
90+
project=project,
91+
status="Initializing Task...",
92+
)
93+
imagery_ask_qwen.delay(result.id)
94+
return result
95+
96+
def finalize(self, result):
97+
pass
98+
99+
100+
@shared_task(base=AnalysisTask)
101+
def imagery_ask_qwen(result_id):
102+
# Only available with [tasks] extra
103+
from huggingface_hub import ( # noqa: PLC0415
104+
InferenceEndpointTimeoutError,
105+
get_inference_endpoint,
106+
)
107+
108+
result = TaskResult.objects.get(id=result_id)
109+
imagery = RasterData.objects.get(id=result.inputs.get("imagery"))
110+
text_prompt = result.inputs.get("text_prompt")
111+
max_tokens = int(result.inputs.get("max_tokens"))
112+
113+
result.write_status("Encoding imagery...")
114+
imagery_path = utilities.field_file_to_local_path(imagery.cloud_optimized_geotiff)
115+
src = large_image.open(imagery_path)
116+
thumbnail_bytes, _ = src.getThumbnail(THUMBNAIL_SIZE, THUMBNAIL_SIZE, encoding="PNG")
117+
thumbnail_b64 = base64.b64encode(thumbnail_bytes).decode("utf-8")
118+
thumbnail_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
119+
120+
result.write_status("Starting inference endpoint...")
121+
endpoint = get_inference_endpoint(
122+
name=settings.UVDAT_HF_ENDPOINT_NAMES["qwen"],
123+
namespace=settings.UVDAT_HF_NAMESPACE,
124+
token=settings.UVDAT_HF_TOKEN,
125+
)
126+
endpoint.resume()
127+
try:
128+
endpoint.wait(timeout=MAX_STARTUP_WAIT)
129+
except InferenceEndpointTimeoutError:
130+
result.write_error("Endpoint failed to start in 5 minutes. Try again later.")
131+
return
132+
133+
result.write_status("Sending question to Qwen...")
134+
messages = [
135+
{
136+
"role": "system",
137+
"content": SYSTEM_PROMPT,
138+
},
139+
{
140+
"role": "user",
141+
"content": [
142+
{"type": "image_url", "image_url": {"url": thumbnail_uri}},
143+
{"type": "text", "text": text_prompt},
144+
],
145+
},
146+
]
147+
148+
result.write_status("Awaiting Qwen's response...")
149+
chat = endpoint.client.chat_completion(
150+
model="unsloth/Qwen3.5-9B-GGUF",
151+
messages=messages,
152+
max_tokens=max_tokens,
153+
)
154+
response = ""
155+
for choice in chat.choices:
156+
if choice.finish_reason == "length":
157+
# max tokens exceeded, use reasoning content
158+
response += choice.message.reasoning_content
159+
else:
160+
response += choice.message.content
161+
result.write_outputs({"response": response})

uvdat/core/tests/test_analytics.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ def test_rest_list_analysis_types(user, authenticated_api_client, project):
1919
user.is_superuser = True
2020
user.save()
2121

22-
analysis_type_instances = [at() for at in analysis_types]
22+
analysis_type_instances = [at() for at in analysis_types if at.is_enabled()]
2323
resp = authenticated_api_client.get(f"/api/v1/analytics/project/{project.id}/types/")
2424
data = resp.json()
25+
2526
assert len(data) == len(analysis_type_instances)
2627
assert {type_info.get("name") for type_info in data} == {
2728
i.name for i in analysis_type_instances

uvdat/settings/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,10 @@
158158
}
159159

160160
UVDAT_WEB_URL: str = env.url("DJANGO_UVDAT_WEB_URL").geturl()
161+
UVDAT_HF_TOKEN: str | None = env.str("DJANGO_UVDAT_HF_TOKEN", default=None)
162+
UVDAT_HF_NAMESPACE: str | None = env.str("DJANGO_UVDAT_HF_NAMESPACE", default=None)
163+
UVDAT_HF_ENDPOINT_NAMES: dict = env.dict("DJANGO_UVDAT_HF_ENDPOINT_NAMES", default={})
164+
161165
UVDAT_ENABLE_FLOOD_SIMULATION: bool = env.bool("DJANGO_UVDAT_ENABLE_FLOOD_SIMULATION", default=True)
162166
UVDAT_ENABLE_FLOOD_NETWORK_FAILURE: bool = env.bool(
163167
"DJANGO_UVDAT_ENABLE_FLOOD_NETWORK_FAILURE", default=True
@@ -172,6 +176,7 @@
172176
UVDAT_ENABLE_UNCERTAINTY_QUANTIFICATION: bool = env.bool(
173177
"DJANGO_UVDAT_ENABLE_UNCERTAINTY_QUANTIFICATION", default=True
174178
)
179+
UVDAT_ENABLE_IMAGERY_ASK_QWEN: bool = env.bool("DJANGO_UVDAT_ENABLE_IMAGERY_ASK_QWEN", default=True)
175180

176181
logging.getLogger("pyvips").setLevel(logging.ERROR)
177182
logging.getLogger("rasterio").setLevel(logging.ERROR)

0 commit comments

Comments
 (0)