Repository navigation
feat(sign): global DB-backed store for DTW word reference samples #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| package com.ssu.unithon.sign.api; | ||
|
|
||
| import com.ssu.unithon.sign.application.SignWordReferenceService; | ||
| import jakarta.validation.Valid; | ||
| import jakarta.validation.constraints.NotEmpty; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.web.bind.annotation.DeleteMapping; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.PutMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.ResponseStatus; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| /** Global custom-word DTW reference store -- see SignWordReferenceService for why it's global. */ | ||
| @RestController | ||
| @RequestMapping("/api/sign-language/references") | ||
| public class SignWordController { | ||
|
|
||
| private final SignWordReferenceService service; | ||
|
|
||
| public SignWordController(SignWordReferenceService service) { | ||
| this.service = service; | ||
| } | ||
|
|
||
| @GetMapping | ||
| public Map<String, List<List<List<Double>>>> list() { | ||
| return service.listAll(); | ||
| } | ||
|
|
||
| @PutMapping("/{word}") | ||
| @ResponseStatus(HttpStatus.NO_CONTENT) | ||
| public void replace(@PathVariable String word, @Valid @RequestBody ReplaceRequest request) { | ||
| service.replaceSamples(word, request.samples()); | ||
| } | ||
|
|
||
| @DeleteMapping("/{word}") | ||
| @ResponseStatus(HttpStatus.NO_CONTENT) | ||
| public void delete(@PathVariable String word) { | ||
| service.delete(word); | ||
| } | ||
|
|
||
| record ReplaceRequest(@NotEmpty List<@NotEmpty List<@NotEmpty List<Double>>> samples) {} | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| package com.ssu.unithon.sign.application; | ||
|
|
||
| import com.ssu.unithon.sign.domain.SignWordReference; | ||
| import com.ssu.unithon.sign.infrastructure.persistence.SignWordReferenceRepository; | ||
| import java.time.Instant; | ||
| import java.util.LinkedHashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import tools.jackson.core.type.TypeReference; | ||
| import tools.jackson.databind.ObjectMapper; | ||
|
|
||
| /** | ||
| * Global DTW reference-sample store: word -> samples -> frames -> 150 landmark features, one | ||
| * row per word (see SignWordReference). "Global" is deliberate -- this app has no per-user | ||
| * auth, so a word one signer records is meant to become recognizable for everyone, replacing | ||
| * the earlier per-browser localStorage copy. | ||
| */ | ||
| @Service | ||
| public class SignWordReferenceService { | ||
|
|
||
| private static final TypeReference<List<List<List<Double>>>> SAMPLES_TYPE = new TypeReference<>() {}; | ||
|
|
||
| private final SignWordReferenceRepository repository; | ||
| private final ObjectMapper json; | ||
|
|
||
| public SignWordReferenceService(SignWordReferenceRepository repository, ObjectMapper json) { | ||
| this.repository = repository; | ||
| this.json = json; | ||
| } | ||
|
|
||
| public Map<String, List<List<List<Double>>>> listAll() { | ||
| Map<String, List<List<List<Double>>>> result = new LinkedHashMap<>(); | ||
| for (SignWordReference reference : repository.findAll()) { | ||
| result.put(reference.getWord(), json.readValue(reference.getSamplesJson(), SAMPLES_TYPE)); | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| /** Replaces a word's entire sample set -- mirrors the frontend's recording take, which is meant to supersede whatever was there before, not accumulate alongside it. */ | ||
| @Transactional | ||
| public void replaceSamples(String word, List<List<List<Double>>> samples) { | ||
| String samplesJson = json.writeValueAsString(samples); | ||
| Instant now = Instant.now(); | ||
| repository.findByWord(word) | ||
| .ifPresentOrElse( | ||
| existing -> existing.replaceSamples(samplesJson, now), | ||
| () -> repository.save(new SignWordReference(word, samplesJson, now))); | ||
|
Comment on lines
+46
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- service ---'
cat -n src/main/java/com/ssu/unithon/sign/application/SignWordReferenceService.java | sed -n '1,110p'
printf '%s\n' '--- related declarations ---'
rg -n -S --glob '*.java' 'class SignWordReference|interface .*Repository|findByWord|replaceSamples|SignWordReferenceService|`@Transactional`|unique|UniqueConstraint' src/main/java src/test 2>/dev/null | sed -n '1,240p'Repository: OpenWindowUnithon/backend Length of output: 7756 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- entity ---'
cat -n src/main/java/com/ssu/unithon/sign/domain/SignWordReference.java | sed -n '1,100p'
printf '%s\n' '--- repository ---'
cat -n src/main/java/com/ssu/unithon/sign/infrastructure/persistence/SignWordReferenceRepository.java
printf '%s\n' '--- controller ---'
cat -n src/main/java/com/ssu/unithon/sign/api/SignWordController.java | sed -n '1,90p'
printf '%s\n' '--- exception mappings ---'
rg -n -S --glob '*.java' 'DataIntegrityViolationException|ConstraintViolationException|ExceptionHandler|ResponseStatusException|GlobalException|`@RestControllerAdvice`' src/main/java src/test 2>/dev/null | sed -n '1,180p'Repository: OpenWindowUnithon/backend Length of output: 5927 Make first-write replacement atomic. Concurrent calls to Use a database upsert or retryable conflict strategy. Add an integration test for simultaneous first writes. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| @Transactional | ||
| public void delete(String word) { | ||
| repository.deleteByWord(word); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| package com.ssu.unithon.sign.domain; | ||
|
|
||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.GeneratedValue; | ||
| import jakarta.persistence.GenerationType; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.Lob; | ||
| import jakarta.persistence.Table; | ||
| import java.time.Instant; | ||
| import java.util.UUID; | ||
|
|
||
| /** | ||
| * One custom sign-language word's DTW reference samples, shared globally across every signer | ||
| * (there is no per-user auth in this app, so "record it once, everyone gets it" is the | ||
| * intended scope) rather than kept in each browser's localStorage. samplesJson holds the full | ||
| * sample array (word -> samples -> frames -> 150 landmark features) exactly as the frontend's | ||
| * DTW matcher already shapes it; this entity is a dumb JSON blob store, not a normalized | ||
| * schema -- the samples are meaningless without the frontend's DTW code that produced them. | ||
| */ | ||
| @Entity | ||
| @Table(name = "sign_word_references") | ||
| public class SignWordReference { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.UUID) | ||
| private UUID id; | ||
|
|
||
| @Column(nullable = false, unique = true) | ||
| private String word; | ||
|
|
||
| @Lob | ||
| @Column(nullable = false) | ||
| private String samplesJson; | ||
|
|
||
| @Column(nullable = false) | ||
| private Instant updatedAt; | ||
|
|
||
| protected SignWordReference() {} | ||
|
|
||
| public SignWordReference(String word, String samplesJson, Instant now) { | ||
| this.word = word; | ||
| this.samplesJson = samplesJson; | ||
| updatedAt = now; | ||
| } | ||
|
|
||
| public UUID getId() { | ||
| return id; | ||
| } | ||
|
|
||
| public String getWord() { | ||
| return word; | ||
| } | ||
|
|
||
| public String getSamplesJson() { | ||
| return samplesJson; | ||
| } | ||
|
|
||
| public Instant getUpdatedAt() { | ||
| return updatedAt; | ||
| } | ||
|
|
||
| public void replaceSamples(String samplesJson, Instant now) { | ||
| this.samplesJson = samplesJson; | ||
| updatedAt = now; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.ssu.unithon.sign.infrastructure.persistence; | ||
|
|
||
| import com.ssu.unithon.sign.domain.SignWordReference; | ||
| import java.util.Optional; | ||
| import java.util.UUID; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface SignWordReferenceRepository extends JpaRepository<SignWordReference, UUID> { | ||
|
|
||
| Optional<SignWordReference> findByWord(String word); | ||
|
|
||
| void deleteByWord(String word); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| package com.ssu.unithon.sign.api; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; | ||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | ||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | ||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | ||
|
|
||
| import com.ssu.unithon.sign.infrastructure.persistence.SignWordReferenceRepository; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.springframework.beans.factory.annotation.Autowired; | ||
| import org.springframework.boot.test.context.SpringBootTest; | ||
| import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.test.web.servlet.MockMvc; | ||
| import tools.jackson.databind.JsonNode; | ||
| import tools.jackson.databind.ObjectMapper; | ||
|
|
||
| @SpringBootTest(properties = { | ||
| "spring.datasource.url=jdbc:h2:mem:sign-words;DB_CLOSE_DELAY=-1", | ||
| "livekit.url=wss://test", | ||
| "livekit.api-key=test-key", | ||
| "livekit.api-secret=test-secret", | ||
| "livekit.agent-name=test-agent" | ||
| }) | ||
| @AutoConfigureMockMvc | ||
| class SignWordControllerTests { | ||
|
|
||
| @Autowired | ||
| private MockMvc mvc; | ||
|
|
||
| @Autowired | ||
| private ObjectMapper json; | ||
|
|
||
| @Autowired | ||
| private SignWordReferenceRepository repository; | ||
|
|
||
| @BeforeEach | ||
| void reset() { | ||
| repository.deleteAll(); | ||
| } | ||
|
|
||
| @Test | ||
| void listing_starts_empty() throws Exception { | ||
| String response = mvc.perform(get("/api/sign-language/references")) | ||
| .andExpect(status().isOk()) | ||
| .andReturn().getResponse().getContentAsString(); | ||
|
|
||
| assertThat(json.readTree(response).properties()).isEmpty(); | ||
| } | ||
|
|
||
| @Test | ||
| void replacing_a_word_makes_it_listable_and_replaces_prior_samples() throws Exception { | ||
| mvc.perform(put("/api/sign-language/references/병원") | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .content(""" | ||
| {"samples": [[[0.1, 0.2, 0.3]], [[0.4, 0.5, 0.6]]]} | ||
| """)) | ||
| .andExpect(status().isNoContent()); | ||
|
|
||
| String firstListing = mvc.perform(get("/api/sign-language/references")) | ||
| .andExpect(status().isOk()) | ||
| .andReturn().getResponse().getContentAsString(); | ||
| JsonNode firstBody = json.readTree(firstListing); | ||
| assertThat(firstBody.get("병원")).hasSize(2); | ||
|
|
||
| // A second take with fewer samples fully replaces the first, not appends to it. | ||
| mvc.perform(put("/api/sign-language/references/병원") | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .content(""" | ||
| {"samples": [[[0.9, 0.9, 0.9]]]} | ||
| """)) | ||
| .andExpect(status().isNoContent()); | ||
|
|
||
| String secondListing = mvc.perform(get("/api/sign-language/references")) | ||
| .andExpect(status().isOk()) | ||
| .andReturn().getResponse().getContentAsString(); | ||
| JsonNode secondBody = json.readTree(secondListing); | ||
| assertThat(secondBody.get("병원")).hasSize(1); | ||
| assertThat(secondBody.get("병원").get(0).get(0).get(0).asDouble()).isEqualTo(0.9); | ||
| } | ||
|
|
||
| @Test | ||
| void rejects_an_empty_sample_list() throws Exception { | ||
| mvc.perform(put("/api/sign-language/references/병원") | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .content(""" | ||
| {"samples": []} | ||
| """)) | ||
| .andExpect(status().isBadRequest()); | ||
| } | ||
|
|
||
| @Test | ||
| void deleting_a_word_removes_it_from_the_listing() throws Exception { | ||
| mvc.perform(put("/api/sign-language/references/병원") | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .content(""" | ||
| {"samples": [[[0.1, 0.2, 0.3]]]} | ||
| """)) | ||
| .andExpect(status().isNoContent()); | ||
|
|
||
| mvc.perform(delete("/api/sign-language/references/병원")) | ||
| .andExpect(status().isNoContent()); | ||
|
|
||
| String response = mvc.perform(get("/api/sign-language/references")) | ||
| .andExpect(status().isOk()) | ||
| .andReturn().getResponse().getContentAsString(); | ||
| assertThat(json.readTree(response).properties()).isEmpty(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: OpenWindowUnithon/backend
Length of output: 23631
🏁 Script executed:
Repository: OpenWindowUnithon/backend
Length of output: 10819
Validate each DTW frame before persistence.
SignWordController.replacepassesrequest.samples()directly toSignWordReferenceService.replaceSamples, which serializes it intosamplesJson. The nested@NotEmptyconstraints do not enforce 150 features or non-null values, so[[[0.1]]]and[[[null]]]can be stored and returned globally.Use
@Size(min = 150, max = 150) List<@NotNullDouble>for each frame. UpdateSignWordControllerTestswith valid 150-feature fixtures and rejection cases for short frames and null features.🤖 Prompt for AI Agents