Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
8747bf2
Remove unused random parameter
XanderVertegaal Sep 5, 2025
06e4a93
User-added problems WIP
XanderVertegaal Sep 5, 2025
6be3d10
Merge branch 'develop' into feature/user-problems
XanderVertegaal Sep 5, 2025
9c11b77
Clean up templates
XanderVertegaal Sep 7, 2025
c2eb6ed
Avoid NotImplemented error when gold param is not provided
XanderVertegaal Sep 7, 2025
579f5e1
Show toasts upon saving a problem
XanderVertegaal Sep 7, 2025
6f2db84
Rename problem to problemResponse
XanderVertegaal Sep 15, 2025
508991d
Move route watching to parent AnnotateComponent
XanderVertegaal Sep 15, 2025
cc0bbde
Add AppMode service
XanderVertegaal Sep 15, 2025
c22a67b
Properly separate problemResponse from problem
XanderVertegaal Sep 15, 2025
978a35e
Return KBs from backend; update in frontend
XanderVertegaal Sep 15, 2025
5d83ce6
Use consistent str for ID
XanderVertegaal Sep 16, 2025
289c622
Consistent casing for KB Relationship
XanderVertegaal Sep 16, 2025
fb23d19
Display KB items in browsing mode
XanderVertegaal Sep 16, 2025
830a3d8
Cleanup
XanderVertegaal Sep 16, 2025
49b03d4
Reuse sentences when creating new problem
XanderVertegaal Sep 16, 2025
cf339b8
Fix failing tests
XanderVertegaal Sep 16, 2025
9d70ffa
Show error message upon omitting hypothesis
XanderVertegaal Sep 16, 2025
51b235d
Add more tests
XanderVertegaal Sep 16, 2025
483c0f8
Use int for IDs
XanderVertegaal Sep 17, 2025
d144eb6
Fix failing tests
XanderVertegaal Sep 17, 2025
619439c
Avoid unnecessary requests
XanderVertegaal Sep 22, 2025
52b775a
Use serializers for input validation
XanderVertegaal Oct 23, 2025
4e8c4a9
Sensible naming for urls
XanderVertegaal Oct 23, 2025
aa0b009
Only update User problems
XanderVertegaal Oct 23, 2025
4c30ea7
Regular dict access
XanderVertegaal Oct 23, 2025
8e24695
Indentation
XanderVertegaal Oct 23, 2025
4f4576e
More intuitive related property access
XanderVertegaal Oct 23, 2025
b7f040c
Allow exceptions to bubble up to post()
XanderVertegaal Oct 23, 2025
83dbee6
Let Django crash hard if no KB items can be created
XanderVertegaal Oct 23, 2025
2c39be6
Remove appModeService, integrate logic into ProblemService
XanderVertegaal Oct 23, 2025
bd6b8ce
Let more exceptions bubble up
XanderVertegaal Oct 25, 2025
2663a29
Remove unused import
XanderVertegaal Nov 4, 2025
c4a387b
Refactor serializer tests to use pytest
XanderVertegaal Dec 4, 2025
a826af9
Reinstate Request type annotations
XanderVertegaal Dec 4, 2025
cf4a202
Replace manual serialization with ModelViewSet
XanderVertegaal Dec 4, 2025
1bfb7f8
Remove unused validation code
XanderVertegaal Dec 4, 2025
75146f9
Use serializer create/update methods
XanderVertegaal Dec 4, 2025
90b0668
Remove unused fields
XanderVertegaal Dec 4, 2025
f064c00
Replace routing buttons with anchor tags
XanderVertegaal Dec 4, 2025
a7b09f3
Add template veriable for shorthand
XanderVertegaal Dec 4, 2025
ad76efa
Fix failing tests
XanderVertegaal Dec 4, 2025
2ba0c98
Fix backend tests
XanderVertegaal Dec 7, 2025
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
11 changes: 11 additions & 0 deletions backend/problem/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,16 @@ def serialize(self) -> dict:
case _:
serialized_extra_data = {}

kb_items = KnowledgeBase.objects.filter(problem=self)

return {
"id": self.pk,
"dataset": self.dataset,
"premises": [premise.text for premise in self.premises.all()],
"hypothesis": self.hypothesis.text,
"entailmentLabel": self.entailment_label,
"extraData": serialized_extra_data,
"kbItems": [item.serialize() for item in kb_items],
}


Expand All @@ -105,3 +108,11 @@ class Relationship(models.TextChoices):
on_delete=models.CASCADE,
related_name="knowledge_bases",
)

def serialize(self) -> dict:
return {
"id": self.pk,
"entity1": self.entity1,
"entity2": self.entity2,
"relationship": self.relationship,
}
22 changes: 11 additions & 11 deletions backend/problem/problem_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@

