Commit Graph

165 Commits

Author SHA1 Message Date
hyungi e9a95934ef feat(study): 카드 검수 그룹핑 — manual(직접 추가) 카드를 자료(material)별 묶음 + source_kind 노출
직접 추가 자료 카드(source_kind='manual', 출처 문제 없음)가 검수 UI에서 null 한 덩어리로
뭉치지 않도록 extra.material 별 그룹("[자료] ...") + CardItem.source_kind 노출(프론트 '직접 추가 자료' 라벨).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:41:13 +09:00
hyungi b9f2ade55e feat(study): 암기카드 검수 UI — 백엔드 카드 review API + SvelteKit /study/cards-review
577 카드(needs_review=true)를 보고 채택/수정/폐기하는 첫 검수 화면(학습 흐름 '마지막 한 칸' 1번).

- 백엔드 app/api/study_cards.py(prefix /api/study-cards): GET(출처 문제별 그룹, evidence 동반)·needs-review/count·PATCH(승인 needs_review=false / 수정 시 dedup_hash 재계산+검수완료)·DELETE(soft)·approve-batch(문제 단위, 전체 일괄승인 없음).
- 프론트 /study/cards-review: 반응형 그룹 목록(문제+카드) · 카드별 승인/수정(인라인)/삭제 · 문제 단위 일괄승인 · format 필터 · 세이지 토큰. study 허브에 진입 링크+대기 카운트 배지.
- 카피 drift 정정: 허브 '예정(Phase 2~)'이 가동 중인 퀴즈/SRS/통계를 잘못 표기 → 예정은 카드 SRS·모바일·알람으로 수정.

검증: 백엔드 부팅+라우트 등록 OK(4 route). 프론트 빌드는 배포 시 vite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:49:11 +09:00
hyungi 19f544fb5e feat(study): 공부 암기노트 Phase 1 — 정정/삭제 훅 + needs_review 큐 + 알람 재료 (HR/A)
추출 파이프라인(287~298, 별 커밋) 위 HR/A. 신규 마이그레이션 0 (DDL은 295~298 재사용).

- HR 정정/삭제 훅: PATCH 본문 수정 → 파생 study_memo_cards needs_review=auto(source_changed),
  soft-DELETE → source_deleted. flag_cards_for_source 헬퍼(임시 플래그, 최종정리는 워커 supersede).
- HR needs_review: PATCH set/clear(flagged_by='user' 서버강제) + GET /study-questions/needs-review
  목록·count(부분인덱스 술어 일치, 동적 {id} 라우트보다 먼저 등록해 int 파싱 충돌 회피).
- A 알람 재료: study_topics.focused_at 공부중 토글 + study_reminder cron(09/13/19 KST, due 술어
  quiz_selection SQL 재현·시간슬롯 truncate 멱등·LLM 0) + GET /api/study-reminders/latest(없으면 204).
- 테스트: 가드/정규화 18/18 (정량=evidence 원문·cue/cloze 누출·dedup·배치).

