Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
69108f9
[HOP-7] Added django-allauth, configured settings, added views and urls
Girik1105 Jan 28, 2026
ed39b3c
[HOP-7] Templates
Girik1105 Jan 29, 2026
66ac0c7
[HOP-7] Resolved merge conflicts
Girik1105 Jan 29, 2026
9f8a3ef
[HOP-7] Final templates, better comments in settings, uv lock
Girik1105 Jan 29, 2026
08ca48c
[HOP-8] Added models, views for QA storage and added to admin
Girik1105 Jan 30, 2026
e19ba98
[HOP-7] Resolved merge conflicts
Girik1105 Jan 30, 2026
5b6217f
[HOP-7] comments
Girik1105 Jan 30, 2026
dc5268c
Merge branch 'story/HOP-7' into story/HOP-8
Girik1105 Jan 30, 2026
878af42
[HOP-8] Removed debug print
Girik1105 Jan 30, 2026
0cea583
[HOP-7] Merge conflicts resolved
Girik1105 Feb 4, 2026
a808edf
[HOP-7] Added login view to /
Girik1105 Feb 4, 2026
be00571
[HOP-8] Resolved merge conflicts with HOP-7]
Girik1105 Feb 4, 2026
77534de
[HOP-8] missing try except block, added record before sending to llm …
Girik1105 Feb 4, 2026
36f29c6
[HOP-11] Added recent question bar
Girik1105 Feb 5, 2026
8da315e
Merge branch 'develop' into story/HOP-8
Girik1105 Feb 5, 2026
215f66a
[HOP-8] Added toggle sidebar for nice ui, mock and real response is n…
Girik1105 Feb 6, 2026
821ba86
Revert "[HOP-8] Added toggle sidebar for nice ui, mock and real respo…
Girik1105 Feb 6, 2026
5e88a84
[HOP-8] Mock and real LLM use same response format
Girik1105 Feb 6, 2026
c243434
Merge branch 'story/HOP-8' into story/HOP-9
Girik1105 Feb 6, 2026
77688eb
[HOP-8] Logger added, exception would log full stack trace
Girik1105 Feb 6, 2026
3eccd50
Merge branch 'story/HOP-8' into story/HOP-9
Girik1105 Feb 6, 2026
ff0b753
Merge branch 'develop' into story/HOP-9
Girik1105 Feb 6, 2026
4daf314
Merge branch 'develop' into story/HOP-8
Girik1105 Feb 6, 2026
a587991
Merge branch 'develop' into story/HOP-8
Girik1105 Feb 13, 2026
0dafc69
[HOP-11] Added loggers
Girik1105 Feb 13, 2026
5794f6e
[HOP-8] Added loggers (previous commit had wrong ticket number
Girik1105 Feb 13, 2026
7a74b77
Merge branch 'story/HOP-8' into story/HOP-9
Girik1105 Feb 13, 2026
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
15 changes: 14 additions & 1 deletion hospexplorer/ask/admin.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
from django.contrib import admin
from ask.models import QARecord

# Register your models here.

@admin.register(QARecord)
class QARecordAdmin(admin.ModelAdmin):
list_display = ["id", "user", "truncated_question", "question_timestamp", "answer_timestamp"]
list_filter = ["question_timestamp", "user"]
search_fields = ["question_text", "answer_text", "user__username"]
readonly_fields = ["question_timestamp", "answer_timestamp", "answer_raw_response"]
raw_id_fields = ["user"]
date_hierarchy = "question_timestamp"

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"
35 changes: 35 additions & 0 deletions hospexplorer/ask/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by Django 6.0.1 on 2026-01-30 19:36

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


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='QARecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question_text', models.TextField()),
('question_timestamp', models.DateTimeField(auto_now_add=True)),
('answer_text', models.TextField(blank=True, default='')),
('answer_raw_response', models.JSONField(default=dict)),
('answer_timestamp', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='qa_records', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Q&A Record',
'verbose_name_plural': 'Q&A Records',
'ordering': ['-question_timestamp'],
'indexes': [models.Index(fields=['user', '-question_timestamp'], name='ask_qarecor_user_id_f4353f_idx')],
},
),
]
18 changes: 18 additions & 0 deletions hospexplorer/ask/migrations/0002_qarecord_is_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-02-04 23:32

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('ask', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='qarecord',
name='is_error',
field=models.BooleanField(default=False),
),
]
31 changes: 30 additions & 1 deletion hospexplorer/ask/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
from django.db import models
from django.conf import settings

