Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion hospexplorer/ask/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from django.contrib import admin
from ask.models import Conversation, TermsAcceptance, QARecord
from ask.models import Conversation, TermsAcceptance, QARecord, WebsiteResource


class QARecordInline(admin.TabularInline):
Expand Down Expand Up @@ -47,3 +47,28 @@ class QARecordAdmin(admin.ModelAdmin):
def truncated_question(self, obj):
return obj.question_text[:75] + "..." if len(obj.question_text) > 75 else obj.question_text
truncated_question.short_description = "Question"


@admin.register(WebsiteResource)
class WebsiteResourceAdmin(admin.ModelAdmin):
list_display = ("title", "url", "creator", "modified_at")
search_fields = ("title", "url")
readonly_fields = ("created_at", "modified_at", "creator", "modifier")
help_texts = {
"title": "A short name to identify this website resource.",
"description": "Optional details about what this website covers.",
"url": "The URL the LLM will use as context when answering questions.",
}

def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
for field_name, text in self.help_texts.items():
if field_name in form.base_fields:
form.base_fields[field_name].help_text = text
return form

def save_model(self, request, obj, form, change):
if not change:
obj.creator = request.user
obj.modifier = request.user
super().save_model(request, obj, form, change)
5 changes: 4 additions & 1 deletion hospexplorer/ask/llm_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from django.conf import settings


def query_llm(query):
def query_llm(query, urls=None):
headers = {
"X-API-Key": settings.LLM_TOKEN,
"Content-Type": "application/json",
Expand All @@ -12,6 +12,9 @@ def query_llm(query):
"input": query
}

# allow empty list for no URLs exist to prevent backend errors
payload["urls"] = urls or []

with httpx.Client() as client:
response = client.post(
settings.LLM_HOST,
Expand Down
33 changes: 33 additions & 0 deletions hospexplorer/ask/migrations/0005_websiteresource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 6.0.2 on 2026-03-06 19:10

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('ask', '0004_merge_0003_querytask_0003_termsacceptance'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='WebsiteResource',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('description', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('modified_at', models.DateTimeField(auto_now=True)),
('url', models.URLField()),
('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL)),
('modifier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Website Resource',
'verbose_name_plural': 'Website Resources',
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Generated by Django 6.0.2 on 2026-03-09 17:20

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('ask', '0005_merge_20260304_2256'),
('ask', '0005_websiteresource'),
]

operations = [
]
33 changes: 33 additions & 0 deletions hospexplorer/ask/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,39 @@
from django.conf import settings
from django.db import models

# Abstract Model, fields are inherited by subclasses
class Resource(models.Model):
title = models.CharField(max_length=255)
description = models.TextField(blank=True, default="")
creator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="%(class)s_created",
)
modifier = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="%(class)s_modified",
)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)

class Meta:
abstract = True

def __str__(self):
return self.title


class WebsiteResource(Resource):
url = models.URLField()

class Meta:
verbose_name = "Website Resource"
verbose_name_plural = "Website Resources"


class QueryTask(models.Model):
class Status(models.TextChoices):
Expand Down
35 changes: 19 additions & 16 deletions hospexplorer/ask/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,46 @@
from django.utils import timezone

import ask.llm_connector
from ask.models import QueryTask, QARecord
from ask.models import Conversation, QARecord, QueryTask, WebsiteResource


logger = logging.getLogger(__name__)


def run_llm_task(task_id):
def run_llm_task(task_id, record_id, conversation_id):
"""Background thread that calls the LLM and writes the result to the DB."""
try:
task = QueryTask.objects.get(pk=task_id)
task.status = QueryTask.Status.PROCESSING
task.save(update_fields=["status", "updated_at"])

# Create QARecord to persist the Q&A history
record = QARecord.objects.create(
question_text=task.query_text,
user=task.user,
)
record = QARecord.objects.get(pk=record_id)
conversation = Conversation.objects.get(pk=conversation_id)

# get all website resources for the conversation
# values_list("url", flat=True) fetches only the url column and returns
# plain strings instead of single element tuples
# in llm_connector.py, urls is allowed to be an empty list if there
# are no website resources for the conversation to prevent backend errors
urls = list(WebsiteResource.objects.values_list("url", flat=True))
llm_response = ask.llm_connector.query_llm(task.query_text, urls=urls)

if not llm_response.get("success") or "output" not in llm_response:
raise ValueError("LLM response is missing structure")

llm_response = ask.llm_connector.query_llm(task.query_text)
content = llm_response["output"].get("content", "")

task.result = content
task.status = QueryTask.Status.COMPLETED
task.save(update_fields=["result", "status", "updated_at"])

# Update QARecord with the answer
record.answer_text = content
record.answer_raw_response = llm_response
record.answer_timestamp = timezone.now()
record.save()

# touch conversation updated_at so it stays as the most recent
conversation.save()
except Exception:
logger.exception("Background LLM task failed for task_id=%s", task_id)
try:
Expand All @@ -43,13 +52,7 @@ def run_llm_task(task_id):
task.error_message = "Something went wrong. Please try again."
task.save(update_fields=["status", "error_message", "updated_at"])

# Mark QARecord as error if it was created
QARecord.objects.filter(
question_text=task.query_text,
user=task.user,
is_error=False,
answer_text="",
).update(
QARecord.objects.filter(pk=record_id).update(
is_error=True,
answer_text="Something went wrong. Please try again.",
answer_timestamp=timezone.now(),
Expand Down
70 changes: 56 additions & 14 deletions hospexplorer/ask/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,35 +50,77 @@
this.isLoading = true;

try {
let url = '{% url 'ask:query-llm' %}?query=' + encodeURIComponent(question);
if (this.conversationId) {
url += '&conversation_id=' + encodeURIComponent(this.conversationId);
}
const response = await fetch(url);
const response = await fetch('{% url "ask:query-llm" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}',
},
body: JSON.stringify({
query: question,
conversation_id: this.conversationId || null,
}),
});
const data = await response.json();
// Update conversation ID and URL on first response

if (!response.ok || data.error) {
this.messages.push({ role: 'assistant', content: data.error || 'Something went wrong. Please try again.' });
this.isLoading = false;
this.$nextTick(() => this.scrollToBottom());
return;
}

// Update conversation ID and URL immediately
if (data.conversation_id && !this.conversationId) {
this.conversationId = data.conversation_id;
history.pushState({}, '', '{% url "ask:index" %}c/' + data.conversation_id + '/');
}
// Update sidebar with this conversation
if (data.conversation_id) {
$dispatch('conversation-updated', {
id: data.conversation_id,
label: data.conversation_title || question,
url: '{% url "ask:index" %}c/' + data.conversation_id + '/',
});
}
if (!response.ok || data.error) {
this.messages.push({ role: 'assistant', content: 'Something went wrong. Please try again.' });
} else {
this.messages.push({ role: 'assistant', content: data.message });
}

// Start polling for the LLM result
this.pollForResult(data.task_id);
} catch (error) {
this.messages.push({ role: 'assistant', content: 'Something went wrong. Please try again.' });
this.isLoading = false;
this.$nextTick(() => this.scrollToBottom());
}
this.isLoading = false;
this.$nextTick(() => this.scrollToBottom());
},