검증: 앱 부팅 import+mapper OK · 가드 18/18 PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:08:55 +09:00
hyungi aa2d7814e3 feat(digest): date picker URL sync + article→문서 라우팅 + country 국기·한국어
- GET /api/digest/dates 신설 (브리핑 /briefing/dates 패턴 미러, read-only)
- topic article 제목 enrich (documents 배치 1쿼리 + dedupe(set) + map-miss=null → 프론트 '(제목 없음)')
- /digest 재작성: ?date=&country= URL sync(공유·뒤로가기), 국가 탭=인라인 SVG 국기+한국어, 기사=/documents/{id} 링크(상위5+펼치기)
- Phase 4.5(PR #22) 후속. 검증: py_compile·dates/enrich 쿼리(275 resolve·miss 0)·frontend docker build PASS. 시각 렌더 검증=preview 게이트 대기

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:39:07 +00:00
hyungi 2f152911f7 feat(search): /ask corpus_variant + exact_knn (EVAL-ONLY) for passage-RAG diagnose
PR-DocSrv-Hier-PassageRAG-Diagnose-1 c1. /ask evidence retrieval 의 chunk leg 를
측정 뷰(prehier/hier_sim_*)로 교체 + exact_knn — passage evidence 단위(hier 절 vs
legacy 윈도우) 비교용. /search 와 동일 패턴, run_search 전달. EVAL-ONLY 박제,
default(미지정) 시 기존 /ask byte/behavior 동일(회귀 0). pattern 검증 → 잘못된 값 422.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 06:14:59 +00:00
hyungi 100aaa3b0c feat(search): corpus_variant + exact_knn measurement dispatch (replace-diagnose c4+c5)
PR-DocSrv-Hier-Replace-Diagnose-1 c4+c5. hier vs prehier(legacy) go/no-go 비파괴 측정 hook.
- 측정 뷰 3종 (hier_measure_views.sql, additive/droppable): corpus_chunks_prehier
  (legacy+null-source 375 포함) / hier_sim_raw / hier_sim_clean (childless-tiny<30 제외,
  all-tiny doc 은 legacy fallback 정합).
- retrieval_service: _resolve_corpus_variant + CORPUS_VARIANT_MAP + _VALID_CHUNKS_TABLE
  3 뷰 추가 + exact_knn(SET LOCAL enable_indexscan/bitmapscan=off, eval 전용).
  chunk leg 만 영향 (doc-level + fts/trgm = documents 무관). baseline/None path 회귀 0.
- search_pipeline.run_search + search.py: corpus_variant/exact_knn 전달, unknown→400,
  embedding_backend cand 와 동시 사용 금지(400).
- run_eval: --corpus-variant + --exact-knn flag.
- tests/test_corpus_variant.py 22 PASS (resolver/map/allowlist + SQL injection 거부).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 05:37:15 +00:00
hyungi f7198d9d68 feat(search): expose hier section outline & summaries in document detail
PR-DocSrv-Hier-Section-UI-1 Phase 1 (코드+커밋만, 배포는 Phase 2 backfill 완주 후).

- backend: GET /documents/{id}/sections — hier leaf 목차 + chunk_section_analysis
  요약. document_chunks 직접 조회(retrieval 아닌 목차 표시라 corpus_chunks 뷰
  의도적 우회 — docstring 명시). DISTINCT ON 으로 최신 분석 1행.
- frontend: SectionOutline.svelte(좌측 목차, per-doc 동적 그룹/flat, window
  dedupe, 클릭 시 요약/breadcrumb 인라인), headingPath.ts 순수 유틸(+node:test
  단위테스트 8케이스). [id]/+page.svelte 3-zone 레이아웃 + 우측 메타 Tabs
  [정보|AI|관리] 로 카드 스프롤 해소.
- 절 없는 문서/404 는 목차 숨김(graceful). 본문 점프는 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:22:34 +00:00
hyungi c4a40ab18a docs(search): Phase 2Q closed as evaluated experiment (deprecated, not recommended for production)
사용자 결정 (2026-05-24, measurement chain 4-layer 정정 완료 후):

> Phase 2Q Query Rewrite is closed as an evaluated experiment.
> After result-level dedup correction, true net gain was marginal
> (NDCG +0.019, Recall t≥2 +0.030) while latency cost was high
> (cold +876%, warm +320%). Therefore, multi-query rewrite is not
> recommended for default production rollout. Keep opt-in path as
> experimental/deprecated reference only; do not proceed to
> Cache-Prewarm unless future real-query evidence shows a stronger gain.

변경:
- docs/phase_2q_apply_opt_in.md: 🛑 DEPRECATED / EXPERIMENTAL status 박제. measurement chain
  정정 history (4-layer) + 진짜 효과 + Phase 2Q 성과 보존.
- app/api/search.py: rewrite_backend query param description 갱신 (⚠️ EXPERIMENTAL/DEPRECATED,
  production 추천 문구 제거, opt-in 실험 reference 만 유지 명시).

5 액션 박제 (사용자 결정):
  1. opt-in 코드 유지 (recommended=false / experimental)
  2. docs/ deprecated 박제
  3. search.py description production 추천 제거
  4. PR-2Q-Cache-Prewarm + PR-2Q-Apply-Default-ON-1 폐기
  5. Extended 4건 중 SynonymDict (deterministic, LLM 우회) 만 별도 후보 보존

신규 feedback memory: [[feedback_measurement_chain_audit]] — Diagnose 측정이 Apply/rollout
결정 기준일 때 retrieval/fusion/rerank/eval 모든 layer audit 필수. Phase 2Q 4-iteration
정정 chain (0.927→0.876→0.641→0.663) origin.

Phase 2Q 성과 (실패가 아닌 좋은 실험):
- chunk_id/doc_id 중복 inflation 발견 + measurement chain audit pattern 확립
- LLM rewrite 는 현재 DS 검색 기본값으로는 ROI 낮음 결론 확보
- search_pipeline 의 multi-query 합성 + 3-layer dedup 인프라 보존 (Extended SynonymDict
  또는 미래 cloud LLM scaffold 재사용 가능)
- 신규 feedback memory 4건: fixture-first-call-shape / apply-prereq-structural-fix /
  graded-ndcg-dedup-invariant / measurement-chain-audit

main 위 직접 commit (read-only docs / API description, retrieval path 영향 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:57:11 +00:00
hyungi 59bde9a399 feat(search): phase-2q apply opt-in — production rollout 시작, 1주 관찰 (gemma-4)
plan pr-2q-apply-query-rewrite-1-bright-meadow.md. Phase 2Q Diagnose closure +
Rerank-Payload-Fix (main 0257a5d) 완료 후 Apply rollout. opt-in path 가 Phase 1B/2
부터 이미 production 가동 중 → 본 PR 의 production 영향 0 (marker PR).

rollout 정책:
  · default = rewrite_backend null (single-query path, baseline 회귀 0 invariant)
  · 명시 opt-in = ?rewrite_backend=cand_multi_query_macmini (추천 gemma-4)
  · 대안 = cand_multi_query_macbook (qwen3.6, mixed/english 강점, MacBook 가동 시)
  · 1주 관찰 (2026-05-24 ~ 2026-05-31) → metric 정상 시 default ON 별 PR

변경 (production 영향 0):
- docs/phase_2q_apply_opt_in.md 신규 — 사용자 가시화:
  · 사용 방법 (query param + SvelteKit fetch 예시)
  · 1주 관찰 metric 목표 (cache hit ≥ 50% / LLM warm p50 ≤ 1500 / 503 ≤ 5/day / Recall t≥3 ≥ 0.74)
  · 추천 LLM 사유 (decision md §4 4-factor) + 대안 명시
  · Phase 2 QueryAnalyzer sequencing 박제 (영향 0, ask_events 0건 운영 관찰 후 확정)
  · Follow-up PR 5건 명시 (Telemetry / Alert / Default-ON / Cache-Prewarm / Category-Analysis)
- app/api/search.py — rewrite_backend query param description 갱신.
  Apply 진입 박제 + 추천 LLM 표시 + docs 링크. 동작 변경 0.
- tests/search_eval/baselines/v0_2_phase2q_apply_smoke_2026-05-24.json — production smoke:
  · opt-in path HTTP 200 + total_ms 957 (cache hit) + rerank_ms 109 (정상 호출) + fallback 0
  · baseline path HTTP 200 + total_ms 207 + rerank_ms 19 + fallback 0 (회귀 0 확정)

38/38 unit test PASS (회귀 0). main HEAD 0257a5d 위 branch.

Closure gate PASS:
  · docs 가시화 / search.py description / smoke json 박제
  · production smoke 양쪽 path 정상 + 회귀 0 verify
  · 메모리 갱신 + 1주 관찰 종료일 2026-05-31 박제

Follow-up: 1주 후 PR-2Q-Apply-Default-ON-1 (metric 정상 시) 또는 fix PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:01:49 +00:00
hyungi 1ae7802485 Merge pull request 'Feat/ds ai routing policy' (#23) from feat/ds-ai-routing-policy into main
Reviewed-on: #23
2026-05-24 12:20:49 +09:00
hyungi ecd2350c15 feat(search): Phase 2Q Diagnose Phase 2 — multi-query retrieval fusion
phase-2q-query-rewrite-diagnose.md v6 plan §5.5 + §7 Phase 2.
Phase 1B 3e6866b (scaffold + dispatcher) 위 retrieval 합성 wire-up.

신규:
- search_pipeline._rrf_fuse_variants() — N variant ranked list RRF 합성.
  fusion_service.RRFOnly 알고리즘 동일 (k=60), 첫 등장 variant representative 보존.
- search_pipeline.search_with_rewrite() — variant N 별 retrieval+fusion 후
  unified RRF (cap 60) → reranker 1회 (query=원본 q) → diversity+freshness+display.
  · per-variant K = 50//3 = 16 (PHASE2Q_PRODUCTION_TOPK//N, A1 채택)
  · variant 별 retrieval asyncio.gather 병렬
  · chunks_by_doc merge (variant 무관 unified reranker input)
  · production fusion_service.get_strategy() + rerank_chunks() 재사용
- 상수: PHASE2Q_PRODUCTION_TOPK=50, PHASE2Q_UNIFIED_CAP=60, PHASE2Q_RRF_K=60.

수정:
- search_pipeline.run_search() — rewrite_backend param 추가. hybrid + cand_<slug> 시
  search_with_rewrite() 위임. baseline/None 시 기존 single-query path 그대로 (invariant).
- app/api/search.py — Phase 1B scaffold discard call 제거. run_search 에 rewrite_backend
  전달. ValueError → 400 (unknown_rewrite_backend 우선 분기) / RuntimeError → 503
  (rewrite_llm_unavailable).
- tests/test_query_rewriter.py — Phase 2 test 9개 추가:
  · _rrf_fuse_variants 6 (single / overlap accumulation / representative / cap limit /
    empty / rank position)
  · search_pipeline import + run_search rewrite_backend default=None signature 1
  · PHASE2Q_* constants 1
  · DATABASE_URL dummy 주입 (api.search import → SQLAlchemy engine init 회피)

30/30 unit test PASS (Phase 1B 21 + Phase 2 9).

baseline 회귀 0 invariant:
- run_search(rewrite_backend=None) → 기존 path 100% 그대로 (분기 first line guard)
- run_search(rewrite_backend=baseline) → 동일
- mode != hybrid → multi-query path 비활성 (text-only/vector-only/trgm 영향 0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:41:50 +00:00
hyungi 3e6866b4ae feat(search): Phase 2Q Diagnose Phase 1B — scaffold + dispatcher
phase-2q-query-rewrite-diagnose.md v6 plan Phase 1 의 fixture 외 잔여.
Phase 1A 446ba82 위 dispatcher + cache + LLM call + API param + eval flag + 21 unit test.
retrieval 합성 (search_with_rewrite) 은 Phase 2 별 commit.

신규:
- app/services/search/query_rewriter.py — LLM_BACKEND_MAP + _resolve + cache + rewrite()
  · slug-based allowlist (no silent fallback), httpx 직접, Priority.FOREGROUND semaphore
  · sampling 박제 (gemma response_format json_object / qwen prompt rule only — Phase 0 inspect 9)
  · manual TTL cache (query_analyzer 패턴 1:1, sha256[:32] NFKC key, LLM_REWRITE_TIMEOUT_MS=15000)
- tests/test_query_rewriter.py — 21 test PASS (resolve / cache key / parser / cache TTL / constants)

수정:
- app/api/search.py — ?rewrite_backend= query param + 400 unknown / 503 unavailable.
  scaffold = call but discard variants (retrieval path 영향 0). Phase 2 에서 합성.
- tests/search_eval/run_eval.py — --rewrite-backend flag + 4 hot spot wire-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:25:03 +00:00
hyungi 076c0e1802 feat(eval): Phase 2B Reranker Diagnose — dispatcher + gte 측정 + decision (H3 bge-reranker-v2-m3 유지)
round-2-review-mighty-starfish.md v2.1 (Phase 2B Reranker Diagnose) plan 실행.
Phase 2A 의 CANDIDATE_BACKEND_MAP 패턴 재사용 + RERANKER_BACKEND_MAP 신규.

코드 변경 (4 파일):
- app/services/search/rerank_service.py:
  - RERANKER_BACKEND_MAP allowlist (baseline / cand_gte_ml_base, slug-based resolve)
  - _resolve_reranker(slug) → endpoint URL or None
  - _rerank_via_candidate_endpoint() — 후보 TEI POST /rerank
  - rerank_chunks() 시그니처에 reranker_backend + snapshot_*_id_max 추가 + dispatch log
- app/services/search/search_pipeline.py: run_search() threading
- app/api/search.py: reranker_backend Query parameter + 400 unknown_reranker_backend 에러 매핑
- tests/search_eval/run_eval.py: --reranker-backend flag + call_search/evaluate threading

infra:
- docker-compose.override.rerank-cand.yml: 3 후보 service (gte_ml_base / mxbai_large / bge_v2_gemma_2b),
  profile 'rerank-cand' 격리, restart=unless-stopped

측정 산출물 (51 case, scored=46, failure=5):
- reports/v0_2_phase2b_baseline_snapshot_2026-05-23.csv (NDCG 0.659, Phase 2A 와 일치 = 재현성 PASS)
- reports/v0_2_phase2b_gte_ml_base_2026-05-23.csv
- tests/search_eval/baselines/v0_2_phase2b_{baseline_snapshot,gte_ml_base}_2026-05-23.json
- reports/phase_2b_reranker_decision_2026-05-23.md
- tests/fixtures/tei_rerank_response.json (G0-1 한국어+영어 mixed sample sanity PASS)

후보 TEI 1.7 호환성 (Phase 1 smoke gate):
- cand_gte_ml_base       :  PASS (xlm-roberta-based, TEI 호환)
- cand_mxbai_large       :  deberta-v2 미지원 → Phase 2B-Extended (sentence-transformers wrapper)
- cand_bge_v2_gemma_2b   :  LLM-based reranker, 1_Pooling/config.json 부재 → Phase 2B-Extended (FlagEmbedding wrapper)

결과 (1 후보 측정 + baseline rebaseline):
| Candidate                          | NDCG  | Δ baseline | mixed | korean | exam  | p50 ms |
|------------------------------------|------:|-----------:|------:|-------:|------:|-------:|
| bge-reranker-v2-m3 (baseline)      | 0.659 | —          | 0.39  | 0.51   | 0.74  | 454    |
| cand_gte_ml_base                   | 0.604 | -0.055     | 0.38  | 0.41   | 0.62  | 345    |

Decision (H3): bge-reranker-v2-m3 유지. gte 의 reranker quality 가 production 보다 약함 (korean_only -0.10, exam -0.12, overall -0.055).

후속 PR 백로그 (6건):
- PR-Search-Query-Rewrite-1 (Phase 2Q, korean_only/mixed 보완 권고)
- PR-2B-Extended-Mxbai-Large (sentence-transformers wrapper)
- PR-2B-Extended-Bge-V2-Gemma (FlagEmbedding LayerwiseReranker wrapper)
- PR-2B-Extended-Jina-V2-ML (license 결정 후, 개인 비영리 가정)
- PR-2B-Cloud-Reranker-Scaffold-1 (Cohere scaffold-only, 선택)
- PR-2B-Rerank-Cand-Cleanup-1 (1주 후 cand 컨테이너 정리)

production 영향:
- production reranker (bge-reranker-v2-m3) 변경 0
- config.yaml ai.models.rerank.endpoint 변경 0
- embedding (bge-m3 ollama) 변경 0 (Phase 2A 결정 보존)
- documents / document_chunks 변경 0 (21365 docs / 30605 chunks 그대로)
- 4 smoke PASS (baseline / baseline+snapshot / cand_gte_ml_base / cand_invalid → 400)
- dispatch log 박제 verify (endpoint + snapshot id)

closure gate: 16 항목 PASS (flex closure 조항 적용 — 1 후보 측정, 2 후보 TEI 호환 탈락 사유 명시).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:37:42 +00:00
hyungi 3092e3009d feat(eval): Phase 2A Diagnose Phase 3+4 — dispatcher + 3 측정 + decision (H3 bge-m3 유지)
phase-2a-embedding-diagnose.md v4 § 6 (dispatcher) + § 7 Phase 3 (51 case 측정) + § 7 Phase 4 (decision)
Round 2 review: round-2-review-mighty-starfish.md (R2-2 + R2-B1 페어 invariant + slug-based resolve)

코드 변경:
- app/services/search/retrieval_service.py:
  - CANDIDATE_BACKEND_MAP allowlist (baseline / cand_me5_large_inst / cand_snowflake_l_v2)
  - _resolve_backend(slug) → docs_table/chunks_table/embed_endpoint or None
  - _embed_query_via_tei() — candidate TEI 엔드포인트 호출 (cache 미사용)
  - _VALID_DOCS_TABLE + _VALID_CHUNKS_TABLE regex (R2-B1 2단계 gate)
  - _search_vector_docs / _search_vector_chunks: docs_table/chunks_table + snapshot_*_id_max 파라미터
  - search_vector + search_vector_multilingual: embedding_backend + snapshot_*_id_max 파라미터 + dispatch log
- app/services/search/search_pipeline.py: run_search() 시그니처 + 4 search_vector* 호출 threading
- app/api/search.py: 3 Query parameter + ValueError → HTTP 400 (allowed list 응답)
- tests/search_eval/run_eval.py: --embedding-backend + --snapshot-doc-id-max + --snapshot-chunk-id-max
  + call_search/call_search_full/evaluate threading + main 3 asyncio.run threading

측정 산출물 (51 case, scored=46, failure=5):
- reports/v0_2_phase2a_baseline_snapshot_2026-05-23.csv (snapshot filter 적용 production path)
- reports/v0_2_phase2a_me5_large_inst_2026-05-23.csv
- reports/v0_2_phase2a_snowflake_l_v2_2026-05-23.csv
- tests/search_eval/baselines/v0_2_phase2a_{baseline_snapshot,me5_large_inst,snowflake_l_v2}_2026-05-23.json (3개)

결과:
| Candidate                          | NDCG | Δ vs baseline | mixed | korean_only | p50 ms |
|------------------------------------|-----:|--------------:|------:|------------:|-------:|
| bge-m3 (baseline snapshot)         | 0.659| —             | 0.39  | 0.51        | 464    |
| cand_me5_large_inst                | 0.477| -0.182        | 0.17  | 0.47        | 194    |
| cand_snowflake_l_v2                | 0.616| -0.043        | 0.35  | 0.52        | 254    |

Decision (H3): bge-m3 유지. 둘 다 net 회귀.
- mE5-large-instruct: 전 카테고리 회귀 (-0.182). prefix 미적용 변수 — 별 PR PR-2A-mE5-Prefix-Retry 후보.
- snowflake_l_v2: 가벼운 회귀 (-0.043). korean_only +0.01 미세 개선 신호.
- korean_only/mixed 약점 보완은 Phase 2B (Reranker) 또는 Phase 2Q (Query rewrite) 권고.

Decision report: reports/phase_2a_embedding_decision_2026-05-23.md (§ 1~8 포함, Closure gate 16 항목 모두 PASS).

후속 PR 백로그:
- PR-2A-mE5-Prefix-Retry (별 PR)
- PR-2A-Extended-Bge-Mgemma2 (별 PR, v3 결정)
- PR-2A-Cloud-Embedding-Scaffold-1 (Cohere/Voyage scaffold-only, 선택)
- PR-Search-Query-Rewrite-1 (Phase 2Q)
- PR-Search-Reranker-V2-Diagnose (Phase 2B)
- PR-2A-Chunks-Cand-Cleanup-1 (1주 후 cand 테이블 DROP)

production 영향:
- documents / document_chunks 컬럼/row 변경 0
- config.yaml 변경 0 (ollama bge-m3 unchanged)
- 추가된 endpoint = query parameter opt-in (미지정 시 production path 회귀 0)
- smoke 4건 PASS (baseline / baseline+snapshot / cand_me5 / cand_invalid → HTTP 400)
- dispatch log 박제 verify (snapshot_doc/chunk_id_max 박제)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 06:55:13 +00:00
hyungi bcf644f893 refactor(search): /api/search/ask dispatcher route via llm-router
PR-2 of DS AI routing policy (2026-05-23, see plan
~/.claude/plans/document-server-ai-cheeky-reddy.md +
memory project_document_server_ai_routing_policy).

DS 의 모든 backend 호출이 llm-router :8890 단일 경유. 정칙 정합:
- 신규 RouterBackend (services/llm/backends.py) — alias 별 router POST
  + requires_gate 분기 (mac-mini-default 만 llm_gate FOREGROUND 보호).
- 기존 GemmaMacMiniBackend + QwenMacBookBackend = legacy 보존
  (DS_BACKENDS_VIA_ROUTER=false rollback safety only). 1주 후 별
  cleanup PR (PR-DS-Backends-Legacy-Cleanup-1) 로 폐기.
- get_backend factory dual-path (env flag) — backward-compat
  (gemma-macmini alias → mac-mini-default 매핑).
- search.py:457 Query pattern 확장: mac-mini-default|claude-cloud|auto
  추가. /ask/react 의 isinstance(QwenMacBookBackend) → hasattr
  duck-typing (RouterBackend + Legacy 모두 generate_with_tools 구현).
- SearchAskBackendConfig 에 router_url 신규 (env LLM_ROUTER_URL 또는
  hardcoded MVP default http://100.76.254.116:8890).
- docker-compose.yml fastapi env 에 LLM_ROUTER_URL +
  DS_BACKENDS_VIA_ROUTER 추가.

AIClient (_call_chat, call_triage, call_primary, call_fallback) 경유
path 는 별 PR (PR-AIClient-Router-Migration-1) — MVP scope C 채택,
회귀 risk 최소화.

Closure (즉시 fixture/matrix):
- factory smoke 6 alias (None/mac-mini-default/gemma-macmini/
  qwen-macbook/claude-cloud/auto) + 1 invalid (nonsense → ValueError).
- live 3 case: mac-mini-default 200 \"pong! 🏓\" + qwen-macbook cold
  502 upstream_502_primary=ConnectError + claude-cloud 503
  provider_not_configured.
- silent fallback 0 + direct M5/Mac mini socket 0
  (RouterBackend 만 router 호출).

Backup: ~/.local/share/ds-routing-pr2-backups/20260523/
(backends.py + config.py + search.py + docker-compose.yml).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 03:41:29 +00:00
hyungi 51c3f6df10 feat(search): /ask/react endpoint with Qwen native tool calling ReAct loop
PR-DocSrv-Ask-ToolCalling-ReAct-1 — Qwen3.6-27B-8bit 의 native tool calling
으로 ReAct loop 도입. 기존 /api/search/ask 무수정. 트랙 B (frontend /ask SSE)
와 파일 단위 충돌 0 (search.py 의 ask() 함수 line diff = 0, 순수 추가).

핵심 invariant:
- 별 endpoint /api/search/ask/react (qwen-macbook only, implicit opt-in)
- MacBook unavailable 시 HTTP 503 + error_reason=macbook_unavailable.
  Gemma 자동 fallback X (정정 4 의 연장)

G0 (구현 전 hard gate, plan b-velvety-hare.md):
- G0-1 fixture (tests/fixtures/qwen_tool_call_response.json): 실제 mlx-vlm
  응답 박제. shape = OpenAI 표준 호환 (choices[0].message.tool_calls +
  function.arguments JSON string). generate_with_tools() 가 본 shape 기준 구현.
- G0-2 counter semantics: max_tool_rounds=2 + max_llm_calls=3 + search_exec_max=2.
  마지막 LLM 호출은 tool_choice="none" + system instruction 으로 final 강제.
- G0-3 trace exposure: default response 의 debug_trace=null. debug=true 시만
  채움. server log 에는 항상 round 기록.

backends.py (193 → 261줄):
- QwenMacBookBackend.generate_with_tools(messages, tools, tool_choice)
  신규 method. 기존 generate() 무수정. BackendUnavailable 처리 동일.

react_loop.py 신규 (275줄):
- agentic_ask_loop(session, query, *, backend, max_tool_rounds, debug)
- tool round 안에서 run_search 호출, results dedup by id, final round 강제,
  partial=True 조건 (final content 빈 경우)

search.py (+82줄):
- POST /api/search/ask/react + AskReactRequest/Response schema
- BackendUnavailable → JSONResponse(503, error_reason=macbook_unavailable)

config.yaml + config.py:
- search.ask.react: { enabled, max_tool_rounds=2, search_tool_limit=5,
  search_tool_mode=hybrid }

tests (566줄, 18 신규 + 23 회귀 모두 PASS):
- test_react_loop.py 13건: G0-1 fixture shape / G0-2 counter cap / G0-3 trace
  exposure / BackendUnavailable propagation / sources dedup
- test_search_ask_react_endpoint.py 5건: 503 + run_search 호출 0 / 정상 200 /
  debug=true trace 노출 / max rounds partial
- 회귀 (test_ask_eval_auth 9 + test_search_ask_macbook_503 5 +
  test_backend_dispatcher 9) 모두 PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:43:47 +00:00
hyungi a7b8f15870 feat(search): /ask backend dispatcher (qwen-macbook opt-in, no silent fallback)
PR-MacBook-RAG-Backend-1 — /api/search/ask 의 명시 backend 선택 진입점.

핵심 invariant (정정 4):
- backend 미지정 = Gemma Mac mini default, 응답 contract 변동 0
- backend="qwen-macbook" 명시 opt-in 만 MacBook M5 Max mlx-vlm.server 호출
- MacBook unavailable 시 HTTP 503 + error_reason=macbook_unavailable
- 자동 fallback 절대 금지 — 실패 path 에서 Gemma backend.generate() 호출 0

backend dispatcher (services/llm/):
- BackendBase / GemmaMacMiniBackend / QwenMacBookBackend / BackendUnavailable
- Qwen backend 는 Mac mini llm_gate 점유 X, 별 Semaphore(1) — llm_gate
  docstring 의 single-inference 영구 룰은 같은 endpoint 한정으로 scope 명시
- httpx Connect/Read/Pool/Timeout/5xx → BackendUnavailable, 4xx 전파

synthesis_service.py:
- backend 인자 추가, status="backend_unavailable" 신규
- cache key 에 backend_name 포함 (qwen ↔ gemma 캐시 충돌 차단)

config:
- search.ask.backend.{macmini_url, macbook_url, macbook_model,
  timeout_connect_s=1, timeout_read_s=30}
- MacBook endpoint = http://100.118.112.84:8810 (M5 Max Tailscale bind)

tests (14 신규):
- tests/services/test_backend_dispatcher.py (9): dispatcher 정합성 + Qwen
  generate path (mock 200 / dead port / 5xx / 4xx) + cache identity
- tests/api/test_search_ask_macbook_503.py (5): 정정 4 핵심 invariant.
  backend=qwen-macbook 비가용 시 gemma.generate.assert_not_called()

기존 ask 회귀 0 (test_ask_eval_auth 9건 등 85건 모두 PASS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:10:44 +00:00
Hyungi Ahn eae1f48d62 feat(worker-pool): Registry-1C cap 1MB + deterministic compaction
사용자 결정 2026-05-19: 100KB cap 이 운영 7d 데이터 1.36MB 대비 부족 →
cap 상향만으로 raw 비대화 위험. cap 1MB + payload compaction 병행.

fetch_recap_context() 변경:
- memo payload item field 축소 = id/title/ai_tldr/ai_event_kind/created_at (5 필드)
  (ai_bullets/file_type/source_channel/category/extracted_text 등 제외)
- memo top-N = RECAP_MEMO_TOP_N env (default 200) — 초과분은 aggregate 로
- aggregate = memos_by_day + memos_by_kind + omitted_memos
- payload_compacted flag = aggregate fallback 발현 여부
- events 는 raw (운영 7d 데이터에서 통상 0~소량)

internal_worker.py:
- PAYLOAD_MAX_BYTES → _payload_max_bytes() env override
  (WORKER_RECAP_PAYLOAD_MAX_BYTES default 1_000_000)
- JobsRecapResponse 에 payload_compacted / omitted_memos 노출
- 413 detail 에 "after compaction" 명시 + RECAP_MEMO_TOP_N 조정 안내

테스트 3 항목 신규 + 기존 endpoint 413 test 업데이트:
- 700 memo → 200 kept + 500 omitted + compacted=true + < 1MB
- 10 memo → compacted=false + omitted=0
- 비정상 큰 title (compaction 후에도 cap 초과) → 413 유지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:51 +09:00
Hyungi Ahn 0ea72c1aa6 feat(worker-pool): Registry-1C recap context + /jobs/recap + 100KB guard
- app/services/worker_recap_context.py — fetch_recap_context(user_id, days)
  documents file_type='note' 7d (single-user invariant) + events 7d
  (user_id 매칭 + cancelled 제외) JOIN. timezone Asia/Seoul.
- /internal/worker/jobs/recap POST — 일반 user JWT 인증 + context 조립
  + worker_jobs INSERT. job_type='recap' + payload JSONB.
- payload 100KB guard — JSON 직렬화 100_000 bytes 초과 시 413.
- 회귀 위험 0: memos/events API select 절 touch 0, read-only 쿼리만.

worker-pool-policy §B.2 invariant 보존: ProcessingQueue 무변경, 운영 자동
분기 변경 0, canonical promote 0 (worker_jobs.payload JSONB only).

Notebook-Pilot-1 entry condition 4항목 모두 충족 가능:
manual recap E2E / payload <100KB guard / residue 0 / 권한 분리 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:44:07 +09:00
Hyungi Ahn f60d6e52fc feat(worker-pool): Registry-1B Pull 활성화 (auth + worker_jobs + 5 endpoint)
worker-pool-policy §B 1B 영역 완료. 1A scaffold (mig 270~274 + 503 stub) 위에:
- mig 275/276: worker_jobs (status CHECK + user_id=owner) + pending partial index
- create_laptop_worker_bot_token + require_worker_user dependency (voice-memo 동형)
- /internal/worker/{register,heartbeat,claim,result,drain} 5 endpoint 실 구현
- /claim FOR UPDATE SKIP LOCKED + 204 body 0
- /result 소유권 검증 (worker_id 매칭, 404) + failed 재시도 (attempts/max)
- explicit failure 시 request.result 무시 (DB result NULL 유지)
- 테스트 22 항목 7 파일

policy §B.2 5 invariant 보존: voice-memo wrapper 변경 0, drain advisory,
result raw JSONB, ProcessingQueue 무변경, 운영 자동 분기 변경 0.

활용처 (recap context + /jobs/recap + payload 100KB guard) = Registry-1C 영역.
stale recovery / 노트북 client / canonical promote = Notebook-Pilot-1 영역.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:54:07 +09:00
Hyungi Ahn bbd92a840a feat(worker-pool): Registry-1A scaffold — worker_capabilities/heartbeats + /internal/worker/* 5 endpoint 503 stub
PR-Worker-Pool-Registry-1A (scaffold only, no runtime activation).

신규:
- migrations/270~274 (1 statement/1 file 강제): worker_capabilities + 2 idx + worker_heartbeats + 1 idx
- app/models/worker_pool.py: WorkerCapability + WorkerHeartbeat ORM (queue.py 패턴)
- app/api/internal_worker.py: 5 endpoint 모두 _stub_503() — register/heartbeat/claim/result/drain
- tests/test_internal_worker_stub.py: 503 응답 smoke (inline ASGI client, DB 의존 0)

수정:
- app/main.py: import + include_router 각 1줄 (prefix=/internal/worker, internal_study 일관)

scaffold-first + phase-gate-material-first 강제 (worker-pool-policy §1, §12):
- 인증 dependency 0 (1B 에서 JWT + require_worker_user)
- ProcessingQueue 변경 0 (방향 b: worker_jobs 별 table = 1B)
- LLM 호출 0 / canonical DB 변경 0 / 운영 자동 분기 0

회귀 0 (1주 안전망 = app/main.py.pre-registry-1a.20260518).

plan: ~/.claude/plans/floofy-exploring-mitten.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:24:59 +09:00
hyungi 74876b674c feat(auth): JWT iat + users.password_changed_at invalidation (PR-Docsrv-JWT-Invalidation-1)
PR-Infra-Sec-1H Phase 0 audit 에서 DS jwt invalidation 정책 부재 확정.
password rotation 으로 구 365d JWT (voice-memo-bot 등) invalidate 안 되는
hard gate STOP 진입 → 선행 PR 분리.

- migration 269: users.password_changed_at timestamptz NULL (legacy 호환)
- create_access_token / create_refresh_token: payload 에 iat (int 초) 추가
- verify_password_changed_at helper: int(password_changed_at.timestamp()) > int(iat) 시 401
- get_current_user + refresh_token route: verify helper 호출
- change_password / setup signup / seed_admin INSERT+UPDATE: password_changed_at 갱신

NULL = 검증 skip (migration 직후 운영 영향 0). 첫 password 변경 후만 iat
검증 활성. Sec-1H 의 G-token-old hard gate 통과 path 확보.
2026-05-17 06:20:46 +00:00
Hyungi Ahn a08b620894 refactor(search): swap 10 call sites to acquire_mlx_gate(Priority.*) (B-1)
DS-Mac-mini-26B-Priority-Gate-1 — 사용자-facing 7 + worker 3 = 10 site 의
`async with get_mlx_gate():` → `async with acquire_mlx_gate(Priority.*):` 교체.

Foreground 6 (user-facing path):
- app/services/search/evidence_service.py:315 (/ask evidence stage)
- app/services/search/classifier_service.py:103 (/ask classifier stage)
- app/services/search/synthesis_service.py:299 (/ask synthesis stage)
- app/api/documents.py:1306 (수동 analyze API)
- app/api/study_topics.py:1183 (subject note 동기 생성)
- app/api/study_questions.py:1560 (study explanation 동기 API)

Background 4 (worker queue / fire-and-forget):
- app/services/search/query_analyzer.py:240 (V0 grep 확인: fire-and-forget only,
  search_pipeline.py:179 trigger_background_analysis 만, docstring rule
  "analyze() 동기 호출 금지" 부합 → BACKGROUND 확정)
- app/workers/deep_summary_worker.py:110 (classify-escalate worker)
- app/workers/study_explanation_worker.py:149
- app/workers/study_session_analysis_worker.py:237

Cleanup:
- query_analyzer._get_llm_semaphore() 제거 — self-only, unused, signature 거짓말
  (이제 get_mlx_gate 가 Semaphore 아닌 context manager 반환)

기존 get_mlx_gate() legacy wrapper 는 보존 (BACKGROUND 매핑). user-facing path
잔재 0 — closure gate grep 검증 통과 (별 commit 에서).
2026-05-17 08:51:57 +09:00
Hyungi Ahn 73f328cb65 fix(search): DS RAG LLM_TIMEOUT_MS align 15s/3s → 30s/10s (B-3 Synthesis-Timeout-Calibration-1)
PR-Hermes-Docsrv-Search-1 closure 측정 (synthesis_ms=30~48s / ev_ms=15005 /
query_analyze 45s) 으로 15s LLM_TIMEOUT 빈발 timeout 확인. Mac mini 26B 동시
호출 (gate Semaphore 1 직렬화 후에도 evidence + synthesis + classifier +
query_analyzer + verifier 가 sequential 누적) 시 각 호출 30s 까지 필요.

5곳 변경:
- synthesis_service.LLM_TIMEOUT_MS 15000 → 30000
- evidence_service.LLM_TIMEOUT_MS 15000 → 30000
- verifier_service.LLM_TIMEOUT_MS 3000 → 10000
- query_analyzer.LLM_TIMEOUT_MS 15000 → 30000
- search.py:522 classifier wait_for 15.0 → 30.0 (classifier_service align)
- search.py:641 verifier wait_for 4.0 → 10.0 (verifier_service align)

classifier (이전 PR 에서 30s 로 align 완료) 와 동일 정책 — outer wait_for
가 inner LLM_TIMEOUT_MS 를 override 하지 않도록 align.

ask 응답 latency 상한 ↑ 의도된 trade-off — 안정성 (refusal_gate
conservative_refuse 회피 + grounding/verifier 정상 동작) 우선.

영향: PR-1 fixture 회귀 0 예상 (이전 timeout 이 새 한도 안). B-1 Throughput-1
(priority queue / 모델 분리) 별 PR 진입 시 latency 본격 단축 검토.
2026-05-17 08:01:22 +09:00
Hyungi Ahn 5846baedc7 fix(search): ask classifier wait_for 6s → 15s (outer wrapper override 해소)
A1 (LLM_TIMEOUT_MS 5→15→30) + config(10→15→30) 후속 진단: 8/10 fixture query 가
"classifier ok" 또는 "classifier error" 로그 없이 conservative_refuse(no_classifier)
경로. search.py:518 의 outer wrapper `asyncio.wait_for(classifier_task, timeout=6.0)`
가 classifier_service.LLM_TIMEOUT_MS 와 httpx timeout 모두 override.

6s 한계 → 동시 부하 시 거의 모든 classifier 호출 6s 안에 못 끝남 → AsyncIO TimeoutError
→ ClassifierResult("timeout") → refusal_gate 가 verdict=None 받아 conservative_refuse.

15s 로 상향 — classifier_service 내부 30s 와 align 하지 않은 이유 = ask 응답 시간 상한
유지 (evidence parallel 종료 후 추가 9s 대기 cap). Mac mini 26B 동시 부하 시 실측
elapsed 11-14s 까지 자주 발생 → 15s 가 합리 균형.

본 fix 가 진짜 closure 효과. PR-Hermes-Docsrv-Search-1 Layer 1 fixture 의 8/10
no_classifier 경로 해소 예상.
2026-05-16 19:46:49 +09:00
Hyungi Ahn 19bf5b1e38 feat(memo): Hermes input gateway — source_channel='hermes' + source_metadata jsonb
PR-Hermes-Docsrv-Bridge-1 v1. Hermes Agent (Mac mini Discord) 를 Document Server
입력 게이트웨이로 reframe — 코딩 executor X, Claude Code 변동 0.

변경:
- migration 267: source_channel enum 에 'hermes' 추가
- migration 268: documents.source_metadata jsonb NOT NULL DEFAULT '{}' 추가
- Document model: source_metadata 컬럼 ORM 매핑 + enum 'hermes' 노출
- MemoCreate: source_channel + source_metadata 필드 수용 (default='memo' 호환)
- create_memo: channel allowlist (memo/voice/hermes) + metadata jsonb 저장
- list_memos: IN tuple 에 'hermes' 추가 (inbox 노출)
- MemoResponse + _to_memo_response: source_metadata 노출 (UI 배지 준비)

LLM 호출 0 — Hermes 의 HTTP POST 만. 분류/요약은 classify_worker 비동기 처리.
promote-to-event guard (562/664) 변경 0 — v1 = hermes 메모 promote 차단 유지.

plan: ~/.claude/plans/idempotent-seeking-hollerith.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:44:15 +09:00
hyungi 5125f82d4a feat(study): Mac mini derived-worker (PR-MacMini-Derived-Worker-1)
GPU = RAG context provider, Mac mini = LLM 가공 공장.

GPU 측 변경:
- app/api/internal_study.py: GET /internal/study/explanation-context/{qid}
  Bearer auth, gather_explanation_context + _render_envelope_prompt 재호출.
  204=evidence missing, 410=deleted/ready.
- app/workers/study_queue_consumer.py: settings.study_explanation_enabled
  false 시 explanation 분기 skip (status/attempts 미변경, pending 유지 → Mac mini 흡수).
- app/core/config.py: study_explanation_enabled + internal_worker_token 2 setting.
- app/main.py: internal_study_router include (prefix /internal/study).
- docker-compose.yml: fastapi ports → 100.110.63.63:8000 Tailscale bind,
  STUDY_EXPLANATION_ENABLED + INTERNAL_WORKER_TOKEN env 추가.

Mac mini 측: ~/derived-worker/ (별도 push 0, 어제 작성).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 03:13:43 +00:00
Hyungi Ahn 52f86acda7 feat(auth): voice-memo bot 365d access token (PoC v1)
bot 계정(`voice-memo-bot`) 한정 long-expiry access token 발급 경로 추가.
일반 사용자 흐름 영향 0 (env gate default false).

- core/auth.py: create_voice_memo_bot_token() 신규 (env gate + username hard-match)
- api/auth.py: login route 에 bot 분기 (bot 이면 long token 반환, 일반은 기존 흐름)
- docker-compose.yml: 3 env (VOICE_MEMO_BOT_TOKEN_ENABLED/_USERNAME/_EXPIRE_DAYS) default false

OpenClaw `/voice-memo` plugin → DS `/memos/` Bearer proxy 의 auth 기반.
정식 service-account/api_keys 테이블은 Phase 2 (multi-service 인입 추가 시점).

plan: ~/.claude/plans/rosy-launching-otter.md
project: ~/.claude/projects/-Users-hyungiahn/memory/project_voice_memo_pipeline.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:24:18 +09:00
hyungi 4b8120d83f feat(briefing): date picker + 카드별 읽음/하이라이트 액션
사용자 요청 (2026-05-13):
- 오늘 briefing 만 보여주고 과거 못 보는 게 아쉬움 → 날짜 선택 UI
- 시간대 별 나열은 오히려 불편 → date dropdown 1단계 선택
- 각 카드에 읽음/하이라이트 토글

Schema (migrations 263~266, 단일 statement):
- briefing_topics.is_read BOOL NOT NULL DEFAULT false
- briefing_topics.read_at TIMESTAMPTZ
- briefing_topics.highlighted BOOL NOT NULL DEFAULT false
- briefing_topics.highlighted_at TIMESTAMPTZ

API (app/api/briefing.py):
- TopicResponse 에 id / is_read / read_at / highlighted / highlighted_at 추가
- GET /api/briefing/dates → 사용 가능 날짜 목록 (60일 cap)
  · briefing_date / total_topics / total_articles / status / read_count / highlighted_count
- PATCH /api/briefing/topics/{id}/read body {value: bool} → 읽음 토글
- PATCH /api/briefing/topics/{id}/highlight body {value: bool} → 하이라이트 토글
- 토글 시 *_at 컬럼 자동 설정/NULL

UI (frontend/src/routes/news/+page.svelte):
- 헤더 우측 <select> date dropdown — 최신 + N일치 (highlighted_count 별 표시)
- 선택 시 /api/briefing?date=… 로 해당 날짜 briefing 로드
- 카드 우측 상단 ★ (하이라이트) + 읽음 버튼
- 하이라이트 = Card class ring-2 ring-yellow-400
- 읽음 = 외부 div class opacity-60 (시각 차분화, 펴기 가능)
- 토글 즉시 PATCH 호출 + 로컬 state 갱신

each key topic.topic_rank → topic.id 변경 (이미 unique).
2026-05-12 22:05:06 +00:00
Hyungi Ahn 431d4fe010 feat(briefing): add morning briefing schema + services + api (historical off)
야간 수집 뉴스 (KST 00:00~05:00) topic×country 비교 분석 1페이지 카드.
Phase 4 Global Digest 와 코드/로직/테이블 분리, 알고리즘만 services/clustering_common 공유.

Backend 신규:
- migrations/255_morning_briefings.sql: morning_briefings + briefing_topics
  (briefing_date UNIQUE, UNIQUE(briefing_id,topic_rank), FK CASCADE,
  historical_* 3컬럼 nullable, cluster_members JSONB, country_perspectives
  JSONB, status 4-state success|partial|failed|empty)
- app/models/briefing.py: SQLAlchemy ORM
- app/services/briefing/loader.py: KST 5h 윈도우 + news_sources prefix
  fallback (Phase 4 패턴 미러) + historical candidate pool 로더
- app/services/briefing/clustering.py: cluster_global topic-first
  (LAMBDA=ln(2)/2h, MIN_COUNTRIES_PER_TOPIC=2, MAX_TOPICS=7)
- app/services/briefing/comparator.py: call_primary 26B + JSON envelope
  sanitize (cap perspectives 10 / divergences 3 / convergences 2 /
  quotes 5) + fallback row 고정 형태 + retrieve_historical cosine top-K
- app/services/briefing/pipeline.py: load→cluster→select(K=7,λ=0.6)
  →historical→compare→status 4-state→delete+insert transaction
- app/workers/briefing_worker.py: APScheduler/수동 호출 공용 진입점,
  600s hard cap
- app/prompts/briefing_comparative.txt: 한국어 비교 분석 JSON 프롬프트,
  {articles_block} + {historical_block} 2섹션, 인용 금지 라벨
- app/api/briefing.py: GET /latest, GET ?date=, POST /regenerate?date=
  (admin, sync delete+insert tx, regenerated:true)

Backend 수정:
- app/main.py: briefing_router 등록 (/api/briefing prefix). scheduler
  등록은 PR-3 에서.
- app/services/digest/selection.py: select_for_llm 매개변수화 (K, λ
  caller 주입). Phase 4 동작은 default 값으로 보존.

Historical 정책:
- BRIEFING_HISTORICAL_ENABLED env flag, default off.
- flag off → historical_* 컬럼 모두 NULL, prompt {historical_block} 빈
  라벨, retrieval 호출 안 함.
- flag on (PR-1b 에서 enable) → cluster centroid 와 과거 30일 doc
  embedding cosine top-K 5 (sim≥0.70), prompt 에 주입.

Country canonical (실측 확인 후):
- documents.country 컬럼 부재 확정
- document_chunks.country 매칭률 0% (chunks 자체가 뉴스에 안 만들어짐)
- 유일 country 신호 = news_sources prefix 매핑 (Phase 4 와 동일)

Tests:
- tests/test_briefing_historical.py: 3 경로 회귀 (flag off/on with
  fixture/on zero match) + sanitize cap + fallback row 형태.

Verification: PR-1.8 에서 GPU 컨테이너 pytest + 수동 regenerate.
2026-05-12 12:58:50 +09:00
Hyungi Ahn 3dc78e4f94 fix(memos): voice memo file_type → 'immutable' (doc_type enum 호환)
GPU 서버 main pull 후 /api/memos/?archived=false 가 500 — doc_type enum 에
'audio' 값 없음 (immutable/editable/note 만). list_memos WHERE file_type IN
('note', 'audio') 가 invalid_text_representation.

수정:
- voice upload Document.file_type = 'audio' → 'immutable' (기존 audio 컨테이너
  인입과 같은 패턴: file_type='immutable' + category='audio' + source_channel='voice')
- list_memos 필터에서 file_type 조건 제거 (source_channel IN ('memo','voice') 만으로
  분리 — file_type='immutable' 필터는 일반 PDF 까지 끌어옴, 위험)
- module docstring + voice upload 주석 업데이트

원본 plan 의 file_type='audio' 결정은 doc_type enum 미확인이 원인.
enum 확장(ALTER TYPE ADD VALUE 'audio') 대신 기존 패턴 재사용 — 안전 + 회귀 X.
2026-05-11 12:28:58 +09:00
Hyungi Ahn 6490050b04 feat(memos): promote memo to event + voice memo upload endpoint
PR-2B/2C backend 2/2. plan v9 commit 분할 2~3 통합 (memos.py 단일 파일 변경).

PR-2B promote-to-event:
- POST /api/memos/{memo_id}/promote-to-event — 메모 → events 1-click 승급
  · kind 결정: body.kind > documents.ai_event_kind > 400
  · activity_log 면 status=done + ended_at=now() 자동 (5초 행동 기록 UX)
  · calendar_event + start_at 있으면 status=scheduled
  · Event row + events_history(create) 자동 생성
  · memo_document_id 자동 link + source='memo' + raw_metadata 에 AI 추천값 보존
  · 한 메모 → N events 가능 (사용자 의도에 따라 dedup 없음)
- POST /api/memos/{memo_id}/dismiss-event-suggestion — '그냥 메모' (ai_event_kind='note' 강제)
  · MVP: AI 추천값과 사용자 확정값 같은 컬럼 (정확도 측정 흐려질 수 있음)
  · 백로그: user_event_kind 별 컬럼 분리 (plan Memo Intake Upgrade 백로그)
- MemoResponse 확장: ai_event_kind / ai_event_confidence / source_channel / file_type / file_path
- list_memos 필터 완화: file_type IN (note, audio) + source_channel IN (memo, voice)
  → voice 메모도 같은 inbox list 에 표시 (사용자 의도: 메모 = 모든 입력의 inbox)

PR-2C voice upload:
- migration 254: ALTER TYPE source_channel ADD VALUE 'voice'
- POST /api/memos/voice (multipart audio + recorded_at + device_hint)
  · 검증: Content-Type audio/* + size ≤ 50MB + 확장자 화이트리스트
  · NAS 저장: /documents/PKM/Recordings/{YYYY-MM}/{uuid}.{ext}
  · fsync + rename(atomic) 패턴 (NAS soft mount 안전)
  · Document row: file_type='audio' + source_channel='voice' + category='audio'
  · enqueue stt 큐 → 기존 stt_worker → classify (PR-2B triage) → embed → chunk
  · extract_meta 에 device_hint / recorded_at 보존
- 응답: MemoResponse (file_path 포함, frontend audio player 용)

원칙: AI worker 는 events row 직접 생성 X. 본 endpoint 가 사용자 의도 channel.
2026-05-11 12:06:41 +09:00
Hyungi Ahn 6d71116553 feat(events): PR-2 UI MVP — 4-tab + 빠른 행동 기록 + 상세/생성/이력
plan v6 PR-2 scope. 5초 행동 기록 UX 가 핵심 가설.

Backend:
- GET /api/events/{id}/history — events_history timeline 조회 (lifecycle op 자동 기록)

Frontend (SvelteKit 5 runes mode):
- /events 메인 — 4-tab (오늘/Inbox/예정/활동) + 빠른 행동 기록 widget
  · 단일 입력 + Enter → POST /api/events kind=activity_log
  · status=done + 시간 default 채워짐 (서버 측) → Activity 탭 즉시 반영
  · 새 항목을 list 최상단 prepend (refetch 불필요)
  · 연속 입력 위해 입력 ref focus 유지
  · lifecycle 버튼 (complete/defer/cancel/reactivate) — activity_log 는 lifecycle 대상 X
- /events/[id] 상세 — PATCH 허용 필드 edit (title/desc/시간/priority/project_tag) + history timeline
  · PATCH 금지 필드는 UI 노출 X (status/completed_at/cancelled_at/defer_until 은 별 버튼)
- /events/new — kind 선택 (task/calendar_event/activity_log) 후 필드 분기 form
  · task: due_at + start_at (선택, "14:00 전화" 같은 시각 task 허용 — 라운드 10)
  · calendar_event: start_at 필수 + end_at + all_day
  · activity_log: started_at/ended_at 비우면 서버 default now()
- Sidebar 메모 옆에 events 진입점 (CalendarCheck icon)

API helpers: frontend/src/lib/utils/events.ts (createEvent / logActivity / list*
/ lifecycle ops / kind&status enum label/color).

quickref doc: docs/events_api_quickref.md (이전 commit, PR-2 frontend reference).

PR-2 핵심 가설 검증 = 빠른 입력 → 저장 → Activity 즉시 반영 → 새로고침 유지.
PR-1 deferred HTTP behavior 5건도 본 UI 의 자연 사용으로 닫힘.
2026-05-11 07:56:31 +09:00
Hyungi Ahn 9d9b3359b0 feat(events): PR-1 Events Core — schema + ORM + 최소 API
개인 운영 로그 / 일정 / 할 일 / 회고용 1차 컨테이너 도메인 신설.
plan: ~/.claude/plans/beszel-tingly-sloth.md (라운드 12 v6).

Schema:
- enum 5종 (event_kind / event_status / event_source / event_actor / history_change_kind)
- events 테이블: kind(task|calendar_event|activity_log) + lifecycle 7-state status
- events_history: lifecycle op 자동 기록, FK RESTRICT (이력은 시점 사실)
- CHECK: calendar_event → start_at NOT NULL / activity_log → started_at|ended_at NOT NULL
- partial unique (source, source_ref) — 외부 source dedup (PR-4 활용)
- partial index (active status / activity_log timeline)

API:
- POST /api/events (kind=activity_log shortcut: status=done + ended_at=now() default)
- GET /api/events/{id} | /api/events?kind&status&from&to&project_tag&source
- PATCH /api/events/{id} (extra=forbid + 시간 필드 변경 시 reschedule history)
- POST /api/events/{id}/{complete,cancel,defer,reactivate} (history 자동)
- GET /api/events/today (Asia/Seoul default, deferred 는 defer_until<=now() 만)
- GET /api/events/inbox | /api/events/activity?from&to

제외 (PR-2~5 또는 백로그):
- DELETE (회고 데이터 → /cancel 일관화)
- log shortcut / upcoming endpoint (POST + GET ?from&to 로 흡수)
- /ingest (PR-4 MailPlus forward 시 정확한 요구로 추가)
- iCal export / ntfy 알림 / recurrence / 일반 edit history
2026-05-11 07:19:04 +09:00
Hyungi Ahn 8ca27eb573 fix(markdown): img auth via ?token= query param (Authorization header 미지원)
`<img src=>` 가 Authorization header 를 못 보내서 /api/documents/{id}/images/{key}/raw
가 401 반환 → 이미지 안 보임. 기존 /file?token= iframe 패턴과 동일하게 access token
쿼리 파라미터로 전달.

backend: get_current_user 의존성 제거하고 token 쿼리 파라미터 직접 검증 (기존 /file
엔드포인트와 동일 흐름).

frontend: MarkdownDoc 의 swap selector 가 img.src 에 ?token={getAccessToken()} 부여.
로그아웃 상태면 placeholder 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:47:09 +09:00
Hyungi Ahn 68fa86ea52 feat(markdown): persist extracted images with auth routes
Markdown Canonical Phase 1B.5 — marker 가 추출하던 이미지를 NAS 에 영구 저장하고
DB 메타 + 인증 라우트 + 프론트 swap 까지 wiring.

핵심 변경:
- marker-service /convert 응답에 base64 image 리스트 포함 (stateless 유지, NAS write 권한 X)
- marker_worker 가 NAS `/documents/extracted_images/{doc_id}/` 에 persist + UPSERT +
  고아 row DELETE + md_content ref 를 `docimg:img_NNN` stable scheme 으로 정규화
- /api/documents/{id}/images/{key}/raw 인증 라우트 (Cache-Control private + ETag = content_hash)
- frontend MarkdownDoc 가 placeholder card 안의 docimg ref 를 실제 <img> 로 swap

원칙:
- 이미지 binary = NAS, metadata = Postgres (학습 섹션 패턴 동일)
- image_key sequence 기반 결정적 → 재변환 idempotent
- MARKDOWN_IMAGE_PERSIST=false env 로 rollback 가능 (placeholder card 폴백 자연 유지)

기존 28건 marker success 문서는 본 PR 에서 건드리지 않음 — deploy + 신규 업로드 1건 +
sample 5건 검증 후 scripts/marker_reprocess_existing_success.py 로 targeted reprocess.

plan: ~/.claude/plans/piped-humming-crystal.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:05:41 +09:00
Hyungi Ahn 5185501bbd feat(search): PR-RAG-Time-1 freshness decay (news/law_monitor)
뉴스/법령 알림 retrieval 결과에 시간 가중치 soft multiplier 적용.
reranker 이후 final score 합성 단계에서 운영 정책 단계로 분리.

- news (source_channel='news'): half-life 90일
- law_monitor (source_channel='law_monitor'): half-life 365일
- 비적용: manual / drive_sync / inbox_route / memo / Manual / Reference /
  Academic_Paper / Checklist / KGS Code / Study / content_origin='ai_drafted'
- formula: decay = exp(-ln(2) * age / HL); final = base * (0.7 + 0.3 * decay)
- floor 0.7 (완전 demote 금지)
- 가드: missing date / future date / unknown source 모두 no-op
- 임시 date source: documents.created_at (published_date 컬럼 부재 — 후속 PR)

debug 메타 (?debug=true 응답 + logs/search.log):
  base_score / age_days / decay_factor / freshness_adjusted_score /
  freshness_policy / freshness_date_source

신규: app/services/search/freshness_decay.py
hook: app/services/search/search_pipeline.py:303 (apply_diversity 직후, normalize 직전)
schema: app/api/search.py SearchResult.freshness_debug (Optional[dict])
tests: tests/test_freshness_decay.py 24 case (정책 디스패처 9 + age/decay/score 11 + apply integration 6 — guard 1~6 all)

Episode/Fact layer 와 contradiction detection 은 본 PR 스코프 외.
plan: ~/.claude/plans/pr-rag-time-1-freshness-decay.md
2026-05-03 08:38:09 +09:00
Hyungi Ahn 6785d53d3d feat(study): Phase 4-B v1 세션 단위 종합 분석 (자유 마크다운)
Phase 4-A 가 wrong/unsure 한 문제씩 풀이 캐시. 4-B 는 세션 전체 wrong/unsure
5~30건을 묶어 200~400자 자연어 요약 1건 생성. 결과 화면 헤더 카드.

큐 인프라는 4-A study_question_jobs 와 분리 — FK 단일 의미 + 운영 SQL 명확성
+ 4-A/4-B 가드/payload/재시도 정책 차이. 신규 study_quiz_session_jobs (큐) +
study_quiz_session_analysis (결과 캐시 PK=session_id, UPSERT) + 전용 consumer.

Backend:
- migrations/233 — study_quiz_session_jobs (FK study_quiz_sessions NOT NULL,
  status pending/processing/completed/failed/skipped, max_attempts=2)
- migrations/234 — partial unique idx (session_id) WHERE pending/processing
- migrations/235 — study_quiz_session_analysis (session_id PK, summary_md,
  confidence, model_name, generated_at, is_stale)
- models/study_quiz_session_job — ORM + enqueue_session_analysis_job() (멱등)
- models/study_quiz_session_analysis — ORM (PK = session_id)
- services/study/session_summary_guard — GUARD_PATTERN (정규식) +
  normalize_confidence() 단일 source, worker + tests 가 import 공유
- services/study/session_summary_rag — gather_session_summary_context()
  documents 만 (PR-3 _gather_document_evidence 재사용). evidence 없어도 호출
  허용 (4-A 와 다른 정책 — 세션 기록 자체가 evidence)
- services/study/session_analysis_enqueue — auto (finalize/fallback) +
  request_session_analysis_regenerate (manual). manual 은 wrong/unsure < 5
  즉시 차단, active job 차단, 기존 analysis 있으면 is_stale=true 박기
- prompts/study_session_summary_envelope.txt — envelope JSON
  {summary_md, confidence}. 정량 정수만 인용 가능, 비율/추세/범위/날짜 금지
- workers/study_session_analysis_worker — terminal status 분기:
  · wrong/unsure < 5 → status=skipped, error_code=insufficient_attempts
  · question_text/outcome 부족 → skipped, evidence_missing
  · GUARD_PATTERN match → failed, guard_fail
  · 800자 hard cap + confidence normalize
  · timeout/parse/unknown → 재시도 후보
  · UPSERT study_quiz_session_analysis ON CONFLICT DO UPDATE (PK session_id)
- workers/study_session_queue_consumer — 4-A consumer 패턴 복제. BATCH_SIZE=1
  + STALE_MINUTES=10. MLX gate 4-A 와 공유 (Semaphore(1))
- main.py — APScheduler add_job(consume_study_session_queue, ..., 1분 주기)
- session_finalize — 끝에서 enqueue_session_analysis_auto (best-effort)
- api/study_topics:
  · QuizSessionAnalysisOut + ai_session_analysis 응답 필드 (analysis row +
    최신 job status/error_code)
  · GET fallback enqueue (기존 analysis 또는 active job 없으면만, non-blocking)
  · POST /quiz-sessions/{sid}/regenerate-summary — manual 트리거

Frontend (quiz-sessions/[sid]/+page.svelte):
- 결과 헤더에 세션 요약 카드 (AI 풀이 indicator 직후, 바로 할 일 직전)
- summary_md 박혔으면 markdown 렌더, 없으면 job_status / error_code 분기:
  · pending/processing → "AI 가 세션 분석 중"
  · insufficient_attempts → "오답·모르겠음 5건 미만"
  · evidence_missing → "자료 부족"
  · guard_fail → "환각 검증 차단" + 재생성 링크
- confidence='low' 배지 + is_stale "재생성 중" 배지
- 재생성 버튼 + regenerateSummary() — reason 별 toast 분기

ship gate:
- tests/test_session_summary_guard_pattern.py — 허용 5 + 차단 7 케이스 +
  normalize_confidence 표준/비표준 검증. python3 직접 실행 패스.

Plan: ~/.claude/plans/nifty-sparking-spindle.md (Phase 4-B v1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:20:29 +09:00
Hyungi Ahn c7630b9815 feat(study): Phase 4-A 결과 화면 inline indicator — AI 풀이 진척 노출
결과 화면에서 사용자가 [AI 해설 보기] 누를 때 캐시 hit/miss 가 불투명함.
헤더에 한 줄 indicator 추가 — 오답·모르겠음 대상 N건 중 ready 박힌 카운트
+ 진행 중/실패/자료 부족 분포.

Backend (study_topics.py get_quiz_session):
- questions[i].ai_explanation_status 응답에 추가 (q.ai_explanation_status 그대로)
  · frontend 가 attempts.outcome (wrong/unsure) 와 결합해 카운트

Frontend (quiz-sessions/[sid]/+page.svelte):
- $derived aiExplProgress — wrong/unsure attempts 와 question.ai_explanation_status
  결합 카운트 (target / ready / pending / failed / skipped)
- 헤더에 Sparkles 아이콘 + "AI 풀이 자동 생성: N/M (P%)" 한 줄
  · pending > 0: "생성 중 N" (warning 색)
  · failed > 0: "실패 N" (error 색)
  · skipped > 0: "자료 부족 N" (dim)
  · 셋 다 0인데 ready < target: "대기열 처리 대기" (worker 1분 주기 안내)

이 indicator 는 GET fallback enqueue 와 함께 작동 — 결과 화면 진입 시점에
backfill 이 누락된 wrong/unsure 가 이미 enqueue 되고, 1분 주기로 ready 박힘.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:15:35 +09:00
Hyungi Ahn 3db5d331de feat(study): Phase 4-A 운영 가시화 — 통계 대시보드 AI 풀이 카드
Phase 4-A 가 wrong/unsure 풀이를 background batch 로 캐시하는데, 사용자/운영자
입장에서 (1) 지금까지 얼마나 캐시 채워졌는지, (2) 환각 차단/파싱 실패/자료 없음
같은 worker 결과 분포를 볼 수 없었음. 통계 대시보드에 카드 추가.

Backend (study_question_progress.py /stats):
- StatsAiExplanation 신규 응답 섹션
  · status_distribution — 토픽 전체 study_questions.ai_explanation_status 분포
    (none/ready/failed/skipped/stale/pending 6 키 default 0)
  · target_total / target_ready — wrong/unsure progress 의 ready 비율
    (캐시 hit 가능성 추정 핵심 지표)
  · recent_jobs — 최근 7일 study_question_jobs 의 (status, error_code) 분포
    ('completed', 'failed:guard_fail', 'failed:parse_fail', 'skipped:evidence_missing'
    같은 합성 키)

Frontend (/study/topics/[id]/stats):
- 신규 Card "AI 풀이 캐시" — Sparkles 아이콘
  · 큰 숫자 + 진행률 바: ready / wrong+unsure
  · 토픽 전체 status 분포 inline (한국어 라벨)
  · 최근 7일 worker 결과 grid (환각 차단 / 파싱 실패 / 자료 없음 skip 등 분리)
- statusLabel / jobLabel 헬퍼 — 운영자 친화 한국어

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:59:20 +09:00
Hyungi Ahn e8da53490c feat(study): Phase 4-A wrong/unsure AI 풀이 prefetch batch
PR-3 의 결과 화면 [AI 해설 보기] 실시간 호출이 클릭 시 8~30초 대기. 풀이 직후
백그라운드 batch 로 미리 생성해 캐시 hit. 환각 가드는 PR-3 보다 강화 — envelope
JSON {answer_choice, explanation_md, confidence} + answer_choice == correct_choice
검증 + evidence 의무.

processing_queue 가 documents.id FK 라 study_questions 에 직접 재사용 불가 →
별도 study_question_jobs 테이블 + 별도 consumer.

Backend:
- migrations/231 — study_question_jobs CREATE TABLE (13컬럼, kind 권장값
  'explanation' / 'session_summary' 예약, status pending/processing/completed/
  failed/skipped, max_attempts=2)
- migrations/232 — partial unique idx (qid, kind) WHERE status IN
  (pending, processing) — active 행 중복 차단, terminal 이력 누적 허용
- models/study_question_job — ORM + enqueue_study_question_job() 헬퍼
  (on_conflict_do_nothing 멱등)
- prompts/study_explanation_envelope.txt — envelope 형식 프롬프트
  (answer_choice 1~4 강제, confidence high/medium/low)
- workers/study_explanation_worker — terminal status 분기:
  · evidence 둘 다 빈 리스트 → job/question 모두 skipped (LLM 호출 X)
  · answer_choice != correct_choice → guard_fail / failed (재시도 X)
  · timeout/parse → 재시도 후보 (max_attempts=2)
  · catch-all except → unknown 명시 + retryable 분기
  · question.ai_explanation_status='ready' 이미 박혀있으면 즉시 completed
  · confidence 는 job.payload 에 보존 (운영 분석)
- workers/study_queue_consumer — APScheduler 1분 주기, BATCH_SIZE=1, MLX gate
  Semaphore(1) 공유. STALE_MINUTES=10 자체 복구
- main.py — scheduler.add_job(consume_study_queue, ..., id='study_queue_consumer')
- services/study/explanation_enqueue — finalize + GET fallback 공유 헬퍼:
  filter_needs_explanation (study_questions status + 최신 job error_code 필터,
  guard_fail/evidence_missing 인 마지막 job 은 자동 재enqueue 제외) +
  enqueue_explanation_for_qids (max_count cap)
- session_finalize — 끝에서 wrong/unsure qid prefetch enqueue (best-effort,
  실패해도 finalize 자체 안 깨짐)
- api/study_topics get_quiz_session — done 세션에서 backfill enqueue (max=30,
  non-blocking, debug 로그)

대상 조건: ai_explanation_status IN ('none', 'failed') OR ai_explanation IS NULL.
stale / skipped / pending / ready 는 자동 enqueue 대상 X. stale 재생성은 PR-3
명시 [다시 생성] 또는 후속 Phase 에서.

Plan: ~/.claude/plans/nifty-sparking-spindle.md (Phase 4-A)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:42:08 +09:00
Hyungi Ahn 711b81f8f0 feat(study): Phase 2-F due_at 정체 정리 — overdue redistribute
사용자가 며칠 안 들어오면 due_today 가 누적되어 학습 페이스 압박. Phase 1
plan 위험 항목 처리. 자동 batch 대신 사용자 명시 액션으로 통제권 보장.

Backend:
- POST /study-topics/{tid}/review-queue/redistribute — overdue 를 round-robin
  분산. days_offset = i % spread_days + 1 (오늘 + 1~7일). 같은 날 안에서도
  i*7분 spread 로 시간 분산. review_stage 는 보존 (재배치만, stage 리셋 X).
  body { spread_days: 1~14, default 7 }. 응답 { redistributed_count, spread_days }.
- GET /review-queue?tab=due_today 응답에 overdue_count: int 옵션 필드 — UI 가
  경고 + [정리] 노출 판단. due_at < today 0시 (UTC) + stage<4 카운트.

Frontend (review-queue):
- due_today 탭에서 overdue_count>0 시 노란 banner — "정체 N건" + [정리] 버튼.
- 정리 클릭 → confirm → POST → toast (N건을 7일에 분산) → 카운트/목록 reload.
- 다른 탭에서는 banner 미노출 (backend 가 overdue_count=0 응답).

Plan: ~/.claude/plans/crispy-petting-dijkstra.md (Phase 2-F)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:48:00 +09:00
Hyungi Ahn f42f6ff480 feat(study): Phase 2-E 복습함 멀티 셀렉트 → 복습 세션
복습함 카드 단위 체크박스 + sticky bottom bar 로 N개 골라 한 quiz_session.
backend QuizSessionStartRequest 에 question_ids 파라미터 추가 — 우선순위
stage > question_ids > 기존 subject 경로. 명시되면 selection 우회 + 검증
(user × topic 소속 + 미삭제 + 최대 200 + 중복 제거 순서 보존).

Backend:
- question_ids: list[int] | None — Field 한도 200
- valid_set 검증: 다른 user/topic 또는 deleted_at 인 qid 는 silent drop
- subject_distribution 자동 계산 (결과 카드용)
- 빈 wanted / 무효 qid → 400

Frontend (review-queue 페이지):
- 카드 좌측 체크박스 (분리 영역, 본문 클릭은 기존대로 문제 페이지)
- "이 페이지 전체 선택 / 해제" 토글
- 선택 N>0 시 sticky bottom bar — `{N}개 풀이 시작` 버튼
- 탭 변경 시 선택 초기화 (다른 의도 묶음 가능성)
- 페이지 이동 시 선택 유지 (Set<question_id>)
- 진행 중 in_progress 세션 있으면 confirm 후 abandon
- 200 한도 도달 시 toast 경고

Plan: ~/.claude/plans/crispy-petting-dijkstra.md (Phase 2-E)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:39:46 +09:00
Hyungi Ahn d39882c38e feat(study): Phase 2-D 학습 통계 대시보드 — 6 섹션
신규 라우트 /study/topics/[id]/stats — backend 단일 endpoint 호출로 6 섹션:
진척도 / 학습 상태 분포 / 복습 큐 / 세션 추이 / 일별 풀이량 / 과목별 약점.
차트는 SVG 직접 렌더 (의존성 0).

Backend (app/api/study_question_progress.py):
- GET /study-topics/{tid}/stats — 6~7 쿼리 묶음
  · 문제 진척도 (study_questions count + progress count)
  · pattern_state 분포 (NULL → unattempted + 토픽 미시도분 합산)
  · review_stage 분포 (0/1/2/3/mastered≥4)
  · due 분류 (today / this_week / later / mastered) — datetime 비교 + filter
  · 최근 done 세션 추이 (Phase 2-B 4 컬럼 활용, limit 20)
  · 일별 풀이량 30일 (cast Date + group)
  · 과목별 약점 (subject 별 attempted/correct/pending_review/chronic)

Frontend (/study/topics/[id]/stats):
- Card grid 6개. 진행률 바 + stacked horizontal bar + SVG sparkline + bar chart.
- 패턴 분포: 7색 stacked bar + 범례 grid.
- 복습 큐: 4 카운트 박스 + stage 분포 inline.
- 세션 추이: SVG sparkline (50% baseline) + 최근 5세션 표 (회복/퇴행/새로 맞힘 인라인).
- 일별 풀이량: SVG bar (max 동적) + title tooltip + start/end 날짜 라벨.
- 과목별: 정답률 진행률 바 + 미확인/반복 오답 인라인.

진입: 토픽 페이지 헤더 [통계] 버튼.

Plan: ~/.claude/plans/crispy-petting-dijkstra.md (Phase 2-D)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:04:03 +09:00
Hyungi Ahn d3bf963a66 feat(study): Phase 2-B 결과 화면 변화 카운트 + 확인완료 progress 통합
Phase 1 finalize 가 계산하던 SessionSummary 가 응답에 포함되지 않고 discard
되던 것을 quiz_session row 4 컬럼으로 영속화. 결과 화면 헤더에 회복/퇴행/
새로 맞힘/반복 오답 누적 변화 카운트 + "바로 할 일" 콜아웃 (지금 시점
progress 기반 동적 카운트 — pending_review/chronic/regressed). 동적 카운트는
결과 GET 호출 시점에만 계산 (목록 endpoint 비용 회피).

확인완료 통합 — 결과 카드의 [학습완료] 버튼이 attempts.reviewed_at 만 박던
것을 progress.last_reviewed_at + (wrong/unsure 면 due_at 최초 부여) 도 같이
박도록. reviewed=false 토글은 attempts 만 되돌림 (다른 attempt 가 검토 표시
했을 수 있어 progress 의 last_reviewed_at 은 보존).

- migrations/230 — quiz_sessions 4 컬럼 ADD (단일 ALTER TABLE)
- StudyQuizSession 모델 + finalize_session 가 row 영속화
- QuizSessionSummary 응답에 4 스냅샷 + 3 동적 필드 (default 0)
- _build_session_summary include_progress_counts=True 시 SQL 3회
- review-mark 가 reveiwed=true 시 progress 동기화
- 결과 화면: 헤더 변화 카운트 줄 + 바로 할 일 콜아웃 (값 있을 때만)

Plan: ~/.claude/plans/crispy-petting-dijkstra.md (Phase 2-B)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:49:01 +09:00
Hyungi Ahn d038f11444 feat(canonical): Phase 1C MarkdownDoc renderer + heading anchor + KaTeX
문서 상세 페이지에서 canonical markdown(md_content) 을 우선 렌더하고
없으면 extracted_text fallback. md_frontmatter 가 있으면 본문 위에 메타
박스. h1~h6 에 GFM heading id + hover 시 # 링크 표시. 이미지 alt 가
있으면 figure + figcaption. KaTeX 수식 ($...$ / $$...$$) 지원.

Backend:
- DocumentDetailResponse 신규 (DocumentResponse + extracted_text + md_*)
- GET /documents/{doc_id} 응답 모델 전환
- 리스트 응답은 DocumentResponse 그대로 (페이로드 비대화 회피)

Frontend:
- lib/utils/docMarkdown.ts — 별도 Marked 인스턴스 (study mathMarkdown.ts
  영향 0). marked-katex-extension + marked-gfm-heading-id + custom image
  renderer (figure/figcaption + data-md-img marker).
- lib/components/MarkdownDoc.svelte — md_content/extracted_text 우선순위,
  frontmatter 박스, mdStatus=failed 안내 배지, heading anchor DOM 후처리.
- /documents/[id] markdown / hwp-markdown / article viewer 3 곳 wiring.
- app.css — .markdown-doc heading-anchor / md-figure / katex 가로 스크롤.

이미지 ImgAuth 후처리(blob URL 교체) wiring 은 Phase 1B.5 에서. 현재는
data-md-img="1" 마킹만 두고 marker 출력 src 그대로.

Plan: ~/.claude/plans/plan-idempotent-sundae.md (Phase 1C)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:37:33 +09:00
Hyungi Ahn 9094b2dbc5 feat(study): Phase 1-E 풀이 선별 알고리즘 — bucket + stage 비율
vision 의 단일 풀이 진입점 — stage (intro/learning/pre_exam) + size 옵션으로
같은 endpoint 가 다른 분포의 문제 출제.

services/study/quiz_selection.py:
- bucket: unattempted / wrong_or_unsure / due_review / regressed / frequent / random
- stage 별 비율:
  - intro:    unattempted 55, wrong_or_unsure 30, frequent 15
  - learning: due_review 20, wrong_or_unsure 40, unattempted 30, frequent 10
  - pre_exam: due_review 20, wrong_or_unsure 30, regressed 10, frequent 20, random 20
- bucket 우선순위 (dict 순서) — 다음 bucket 은 이미 뽑힌 qid 제외
- 후보 부족 시 random backfill, 그래도 부족 시 ValueError

api/study_topics.py:
- QuizSessionStartRequest 에 stage / size 옵션 추가
- stage 명시 시 select_questions_for_quiz 사용
- stage 미명시 시 기존 PR-12-B 경로 (subject bucket + spacing) 호환 유지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:30:11 +09:00
Hyungi Ahn e5982ebde4 feat(study): Phase 1 학습 루프 데이터 계층 — progress 캐시 + finalize + review API
vision (풀이 → 확인 → 학습 → 복습 → 다음 풀이 가중치) 의 데이터 계층.

데이터 모델 (migrations 222~225):
- study_question_progress 테이블 — user × topic × question 단위 현재 상태 캐시
  - 마지막 시도: last_outcome, last_attempted_at, last_attempt_id
  - 검토 상태: last_reviewed_at
  - 복습 큐: due_at, review_stage
  - 패턴 분류 (derived): pattern_state, pattern_updated_at, pattern_window_attempts
- 3 partial idx (due / topic_pattern / pending_review) — 탭별 빠른 조회

패턴 분류 (services/study/learning_pattern.py):
- 7 분류: unattempted/unsure/chronic_wrong/regressed/recovered/stable/unstable
- 윈도우 = 최근 3회 + 과거 correct/wrong 존재 여부
- chronic_wrong > regressed > recovered 우선순위 (보수적 학습)
- 가드: wrong 1회만으로 regressed 안 됨 (이전 correct 이력 필요)
- stable 은 3 연속 correct 부터

세션 종료 집계 (services/study/session_finalize.py):
- attempts append-only 원본 보존, progress upsert 만
- 마지막 attempt 직후 finalize hook 자동 발동
- finalize 는 last_* + pattern_state 만 갱신, due_at 미진입 문제는 NULL 유지
- 이미 due_at 박힌 문제는 finalize 가 stage 갱신 (correct → +1 / wrong → 리셋)

API (api/study_question_progress.py):
- POST /study-topics/{tid}/questions/{qid}/review-complete
  → last_reviewed_at + (wrong/unsure 인 경우만) due_at 최초 부여
- GET /study-topics/{tid}/review-queue?tab=due_today|pending_review|chronic|regressed|mastered
  → 5 탭 paginated 조회
  → pending_review 는 last_reviewed_at < last_attempted_at 까지 포함 (이전 확인완료 후 다시 wrong 잡힘)

Phase 1-E (풀이 선별 알고리즘) 은 후속 commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:28:46 +09:00
Hyungi Ahn 219e233a48 feat(study): related-types DB 캐시 — HNSW 매번 재계산 제거
- migrations 220/221: study_questions 에 related_repeat/similar JSONB + 카운트/grade/computed_at/threshold_version + partial idx
- 임베딩 워커: ready 처리 직후 같은 트랜잭션에서 related 계산·저장 + 같은 토픽 ready 행들의 related_computed_at=NULL invalidation
- 신규 cron study_q_related_refresh (1분, batch=20) — stale 캐시 일괄 재계산
- API list_related_types: cache hit (computed_at + threshold version 일치) 시 SELECT 1번으로 응답. miss 면 즉시 계산+저장 후 응답
- update_question PATCH: 본문/exam_round 변경 시 related_computed_at=NULL
- soft delete: 같은 토픽 ready 행 invalidation

threshold 변경 시: related_types.THRESHOLD_VERSION 갱신 + UPDATE WHERE version != '<신>' SET computed_at=NULL 한 번이면 cron 자동 일괄 재계산.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:22:31 +09:00
Hyungi Ahn fc8aea1649 feat(study): 반복 출제 라벨 등급 + cosine 임계값 0.85 조정
- round_count 별 등급 매핑 (단골/잘 나오는 반복 출제/반복 출제/신출/빈출)
  - ≥7 단골, 5–6 잘 나오는 반복 출제, 3–4 반복 출제,
    2 + max(연도)≥2024 신출, 2 + 모두 옛 빈출
- SIMILAR_THRESHOLD 0.88 → 0.85 (5-source 분포 측정 결과 자연 갭 위치 반영)
- API 응답 + 프론트 3곳 (보기/통합뷰/결과 카드) 라벨 일괄 통일

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:50:39 +09:00