# Create your models here.

class QARecord(models.Model):
"""
Stores a question-answer pair from user interactions with the LLM.
"""
# Question fields
question_text = models.TextField()
question_timestamp = models.DateTimeField(auto_now_add=True)

# Answer fields
answer_text = models.TextField(blank=True, default="")
answer_raw_response = models.JSONField(default=dict)
answer_timestamp = models.DateTimeField(null=True, blank=True)
is_error = models.BooleanField(default=False)

user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="qa_records")

class Meta:
ordering = ["-question_timestamp"]
verbose_name = "Q&A Record"
verbose_name_plural = "Q&A Records"
indexes = [
models.Index(fields=["user", "-question_timestamp"]),
]

def __str__(self):
truncated = self.question_text[:50]
suffix = "..." if len(self.question_text) > 50 else ""
return f"{self.user.username}: {truncated}{suffix}"
33 changes: 33 additions & 0 deletions hospexplorer/ask/static/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -10870,3 +10870,36 @@ body.sb-sidenav-toggled #wrapper #sidebar-wrapper {
margin-left: -15rem;
}
}

/* Recent Questions Sidebar Styles */
#sidebar-wrapper {
display: flex;
flex-direction: column;
}

#sidebar-wrapper .list-group {
flex: 1;
overflow-y: auto;
max-height: calc(100vh - 180px);
}

#sidebar-wrapper .question-item {
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-left: 3px solid transparent;
transition: border-color 0.2s, background-color 0.2s;
}

#sidebar-wrapper .question-item:hover {
border-left-color: #0d6efd;
background-color: #f8f9fa;
}

