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
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
| 2026-11-05 | v3.98 | **사전: 고친 낱말 제안(PR6, 소유자 선택: 자동 바꾸기 없이 제안만).** Rust `meetings::corrections`가 녹음 노트(메모·예시 노트 제외)에서 `corrected_text`가 원문과 다른 줄을 [원문, 고친 문장]으로 모아(5000쌍까지) `glossary_corrections`로 넘기고, 화면은 기존 `vocab.termFromEdit`(낱말 1~2개·소리가 비슷함·용어처럼 보임)로 낱말을 골라 `dictionary.suggestFromCorrections`가 세고 거른다(사전에 있음·무시함 제외, 많이 고친 순 8개). 사전 화면에 `고쳐 쓴 낱말` 칩: 누르면 추가, ×는 무시(localStorage). 글자를 저절로 바꾸지 않아 측정이 필요 없다. 규칙이 화면 한 곳(vocab.ts)에만 있도록 코어 명령 대신 Rust가 원문만 읽어 넘긴다. |
| 2026-11-05 | v3.99 | **의존성 줄이기: onnxruntime을 뺐다(소유자 선택: 지금 모델을 MLX로).** 목소리 프로필의 CAM++ ONNX를 `onnx_mlx`가 그대로 실행한다: protobuf에서 그래프·가중치·메타데이터를 직접 읽고(필드 몇 개뿐이라 `onnx` 패키지 없이), 이 모델이 쓰는 26개 연산만 구현한다(모양 계산은 numpy, 텐서는 MLX, 모르는 연산은 실행 전 오류). 받는 파일·sha256·`MODEL_ID`는 그대로라 **등록한 목소리를 다시 등록하지 않는다**. onnxruntime 1.30과 비교: 무작위 입력 5가지 길이에서 cosine 1.0·최대 차이 약 1e-5, `bench/voice_eval.py`(AI Hub 4세션 40명 1427발화, 지표만) 결과가 RTF(0.0085 → 0.0082) 말고 모두 같다. 검사: `tests/test_onnx_mlx.py`(테스트 안에서 만든 작은 ONNX로 protobuf·연산을 numpy와 비교, slow는 실제 모델을 onnxruntime으로 미리 뽑아 둔 임베딩과 비교). 후보였던 mlx-community CAM++는 출력 512차원·가중치 6MB로 우리 모델(192차원·28MB)과 달라 쓰지 않았다. 번들 감소량은 다음 번들 빌드에서 잰다(개발 환경의 onnxruntime 폴더는 75MB). |
| 2026-11-06 | v3.100 | **⌘K 넓히기: 이 노트의 동작과 폴더·태그로 가기(브랜치 `feat/palette-note-actions`, 앱만).** 노트를 열었을 때 맨 앞 `이 노트` 묶음: 즐겨찾기(`org.setFavorite`)·태그 편집(`org.requestTagFocus`, 사이드바 줄 메뉴와 같은 통로)·형식별 내보내기·Obsidian. 내보내기는 `useNoteExport`가 MeetingView 안에 있어 App이 `exportReq {id, format, nonce}`를 내려 주고 MeetingView가 노트를 다 읽은 뒤 한 번 `exp.exportAs`/`toObsidian`을 부른다(노트 머리 메뉴와 같은 함수: Markdown·Word는 넣을 내용 고르기 창). 목록이 길어지지 않게 `PaletteAction.typed`(빈 입력에서는 숨기고 찾을 때만)를 두어 빈 입력에서는 Markdown·Word만 보인다. 입력이 있으면 `폴더·태그` 묶음: 고르면 사이드바 거르기를 그 폴더(태그) 하나로 바꾸고(`clearFilters` → `selectFolder`/`toggleTag`) 노트 목록을 펼친다. 폴더로 옮기기·인쇄는 고르는 창·쓰던 메모 저장이 끼어 넣지 않았다. WORDS §4.30. |
| 2026-11-06 | v3.101 | **일정과 노트 잇기(브랜치 `feat/event-note-link`).** v3.96의 이번 주 띠는 일정과 그 일정으로 녹음한 노트를 따로 보였다(노트에 일정 ID가 없었다). `event.json`에 선택 키 `id`(EventKit 일정 ID, 한 줄 256자 이하)를 더했다: 앱 `calendar::clean_event`와 코어 `calendar_event.clean_event`가 둘 다 남긴다(코어가 녹음 폴더 → 노트 폴더로 옮길 때 다시 검사하므로). 노트 목록 `MeetingInfo.event`에 `{id?, title, start}`만 싣는다(`calendar::note_event`, 목록 캐시 안: `event.json`은 두 저장 경로 모두 transcript.json보다 먼저 쓰인다). 화면 `week.linked`: 같은 순간에 시작하고 ID가 같으면(반복 일정은 회차마다 ID가 같아 시각으로 가른다), ID가 없는 옛 노트는 제목이 같으면 잇는다. `groupWeek`가 이어진 노트를 일정(`DayEvent.notes`) 아래로 옮기고 그날 노트에서는 뺀다(`dayNotes`는 둘 다 센다). 미리보기는 세 번째 노트를 화요일 스탠드업에 이었다. 실제 EventKit ID로 녹음해 잇는 것은 이 터미널에 캘린더 권한이 없어 확인하지 않았다(릴리스 번들 확인 때 함께). |
| 2026-10-30 | v3.78 | **한국어 파인튜닝 엔진을 8bit 변환본으로 교체(브랜치 `feat/univa-8bit`).** 0.5.2에 올린 `asr-ko-univa`가 원본 bf16(4.7GB)이었다(변환·업로드 위치를 정하지 않고 허브 원본을 그대로 등록했다). `mlx_audio.convert -q --q-bits 8 --q-group-size 64 --q-mode affine`로 변환해(표준 엔진과 같은 설정, `model.safetensors` 2,463,307,541바이트로 공식 Qwen3-ASR 1.7B 8bit와 같다) 소유자 계정에 `voyagar/Qwen3-ASR-1.7B-ko-mlx-8bit`(공개, Apache-2.0, 커밋 `8fcc32c4`, 모델 카드에 원 제작자·원본 revision·변환 명령)로 올리고 등록 목록을 바꿨다. ID `asr-ko-univa`·`level_note`는 그대로라 사용자가 고른 엔진 설정은 유지되고, 새 폴더(`qwen3-asr-1.7b-ko-8bit`)를 받을 때까지는 받아 둔 다른 엔진으로 변환한다. 0.5.2로 bf16을 받은 폴더(`qwen3-asr-1.7b-ko-bf16`)는 등록 밖이 되어 `models prune`으로 지운다. 간이 점검: 원본과 출력 차이 CER 2.5, 처리 시간 약 30% 단축(docs/design/asr-engines.md). |
| 2026-10-30 | v3.77 | **0.5.2 릴리스 준비(브랜치 `fix/mic-config-change-crash`).** 버전 0.5.2를 다섯 매니페스트와 잠금 파일에 맞췄다. 0.5.1 이후: 한국어 파인튜닝 엔진 `asr-ko-univa` 등록과 모델 화면 변환 엔진 라디오 목록(v3.75), 장치 전환 중 녹음 프로그램 크래시 수정(v3.76, 이슈 #57). 릴리스 노트 `docs/releases/v0.5.2.md`, 로드맵 항목. |
| 2026-10-30 | v3.76 | **녹음 중 입력 장치가 바뀌면 `tractus-rec`가 죽던 것(이슈 #57, 브랜치 `fix/mic-config-change-crash`).** `AVAudioEngineConfigurationChange` → `Recorder.restart` → `MicSource.open`의 `installTap`이 Objective-C 예외(NSException)를 던져 Swift `do/catch`를 지나 프로세스가 abort됐다(66분 녹음 중 크래시, `recording.json`의 `duration`이 null로 남음). ① `RecorderKit.micFormatProblem`: tap 전에 채널·표본 속도 0과 하드웨어 포맷 불일치를 오류로 던진다(`restart`의 경고와 watchdog 2초 재시도로 넘어감). 음성 처리(에코 제거) 중에는 두 포맷이 다를 수 있어 표본 속도 일치는 보지 않는다. ② `MicSource.stop()`마다 새 `AVAudioEngine`으로 바꾸고 관찰자를 다시 단다(장치가 바뀐 뒤 같은 엔진의 입력 노드가 낡은 포맷을 쥐는 것을 피함). ③ 새 SwiftPM 타깃 `ObjCCatch`(Objective-C, `TractusObjC.catching { }`)로 `installTap`을 감싸 NSException을 Swift 오류로 바꾼다(`recorder/`의 tools-version 5.10·플랫폼은 그대로). 이 Mac의 실제 마이크로 일반·에코 제거 녹음이 정상임은 확인했고, **실제 장치 전환(블루투스 연결·해제)은 재현하지 못했다.** 비정상 종료로 `duration`이 null인 녹음은 앱의 `변환 대기 중인 녹음`에 보이고 코어가 그대로 변환 전 노트로 저장한다는 것을 테스트로 고정했다. |
Expand Down
2 changes: 1 addition & 1 deletion WORDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ LLM 프롬프트(요약·AI 질문)도 이름 없는 참석자를 `참석자 N`
| 제목 | `이번 주`. 다른 주를 보면 `{month} {range}일`(예: `2026년 10월 5–11일`) + 단추 `이번 주` |
| 옮기기 단추 (`aria-label`·`title`) | `지난주` / `다음 주` |
| 요일 칸 묶음 / 칸 (`aria-label`) | `요일` / `{day}일 {weekday}요일 · 일정 {e} · 노트 {n}` |
| 고른 날 | `{day}일 {weekday}요일`. 일정 줄은 `09:30–10:00` 또는 `종일` + 제목 + 참석자 줄(§4.24와 같은 `외 n명`), 오늘 시작해 끝나지 않은 일정만 `이 일정으로 녹음`. 노트 줄은 홈 `최근 노트`와 같다 |
| 고른 날 | `{day}일 {weekday}요일`. 일정 줄은 `09:30–10:00` 또는 `종일` + 제목 + 참석자 줄(§4.24와 같은 `외 n명`), 오늘 시작해 끝나지 않은 일정만 `이 일정으로 녹음`. 노트 줄은 홈 `최근 노트`와 같다. 그 일정으로 녹음한 노트는 일정 줄 바로 아래에 들여 쓰고(새 문구 없음) 그날의 노트 줄에서는 빠진다. 칸의 노트 점·개수는 둘 다 센다 |
| 빈 날 | `일정과 노트가 없습니다` |

## 5. 금지어 검사
Expand Down
30 changes: 29 additions & 1 deletion app/src-tauri/src/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const EVENT_FILE: &str = "event.json";
const MAX_TITLE: usize = 200;
const MAX_NAME: usize = 80;
const MAX_ATTENDEES: usize = 50;
const MAX_ID: usize = 256;

/// `tractus-rec calendar <args>` → stdout 한 줄 JSON
fn run(s: &Settings, args: &[String]) -> Result<Value, String> {
Expand Down Expand Up @@ -134,7 +135,22 @@ pub fn clean_event(v: &Value) -> Result<Value, String> {
names.push(n);
}
}
Ok(json!({"title": title, "start": start, "end": end, "calendar": text("calendar", MAX_TITLE).unwrap_or_default(), "attendees": names}))
let mut ev = json!({"title": title, "start": start, "end": end, "calendar": text("calendar", MAX_TITLE).unwrap_or_default(), "attendees": names});
// 캘린더의 일정 ID: 이번 주 띠가 일정과 노트를 잇는다. 모양이 이상하면 버린다 (잇기만 못 할 뿐)
if let Some(id) = v["id"].as_str().filter(|x| !x.is_empty() && x.len() <= MAX_ID && !x.chars().any(char::is_control)) {
ev["id"] = json!(id);
}
Ok(ev)
}

/// 노트 폴더 event.json에서 일정과 노트를 잇는 데 쓰는 것만 (노트 목록 MeetingInfo.event). 없거나 깨졌으면 None
pub fn note_event(note_dir: &Path) -> Option<Value> {
let v = clean_event(&serde_json::from_str::<Value>(&std::fs::read_to_string(note_dir.join(EVENT_FILE)).ok()?).ok()?).ok()?;
let mut link = json!({"title": v["title"], "start": v["start"]});
if v["id"].is_string() {
link["id"] = v["id"].clone();
}
Some(link)
}

pub fn write_event(rec_dir: &Path, event: &Value) -> Result<(), String> {
Expand Down Expand Up @@ -170,6 +186,18 @@ mod tests {
assert!(clean_event(&json!({"title": "", "start": "a", "end": "b"})).is_err());
assert_eq!(clean_event(&json!({"title": "x".repeat(300), "start": "a", "end": "b"})).unwrap()["title"].as_str().unwrap().len(), 200); // 긴 제목은 잘라 쓴다
assert_eq!(clean_event(&json!({"title": "t", "start": "a", "end": "b", "attendees": ["guest@example.com", "김민수"]})).unwrap()["attendees"], json!(["김민수"])); // 이메일은 남기지 않는다
assert_eq!(clean_event(&json!({"id": "E1:2", "title": "t", "start": "a", "end": "b"})).unwrap()["id"], "E1:2");
assert!(clean_event(&json!({"id": "a\nb", "title": "t", "start": "a", "end": "b"})).unwrap().get("id").is_none());
}

#[test]
fn note_event_keeps_only_what_links_a_note() {
let d = std::env::temp_dir().join(format!("tractus-note-event-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
assert!(note_event(&d).is_none());
std::fs::write(d.join(EVENT_FILE), r#"{"id": "E1", "title": "주간", "start": "2026-11-05T09:30:00+09:00", "end": "2026-11-05T10:00:00+09:00", "attendees": ["김민수"]}"#).unwrap();
assert_eq!(note_event(&d), Some(json!({"id": "E1", "title": "주간", "start": "2026-11-05T09:30:00+09:00"})));
std::fs::remove_dir_all(&d).unwrap();
}

#[test]
Expand Down
4 changes: 4 additions & 0 deletions app/src-tauri/src/meetings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ pub struct MeetingInfo {
/// 예시 노트(온보딩)일 때만 싣는다
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub sample: bool,
/// 캘린더 일정으로 시작한 노트: {"id"?, "title", "start"} (event.json, 이번 주 띠가 일정과 잇는다)
#[serde(skip_serializing_if = "Option::is_none")]
pub event: Option<serde_json::Value>,
}

#[derive(Serialize, Debug)]
Expand Down Expand Up @@ -314,6 +317,7 @@ fn info(dir: &Path, t: &Transcript) -> MeetingInfo {
state: recorded_state(t),
tracks_bytes: recorded_state(t).map(|_| dir_bytes(&dir.join("tracks"))),
sample: t.sample == Some(true),
event: crate::calendar::note_event(dir), // event.json은 transcript.json보다 먼저 쓰여 목록 캐시가 놓치지 않는다
}
}

Expand Down
2 changes: 2 additions & 0 deletions app/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export interface MeetingInfo {
tracks_bytes?: number;
/** 예시 노트 (온보딩, `tractus note sample`). 목록에 `예시` 표시 */
sample?: boolean;
/** 캘린더 일정으로 시작한 노트의 일정 (event.json). 이번 주 띠가 일정과 잇는다 */
event?: { id?: string; title: string; start: string };
}

export interface Summary {
Expand Down
36 changes: 24 additions & 12 deletions app/src/components/home/WeekStrip.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// 홈의 `이번 주` 띠 (WORDS.md §4.31): 월~일 7칸에 날마다 캘린더 일정·노트 개수, 고른 날의 목록(일정 → 녹음, 노트 → 열기). 규칙은 week.ts
import { useEffect, useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { AudioLines, CalendarDays, ChevronLeft, ChevronRight, FileText, Mic } from "lucide-react";
import type { CalendarEvent, MeetingInfo } from "../../api";
import { attendeeLine, CARD_RECORD } from "../../calendar";
Expand All @@ -8,7 +8,7 @@ import { GatedButton } from "../../GatedButton";
import { clock, t } from "../../i18n";
import { dayParts } from "../../lib";
import { isMemo, noteTime } from "../../memo";
import { addDays, groupWeek, recordable, weekDays, weekStart, ymd, useWeekEvents, type WeekEvent } from "../../week";
import { addDays, dayNotes, groupWeek, recordable, weekDays, weekStart, ymd, useWeekEvents, type WeekEvent } from "../../week";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/components/ui/utils";

Expand Down Expand Up @@ -70,15 +70,15 @@ export function WeekStrip({ notes, onOpen, onRecordEvent, recordGate, recording
key={d}
type="button"
aria-pressed={d === day}
aria-label={t("{day}일 {weekday}요일 · 일정 {e} · 노트 {n}", { day: dp.day, weekday: dp.weekday, e: g.events.length, n: g.notes.length })}
aria-label={t("{day}일 {weekday}요일 · 일정 {e} · 노트 {n}", { day: dp.day, weekday: dp.weekday, e: g.events.length, n: dayNotes(g) })}
className={cn("flex flex-col items-center gap-1 py-2.5 outline-none transition-colors hover:bg-accent/60 focus-visible:bg-accent", d === day && "bg-accent")}
onClick={() => setPicked(d)}
>
<span className={cn("text-[11px] text-subtle-foreground", isToday && "font-semibold text-rec")}>{dp.weekday}</span>
<span className={cn("grid size-7 place-items-center rounded-full text-[13px] font-semibold tabular-nums", isToday && "bg-rec text-white")}>{dp.day}</span>
<span className="flex h-2 items-center gap-0.5" aria-hidden>
<Dots n={g.events.length} className="bg-primary" />
<Dots n={g.notes.length} className="bg-muted-foreground/50" />
<Dots n={dayNotes(g)} className="bg-muted-foreground/50" />
</span>
</button>
);
Expand All @@ -89,16 +89,15 @@ export function WeekStrip({ notes, onOpen, onRecordEvent, recordGate, recording
{!cur.events.length && !cur.notes.length && <p className="m-0 px-4 pt-1 pb-4 text-[13px] text-subtle-foreground">{t("일정과 노트가 없습니다")}</p>}
<ul className="m-0 list-none p-0 pb-1.5">
{cur.events.map((ev) => (
<EventRow key={`${ev.id}-${ev.start}`} ev={ev} now={now} gate={recordGate} recording={recording} onRecord={() => onRecordEvent(ev)} />
<Fragment key={`${ev.id}-${ev.start}`}>
<EventRow ev={ev} now={now} gate={recordGate} recording={recording} onRecord={() => onRecordEvent(ev)} />
{ev.notes.map((m) => (
<NoteLine key={m.id} m={m} onOpen={onOpen} inEvent />
))}
</Fragment>
))}
{cur.notes.map((m) => (
<li key={m.id}>
<button type="button" className="flex w-full items-center gap-3 px-4 py-2 text-left outline-none hover:bg-accent/60 focus-visible:bg-accent" onClick={() => onOpen(m.id)}>
{isMemo(m) ? <FileText className="size-4 shrink-0 text-muted-foreground" aria-hidden /> : <AudioLines className="size-4 shrink-0 text-muted-foreground" aria-hidden />}
<span className="min-w-0 flex-1 truncate text-[13px]">{m.title || t("제목 없음")}</span>
<span className="shrink-0 text-xs text-subtle-foreground tabular-nums">{noteTime(m)}</span>
</button>
</li>
<NoteLine key={m.id} m={m} onOpen={onOpen} />
))}
</ul>
</div>
Expand All @@ -107,6 +106,19 @@ export function WeekStrip({ notes, onOpen, onRecordEvent, recordGate, recording
);
}

/** 노트 줄. inEvent: 위 일정으로 녹음한 노트 (일정 줄 아래에 들여 쓴다) */
function NoteLine({ m, onOpen, inEvent = false }: { m: MeetingInfo; onOpen: (id: string) => void; inEvent?: boolean }) {
return (
<li>
<button type="button" className={cn("flex w-full items-center gap-3 py-2 pr-4 text-left outline-none hover:bg-accent/60 focus-visible:bg-accent", inEvent ? "pl-11" : "pl-4")} onClick={() => onOpen(m.id)}>
{isMemo(m) ? <FileText className="size-4 shrink-0 text-muted-foreground" aria-hidden /> : <AudioLines className="size-4 shrink-0 text-muted-foreground" aria-hidden />}
<span className="min-w-0 flex-1 truncate text-[13px]">{m.title || t("제목 없음")}</span>
<span className="shrink-0 text-xs text-subtle-foreground tabular-nums">{noteTime(m)}</span>
</button>
</li>
);
}

/** 칸의 점: 3개까지는 점, 넘으면 `+n` */
function Dots({ n, className }: { n: number; className: string }) {
if (!n) return null;
Expand Down
11 changes: 10 additions & 1 deletion app/src/preview/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// 빌드(index.html)에는 들어가지 않는다. `?lang=en`이면 화면도 가짜 데이터도 영어다(README 영어 스크린샷·데모).
import { emit } from "@tauri-apps/api/event";
import { mockIPC, mockWindows } from "@tauri-apps/api/mocks";
import { addDays, weekStart } from "../week";
import dataKo from "./data/meetings.json";
import doctorKo from "./data/doctor.json";
import modelsKo from "./data/models.json";
Expand Down Expand Up @@ -301,6 +302,13 @@ const glossaryAdd = (terms: string[]) => {
return { added: fresh.length, skipped: terms.filter((x) => x.trim().length > 40).length };
};

/** 이번 주 화요일 9:30 (calendar_week의 데일리 스탠드업 s1) */
const standupAt = () => {
const d = addDays(weekStart(new Date()), 1);
d.setHours(9, 30);
return d.toISOString();
};

const handlers: Record<string, (a: T) => unknown> = {
glossary_list: () => ({ terms: glossary, max: 50 }),
// 사전 제안: 사람이 고친 문장 쌍 (온톨로지는 두 번, Grafana 한 번, 말투 고침은 제안되지 않는다)
Expand All @@ -323,7 +331,8 @@ const handlers: Record<string, (a: T) => unknown> = {
rec_send: fakeRecSend,
marks_save: (a) => ((noteMarks = { ...noteMarks, [a.id]: a.marks }), { marks: a.marks }),
summary_start: fakeSummary,
list_meetings: () => notes.map(info),
// 세 번째 노트는 이번 주 화요일 데일리 스탠드업(calendar_week의 s1)으로 녹음한 노트다: 이번 주 띠에서 일정 아래에 보인다
list_meetings: () => notes.map(info).map((m, i) => (i === 2 ? { ...m, event: { id: "s1", title: "", start: standupAt() } } : m)),
note_sample: () => (sampleId = notes.find((n) => n.t.title.includes(Q3))?.t.meeting_id ?? notes[0].t.meeting_id),
list_recordings: () => [],
list_folders: () => [{ id: "f1", name: EN ? "Customers" : "고객사", parent: null, order: 0, created_at: "2026-10-01T09:00:00+09:00" }],
Expand Down
Loading
Loading