Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ object FarmingRecordVoiceToolSchema {
"name" to "save_farming_record",
"description" to
"사용자가 말한 영농 작업 내용을 영농일지 항목으로 구조화한다. " +
"누락된 필수 항목이 있으면 이 도구를 호출하지 말고 사용자에게 먼저 되물어라.",
"필수 항목은 farmId·cropId·workType·memo이며, 작업 유형별 상세(planting/harvest 등)는 " +
"해당 유형일 때만 필요하다. 이 필수 항목이 누락됐으면 호출하지 말고 먼저 되물어라. " +
"다만 WATERING·WEEDING·PRUNING·ETC(기타)처럼 별도 상세가 없는 유형은 필수 4개만 갖춰지면 " +
"상세를 기다리지 말고 곧바로 이 도구를 호출한다.",
"parameters" to mapOf(
"type" to "object",
"properties" to mapOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,20 @@ object VoiceSessionInstructions {
seedAmountUnit)만 묻고, 모종 심기(SEEDLING)면 모종수(seedlingCount/seedlingUnit)만 물으세요.
번식법(propagationMethod)은 모종 심기일 때만 선택적으로 물을 수 있습니다.
반대쪽 항목은 절대 채우지 마세요.
- WATERING(물주기)·WEEDING(제초)·PRUNING(가지치기)·ETC(기타)는 반드시 필요한 상세가 없습니다.
이 유형들은 상세를 더 묻거나 기다리지 말고, 농지·작물이 확인되면 memo를 작성해 곧바로
save_farming_record를 호출하세요. 특히 ETC는 물어볼 상세가 없다는 이유로 대화를 계속 끌지 마세요.
""".trimIndent()

// 예외 상황을 사용자가 반드시 인지하도록 하는 규칙. 조용히 넘기거나 값을 지어내지 않는다.
private val EXCEPTION_HANDLING = """
예외 상황이 생겨도 사용자가 반드시 인지하도록, 조용히 넘기거나 임의로 값을 지어내지 말고
항상 말로 알리세요:
- 작업 유형을 특정할 수 없으면 특정 유형에 억지로 맞추지 말고 ETC(기타)로 분류하고,
"그 작업은 특정 유형에 안 맞아서 '기타'로 기록할게요"처럼 알리세요.
"그 작업은 특정 유형에 안 맞아서 '기타'로 기록할게요"처럼 알리세요. ETC로 분류했더라도
거기서 멈추지 말고, 다른 작업 유형과 똑같이 농지(farmId)·작물(cropId)을 확인하고(모르면
물어보고) 대화 내용으로 memo를 작성해 save_farming_record를 반드시 호출하세요. ETC는
별도 상세 정보가 없을 뿐, 기록을 저장하는 절차는 다른 유형과 완전히 동일합니다.
- 사용자가 말한 값이 선택지에 없으면 임의로 만들지 말고, 가장 비슷한 항목이 있으면
"○○ 말씀이신가요?"로 확인하고, 마땅한 항목이 없으면 비우거나 기타로 두겠다고 알리세요.
- 숫자만 말하고 단위가 분명하지 않으면 단위를 되물으세요(예: "20이면 ml인가요, g인가요?").
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,13 @@ class FarmingRecordVoiceToolSchemaTest {
fun `최상위 필수 필드는 변하지 않는다`() {
assertThat(parameters()["required"]).isEqualTo(listOf("farmId", "cropId", "workType", "memo"))
}

@Test
fun `상세 없는 유형은 필수 4개만으로 곧바로 호출하라고 안내한다`() {
// ETC 등 상세 없는 유형에서 모델이 저장 도구를 끝내 호출하지 않던 버그 방지 회귀 테스트.
val description = schema["description"] as String

assertThat(description).contains("ETC")
assertThat(description).contains("곧바로 이 도구를 호출")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,24 @@ class VoiceSessionInstructionsTest {
assertThat(instructions).contains("ETC(기타)로 분류")
}

@Test
fun `ETC로 분류해도 농지·작물·memo를 채워 save_farming_record를 호출하도록 지시한다`() {
val instructions = build()

// ETC를 분류만 하고 멈춰 tool 호출이 안 돼 후보(candidate)가 비던 버그 방지 회귀 테스트.
assertThat(instructions).contains("ETC로 분류했더라도")
assertThat(instructions).contains("save_farming_record를 반드시 호출")
}

@Test
fun `상세 없는 유형은 상세를 기다리지 말고 곧바로 저장하도록 지시한다`() {
val instructions = build()

// 상세 없는 유형(특히 ETC)에서 모델이 대화만 끌고 저장을 안 하던 버그 방지 회귀 테스트.
assertThat(instructions).contains("반드시 필요한 상세가 없습니다")
assertThat(instructions).contains("대화를 계속 끌지 마세요")
}

@Test
fun `못 처리한 예외를 사용자에게 인지시키는 규칙을 포함한다`() {
val instructions = build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,51 +42,54 @@ struct CropSelectionBody: View {
@State private var selectedCategoryCode: String?
@State private var selectionLimitMessage: String?

/// `crops` sorted once whenever it changes, so tab switches and searches only filter — they never re-sort.
@State private var sortedCrops: [Crop] = []
/// O(1) id → crop lookup for the selected-chip labels, instead of a linear scan per chip.
@State private var cropsByID: [UUID: Crop] = [:]

private var selectedCount: Int { selectedCropIDs.count }

/// `nil` means the "전체" tab: no category filter applied.
private var filteredCrops: [Crop] {
let selectedCode = selectedCategory?.code
let byCategory = selectedCode.map { code in
crops.filter { $0.categoryCode == code }
} ?? crops
let byCategory = selectedCategoryCode.map { code in
sortedCrops.filter { $0.categoryCode == code }
} ?? sortedCrops

let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
let filtered = keyword.isEmpty
return keyword.isEmpty
? byCategory
: byCategory.filter { $0.name.localizedCaseInsensitiveContains(keyword) }

return filtered.sorted { lhs, rhs in
lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
}
}

private var selectedCategory: CropCategory? {
guard !categories.isEmpty else { return nil }
if let selectedCategoryCode,
let category = categories.first(where: { $0.code == selectedCategoryCode }) {
return category
}
return categories.first
/// Tab 0 is always "전체"; tabs 1... mirror `categories`.
private var categoryTabTitles: [String] {
["전체"] + categories.map(\.label)
}

private var selectedCategoryIndex: Int {
guard let selectedCategory else { return 0 }
return categories.firstIndex { $0.code == selectedCategory.code } ?? 0
guard let selectedCategoryCode,
let index = categories.firstIndex(where: { $0.code == selectedCategoryCode }) else {
return 0
}
return index + 1
}

private var selectedCategoryBinding: Binding<Int> {
Binding {
selectedCategoryIndex
} set: { index in
guard categories.indices.contains(index) else { return }
selectedCategoryCode = categories[index].code
if index == 0 {
selectedCategoryCode = nil
} else if categories.indices.contains(index - 1) {
selectedCategoryCode = categories[index - 1].code
}
selectionLimitMessage = nil
}
}

private var selectedCropChips: [SelectedCropChip] {
selectedCropIDs.map { cropID in
let cropName = crops.first(where: { $0.id == cropID })?.name ?? "선택 작물"
let cropName = cropsByID[cropID]?.name ?? "선택 작물"
return SelectedCropChip(id: cropID, name: cropName)
}
}
Expand Down Expand Up @@ -125,13 +128,16 @@ struct CropSelectionBody: View {
// 키보드가 하단 트레이(선택 칩 + CTA)를 밀어 올리지 않도록 고정한다 (SearchView와 동일 패턴).
.ignoresSafeArea(.keyboard, edges: .bottom)
}
.task {
selectedCategoryCode = selectedCategoryCode ?? categories.first?.code
.task(id: crops) {
rebuildCropCaches()
}
.onChange(of: categories) { _, categories in
guard selectedCategoryCode == nil else { return }
selectedCategoryCode = categories.first?.code
}

private func rebuildCropCaches() {
sortedCrops = crops.sorted { lhs, rhs in
lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
}
cropsByID = Dictionary(uniqueKeysWithValues: crops.map { ($0.id, $0) })
}

@ViewBuilder
Expand Down Expand Up @@ -212,23 +218,13 @@ struct CropSelectionBody: View {
.frame(height: 56)
}

@ViewBuilder
private var categoryTabs: some View {
if categories.isEmpty {
Text("카테고리를 불러오는 중...")
.appTypography(.labelMedium)
.foregroundStyle(Color.Text.muted)
.frame(height: 56)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
} else {
AppTabBar(
titles: categories.map(\.label),
selection: selectedCategoryBinding,
scrollable: true
)
.frame(height: 56)
}
AppTabBar(
titles: categoryTabTitles,
selection: selectedCategoryBinding,
scrollable: true
)
.frame(height: 56)
}

private func cropRow(_ crop: Crop) -> some View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ struct RecordCropFilterSheet: View {
}

var body: some View {
AppFilterSheetScaffold(title: "진행중인 작물", height: 274) {
AppFilterSheetScaffold(title: "진행중인 작물") {
if crops.isEmpty {
Text("진행중인 작물이 없어요.")
.appTypography(.bodyMedium)
Expand Down Expand Up @@ -87,7 +87,7 @@ struct RecordWorkTypeFilterSheet: View {
}

var body: some View {
AppFilterSheetScaffold(title: "영농 활동", height: 274) {
AppFilterSheetScaffold(title: "영농 활동") {
AppFlowLayout(spacing: Spacing.sm, lineSpacing: Spacing.sm) {
ForEach(WorkType.allCases, id: \.self) { workType in
let selected = draft.contains(workType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ struct ReportWorkTypeFilterSheet: View {
}

var body: some View {
AppFilterSheetScaffold(title: "영농 활동", height: 274) {
AppFilterSheetScaffold(title: "영농 활동") {
AppFlowLayout(spacing: Spacing.sm, lineSpacing: Spacing.sm) {
ForEach(WorkType.allCases, id: \.self) { workType in
let selected = draft.contains(workType)
Expand Down
Loading