Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fc79f61
Add Icon Button Component
XanderVertegaal Sep 22, 2025
c7d3134
Add copy button
XanderVertegaal Sep 22, 2025
c55719c
Avoid unnecessary requests
XanderVertegaal Sep 22, 2025
5440a8a
Compare route params
XanderVertegaal Sep 22, 2025
d42d1e2
Add base field to form
XanderVertegaal Sep 22, 2025
7b4796f
Add base field to Problem
XanderVertegaal Sep 22, 2025
e7da8fc
Use Problem.field in problem views
XanderVertegaal Sep 22, 2025
07297b3
Use update_or_create for sentences
XanderVertegaal Sep 22, 2025
aadfd32
Use base field in frontend
XanderVertegaal Sep 22, 2025
60a2e5a
Simplify Annotate component template
XanderVertegaal Sep 22, 2025
bcb27e9
Keep queryParams after form update
XanderVertegaal Sep 22, 2025
a68c25a
Fix failing tests
XanderVertegaal Sep 22, 2025
1e089b0
Merge branch 'feature/user-problems' into feature/derived-problems
XanderVertegaal Oct 25, 2025
658a1fe
Add base field to serializer
XanderVertegaal Oct 25, 2025
8c23335
Add Replace ViewMode with AppMode in ProblemDetailsComponent
XanderVertegaal Oct 25, 2025
d0e0d34
Remove unused import
XanderVertegaal Oct 25, 2025
c06aa05
Merge branch 'feature/user-problems' into feature/derived-problems
XanderVertegaal Oct 25, 2025
7897f67
Merge branch 'feature/user-problems' into feature/derived-problems
XanderVertegaal Dec 7, 2025
0428b12
Outfactor util
XanderVertegaal Dec 7, 2025
5006b4c
Set base in serializer
XanderVertegaal Dec 7, 2025
873b040
Reinstate IconButton for non-navigational buttons
XanderVertegaal Dec 7, 2025
b4ec78d
Remove unused imports
XanderVertegaal Dec 7, 2025
bea3b3e
Silence Python type warning
XanderVertegaal Dec 7, 2025
2ef9935
Return base correctly
XanderVertegaal Dec 8, 2025
f43bd3f
Avoid duplicate results upon filtering
XanderVertegaal Dec 8, 2025
7188e7f
Outfactor general utility function
XanderVertegaal Dec 8, 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
9 changes: 6 additions & 3 deletions backend/problem/management/commands/import_fracas.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ def _annotate_section_subsections(tree: ET.ElementTree) -> None:

root = tree.getroot()

if root is None:
raise ValueError("The XML file is empty or malformed.")

for element in root:
if element.tag == "comment" and element.attrib.get("class") == "section":
current_section = element.text.strip() if element.text else None
Expand Down Expand Up @@ -107,15 +110,15 @@ def import_fracas_problems(self, fracas_path: str) -> None:
)
skipped += 1
continue
hypothesis = Sentence.objects.create(
hypothesis = Sentence.objects.update_or_create(
text=FracasData._text_from_element(hypothesis_node)
)
)[0]

premises = []
premise_nodes = problem.findall("p")
for node in premise_nodes:
if node.text:
premises.append(Sentence.objects.create(text=node.text.strip()))
premises.append(Sentence.objects.update_or_create(text=node.text.strip())[0])