@dataclass
class RelatedProblemIds:
first: Optional[str] = None
previous: Optional[str] = None
next: Optional[str] = None
last: Optional[str] = None
total: Optional[int] = None
first: int | None = None
previous: int | None = None
next: int | None = None
last: int | None = None
total: int | None = None


def get_related_problem_ids(
problem_qs: QuerySet[Problem],
problem_id: Optional[int],
problem_id: int | None = None,
) -> RelatedProblemIds:
"""
Retrieves the IDs of surrounding problem objects
Expand All @@ -44,10 +44,10 @@ def get_related_problem_ids(
problem = None

return RelatedProblemIds(
first=str(first_problem.pk) if first_problem else None,
previous=str(previous_problem.pk) if previous_problem else None,
next=str(next_problem.pk) if next_problem else None,
last=str(last_problem.pk) if last_problem else None,
first=first_problem.pk if first_problem else None,
previous=previous_problem.pk if previous_problem else None,
next=next_problem.pk if next_problem else None,
last=last_problem.pk if last_problem else None,
total=total,
)

Expand All @@ -70,7 +70,7 @@ def get_filters(query_params: QueryDict) -> Q | None:
filters &= Q(dataset=dataset)
if entailment_label:
filters &= Q(entailment_label=entailment_label)
if gold is not None:
if gold is not None and gold != "":
raise NotImplementedError()
if text:
filters &= Q(
Expand Down
236 changes: 219 additions & 17 deletions backend/problem/views/problem.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,29 @@
from dataclasses import dataclass
from typing import Optional
from dataclasses import asdict, dataclass
from typing import TypedDict

from django.http import JsonResponse
from rest_framework.views import APIView, Request
from rest_framework.views import APIView

from langpro_annotator.logger import logger
from problem.problem_details import get_filters, get_related_problem_ids
from problem.models import Problem
from problem.models import KnowledgeBase, Problem, Sentence


@dataclass
class ProblemResponse:
id: Optional[int] = None
index: Optional[int] = None
problem: Optional[Problem] = None
error: Optional[str] = None
problem: Problem | None = None
index: int | None = None
error: str | None = None

first: Optional[str] = None
previous: Optional[str] = None
next: Optional[str] = None
last: Optional[str] = None
total: Optional[int] = None
first: int | None = None
previous: int | None = None
next: int | None = None
last: int | None = None
total: int | None = None

def json_response(self, status=200) -> JsonResponse:
return JsonResponse(
{
"id": self.id,
"index": self.index,
"problem": self.problem.serialize() if self.problem else None,
"error": self.error,
Expand All @@ -38,8 +37,21 @@ def json_response(self, status=200) -> JsonResponse:
)


@dataclass
class SaveProblemResponse:
id: int | None = None
error: str | None = None

def json_response(self, status=200) -> JsonResponse:
return JsonResponse(asdict(self), status=status)


class ProblemView(APIView):
def get(self, request: Request, problem_id: int | None = None):
def get(self, request, problem_id: int | None = None) -> JsonResponse:
"""
If a Problem ID is provided, retrieves the requested Problem.
Otherwise, simply returns the first problem of the QS.
"""
filters = get_filters(request.query_params)

qs = Problem.objects.all()
Expand All @@ -63,12 +75,202 @@ def get(self, request: Request, problem_id: int | None = None):
related_problem_ids = get_related_problem_ids(qs, problem_id)

return ProblemResponse(
id=problem.pk if problem else None,
index=problem_index,
problem=problem,
index=problem_index,
first=related_problem_ids.first,
previous=related_problem_ids.previous,
next=related_problem_ids.next,
last=related_problem_ids.last,
total=related_problem_ids.total,
).json_response(status=200)

def post(self, request, problem_id: int | None = None) -> JsonResponse:
"""
If the Problem ID is None, attempts to create a new Problem;
else the associated Problem is updated.
"""
parse_input = request.data

try:
validated_input = validate_input(parse_input)
except ValueError as e:
logger.error(f"Input validation error: {e}")
return SaveProblemResponse(
id=None,
error=str(e),
).json_response(status=400)

problem: Problem | None = None
error: str | None = None

if problem_id is None:
try:
problem = create_problem_from_input(validated_input)
except Exception as e:
error = f"Error creating problem: {str(e)}"
else:
try:
problem = update_problem_from_input(validated_input)
except Exception as e:
error = f"Error updating problem: {str(e)}"

if problem is None or error is not None:
return SaveProblemResponse(
id=None,
error=error,
).json_response(status=500)

return SaveProblemResponse(id=problem.pk, error=None).json_response()


class KBItem(TypedDict):
id: int
entity1: str
relationship: str
entity2: str


class ParseInput(TypedDict):
id: int
premises: list[str]
hypothesis: str
kbItems: list[KBItem]


def validate_input(parse_input: dict) -> ParseInput:
"""
Validate the parse input data.
"""
if not isinstance(parse_input, dict):
raise ValueError("Input must be a dictionary")

if "id" not in parse_input or (
parse_input["id"] is not None and not isinstance(parse_input["id"], int)
):
raise ValueError("Invalid problem 'id' field")

if "premises" not in parse_input or not isinstance(parse_input["premises"], list):
raise ValueError("Missing or invalid 'premises' field")

if "hypothesis" not in parse_input or not isinstance(
parse_input["hypothesis"], str
):
raise ValueError("Missing or invalid 'hypothesis' field")

if "kbItems" not in parse_input or not isinstance(parse_input["kbItems"], list):
raise ValueError("Missing or invalid 'kbItems' field")

for item in parse_input["kbItems"]:
if not isinstance(item, dict):
raise ValueError("Each kbItem must be a dictionary")
if "id" not in item or (
item["id"] is not None and not isinstance(item["id"], int)
):
raise ValueError("Invalid 'id' in kbItem")
if "entity1" not in item or not isinstance(item["entity1"], str):
raise ValueError("Missing or invalid 'entity1' in kbItem")
if "relationship" not in item or not isinstance(item["relationship"], str):
raise ValueError("Missing or invalid 'relationship' in kbItem")
if item["relationship"] not in KnowledgeBase.Relationship.values:
raise ValueError(f"Invalid 'relationship' in kbItem.")
if "entity2" not in item or not isinstance(item["entity2"], str):
raise ValueError("Missing or invalid 'entity2' in kbItem")

return ParseInput(
id=parse_input["id"],
premises=parse_input["premises"],
hypothesis=parse_input["hypothesis"],
kbItems=parse_input["kbItems"],
)


def create_problem_from_input(parse_input: ParseInput) -> Problem:
"""
Save a new Problem instance from the given parse input data.
"""
try:
premise_sentences = [
Sentence.objects.get_or_create(text=premise)[0]
for premise in parse_input["premises"]
]
except Exception as e:
raise ValueError(f"Error creating premise sentences: {e}")

try:
hypothesis_sentence = Sentence.objects.get_or_create(
text=parse_input["hypothesis"]
)[0]
except Exception as e:
raise ValueError(f"Error creating hypothesis sentence: {e}")

problem = Problem.objects.create(
hypothesis=hypothesis_sentence,
dataset=Problem.Dataset.USER,
# TODO: Determine entailment label based on LangPro parser output.
entailment_label=Problem.EntailmentLabel.UNKNOWN,
extra_data={},
)

problem.premises.set(premise_sentences)

update_or_create_kb_items(problem=problem, kb_items=parse_input["kbItems"])

return problem


def update_or_create_kb_items(problem: Problem, kb_items: list[KBItem]) -> None:
kb_ids: list[str] = []
for item in kb_items:
id = item.get("id")
entity1 = item.get("entity1")
relationship = item.get("relationship")
entity2 = item.get("entity2")

if id is None:
try:
kb = KnowledgeBase.objects.create(
entity1=entity1,
relationship=relationship,
entity2=entity2,
problem=problem,
)
kb_ids.append(kb.pk)
except Exception as e:
raise ValueError(f"Error creating knowledge base items: {e}.")
else:
try:
kb = KnowledgeBase.objects.get(id=id, problem_id=problem.pk)
except KnowledgeBase.DoesNotExist:
raise ValueError(f"Unable to find knowledge base item with id {id}.")
kb.entity1 = entity1
kb.relationship = relationship
kb.entity2 = entity2
kb.save()
kb_ids.append(kb.pk)

# Delete existing knowledge bases associated to this problem that are
# not included in the input.
KnowledgeBase.objects.filter(problem_id=problem.pk).exclude(id__in=kb_ids).delete()


def update_problem_from_input(parse_input: ParseInput) -> Problem:
try:
problem = Problem.objects.get(id=parse_input["id"])
except Problem.DoesNotExist:
raise ValueError(f"Cannot find Problem with ID: {parse_input["id"]}")

problem.hypothesis = Sentence.objects.get_or_create(text=parse_input["hypothesis"])[
0
]
problem.save()

premises: list[Sentence] = []
for input_premise in parse_input["premises"]:
premise = Sentence.objects.get_or_create(text=input_premise)[0]
premises.append(premise)

problem.premises.set(premises)

update_or_create_kb_items(problem, parse_input["kbItems"])

return problem
Loading