#sidebar-wrapper .sidebar-section-heading {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.75rem;
}
18 changes: 10 additions & 8 deletions hospexplorer/ask/static/js/scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,19 @@
window.addEventListener('DOMContentLoaded', event => {

// Toggle the side navigation
const sidebarToggle = document.body.querySelector('#sidebarToggle');
if (sidebarToggle) {
// Uncomment Below to persist sidebar toggle between refreshes
// if (localStorage.getItem('sb|sidebar-toggle') === 'true') {
// document.body.classList.toggle('sb-sidenav-toggled');
// }
sidebarToggle.addEventListener('click', event => {
const sidebarToggles = document.body.querySelectorAll('#sidebarToggle');

// Restore sidebar state from localStorage
if (localStorage.getItem('sb|sidebar-toggle') === 'true') {
document.body.classList.add('sb-sidenav-toggled');
}

sidebarToggles.forEach(toggle => {
toggle.addEventListener('click', event => {
event.preventDefault();
document.body.classList.toggle('sb-sidenav-toggled');
localStorage.setItem('sb|sidebar-toggle', document.body.classList.contains('sb-sidenav-toggled'));
});
}
});

});
75 changes: 52 additions & 23 deletions hospexplorer/ask/templates/_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,30 @@
<body>
<div class="d-flex" id="wrapper">
<!-- Sidebar-->
<div class="border-end bg-white" id="sidebar-wrapper">
<div class="border-end bg-white" id="sidebar-wrapper"
x-data="sidebarQuestions()"
@question-asked.window="addQuestion($event.detail)">
<div class="sidebar-heading border-bottom bg-white">
<img width="50" src="{% static 'assets/asu.png' %}">
Hopper
</div>
<div class="list-group list-group-flush">
<!-- Recent Questions Section -->
<div class="sidebar-section-heading small text-muted px-3 py-2 border-bottom bg-light">
Recent Questions
</div>
<template x-for="question in questions" :key="question.id">
<a href="#" class="list-group-item list-group-item-action py-2 px-3 question-item"
:title="question.question_text"
x-text="truncate(question.question_text, 35)">
</a>
</template>
<div x-show="questions.length === 0" class="text-muted small px-3 py-2">
No questions yet
</div>

{% if user.is_authenticated %}
<div class="p-3 border-top">
<div class="p-3 border-top mt-auto">
<div class="small text-muted mb-2">Signed in as <strong>{{ user.username }}</strong></div>
<a href="{% url 'account_logout' %}" class="btn btn-outline-secondary btn-sm w-100">Sign Out</a>
</div>
Expand All @@ -33,38 +49,51 @@
</div>
<!-- Page content wrapper-->
<div id="page-content-wrapper">
<!-- Top navigation-->
<!--<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
<!-- Top navigation with toggle -->
<nav class="navbar navbar-light bg-white border-bottom">
<div class="container-fluid">
<button class="btn btn-primary" id="sidebarToggle">Toggle Menu</button>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ms-auto mt-2 mt-lg-0">
<li class="nav-item active"><a class="nav-link" href="#!">Home</a></li>
<li class="nav-item"><a class="nav-link" href="#!">Link</a></li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" id="navbarDropdown" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown</a>
<div class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="#!">Action</a>
<a class="dropdown-item" href="#!">Another action</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#!">Something else here</a>
</div>
</li>
</ul>
</div>
<button class="btn btn-outline-secondary btn-sm" id="sidebarToggle">
&#9776; Menu
</button>
</div>
</nav>-->
</nav>
<!-- Page content-->
<div class="container-fluid">
{% block content %}{% endblock %}

</div>
</div>
</div>
<!-- Bootstrap core JS-->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Core theme JS-->
<script src="{% static 'js/scripts.js' %}"></script>
<!-- Sidebar Questions Component -->
<script>
window.initialQuestions = {{ recent_questions_json|safe|default:"[]" }};

function sidebarQuestions() {
return {
questions: window.initialQuestions || [],

addQuestion(detail) {
const newQuestion = {
id: Date.now(),
question_text: detail.text
};
this.questions.unshift(newQuestion);
if (this.questions.length > 10) {
this.questions = this.questions.slice(0, 10);
}
},

truncate(text, length) {
if (!text) return '';
if (text.length <= length) return text;
return text.substring(0, length) + '...';
}
};
}
</script>
</body>
</html>
10 changes: 9 additions & 1 deletion hospexplorer/ask/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,16 @@ <h1 class="mt-4">Hopper</h1>
isLoading: false,

async getAnswer() {
if (!this.userQuery.trim()) return;

const questionText = this.userQuery;
this.isLoading = true;

// Dispatch event to update sidebar
$dispatch('question-asked', { text: questionText });

try {
const response = await fetch('{% url 'ask:query-llm' %}?query=' + encodeURIComponent(this.userQuery));
const response = await fetch('{% url 'ask:query-llm' %}?query=' + encodeURIComponent(questionText));
const data = await response.json();
if (!response.ok || data.error) {
this.answers.push('Something went wrong. Please try again.');
Expand All @@ -31,6 +38,7 @@ <h1 class="mt-4">Hopper</h1>
} catch (error) {
this.answers.push('Something went wrong. Please try again.');
}
this.userQuery = '';
this.isLoading = false;
}
}">
Expand Down
55 changes: 47 additions & 8 deletions hospexplorer/ask/views.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,67 @@
import logging
from django.shortcuts import render
from django.http import JsonResponse
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.utils import timezone
import json
import ask.llm_connector
from ask.models import QARecord

logger = logging.getLogger(__name__)

@login_required
def index(request):
return render(request, "index.html", {})
recent_questions = list(
QARecord.objects.filter(user=request.user).values('id', 'question_text')[:10]
)
return render(request, "index.html", {
'recent_questions_json': json.dumps(recent_questions, default=str)
})


@login_required
def mock_response(request):
"""Returns a mock LLM response in the same format as the real server."""
return JsonResponse({
"message": "Okay, the user wants a three-sentence bedtime story about a unicorn. Let's start by thinking about the key elements of a good bedtime story. They usually have a peaceful setting, a gentle conflict or quest, and a happy ending.\n\nFirst sentence needs to set the scene. Maybe a magical forest with a unicorn. Luna is a common unicorn name, sounds soft. Moonlight and stars could add a calming effect.\n\nSecond sentence should introduce a small problem or something the unicorn does. Healing powers are typical for unicorns. Maybe she finds an injured animal, like a fox. Using her horn to heal adds magic.\n\nThird sentence wraps it up with a happy ending. The fox recovers, they become friends, and the forest is peaceful. Emphasize safety and dreams to make it soothing for bedtime.\n\nCheck if it's exactly three sentences. Yes. Language is simple and comforting, suitable for a child. Avoid any scary elements. Make sure it flows smoothly and conveys warmth.\n</think>\n\nUnder the shimmering moonlit sky, a silver-maned unicorn named Luna trotted through the enchanted forest, her hooves leaving trails of stardust. When she discovered a wounded fox whimpering beneath an ancient oak, she touched her glowing horn to its paw, weaving magic that healed the hurt. With the fox curled beside her, Luna rested on a bed of moss, her heart full as the forest whispered lullabies, ensuring all creatures drifted into dreams of peace."
"choices": [{
"message": {
"content": "Under the shimmering moonlit sky, a silver-maned unicorn named Luna trotted through the enchanted forest, her hooves leaving trails of stardust. When she discovered a wounded fox whimpering beneath an ancient oak, she touched her glowing horn to its paw, weaving magic that healed the hurt. With the fox curled beside her, Luna rested on a bed of moss, her heart full as the forest whispered lullabies, ensuring all creatures drifted into dreams of peace."
}
}]
})

@login_required
def query(request):
query_text = request.GET.get("query", "")
record = QARecord.objects.create(
question_text=query_text,
user=request.user,
)
try:
llm_response = ask.llm_connector.query_llm(request.GET["query"])
content = llm_response["choices"][0]["message"]["content"]
return JsonResponse({"message": content})
except (KeyError, IndexError, TypeError) as e:
return JsonResponse({"error": f"Unexpected response from server: {e}"}, status=500)
llm_response = ask.llm_connector.query_llm(query_text)

# Mock and real LLM use the same response format
if "choices" not in llm_response or not llm_response["choices"]:
raise ValueError("LLM response is missing structure")
answer_text = llm_response["choices"][0].get("message", {}).get("content", "")

record.answer_text = answer_text
record.answer_raw_response = llm_response
record.answer_timestamp = timezone.now()
record.save()

return JsonResponse({"message": answer_text})
except (KeyError, IndexError, TypeError, ValueError) as e:
logger.exception("Unexpected response from server")
error_msg = f"Unexpected response from server: {e}"
except Exception as e:
return JsonResponse({"error": f"Failed to connect to server: {e}"}, status=500)
logger.exception("Failed to connect to server")
error_msg = f"Failed to connect to server: {e}"

# The try block returns on success, so this only runs on error.
record.is_error = True
record.answer_text = error_msg
record.answer_timestamp = timezone.now()
record.save()
return JsonResponse({"error": error_msg}, status=500)
2 changes: 1 addition & 1 deletion mockoon/llm-mock.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"responses": [
{
"uuid": "chat-completions-response",
"body": "{\n \"message\": \"This is a mock response from the local mockserver. Your query was received successfully.\"\n}",
"body": "{\n \"choices\": [{\n \"message\": {\n \"content\": \"Under the shimmering moonlit sky, a silver-maned unicorn named Luna trotted through the enchanted forest, her hooves leaving trails of stardust. When she discovered a wounded fox whimpering beneath an ancient oak, she touched her glowing horn to its paw, weaving magic that healed the hurt. With the fox curled beside her, Luna rested on a bed of moss, her heart full as the forest whispered lullabies, ensuring all creatures drifted into dreams of peace.\"\n }\n }]\n}",
"latency": 0,
"statusCode": 200,
"label": "Success response",
Expand Down