entailment_label = self.ENTAILMENT_LABELS.get(
Expand Down
5 changes: 2 additions & 3 deletions backend/problem/management/commands/import_sick.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,8 @@ def import_sick_problems(self, sick_path: str) -> None:

extra_data = SickData.import_data(problem)

premise = Sentence.objects.create(text=problem["sentence_A"])

hypothesis = Sentence.objects.create(text=problem["sentence_B"])
premise = Sentence.objects.update_or_create(text=problem["sentence_A"])[0]
hypothesis = Sentence.objects.update_or_create(text=problem["sentence_B"])[0]

problem = Problem.objects.create(
dataset=Problem.Dataset.SICK,
Expand Down
13 changes: 10 additions & 3 deletions backend/problem/management/commands/import_snli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import csv
from typing import Literal

from django.core.management.base import BaseCommand
from tqdm import tqdm
Expand Down Expand Up @@ -53,7 +54,9 @@ def handle(self, *args, **options):

self.import_snli_problems(snli_paths)

def import_snli_problems(self, snli_paths: list[tuple[str, str]]) -> None:
def import_snli_problems(
self, snli_paths: list[tuple[Literal["dev", "train", "test"], str]]
) -> None:
"""
Import SNLI 1.0 problems from a list of SNLI TSV files and enter them into the database.
"""
Expand Down Expand Up @@ -99,9 +102,13 @@ def import_snli_problems(self, snli_paths: list[tuple[str, str]]) -> None:

extra_data = SNLIData.import_data(problem, subset)

premise = Sentence.objects.create(text=problem["sentence1"])
premise = Sentence.objects.update_or_create(
text=problem["sentence1"]
)[0]

hypothesis = Sentence.objects.create(text=problem["sentence2"])
hypothesis = Sentence.objects.update_or_create(
text=problem["sentence2"]
)[0]

new_problem = Problem.objects.create(
dataset=Problem.Dataset.SNLI,
Expand Down
26 changes: 26 additions & 0 deletions backend/problem/migrations/0005_problem_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 4.2.20 on 2025-09-22 11:47

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


class Migration(migrations.Migration):

dependencies = [
("problem", "0004_sentence_remove_problem_premises_knowledgebase_and_more"),
]

operations = [
migrations.AddField(
model_name="problem",
name="base",
field=models.ForeignKey(
blank=True,
help_text="The base problem from which this problem was derived, if any.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="derived_problems",
to="problem.problem",
),
),
]
10 changes: 10 additions & 0 deletions backend/problem/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ class EntailmentLabel(models.TextChoices):
default=Dataset.USER,
)

base = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="derived_problems",
help_text="The base problem from which this problem was derived, if any.",
)

premises = models.ManyToManyField(
Sentence,
related_name="premise_problems",
Expand Down Expand Up @@ -77,6 +86,7 @@ def serialize(self) -> dict:

return {
"id": self.pk,
"base": self.base.pk if self.base else None,
"dataset": self.dataset,
"premises": [premise.text for premise in self.premises.all()],
"hypothesis": self.hypothesis.text,
Expand Down
1 change: 0 additions & 1 deletion backend/problem/problem_details.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from typing import Optional
from dataclasses import dataclass

from django.http import QueryDict
Expand Down
11 changes: 11 additions & 0 deletions backend/problem/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class ProblemInputSerializer(serializers.Serializer):
many=True, allow_empty=True, help_text="List of knowledge base items"
)

base = serializers.IntegerField(required=False, allow_null=True)

def validate_id(self, value):
"""
Validate that the problem ID exists and belongs to a user problem.
Expand All @@ -53,3 +55,12 @@ def validate_id(self, value):
f"Problem with ID {value} does not exist or is not a user problem."
)
return value

def validate_base(self, value):
"""Validate that the base problem ID exists if provided."""
if value is not None:
if not Problem.objects.filter(id=value).exists():
raise serializers.ValidationError(
f"Base problem with ID {value} does not exist."
)
return value
11 changes: 11 additions & 0 deletions backend/problem/views/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ def create_problem_from_input(parse_input: dict) -> Problem:
)[0]

problem = Problem.objects.create(
base_id=parse_input["base"],
hypothesis=hypothesis_sentence,
dataset=Problem.Dataset.USER,
# TODO: Determine entailment label based on LangPro parser output.
Expand Down Expand Up @@ -207,6 +208,16 @@ def update_problem_from_input(parse_input: dict) -> Problem:
problem.hypothesis = Sentence.objects.get_or_create(
text=parse_input["hypothesis"],
)[0]

if parse_input["base"] is None:
problem.base = None
else:
try:
base_problem = Problem.objects.get(id=parse_input["base"])
except Problem.DoesNotExist:
raise ValueError(f"Cannot find base Problem with ID: {parse_input['base']}")
problem.base = base_problem # type: ignore

problem.save()

premises: list[Sentence] = []
Expand Down
29 changes: 8 additions & 21 deletions frontend/src/app/annotate/annotate.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,52 +4,39 @@
@let adding = appMode === 'add';
@let editing = appMode === 'edit';


<div class="row mb-4">
<div class="d-flex flex-column col-4 gap-2">
@if (browsing) {
<div class="card text-bg-light p-3">
<la-navigator />
<la-search />
</div>
} @else if (adding) {
} @else if (adding || editing) {
<section class="d-flex flex-column gap-2">
@if (adding) {
<p class="text-muted" i18n>
You are currently adding a new problem.
</p>
<p class="text-muted" i18n>
Hit 'Start Parse' to send your problem data to the parser and
inspect the results.
</p>
<p class="text-muted" i18n>
If you are happy with the changes, save the problem by clicking
the 'Save' button.
</p>
<p class="text-muted" i18n>
This will add the problem to the database.
</p>
</section>
} @else if (editing) {
<section>
} @else {
<p class="text-muted" i18n>
You are currently editing an existing problem.
</p>
}
<p class="text-muted" i18n>
Hit 'Start Parse' to send your problem data to the parser and
inspect the results.
</p>
<p class="text-muted" i18n>
If you are happy with the changes, save the changes by clicking
If you are happy with the changes, save the problem by clicking
the 'Save problem' button.
</p>
@if (isUserProblem$ | async) {
@if (adding) {
<p class="text-muted" i18n>
This will update this problem in the database.
This will add the problem to the database.
</p>
} @else {
<p class="text-muted" i18n>
This will create a new problem in the database, leaving the
original problem unchanged.
This will update this problem in the database.
</p>
}
</section>
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/app/annotate/annotate.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { AnnotationInputComponent } from "./annotation-input/annotation-input.co
import { SearchComponent } from "./search/search.component";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { faBinoculars, faPlus } from "@fortawesome/free-solid-svg-icons";
import { ActivatedRoute, Router } from "@angular/router";
import { ActivatedRoute, Router, ParamMap } from "@angular/router";
import { ProblemService } from "@/services/problem.service";
import { combineLatest, distinctUntilChanged, map } from "rxjs";
import { CommonModule } from "@angular/common";
Expand Down Expand Up @@ -56,6 +56,7 @@ export class AnnotateComponent implements OnInit {
editParam$
])
.pipe(
distinctUntilChanged((oldParams, newParams) => this.areParamsEqual(oldParams, newParams)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(([params, queryParams, edit]) => {
Expand All @@ -70,4 +71,22 @@ export class AnnotateComponent implements OnInit {
public addProblem(): void {
this.router.navigate(["/", "annotate", "new"]);
}

private areParamsEqual(
[oldParams, oldQueryParams, oldEditParam]: [ParamMap, ParamMap, boolean],
[newParams, newQueryParams, newEditParam]: [ParamMap, ParamMap, boolean]
): boolean {
const compareMaps = (map1: ParamMap, map2: ParamMap) => {
if (map1.keys.length !== map2.keys.length) {
return false;
}
return map1.keys.every((key: string) => map1.get(key) === map2.get(key));
};

return (
compareMaps(oldParams, newParams) &&
compareMaps(oldQueryParams, newQueryParams) &&
oldEditParam === newEditParam
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,16 @@
(click)="startParse()"
i18n-label
/>
@if ((viewMode === 'edit' || viewMode === 'add') && userProblem) {
@if (appMode === 'browse') {
<la-icon-button
[icon]="faCopy"
buttonStyle="secondary"
label="Copy problem"
(click)="copyProblem()"
i18n-label
/>
}
@if ((appMode === 'edit' || appMode === 'add') && userProblem) {
<la-icon-button
[icon]="faFloppyDisk"
label="Save problem"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ describe("AnnotationInputComponent", () => {
it('should build form with correct structure and values from problem data', () => {
const mockProblem: Problem = {
id: 123,
base: null,
premises: ["First premise", "Second premise"],
hypothesis: "Test hypothesis",
entailmentLabel: EntailmentLabel.ENTAILMENT,
Expand Down Expand Up @@ -104,6 +105,7 @@ describe("AnnotationInputComponent", () => {
it('should handle empty premises and kbItems arrays', () => {
const mockProblem: Problem = {
id: 123,
base: null,
premises: [],
hypothesis: "Empty test hypothesis",
entailmentLabel: EntailmentLabel.NEUTRAL,
Expand All @@ -127,6 +129,7 @@ describe("AnnotationInputComponent", () => {
it('should create form controls with required validators', () => {
const mockProblem: Problem = {
id: 1,
base: null,
premises: ["Test premise"],
hypothesis: "Test hypothesis",
entailmentLabel: EntailmentLabel.CONTRADICTION,
Expand All @@ -148,6 +151,7 @@ describe("AnnotationInputComponent", () => {
it('should navigate when problem ID is different from current route', () => {
const mockProblem: Problem = {
id: 12,
base: null,
premises: [],
hypothesis: "",
entailmentLabel: EntailmentLabel.UNKNOWN,
Expand All @@ -167,6 +171,7 @@ describe("AnnotationInputComponent", () => {
it('should not navigate when problem ID matches current route', () => {
const mockProblem: Problem = {
id: 17,
base: null,
premises: [],
hypothesis: "",
entailmentLabel: EntailmentLabel.UNKNOWN,
Expand Down
Loading