From 3882d846c5280eaf3e9b270ec932ee8913da1948 Mon Sep 17 00:00:00 2001 From: eeeeeaaan Date: Wed, 26 Aug 2026 01:49:01 +0900 Subject: [PATCH] feat(sign): add a global DB-backed store for DTW word reference samples Custom sign-language words (the DTW-matched reference samples the frontend records for words the base recognizer doesn't know) have only ever lived in each browser's localStorage -- one signer's recorded vocabulary never carried over to a different device or browser, and looked like it "synced" only because a Cloudflare Pages preview branch reuses the same origin across redeploys. Add a global store instead: this app has no per-user auth, so a word is meant to become recognizable for everyone once recorded, not scoped per account. SignWordReference is a JSON-blob entity (one row per word, samples stored as JSON exactly in the shape the frontend's DTW matcher already produces: samples -> frames -> 150 landmark features) rather than a normalized schema, since the data is meaningless without the frontend code that produced it. Endpoints under /api/sign-language/references: - GET: word -> full sample set, for the frontend to load into an in-memory matching cache (DTW runs every video frame client-side, so recognition itself must stay local -- this is a sync point, not a per-frame lookup) - PUT /{word}: replaces a word's entire sample set, mirroring the frontend's recording take (supersede, not accumulate) - DELETE /{word}: removes a word Co-Authored-By: Claude Sonnet 5 --- .../unithon/sign/api/SignWordController.java | 47 ++++++++ .../application/SignWordReferenceService.java | 56 +++++++++ .../sign/domain/SignWordReference.java | 67 +++++++++++ .../SignWordReferenceRepository.java | 13 ++ .../sign/api/SignWordControllerTests.java | 111 ++++++++++++++++++ 5 files changed, 294 insertions(+) create mode 100644 src/main/java/com/ssu/unithon/sign/api/SignWordController.java create mode 100644 src/main/java/com/ssu/unithon/sign/application/SignWordReferenceService.java create mode 100644 src/main/java/com/ssu/unithon/sign/domain/SignWordReference.java create mode 100644 src/main/java/com/ssu/unithon/sign/infrastructure/persistence/SignWordReferenceRepository.java create mode 100644 src/test/java/com/ssu/unithon/sign/api/SignWordControllerTests.java diff --git a/src/main/java/com/ssu/unithon/sign/api/SignWordController.java b/src/main/java/com/ssu/unithon/sign/api/SignWordController.java new file mode 100644 index 0000000..55a0080 --- /dev/null +++ b/src/main/java/com/ssu/unithon/sign/api/SignWordController.java @@ -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>>> 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>> samples) {} +} diff --git a/src/main/java/com/ssu/unithon/sign/application/SignWordReferenceService.java b/src/main/java/com/ssu/unithon/sign/application/SignWordReferenceService.java new file mode 100644 index 0000000..d65dd64 --- /dev/null +++ b/src/main/java/com/ssu/unithon/sign/application/SignWordReferenceService.java @@ -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>>> 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>>> listAll() { + Map>>> 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>> 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))); + } + + @Transactional + public void delete(String word) { + repository.deleteByWord(word); + } +} diff --git a/src/main/java/com/ssu/unithon/sign/domain/SignWordReference.java b/src/main/java/com/ssu/unithon/sign/domain/SignWordReference.java new file mode 100644 index 0000000..947343e --- /dev/null +++ b/src/main/java/com/ssu/unithon/sign/domain/SignWordReference.java @@ -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; + } +} diff --git a/src/main/java/com/ssu/unithon/sign/infrastructure/persistence/SignWordReferenceRepository.java b/src/main/java/com/ssu/unithon/sign/infrastructure/persistence/SignWordReferenceRepository.java new file mode 100644 index 0000000..f0e5b31 --- /dev/null +++ b/src/main/java/com/ssu/unithon/sign/infrastructure/persistence/SignWordReferenceRepository.java @@ -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 { + + Optional findByWord(String word); + + void deleteByWord(String word); +} diff --git a/src/test/java/com/ssu/unithon/sign/api/SignWordControllerTests.java b/src/test/java/com/ssu/unithon/sign/api/SignWordControllerTests.java new file mode 100644 index 0000000..ba4c59c --- /dev/null +++ b/src/test/java/com/ssu/unithon/sign/api/SignWordControllerTests.java @@ -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(); + } +}