pollForResult(taskId) {
const pollUrl = '{% url "ask:index" %}poll/' + taskId + '/';
this.pollInterval = setInterval(async () => {
try {
const response = await fetch(pollUrl);
const data = await response.json();

if (data.status === 'completed') {
clearInterval(this.pollInterval);
this.pollInterval = null;
this.messages.push({ role: 'assistant', content: data.message });
this.isLoading = false;
this.$nextTick(() => this.scrollToBottom());
} else if (data.status === 'failed') {
clearInterval(this.pollInterval);
this.pollInterval = null;
this.messages.push({ role: 'assistant', content: data.error || 'Something went wrong. Please try again.' });
this.isLoading = false;
this.$nextTick(() => this.scrollToBottom());
}
// pending/processing: keep polling
} catch (error) {
clearInterval(this.pollInterval);
this.pollInterval = null;
this.messages.push({ role: 'assistant', content: 'Something went wrong. Please try again.' });
this.isLoading = false;
this.$nextTick(() => this.scrollToBottom());
}
}, 1500);
},

// Smoothly scrolls chat to the latest message
Expand Down
1 change: 1 addition & 0 deletions hospexplorer/ask/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
re_path(r"^new/$", views.new_conversation, name="new-conversation"),
re_path(r"^c/(?P<conversation_id>\d+)/$", views.conversation_detail, name="conversation"),
re_path(r"^query/$", views.query, name="query-llm"),
re_path(r"^poll/(?P<task_id>[0-9a-f-]+)/$", views.poll_query, name="poll-query"),
re_path(r"^mock$", views.mock_response, name="mock-response"),
re_path(r"^terms/$", views.terms_view, name="terms-view"),
re_path(r"^terms/accept/$", views.terms_accept, name="terms-accept"),
Expand Down
Loading