Compare commits

..

421 Commits

Author SHA1 Message Date
hyungi ceabd1fcac 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:18 +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 7c9aff393a feat(search): MLX priority gate (B-1, Priority.FOREGROUND vs BACKGROUND)
DS-Mac-mini-26B-Priority-Gate-1 — Mac mini 26B single-inference gate 를
FIFO Semaphore → 우선순위 기반 heap dispatch 로 교체. concurrency 1 유지,
queue ordering 만 foreground 우선.

API:
- Priority(IntEnum): FOREGROUND=0, BACKGROUND=100
- acquire_mlx_gate(priority=DEFAULT_PRIORITY) async context manager
- DEFAULT_PRIORITY = BACKGROUND (안전 default, foreground 짓밟지 않음)
- get_mlx_gate() legacy wrapper — context-manager only 호환

구현:
- _inflight: bool + _waiters heap [(priority, seq, future, enqueue_ts)]
- fast-path: not inflight and not waiters → 즉시 inflight, Future 생성 X
- _dispatch_next_locked: cancelled/done Future skip (heap 잔재 risk 회피)
- release: lock 안에서 pop, set_result 는 loop.call_soon (lock 밖) reentry deadlock 회피
- dispatch / enqueue / release / WARN log (observability)
- BACKGROUND wait_ms > 300_000 (5분) 시 starvation WARN — aging 은 Phase 2 deferred

Tests (tests/test_priority_gate.py, 6 scenario):
1. FIFO within same priority
2. Foreground jumps queue (bg5 대기 중 fg 들어오면 즉시 다음 슬롯)
3. Long-running background blocks foreground (preemption X, intended)
4. Mixed concurrent enqueue (FG fifo 먼저, BG fifo 후)
5. Backward compat (legacy get_mlx_gate() = BACKGROUND 매핑)
6. Cancelled waiter skip (heap 의 죽은 Future 건너뜀, gate stuck X)

Site 교체는 별 commit (refactor(search): swap 10 call sites).

plan: ~/.claude/plans/hermes-polymorphic-rossum.md
2026-05-17 08:42:58 +09:00
Hyungi Ahn 7e346d2d3f docs(search): DS-Synthesis-Timeout-Calibration-1 (B-3) closure 보고서
5곳 LLM_TIMEOUT_MS + 2곳 outer wait_for align (classifier 30s 와 동일 정책).
synthesis/evidence/verifier/query_analyzer 모두 동시 부하 시 30s 까지 필요.

Regression fixture 결과: 10/10 HTTP 200 + 5/5 search + 3/3 failure injection
모두 PASS (회귀 0). 응답 시간 +4~20s 증가 (안정성 ↑ 의도된 trade-off).

p95 12s gate 는 여전히 FAIL — B-1 Throughput-1 (priority queue / 모델 분리)
별 plan 으로 latency 단축 방향 진입.
2026-05-17 08:07:51 +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 117597c8aa docs(hermes): PR-Hermes-Skill-Curl-Refine-2 (SHIPPED) + MaxTokens-Followup (PARTIAL+REVERTED)
Curl-Refine-2 (SHIPPED): 3 SKILL.md 본문 "Tool 선택 (필독)" 단락 추가 — terminal
direct curl 강조 + execute_code Python wrap 금지. E2E: Gemma 1st turn
execute_code → terminal 전환 + DS API 도달 0→1 + real corpus citations
("test-voice-memo", "The Good List") 첫 성공. Hard-Enforcement-1 의 hook 와
시너지 (1 call cap + 1st 정상 path).

MaxTokens-Followup 1차 (PARTIAL+REVERTED): agent.disabled_toolsets 15 toolsets
비활성 → stream 102KB→80KB 22% 감소. BUT Gemma terminal tool_call 시
"invalid tool call" 회귀 발생 → revert. toolset dependency graph 조사 후
minimal safe disabled list 결정 = 별 트랙 PR-Hermes-MaxTokens-Investigation-1.

A 카테고리 6 PR + 부산 Curl-Refine-2 모두 SHIPPED. PR-1/2 user-facing E2E 완성.
2026-05-17 07:51:02 +09:00
Hyungi Ahn 9458bea595 docs(hermes): PR-Hermes-MultiTurn-Hard-Enforcement-1 closure 보고서
Polish-1 의 prompt-only enforcement (PARTIAL) escalate. Shell hook
(~/.hermes/agent-hooks/docsrv_repeat_block.py) + config.yaml hooks.pre_tool_call.
execute_code/terminal tool_input 의 DS endpoint URL regex 검출 후 session-별
카운트 ≥ 1 면 silent block.

검증:
- Unit smoke 4/4 PASS
- E2E hook 매칭 2건 정확: 1st execute_code (Python wrap) allow → 2nd terminal
  (direct curl) block. state={"docsrv_ask": 1}.

부산 발견: Gemma 의 1st turn code generation quality (Python f-string + curl
wrap → SyntaxError) 으로 DS API 실 호출 0 — Hermes/Adapter A 무관, 별 트랙
PR-Hermes-Skill-Curl-Refine-2 (P3).
2026-05-17 07:35:07 +09:00
Hyungi Ahn dffc8b24dd docs(hermes): PR-Hermes-Skill-Polish-1 closure 보고서
3 SKILL.md (docsrv_memo/search/ask) frontmatter 표준화 — prerequisites.env →
required_environment_variables (agentskills.io 표준). skill_view 시 자동
register_env_passthrough 발화 + config-level terminal.env_passthrough 와
이중 안전망.

docsrv_ask 본문: Multi-Turn 차단 정책 + Response Format verbatim 강화.

검증:
- Layer 1 fixture 회귀 0 (5/5 raw_leak, 3/3 finish_reason 동일)
- E2E: pre-polish 4 turn → post-polish 3 turn (25% 감소, but 목표 1 turn 도달 X)
  — prompt-only enforcement 한계 명확화

결정:
- Skill-Curl-Refine-1 (frontmatter) = SHIPPED
- Multi-Turn-Refinement-1 (prompt) = PARTIAL — plugin-level escalate
- 신규 트랙 PR-Hermes-MultiTurn-Hard-Enforcement-1 (P2) 박힘 (Answer-Policy-1
  과 통합 검토)
2026-05-17 07:13:53 +09:00
Hyungi Ahn bd89d07b70 docs(hermes): PR-Hermes-Sandbox-Env-Propagation-1 closure 보고서
PR-Hermes-Docsrv-Search-1 / PR-Hermes-WebSearch-1 의 user-facing E2E 마지막 조각.
Adapter A 후 잔존한 401: execute_code/terminal 샌드박스가 HERMES_DOCSRV_TOKEN
strip. 해결 = ~/.hermes/config.yaml terminal.env_passthrough 1줄 추가.

검증:
- Direct: is_env_passthrough("HERMES_DOCSRV_TOKEN")=True, CLAUDE_API_KEY=False
  (GHSA-rhgp-j443-p4rf provider blocklist 유지)
- E2E: Hermes chat → DS API 200 → conf=medium completeness=full + real corpus
  citations ("test-voice-memo", "The Good List: 6 Things to Add Joy to Your Day")

PR-1/2 user-facing E2E unlock 완료 — Discord smoke 검증 진입 가능
(가족 onboarding 전 hyungi 채널 한정).
2026-05-17 06:37:35 +09:00
Hyungi Ahn d3bc378c21 docs(hermes): PR-Hermes-ToolCall-Adapter-1 closure 보고서
mlx-proxy _stream_mlx 에 SSE filter 추가 — Gemma 4 raw <|tool_call> 토큰 leak
suppression + 구조화 tool_calls 시 finish_reason 'stop'→'tool_calls' override.

Layer 1 fixture (5 case): 5/5 raw_leak suppressed + 3/3 finish_reason override.
Hermes chat multi-turn agent loop unlocked (이전 hallucinated 종결 → tool 실행).

후속 = PR-Hermes-Sandbox-Env-Propagation-1 (execute_code 가
HERMES_DOCSRV_TOKEN inherit 못 함 — PR-1/2 user-facing E2E 마지막 조각).
2026-05-16 20:42:34 +09:00
Hyungi Ahn e5345d7832 docs(hermes): PR-Hermes-WebSearch-1 closure 보고서
ddgs (DuckDuckGo) provider 활성. Layer 1 fixture 4/4 results (p95 12.3s, ddgs raw
latency 한계).

SearXNG (LocalScout PR-A 잔존) 활성화는 PR-2B 로 분리 — LAN-only bind 로 Mac mini
Tailscale 접근 불가. ddgs 1주 사용 후 SearXNG swap ROI 판정 예정.

channel_prompts 9줄 통합 (PR-1 4줄 + PR-2 web 분기 5줄). LLM tool-call 실제
실행은 Adapter A blocker — Layer 2/3 user-facing E2E 는 Adapter A closure 후.
2026-05-16 20:22:43 +09:00
Hyungi Ahn d14064b225 docs(hermes): PR-Hermes-Docsrv-Search-1 closure 보고서
Hermes 의 첫 read-only orchestrator (docsrv_search + docsrv_ask skill) 구현 + DS-side
Mac mini 26B concurrent load 5건 fix closure.

핵심:
- Layer 1 curl-direct fixture 10/10 HTTP 200 + failure 3/3 PASS
- DS-side 5 commit 으로 race condition 해소 (LLM_TIMEOUT, gate, wait_for, config)
- Layer 2 Hermes CLI invoke 는 Gemma 4 tool-call leak 으로 hallucinated — Adapter A blocker
- Layer 3 Discord smoke 도 동일 — 사용자 검증은 Adapter A closure 후 이월

후속 5 별 트랙 명시.
2026-05-16 20:07:18 +09:00
Hyungi Ahn ad3d51e3e0 fix(search): classifier + evidence gate 안으로 이동 (Mac mini 26B race 종결)
llm_gate.py docstring 영구 룰: "MLX primary 호출 경로는 예외 없이 gate 획득 필수".
PR #20 이후 classifier (Mac mini 26B 신규) + evidence (triage→Mac mini 26B 통합)
모두 gate 외부 실행 — concurrent 안전성 별 검토 명시. 1주 관찰 결과: race 빈번.

본 PR-Hermes-Docsrv-Search-1 Layer 1 fixture 측정:
- 8/10 query "conservative_refuse(no_classifier)" — classifier 가 동시 부하 시
  거의 모두 ReadTimeout 또는 wait_for(6s) timeout
- evidence ev_ms=15005 — synthesis 와 race 로 15s 누적

영향:
- ask total 시간 증가 (parallel race → serialized): query_analyzer 5s +
  classifier 3-5s + evidence 5s + synthesis 30s ≈ 40-45s 상한 (현실 평균)
- 응답률 ↑: race timeout 으로 인한 conservative_refuse 해소
- 사용자 체감: 빠른 거절 → 의미있는 답변. 단 대기 시간 ↑

후속:
- skill `docsrv_ask` curl `--max-time 20` → 60s 상향 필요 (별 PR 또는 본 PR
  안의 follow-up)
- 본 메모리 `2026-05-21 Mac mini 26B 1주 부하 측정` observation 의 결정
  outcome: gate 복귀 (triage 별 작은 모델 재도입 옵션은 보류)
2026-05-16 19:54:55 +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 a332a8aabe fix(search): classifier timeout 15s → 30s (concurrent load 2x margin)
A1+config(15s) 후속 진단: voice memo PoC plan 호출 elapsed_ms=14432 — 15s 한계 거의
밀착. Mac mini 26B 동시 부하 (classifier + evidence + synthesis 3-way) 시 빈번
ReadTimeout 잔존.

30s 로 2x 마진 확보 — config.yaml + classifier_service.py 양쪽 align. Phase 3.5
guardrail 동작 자체에는 영향 없음 (timeout 시 fallback 경로 동일).

향후 별 트랙 (DS-Mac-mini-26B-Concurrent-Load-1): asyncio.Semaphore 도입으로
Mac mini 26B 동시 호출 제한 vs triage 만 작은 모델 재도입. 본 PR 은 timeout
완화만.
2026-05-16 19:42:49 +09:00
Hyungi Ahn a8b84e641a fix(search): classifier.timeout config 10s → 15s (httpx inner timeout align)
A1 timeout 5s → 15s 후 진단 로그가 httpx.ReadTimeout('') 확정. classifier_service
의 asyncio.timeout 외부 wrap (15s) 보다 AIClient._request 내부 httpx timeout
(10s, config.yaml classifier.timeout) 가 먼저 fire → ReadTimeout 빈 메시지 raise.

두 timeout 을 15s 로 align — Mac mini 26B 동시 부하 (PR #20 후속) 시 classifier
지연 ≤15s 까지 허용.

후속: evidence_service.py / synthesis_service.py 의 timeout 도 동일 패턴 검토
필요 (별 PR, DS-Mac-mini-26B-Concurrent-Load-1 트랙).
2026-05-16 19:12:51 +09:00
Hyungi Ahn 542b6a0084 fix(search): classifier error log type+repr (empty-msg exception 진단)
PR-Hermes-Docsrv-Search-1 Layer 1 fixture 가 classifier error: <빈 메시지> 빈번 발생
보고. isolation 직접 호출은 3/3 성공, 동시 부하 (ask endpoint 의 classifier + evidence
parallel) 시에만 발생.

Exception type + repr 캡처해서 root cause 식별 (httpx.ReadTimeout / TimeoutError /
ConnectionError / 기타 무엇인지). 식별 후 후속 PR (DS-Classifier-Concurrent-Load-1)
에서 본격 mitigation.
2026-05-16 19:08:23 +09:00
Hyungi Ahn c769ad14ad fix(search): classifier LLM_TIMEOUT_MS 5s → 15s (Mac mini 26B concurrent load)
PR #20 (f139945) GPU LLM 제거 후 Mac mini 26B 가 triage + classifier + chat + STT
동시 흡수. classifier_service hardcoded 5s timeout (config.yaml `timeout: 10` 무시)
이 동시 부하 시 빈번 초과 → CIRCUIT_THRESHOLD(5) 누적 → circuit 60s open →
verdict=None → refusal_gate conservative_refuse(no_classifier) 경로.

실측: 정상 부하 단독 호출 = 2.3s (500 prompt + 49 completion tokens), 동시 호출 시
ev_ms/synth_ms 가 15s 까지 누적 — 5s 한계가 architectural mismatch.

15s 로 상향 → classifier 정상 verdict 반환 → refusal_gate 가 classifier 의
sufficient/insufficient 사용 (conservative fallback 회피).

본 fix 는 [[2026-05-21 Mac mini 26B 1주 부하 측정]] observation 의 회귀 결과로
자연 정리. config.yaml `classifier.timeout: 10` 와는 별 변수 — 본 1줄은 코드 내
한계, config 항목은 별 PR (Config-Driven-Timeout-1) 에서 통합 검토.

발견 경로: PR-Hermes-Docsrv-Search-1 Layer 1 fixture (curl direct, 10/10 ask)
가 conservative_refuse(no_classifier) 8건 + timeout 2건 보고. fastapi log
"classifier circuit OPEN for 60s" + "classifier timeout" 페어 발견.
2026-05-16 19:02:55 +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 Ahn 3627060d2a fix(ingest): devonagent extract md_status 'ready' → 'success'
documents_md_status_check 제약은 {pending/processing/success/partial/failed/skipped}
만 허용. extract_worker 의 web HTML 분기가 'ready' 박아서 CheckViolationError
로 3회 실패. plan/docs/메모리에 'ready' 로 잘못 표기됐던 것 수정.

19668 (첫 sample doc) 검증 중 발견. fix 후 queue 'failed' 행 reset 으로 재실행.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:42:15 +09:00
Hyungi Ahn 0cbba0ceeb feat(ingest): devonagent 트랙 Phase 1 ingest 활성화
DEVONagent/DEVONthink 가 발견한 웹페이지를 NAS Web/ drop → file_watcher
ingest → extract 4-tier fallback (trafilatura/sibling-md/readability/bs4)
→ embed + chunk 까지. classify/preview/markdown SKIP.

- source_channel='devonagent' (migration 001 dormant 활성화)
- file_watcher: SCAN_TARGETS 통합 + Web/ rglob + canonical_url dedup +
  sidecar 누락 정책 (skip 안 함, web_meta.sidecar_missing=true flag)
- extract_worker: HTML+devonagent 분기 + md_extraction_engine 4-tier 구분
  (trafilatura → sibling .md ≥200char → readability+markdownify → bs4_text)
- queue_consumer: enqueue_next_stage 의 extract stage 만 source_channel-
  aware override (devonagent → [embed, chunk])
- classify_worker: devonagent safety skip (law_monitor 패턴 mirror,
  ai_domain='Web', ai_tags=['Web/{host}'])
- requirements: trafilatura/readability-lxml/markdownify 추가
- docs: devonthink-web-bridge.md 설치 가이드 + first-wins 정책 명시

Phase 1 closure 기준 = 재료 품질 (검색 가능 + 노이즈율 + dedup + 엔진 분포).
활용처(ai_tldr/digest/PKM 회고)는 1-2주 OR 30-50건 관찰 후 별 PR 에서 결정.

Plan: ~/.claude/plans/db-snuggly-petal.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:23:16 +09:00
hyungi 118f32f9b1 refactor(ai): PR #20 reframe cleanup — Ollama LLM 잔재 주석 정정
PR #20 (2026-05-14, GPU LLM 제거 + Mac mini 26B MLX 흡수) 의 swap 이
backends.json + 코드 주석/docstring 까지 따라가지 못한 표현 잔재 정리.

- app/ai/client.py: AIClient docstring 및 call_triage / call_fallback
  docstring 의 "4B Ollama" → "Mac mini 26B MLX" / "현재는 triage 와
  동일 엔드포인트" → "Claude Sonnet 4 API (PR #20 swap 완료)"
- app/core/config.py: triage/primary/fallback 주석 통합 + Phase 3.5
  classifier/verifier 주석에 PR #20 endpoint 명시 (history 보존)
- app/services/search/{llm_gate,classifier_service,verifier_service,
  evidence_service}.py: "fallback(Ollama)" / "Ollama concurrent OK"
  / "triage(4B Ollama)" 표현을 Mac mini 26B MLX endpoint 기준으로
  정정 + concurrent 안전성 별 검토 마커 추가
- app/services/digest/summarizer.py: "MLX hang/Ollama stall 방어"
  → "MLX hang / fallback Claude API stall 방어"
- app/services/prompt_versions.py: SUMMARY_TRIAGE_TASK + ASK_PROMPT_VERSION
  주석의 "4B Ollama" / "4B gemma Ollama" → Mac mini 26B MLX
- app/workers/classify_worker.py: B-1 tier triage docstring 정정

코드 동작 변경 0 (주석/docstring 만). embed_worker / study_question_embed_worker
의 "Ollama bge-m3" 표현은 사실 정확이라 유지.

검증:
- ollama list → bge-m3:latest 잔존 (embedding owner)
- /api/embeddings probe → 1024-dim 200 OK
- fastapi embed/ollama error 0 (last 10min)
- document.hyungi.net 200

plan: ~/.claude/plans/4-stateless-dongarra.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:09:15 +00:00
Hyungi Ahn e74d5e29a0 docs(news): RSS 후보 명단 (PR-News-Prep-Layer-1)
약한 국가 (TW/HK/IN/CN 활성 2) 보강 후보 8건. 자동 HEAD 검증 4/8 :
  - HKFP / The Hindu / TOI World / Caixin English

URL 갱신 필요 4건 — Focus Taiwan / 自由時報 / Scroll.in / RTHK
사용자가 직접 RSS index 확인 후 갱신 + enable 결정. 본 PR INSERT 안 함.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:43:06 +09:00
Hyungi Ahn 73734d5585 fix(news): backfill INTERVAL bind 을 make_interval(days=>:days) 로 교체
asyncpg 가 :days || ' days' 의 int → text 암묵 변환을 거부함.
make_interval 사용으로 int 그대로 바인딩 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:40:11 +09:00
Hyungi Ahn 78b8b52a86 fix(news): backfill script sys.path 컨테이너 호환 (parent.parent / 'app' 또는 parent.parent)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:39:47 +09:00
Hyungi Ahn 08cf676c26 fix(news): news 문서 chunk stage enqueue 추가 + 7일 백필 스크립트
document_chunks.country 가 7일 분포 기준 99.9% NULL 이었던 root cause = news_collector 가
summarize + embed 만 enqueue 하고 chunk 를 enqueue 하지 않아 chunk_worker 가 news 문서에 한 번도 안 돌고 있었음.
queue_consumer.next_stages 의 summarize 키 부재가 follow-up 미연결 원인.

news 외 summarize 흐름 부수영향 회피를 위해 next_stages 가 아니라 news_collector RSS/API 양쪽에 chunk
enqueue 1줄씩 명시 추가. days_old <= 30 가드 안에서 embed 와 동일 정책.

scripts/news_chunk_country_backfill.py — doc 단위 small batch, 실패 doc skip,
50건마다 progress. queue 우회 직접 chunk_worker.process 호출로 timing 통제.

Gate (PR closure):
  A) chunked_doc_pct > 95%  최근 7일 news doc 중 chunk 보유 비율
  B) country null_pct < 5%  최근 7일 news chunk country NULL 비율

plan: ~/.claude/plans/7-whimsical-crab.md (PR-News-Prep-Layer-1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:35:53 +09:00
hyungi e78a10b805 Merge pull request 'feat(digest): Phase 4.5 SvelteKit UI' (#22) from feat/digest-ui-phase45 into main
Reviewed-on: #22
2026-05-15 14:05:12 +09:00
hyungi 2893029d8d feat(digest): Phase 4.5 SvelteKit UI
/digest 라우트 신규 — Phase 4 (7일 rolling country×topic batch digest) backend
운영 데이터 사용자 진입점. 최신 1건 (GET /api/digest/latest) 표시 + country
pill 탭 + topic 카드 (rank/label/summary/article_count/importance, fallback
Badge 조건부).

- frontend/src/routes/digest/+page.svelte 신규 (123 LOC) — Svelte 5 runes,
  Tabs snippet 패턴, 404 EmptyState 흡수, country reload 보호.
- frontend/src/routes/+layout.svelte nav 1줄 추가 (아침 브리핑 뒤).

후속 별 PR: date picker, article click 라우팅, 국기+한국어 dictionary,
Phase 4.6 feedback loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 05:04:22 +00:00
hyungi f17d58f992 chore(gitignore): host venv + 백업/롤백 스냅샷 패턴 추가
.venv/ (host venv, 76M), *.bak / *.pre-* / .pre-*/ (작업 전 백업).
git history가 source of truth이므로 working tree 백업은 ignore.
2026-05-15 04:46:26 +00:00
hyungi 03a37c4b01 chore(reports): Phase 1/2 baseline + 2026-04~05 평가·관측 자료 보존
Phase 1.1a~1.3 / Phase 2.1~2.3 평가셋 측정 결과 + regression baseline + D9 STT 후속 VRAM 피크 관측 데이터.
project_search_v2 메모리에 Phase 2 평가셋 v0.2 baseline용 보존 명시.
2026-05-15 04:45:56 +00:00
hyungi 10244a726f Merge pull request 'feat(study): Mac mini derived-worker (PR-MacMini-Derived-Worker-1)' (#21) from feat/macmini-derived-explanation into main
Reviewed-on: #21
2026-05-15 13:36:26 +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 261036c7b2 ops(file-watcher): idle fire 로그 가시화
watch_inbox() 가 new_count/changed_count 둘 다 0 일 때 silent — PR-NAS-Watch-Folder 검증 시 fire 추적 부재 확인 후 보완. else 분기 추가해 매 5min fire 마다 "변경 없음 (idle)" info 로그 한 줄.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:32:38 +09:00
Hyungi Ahn a6b8dae18e fix(gpu-health): container_ip() 가 document_server network IP 만 추출
ollama 는 home-gateway-network / document_server / ollama_default 3개 network 에 속해
range loop 가 모든 IP concat. (index .NetworkSettings.Networks "hyungi_document_server_default").IPAddress
로 명시. 다른 GPU 서비스 4개도 동일 single-network 이라 호환.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:02:54 +09:00
Hyungi Ahn 8f4413a38c fix(gpu-health): scripts 호출 도구를 host curl + container IP 로 통일
OCR/STT 컨테이너 안에 curl 미설치 (slim python image). docker exec curl 표준은
실측 OCI exec 실패. host curl + docker bridge IP (172.20.0.x) 로 변경 — host
publish 추가 아니라 docker network 내부 검증이라 보안 표면 동일.

reranker 만 curl 있고 OCR/marker/STT 는 python 만 있어 분기 발생을 회피.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 09:51:59 +09:00
Hyungi Ahn 98ee7dffe2 ops(gpu-health): GPU 서비스 health/smoke 표준화 + synthetic VRAM 피크 가드
PR-GPU-Health-1. 운영 준비성 표준화 PR (모델 성능 개선 아님).

- OCR /smoke endpoint 추가 (160x60 OK PNG in-memory, 200/503 분기, Docker healthcheck 미사용)
- marker /health endpoint 추가 (stt/ocr 동일 시그니처)
- reranker docker-compose healthcheck 추가 (TEI :80/health)
- scripts/gpu_service_smoke.sh: docker exec 표준 점검 (OCR/STT expose-only)
- scripts/gpu_vram_fixture.sh: Mode A sequential + Mode B light overlap + --stress 옵션
- tests/load/fixtures/: synthetic ocr_ok.png / sine_30s.wav / lorem_1p.pdf

OCR 빈 응답 false negative — root cause: ports 미매핑.
결정: ocr-service / stt-service 는 expose-only 유지, 운영 점검은 docker exec 내부 curl 표준.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 09:42:07 +09:00
hyungi f1399459c5 Merge pull request 'refactor(ai): GPU Ollama LLM 제거 — Mac mini 26B 단일 generation 호스트로 통일' (#20) from feat/gpu-llm-remove into main
Reviewed-on: #20
2026-05-14 08:34:00 +09:00
Hyungi Ahn 4eed0bc4f8 refactor(ai): GPU Ollama LLM 제거 — Mac mini 26B 단일 generation 호스트로 통일
GPU 서버 정체성 = embedding/rerank/STT/OCR/marker 특화 백엔드.
Generative LLM 0. Mac mini gemma-4-26B-A4B 가 triage + primary +
classifier 모두 흡수. fallback 은 Claude Sonnet 4 API (자동 trigger,
premium 과 budget 공유).

- triage: GPU Ollama gemma4:e4b → Mac mini :8801 26B (primary 동일 endpoint)
- fallback: GPU Ollama gemma4:e4b → Claude Sonnet 4 API (require_explicit_trigger=false)
- classifier: GPU Ollama gemma4:e4b → Mac mini :8801 26B (max_tokens 512)
- primary / premium / embedding / rerank: 변경 0

후속 (별 커밋): `ssh gpu "ollama rm gemma4:e4b-it-q8_0"` — VRAM ~11GB 회수.

Mac mini 단일화 위험 mitigation = (1) Mac mini uptime 31d 무중단 검증,
(2) Claude Sonnet 4 API daily_budget $5 안 (Mac mini up 가정 호출 빈도 낮음),
(3) Beszel siteMonitor :8801 health check + Synology Chat alert.

plan: ~/.claude/plans/rosy-launching-otter.md §C/§D/§E (7-device LLM 배치 + 운영 전략)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:16:40 +09:00
hyungi 92aa2aaf53 Merge pull request 'feat(auth): voice-memo bot 365d access token (PoC v1)' (#19) from feat/voice-memo-bot-token into main
Reviewed-on: #19
2026-05-13 14:19:41 +09: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 Ahn 08e7fed984 ops(search): reranker drift fix 사후 재측정 (postfix observation) 2026-05-13 12:06:20 +09:00
Hyungi Ahn d3303cec1c fix(search): point reranker endpoint to TEI service 2026-05-13 12:02:26 +09:00
hyungi 1293c7094a Merge pull request 'feat/news-tech-ai-sources' (#17) from feat/news-tech-ai-sources into main
Reviewed-on: #17
2026-05-13 07:54:59 +09:00
hyungi 38b3630492 Merge pull request 'feat(briefing): date picker + 카드별 읽음/하이라이트 액션' (#16) from feat/briefing-date-picker-and-actions into main
Reviewed-on: #16
2026-05-13 07:54:51 +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 5a86e045f1 feat(news): seed 14 tech/AI news sources (8 countries)
briefing/digest 의 cross-country tech 토픽 다양성 확보용 source seed.
- KR ×2: GeekNews (Hada), AI Times
- US ×4: Hacker News, ArsTechnica AI, The Verge Tech, TechCrunch
- GB ×2: The Register, BBC Technology
- DE ×1: Heise Online
- JP ×2: ITmedia News, Gigazine
- CN ×1: 36Kr
- FR ×1: ZDNet France
- IN ×1: Analytics India Magazine

idempotent: WHERE NOT EXISTS (name). 운영 DB 에는 이미 적용됨,
백업 복원/신규 deploy 환경에서 자동 시드.

수집 검증 (2026-05-13 1차 fire, 8 source):
- 성공: Hacker News 30 / ArsTechnica AI 20 / Verge 10 / TC 20 / Register 50 / Heise 153 (총 283건 신규)
- 후속 fix: GeekNews 의 http redirect → feedburner 직접 URL, AI Times URL 오타 → S1N1.xml.

content category 는 news_sources.category (Tech / AI) 로 보존, briefing 의 country
필터 (MIN_COUNTRIES_PER_TOPIC ≥ 2) 와 호환.
2026-05-12 21:47:15 +00:00
hyungi 1d3d61d31e fix(briefing): lower clustering threshold 0.78 → 0.70
배포 후 관측 결과 (2026-05-13 새벽):
- 126 docs / 7 countries 인데 THRESHOLD=0.78 로 raw_clusters=124, dropped_min_articles=122, kept=1.
- 거의 매 article 이 별 cluster 로 갈려 토픽 묶음 실패.
- 같은 cron 어제 (5/12) 는 101 docs 에서 6 topics 성공 — 그날 뉴스가 우연히 같은 토픽으로 더 모인 case.

수동 측정 (5/13 동일 docs):
- 0.78 → kept=1
- 0.70 → kept=5 (allowed)

영구 변경 = THRESHOLD=0.70. cross-country 필터 (MIN_COUNTRIES≥2) + min_articles(≥2) 그대로
유지하므로 noise topic 위험은 제한적.

원본 주석 (0.75~0.80 중간값) 도 갱신.
2026-05-12 21:44:00 +00:00
hyungi 12ebc7c78c Merge pull request 'fix/scheduler-kst-timezone' (#15) from fix/scheduler-kst-timezone into main
Reviewed-on: #15
2026-05-13 06:34:12 +09:00
hyungi 2dbbeac1c7 fix(daily_digest): cast today to date object for KST comparison
매일 20:00 KST cron fire 시 fail:
  UndefinedFunctionError: operator does not exist: date = character varying

원인: today 가 strftime("%Y-%m-%d") 로 string, func.date(created_at) 가 date 타입.
PostgreSQL 가 date = string 비교 거부.

Fix: today = datetime.now(ZoneInfo("Asia/Seoul")).date() — date 객체로.
KST 기준은 scheduler cron 이 KST 20:00 에 fire 되므로 자연 일치.

scope: app/workers/daily_digest.py:24
2026-05-12 21:30:41 +00:00
hyungi 138f689c98 fix(scheduler): pass KST timezone to all CronTriggers
AsyncIOScheduler(timezone="Asia/Seoul") 의 scheduler-level timezone 이
CronTrigger 에 자동 전파되지 않아 6 cron 모두 UTC 로 fire 되던 버그.

영향 (모두 9h 오차):
- morning_briefing  의도 05:10 KST → 실제 14:10 KST
- daily_digest      의도 20:00 KST → 실제 05:00 KST (다음날)
- global_digest     의도 04:00 KST → 실제 13:00 KST
- law_monitor       의도 07:00 KST → 실제 16:00 KST
- mailplus_morning  의도 07:00 KST → 실제 16:00 KST
- mailplus_evening  의도 18:00 KST → 실제 03:00 KST (다음날)

Fix: 모든 CronTrigger 에 timezone=KST (= ZoneInfo("Asia/Seoul")) 명시.

검증 (재시작 후):
  law_monitor          next: 2026-05-13 07:00 KST
  mailplus_morning     next: 2026-05-13 07:00 KST
  mailplus_evening     next: 2026-05-13 18:00 KST
  daily_digest         next: 2026-05-13 20:00 KST
  global_digest        next: 2026-05-14 04:00 KST
  morning_briefing     next: 2026-05-14 05:10 KST
2026-05-12 21:30:34 +00:00
Hyungi Ahn 8f7871b443 ops(search): PR-RAG-Time-1 1주 후 재측정 PASS
baseline (2026-05-03) + week1 (2026-05-12) 두 측정 결과 JSON/MD 합본.

회귀 판정 4신호 모두 통과:
- top3 doc_id 변동: 0/6 쿼리
- freshness_ms max: 0.54ms (임계 10ms)
- total_ms max: 413ms (임계 500ms, warmup 후)
- policy 분포: 9/30 동일

별 이슈: reranker 404 drift 발견 (config.yaml endpoint = ollama 호출, 실제는 TEI 컨테이너). PR-RAG-Time-1 본질 회귀와 분리. 별 incident 트랙.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:41:40 +09:00
hyungi 626e859a81 Merge pull request 'docs(claude): refresh — drop stale model/IP, inventory authoritative' (#14) from docs/claude-md-refresh into main
Reviewed-on: #14
2026-05-12 15:07:39 +09:00
Hyungi Ahn f6f8f3b9d8 docs(claude): refresh — drop stale model/IP, inventory authoritative
stale 영역 정리:
- Qwen3.5-35B-A3B / nomic-embed-text / Qwen2.5-VL-7B → 역할별 표기 (실제 모델은 inventory)
- Mac mini Tailscale 100.76.254.116 / GPU 100.111.160.84 / NAS 100.101.79.37 → 모두 폐기 (D21 closure 2026-05-12), LAN 표기만 유지
- Mac mini nginx 앞단 프록시 → 폐기 (home-caddy 가 직접 ingress)
- "Mac mini 메인 docker compose" → GPU 가 메인 정정

추가:
- 운영 변경 정책 (inventory → config → deploy → verify)
- 머신 역할 표 / AI 파이프라인 역할 표 / 워커 스케줄 표
- 아침 브리핑 / global digest 진입점 + scheduler timezone
- asyncpg multi-statement 1 파일 1 statement 규칙 (PR-MorningBriefing-1 fix 교훈)
- 디자인 토큰 only 규칙
- 한국어 NFS 경로 NFC/NFD
2026-05-12 15:07:12 +09:00
hyungi 1f4bbb9413 Merge pull request 'docs(readme): refresh stack/features/infra to 2026-05 reality' (#13) from docs/readme-refresh into main
Reviewed-on: #13
2026-05-12 15:05:20 +09:00
Hyungi Ahn 6d8d207669 docs(readme): refresh stack/features/infra to 2026-05 reality
- AI: Qwen3.5-35B → gemma-4 26B MLX / 4B triage / bge-m3 / TEI reranker / Surya OCR / MLX Whisper
- infra: Mac mini Docker Compose → GPU 서버 메인 / Mac mini = MLX inference + STT
- features: 아침 브리핑, Phase 4 Digest, library, memos, events, study, audio/video, marker
- inventory authoritative 안내 (README 가 stale 진실 대신 inventory 우선)
- gpu-server/ deprecated 표기
2026-05-12 15:03:52 +09:00
hyungi 49f44bba60 Merge pull request 'feat(briefing): register 05:10 KST APScheduler cron' (#12) from feat/morning-briefing-scheduler into main
Reviewed-on: #12
2026-05-12 14:54:52 +09:00
Hyungi Ahn 55e39818ec feat(briefing): register 05:10 KST APScheduler cron
매일 KST 05:10 morning_briefing_run 자동 실행. scheduler timezone=Asia/Seoul
이라 hour=5 minute=10 만 명시. Phase 4 04:00 cron 종료 후 70분 buffer + MLX
semaphore 충돌 회피.
2026-05-12 14:54:20 +09:00
hyungi ff351e5a0f Merge pull request 'feat/morning-briefing-frontend' (#11) from feat/morning-briefing-frontend into main
Reviewed-on: #11
2026-05-12 14:53:18 +09:00
Hyungi Ahn 1696926b8c refactor(briefing): nav label to 아침 브리핑 2026-05-12 14:35:16 +09:00
Hyungi Ahn 4d9beb37ef feat(briefing): swap /news to morning briefing card UI
- /news/+page.svelte 전면 재작성: article list 폐기, /api/briefing/latest fetch → topic 카드 list
- 각 카드: topic_label + headline + country_perspectives (flag + 한국어 + summary + article #id 링크) + divergences/convergences/key_quotes + historical_context
- status 4-state UI 분기 (empty/partial/failed/success)
- 디자인 시스템 토큰 only, Card 공용 컴포넌트 재사용, Svelte 5 runes + TS
- layout 라벨 뉴스 → 브리핑 (라우트 /news 유지)
- 백업: git history
2026-05-12 14:30:42 +09:00
hyungi 8b4f4e53f4 Merge pull request 'feat/morning-briefing-backend' (#10) from feat/morning-briefing-backend into main
Reviewed-on: #10
2026-05-12 14:26:13 +09:00
Hyungi Ahn 6966be9cf6 fix(briefing): backfill country_perspectives[].article_ids from cluster members
LLM 이 article_ids 를 자율적으로 비워두는 케이스 (2026-05-12 첫 briefing 6
topics 모두 빈 list) 를 서버에서 보정.

후처리 정책 (_resolve_article_ids):
1. LLM 이 준 id ∩ cluster member id (엉뚱한 id 차단, hallucination 방어)
2. 비어있으면 같은 country cluster member top weight N 개 자동 주입
3. cluster 안 country 매칭 멤버 0 → []

per-country cap = MAX_ARTICLE_IDS_PER_COUNTRY = 5. weight 내림차순.

API 계약 강화: country_perspectives 가 있는 topic 은 article_ids ≥ 1 보장
(같은 country cluster member 존재 시). frontend / 외부 채널 / archive UI
모두 신뢰 가능.

tests 3 케이스 추가.
2026-05-12 13:15:26 +09:00
Hyungi Ahn 36fea2789a fix(briefing): split migration into 4 single-statement files
asyncpg 의 prepared statement 가 multi-statement 불허. Phase 4 101 SQL 은
2026-04-08 적용 당시엔 통과했지만 현재 asyncpg/sqlalchemy 버전에서 fail.

255_morning_briefings_table.sql  CREATE TABLE morning_briefings
256_morning_briefings_idx.sql    CREATE INDEX (briefing_date)
257_briefing_topics_table.sql    CREATE TABLE briefing_topics + UNIQUE
258_briefing_topics_idx.sql      CREATE INDEX (briefing_id, topic_rank)
2026-05-12 13:04:56 +09:00
Hyungi Ahn 4aed9c6173 fix(briefing): simplify migration SQL (remove unicode, ::jsonb cast)
asyncpg 'cannot insert multiple commands into a prepared statement' 회피.
가설: 한국어 코멘트의 special char (lambda/arrow) + '::jsonb' cast 가 asyncpg
prepare 에서 multi-statement 오인. Phase 4 101 SQL 패턴과 정확히 맞춤 — JSONB
column 이라 default literal 은 자동 cast.
2026-05-12 13:02:16 +09: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 1ca6d8b522 refactor(digest): extract clustering helpers to clustering_common
Phase 4 Global Digest 의 클러스터링 핵심 알고리즘 (time-decay weight,
adaptive threshold, greedy cosine assign + EMA centroid, importance
normalize) 을 `app/services/clustering_common.py` 로 추출. country
축은 caller 책임 — Phase 4 cluster_country 는 그대로 country 별 호출,
신규 morning briefing 모듈이 country 없이 cluster_global 로 호출 예정.

selection.py 의 중복 _normalize 도 공통 util 로 통일.

동작 변경 0:
- LAMBDA / threshold / EMA alpha / MIN_ARTICLES 모두 Phase 4 기본값 유지
- docs.sort (in-place) → sorted (copy) 변경했으나 caller 가 정렬된
  docs 를 재사용하지 않으므로 무관 (dict element 의 weight 부여는
  reference 라 그대로 반영)

다음 commit 에서 Phase 4 회귀 검증 (digest regenerate diff 0).
2026-05-12 12:38:32 +09:00
hyungi de36a9abca Merge pull request 'fix(memos): voice memo file_type → 'immutable' (doc_type enum 호환)' (#9) from fix/memos-voice-doc-type into main
Reviewed-on: #9
2026-05-11 12:29:44 +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 f3693fa2ea Merge pull request 'feat/memo-intake-upgrade' (#8) from feat/memo-intake-upgrade into main
Reviewed-on: #8
2026-05-11 12:10:50 +09:00
Hyungi Ahn 1424e79495 docs(memos): iOS Shortcuts guide for voice memo upload 2026-05-11 12:09:12 +09:00
Hyungi Ahn e3adbb8961 feat(frontend): show memo triage and voice source UI
PR-2B/2C frontend (commit 4/4). plan v9 Memo Intake Upgrade.

PR-2B 분류 표시 + 1-click promote:
- 메모 카드 상단에 AI 분류 배지 (task/calendar/activity/reference + confidence%)
- ai_event_kind != 'note' 메모 하단에 4 버튼:
  · [할 일로] [일정으로] [활동으로] (AI 추천 kind 는 색깔 highlight)
  · [그냥 메모] (dismiss → ai_event_kind='note' 강제)
- promote 후 메모 카드에 "→ events #N" link 배지 (사용자 시각 확인)

PR-2C 음성 메모 표시:
- source_channel='voice' 메모는 🎙️ "음성" 배지
- audio player (<audio src=/api/documents/{id}/file?token=>) — 기존 file endpoint 재활용
- STT 대기 중인 voice 메모는 "음성 → 텍스트 변환 대기 중…" placeholder

API helpers:
- promoteMemo(memoId, kind) → POST /memos/{id}/promote-to-event
- dismissEventSuggestion(memoId) → POST /memos/{id}/dismiss-event-suggestion
- voiceAudioUrl(memoId) → /api/documents/{id}/file?token= (access token URL pattern)

Sidebar 영향 0 (events 진입점은 이미 PR-2 에서 추가됨).

원칙 (재명시): AI worker 는 events row 직접 생성 X — 본 UI 의 promote 버튼만이 events 진입.
2026-05-11 12:08:34 +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 63990ac632 feat(memos): add AI event-kind triage fields
PR-2B (Memo Inbox Triage) backend 1/2. plan: beszel-tingly-sloth.md 라운드 13.
사용자 비전 = 메모는 inbox, AI 는 triage assistant. AI worker 는 events row 직접 생성 X.

Migrations 250–253 (실측 N=250):
- 250 CREATE TYPE event_kind_hint AS ENUM (note|task|calendar_event|activity_log|reference)
- 251 ALTER TABLE documents ADD ai_event_kind event_kind_hint
- 252 ALTER TABLE documents ADD ai_event_confidence NUMERIC(3,2) + CHECK 0–1
- 253 CREATE INDEX idx_documents_ai_event_kind partial WHERE ai_event_kind IS NOT NULL

ORM:
- Document.ai_event_kind / ai_event_confidence 컬럼 추가 (Enum SQLAlchemy 동기)
- source_channel enum 에 'voice' 추가 (PR-2C 와 호환)

Worker:
- classify_worker Phase 3 (Gemma 4B triage) 확장
  · TriageOutput 에 event_kind_hint + event_kind_confidence 필드 추가
  · 4B 응답에 hint 가 있을 때만 Document 에 저장 (enum 외 값은 무시)
- prompt p3a_short_summary.txt 확장 — note/task/calendar_event/activity_log/reference
  분류 기준 + confidence + default='note' 명시

원칙: AI worker 는 hint 만 제공. events 생성은 다음 commit 의 promote endpoint 에서만.
2026-05-11 12:04:21 +09:00
hyungi a842dc682e Merge pull request 'wip/gpu-main-snapshot-2026-05-11' (#7) from wip/gpu-main-snapshot-2026-05-11 into main
Reviewed-on: #7
2026-05-11 08:11:44 +09:00
hyungi 2f7b45d82c Merge pull request 'feat/events-ui-mvp' (#6) from feat/events-ui-mvp into main
Reviewed-on: #6
2026-05-11 08:11:32 +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 477be3892a docs(events): PR-1 → PR-2 quickref — API contract + 5초 행동 기록 UX 가이드
PR-2 (frontend UI MVP) 진입 전 reference doc. plan: beszel-tingly-sloth.md v6.

내용:
- JWT 인증 flow (curl 예시)
- 9 endpoint 표 (Create/List/Detail + 4 Lifecycle + 3 View)
- kind / status enum 의미 + UI 분기 hint
- 빠른 행동 기록 5초 UX (PR-2 핵심 가설)
- PR-2 smoke 로 자연 검증할 5건 (PR-1 closure 의 deferred 항목)
- events_history 조회 endpoint 미존재 (필요 시 PR-2 에서 추가)

authoritative API contract = /openapi.json. 본 doc 은 frontend cheat sheet.
2026-05-11 07:50:33 +09:00
hyungi bce18386f0 Merge pull request 'docs(storage): Storage PR-1 — read-only inventory + 정책 문서' (#4) from chore/storage-inventory into main
Reviewed-on: #4
2026-05-11 07:26:46 +09:00
hyungi dc96d2b298 Merge pull request 'feat(events): PR-1 Events Core — schema + ORM + 최소 API' (#5) from feat/events-core into main
Reviewed-on: #5
2026-05-11 07:26:31 +09:00
Hyungi Ahn 768fc36746 docs(storage): Storage PR-1 — read-only inventory + 정책 문서
Storage Backbone NAS 트랙의 첫 PR. plan v6 명시대로 read-only inventory PR
— 운영 변경 / mount 변경 / file_path 갱신 / asset 이동 모두 0건. 문서만.

산출물:
- docs/storage_layout.md  영구 정책 문서 (정책 / 마운트 매트릭스 / NFS 옵션 baseline)
- reports/storage_inventory_2026-05-11.md  측정 결과 snapshot

핵심 인사이트:
1. NAS binary layer 는 이미 잘 분리되어 있음 — PKM/extracted_images/
   study_question_images 모두 이미 NAS. 추가 이관 PR-3/4 작업량 거의 없음.
2. 현 GPU NFS mount = plan v6 권고안 baseline 과 정확히 같음
   (soft, vers=4.1, timeo=10, retrans=3) — PR-2 는 mount 옵션 변경 아닌
   애플리케이션 layer (정규화 wrapper / 장애 처리 / uid 매핑) 에 집중.
3. fastapi 만 NAS rw, worker 는 ro — 원본 안전 분리 OK.
4. Postgres pgdata = 1.1GB (DB 본체 이관 안 함, plan 결정 = GPU 잔류).
5. PR-4 도입 시 extracted_emails/ 신규 디렉토리 추가 예정 (Storage PR-5 합류).

실측 명령: SSH 100.111.160.84 → df/mount/du/docker volume ls/docker run
-v ... alpine du. 모두 read-only. 운영 영향 0.
2026-05-11 07:23:28 +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 aca2f0d62c feat(canonical): restore GPU STT owner and extend KGS watch paths
D9 Track B revised (2026-05-08):

1) STT owner GPU 정식 복귀:
   - docker-compose.yml: stt-service profiles:[legacy] 제거 → 상시 활성
   - fastapi STT_ENDPOINT = http://stt-service:3300 (compose 내부 DNS)
   - 정책: Mac mini = Gemma 26B 전용 우선이므로 STT/Whisper 는 호출량 무관
     GPU 서버 소유. 이전 "Mac mini 이전본" 주석은 trace 오인 기반.

2) KGS Code 등 외부 학습 자료 추가 스캔 경로:
   - ADDITIONAL_WATCH_TARGETS env (쉼표 구분, PKM 상대경로)
   - app/core/config.py: additional_watch_targets list 설정 추가
   - app/workers/file_watcher.py: 추가 watch path 처리
   - app/workers/classify_worker.py: KGS Code 분류 분기 (가스기사 학습 자료)
   - 모두 expected_category=library 처리 (md/pdf/docx 만)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 05:47:20 +00:00
hyungi c1b22d8833 docs(eval): Phase 2 path fix — log_tsv/post-report 는 /app/logs (bind-mount), /app/evals 는 미마운트
cron dry-run 검증 중 발견:
- /app/scripts/ 는 bind-mount 활성 (Phase 2 main FF 후 컨테이너 가시화 ✓)
- /app/evals/ 는 fastapi 이미지에도 없고 compose 마운트도 없음
- 이전 README/plan 의 --log-tsv /app/evals/markdown/... 은 컨테이너
  writable layer 에 쓰여 재기동 시 유실되는 문제

해결: nightly --log-tsv 와 post-report --output-* 는 /app/logs/ 사용
(rw bind-mount → host ~/Documents/code/hyungi_Document_Server/logs/ 영구).
주 1회 git commit 시 logs/ → evals/markdown/ 로 cp 후 add.

post-report 도 동일 패턴.
2026-05-10 05:47:20 +00:00
hyungi df2b09b0fa docs(eval): Phase 2 canary retry GO — success 37/40 (92.5%) failed 2 skipped 1
옵션 C 실행 (2026-05-03 02:36-02:39 UTC):
- 5201 documents stuck processing → failed (conditional UPDATE 1 row)
- 3817 재 enqueue → success 35.8s
- 4059 재 enqueue → success 100.7s
- GPU contention 해소 확인 (free 8820 MiB)

최종 tally: success 37 / failed 2 (3810 corrupt PDF + 5201 scan-likely
timeout) / skipped 1 (5090 MAX_PAGES). Plan 3 게이트 모두 PASS.

다음 = 사용자 승인 게이트 (2-C 진입 + nightly 모드 선택). main 머지 +
parent pull + cron 추가 4단계 대기.

후속 백로그 (Phase 1B+, 별도 PR):
- B1 scan-likely auto-skip (5201 패턴)
- B2 OOM 503 transient (야간 contention 자동 복구)
- B3 queue exhausted → doc.md_status 동기화 (corner case 정리)
2026-05-10 05:47:20 +00:00
hyungi 5bf9ff9dc2 docs(eval): Phase 2 canary 결과 — HALT (failed 4/40 = 10%, but 분류상 Marker 0 fail)
35 success / 3 failed / 1 skipped / 1 stuck processing (corner case).
Plan 게이트 FAIL (success<36 + failed>2). 다만 failure root cause 분석:
- 2/4 = GPU contention (5.93+5.35 GiB 다른 process 점유, free 50 MiB)
- 1/4 = 진짜 corrupt PDF (Pdfium error, non-retryable)
- 1/4 = scan-likely + tiny text + ReadTimeout (Phase 1B corner case)

Marker quality 자체 fail = 0. p50 elapsed 33.2s (1D 34s 와 동등),
text_length_ratio p50 1.00 (1D 1.15 대비 -13%, 정상 범위), 신규 warning 없음.

사용자 결정: A(수용) / B(코드 가드 추가) / C(OOM 2건 즉시 재 enqueue → GO 통과)
/ D(HALT 유지). 추천 C 또는 A.

5201 stuck processing 은 어느 옵션이든 수동 DB 정리 필요 (사용자 승인 후).
2026-05-10 05:47:20 +00:00
hyungi f61dce262e docs(eval): Phase 2 경로 정책 정정 — 2-B /app/logs vs 2-C /app/scripts canonical
Plan/README 가 /app/scripts 를 통일 경로로 가정했으나 실측 결과 read-only
bind-mount 라 docker cp 불가. soft lock 으로 --build 도 금지. 단계별로
다른 경로 사용해야 함:

- 2-B canary (pre-merge): /app/logs/phase2_backfill.py + /app/logs/*.csv
  (docker cp worktree → /app/logs rw bind-mount). canary 검증 동안
  미검증 코드 main 진입 회피.
- 2-C nightly (post-merge canonical): /app/scripts/phase2_backfill.py +
  /app/evals/markdown/phase2_* (feat/phase2-backfill main 머지 +
  parent git pull 후 bind-mount 자동 활성). cron 도 canonical path.

evals/markdown/README.md 의 enqueue 예제 + 신규 #### 경로 정책 섹션 반영.
2026-05-10 05:47:20 +00:00
hyungi 48f8bf6ca6 docs(eval): Phase 2 canary sample — 40 docs (seed 20260503)
Bucket distribution (algorithm vs allocated):
- large (>10MB): 6 / 6
- scan_likely (text_density<5): 2 / 2
- study_note born-digital: 10 / 10
- Academic_Paper born-digital: 2 / 8 (under-fill — only 20 born-digital docs total in pool)
- Reference born-digital: 0 / 6 (under-fill — 동상)
- tech_doc (Standard/Manual/Specification): 4 / 4
- minor_doc (Note/Report/Memo/NULL): 4 / 4
- filler (rest from candidates): 12 (picked up under-fill slack)

Note: 1D 의 born-digital bias 가정이 Phase 2 실 모집단과 안 맞음
(text_density 분포가 mixed-dominant: 174/237). 그래도 40 docs 가 large /
scan-likely / 다양 doctype 커버 — canary 진단 목적 충족.

Next: 사용자 승인 게이트 — --no-dry-run enqueue 시점 결정.
2026-05-10 05:47:20 +00:00
hyungi ac58c8262c docs(eval): Phase 2 inventory dry-run — 237 pending PDFs, 227 convert candidates
- forecast_skip_reason distribution:
  - none: 227 (convert candidates)
  - over_max_pages_estimated: 10 (file_size > 25MB proxy)
  - handwritten_hint: 0 (1D-A1 skip already in marker_worker)
  - doctype_skip: 0
- file_size_band: S=47 / M=160 / L=30
- text_density_band: mixed=174 / scan-likely=43 / born-digital=20
- doc_type top: study_note 79 / Academic_Paper 57 / Reference 35 / Standard 24 / Manual 19
- 시드 baseline for select-canary (next step)
2026-05-10 05:47:20 +00:00
hyungi 25ee10ac34 feat(scripts): Phase 2 markdown backfill — script + README
- scripts/phase2_backfill.py: 5 subcommands
  - inventory: pending PDFs dry-run CSV with skip forecast
  - select-canary: stratified 40 sample (seed 20260503)
  - enqueue: one-shot from sample CSV (--no-dry-run gate)
  - nightly-enqueue: cron-friendly with disable flag / marker /ready /
    active-queue threshold (oldest_age stuck guard) / DB pool guards
  - post-report: final state CSV + 1D baseline comparison MD
- evals/markdown/README.md: Phase 2 section appended
- plan: ~/.claude/plans/iridescent-gathering-clover.md
- depends on Phase 1B handwritten skip 7d0fca2 (marker_worker side guard)
2026-05-10 05:47:20 +00: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 f2a5c729b7 fix(scripts): marker reprocess SQL — CAST(:payload AS jsonb) 로 named-param 충돌 해소
`:payload::jsonb` 의 `::` postfix 캐스트가 SQLAlchemy text() 의 named-param prefix
`:` 와 충돌해 asyncpg syntax error. doc 3757 sample reprocess 시 발견.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:27:17 +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 5b62c59f8a fix(canonical): marker_worker transport 계층 오류는 transient retry 분류
기존: (ConnectError, TimeoutException) 만 transient → raise → queue retry.
ReadError / WriteError / RemoteProtocolError 같은 다른 transport 류는
'except Exception' 이 잡아 _fail 처리 → max_attempts 무시하고 final fail.

Phase 1D pilot 에서 5111/5115 두 건이 'Server disconnected without
sending a response' (RemoteProtocolError) 로 retry 없이 final fail.

Fix: except (ConnectError, TimeoutException) → except TransportError.
TransportError 가 Connect/Read/Write/RemoteProtocol/Timeout 의 공통 부모
라서 모든 transport 계층 오류가 transient queue retry 대상이 됨.

5135 의 ReadTimeout (queue exhausted) 는 본 fix 와 별개 — 8.4MB PDF 가
MARKER_TIMEOUT=300s 안에 못 끝나 3번 retry 다 timeout. timeout 자체를
늘리거나 큰 PDF 분할 처리하는 별도 결정 필요.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:29:47 +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 e4fe18b7a8 docs(eval): 1D pilot 약식 평가 결과 기록
사용자 quality 평가:
  "애플펜슬로 필기한건 내 글씨체 이슈에 더해서 좋은 자료를 뽑아내지
   못하네 그 외에는 잘되는거 같은데"

분류:
  overall_pass=true   24건 — 일반 PDF (born-digital + scan-like 中
                              5127 같이 정상 변환되는 케이스)
  overall_pass=false   4건 — 애플펜슬 필기 4건 (4798/4813/4815
                              controlled_backfill + 4809 anchor)
  overall_pass=empty   2건 — page_count > MAX_PAGES=200 의도 skip
                              (5178 ASME 272p, 5180 ASME Sec I 453p)

정식 rubric 5축 (text_accuracy/structure/noise_rate/multi_script/
completeness) 점수는 비워둠 — 사용자 약식 판정으로도 의사결정 매트릭스
분기 (필기만 fail → SKIP rule 확장) 가 명확해 정식 채점 over-investment.

후속 라운드 (Marker 튜닝/대안 OCR 도입 시) 같은 30건 재평가에는 정식
rubric 채울 가치 있음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:15:33 +09:00
Hyungi Ahn 7d0fca267d feat(marker): handwritten 자동 skip — Phase 1D pilot 결과 반영
1D pilot (2026-05-02 야간 sweep, 25 controlled_backfill 결과) 에서
필기 PDF 3건 (4798 / 4813 / 4815) 이 status='success' 로 변환됐으나
사용자 quality 평가에서 좋은 자료 추출 불가 판정. 근본 원인은 Marker
설정 부족이 아니라 입력 자체 (애플펜슬 손글씨 + 사용자 글씨체 = OCR/
layout 모델 한계 영역). Marker 튜닝으로 해결될 영역이 아니므로 enqueue
단계에서 자동 skip.

가드 로직:
  marker_worker.process() 의 doc_type SKIP 직후 (1.5 단계) title/path 의
  보수적 키워드 4개 (필기, 손글씨, handwritten, handwriting) 매칭 시
  _set_skipped() 호출. md_content/md_content_hash NULL clear,
  md_extraction_error='skipped: handwritten note (title/path heuristic)',
  content_origin='extracted'.

키워드 선정 (보수적):
  포함: 필기 / 손글씨 / handwritten / handwriting
  제외 (false positive 위험):
    - 노트 (노트북 매뉴얼 / release notes / Note_240528_워크숍 같이
      필기 아닌 정상 문서까지 잡음)
    - scan / 스캔 (스캔 PDF 中 정상 변환되는 케이스 있음, 1D 결과
      doc 5127 표준기계설계(KS)_08_핀 density 1.59 / scan_likely 인데
      성공)

logger:
  markdown_skip_handwritten_hint id=<id> keyword=<matched> title=<...>

regex 단위 테스트 15 케이스 (실 production fastapi venv) 전부 통과:
  매칭: Note_240805_용접교육 필기 / Note_240827_필기 / 손글씨 모음 /
        Handwritten Notes 2024 / handwriting practice / path/필기/* /
        path/handwritten_collection/* (8건)
  비매칭: 다이아프람워크숍 / 노트북 매뉴얼 / Release notes v2 / PIPE
          FABRICATORS / 표준기계설계 / scan documentation / 스캔 문서 (7건)

이번 가드는 enqueue 시점 적용. 이미 success 인 4건의 md_content 는
보존 (사용자가 직접 보고 싶을 때 표시 가능). 정리 필요 시 별건.

후속 (별 PR):
  - A2 (정식 doc_type='필기노트' 라벨): 1D 3건 sample 너무 적어 라벨
    정의 보류. 필기 PDF 누적 후 별도 검토.
  - C (Phase 2 풀 backfill plan): 본 PR 머지 후 별도 라운드.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:11:42 +09:00
Hyungi Ahn 0362f52130 fix(scripts): Phase 1D enqueue 가 existing_success 재처리하지 않도록 필터
Round 2 sample 에 existing_success 5건 (anchor doc 4809 + calibration 4)
이 포함되었지만, cmd_enqueue 가 sample_source 무시하고 30건 전부 enqueue
하던 버그. 결과:
  - existing 5건 marker 재처리 (~25분 marker 시간 낭비)
  - 동일 quality output 으로 md_content overwrite → baseline 유실
  - anchor (doc 4809) 의 "before" 상태가 사라져 후속 라운드 비교 anchor 손상

Fix:
  - default = sample_source == "controlled_backfill" 만 enqueue (25건)
  - --include-existing flag 추가 (후속 Marker 튜닝 라운드에서 anchor 재처리
    필요 시 사용)
  - print 로 mode 명시 + 제외된 ids 표시

야간 단발 sweep (23:00 KST) 예약 실행 전 fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:27:31 +09:00
Hyungi Ahn b09687d41d feat(scripts): Phase 1D Round 2 — controlled backfill stratification
기존 phase1d_pilot.py (단순 ai_domain × file_size 3-bucket) 를 plan
~/.claude/plans/stratified-mingling-otter.md 의 4축 + sample_source 분리
+ forced_include 로 augment.

Round 1 (ai_domain × file_size 3-bucket) 의 한계:
  pending PDFs 의 자연 분포만 반영 → 알려진 약점 (필기/스캔/한중일
  mixed OCR) 이 sample 에 안 들어옴. 1C 시각 확인에서 doc 4809
  (Note_240805_용접교육 필기) 가 실제로 그 패턴을 보였는데, 자연
  selection 에 맡기면 다음 라운드도 같은 case 가 빠질 위험.

Round 2 디자인:
  - 4 축 stratification: doc_type × file_size_band × text_density_band
    × handwritten_hint
  - sample_source ∈ {existing_success(5), controlled_backfill(25)}
  - forced_include doc 4809 — known bad anchor. 다음 튜닝/대안 도입 후
    같은 문서 재변환 결과와 1:1 비교 가능.
  - text_density = LENGTH(extracted_text) / (file_size / 1024) chars/KB
    가장 깨끗한 단일 proxy. 0.17(필기 4809) ↔ 94(born-digital 3759)
    양 끝 검증.
  - script_mix proxy: Hangul/CJK/Hiragana/Katakana/Latin Unicode block
    ratio → korean_dominant / mixed_korean_cjk / mixed_korean_latin /
    cjk_dominant / latin_dominant / unknown.
  - page_count_estimate: existing_success 는 md_extraction_quality.
    metrics.source_page_count 사용. controlled_backfill 은 NULL
    (marker 가 PyMuPDF 로 어차피 다시 읽음).
  - 시드 SAMPLE_SEED=20260502 고정, 재현성 보장.

Sample 분포 (실측 2026-05-02):
  bucket_label: born_digital=12, mixed=5, existing_calibration=4,
                handwritten=3, scan_likely=3, large=2, existing_anchor=1
  doc_type: Academic_Paper=7, study_note=6, Standard=5, Note=4,
            Reference=3, Manual=3, Drawing=1, Report=1
  file_size_band: M=14, S=12, L=4
  text_density_band: born-digital=15, scan-likely=9, mixed=6
  handwritten_hint: lo=26, hi=4 (모집단 1.1% 대비 13배 over-sample)
  forced anchor doc 4809 = density 0.17 (사용자 시각 확인의 그 문서)

새 subcommand:
  eval_template — pilot_1d_eval.csv 스켈레톤 (rubric 5축 1~5 +
  overall_pass + notes). 사용자가 MarkdownDoc + PDF 토글 비교하며
  점수 채움.

기존 cmd_enqueue (snapshot/backup/dedup) + cmd_report (quality 메트릭)
는 유지.

산출물:
  scripts/phase1d_pilot.py — 4축 + sample_source + forced_include +
    eval_template subcommand. CSV+JSON dual output.
  evals/markdown/README.md — rubric + decision matrix + workflow guide.
  evals/markdown/pilot_1d_sample.csv — 30 rows × 15 cols (시드 결과,
    재현성 보존).
  evals/markdown/pilot_1d_eval.csv — 빈 스켈레톤 (사용자 평가 후 채움).

실행 경계:
  Step 1~3 (selection / template / dry-run) = 본 PR 으로 완료.
  Step 4 (--yes enqueue, 실제 30건 markdown 큐 인입) = 사용자 timing
  승인 + 야간 단발 sweep 윈도우 (23:00~03:00 KST) 안에서 별도 실행.
  marker-service BATCH_SIZE=1, 30건 평균 5분/건 ≈ 2.5h.

Verify:
  GPU 서버 fastapi 컨테이너에서 select 실행 → 30건 sample CSV 생성됨.
  eval_template subcommand 동작 확인. enqueue dry-run 으로 30 doc_ids
  + snapshot 출력 후 사용자 취소 분기 확인.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:15:09 +09:00
Hyungi Ahn 91e7a64713 ops(guardrails): activate migration 142 ask_events.source NOT NULL
1주 운영 관찰 후 활성화 (배포 2026-04-17 이후 source IS NULL 행 0건 확인).
deferred → migrations/142_*.sql 이동.
2026-05-02 16:12:38 +09:00
Hyungi Ahn d6e0f5de04 feat(frontend): Phase 1C — markdown viewer 완성 (PDF 통합 + status badge + image placeholder)
Phase 1B marker_worker 결과(현재 success 29건, 전부 PDF)를 사용자 흐름에
연결하고 1D pilot 품질 평가 데이터를 확보하기 위한 viewer 마무리 작업.

빠진 부분 3가지를 닫는다:

1) PDF viewerType 기본 view = Markdown
   - md_status='success' AND md_content 비어있지 않음일 때 MarkdownDoc 기본 표시.
   - 사용자가 "PDF 원본" 토글 시 iframe.
   - pdfViewMode 초기화는 doc.id 변경 시에만 (lastDocId tracker) — reactive cycle
     이 사용자 토글을 덮어쓰지 않도록 보호.
   - markdown 사라지는 케이스(success → failed 재처리)는 자동으로 pdf 로 보호.

2) Image renderer → placeholder card (docMarkdown.ts)
   - md_content 의 69%(20/29)에 image syntax 포함. asset serving(1B.5) 미구현
     상태에서 raw <img> 를 emit 하면 깨진 아이콘 → 1D pilot 평가가 markdown
     품질이 아닌 viewer 미완성 문제로 오염됨.
   - href / alt / basename 모두 escape 후 figure.md-image-placeholder 로 렌더.
   - 원본 src 는 data-md-image-src 에 escape 보존 → 1B.5 ImgAuth selector 로
     실제 <img> 로 교체할 entry point 마련.
   - DOMPurify ADD_ATTR 에 data-md-image-src 추가.

3) MarkdownStatusBadge (신규) — 4-state badge
   - pending 숨김(legacy 9792건 시각 노이즈 회피).
   - processing/success/skipped/failed 표시.
   - success tooltip: md_extraction_quality 의 metrics raw 일부
     (markdown_heading_count / markdown_table_row_count / markdown_image_count /
     text_length_ratio / warnings) 만 노출. text_length_ratio / null /
     metrics nested / flat fallback 모두 방어.
   - skipped/failed tooltip: md_extraction_error 또는 정책 문구.
   - MarkdownDoc 내부 + PDF iframe fallback 양쪽에서 재사용 → failed 같이
     MarkdownDoc 가 안 렌더되는 경로에서도 사용자가 상태를 알 수 있음.

기존 markdown/hwp-markdown/article 분기에도 mdExtractionQuality prop 전달.

Out of scope (1B.5 또는 후속):
- ImgAuth blob URL 실제 wiring (data-md-image-src selector + Bearer raw)
- /data/assets/<doc_id>/ 저장 + 서빙
- Caddy /data/assets/* 라우팅
- localStorage 사용자 view preference 저장
- side-by-side viewer (1D pilot 결과 본 후)
- quality chip 별도 UI (1D 후)

Verify:
- npm run build 통과
- npm run lint:tokens 신규 파일 위반 0
- 관련 plan: ~/.claude/plans/iterative-nibbling-catmull.md
- pre-flight: md_extraction_quality 실제 shape 확인 ({score, metrics:{...}, warnings:[]})

Risks:
- feature/design-system worktree 가 [id]/+page.svelte 의 stale 버전 보유
  (main 보다 212 commits behind, MarkdownDoc 부재). 1C 머지 후 worktree
  머지 시 conflict 확정 — 그쪽 rebase 필요 (별건).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:38:45 +09:00
Hyungi Ahn dfc5913c5e fix(tests): explanation cap test setup — 한글 chunk 길이 부족 보정
case 3/4 의 setup 이 EXPLANATION_MAX_CHARS (1200) 보다 작은 text 를 만들어
assert 실패. 한글 chunk 반복 횟수 늘려 1200 자 이상 보장.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:35:34 +09:00
Hyungi Ahn 6b52d57bac feat(study): Phase 4-A explanation_md 길이 cap + prompt 강화
운영 데이터에서 ready 박힌 풀이가 793/838/866자 — 권장 200~400 대비 큰 편.
1차 운영 후 결과 화면 가독성 + 토큰 사용량 통제 위해 prompt 강화 + 저장 전 cap.

Prompt (study_explanation_envelope.txt):
- explanation_md 권장 300~600자, 최대 900자 명시
- 핵심 개념 + 정답 근거 + 헷갈리는 1~2개 오답만 — 모든 오답 풀이 X
- explanation_md 안 줄바꿈 최소화 (parse_json fix 와 결합 — invalid escape 줄임)
- LaTeX 수식 자제 — \\circ/\\text/\\, 매크로 가능하면 평문 ('0°C', 'C')
- 출력은 raw JSON 한 객체만 — 코드 펜스/thinking/메타 X 강조

Worker (study_explanation_worker.py):
- _cap_explanation_md(text, max_chars=1200) 헬퍼 신규
  · 1200자 이하 passthrough
  · 초과 시 마지막 200자 안에서 \\n\\n / \\n / '. ' / '다.' / '요.' 경계 탐색
  · 경계에서 자르기 + '…' (단어 중간 자르기 회피)
  · 경계 못 찾으면 단순 자르기 + '…'
- save 전 cap 적용. ai_explanation_status='ready' 유지 (cap 됐다고 failed X)
- payload 에 운영 분석 metadata: explanation_len_original / _saved / capped 플래그

검증:
- tests/test_explanation_cap.py (6 케이스)
  · short passthrough / exact at limit / paragraph boundary / sentence boundary
  · no boundary fallback / empty input
- scripts/phase4_health.sql 섹션 8/9 추가
  · ai_explanation 길이 p50/p95/max (study_questions.ready)
  · cap 작동 빈도 (job.payload 의 explanation_capped/_original/_saved)

cap 1200 = 800 (4-B summary_md) 보다 여유 — 기사시험 풀이는 공식+오답+개념 묶이면
800 빡빡함. 운영 후 800~1000 으로 조정 검토.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:33:18 +09:00
Hyungi Ahn b3dbf1a11e fix(ai): parse_json_response — string literal 안만 fix 하는 stateful walker
직전 fallback 의 무차별 newline replace 가 string 외부 (object 구조) 의 raw newline
까지 escape 해서 JSON 거부. 또 LaTeX 수식 (\circ, \text, \, etc) 의 invalid backslash
는 newline 이슈와 별개라 별도 fix 필요.

state machine: in_string 토글 (`\"` 만남). string literal 안에서만:
- raw LF/CR/TAB → \\n/\\r/\\t 로 변환
- backslash 다음에 valid escape char (\"\\/bfnrtu) 면 그대로
- backslash 다음에 invalid (\\c, \\,) 면 backslash 자체를 \\\\ 로 escape
- string 외부 raw newline 은 JSON whitespace 라 보존

운영 데이터 id=243 의 raw 940자에 \\circ \\text \\, \\approx \\times 등 다수 LaTeX +
markdown 줄바꿈 → 새 walker 가 두 케이스 모두 fix. 다른 worker (classify/triage/
study_explanation/evidence/study_session_analysis) 자동 혜택.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:00:20 +09:00
Hyungi Ahn 95b127fd8d fix(ai): parse_json_response — raw newline escape fallback (5단계)
Phase 4-A debug 결과 study_question_jobs.parse_fail 33건의 raw preview 분석:
- 모델이 explanation_md 안에 raw newline (LF) 그대로 박음 ('### [풀이]\n\n**자료...')
- JSON 표준상 string literal 안 raw control char 금지 → json.loads 거부
- 4단계 fallback (greedy slice) 도 이 때문에 실패

5단계 fallback 추가: candidate 의 \r\n/\n/\r 을 ``\\n``/``\\r`` escape 로 치환 후 재시도.
이미 escape 된 ``\\n`` (Python str = backslash+n 두 글자) 는 raw newline 아니라 영향 없음.
다른 worker (classify/triage/study_explanation/evidence/study_session_analysis) 모두
같은 파서를 공유하므로 자동으로 혜택.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:56:01 +09:00
Hyungi Ahn ff41feb3e3 fix(study): Phase 4-A parse_fail 디버깅 — 파서 fallback + raw 저장
운영 데이터에서 4-A study_question_jobs 의 33/114 가 'envelope JSON parse failed'
로 종결. parse_json_response 의 balanced 정규식이 못 잡는 케이스 다수 추정.

원인 분류 위해:
1. 파서 보강 (app/ai/client.py)
   - 기존 4단계 파싱 (fenced / balanced finditer / 전체 cleaned) 보존
   - 5단계 fallback 추가: first '{' ~ last '}' greedy slice → json.loads
   - envelope JSON 안에 내부 따옴표/뉴라인/escape 때문에 balanced 가 못 잡는
     케이스 방어. 모델이 JSON 앞뒤 자유 텍스트 섞어도 본체만 추출.
   - 회귀 위험 낮은 추가만 (앞 단계 성공 시 즉시 반환)

2. parse_fail 시 raw preview 저장 (study_explanation_worker)
   - 3개 inline parse_fail 분기 (not_dict / invalid_answer_choice /
     empty_explanation_md) 모두 _save_raw_preview() 헬퍼 호출
   - job.payload.debug_raw_preview = raw_text[:1000]
   - job.payload.parse_fail_reason = 분류 키
   - 향후 parse_fail row 의 payload 분석으로 원인 정확히 분류 가능

다음 단계: 배포 후 재발생 추이 + raw preview 분석 → prompt 추가 강화 또는
parser 추가 보강.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:48:10 +09:00
Hyungi Ahn 8074be6b6d feat(study): Phase 4-D 운영 관찰 + confidence calibration
Phase 4-B v1 첫 검증 결과 자료 부족 토픽인데도 모델이 confidence='high'
박는 케이스 발견. 정의 (high = 자료 + 다른 ai_explanation 으로 패턴 명확)
보다 과신 — UX 신뢰도 위험. 자동 cap 보정 + 운영 관찰 SQL 추가.

confidence calibration (services/study/session_summary_guard):
- calibrate_confidence(c, ctx_docs_count, ready_explanation_count) 신규
  · ctx_docs_count == 0 AND ready_explanation_count == 0 → 'low' cap
  · ctx_docs_count == 0 (ready 만 있음)  → 'medium' cap
  · ctx_docs_count >= 1                  → 모델 값 그대로
- 모델이 정의보다 더 보수적인 값 박은 경우 (모델 'low' + cap 'medium') 는
  보존 — 더 보수적인 값을 절대 올리지 않음

worker 적용 (study_session_analysis_worker):
- ctx_docs_count = len(ctx_docs)
- ready_explanation_count = sum(1 for a in prompt_attempts if a.get('ai_explanation'))
- calibrate_confidence 호출 → study_quiz_session_analysis.confidence 박힘
- job.payload 에 운영 분석 metadata 보존:
  · ctx_docs_count / ready_explanation_count
  · model_confidence_raw (모델 응답) vs calibrated_confidence (cap 후)
  · prompt_attempts / valid_attempts_total / summary_len
  → SQL 4 번 쿼리가 cap 작동 빈도 측정

scripts/phase4_health.sql (신규 운영 점검 SQL 7 섹션):
1. 4-A study_question_jobs status × error_code 분포
2. 4-B study_quiz_session_jobs status × error_code 분포
3. 4-B confidence 분포 (calibrated)
4. 4-B model_confidence_raw vs calibrated 차이 (cap 작동 빈도)
5. 4-A/4-B 최근 7일 처리 지연 p50/p95/max/avg
6. 4-A/4-B skipped 사유 분포
7. 4-B guard_fail / parse_fail / llm_timeout 비율

ship gate (단위 테스트):
- test_calibrate_confidence_no_evidence_caps_to_low (3 케이스)
- test_calibrate_confidence_only_explanations_caps_to_medium (3 케이스)
- test_calibrate_confidence_with_documents_passthrough (3 케이스)
- test_calibrate_confidence_normalizes_invalid_first (2 케이스)

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:33:57 +09:00
Hyungi Ahn 1186537ecf fix(study): Phase 4-B v1 worker — completed 박을 때 error_code 명시 clear
이전 attempt 가 llm_timeout/parse_fail 박은 후 다음 attempt 가 정상 완료해도
error_code 가 잔존해서 운영 분석 시 혼선. status='completed' 박는 시점에
error_code = None / error_message = None 으로 명시 reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:28:43 +09:00
Hyungi Ahn ea6b2cf351 fix(study): Phase 4-B v1 prompt cap — 큰 세션 LLM timeout 방어
세션 1 (wrong+unsure 84건) 에서 prompt 가 23K자 넘어 30초 timeout. plan 가정
(5~30건) 대로 MAX_ATTEMPTS_IN_PROMPT=30 cap 추가. 가장 최근 attempts 우선
(answered_at asc 정렬의 뒤쪽). 기존 valid_attempts 카운트 검증 (5건 미만 skip)
은 그대로 유지 — cap 은 prompt 입력만, 검증은 전체 기준.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:25:14 +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 43097e6fd9 fix(study): Phase 4-A envelope 프롬프트 — answer_choice 사용자 정답 강제
검증 결과 모델이 envelope 안에서 자료 근거로 정답 번호를 재판단해서 거의 매번
guard_fail (answer_choice != correct_choice). 환각 가드는 정확히 작동했지만
caching 효율 0%.

PR-3 의 free-form 풀이는 "사용자 정답 우선, 충돌 명시" 라 정상 ready 박혔지만
envelope.txt 가 "자료 근거 우선" 으로 충돌. 환각 가드의 본질 — 모델이 envelope
형식을 어겨 임의로 다른 번호를 박는 케이스 차단 — 을 유지하되, answer_choice
값은 사용자 정답 (correct_choice) 을 그대로 박도록 명시.

자료 근거와 사용자 정답이 다를 경우 explanation_md 안에 짧게 명시만 하고
answer_choice 는 보존. 정답 자체를 바꾸는 게 환각 가드의 차단 대상이라고 강조.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:47:44 +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 7cab78e490 ops(canonical): Phase 1D enqueue 전 backup + targets + md_status 스냅샷
enqueue 시작 직전 3가지 흔적 남김:
  (1) /tmp/phase1d_pilot.json 의 timestamped 사본 (재실행 대비)
  (2) 대상 30건 document_id 한 줄 출력
  (3) documents.md_status 분포 스냅샷 JSON 저장

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:00:33 +09:00
Hyungi Ahn 7e5716e594 feat(study): Phase 2-C 복습함 페이지 — 5탭 + 진입 동선
review-queue API (Phase 1) 를 사용한 복습함 페이지 신규.
탭: 오늘 할 일 (due_today) / 미확인 (pending_review) / 반복 오답 (chronic) /
퇴행 (regressed) / 학습완료 (mastered).

- 신규 라우트: /study/topics/[id]/review-queue
- 5탭 sticky + 카운트 배지 (page_size=1 5회로 카운트만 빠르게 — backend 변경 0)
- 페이지네이션 (page_size=50, ?page= URL 동기)
- ?tab= URL 동기 (새로고침/뒤로가기 보존, replaceState 사용)
- 카드 클릭 → 개별 문제 페이지 이동 (멀티 셀렉트 풀이는 후속)
- 진입 동선: 결과 화면 "바로 할 일" 콜아웃 → 해당 탭으로 directlink,
  결과 화면 footer + 토픽 페이지 헤더에 [복습함] 버튼 추가

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:57:44 +09:00
Hyungi Ahn 3e831a2dc7 fix(canonical): Phase 1D script sys.path — /app/scripts/.. 가 PYTHONPATH 루트
fastapi 컨테이너는 WORKDIR=/app, 코드가 직접 풀려있고 app/ 디렉토리 없음.
backfill_category.py 의 ../app 패턴은 컨테이너 안에서 /app/app (없음)
가 되어 ModuleNotFoundError. 스크립트 자기 디렉토리의 .. 를 sys.path 에
넣어 /app 루트 노출.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:50:23 +09:00
Hyungi Ahn f98cf2e505 ops(canonical): Phase 1D marker pilot one-shot script (select/enqueue/report)
30건 한정 stratified pilot. baseline markdown 품질 측정 후 Phase 2 전체
백필 결정. 영구 worker 경로 아님.

대상 WHERE:
  deleted_at IS NULL
  AND file_format='pdf'
  AND md_status='pending'
  AND category='document'
  AND document_type NOT IN SKIP_DOC_TYPES (marker_worker 와 일관)

Stratification:
  ai_domain × file_size_bucket (small<500KB / medium<5MB / large)
  documents 에 page_count 컬럼 부재 (marker_worker 가 PyMuPDF 로 동적
  측정) → file_size 를 길이 proxy 로 사용.
  cell 안에서 file_size 작은/큰 mix 로 짧은/긴 문서 차이 관찰.

Subcommands:
  select  — 30건 dry-run + JSON 저장 (/tmp/phase1d_pilot.json)
  enqueue — markdown 큐 enqueue (uq_queue_active 충돌 시 skip)
  report  — md_status / 평균 elapsed / 실패 top5 / heading anchor 후보 /
           KaTeX 후보 / file_size bucket 별 success 비율 / UI 검수 URL

리포트 메모:
  markdown_image_count 는 현재 server.py 가 _images 버림 → 0 정상.
  Phase 1B.5 에서 _images 출력 시 자동 활성.

실행:
  docker compose exec fastapi python /app/scripts/phase1d_pilot.py select
  docker compose exec fastapi python /app/scripts/phase1d_pilot.py enqueue --yes
  docker compose exec fastapi python /app/scripts/phase1d_pilot.py report

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:49:17 +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 c46fd564af feat(study): Phase 2-A 풀이 시작 UI — 학습 단계 + 분량 토글
vision 의 단일 풀이 진입점 — 옵션 토글로 학습 단계 (입문/학습 중/시험 직전) +
분량 (30/50/100) 선택. Phase 1-E bucket+stage 알고리즘과 매칭.

- 학습 단계 3 카드 + 분량 3 토글이 메인 옵션
- 단계 선택 시 분량 토글 노출
- 단계 미선택 시 "고급 옵션" collapsible — 기존 PR-12-B subject 단위 출제 호환
- 시작 버튼 disabled 상태 가이드 (단계 선택 또는 고급 옵션 펼침 필요)

서버 호출:
- optStage 있으면 { stage, size, abandon_existing } body
- 없으면 기존 { target_per_subject, subject, wrong_only, abandon_existing }

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:39:20 +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 242288aaf3 fix(study): Phase 1 migrations 222-225 → 226-229 — markdown canonical layer 222 충돌 회피
타 PR (markdown canonical layer Phase 1B) 의 222_processing_queue_stage_markdown.sql 와
번호 충돌. init_db 가 'migration 버전 중복' 에러 띄움. 4파일 + SQL 헤더 주석 일괄 rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:32:16 +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 e66addf975 fix(canonical): marker engine_version via importlib.metadata
marker module 이 __version__ attribute 를 노출하지 않아 ship gate 10 에서
engine_version="unknown" 으로 표시되던 cosmetic 문제. importlib.metadata.
version("marker-pdf") 로 패키지 버전 정확히 읽음.

테스트: ship gate 10 PASS 확인 후 재배포.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:19:46 +00:00
Hyungi Ahn daaf18bdae fix(canonical): add markdown to process_stage ORM enum (Phase 1B follow-up)
migration 222 가 DB enum 에 markdown 을 추가했지만 SQLAlchemy ORM 측 enum
정의 (app/models/queue.py) 에 누락되어 LookupError 발생.

테스트 enqueue → consumer 실행 시:
LookupError: 'markdown' is not among the defined enum values.

DB enum 마이그레이션은 migration 222 가 처리. ORM 측은 SQLAlchemy 가
직렬화/역직렬화에 사용하는 Python 측 enum mirror 역할.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:16:03 +00:00
Hyungi Ahn e50869cbda feat(canonical): Phase 1B marker-service + marker_worker for PDF→markdown (222)
신규 컨테이너 marker-service (port 3300, Marker 1.10.2 + surya 0.17.1 + HF
cache volume). marker_worker 가 markdown stage 큐 소비:
  classify_worker → enqueue 'markdown' (leaf, embed/chunk 와 독립)
  → SKIP_DOC_TYPES (발주서/세금계산서/명세표) 스킵
  → 확장자 != .pdf 스킵 (Phase 1B = PDF only)
  → page_count > 200 스킵
  → marker-service POST /convert
  → 422/404 = doc-level failed, 5xx = queue retry

안정성 장치:
- migration 222: ALTER TYPE process_stage ADD VALUE markdown (단일 statement)
- md_extraction_quality JSONB dict 직접 저장
- skip 시 md_content/hash NULL 클리어
- /ready Response.status_code + warmup_error 가시화
- HF cache volume (build-time download 0)
- file_path 는 NAS 상대경로 → /documents prefix prepend

성공 기준: 파이프라인 안정성. markdown 품질은 Phase 1D pilot.

Pre-flight (2026-05-01):
- marker-pdf 1.10.2 stable
- file_path 9503건 NAS 상대경로
- DOCUMENT_TYPES 한국어 7종 → SKIP alias 보강
- queue retry max_attempts=3 + reset_stale_items 확인
- main 220/221 study_q_related 선점 → 222 rebump

Plan: ~/.claude/plans/plan-idempotent-sundae.md (Round 5 approved)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:06:23 +00:00
Hyungi Ahn 2dd0f655bc refactor(study): 통합뷰 문제 카드 viewport prefetch — 첫 진입 가속
회차 카드 expand 시 100개 문제 카드가 viewport 에 들어오는 즉시 SvelteKit 이
question 라우트의 코드 chunk (KaTeX/marked/DOMPurify) prefetch 시작. 카드 클릭
시점엔 이미 파싱 완료 상태.

데이터(`/study-questions/{qid}`)는 hover 시점에만 prefetch — 카드 100개 전체
스캔이 100번의 데이터 fetch 가 되지 않게 분리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:05:37 +09:00
Hyungi Ahn 56f9aecf77 refactor(study): SvelteKit hover 시 라우트 코드/데이터 prefetch 활성화
처음 문제 진입 시 KaTeX + marked + DOMPurify 등 무거운 chunk 가 lazy load 되어 느림.
다음/이전 버튼은 같은 번들 재사용이라 빠름. 카드 hover 시점에 prefetch 시작 →
클릭 시점엔 이미 파싱 완료된 상태.

app.html body 에 data-sveltekit-preload-code/data="hover" 추가 (전역).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:02:10 +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 fe26aadb27 fix(canonical): split Phase 1A migrations into single-statement files (211-219)
asyncpg exec_driver_sql 의 prepared statement 제약상 multi-statement 파일은
"cannot insert multiple commands into a prepared statement" 에러로 적용 실패.
규칙: 한 migration = 한 statement (다중 ADD COLUMN 절은 단일 statement 라 허용,
인덱스/CHECK/CREATE TABLE 은 별도 파일).

이전 cee01af 의 211_md_canonical_layer.sql (6 statements) + 212_document_lineage.sql
(3 statements) 을 9 파일로 분할:
  211 ALTER TABLE documents ADD COLUMN x13
  212 ADD CONSTRAINT documents_md_draft_status_only_ai
  213 idx_documents_md_status_pending
  214 idx_documents_content_origin
  215 idx_documents_md_frontmatter_gin (선제 인덱스)
  216 idx_documents_md_draft_status
  217 CREATE TABLE document_lineage
  218 idx_document_lineage_source
  219 idx_document_lineage_derived

dry-run 재검증: 13 cols / 28 doc idx / 4 lineage idx PASS.
계획 변경 없음 — schema 결과 동일, 적용 단위만 분할.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:57:11 +00:00
Hyungi Ahn cee01af96a feat(canonical): Phase 1A markdown canonical layer schema (211/212)
documents 13 신규 컬럼 (md_content/md_frontmatter/md_status/content_origin
포함) + 4 인덱스 + 1 CHECK 제약 + document_lineage 테이블 (FK RESTRICT).

상태값은 모두 TEXT+CHECK (확장 시 enum drop/rebuild 비용 회피).
어떤 워커도 컬럼을 채우지 않음 — 스키마 기반만 깔고 Phase 1B 에서
marker_worker 로 채우기 시작.

Plan: ~/.claude/plans/plan-idempotent-sundae.md (round 3 approved)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:51:31 +00:00
Hyungi Ahn 36c8a0df3c refactor(study): 본문 표시를 q 단독 도착 시점으로 unblock
- load() 가 q 도착 후 related-types/siblings Promise.all 까지 기다려서 loading=false → 빈 카드 노출 시간이 셋 중 가장 느린 것 기준으로 늘어남
- q 직후 loading=false, 나머지 두 fetch 는 fire-and-forget
- related 섹션 자체 relatedLoading, prev/next 는 siblings 비면 안 보여 UX 영향 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 10:44:00 +09:00
Hyungi Ahn 4687546e37 refactor(study): 문제 상세 페이지 loadTopic 병렬화 + roundSiblings 캐시
- onMount: await loadTopic(); await load(); → Promise.allSettled 병렬화
- 같은 회차 안 prev/next 이동 시 page_size=200 batch fetch 반복 제거
  - module-level Map 캐시, key=topicId:encodeURIComponent(exam_round)
  - TTL 5분, get/set 시 얕은 복사로 참조 공유 차단

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 10:27:37 +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
Hyungi Ahn 5404343a1a fix(study): HC-5 block math spacing — KaTeX \$\$...\$\$ 앞뒤 빈 줄 보장 자동 fix
문제: 보기/해설 본문의 \$\$ ... \$\$ block math 가 앞뒤 빈 줄 없으면
마크다운 파서가 라벨/텍스트와 같은 단락에 묶어 KaTeX 렌더 실패 → raw 표시.

운영 결과 (21회분 = 2,100문항):
- HC-5 detect 317건 모두 자동 fix 완료. 모든 회차 재검사 0건.
- 추가 fix: q1579 (2023년 1회 q81) 바이메탈 ASCII 다이어그램 fence wrap.

알고리즘:
- 자체 줄 \$\$...\$\$ (한 줄 안 시작·종료, 길이 4+) detect.
- 앞·뒤 라인이 비어있지 않으면 빈 줄 삽입 — idempotent.
- inline \$ ... \$ 영향 없음.
- 의미 변경 0 (빈 줄 삽입만, 본문 텍스트/수식 보존).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:29:39 +09:00
Hyungi Ahn 87b6c38d99 feat(study): 보기 동그라미 숫자 (①②③④) 형식 지원 + 10회분 추가
운영 중 발견 — 2023년 이후 회차 md 가 보기를 ①②③④ 으로 표기.
파서가 "1번:" / "1." / "1)" 만 매칭해서 100문항 보기 1~4번 비어있음 → import abort.
CIRCLED 매핑 활용해서 동그라미 숫자도 처리 추가.

운영 결과 (10회분 추가, 누락 png 제외):
- 2022년 3회 / 2023년 1회: 100건 (이미지 0)
- 2023년 2회: 98건 / 2023년 3회: 96건 (png 일부 누락)
- 2024년 1·2·3회: 각 98건 (png 누락)
- 2025년 1·2·3회: 97/99/97건 (png 누락)
- audit: HC 0 / LC-5 1건 자동 fix (q2183 표 구분자)
- 누락 png 19건은 사용자 추후 보충 예정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 06:47:06 +09:00
Hyungi Ahn 1d73986fd6 feat(study): 가스기사 import 스크립트 — 보기 형식 다양화 + subject 슬래시 정규화
운영 중 발견한 패턴 추가:
- 보기 형식: "1번:" + "1." + "1)" 모두 매칭 (2022년 회차에서 "1." 사용 발견).
- subject 정규화: 괄호 형태(연소공학 (열역학))뿐 아니라 슬래시 형태
  (가스안전관리 / 가스설비) 도 head + scope 분리.

운영 결과 (6회분 = 600문항 추가):
- 2020년 3회 / 2021년 1·2·3회 / 2022년 1·2회 모두 등록 완료.
- 이미지 27건 자동 첨부 (1+4+7+6+5+4).
- audit: HC 0건, LC-5 2건 (2022년 2회 q41/q90 표 구분자 누락) 자동 fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:56:17 +09:00
Hyungi Ahn cb07ffa4ce feat(study): study_questions DB 마크다운 정합성 audit 스크립트
scripts/audit_study_question_markdown.py:
- HC 자동 fix (HC-1 outer fence / HC-2 escape 잔재 / HC-3 HTML 엔티티 / HC-4 공백)
  · HC-2 KaTeX 명령어 (\rho, \nabla 등) false positive 회피 — lookahead (?![A-Za-z])
  · 비정상 카운트 abort_threshold 안전장치
- LC 리포트 (LC-1 백틱 / LC-2 \$\$ / LC-3 \$ / LC-4 ** / LC-5 표 / LC-6 들여쓰기)
  · 각 항목에 edit 페이지 URL 포함 — 사용자 직접 처리 가능
  · LC-5 다컬럼 표만 검사 (|...|y|... pipe 3+) — 절대값 |x| 한컬럼 false positive 회피

운영 결과 (5회분 = 500문항):
- 2019년 1회: HC-4 43건 + LC-1 8건 + LC-3 2건 + LC-6 3건 자동/사용자 fix
- 2019년 2회: LC-1 4건 자동 fix
- 2019년 3회 / 2020년 1·2회: 0건
- 모두 audit PASS (HC 0 / LC 0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:06:39 +09:00
Hyungi Ahn b20c4f933b feat(study): exam_round 필터 + 일괄 import 스크립트 — 1천+ 문제 대비 (P0)
문제: 1천+ 문항 토픽에서 보기 페이지 prev/next 가 page_size=200 cap 으로
회차 외 문항만 받아 같은 회차 prev/next 누락 회귀.

해결:
- /study-topics/{tid}/questions 에 exam_round Query 파라미터 추가 (exact match).
- StudyQuestionSummary 응답에 exam_question_number 필드 추가.
- exam_round 필터 시 정렬 = exam_question_number asc NULLS LAST, created_at asc.
- 보기 페이지 loadRoundSiblings 가 ?exam_round= 로 한 회차만 fetch.
- 토스트 문구 "토픽 200문제 초과" → "이 회차에 200문항 초과" (의미 일치).

추가 — 가스기사 기출 일괄 import 스크립트:
- scripts/import_gas_questions.py: md 파서 + dry-run + apply.
  · exam_question_number 3소스 (파일명/제목/메타) 일치 검증.
  · subject 정규화 (괄호 세부분류는 scope 로 이동, 5과목 통일).
  · 이미지 4케이스 판정 + import_reports/{회차}_image_required.md 생성.
  · 첫 실패 abort 기본, --skip-existing/--continue-on-error 옵션.
  · 토큰 사전 검사 (GET /study-topics/{tid}).
- import_reports/: 2019년 1~3회 + 2020년 1~2회 리포트.
- 운영: 4회분 360문항 자동 import 완료 (이미지 4건 자동 첨부).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:39:02 +09:00
Hyungi Ahn 373dd059b7 fix(study): outer fenced code block auto-unwrap (renderMathMarkdown + DB 일괄 정리)
AI 응답이 마크다운 자체를 \`\`\` 으로 감싸서 오는 패턴 (시작만 있고 닫음 누락 포함)
때문에 explanation/AI 해설 영역이 raw 코드블록으로 보이는 회귀.

- frontend/lib/utils/mathMarkdown.ts: stripOuterFence helper.
  - terminated wrap 처리 (inner 에 \`\`\` 추가 있으면 보존)
  - unterminated 처리 (백틱 그룹 == 1 인 경우만 안전하게 unwrap)
  - 본문 중간 정상 코드블록은 보존
- scripts/strip_outer_fences.py: dry-run + --apply 양 모드.
  - 5개 필드 (question_text, choice_1~4, explanation, ai_explanation, content) 검사.
  - 운영 결과 explanation 34건 unwrap 적용 완료, recount 0 검증.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:55:18 +09:00
Hyungi Ahn 73b895c613 fix(study): 새 회차 진입 시 dropdown 에 (신규) 옵션 표시 — mode 전환 대신 옵션 추가
이전 fix(4c26b91)는 query 회차명이 examRounds 에 없을 때 mode='new' 자동 전환했지만,
사용자 화면은 여전히 select 모드 노출 (캐시 또는 동선 이슈). 더 직관적인 방식으로 수정:

- onMount 의 mode='new' 자동 전환 제거.
- select dropdown markup 에 query 회차가 examRounds 에 없으면 "(신규)" 라벨 옵션 추가.
- 사용자는 select 모드 그대로 유지하면서 신규 회차도 보임. 폼 제출 시 그 값 그대로 박힘.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:17:44 +09:00
Hyungi Ahn 4c26b9153f fix(study): 새 회차 시작 동선 — query 회차명이 examRounds 에 없을 때 mode='new' 자동 전환
회차 카드 페이지의 [새 회차 시작] → /questions/new?exam_round=...&start_qnum=1 진입 시
query 의 회차명이 기존 examRounds 에 없으면 (신규 회차라 등록된 문제 0개) select dropdown
옵션에 매칭이 없어서 회차 정보가 표시 안 되는 회귀.

onMount 에서 query 회차명이 examRounds 에 없으면 mode='new' + f_exam_round_new prefill.
사용자가 신규 회차로 입력한 이름이 그대로 폼에 박힘.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:13:13 +09:00
Hyungi Ahn 13404cd366 feat(study): 같은 유형 과밀 방지 — 출제 단계 spacing (PR-12-B)
학습 의미: 한 quiz 세션 안에서 같은 유형 문제가 과도하게 몰리지 않게 분산.
같은 유형을 없애는 게 아니라 펼치는 것 — dedup/제거 프레임 금지.

- 마이그레이션 210: study_quiz_sessions.quiz_mode VARCHAR(30) DEFAULT 'random'
- ORM: StudyQuizSession.quiz_mode 필드
- service.related_types: apply_type_spacing helper 추가
  - SPACING_THRESHOLD=0.88 (회차 무관 — PR-12-A 회차 필터 재사용 X)
  - PER_TYPE_CAP=2 (local neighbor cap, transitive cluster 보장 X)
  - SPACING_BUFFER_RATIO=2.0
  - 3단계 fallback: ready spacing → pending 보충 → hold cap 위반 fallback
  - debug 로그 type_spacing_applied subject=... ready=N selected=M ...
- _select_questions_for_topic: subject bucket 단위 spacing (과목 균등 보호)
- QuizMode Enum (random) — 향후 frequent_focus/wrong_variants 예약
- start_quiz_session 에 quiz_mode 받기 + apply_spacing 전달
- 프론트 startNewQuiz body 에 quiz_mode='random' 명시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:45:15 +09:00
Hyungi Ahn cbe852bb37 feat(study): 반복 출제 / 유사 유형 분리 표시 (PR-12-A)
학습 의미가 회차 간 반복성 — 차단/제거가 아니라 패턴 표시 frame.

- 신규 service `related_types.py` — threshold/회차 필터/round_count 계산 공유
  - REPEAT >= 0.95 / SIMILAR 0.88~0.95
  - 회차 조건 백엔드 강제 (자기 자신/같은 회차/null exam_round candidate 제외)
  - round_count: related_count == 0 → 0 (현재 회차만 1로 채우지 않음)
- GET /study-questions/{qid}/related-types — 단건 분류 (repeat_questions / similar_questions)
- POST /study-topics/{tid}/related-types-bulk — 카드 배지용 카운트 batch
  - 비교 대상 = 토픽 전체 ready pool (입력 qid 끼리 비교 X)
  - 응답 키 보존 — 권한 없음/임베딩 미준비 등도 (0,0,0,0)
- 보기 페이지: PR-11 비슷한 문제 토글 제거 + 🔥 반복 출제 / 🧩 유사 유형 두 섹션 자동 노출
  - 헤더 = round_count "N개 회차", 본문 위 = related_count "관련 N문제"
  - source_status / source_exam_round 안내 분기
- 결과 페이지 (틀린/모르겠음 카드): bulk 호출 후 round_count >= 2 일 때만 배지
- 통합뷰 회차 expand 시 lazy bulk 호출 — 같은 회차 캐시
- 기존 /similar 엔드포인트 유지 (raw 디버깅용)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:09:14 +09:00
Hyungi Ahn 8525c9aefb fix(study): 마크다운 컨테이너 클래스 prose → markdown-body
@tailwindcss/typography 플러그인 미설치 — prose prose-sm prose-invert
max-w-none 클래스가 무효라 결과 페이지(특히 모르겠음·틀림 카드)와 풀이 페이지의
질문 본문/사용자 해설/AI 해설/분야 설명에서 마크다운 스타일링이 안 먹었음.

이 codebase 의 정식 마크다운 클래스는 src/app.css 에 정의된 .markdown-body
(h1~h4, ul/ol, blockquote, code, pre, table, hr 등 완비). 모든 renderMathMarkdown
컨테이너에 markdown-body + math-area 두 클래스 적용.

영향 파일:
- review/+page.svelte (풀이 중 본문)
- quiz-sessions/[sid]/+page.svelte (결과 카드 expand 시 본문/해설/AI/분야설명)
- questions/[qid]/+page.svelte (보기 페이지)
- questions/[qid]/edit/+page.svelte (편집 페이지의 AI 풀이 미리보기)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:13:39 +09:00
Hyungi Ahn 1cf64fd11e feat(study): 문제 회차별 그룹 + 읽기전용 보기 페이지 (PR-11)
- 통합뷰 문제 섹션: 평면 리스트 → 회차별 아코디언 (디폴트 모두 접힘)
- 회차 정렬: "YYYY년 N회" 파싱 → year desc / round desc (localeCompare 단독 회귀 차단)
- 회차 행 라벨: "총 시도 N건 · 마지막 결과: 정답 K / 오답 M" (누적/마지막 혼동 회피)
- 회차 미지정 그룹은 노란 톤 + 안내, 표시 문자열은 UI 전용 (원본 NULL 분리)
- 본문 / [편집] 링크 구조 분리로 이벤트 버블링 충돌 차단
- /study/topics/{tid}/questions/{qid} 신규 — KaTeX 마크다운 렌더 + 정답 표시 +
  AI 해설 5상태 (idle/loading/success/stale/error) + 비슷한 문제 + prev/next
- prev/next URL 직접 접근 — 단건 fetch + 같은 회차 목록 fetch 자체 처리
- page_size=200 만땅 + total>200 시 토스트 안내 (조용히 자르지 않음)
- 사용자 입력 해설/이미지 없으면 섹션 숨김, exam_round NULL 이면 prev/next 비활성
- StudyTopicQuestionSummary 에 exam_question_number 추가 (회차 안 정렬 키)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:01:27 +09:00
Hyungi Ahn 6e25523600 fix(study): quiz_session 결과 — StudyQuestionImage.position → sort_order 재사용
PR-10 결과 페이지에서 GET /quiz-sessions/{sid} 가 500. 이미지 batch 호출에서
존재하지 않는 컬럼 position 사용 → AttributeError. 기존
_images_for_questions_batch 헬퍼 (sort_order 기준 + served_url 포함) 재사용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:52:52 +09:00
Hyungi Ahn 7f4d64c6df feat(study): 문제풀이 세션 + 결과 카드 + 학습완료 체크 (PR-10)
- study_quiz_sessions 테이블 (한 토픽 in_progress 1개 partial unique)
- study_question_attempts 에 quiz_session_id + reviewed_at 컬럼
- 풀이 진행률 서버 단일 진실 (cursor) — 나갔다 와도 이어풀기 가능
- 통합뷰: 진행 중 카드(이어풀기) + 최근 완료 결과 카드(미확인 N건 배지)
- 신규 /quiz-sessions/[sid] 결과 페이지 (3 카테고리 + AI 해설 + 분야 설명 + 학습완료 토글)
- /review 페이지는 풀이만, 마지막 문제 풀이 후 결과 페이지로 redirect
- 마이그레이션 206~209 (single-statement, asyncpg 호환)
- API: POST/GET/PATCH /study-topics/{tid}/quiz-sessions(/{sid}),
       PATCH /study-question-attempts/{aid}/review-mark
- AttemptCreate.quiz_session_id 추가 — submit_attempt 가 같은 트랜잭션에서
  세션 cursor + count 증가, 마지막이면 status='done' + finished_at

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:49:21 +09:00
Hyungi Ahn d968b2d901 feat(study): 문제풀이 모드 개편 + 결과 분류 + 분야 설명 (PR-9)
- 라벨 "복습 시작" → "문제풀이"
- attempts.outcome 컬럼 + selected_choice nullable (correct/wrong/unsure)
- 풀이 중 정답·해설·AI·비슷한 문제 모두 비노출, 답 클릭 시 자동 진행
- "모르겠음" 5번째 옵션 추가
- 결과 화면 = 정답/틀린/모르겠음 3 카테고리 탭, 카드 클릭 expand
  - 틀린 → PR-3 AI 해설 (RAG)
  - 모르겠음 → 분야(subject+scope) 설명 AI 즉석 생성 + 캐시 (PR-9 신규)
- 분야 설명 RAG: 매핑 documents 청크 + 같은 분야 다른 문제·해설 → bge-reranker
- 마이그레이션 200~205 (single-statement, asyncpg 호환)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:58:35 +09:00
Hyungi Ahn 3abccc512d fix(study): 마이그레이션 198 single-statement 분리 — 199_idx 추가
CREATE TABLE + CREATE INDEX 한 파일에 들어가 asyncpg prepared statement
원칙 위반 (cannot insert multiple commands). 198 = TABLE 만, 199 = idx 분리.
첫 시작에서 198 적용 fail 로 init_db 트랜잭션 전체 롤백 → 컨테이너 시작 후
schema_migrations 미반영 + study_question_images 테이블 미생성. 본 fix 후
다음 시작 시 198+199 순차 적용.
2026-04-28 13:44:59 +09:00
Hyungi Ahn b58268ba96 fix(study): Svelte fragment 문법 제거 — <></> 대신 명시적 태그 2026-04-28 13:43:31 +09:00
Hyungi Ahn 8b15e6e019 feat(study): 문제 첨부 이미지 (PR-8)
문제별 N개 이미지 첨부. 회로도/그래프 등이 필요한 시험 문제 지원.
입력·편집·복습 모두에서 표시.

데이터 모델 (migration 198):
- study_question_images: id, user_id FK CASCADE, study_question_id FK CASCADE,
  file_path, file_size, mime_type, sort_order, created_at
- partial idx (study_question_id, sort_order, id)

저장: NAS /documents/study_question_images/{topic_id}/{qid}/{img_id}.{ext}
file_watcher 가 보는 PKM 경로와 분리 — 자동 인덱싱 안 됨.

API:
- POST /api/study-questions/{qid}/images (multipart, MIME PNG/JPEG/WEBP/GIF,
  10MB/파일 제한, sort_order 자동 max+1)
- GET /api/study-questions/{qid}/images/{img_id}/raw (FileResponse, Bearer 인증)
- DELETE /api/study-questions/{qid}/images/{img_id} (DB row + 파일 시스템 정리)
- StudyQuestionResponse / ReviewQuestionItem 응답에 images 배열 포함
- StudyQuestionSummary 응답에 has_images bool 추가

프론트:
- 신규 lib/components/ImgAuth.svelte — Bearer 인증 endpoint 의 이미지를 fetch +
  blob URL 로 변환해 <img> 표시. unmount 시 URL.revokeObjectURL.
- /questions/new: 입력 폼에 이미지 dropzone (client-side 보유) → POST
  /questions 받은 qid 로 자동 multipart 업로드. "저장 후 계속 입력" 시 reset.
- /questions/[qid]/edit: 별도 카드 — 기존 이미지 grid + 추가/삭제. 즉시 업로드.
- /review: 문제 본문 아래 이미지 grid (max-h-72 object-contain).
- 모든 표시는 ImgAuth 컴포넌트 — accessToken 만료 케이스 대비.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:41:50 +09:00
Hyungi Ahn df52cb191b fix(study): TS annotation 제거 — plain JS svelte 파서 호환
applyQueryParams(): boolean 형태가 vite-plugin-svelte 의 JS 모드에서
parse error → 빌드 실패 → 직전 fix 모두 컨테이너에 적용 안 됨.
2026-04-28 13:30:55 +09:00
Hyungi Ahn a7b3164f78 fix(study): 클라이언트 카운터 신뢰 X — 서버 max+1 자동 채움 (user-edited dirty flag)
이전 fix(effect→onchange)에도 race 재발 (id 306,307 qnum=1,2 로 또 들어감).
근본 해결 — 클라이언트의 f_qnum 표시값과 실제 저장값을 분리.

변경:
- f_qnum_user_edited dirty flag 추가
- input 에 oninput → user_edited=true (사용자가 직접 박스 수정한 경우)
- onMount fallback / onRoundChange / applyNewRound / 저장 후 → user_edited=false
- POST body 의 exam_question_number: user_edited=true 면 명시 전송, false 면
  null → 서버가 같은 회차 max+1 자동 채움 (PR-6 의 기존 서버 로직)
- POST 응답의 실제 저장 qnum 으로 화면 동기화 (saved.exam_question_number)
  → 표시값이 어긋났어도 저장 후 정확하게 갱신
- applyNewRound 에서 이미 존재하는 회차명 입력 시 next_question_number 적용
  (사용자가 dropdown 대신 새 회차 모드로 같은 이름 다시 입력해도 1번부터 다시 시작 X)

이제 클라이언트가 어떤 표시값을 보여주든 실제 저장은 항상 정확. 사용자가
직접 박스를 수정한 경우만 명시 전송.
2026-04-28 13:25:08 +09:00
Hyungi Ahn 0d66107743 fix(study): 회차 변경 race 제거 — $effect → 명시적 onchange 핸들러
$effect 가 examRounds fetch 전 첫 실행되며 f_qnum=1 로 reset 하는 race 가
이전 fix(lastExamRound sync) 만으로 완전히 막히지 않음. effect 자체를
제거하고 select onchange + applyNewRound 에서 명시 호출하는 onRoundChange()
로 변경. examRounds 미적재 시 (length=0) 는 skip — onMount fallback 이 처리.

이제 흐름:
- 진입 (sessionStorage prefill 만 있음) → onMount await fetch 후 fallback
  으로 next_question_number 적용
- dropdown 으로 회차 변경 → onchange={onRoundChange}
- 새 회차 입력 → applyNewRound() 안에서 직접 f_qnum=1
- examRounds 변경 (저장 후 refreshExamRounds) → 어떤 자동 reset 도 발생 안 함
2026-04-28 13:14:52 +09:00
Hyungi Ahn 5b7e06abc1 fix(study): 입력 페이지 진입 시 회차 next_question_number race 수정
$effect 가 examRounds fetch 전에 첫 실행되면 found=undefined → f_qnum=1
로 강제 reset. 그 후 examRounds fetch 완료해도 effect 재실행 안 돼서
사용자가 그대로 입력 시작 → 회차 안 문항 번호 중복 (1,2,3,1,1,...) 발생.

수정:
- applyQueryParams() 가 start_qnum 명시 여부 boolean 반환
- onMount 에서 await loadTopicAndRounds() 후 explicit start_qnum 없고
  f_exam_round 가 있으면 examRounds.find().next_question_number 명시 적용
- lastExamRound 를 현재 값으로 sync — $effect 첫 실행이 또 reset 안 함

이미 발생한 데이터(중복 qnum) 는 사용자 직접 정정 또는 별도 SQL 보정 필요.
이 fix 후 새로고침/재진입 시에는 정상 next 적용.
2026-04-28 13:08:05 +09:00
Hyungi Ahn f6393fbe66 feat(study): 수식 입력/표시 KaTeX 렌더링 (PR-7)
기사시험 문제·해설에 √, ρ, R̄, α/β/γ, ㎥, $\\sqrt{...}$ 같은 수식이 자주
들어가는데 기존 plain text 표시는 LaTeX 문법이 그대로 노출되거나 깨짐.
표시·미리보기 영역에서만 KaTeX 렌더링 (입력 textarea 는 plain text 유지).

의존성: marked-katex-extension + katex (frontend/).

공통 유틸 frontend/src/lib/utils/mathMarkdown.ts:
- renderMathMarkdown(text): block 렌더 (문제 본문·해설·AI 해설용)
- renderMathMarkdownInline(text): inline parseInline (보기 1~4 button 안)
- 별도 marked 인스턴스 사용 → 글로벌 marked 영향 없음
- $...$ inline / $$...$$ block 모두 지원
- KaTeX throwOnError=false → 잘못된 수식은 빨간색 fallback (페이지 안 깨짐)
- DOMPurify USE_PROFILES.html + ADD_ATTR style/aria-hidden + FORBID
  script/iframe/onclick 등 — XSS 차단 유지
- 실패 시 text-only fallback (HTML escape)

CSS (app.css):
- .math-area .katex-display { overflow-x: auto } — 모바일 가로 overflow
  생기면 수식만 가로 스크롤, 페이지 레이아웃 보존
- .katex { white-space: nowrap } — KaTeX 자체 줄바꿈 방지

적용 위치 (표시·미리보기만, textarea 무변경):
- review: 문제 본문, 보기 1~4(inline), 답 제출 후 explanation, AI 해설
- edit: AI 해설 본문 (기존 marked → 통일)
- new 화면 preview / 통합뷰 카드 snippet: 무변경 (1차 보류, 사용자 요청 시 추가)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:52:34 +09:00
Hyungi Ahn 7dd77ec926 fix(classify): data_origin enum 검증 — knowledge 등 잘못된 값 cascade fail 방지
AI 응답에서 dataOrigin='knowledge' 같은 doc_purpose enum 값이 data_origin
컬럼에 잘못 매핑되면 asyncpg InvalidTextRepresentationError 발생. 같은
classify_worker session 의 후속 autoflush 호출이 PendingRollbackError
로 cascade 되어 batch 안 다른 문서까지 모두 실패.

doc_purpose 처럼 enum 허용값(work/external) 검증 후 박도록 수정. 외 값은
skip (data_origin NULL 유지). 가스기사 토픽 결손 15건의 RAG 결손 root cause.
2026-04-28 10:01:45 +09:00
Hyungi Ahn 37e9391d0d fix(study): AI 풀이 본문 markdown 렌더링 (review + edit)
기존엔 whitespace-pre-line 으로 plain 표시 → '**굵게**' 같은 markdown 문법이
그대로 노출. DocumentViewer 와 동일한 marked + DOMPurify 패턴 적용. prose
타이포그래피 클래스로 list/heading/inline 코드 스타일 자동.
2026-04-28 09:45:25 +09:00
Hyungi Ahn 8803e6a0fd feat(study): 시험·회차·문항 관리 (PR-6)
기사시험 회차별 100문제 채워가기 시나리오. 문제 입력 페이지를 단순 폼에서
"회차 진행률 추적·재개" 도구로 보강.

데이터 모델 (migrations 195~197):
- study_topics: exam_round_size INT CHECK 1~300 (회차당 문항 수, NULL=미설정)
  + exam_subjects JSONB DEFAULT '[]' (과목 리스트, 입력 페이지 드롭다운 옵션)
- study_questions: exam_question_number SMALLINT CHECK >0 (회차 안 문항 번호)
- partial idx (study_topic_id, exam_round, exam_question_number) WHERE
  deleted_at IS NULL AND exam_round IS NOT NULL — 회차별 max+count 고속화

백엔드:
- POST /questions: exam_round 명시 + exam_question_number 미명시 시 서버가
  같은 토픽·회차의 max+1 자동 채움
- 신규 GET /api/study-topics/{id}/exam-rounds: 회차별 진행률 집계
  {exam_round_size, items: [{exam_round, question_count, max_question_number,
   next_question_number, is_complete}]}
- StudyTopic Create/Update/Response/Meta 에 exam_round_size·exam_subjects
- StudyQuestion Create/Update/Response 에 exam_question_number
- exam_question_number 변경은 embedding stale 트리거에서 제외 (의미 영향 없음)

프론트:
- 토픽 생성/편집 모달: "시험 정보" 섹션 (회차당 문항 수 + 과목 리스트
  +추가/제거 칩)
- /study/topics/[id]/exam-rounds 신규 페이지: 회차 카드 + 진행 바 +
  [N번부터 이어서] 버튼 + [새 회차 시작] 모달
- 통합뷰 문제 섹션 헤더에 [회차 보기] 진입점
- /questions/new 페이지 전면 개편:
  - 시험명 = topic.name 자동 prefill
  - 과목 드롭다운 (topic.exam_subjects + 기존 distinct, "직접 입력" 토글)
  - 회차 드롭다운 (기존 distinct + "새 회차")
  - 문항 번호 자동 (회차 선택 시 next_question_number, 새 회차 = 1)
  - 진행률 바 (현재/exam_round_size)
  - 출처/메모 자동 합성 "회차 N번" (수정 가능)
  - "저장 후 계속 입력" → 본문/보기/정답 reset, 회차 유지, 문항 +1
  - 회차 변경 감지 시 문항 번호 1로 reset
  - exam_round_size 도달 시 회차 강조 + "저장 후 계속 입력" 비활성
- query string ?exam_round=&start_qnum= 지원 (회차 목록에서 재개 진입)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:31:06 +09:00
Hyungi Ahn 5b55274368 feat(study): 비슷한 문제 검색 (PR-5)
study_questions 자동 임베딩(PR-4 bge-m3 1024차원) 기반 cosine 유사도
top-K. 26B 호출 없음, vector search 만. additive UI — 기존 입력·복습
흐름 영향 없음.

백엔드: GET /api/study-questions/{id}/similar?limit=5&topic_only=true
- 자기 자신/soft-deleted/embedding_status!=ready 제외
- topic_only=true (default) 면 같은 study_topic 안에서만
- 응답: items[{id, question_text(80자 truncate), subject, scope, exam_round,
  similarity(1-cosine), attempt_count, last_correct}], source_status, source_id
- 현재 문제 embedding 미생성/실패/stale 시 빈 결과 + source_status 안내
- attempt_count + last_correct batch 조회 (N+1 회피)

프론트:
- 편집 화면(/questions/[qid]/edit): 페이지 로드 시 자동 GET /similar →
  카드 5개. 본문 truncate + subject/scope/exam_round + 유사도 % + attempt
  배지 (정/오답 아이콘). 카드 클릭 시 해당 문제 편집 페이지로 이동.
- 복습 화면(/review): 답 제출 후 "비슷한 문제 보기" 토글 → expand 5개 카드.
  같은 형태. 다음 문제로 cursor 이동 시 자동 닫힘.
- 통합뷰: 변경 없음 (이미 편집 진입점이 시각적 cue 역할).

source_status별 안내 (pending/failed/stale/none): 임베딩이 아직 준비 안 됐을
때 "약 1분 안에 cron 자동 처리" 메시지 노출.

후속 PR 예정: subject/scope 자동 추천(PR-6), 오답노트/통계(PR-7),
AI 풀이 idle batch(PR-8). 현재 PR-5 는 vector search 결과 노출까지만.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:05:55 +09:00
Hyungi Ahn 5a8c7595d7 fix(study): 워커 mapper chain — User/Document FK ref 추가 2026-04-28 08:59:33 +09:00
Hyungi Ahn b0a087ab6f fix(study): 워커 mapper chain — StudySession 도 defensive import 2026-04-28 08:57:57 +09:00
Hyungi Ahn de781ed622 fix(study): 워커 단독 진입 시 StudyQuestion mapper 초기화 위해 StudyTopic defensive import 2026-04-28 08:55:55 +09:00
Hyungi Ahn 9d4aa201a8 feat(study): study_questions 자동 임베딩 (PR-4)
문제 본문 + 보기 1~4 → bge-m3 1024차원. status 자체가 큐 역할 (별도 큐
테이블 없음 — ProcessingQueue 인프라 영향 0). APScheduler 1분 cron 이
status in {none, failed, stale} 행을 batch=10 처리. 새 문제는 default
'none' 으로 자동 backfill.

데이터 모델 (migrations 193~194):
- study_questions: embedding vector(1024), embedding_status VARCHAR(20)
  DEFAULT 'none' (none/pending/ready/failed/stale), embedding_updated_at,
  embedding_model
- HNSW partial index (vector_cosine_ops) WHERE deleted_at IS NULL AND
  embedding IS NOT NULL — bge-m3 cosine 기준, documents.embedding (ivfflat)
  과 ops 일관

재계산 트리거: question_text / choice_1~4 변경 시 ready→stale 자동.
correct_choice / explanation / subject / scope 변경은 재계산 안 함
(의미 검색에 영향 없음).

워커 (workers/study_question_embed_worker.py):
- race-safe pending 마킹 (조건부 UPDATE WHERE status IN none/failed/stale)
- AIClient.embed(text) bge-m3 호출, 15s timeout
- 실패 시 status='failed', 직전 embedding 보존, 다음 cron 틱에 재시도
- 본문 = "문제: ...\n보기:\n1. ...\n2. ...\n3. ...\n4. ..." (subject/scope
  의도 제외 — 분류명이 의미 검색 노이즈)

후속 PR 예정: 비슷한 문제 검색 UI / 중복 입력 감지 / RAG 정확도 향상 /
오답 클러스터링. 본 PR 은 임베딩 저장·재계산·backfill 까지만.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:54:02 +09:00
Hyungi Ahn e1a2cdc677 feat(study): AI 풀이 생성 — 수동 트리거 + RAG (PR-3)
복습 답 제출 후 또는 편집 화면에서 사용자가 명시적으로 누를 때만 AI 가
4지선다 풀이 생성. 자동 일괄 생성 금지 (하루 100문제 입력 시 MLX 부하·
잘못 입력 문제 해설 위험).

데이터 모델 (migrations 191~192):
- study_questions 4 컬럼 추가: ai_explanation TEXT, ai_explanation_status
  VARCHAR(20) DEFAULT 'none' (none/pending/ready/failed/stale),
  ai_explanation_generated_at, ai_explanation_model
- partial idx (study_topic_id, ai_explanation_status) WHERE status != 'none'

PATCH stale 자동 전이: question_text/choice_*/correct_choice 변경 시
status='ready' 만 'stale' 로. 본문은 보존, UI 배지 + "다시 생성" 동선.

신규 엔드포인트: POST /api/study-questions/{id}/ai-explanation
- regenerate=false + ready/stale → 캐시 즉시 (MLX 호출 없음, is_stale 플래그)
- pending → 409 (race-safe 조건부 UPDATE 로 동시 호출 차단)
- 그 외 → 새 생성

RAG 입력 풀:
- 1순위: study_topic 매핑 documents 청크 + ai_summary, bge-reranker top-5
- 2순위: 같은 토픽 다른 questions (자기 자신 제외, ai_explanation 은 ready
  상태만 포함 — 재귀적 hallucination 방지), reranker top-3
- 제외: 필기 OCR / 외부 웹 / Premium 모델

모델: Mac mini MLX gemma-4-26b primary 단독. get_mlx_gate() Semaphore(1) 경유,
30s timeout. 실패 시 status='failed' + 직전 본문 보존.

프롬프트 (app/prompts/study_question_explanation.txt): 자료 우선순위·인용
형식·할루시네이션 방지 절대 규칙 (법령명·조항·수치·표준 번호 단정 금지,
"자료에서 확인되지 않음" 명시).

프론트:
- 복습 화면 답 제출 후 인라인 expand. status별 버튼 분기 (ready 캐시 /
  stale "이전 풀이"+"다시 생성" / failed "다시 시도")
- 편집 화면 별도 카드. 상태 배지 + "이전 풀이 보기" / "다시 생성" 분리
- 참고 근거 토글 (source_type 별 아이콘 📄/ + 제목 + snippet)

후속 PR 보류: 오답노트/통계, AI 일괄 백그라운드 생성, 필기 OCR RAG,
Premium/Claude 재생성, /api/search/ask retrieval scope 통합.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:41:46 +09:00
Hyungi Ahn 0e2a430a6c fix(study): 통합뷰 자료 섹션 카테고리 트리 그룹핑 + 접기
가스기사처럼 한 워크스페이스에 273건 자료가 묶이면 평면 리스트로 쭉 나열
되어 통합뷰가 무너졌음. /study/topics/[id] 자료 섹션을 자료실 카테고리
경로 기반 트리로 그룹핑하고 노드별 접기/펼치기 도입. 기본값 모두 접힘.

백엔드: StudyTopicDocumentSummary 에 library_paths(`@library/<path>` 태그
에서 prefix 제거) 필드 추가. 그룹핑은 첫 path 만 사용 (단순화).

프론트: documents 를 path segment 별로 트리 빌드 → snippet 재귀 렌더링.
헤더에 "자료 N개 · 카테고리 K개 · [모두 펼치기/접기]" 컨트롤. 분류 없는
자료는 "분류 없음" 그룹으로 별도. 자료 0건 path 는 자동 누락.

필기/문제 섹션은 분류축이 달라(certification/subject vs subject) 동일
트리 못 쓰므로 본 PR 범위 밖. 후속에서 패턴 일관성 검토.
2026-04-28 08:14:58 +09:00
Hyungi Ahn 4b7156061e feat(study): 문제은행 + 복습모드 (study_questions)
study_topic 워크스페이스에 4지선다 문제은행 자산 트랙 추가. 기사시험 필기
대비 시나리오 — 빠른 반복 입력 + 과목별 균등 추출 복습 + 정오답 누적.

데이터 모델 (migrations 186~190):
- study_questions: study_topic 1:N, soft delete, is_active 토글, correct_choice
  SMALLINT CHECK 1~4
- study_question_attempts: 답 제출 1행 누적. study_question_id FK는 ON DELETE
  RESTRICT (이력 보존 원칙 — hard delete 실수로 풀이 기록 소실 차단)

설계 원칙:
- 문제 삭제는 API 에서 soft delete only. attempts FK RESTRICT 로 DB 레벨도 보호
- correct_choice 변경 시 기존 attempts.is_correct 재계산 안 함 (시점 사실 보존)
- 복습 default = 과목별 target_per_subject(20) 무작위 균등 추출. 한 과목이
  부족하면 가용한 만큼만
- wrong_only=true 정의 = 가장 최근 attempt 가 오답인 문제 (latest-wrong, ever-wrong 아님)
- 출제 응답에서 정답·해설 비공개. 답 제출 시점에만 노출
- subject/scope 강한 enum 미사용 (자유 텍스트, 자동완성은 후속)

API: /api/study-topics/{id}/questions, /review/questions, /api/study-questions/{id},
/attempt. 통합뷰(/study-topics/{id}) 응답에 sections.questions / stats.question_count
추가. 기존 question_set_count 는 후속 PR(회차/모의고사 묶음)용으로 보존.

프론트: /study/topics/[id]에 문제 섹션 + "새 문제"/"복습 시작" 진입.
/questions/new (저장 후 계속 입력 + sessionStorage persistent),
/questions/[qid]/edit (정답 변경 시 attempts 재계산 안 됨 안내 배너),
/review (시작 옵션 → 풀이 → 마지막 요약).

후속 PR 예정: 오답노트/취약 과목 리포트, AI 해설/클러스터링, spaced
repetition, 이미지 OCR 입력, CSV import, study_question_sets 묶음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:00:37 +09:00
Hyungi Ahn efa1781211 fix(study): 자료 선택 100건 초과 시 422 — chunk 분할 POST
페이지네이션으로 여러 페이지에서 전체선택을 누적하면 100건 초과로 백엔드
StudyTopicDocumentLinkRequest 의 max_length=100 위반 → 422. 백엔드 제약은
abuse 방어용으로 유지하고, 프론트에서 100개씩 chunk 로 분할 POST + 결과
카운트 누적해 단일 토스트로 보고.
2026-04-28 07:36:47 +09:00
Hyungi Ahn 88806f0a24 fix(study): 자료 추가 모달 page_size 100 + 페이지네이션 + 일괄 추가 안내
기존 page_size=50 으로 박혀 있어서 한 카테고리에 50건 초과 자료가 있을
때 51번째부터 안 보였음. page_size 를 백엔드 max(100)로 올리고 이전/다음
페이지 컨트롤 + 총 건수/페이지 표시 추가. 100건 초과 시 모달 상단에
"좌측 트리 폴더+ 아이콘으로 한 번에 추가" 안내 배너.
2026-04-28 07:33:49 +09:00
Hyungi Ahn 62afc571c0 feat(study): 카테고리 트리에서 자료 일괄 추가
자료 추가 모달이 1건씩 검색·체크박스만 지원해서 같은 카테고리에 자료가
많을 때 비효율적. /api/library/tree 의 카테고리 구조를 모달 좌측에 띄우고,
노드 옆 아이콘 한 번으로 그 path 하위 자료 전체를 한 번에 매핑.

백엔드: POST /api/study-topics/{id}/documents/by-path 추가. user_tags
@library/<path> prefix 매칭(documents.py 의 list_library_documents 와
동일한 EXISTS 쿼리)으로 100건 limit 우회. 응답은 linked_count /
skipped_existing_count / total_in_path 카운트만 노출.

프론트: 모달을 max-w-4xl + grid(트리/자료) 레이아웃으로 개편. 트리 노드
클릭 = 우측 자료 목록 path 필터링, 노드 옆 FolderPlus 버튼 = 즉시 일괄
추가. 검색·체크박스·전체선택은 그대로. 모바일은 트리가 상단 max-h-40vh
영역으로 stack.
2026-04-28 07:29:59 +09:00
Hyungi Ahn 63ed4d81e5 feat(study): study_topics 학습 워크스페이스 컨테이너 도입
필기 세션과 자료(library document)를 한 학습 주제(예: 가스기사) 아래로 묶는
1차 컨테이너. 향후 단어장/오디오/문제세트 등 학습 자산이 같은 묶음으로 들어올 수
있도록 응답 구조(sections + stats)를 dict 기반으로 설계.

데이터 모델 (migrations 179~185):
- study_topics: user_id × name partial unique (active 행만), soft delete
- study_sessions.study_topic_id: 1:N nullable FK (ON DELETE SET NULL)
- study_topic_documents: 자료 N:M 매핑 (user_id 반정규화로 권한 격리)

설계 원칙:
- documents.category(자료실 UI 축)와 직교 → 자료실 facet/카테고리 미터치
- StudySession.certification/subject/topic 보존 (세부 메타로 계속 사용)
- study_type은 느슨한 분류 (강한 enum 미사용, jlpt_n3 등 확장 여지)
- polymorphic study_topic_items 영구 금지 → 자산 타입별 조인 테이블 추가 방식

API: /api/study-topics CRUD + /by-document/{id} + 자료/세션 매핑 엔드포인트.
프론트: /study/topics 목록 + /study/topics/[id] 통합 뷰(필기·자료 두 트랙) +
        write 폼에 워크스페이스 드롭다운 + study hub 진입 카드.

후속 PR-2 어학 UX, PR-3 오디오 자산, PR-4 AI retrieval scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 07:06:37 +09:00
Hyungi Ahn f005da2e83 ops(study): pressure 파이프라인 진단 패널 — raw/mapped/final 3단계 + tilt/buttons
사용자 분석: 수치 튜닝 무관해 보이면 pressure 입력 자체가 안 들어오는 케이스. perfect-
freehand 옵션 변경 의미 없음. 먼저 PointerEvent.pressure 가 실제로 변동하는지 확인 필요.

진단 패널 (?debug=1) 에 추가:
- PRESSURE PIPELINE 섹션:
  · raw  = PointerEvent.pressure 원본
  · mapped = getStrokePressure 의 inputP (raw 매핑 또는 속도 fallback)
  · final = fixedPressure update 후 perfect-freehand 에 전달되는 값
  · raw min/max — 세션 내 raw pressure 범위 (사용자가 펜 강약 시도 후 확인)
- tiltX, tiltY, ptr width/height, buttons — Pencil 추가 입력 필드.

판별:
- raw 가 항상 0.5 또는 1.0 → 디바이스/브라우저에서 pressure 미전달.
  현재 환경에서는 속도 기반 fallback 이 유일.
- raw 가 변동 (0.1~1.0) 인데 mapped/final 이 일정 → 우리 코드가 무시 중.
- raw + mapped + final 모두 변동 → perfect-freehand 가 무시 (thinning, simulatePressure).
2026-04-27 15:54:23 +09:00
Hyungi Ahn 8b27eadf2e feat(study): PEN_PRESET_NOTABILITY_LIKE — 사용자 지정 프리셋 적용
사용자 분석 + 1차 프리셋 반영:
- streamline 0.75 → 0.45. 입력 lazy 줄여 손끝-잉크 latency 감소.
- smoothing 0.99 → 0.82. 기계적 보정 줄여 자연스러운 필기감.
- thinning 0.35 → 0.45. 변동 폭 키워 필압 차이 명확.
- WIDTH_FACTOR { 0.35, 0.50, 0.85 } → { 0.38, 0.55, 0.90 }.
- MAX_GAP_PX 16 → 6. 빠른 stroke 점선 차단 (촘촘 보간).
- start.taper size×0.20 → ×0.15. end.taper ×0.40 → ×0.25. Notability felt.
- cap: false → true. 둥근 끝점.

Smart pressure 강화 (획 내부 균일):
- PRESSURE_FLOOR 0.5 → 0.6. 약한 입력에서도 선 사라지지 않음.
- FIRST_POINT_PRESSURE 0.7 → 0.72.
- FIXED_THRESHOLD 0.15 → 0.18. 잡음 범위 넓게.
- FIXED_ALPHA_NOISE 0.03 → 0.015. 잡음 더 강하게 무시 → 획 내부 균일.
- FIXED_LARGE 0.30 → 0.32.
- FIXED_ALPHA_INTENT 0.50 → 0.40.

getCoalescedEvents 이미 사용 중 — Chrome 의 raw sample 활용 보장.

테스트 기준:
1. 빠른 가로선 점선 안 됨.
2. 천천히 세로선 굵기 출렁이지 않음.
3. 강/약 stroke 차이 보이되 약한 stroke 도 끊김 없음.
4. 한글 자모 빠르게 이어쓸 때 두 번째 획 누락 없음.
5. Chrome 기준 우선 통과.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:51:10 +09:00
Hyungi Ahn 1ba425f07a fix(study): visual continuity — pressure floor 0.5 + thinning 0.35
사용자 보고: 빠른 stroke 가 점선처럼 끊겨 보임 ("선이 이어지지 않음").

원인: 속도/raw pressure 기반 inputP floor 가 0.25 ~ 0.3 → thinning 0.5 적용 시
outline 폭이 size × 0.5 미만 → 픽셀 단위 정렬 안 되면 dot 패턴.

Fix:
- 속도 기반 inputP floor 0.25 → 0.5. 가장 빠른 stroke 도 size × 0.825 폭 보장.
- raw pressure 매핑 0.3~1.0 → 0.5~1.0. min 폭 보장.
- thinning 0.5 → 0.35. 변동 폭 줄임 (min 폭 더 보장).

Trade-off: 굵기 변동 폭 줄어듦. 하지만 사용자 우선순위 = visual continuity.
inputP 0.5~1.0 + thinning 0.35 → 폭 변동 ±17.5% (충분히 보임).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:47:02 +09:00
Hyungi Ahn fb73f96d2e fix(study): 강한 압력 즉시 반응 — 3단계 threshold + dynamic range 확장 + thinning 키움
사용자 보고: 빡세게 눌러도 굵기 차이 거의 안 남.

원인 분석:
1. raw pressure 0.1~0.99 만 활용했는데 dynamic range 그대로 → 변동 작음.
2. 속도 기반 변동 폭 0.3~1.0 작음 + dist/25 비율 작음.
3. INTENT alpha 0.25 너무 느림 → 강한 변화도 stroke 내내 못 따라감.
4. thinning 0.4 변동 폭 부족.

Fix:
- raw pressure 0.1~0.99 → 0.3~1.0 으로 매핑. dynamic range 확장.
- 속도 기반 0.25~1.0 + 비율 dist/18. 변동 폭 키움.
- 3단계 threshold:
  · dev < 0.15 (잡음) → alpha 0.03 (fixed 유지)
  · 0.15 ≤ dev < 0.3 (의도적) → alpha 0.5 (이전 0.25 → 빠르게 따라감)
  · dev ≥ 0.3 (매우 큼, 빡세게 누름) → 즉시 update (alpha 1.0)
- thinning 0.4 → 0.5. 폭 변동 더 명확.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:44:05 +09:00
Hyungi Ahn 294bd775a9 feat(study): smart pressure (fixed + intentional change) + 굵기 균형 재조정
사용자 보고 통합:
1. "기본이 두꺼움" — 평소 stroke 가 두껍게 느껴짐
2. "힘 줘도 일정 이상 안 두꺼워짐" — max 굵기 부족
3. "약하게 그리면 점선" — min 폭 너무 작음
4. "압력 정해지면 stroke 그 굵기 유지" — Notability felt = stroke 내부 일정
5. "의도적 압력 변화 시 굵기 변동" — 단 명확한 변화는 따라옴

Fix:
- baseSize 6 → 7. max 두꺼움 보장.
- WIDTH_FACTOR { 0.4, 0.6, 1.0 } → { 0.35, 0.5, 0.85 }. 기본 살짝 가늘게.
  결과 normal = 7×0.5 = 3.5 (이전 3.6 비슷), thick = 5.95 (충분히 두꺼움).
- thinning 0.55 → 0.4. fixedPressure 가 잡음 흡수하니 폭 변동 더 키워도 안정.

Smart pressure (getStrokePressure):
- raw pressure 정상 시 → 그것 사용 (Pencil pressure 활용).
- 비정상 시 → 점 간 거리 기반 속도 추정 (mouse / Pencil 미지원 빌드).
- fixedPressure: stroke 시작 시 inputP 로 초기화. 그 후 hybrid update:
  · 변동 < 15% (잡음/평소) → alpha 0.03 (거의 무시) → 균일 굵기
  · 변동 ≥ 15% (의도적 변화) → alpha 0.25 (빠르게 따라감) → 굵기 변화
- simulatePressure: true → false. getStrokePressure 가 자체 처리.

기존 smoothPressureWindow 제거. fixedPressure 가 동일 역할 + Notability felt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:41:09 +09:00
Hyungi Ahn 56efc6ffc5 fix(study): simulatePressure: true 항상 — Pencil pressure 미도달 시 속도 기반 fallback
사용자 보고: 마우스도 Pencil 도 굵기 변화 없음. iPadOS Safari 의 일부 빌드에서
Apple Pencil PointerEvent.pressure 가 정상 도달 안 하거나 일정 → 우리 thinning 0.55
적용해도 input pressure 가 일정이라 효과 0.

Fix: perfect-freehand 의 simulatePressure: true 항상.
- 점 간 속도 (거리) 기반 자동 pressure 추정.
- 빠른 stroke = 가늘게, 천천히 = 굵게.
- Notability 도 동일 felt (속도 기반 ink flow).
- pen 의 실제 pressure 는 무시되지만, 들어오지 않는 빌드에서는 어차피 무관.

stroke 별 simPressure 필드 / serializableStrokes 로직은 유지 (향후 분기 옵션 위해).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:32:05 +09:00
Hyungi Ahn 1a93c9cbe6 fix(study): 필압 굵기 차이 대폭 키움 — thinning 0.55, MIN_PRESSURE 0.25
해석 오류 정정: 사용자 "필압 너무 차이나" = "차이가 너무 *안* 난다" 의미였음. 종이
만년필 reference (4 stroke 굵기 차이 5:1) 가 *원하는* 수준이었던 걸 반대로 해석해서
thinning 줄였던 회귀.

Fix:
- thinning 0.18 → 0.55. 폭 변동 ±55%.
- MIN_PRESSURE 0.4 → 0.25. dynamic range 넓게 (0.25~1.0).
- PRESSURE_WINDOW 12 → 8. 압력 변화 빠르게 따라옴.

조합 시 실제 굵기 비율: 약한 stroke ≈ size×0.42, 강한 stroke ≈ size×1.0 → 약 2.4:1.
종이 reference (5:1) 보다는 약하지만 만년필 felt 명확.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:28:39 +09:00
Hyungi Ahn 9af928b7d7 fix(study): 압력 일관성 + dot 제거 — thinning 0.18, window 12, cap false
사용자 보고: 필압이 너무 차이남 (stroke 마다 굵기 들쭉날쭉) + stroke 끝에 dot 점.
종이 만년필 reference 와 비교 시 우리 앱이 작은 압력 변동에 너무 민감.

Fix:
- thinning 0.28 → 0.18. 폭 변동 ±18%. 작은 압력 차이가 큰 굵기로 변환되지 않음.
- PRESSURE_WINDOW 8 → 12. 평균 더 안정 → stroke 간 일관성.
- cap: true → false. round cap 이 짧은 stroke 에서 dot 처럼 보이던 회귀 제거.
  taper 가 끝을 자연스럽게 마무리하므로 cap 불필요.
- start.taper size*0.15 → 0.2. end.taper size*0.3 → 0.4. cap 없으니 taper 가 직접
  마무리 — 살짝 더 길게 두어 만년필 nib felt 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:26:37 +09:00
Hyungi Ahn 580f3ab728 fix(study): 작은 글자 stroke 인식 — streamline 완화 + taper 짧게
사용자 보고: 글자가 작아지면 제대로 인식 못 함 (스크린샷의 작은 "유" 가 부서져 보임).

원인:
1. streamline 0.86 = 입력 점이 펜 위치보다 lazy 하게 따라옴. 긴 stroke 에선 부드러움
   이지만 짧은 stroke (작은 글자) 에선 lag 누적 > stroke 길이 → 펜이 떨어져도
   stroke 가 못 따라감 → 부서진 dot 처럼 보임.
2. start.taper size*0.3 + end.taper size*0.5 = 짧은 stroke (length ≈ size × 1~2) 의
   거의 전체가 taper 영역 → stroke 가 모두 가늘게 그려짐.

Fix:
- streamline 0.86 → 0.75. 부드러움 + 짧은 stroke 정확성 균형.
- start.taper size*0.3 → 0.15.
- end.taper size*0.5 → 0.3.

만년필 nib felt 는 유지 (taper 비율 그대로) 하되 영향 길이 줄임.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:24:14 +09:00
Hyungi Ahn b7058ba40b feat(study): Notability felt — start/end taper + ease-out
사용자 보고: Notability 의 그 맛이 안 남. 만년필 nib 의 핵심 felt 누락.

Notability 의 만년필 stroke 특징:
- 시작 = nib 이 종이에 닿는 순간. 짧게 가늘게 시작.
- 끝 = nib 이 종이에서 떨어짐. 좀 더 길게 가늘어짐.
- ease-out 곡선: 빠르게 굵어졌다 천천히 안정.

Fix:
- start.taper: size * 0.3, easing: t * (2-t) (ease-out)
- end.taper: size * 0.5, easing: t * (2-t)
- cap: true 유지 (round 끝점)

이전에 taper 가 흔들림 원인이라 뺐었지만, 그건 thinning 0.18 + 보간 점 micro 변동 +
EMA 와 겹친 회귀였음. 지금은 마디/흔들림 모두 차단됐으니 taper 안전하게 도입 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:21:34 +09:00
Hyungi Ahn 30d32ad90c fix(study): 약한 pressure 에서도 stroke 폭 보장 — MIN_PRESSURE floor + thinning 완화
사용자 보고: 쓰다보면 필압이 줄어드는데 그러면 "학" 의 ㅡ 같은 부분이 거의 안
보이고 점선처럼 됨. 사용감 별로.

원인: thinning 0.4 + Pencil pressure 0.2~0.3 (약한 누름) → stroke 폭이 너무 줄어듦.

Fix:
- normalizePressure 에 MIN_PRESSURE 0.4 floor. pressure 0.05~0.4 도 0.4 로 고정.
  dynamic range 0.4~1.0. 약한 pressure 에서도 stroke 가 충분히 보임.
- thinning 0.4 → 0.28. 폭 변동 줄임. floor 와 조합 시 ±17% 정도 변동.

기존 폭 시작점은 유지 (만년필 nib 변화 명확).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:19:18 +09:00
Hyungi Ahn eb35943c58 feat(study): 만년필 굵기 변화 — thinning 0.4 + window 8
마디 해결 후 사용자 피드백: 굵기 변동이 거의 없음. 만년필 느낌 (pressure 따른 명확한
굵기 차이) 원함.

원인: thinning 0.22 + window 16 = 변동 흡수 너무 강함. Pencil pressure 0.3~0.8
변동 → window 평균 거의 일정 + 22% 폭 반응 → 시각적으로 미세.

Fix:
- PRESSURE_WINDOW 16 → 8. pressure 변화 빠르게 따라옴 (마디는 보간 점 16px 으로
  이미 차단됨).
- thinning 0.22 → 0.4. stroke 폭 ±40% 반응. 만년필 nib 처럼 약한 압력 = 가는,
  강한 압력 = 굵은. 명확한 차이.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:16:13 +09:00
Hyungi Ahn 7114081f86 fix(study): MAX_GAP_PX 8 → 16 — 보간 점 절반 줄여 마디 패턴 차단
스크린샷 진단: 빠른 곡선 stroke 에서 일정 간격 마디 (점선 효과) 명확. 윗쪽 천천히
쓴 글씨는 마디 거의 없음. 차이 = stroke 속도. 빠른 stroke = 보간 점 많이 추가됨.

가설: 8px gap 보간이 *일정 간격 dense vertex* 만들고, perfect-freehand outline
polygon 의 vertex 위치가 anti-aliasing 효과로 약간 dim 하게 표현 → 시각적 점선.

Fix:
- MAX_GAP_PX 8 → 16. 보간 점 절반.
- perfect-freehand smoothing 0.99 + streamline 0.86 이 sparse 점에서도 부드러운 곡선
  생성 → 16px 간격 충분. 점선 방지는 30px+ gap 만 보간.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:13:49 +09:00
Hyungi Ahn a7de0d0d4e fix(study): 선 마디 차단 + 큰 흐름의 굵기 변화 — pressure window-average
사용자 보고: 굵기 변동 없고 선 사이사이 마디 (점선 같은 끊어짐) 보임.

원인: EMA(α=0.15) 가 매 점마다 pressure 살짝 변동 + thinning=0.15 → outline polygon
에 점 간 micro 폭 변동 = 마디. 큰 흐름 변동은 약함.

Fix:
- smoothPressure (EMA) → smoothPressureWindow (마지막 16점 평균).
  매 점 변동은 1/16 수준 → micro 변동 평균화 (마디 차단). 큰 흐름은 따라옴.
- 보간된 점 (8px gap interpolation) 의 pressure 도 모두 sp 동일.
  점진 보간 (lp → sp) 이 outline 에 micro 변동 일으키던 부수 원인 제거.
- thinning 0.15 → 0.22. window 평균이 micro 변동 흡수하니 폭 반응 더 크게 두어도
  마디 안 발생. 큰 흐름의 굵기 변화 명확.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:11:25 +09:00
Hyungi Ahn 084b85158b feat(study): Notability 같은 미세한 굵기 변화 — pressure EMA + thinning 0.15
사용자 요청: stroke 굵기가 너무 일정해서 단조로움. Notability 처럼 살짝 압력에 따라
변화 있으면 좋겠다.

이전 thinning 0.18 + PRESSURE_SMOOTH_RATE 5% 조합은 점 간 5% 즉시 변동 가능 →
누적 시 들쭉날쭉. thinning 0 으로 회귀했었음.

Fix:
- Pressure smoothing 알고리즘 변경: rate-limit (±5%) → EMA (alpha 0.15).
  새 값 15% + 이전 값 85% 가중. 잡음/덜컥 변동 제거하면서도 자연스러운 흐름.
- thinning 0 → 0.15. pressure 변화에 stroke 폭 ±15% 반응.
- EMA + thinning 조합 → "부드러운 흐름에 따른 자연스러운 굵기 변화". 흔들림 없음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:07:29 +09:00
Hyungi Ahn 8a65dfd909 fix(study): widthMode 변경 시 기존 stroke 굵기 보존 + 부드러움 살짝 더
1. Stroke 별 size 저장 — 사용자 보고 "굵기 변경하면 기존에 입력했던거 전부 바뀜"
   - 회귀 원인: drawStroke 가 매 redraw 시 effectiveSize ($derived) 사용 →
     widthMode 변경 시 모든 stroke 재그려짐.
   - Fix: Stroke type 에 size 추가. inflight 생성 시 size=effectiveSize 저장.
     drawStroke 가 s.size 사용. legacy stroke (size 없음) 은 첫 draw 시점의
     effectiveSize 로 fix (refW/refH 패턴 동일).
   - cache 무효화 로직 정리: stroke.size 가 불변이므로 _path2d 캐시는 영원 유효.
     기존 _size 비교 제거.
   - serializableStrokes 에 size 포함 — 다음 load 시 굵기 보존.

2. Stroke 부드러움 살짝 더:
   - smoothing 0.98 → 0.99 (사실상 max).
   - streamline 0.82 → 0.86 (input lazy 강화, 손떨림 보정 큼).
   - 0.9 이상은 lag 위험.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:04:31 +09:00
Hyungi Ahn 187fe2bb01 feat(study): 굵기 단계 시프트 + 부드러움 강화 (선 흔들림 차단)
사용자 요청:
1. 굵기 단계 한 단계씩 가는 쪽 시프트 — 새 thin (0.4) 추가, 새 normal (0.6) =
   이전 thin, 새 thick (1.0) = 이전 normal. 이전 thick (1.6) 제거.
2. 만년필 같은 부드러움 + 약한 압력에도 안정.

Stroke 옵션 튜닝 (선 흔들림 차단):
- thinning 0.18 → 0. pressure 변동에 따른 stroke 폭 변화 제거 → 일정 굵기 → 흔들림
  최소화. 사용자 보고 "선이 흔들림" 의 직접 원인이었음.
- smoothing 0.95 → 0.98. 점 간 보간 거의 최대. Pencil 240Hz 미세 떨림 + 손떨림 흡수.
- streamline 0.7 → 0.82. input lazy 강하게. 0.85 이상은 lag 발생 위험.
- start/end taper effectiveSize*0.5 → 0. 짧은 stroke 시작/끝에서 굵기 급변이 흔들림
  인식 강화. cap round 만 유지로 충분.

Pressure smoothing 함수 추가 (선택적 만년필 효과 잔존):
- pushPointWithInterp 에서 점 간 pressure 변동 5% 이내로 제한.
- thinning 0 인 현재는 visible 영향 없지만, 향후 thinning 도입 시 재활용 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:00:24 +09:00
Hyungi Ahn 2041809cb9 feat(study): 지우개 인디케이터 + Pencil stroke 부드러움 + 자동 펜 복귀
1. 지우개 인디케이터 (Notability 스타일):
   - 지우개 모드에서 펜/마우스 hover 만으로도 cursor 위치에 원형 표시.
   - eraserRadius 크기 outline + 12% 반투명 fill — 어디를 지우게 될지 시각 피드백.
   - tool=pen 으로 변경 / canvas pointerleave / 자동 복귀 시 자동 hide.

2. Pencil stroke 부드러움 (사용자 보고: Pencil 글씨가 마우스 대비 들쭉날쭉):
   - thinning: 0.25 → 0 (pressure 변동 무시 = 마우스처럼 일정 굵기).
   - smoothing: 0.85 → 0.95 (점 간 보간 더 강함, Pencil 240Hz 미세 떨림 흡수).
   - streamline: 0.65 → 0.7 (손떨림 보정 강화).

3. 지우개 stroke 종료 시 자동 펜 복귀 (사용자 요청):
   - eraser pointerup/cancel 시 tool='pen' set + cursor null.

Apple Pencil 더블탭 도구 토글은 Web 표준 미지원 — iPadOS 가 OS 차원에서 인식해
시스템 동작으로 처리, 페이지엔 이벤트 미도달. 대안 (캔버스 두 손가락 탭, etc.) 은
별도 결정 필요.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:55:36 +09:00
Hyungi Ahn c8360cd58a fix(study): 확대 회귀 진짜 root cause — inline style 의 reactive cursor 가 imperative width 덮어씀
진단 도구로 확정: 펜 클릭 시 canvas:1512×677 정상 → 지우개 클릭 시 canvas:3024×1354
정확히 2배 (= cssWidth × dpr). canvas.style.width 가 사라져 internal pixel 그대로
displayed → 화면상 2배 확대.

원인: <canvas style="...; cursor: {tool === 'eraser' ? ...}"> 가 reactive variable
(tool) 포함한 inline style. tool 변경 시 Svelte 가 inline style attribute *전체*
재설정 → resizeCanvas() 의 imperative `canvas.style.width = ...px` 가 덮어써져 사라짐.
새로고침 / 창 이동 시 resizeCanvas 다시 호출되며 복구되던 이유.

Fix:
- style:cursor / style:width / style:height directive 로 분리. Svelte 의 style:property
  는 해당 property 만 set 하고 다른 inline style 안 건드림.
- 정적 inline style="..." 에서 cursor 제거.
- resizeCanvas 의 imperative style.width/height 라인 제거 (svelte directive 가 처리).

내부 pixel 은 그대로 imperative set 유지 (canvas.width = cssWidth × dpr — DOM
attribute 라 inline style 과 별개).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:48:50 +09:00
Hyungi Ahn 5b2580d96c ops(study): 디버그 패널에 dimension/button click 측정 추가 — 확대 회귀 진단용
추측 fix 8회 모두 미해결. 진짜 측정값 없이 코드 추론만으로는 한계.
디버그 패널에 다음 추가:
- tool / widthMode 현재 값
- button click 카운터 (pen / eraser / width)
- cssWidth × cssHeight (컴포넌트 내부 좌표 시스템)
- canvas getBoundingClientRect (실제 DOM dimension)
- container getBoundingClientRect

button click 시 어느 dimension 이 변하는지 *숫자로* 즉시 보임. 변화량으로 trigger
element 추적 가능. ?debug=1 query 활성.

(stroke 0개 상태에서는 확대 여부 시각 확인 불가 — dimension 직접 측정이 진단 핵심.)
2026-04-27 14:44:38 +09:00
Hyungi Ahn ba04955ee5 fix(study): legacy stroke 도 첫 draw 시점에 refW/refH 자동 fix
이전 commit (33060e9) 의 drawStrokeScaled 가 refW 없는 legacy stroke 는 1배로
그려서 fix 효과가 새 stroke 에만 적용. 사용자 환경의 기존 stroke 129개에는 비례
보정 안 됐음.

Fix: drawStrokeScaled 안에서 refW 없으면 *첫 draw 시점*의 cssWidth/cssHeight 로
자동 set. 그 후 cssWidth 변화 (button click 의 layout shift / 창 크기 조정) 시
ctx.scale 비례 적용. load 시점 cssWidth = 사용자가 그 strokes 를 보는 환경의
dimension 이므로 일관된 기준.

→ 기존 세션 그대로 두어도 button click / 창 이동 시 stroke 위치 보존.
2026-04-27 14:36:49 +09:00
Hyungi Ahn 33060e9358 fix(study): stroke 좌표 비례 보정 — canvas dimension 변화 시 위치 보존
스크린샷 비교로 root cause 확정: 큰 창에서 그린 stroke 가 작은 창에서 보면 캔버스
전체 차지하는 비례 (반대도 마찬가지). stroke 좌표가 cssWidth/cssHeight 절대 px 로
저장되어 cssWidth 변경 시 시각적 위치/비율 깨짐. 사용자 보고 "펜/지우개 누르면
해당 부분 확대" = button click → reactive cascade → toolbar flex-wrap 임계 또는
다른 layout shift → cssWidth 일시 변경 → stroke 좌표 비례 깨짐.

Fix A: stroke 별 reference dimension
- Stroke type 에 refW / refH (그렸을 시점의 cssWidth/cssHeight) 추가.
- inflight 생성 시 refW=cssWidth, refH=cssHeight 저장.
- redraw 의 drawStrokeScaled() 가 ctx.scale(cssWidth/refW, cssHeight/refH) 적용.
  stroke 좌표는 그대로 두고 transform 만 stroke 별. R3 의 Path2D 캐시 그대로 재활용.
- legacy stroke (refW 없음) 은 1배 (load 시점의 cssWidth 기준).
- serializableStrokes 에 refW/refH 포함 — 다른 환경에서 load 시 비례 복원.

Fix B: toolbar layout shift trigger 차단
- flex-wrap 제거 → overflow-x-auto. 자릿수 변화 (99→100) 등으로 wrap 발생 시
  ResizeObserver 가 cssHeight 변경 → 비례 깨짐의 trigger 였음.
- stroke 카운터에 tabular-nums + shrink-0. 자릿수 변화 시 텍스트 width 일정.

새로고침 / 창 이동 시 정상 복귀하던 이유 = 그 시점에 cssWidth 가 새로 결정되며
모든 stroke 가 같은 기준. button click 시 일시적 layout shift 가 trigger 였던 것.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:36:27 +09:00
Hyungi Ahn b45091c8cb fix(study): 펜/지우개 버튼 focus zoom — mousedown/pointerdown 단계 차단
사용자 보고: "펜이나 지우개를 누르면 자동으로 해당 부분 확대". iOS Safari 의 button
focus 가 mousedown/pointerdown 단계에 발동 → 그 영역으로 자동 zoom in. click 시점의
clickThenBlur 는 이미 늦음 (focus 잡힌 후 blur 시켜도 zoom 유지).

Fix: 모든 toolbar / header button 에 onmousedown={preventDefault} +
onpointerdown={preventDefault} 추가. focus 자체가 안 잡혀서 zoom trigger 없음.
click 이벤트는 별도라 onclick 정상 작동. clickThenBlur 는 잔존 케이스 2차 안전망으로 유지.

대상 buttons:
- HandwriteCanvas toolbar: 펜 / 지우개 / 가늘게/보통/굵게 / Undo/Redo/Trash / PNG 저장
- [id]/+page 헤더: 패널 토글 / 다음 시도

IconButton.svelte Props 에 onmousedown/onpointerdown prop 명시 추가 (기존
{...rest} spread 가 button element 로 전달은 됐지만 TypeScript caller 측 type
narrow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:14:01 +09:00
Hyungi Ahn 39f1b0d124 fix(study): backup() 500ms debounce — sync localStorage 가 ㄱ→ㅏ cooldown 의 root cause
사용자 진단 (디버그 카운터): "ㄱ 쓸때 정상, ㅏ 바로 시도하면 down 카운터도 안 늘어남,
시간 지나면 들어감" → 짧은 main thread block.

코드 검토 결과 endStroke 안의 backup() 호출이 동기 I/O:
  localStorage.setItem(key, JSON.stringify({strokes: 73개...}))
stroke 73 × 평균 30점 ≈ 65KB JSON. JSON.stringify + sync localStorage write 합쳐
iPad CPU 에서 50~200ms main thread block. 그 사이 native pointer event queue 적체.
사용자가 그 시간 안에 펜 댔다 떼면 down/up 짝이 깨져 OS 가 입력 무시 → "ㅏ 안 들어감".

Fix:
- backup() 을 500ms idle debounce. 빠른 연속 stroke 시 backup 0회 → main thread
  block 0 → pointer event 적체 없음 → ㄱ 직후 ㅏ 즉시 진입.
- flushBackup() 별도 함수로 분리. onBeforeUnload / onDestroy 에서 pending 강제 실행
  (페이지 unload 시 backup 손실 방지).

이번 fix 후에도 cooldown 잔존하면 OS Apple Pencil Scribble 흡수 가설로 — iPadOS
설정 > Apple Pencil > Scribble 비활성화 필요.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:08:36 +09:00
Hyungi Ahn 7ed94a25df fix(study): Path2D 캐시로 redraw 비용 O(N→1) — ㄱ→ㅏ hang 의 진짜 root cause
사용자 가설 적중: "ㄱ을 그릴때 ㄱ이 다 그려질때까지 다음 입력이 안되는거 아니야?" =
R3 (redraw 누적 frame budget 초과 → main thread block → 입력 적체).

매 RAF frame 마다 모든 stroke 의 perfect-freehand outline + new Path2D 를 재계산.
stroke 73 × 평균 30 점 ≈ 2200 점 outline 매 frame. iPad CPU 에서 16ms frame budget
초과 → next pointermove/down 이벤트가 main thread queue 에 적체 → 사용자 인식상
"ㄱ 다 그려지기 전엔 ㅏ 입력 안 됨".

Fix:
- Stroke 타입에 _path2d / _size 런타임 캐시 추가. 완료 stroke 는 첫 draw 시점에
  outline + Path2D 생성 후 캐시. 이후 redraw 는 ctx.fill(cachedPath) 만 (GPU 가속).
- inflight 만 매 frame 재계산 (점 추가됨).
- effectiveSize (가늘게/보통/굵게 토글) 변경 시 _size mismatch 로 자동 캐시 무효화.

직렬화 안전:
- _path2d / _size 는 `_` prefix 가 marker. backup()/flushSave() 가 serializableStrokes()
  로 {id, points} 만 추출. 서버/localStorage 에 cruft 안 들어감.

기대 효과:
- redraw 비용: O(strokes × points) → O(strokes × 1 ctx.fill) → O(1 GPU fill ×N).
- main thread block 해소 → pointer 이벤트 큐 적체 사라짐 → 다음 stroke 즉시 진입.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:00:13 +09:00
Hyungi Ahn 50e0a78e1a fix(study): Apple Pencil hover (buttons===0) stroke 연장 차단 + ?debug=1 toggle
스크린샷 진단: 사용자 시나리오에서 stroke 자체는 들어가지만 글씨가 흩어지고 ㄱ→ㅏ 가
의도와 다르게 연결됨. 코드 재검토 결과 명백한 누락 — pointermove 가 e.buttons===0
케이스 (Apple Pencil hover, iPadOS 17+) 를 잡지 않아 hover 이동이 stroke 의 점으로
추가됨. ㄱ 그리고 → 펜 살짝 떼고 (hover 모드, pointerup 안 옴) → ㅏ 위치로 hover
이동 → hover pointermove 가 점 push → ㄱ 끝점에서 ㅏ 위치까지 직선/엉킴.

Fix:
- onPointerMove 에서 e.pointerType==='pen' && e.buttons===0 감지 시 stroke 즉시
  finalize: capture release + isDrawing=false + inflight 보존 (pointerup 흐름).
  pointerup 안 와도 hover 모드 = 사실상 펜 떼짐. 다음 stroke 진입 보장.
- onPointerDown 에서도 같은 가드 (hover-down reject) — hover 진입을 stroke 시작으로
  오인 차단.

Diagnostic:
- DBG = import.meta.env.DEV || (?debug=1 query). prod 에서도 사용자 iPad 진단용으로
  디버그 패널 토글 가능. URL 에 ?debug=1 추가 후 reload.
- 디버그 패널 {#if DBG} 로 게이트.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:54:42 +09:00
Hyungi Ahn 8f1c7175d4 fix(study/sources): 모바일 카테고리 진입 — drawer + breadcrumb
증상: 모바일에서 좌측 트리 hidden md:block 으로 숨겨져 정렬된 최근
자료 외에는 원하는 카테고리를 찾기 어려움.

Fix:
- 헤더 아래 모바일 전용 (md:hidden) 카테고리 진입 바:
  · "카테고리" 버튼 (FolderTree 아이콘) — 좌측 drawer 띄움
  · breadcrumb: 전체 / 가스기사 / 01_유체역학 / 01_basics
    각 segment 클릭 시 해당 path 로 즉시 이동
  · 가로 스크롤 (overflow-x-auto) — 깊은 path 도 자연스럽게
- aside 좌측 트리 모바일 drawer 화:
  · mobileTreeOpen state. fixed left-0 top-0 bottom-0 w-72 max-w-[85vw]
  · 백드롭 클릭 / X 버튼 / 카테고리 선택 시 자동 닫기
  · 데스크톱(md+)에선 기존 normal layout 유지
- navigateAndClose 헬퍼 — 카테고리 클릭 시 navigate + close 한 번에
2026-04-27 12:51:43 +09:00
Hyungi Ahn 6e3ce91de6 fix(library): library-neighbors JSONB cast 오류 — EXISTS 서브쿼리로 교체 2026-04-27 12:43:26 +09:00
Hyungi Ahn e92bf3c06b feat(library): 모바일 학습 detail 최적화 + 다음 자료 네비 (PR-E)
[Backend]
- /api/documents/{id}/library-neighbors — 같은 library_path 내
  prev/next 자료 (title_asc 정렬). user_tags 의 첫 @library/* 태그를
  path 로 사용. category='library' 만 응답.

[Frontend]
- routes/documents/[id]/+page.svelte:
  · 마크다운 본문: 모바일 prose-base (가독성), lg+ prose-sm 유지
    + leading-relaxed
  · onMount 시 자료실 자료면 loadNeighbors 자동 호출
  · 모바일 sticky 하단 바 (lg:hidden):
    [< 이전] [✓ 1회독 완료 + 다음 (primary)] [다음 →]
    - 가운데 버튼: POST /read 후 next 자료로 goto. 마지막 자료면
      "1회독 완료 (마지막 자료)" 텍스트 + next 버튼 disabled.
    - 좌/우 버튼: 회독 카운트 안 함, 단순 이동 (이전 자료 / 회독 안 한 다음)
  · 본문 하단 패딩 (lg:hidden h-20) — sticky 바에 가리지 않음
2026-04-27 12:41:43 +09:00
Hyungi Ahn 24bd363beb feat(library): 자료별 손글씨 노트 (PR-D) — iPad 학습 시 옆에 필기
자료실 자료 detail 에 "필기" 버튼 → 본문 아래에 HandwriteCanvas 띄움.
자료당 사용자별 1개 캔버스 (UNIQUE user×document). upsert 방식.

Backend:
- migrations 177~178: document_notes (user_id, document_id, strokes_json,
  canvas 크기) + UNIQUE(user_id, document_id) + 인덱스
- app/models/document_note.py: DocumentNote ORM
- app/api/document_notes.py:
  · GET    /api/documents/{id}/note  — 단건 조회 (없으면 strokes_json=null)
  · PUT    /api/documents/{id}/note  — upsert (PostgreSQL ON CONFLICT)
  · DELETE /api/documents/{id}/note
  · ownership: WHERE user_id=current_user.id (single-user 가정)
- app/main.py: document_notes_router 등록 (/api/documents prefix)

Frontend:
- routes/documents/[id]/+page.svelte:
  · 자료실 자료 (category='library') 의 affordance row 에 "필기" 토글 추가
  · 클릭 시 GET /note 로 strokes 로드 → HandwriteCanvas 본문 카드 아래 마운트
  · 캔버스 onChange → PUT /note 자동 저장 (HandwriteCanvas 내부 3초 idle 디바운스 활용)
  · 60vh / min-h-[400px] 분할. 모바일에선 본문 아래 스크롤로 자연스럽게.
- HandwriteCanvas 재사용 — sessionId prop 에 documentId 전달.
  localStorage 키도 그대로 사용 (자료별로 namespacing).
2026-04-27 12:38:03 +09:00
Hyungi Ahn 877a5f79d1 fix(study): iPadOS callout 메뉴 차단 — selectstart capture + body user-select 강제
스크린샷 root cause: ㄱ stroke 후 iPadOS Apple Pencil Scribble / Apple Intelligence
가 펜 stroke 를 텍스트 선택 제스처로 해석 → "복사하기 / 선택 영역 찾기 / 찾아보기 /
번역" callout 메뉴 등장 → 메뉴 떠 있는 동안 펜 입력이 메뉴 인터랙션으로 흡수되어
캔버스 stroke 차단 (체감상 ㄱ→ㅏ hang). 메뉴 등장 시 페이지 fit 변경이 사용자에겐
"1사분면 확대" 로 인식. 즉 두 증상 모두 같은 root cause.

element CSS user-select:none 만으로는 OS 레벨 Pencil 인식 차단 못 함.

Fix:
- document.addEventListener('selectstart', ..., { capture: true }) — 모든 자식의
  selection start 를 capture phase 에서 가로채기 + preventDefault.
- selectionchange 시 즉시 removeAllRanges — 어떤 경로로든 selection 이 잡히면 해제.
- document.documentElement / document.body 에 webkitUserSelect=none, userSelect=none,
  webkitTouchCallout=none inline 강제. Svelte 컴포넌트 스코프가 닿지 않는 root
  element 가 selection origin 인 케이스 차단.
- onDestroy 에서 모두 원복 (다른 페이지 selection 영향 없음).

OS 레벨 추가 비활성화 옵션 (사용자 직접): iPadOS 설정 > Apple Pencil > Scribble.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:38:01 +09:00
Hyungi Ahn 3cb065c7e3 fix(study): ㄱ→ㅏ hang 다중 안전망 — window pointerup + inflight plain + dbg DEV gate
이전 commit (7f3955c) 의 element-level pointerleave 안전망이 부족 — 펜이 캔버스
영역 *안*에서 hover 해제되면 pointerleave 미발화 (pointerout 만), 캔버스 element
의 setPointerCapture 가 silently 풀린 케이스도 캔버스 element 핸들러로 못 잡음.
isDrawing 락이 영구 → 다음 stroke 진입 거부 → ㄱ→ㅏ 회귀 잔존.

A. window 레벨 pointerup/pointercancel 안전망 (핵심)
  - window.addEventListener('pointerup'|'pointercancel', onWindowPointerEnd).
  - onWindowPointerEnd 가 isDrawing && pointerId == activePointerId 시 endStroke 호출.
  - 캔버스 element 의 capture 가 풀려도 window 에는 거의 항상 도달 → 락 영구 해제.

B. inflight 를 $state 에서 plain 변수로
  - Svelte 5 deep proxy 가 매 pointermove 의 coalesced push 마다 reactive notify.
    60Hz × 8~12 coalesced = 480회/초 의 reactive trigger 가 onPointerMove 핸들러
    실행 시간을 누적시켜 native event queue 적체 → capture race 가능성 증가.
  - UI 는 redraw 함수가 호출 시점에 inflight 직접 read 하므로 reactive 불필요.
  - dbgInflightPts $derived 제거, 패널은 inline `inflight?.points.length` 사용.

C. dbg state mutation DEV 게이트
  - DBG = import.meta.env.DEV 상수. 모든 dbg = ... 호출을 if (DBG) 로 감쌈.
  - prod 빌드에서 Vite 가 if (false) ... 를 DCE → mutation 비용 0.
  - pointerleave 의 capture 활성 가드는 DBG 와 무관하게 항상 적용 (실제 안전망 로직).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:31:24 +09:00
Hyungi Ahn a428b2e679 feat(study): /study/sources 학습 hub 신설 — 자료 학습 페이지
기존 /library 의 회독 UI 를 학습 hub 로 분리. 학습 의도 인터페이스를
공부(/study) 트랙에 모아 자료실(library) 의 일반 자료 관리와 분리.

신규:
- /study (hub): "자료 학습" / "손글씨 필사 세션" 두 카드 메뉴.
  Phase 2~ 예정 항목 (모바일 카드 / 퀴즈 / SRS) 안내.
  기존 /study → /study/write 자동 redirect 제거.
- /study/sources (자료 학습):
  · 좌측 트리: /api/library/tree 활용. 노드별 회독 안 본 카운트
    (예: "3 / 12") 표시. 활성 경로 자동 펼치기.
  · 우측 본문: /api/documents/library 활용 (path/sort/unread/page).
    DocumentCard 재사용 — 회독 배지 (안 봄/N회독) 그대로 노출.
  · 안 본 자료만 토글 + 정렬 선택 + 페이지네이션.
  · 자료실 관리 기능 (CRUD/업로드/facet/승인 대기) 제외 — 순수 학습 UI.

backend 변경 없음. PR-A 의 /api/documents/{id}/read* 와 library API 응답
read_count/unread_count 그대로 활용.

기존 /library 페이지의 회독 UI (배지/토글/ReadCounter) 는 일관성 위해 유지.
자료를 어디서 들어가든 회독 가능 (자료실 자료 detail 의 ReadCounter 도 그대로).
2026-04-27 12:25:29 +09:00
Hyungi Ahn 9b20a1815f fix(study): app.html viewport meta 강화 — 인증 미흡 SSR 시점에도 핀치줌 차단
직전 commit (7f3955c) 의 page-level svelte:head viewport meta 는 SvelteKit 의 SSR
인증 redirect 시 학습 페이지 컴포넌트가 마운트 안 되어 head 에 미주입. iPad 에서
페이지 reload 시 root template 의 default viewport (initial-scale=1 만) 만 적용되어
OS 핀치줌이 다시 가능 — "1사분면 확대" 회귀의 잔존 trigger.

app.html 의 default viewport meta 자체를 maximum-scale=1, user-scalable=no 로 강화.
- 페이지/라우트/인증 상태와 무관하게 root 차원에서 보장.
- single-user PKM 이라 시각 접근성 zoom trade-off 적음.
- PDF/이미지 viewer 는 자체 zoom 컨트롤 (PDF.js 내장 + 이미지 모달) 사용 → 영향 미미.
- study/write/[id] 의 page-level svelte:head viewport meta 는 동일 값으로 그대로 둠
  (인증된 사용자 SSR 케이스의 의도 표시 + 이중 정의되어도 무해).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:23:08 +09:00
Hyungi Ahn fb4897e256 feat(library): 자료실 회독 추적 frontend (PR-B)
PR-A backend 위에 사용자 UI:

[ReadCounter]
- frontend/src/lib/components/ReadCounter.svelte 신규
- "1회독 완료" 명시 클릭 → POST /read → 토스트
- 현재 N회독 / 마지막 읽음 (방금/N분 전/날짜) 표시
- ↩ 버튼 → DELETE /read/last → 마지막 1건 취소 (confirm)
- 자동 +1 

[자료 detail]
- routes/documents/[id]/+page.svelte 우측 editor stack 상단에
  ReadCounter 마운트 — category='library' 일 때만
- doc 응답의 read_count / last_read_at 으로 초기값 (추가 fetch 불필요)

[자료실 카드 회독 배지]
- DocumentCard.svelte 우측 메타에 텍스트 배지
  안 봄 / 1회독 / 2회독 / N회독 — 색은 매우 약하게 (오해 방지)
- doc.category === 'library' 만

[안 본 자료만 필터]
- backend: /api/documents/library 에 unread bool 파라미터
  Document.id NOT IN (현재 사용자 회독 doc_id) — scalar_subquery
- frontend: /library 페이지에 토글 버튼 (정렬 옆)
  URL ?unread=true 동기화, activeUnread reactive
2026-04-27 12:19:11 +09:00
Hyungi Ahn 7f3955c020 fix(study): ㄱ→ㅏ hang + 1사분면 확대 회귀 — pointerleave 안전망 + viewport meta
증상:
- ㄱ stroke 후 ㅏ stroke 가 안 그려짐. iOS Safari 가 setPointerCapture 를 silently
  풀어 pointerup 이 캔버스로 routing 안 되는 케이스에서 isDrawing 락 잔존 → 다음
  pointerdown 이 onPointerDown:298 가드 에서 거부.
- 캔버스가 1사분면으로 확대되는 OS 핀치줌. element-level gesturestart 차단이 일부
  iOS 빌드에서 흡수만 되고 줌이 진행.

A. pointerleave 안전망 (HandwriteCanvas.svelte)
  - onpointerleave={endStroke} 복구.
  - endStroke 내 pointerleave 분기: canvas.hasPointerCapture true 면 ignore (정상
    흐름, pointerup 곧 도착). false 면 안전망 finalize → isDrawing 락 해제.
  - capture 가 정상 잡힌 케이스엔 영향 없음 (leave 자체가 안 옴).

B. viewport meta 강화 ([id]/+page.svelte)
  - maximum-scale=1, user-scalable=no 추가. iOS 13+ 에서 OS 핀치줌 원천 차단.
  - 페이지별 meta 라 다른 페이지 접근성 영향 0. zoom UI 는 Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:16:45 +09:00
Hyungi Ahn 49d8f68986 feat(library): 자료실 회독 카운트 추적 (PR-A backend)
자료실 자료를 사용자가 명시적으로 "1회독 완료" 클릭 시 +1 누적.
detail 진입 자동 카운트 . append-only 로그.

데이터:
- migrations 174~176: document_reads 테이블 + 인덱스 2개 (단일 statement 분할)

ORM:
- app/models/document_read.py: DocumentRead (user_id, document_id, read_at)

API (app/api/document_reads.py, /api/documents prefix):
- POST   /api/documents/{id}/read       — 회독 +1
- GET    /api/documents/{id}/read-stats — {read_count, last_read_at}
- DELETE /api/documents/{id}/read/last  — 현재 사용자의 그 문서 마지막 1건만
  · ownership: WHERE user_id=current_user.id AND document_id=:doc_id
  · documents 에 user_id 부재 (single-user). multi-user 전환 시 ownership
    check 추가 필요 — 코드 주석 명시.

응답 확장:
- DocumentResponse: read_count(default 0), last_read_at(default None)
- /api/documents/library: 페이지 N건 한정 LEFT JOIN 으로 read 통계 매핑 (N+1 회피)
- /api/library/tree CategoryTreeNode: unread_count 추가
  · 기존 path_docs 가 ancestor 누적 구조라 그대로 활용 — 하위 경로 합산 자동

규칙 (사용자 명시 — 변경 금지):
  · 같은 날 여러 번 클릭 → 각각 별개 회독
  · 실수 클릭 취소 = DELETE /read/last
  · documents 에 read_count 컬럼 추가 , 로그 기반 COUNT(*) 만

plan: ~/.claude/plans/scalable-chasing-stonebraker.md
브랜치: feature/library-reads (손글씨 트랙과 분리)
2026-04-27 12:08:36 +09:00
Hyungi Ahn 33d4fd39c4 fix(study): HandwriteCanvas Phase 1 polish — 디버그 UI DEV 게이트 + pointerleave 정리 + 지우개 segment 거리
- 라이브 디버그 패널 / build timestamp 를 import.meta.env.DEV 로 게이트.
  prod 번들에서 Vite 가 dead-code-eliminate.
- onpointerleave={endStroke} 바인딩 제거. setPointerCapture 가 잡히면 leave 자체가
  안 오고, 캡처 실패 케이스는 OS 가 pointercancel 로 흘려보냄. 주석과 동작 일치.
- eraseAt(x,y) 단일 점 검사 → eraseSegment(x0,y0,x1,y1) 로 교체.
  distSqPointToSegment 헬퍼 추가. eraserLast 추적 (pointerdown set, move 의 segment
  시작점, end 에서 null). 빠른 지우개 stroke 에서 점 사이 stroke 누락 방지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:03:34 +09:00
Hyungi Ahn f88524495a fix(study): pointercancel 폐기 + multi-touch race 차단 + iOS palm rejection 회복
진단 (사용자 디버그 패널): up:3 cancel:4 — pointerup 보다 cancel 이 더 많음.
iPad OS 가 multi-touch / 시스템 gesture 인식 시 active pen pointer 를
강제 cancel. cancel 된 stroke 가 strokes 에 들어가면서 의도 아닌 짧은
노이즈 stroke 누적 → 사용자 글자 망가짐.

[Fix 1] pointercancel 시 inflight 폐기:
- 기존: cancel 도 endStroke 에서 inflight.points.length >= 1 면 strokes 에 추가
- 변경: cancel 은 inflight = null 로 폐기, scheduleRedraw 만
- pointerup 만 정상 finalize

[Fix 2] isDrawing 중 새 pointerdown 무시:
- multi-touch / 두번째 pen 시도 시 진행 stroke 보호
- onPointerDown 첫줄에 if (isDrawing) return

[Fix 3] document level touchstart/touchmove preventDefault 제거:
- blockMultiTouch 가 touch 이벤트 default 처리 차단 → iOS Safari 자체
  palm rejection 메커니즘 망가뜨려 pointercancel 발생률 증가시킴
- 캔버스의 touch-action: none + 영역 외 일반 동작 허용으로 변경
- 핀치줌 차단은 wheel+ctrlKey (데스크톱) + gesture 이벤트 (iOS) 만 유지
2026-04-27 11:28:38 +09:00
Hyungi Ahn 743b1b1b6a ops(study): 캔버스에 라이브 디버그 패널 추가
추측 fix 그만하고 사용자가 어디서 누락 발생하는지 직접 보도록 좌상단에
실시간 pointer event 카운터 표시.

표시 항목:
- lastType / lastPressure (Apple Pencil 인지, pressure 값 정상인지)
- down / move / up / cancel — 각 이벤트 발생 횟수
- rejType (pointerType 거부) / rejId (pointerId 미스매치 거부) / coalesced
- drawing flag / activePointerId / inflight 점 개수 / strokes 개수

진단 시나리오:
- "ㅏ 가 입력 안됨" — down 카운트는 올라가는데 strokes 안 늘면
  endStroke 의 rejId 또는 inflight 가 1점이라 finalize 거부.
- "type 이 touch" 면 손가락 입력. Apple Pencil 인식 안 되는 환경.
- "rejType 카운트 큼" — pen 외 입력이 다수 들어와서 거부됨.
2026-04-27 11:23:03 +09:00
Hyungi Ahn 0de07e94f3 ops(study): 페이지 헤더에 build timestamp 노출 (캐시 검증용)
사용자가 새 코드로 보고 있는지 옛 캐시인지 즉시 확인 가능하도록
헤더 가운데 "build YYYY-MM-DD HH:MM" 작게 표시.
2026-04-27 11:19:16 +09:00
Hyungi Ahn f004d9b49c fix(study): 획 누락 / 점선 / Safari 팝업 추가 fix
[#1 모든 획이 안 들어옴]
- pointerleave 핸들러 제거 — stale leave 가 isDrawing=false 만들어 다음
  pointermove 가 다 무시되던 핵심 누락 원인 차단.
  pointerup / pointercancel 만으로 finalize.
- 1점 stroke (짧은 탭) 도 strokes 에 보존. length>1 검사 제거.

[#2 점선 stroke (긴 직선이 ........)]
- pushPointWithInterp: 점 사이 거리가 8px 초과 시 중간 점 자동 보간.
  iPad 60Hz pointermove + 빠른 펜 이동에서 sparse point 일 때도 매끈.
- perfect-freehand 옵션 재튜닝:
  thinning 0.4 → 0.25 (얇아지지 않게)
  smoothing 0.62 → 0.85 (sparse point 도 부드럽게)
  streamline 0.5 → 0.65 (손떨림 보정 강화)

[#4 Safari 팝업 가끔 뜸]
- pointerdown 시점에 document.getSelection().removeAllRanges() 강제 clear.
  selectstart preventDefault 만으로 부족한 케이스 (펜이 이미 선택된 영역
  위에서 시작) 방어.
2026-04-27 11:14:18 +09:00
Hyungi Ahn aa2ff7d4bc fix(study): HandwriteCanvas 전면 재작성 — Apple Pencil 입력 파이프라인 통합 fix
기존 문제: 점선 stroke / 연속 입력 누락 / 버튼 focus zoom / Safari 선택 팝업.
원인을 4축으로 분리해서 한꺼번에 fix.

[1] 입력 수집 (PointerEvent 상태머신)
- isDrawing flag + activePointerId 매칭으로 stroke 누락 방지
- pointerdown: 이전 inflight 가 살아있으면 finalize 후 새 stroke 시작
- setPointerCapture (try-catch) — element 외 pointer move 도 받음
- pointerup / pointercancel / pointerleave 통합 endStroke
- pointerType === 'pen' (mouse 도 데스크톱) 만, 손가락 거부

[2] coalesced events
- pointermove 의 e.getCoalescedEvents() 모두 points 에 push
- 빠른 필기에서 sparse point → 점선 현상 방지 핵심
- normalizePressure: 0/비정상 값은 0.5 fallback

[3] 렌더링: perfect-freehand polygon fill
- getStroke(thinning:0.4, smoothing:0.62, streamline:0.5, last:true)
- getSvgPathFromStroke (perfect-freehand README 표준 builder)
  → Path2D → ctx.fill() — anti-aliased polygon
- 1점 케이스: arc fill 폴백
- last: true 항상 (진행 중에도 polygon 닫힘)

[4] autosave 입력 분리
- 3초 idle debounce
- flushSave 는 setTimeout 0 으로 다음 macrotask
- PATCH 응답이 strokes 를 덮어쓰지 않음 (응답 무시, fire-and-forget)

[5] Safari/Chrome hardening
- 캔버스/컨테이너: touch-action: none + user-select: none +
  -webkit-touch-callout: none + -webkit-tap-highlight-color: transparent
- canvas 에 oncontextmenu / onselectstart preventDefault
- 모든 toolbar 버튼: clickThenBlur(fn) + tabindex=-1 + BTN_STYLE
  → button focus zoom 차단 (사용자 보고 "버튼 누르면 화면 확대" 핵심)

[6] resize 정책
- ResizeObserver + window resize/orientationchange 만 트리거
- pointermove 마다 resize 절대 안 함
- DPR 반영 + setTransform(dpr,...) 으로 retina 선명

수정 범위 (사용자 명시): HandwriteCanvas.svelte 만. 다른 영역 무수정.
2026-04-27 11:08:36 +09:00
Hyungi Ahn fd507bf9fd fix(study): button focus zoom 차단 + 점선 stroke 단순 곡선으로 교체
증상 1 (사용자 보고): 펜/지우개/굵기 등 어떤 toolbar 버튼이든 누르면 화면
확대. 창을 옮기면 정상 크기. 다시 누르면 또 확대.
원인: iPad/Chrome 의 button focus 시 자동 zoom (focus 후 layout 변경 또는
브라우저 자체 zoom). 우리 fix 들이 핀치줌만 보고 focus zoom 을 놓침.

Fix 1 — clickThenBlur + tabindex=-1:
- 모든 toolbar/header button 의 onclick 을 clickThenBlur(fn) 로 감쌈.
  click 시 즉시 e.currentTarget.blur() 호출 → focus 안 받음 → zoom 안 일어남.
- tabindex={-1} 추가 — 키보드 포커스 자체 차단.

증상 2 (사용자 사진): 빠르게 그린 stroke 가 점선처럼. perfect-freehand 의
polygon outline 이 sparse point 에서 깨짐.

Fix 2 — perfect-freehand 제거, 단순 quadratic bezier:
- ctx.moveTo + 점-점 사이 quadraticCurveTo 보간 + ctx.stroke() 한 번 호출.
- lineCap/lineJoin round, lineWidth = effectiveSize.
- 압력 효과는 미반영 (단일 굵기) — 안정성 우선. 점선 안 됨.
- 1점/2점 케이스 폴백 (arc / lineTo).
2026-04-27 11:01:48 +09:00
Hyungi Ahn 658d73a041 fix(study): wheel + ctrlKey 차단만 유지 (buffer 변경은 revert) 2026-04-27 10:54:37 +09:00
Hyungi Ahn d629a2b4b8 Revert "fix(study): offscreen buffer canvas + 데스크톱 trackpad pinch 차단"
This reverts commit d81cbfed85.
2026-04-27 10:54:02 +09:00
Hyungi Ahn d81cbfed85 fix(study): offscreen buffer canvas + 데스크톱 trackpad pinch 차단
P1 데스크톱 trackpad pinch 줌 차단 (Chrome/Firefox macOS):
- wheel + ctrlKey/metaKey preventDefault 추가 (페이지 zoom 방지)
- 데스크톱 Chrome 은 gesture 이벤트 미발화, wheel + ctrlKey 만 발화
- 사용자 사진 8854/8855: 모드 토글 사이 trackpad pinch 로 페이지 zoom 발생

P2 iPad 입력 씹힘 — main thread 블록 해소:
- offscreen buffer canvas 도입. 완료 stroke 들은 buffer 에 한 번만
  perfect-freehand getStroke + Path2D fill 로 그림.
- 매 frame 의 redraw 는 ctx.drawImage(buffer) + inflight 만 처리.
- strokes 변경 시만 bufferDirty=true → 다음 redraw 에서 rebuild.
- iPad CPU 에서 33+ stroke 매 frame 재계산이 16ms 초과해 pointer event
  누락하던 문제 해소.

Helper:
- setStrokes(next): strokes 재할당 시 buffer rebuild 자동 마킹.
  모든 strokes 갱신 (snapshot, eraseAt, finalize, undo, redo, clear,
  restoreFromLocalStorage) 에 적용.
2026-04-27 10:50:20 +09:00
Hyungi Ahn 38e916643d fix(study): RAF redraw throttle + autosave 비동기 + gesture document-level
여전히 발생하는 입력 누락 / 지우개 누르면 확대 재시도.

P1 줌 차단 강화:
- gesturestart/change/end 를 document level 로 다시 등록 (element-level
  ongesturestart 가 일부 iPad Safari 빌드에서 미발화)
- touchstart/touchmove 의 e.touches.length > 1 도 preventDefault — gesture
  이벤트 자체가 안 들어오는 경우의 핀치 zoom 백업 방어

P2 입력 누락 — 입력 루프와 redraw/저장 분리:
- pointermove 의 redraw() 를 RAF throttle (scheduleRedraw) — 60Hz 보다 빠른
  pointermove 에서 매번 redraw 하던 부담 제거. input 처리 즉시, render 는 frame 당 1회.
- autosave: 5 stroke 즉시 flush 제거 — 빠른 필기 중 JSON.stringify 부하 차단.
  3초 idle debounce 만 유지.
- onChange 호출을 setTimeout 0 으로 다음 macrotask 에 ship — 직렬화가
  pointer event 와 충돌 안 함.
2026-04-27 10:38:05 +09:00
Hyungi Ahn 1a560b5fde fix(study): 필기감 + 연속 stroke + 버튼 줌 차단 종합
P1 Safari 줌 차단:
- viewport meta 의 maximum-scale / user-scalable=no 제거 (접근성)
- 페이지 root div 의 ongesturestart/change/end preventDefault — 영역 제한
- 모든 toolbar/header button 에 직접 inline style 적용:
  touch-action: manipulation, user-select/-webkit-user-select: none,
  -webkit-touch-callout: none, -webkit-tap-highlight-color: transparent

P2 연속 stroke 누락:
- onPointerDown: 이전 inflight 강제 finalize 후 새 stroke 시작
- onPointerMove: pointerId 매칭 완화, isPenLike + inflight 만 체크
  (Apple Pencil pointerId 재사용/변경 케이스 방어)
- endStroke: pointerleave race 방어, pointerup/pointercancel 은 무조건 finalize
- 자동 저장 (PATCH) 은 fire-and-forget 그대로 — 입력과 분리

P3 점선 렌더링 품질:
- perfect-freehand 표준 getSvgPathFromStroke + Path2D fill 로 교체
  (직접 quadraticCurveTo 보다 안정적)
- thinning 0.5, smoothing 0.7, streamline 0.55 로 튜닝
- normalizePressure: 0/비정상 값은 0.5 fallback (점선 방지)
- coalesced events 모두 points 에 push (빠른 필기 샘플 간격 좁힘)
- 단일 점 (탭) 은 작은 원으로 폴백
2026-04-27 10:30:12 +09:00
Hyungi Ahn 20d4457a75 fix(study): user-select / long-press 메뉴 차단
증상 (사용자 사진 8856): 펜으로 쓰는데 "복사하기 / Google 으로 검색" 같은
iOS 텍스트 선택 메뉴가 뜸. Safari 가 펜 입력을 텍스트 선택으로 해석.

Fix:
- 캔버스 + 컨테이너 + 페이지 root 에 user-select / -webkit-user-select /
  -webkit-touch-callout / -webkit-tap-highlight-color 적용
- canvas 에 oncontextmenu preventDefault — long-press 후 메뉴 차단
2026-04-27 10:23:01 +09:00
Hyungi Ahn aad21f4daa fix(study): blockGesture TS 어노테이션 제거 (plain JS 페이지) 2026-04-27 10:20:28 +09:00
Hyungi Ahn 1a8667bcec fix(study): iOS Safari 핀치줌 차단 (페이지 줌 발생 방지)
증상 (사용자 사진 8854/8855): 펜 → 지우개 토글 사이에 두 손가락이 캔버스에
닿으면서 페이지 전체가 핀치줌되어 글자가 커보이고 stroke 점들이 띄엄띄엄
표시. undo/redo 도 zoom 된 좌표계라 효과 안 보임.

원인: touch-action: none / manipulation 만으로 iOS Safari 의 visualViewport
스케일 기반 핀치줌이 차단되지 않음.

Fix:
- /study/write/[id] 페이지 단위 viewport meta override:
  maximum-scale=1, minimum-scale=1, user-scalable=no
  (페이지 unmount 시 svelte:head 가 자동 해제)
- document level gesturestart/gesturechange/gestureend 이벤트
  preventDefault — iOS 비표준 gesture 이벤트 차단
- onDestroy 에서 cleanup
2026-04-27 10:19:42 +09:00
Hyungi Ahn 6d8d56e7cb fix(study): 캔버스 디버그 오버레이 제거 (좌표 표시 거슬림) 2026-04-27 10:15:58 +09:00
Hyungi Ahn 3c41a4cab1 fix(study): Notability 수준 필기감 + 연속 stroke race 방어
필기감:
- perfect-freehand 재도입 (effect race 제거됐으니 안전)
  - thinning 0.6, smoothing 0.65, streamline 0.5
  - simulatePressure false → 실제 e.pressure 반영
- outline polygon 을 quadratic bezier 로 연결 → 부드러운 곡선 (직선 segment )
- ctx.fill() anti-aliased

UI:
- 굵기 토글 (가늘게/보통/굵게) — baseSize × {0.6, 1, 1.6}
- Pencil only (touch 차단)

연속 stroke race fix:
- setPointerCapture/release 제거 → 빠른 pointerup→pointerdown race 차단
- onPointerDown 시 이전 inflight 강제 보존 (드물지만 stale 한 경우)
- pointerleave 핸들러는 inflight 가 살아있을 때만 endStroke
- endStroke: inflight 없으면 즉시 return, activePointerId 만 정리

이전 보고: "ㄱ 쓰고 ㅏ 바로 쓰면 ㅏ 가 입력 안됨" 핵심 원인은 stale
pointerleave 가 두번째 stroke 를 강제 종료시킨 것. 위 race fix 로 해결.
2026-04-27 10:12:18 +09:00
Hyungi Ahn df81cd033a fix(study): Pencil 만 인식 + 더블탭 줌 차단
- isPenLike: 'touch' 제거. pen/mouse 만 허용 → 손가락 stroke/지우개 차단
- 페이지/툴바 영역에 touch-action: manipulation → 버튼 빠른 두 번 탭 시
  iOS Safari 더블탭 줌 차단. 지우개/펜 토글 시 화면 확대되던 현상 fix.
2026-04-27 10:00:34 +09:00
Hyungi Ahn 66c6fb6189 fix(study): stroke 사라짐 핵심 버그 + 디버그 표식 제거
원인: \$effect(initialStrokes 동기화) 가 strokes 도 의존성으로 추적함.
사용자가 펜으로 그린 후 strokes 변경 → effect 재실행 → 조건
"initialStrokes.strokes !== strokes" 가 true → strokes 를 옛 initialStrokes
값으로 되돌림 → 새 stroke 사라짐.
지우개 누르면 글자가 커지는 현상도 같은 effect 가 trigger 되며 strokes 가
옛 값으로 reset + canvas 비율 재계산이 겹쳐 발생.

Fix:
- \$effect 제거. 초기 strokes 는 \$state initial value 로 한 번만 set.
  부모가 prop 새 값을 줘도 무시 (사용자 진행 stroke 우선).
- traceText effect 는 명시적 prev 비교로만 redraw 트리거.
- 디버그용 빨간 사각형 / 빨간 strokeStyle 제거. 정상 색 (--text) 복귀.
2026-04-27 09:56:37 +09:00
Hyungi Ahn cf7c82141b fix(study): debug — 좌상단 빨간 사각형 + 빨간 굵은 stroke 강제
stroke 가 안 보이는 원인 격리. iPad 화면에서:
- 좌상단 빨간 50x50 사각형 보임 + 빨간 stroke 보임 → 토큰 색 문제
- 사각형 보임 + stroke 안 보임 → drawStroke / strokeStyle 문제
- 사각형도 안 보임 → redraw 미호출 또는 canvas 자체 가려짐
2026-04-27 09:50:24 +09:00
Hyungi Ahn 85659ce928 fix(study): perfect-freehand 미사용으로 단순 ctx.stroke() 전환 + 좌표 scale 보정
증상: stroke count 는 올라가는데 화면에 그려지지 않음 + 위치 어긋남.

원인 격리 시도:
- perfect-freehand 의 polygon fill 이 일부 환경에서 제대로 그려지지 않는 것으로
  보여 단순 ctx.beginPath/moveTo/lineTo/stroke() 로 갈아치움. lineCap/lineJoin
  'round' + lineWidth=baseSize 로 자연스러운 라인. 압력 효과는 일시 제거.
- getLocalXY 에 scale 보정 추가: canvas.style.width(cssWidth) 와 rect.width 가
  다른 ResizeObserver 지연 케이스에서 좌표가 어긋나지 않도록 비율 보정.

이번 변경으로도 stroke 가 안 보이면 디버그 오버레이의 좌표/크기를 보고
다른 경로 (캔버스 자체 비활성, layer 가림 등) 추적.
2026-04-27 09:00:39 +09:00
Hyungi Ahn 77790d6dc1 fix(study): 캔버스 풀스크린 + 좌측 floating panel + 좌표 디버그
증상: iPad 에서 펜 입력이 안 들어가거나 다른 위치에 그려지는 보고. 원인은
좌우 분할 layout 에서 우측 캔버스 영역이 좁거나 layout 이 stale.

UI:
- /study/write/[id] layout 을 캔버스 풀스크린 + 좌측 floating panel 로 변경
- 헤더에 패널 토글 버튼. 패널 default closed → 캔버스가 화면 거의 전체
- 캔버스 컨테이너에 border-default/30 추가 (영역 가시화)

좌표/입력:
- isPenLike: 'touch' 도 허용 (iPad 일부 빌드에서 Pencil 이 'pen' 으로 안 들어오는 케이스 방어)
- 디버그 오버레이: 캔버스 크기 + 마지막 pointer 좌표/pressure/type 표시
- ResizeObserver 외에 window resize / orientationchange 리스너 추가
- 마운트 직후 RAF×2 후 한 번 더 resizeCanvas (flex 레이아웃 0x0 첫 paint 방어)
2026-04-27 08:50:39 +09:00
Hyungi Ahn df9da33acb fix(study): stroke 렌더링 + 부분 지우개 모드
stroke 가 안 그려지는 이슈 수정 + 사용자 요청한 부분 지우개 추가.

렌더링 fix:
- last:true 항상 (진행 중 stroke 도 양쪽 outline + cap 완성, polygon 닫힘 보장).
  이전엔 inflight 일 때 last:false 라서 outline 한쪽만 그려져 fill 영역 거의 0.
- thinning 0.5 → 0.3 (시작/끝 부분이 너무 얇아지지 않게)
- baseSize default 4 → 6
- pointermove: main 점을 항상 push (coalesced 는 보간 보조)

부분 지우개:
- tool: 'pen' | 'eraser' state. 툴바에 펜/지우개 토글
- eraser 모드: pointer 가 지나가는 stroke 를 점-원 hit-test 로 즉시 삭제
- eraserRadius = baseSize * 4 (최소 16 px)
- 삭제된 stroke 는 undoStack 으로 — undo 로 복구 가능
- cursor: eraser 면 'cell', 펜이면 'crosshair'
- 전체 지우기는 별도 Trash2 버튼으로 분리
2026-04-27 08:43:59 +09:00
Hyungi Ahn a4f470effb fix(study): canvas stroke 색을 --text 토큰으로 + simulatePressure true
문제: dark mode 에서 stroke #111 이 --bg #0f1117 와 거의 같아 안 보임 +
      Apple Pencil pressure 0 케이스 방어 부재.

수정:
- strokeColor 를 마운트 시 --text 토큰 실측 (e4e4e7 등) 으로 갱신
- simulatePressure true 로 변경 — 압력 0 으로 들어와도 속도 기반으로 굵기 보장
- thinning 0.55 → 0.5
2026-04-27 08:38:10 +09:00
Hyungi Ahn 475a542ea3 feat(study): iPad 손글씨 학습 세션 frontend (Phase 1)
PR-2: 자격증/어학 학습 세션 UI. iPad Safari + Apple Pencil 지원.

신규 컴포넌트:
- HandwriteCanvas — perfect-freehand + PointerEvents (압력/tilt) +
  palm rejection (pointerType==='pen') + DPR + touch-action:none +
  stroke 단위 undo/redo + 5초 idle / 5 stroke 자동 저장 +
  localStorage 백업 + PNG snapshot export
- StudyMetaEditor — study_type(certification/language) 토글, 자격증/어학
  분기 메타 입력, 어학 metadata.reading/meaning/unit_type
- SourceTextPanel — 원문 + 어학 메타 read-only 표시
- AssetList — 연결된 audio/video/scan/handwriting 표시 + 재생 + 연결/해제

라우트:
- /study → /study/write 리다이렉트
- /study/write — 도메인 토글 + 빠른 시작 폼 + 세션 목록
- /study/write/[id] — 좌측 메타/원문/asset, 우측 캔버스 (md+ 분할,
  모바일 위/아래)

Layout/Sidebar:
- 상단 nav 에 "공부" 추가 (메모와 뉴스 사이)
- Sidebar 메모/Inbox 섹션에 GraduationCap 아이콘 항목 추가

기타:
- frontend/package.json: perfect-freehand ^1.2.3 (MIT)
- THIRD_PARTY_LICENSES.md 신규 — perfect-freehand MIT 고지

플랜: ~/.claude/plans/scalable-chasing-stonebraker.md (PR-2)
신규 파일 lint:tokens 회귀 0 (기존 잔존 130 그대로).
2026-04-27 08:30:28 +09:00
Hyungi Ahn e8c348ab21 feat(dashboard): Day 4 튜닝 — 임계치 재조정 + deep_summary 안정성 카드
3일 telemetry (599 triage / 555 deep) 기반 임계치 재평가:

1. 에스컬레이션 비율 — 임계치 의미 reframe
   - 기존: >20% 적색 (튜닝 필요) → 항상 적색 (운영 패턴 97%)
   - 신규: <80% 적색 (정책 매칭 실패 증가)
   - 메시지: "safety 정책상 95~100% 가 정상" 보조 표시
   - safety_reference 99.7%, generic 100% (fallback risk_flag), msds 46.2%
     → 운영 정상 패턴 확인

2. Deep summary 안정성 — 신규 카드 추가
   - mode='summary_deep' 의 error_code IS NOT NULL 비율
   - 현재 5.2% (call_failed 21 + parse:ValidationError 8)
   - >5% 적색 임계
   - MLX 호출 timeout / JSON 파싱 실패 모니터

3. triage JSON 건강도, Backlog Suppression — 임계치 유지
   - 현재 0%, 1% — 매우 안정. 보수적 임계 유효.

Backend: TierHealthStack 에 deep_total / deep_err_total 추가
Frontend: 카드 그리드 3열 → 4열 (lg), Day 4 신규 카드.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 08:29:53 +09:00
Hyungi Ahn 95bcdb851b fix(ops): backfill 쿼리에 빈 extracted_text 제외 — 무한 retry 방지
3일 운영 결과 doc 4811, 5181 가 extracted_text='' (빈 문자열) 인데
IS NOT NULL 만 걸려 enqueue → classify_worker 의 not doc.extracted_text
truthy 체크에서 ValueError → max_attempts(3) 도달 → status=failed.
다음 backfill 사이클에서 다시 enqueue 되어 12회 반복, failed 24건 누적.

수정: tier_backfill.py + backfill_tier.py 양쪽 SQL 에
LENGTH(extracted_text) > 0 추가. 빈 문자열 문서는 enqueue 자체에서 제외.

기존 failed 24건 정리 SQL (사용자가 수동 실행):
  DELETE FROM processing_queue
  WHERE stage='classify' AND status='failed'
    AND error_message LIKE '%extracted_text%';

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 08:25:12 +09:00
Hyungi Ahn 10364fbe1b fix(study): refresh assets attr on create to avoid async lazy load 2026-04-27 08:20:29 +09:00
Hyungi Ahn 2df7b24ac9 fix(study): split migration 164 into 10 single-statement files (asyncpg)
asyncpg prepared statement 는 single-command 만 허용. 원래 한 파일이던 study_sessions
스키마(CREATE TABLE x2 + CREATE INDEX x8)를 143~146 분할 패턴 따라 10개로 분리.

  164: CREATE TABLE study_sessions
  165~169: study_sessions 인덱스 5개 (partial)
  170: CREATE TABLE study_session_assets
  171~173: study_session_assets 인덱스 3개

문제: cannot insert multiple commands into a prepared statement
원인: _run_migrations 가 conn.exec_driver_sql 로 단일 prepared statement 실행
2026-04-27 08:18:40 +09:00
Hyungi Ahn 7804f22dce feat(study): study_sessions backend (Phase 1) — 자격증/어학 일반 학습 세션 + assets 연결
iPad 손글씨 필사 / 모바일 암기노트 / 모바일 퀴즈가 같은 데이터를 공유하는
일반 학습 세션 backend. study_type 으로 certification/language 분기.

- migrations/164: study_sessions + study_session_assets DDL + 5 partial indexes
- app/models/study_session.py: StudySession + StudySessionAsset ORM (cascade)
- app/api/study_sessions.py: CRUD + snapshot(PNG) + assets + filter + groups
  - ownership: 모든 endpoint user_id 검증, mismatch 도 404 (정보 누설 방지)
  - 409 중복: UNIQUE(session, document, asset_type, role) 사전 SELECT + IntegrityError 폴백
  - enum 422: study_type / mode / asset_type / role / review_state / order
  - filter: 11개 (study_type, certification, language_code, learning_level,
    subject, topic, review_state, document_id, asset_type, mode, due_before)
  - groups: certification 트리 + language 트리 + has_audio/has_video
  - snapshot: documents.py atomic rename + error_code 패턴 차용
- app/main.py: /api/study-sessions router 등록

plan: ~/.claude/plans/scalable-chasing-stonebraker.md
Phase 1 미사용 필드 (review_state/quiz/ocr/ai_summary/prompt) 는 NULL 허용,
자동 로직은 Phase 2~4 별도 PR 에서 활성.
2026-04-27 08:15:28 +09:00
Hyungi Ahn c6335c9a1e fix(classify): law_monitor skip 분기 복원 + tier_backfill law 제외
PR-B refactor 과정에서 e88640d 의 process() 진입부 source_channel='law_monitor'
skip 분기가 사라져 매일 07:00 신규 법령 분할마다 26B legacy classify(8s) +
26B legacy summarize(10s) + 4B triage(1.5s) 전부 호출되고 있었다.

법령 분리 PR (stateless-churning-raccoon) 의 명제:
  "법령은 외부 source-of-truth + immutable + 자동 재수집 → 다른 수명주기"
와 일치하도록 process() 진입부에 skip 분기 복원. 최소 필드 (ai_domain='법령',
ai_tags=['법령'], importance='medium') 만 세팅 후 return. queue_consumer 의
NEXT_STAGES['classify']=['embed','chunk'] 가 자동 chain 하므로 검색 영향 0.

법령 도메인 AI 산출물 가치 분석:
  - ai_summary: 법령 해석 환각 위험 (ASME/안전 엔지니어 사고 책임 소지)
  - ai_tldr/bullets: 이미 title 이 같은 정보 노출 — redundant
  - ai_inconsistencies: 공식 정합 문서라 100% false positive
  → 비용 (월 ~14분 26B 점유) 대비 가치 음수, skip 합당.

tier_backfill.py 도 함께 수정:
  - DOMAIN_PRIORITY 에서 ('law', source_channel='law_monitor') 항목 제거
  - safety 필터에 source_channel != 'law_monitor' 추가 (기존 ai_domain LIKE
    'Industrial_Safety%' 매칭 안에 backfill 기 처리한 법령 doc 들이 잡혀
    들어가는 case 차단)
  - 사유: skip 처리될 doc 을 enqueue 하면 야간마다 enqueue→skip→NULL→
    enqueue 무한 루프

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 07:35:27 +09:00
Hyungi Ahn 8427ac886c feat(memo): sync content ↔ memo_task_state on create/update + backfill script
본문에 `- [x]` 로 직접 입력된 체크 항목도 checked_at 가 기록되어 10초 후
자동 숨김 대상이 되도록 create_memo / update_memo 에 sync 로직 추가.

- _sync_task_state_with_content: - [x] 에 checked_at 없으면 현재 시각으로 기록,
  - [ ] 또는 사라진 index 는 state 에서 정리
- scripts/backfill_memo_task_state.py: 배포 이전 기존 노트에 현재 시각 backfill
  (docker compose exec fastapi python /app/scripts/backfill_memo_task_state.py --apply)
2026-04-24 15:40:18 +09:00
Hyungi Ahn a95294ff42 feat(ops): 야간 auto tier 백필 스케줄러 (PR-B 레거시 해소)
6720건 레거시 문서를 야간에 자동으로 tier triage + deep_summary 처리.

app/workers/tier_backfill.py (신규):
- APScheduler 30분 주기 트리거. KST 00:00~06:00 시간대만 실제 enqueue.
- safety > law > manual 우선순위 25건씩 classify 큐 재투입.
- classify 큐 40건 이상 쌓여있으면 MLX 부하 보호로 skip.
- drive_sync / memo / news 는 제외 (plan 스코프 밖 또는 가치 낮음).
- off-switch: settings.ai.tier_backfill.enabled = false 로 전면 중단 가능.

app/main.py lifespan:
- scheduler.add_job(tier_backfill_run, interval=30min, id='tier_backfill').
- AsyncIOScheduler 이미 timezone='Asia/Seoul' 로 설정돼 tier_backfill 내부의
  zoneinfo('Asia/Seoul') 와 일치.

수치 예상: 야간 6시간 × 2회/시간 × 25건 = 150건/야간.
6720 / 150 = 약 45일이면 전체 레거시 소화.
MLX 부하 제어가 가장 강한 관심 — R2 backlog guard 와 중복 안전장치.

운영 중 과부하 감지 시: config.yaml 에 `ai.tier_backfill.enabled: false` 만
넣으면 즉시 정지 (재시작 없이 스케줄러가 매번 체크).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:28:28 +09:00
Hyungi Ahn 814882a0fe feat(ops): tier triage 레거시 백필 스크립트
PR-B B-1 배포 이전에 classify 된 6770건 레거시 문서에 대해 ai_tldr /
ai_bullets / ai_detail_summary 등 tier 산출물을 채우기 위한 백필 도구.

사용:
  docker exec hyungi_document_server-fastapi-1 \
    python /app/scripts/backfill_tier.py --domain safety --limit 50 --dry-run
  docker exec hyungi_document_server-fastapi-1 \
    python /app/scripts/backfill_tier.py --domain safety --limit 50 --apply

도메인 필터: safety / law / manual / news / drive_sync / memo

ORDER BY created_at DESC 로 최신 우선. ON CONFLICT DO NOTHING 이라
기존 pending/processing 행 있으면 중복 enqueue 방지.

MLX 26B 단일 Semaphore 경로라 처리 속도 ~1건/분. 50건 ≈ 1시간.
대량 백필은 야간 분할 권장. 이번 세션 Industrial_Safety 50건이
첫 smoke 대상.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:30:31 +09:00
Hyungi Ahn 320c02fe29 fix(memo): bump migration number 161 → 163 (collision with PR-B 161/162)
cherry-pick 시점에 main 이 PR-B B-2 (161_analyze_events_answerability +
162_analyze_events_answerability_idx) 까지 포함해 번호 충돌. fastapi
부팅 시 _parse_migration_files 가 "migration 버전 중복: 161_..." 로
RuntimeError. 163 로 재지정 (schema_migrations 의 기존 161/162 레코드는 그대로 유효).
2026-04-24 12:59:39 +09:00
Hyungi Ahn 9d344c87ea feat(memo): auto-hide completed tasks after 10s with toggle
체크박스 체크 후 10초 경과 항목을 대시보드 핀 메모 / /memos 에서
자동 숨김, 메모 푸터 "완료 N개 보기" 버튼으로 토글.

- migration 161: documents.memo_task_state JSONB — {"<idx>":{"checked_at":"ISO"}}
- PATCH /memos/{id}/tasks/{task_index} 전용 엔드포인트:
  · SELECT FOR UPDATE 로 동시 토글 race 차단
  · task_index drift 시 stale state 자동 정리 (400 대신 200)
  · AI 재처리/큐 enqueue 의도적 스킵 + memo_task_toggle_skip_ai 로그
- renderMemoHtml(taskStates, now) → 경과 항목에 memo-task-hidden 클래스
- Svelte 5 $effect cleanup 으로 setInterval 누수 방지
2026-04-24 12:56:55 +09:00
Hyungi Ahn ebc37961e0 fix(memo): prevent title overwrite on checkbox patch
체크박스 토글 같은 {content}-only PATCH 에서 body.title==None 을 무조건
_auto_title(content)로 재생성해 제목이 체크박스 라인으로 덮어씌워지는 버그.
Pydantic model_fields_set 으로 title 전송 여부를 구분해 PATCH semantics 정상화.
2026-04-24 12:56:51 +09:00
Hyungi Ahn e2b32fe9b7 fix(ai): B-1 R2 risk_flag_requires_26b 를 hard escalate 로 승격
실측 발견 (safety 8건 재분류):
- 10574 KRAS (safety_operational) → escalate=true (guard 전 pass)
- 10568 JSA (safety_operational) → escalate=false suppressed=True
- 10570 PPE (safety_operational) → escalate=false suppressed=True
- 동일 도메인인데 4건 중 1건만 26B 처리. 같은 질의 종류 문서가
  누구는 깊이 있고 누구는 짧음 → 사용자 관점 일관성 붕괴.

원인: risk_flag_requires_26b 가 soft escalate 분류 → R2 backlog guard
의 ratio 임계치(0.3) 에 걸림. 방금 classify 8건 enqueue 중 앞선 건들이
deep_summary 큐 채우자 뒤 건들이 전부 suppress.

수정: HARD_ESCALATE_REASONS 에 risk_flag_requires_26b 추가. safety/
health/chemical 등 도메인 정책 기반 escalate 는 절대 억제하지 않음.
soft 영역은 여전히 남아있음: self_declare (4B 자가선언), deep_requested
(recommend_deep_summary). 이 둘만 backlog guard 가 억제 대상.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:33:12 +09:00
Hyungi Ahn 93a687a51d fix(ai): B-1 deep_summary 잘린 응답 field-level regex fallback
_parse_outermost_json 도 열린 문자열 중간에 응답 끊기면 실패.
실전 MLX 응답이 entities_confirmed 내부 문자열에서 종료되는 패턴이라
detail/tldr/bullets/inconsistencies 전부 손실되던 이슈.

_regex_extract_fields helper 추가: "key":"value" 쌍 개별 매칭으로
앞쪽 완결된 필드만이라도 건진다. detail 이 응답 앞부분에 있어 잘림
지점보다 앞이면 성공.

순서:
  1. _parse_outermost_json (brace balance)
  2. parse_json_response (기존 regex)
  3. _regex_extract_fields (field-level fallback)

entities_confirmed 제거 같은 프롬프트 수정은 PR-A 영역이라 건드리지
않고, PR-B 워커에서 방어. 근본 해결은 p3c_deep_summary 에서 불필요
필드 제거 또는 max_tokens 튜닝을 policy 소유자가 결정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:27:04 +09:00
Hyungi Ahn 154cb1c8bd fix(ai): B-1 deep_summary JSON parser 강건화 (최외곽 JSON 추출)
실측 버그 (doc 10573 산업안전보건법 deep 처리):
- 26B MLX 응답 길이 1131자 (8192 token 한도 미도달) 에서 응답이
  \`entities_confirmed\` 섹션 중간에 잘림.
- parse_json_response 의 regex \`{[^{}]*(?:{[^{}]*}[^{}]*)*}\` 가 1단계
  중첩까지만 매칭 + reversed 순회로 "가장 마지막 valid JSON" 우선 반환.
- 결과적으로 entities_confirmed 내부 객체 (\`{"people":[],"orgs":[],...}\`)
  가 파싱돼 detail/tldr/bullets 전부 손실 → ai_detail_summary 빈값.

수정: deep_summary_worker 에 \`_parse_outermost_json\` helper 추가.
brace balance + 문자열 리터럴 인식으로 첫 '{' 부터 최외곽 '}' 까지 추출.
응답이 잘려 closure 없으면 남은 depth 만큼 '}' 보강 후 재시도 (partial
응답도 최대한 복구). parse_json_response 는 fallback.

이 수정 후 doc 10573 재처리 smoke 필요. entities_confirmed 필드는 정보창
UI 에 안 쓰므로 응답에서 제거하는 프롬프트 조정은 다음 라운드.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:25:01 +09:00
Hyungi Ahn 165b00f917 fix(ai): B-1 subject_domain 매칭 + RoutingDecision.escalate_to_26b 존중
실측 발견 (safety md 8건 tier triage 결과):
1. **분류 오분류**: 본문에 "MSDS" 한 번 스쳐도 msds 도메인 매칭됨.
   개인보호구/중대재해/밀폐공간/산업안전보건법 전부 msds 로 잘못 판정.
2. **RoutingDecision 무시**: PR-A domain_policy 의 high_impact=true 와
   risk_flag_requires_26b 때문에 RoutingDecision.escalate_to_26b=True 이지만
   내 _classify_escalation_reason 이 이걸 안 봐서 escalate=False 로 마감.
   safety/msds/hazard_specific 전부 4B 만 돌고 26B 정책 우회.

수정:
- _match_subject_domain: (a) title 기반 매칭 우선 추가 — 파일명이 의도의
  1차 시그널. (b) 본문 키워드는 **2회 이상 등장**해야 match (single-mention
  오분류 방지). 우선순위도 재배열 (msds 맨 앞 → hazard/safety 뒤로).
- _classify_escalation_reason: routing_decision 파라미터 추가. 4B 자체
  판정 (long_context / low_confidence / self_declare / deep_requested)
  이후 PR-A routing_decision.escalate_to_26b 가 True 이면 그 escalation_reasons
  중 "high_impact" 외의 구체 사유(risk_flag_requires_26b 등) 를 채택.
- _run_tier_triage: routing_decision 을 먼저 계산하여 _classify_escalation_reason
  에 전달. _apply_triage_result 는 routing_decision 을 param 으로 받음
  (중복 계산 제거).

이 변경 후 safety/msds/hazard_specific/incident_report 도메인 문서는 항상
26B escalate → deep_summary 큐. MLX 부하 증가하지만 plan 의도대로 정책 준수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:18:59 +09:00
Hyungi Ahn f872e4666f fix(ai): B-1 envelope.from_stage PR-A enum 값으로 정정
doc 5260 (confidence 0.3 low_confidence 에스컬레이션) 실측에서 발견:
EscalationEnvelope(from_stage='summary_triage') 가 PR-A ValidFromStage
({triage, summarize_short, advice_trigger, classify, night_sweep, ask_pre,
unknown}) 에 없어 ValueError 발생 → 모든 deep_summary enqueue 가 envelope
생성 단계에서 터짐. tldr/bullets 기록은 envelope 실패 전에 완료되어 영향
없음 (try/except 가 classify 전체는 보호).

P3a short summary 에서의 에스컬레이션 의미에 맞춰 'summarize_short' 로 변경.
내부 task 이름 (SUMMARY_TRIAGE_TASK = 'p3a_short_summary') 는 analyze_events.
prompt_version 기록 전용이라 그대로 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:04:47 +09:00
Hyungi Ahn 04f9eb6582 feat(ui): B-3 정보창 tier 자동 표시 + 대시보드 3종 카드
정보창 (AnalysisPanel):
- doc prop 추가. doc.ai_tldr / ai_bullets / ai_detail_summary / ai_inconsistencies
  있으면 버튼 없이 자동 렌더 (Section A).
- tier 배지 (triage=흰 / deep=파랑) + tldr + bullets + detail 계층 카드.
- inconsistencies kind 별 아이콘: version_drift=Calendar / procedure_conflict=
  GitBranch / source_conflict=Quote / missing_basis=HelpCircle. warning 톤.
- 기존 "고급 분석" 버튼 (/documents/{id}/analyze 4층 응답) 은 Section B 로 유지.

AIClassificationEditor:
- 제목 옆 tier 배지 ("깊이" accent / "짧음" neutral) — ai_analysis_tier 값 기준.

대시보드 (B-3 3종 카드):
- "에스컬레이션 비율 (24h)": escalated_to_26b / triage_total. 20% 초과 적색,
  1% 미만 회색 (false negative 신호). reason 상위 4개 뱃지.
- "triage JSON 건강도 (24h)": error_code='triage_json_invalid' / triage_total.
  5% 초과 적색 (프롬프트/모델 이슈).
- "Backlog Suppression (24h)": suppressed_reason IS NOT NULL / triage_total.
  10% 초과 주황 (임계치 재조정 신호).

Backend:
- dashboard.py 에 TierHealthStack 모델 + analyze_events 24h 집계 쿼리.
- escalation_by_reason (unnest(escalation_reasons)) + escalation_by_domain
  (subject_domain) 서브 집계.

Frontend types:
- stores/system.ts DashboardSummary 에 tier_health 옵셔널 필드 추가.

UI 는 PR-A shadow 기간에도 tier_health.triage_total > 0 조건으로 조건부 표시 —
데이터가 없으면 카드 자체가 숨겨져 첫 삽입 시 UX 충격 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:38:53 +09:00
Hyungi Ahn 34f79f84f2 feat(search): B-2 evidence LLM → 4B triage 전환 + answerability 컬럼
Plan 본래 의도: 근거 선별은 4B, 합성은 26B.

- evidence_service: LLM 호출을 primary(26B MLX) → triage(4B Ollama) 로 전환.
  Ollama concurrent 가능하므로 get_mlx_gate() 제거. synthesis 는 여전히
  llm_gate Semaphore(1) 경유로 MLX 보호.
- prompt_version v3-evidence-triage bump (synthesis 프롬프트 자체는 v2-600char
  그대로, evidence LLM 경로 변경을 분리 추적).
- migrations 161/162: analyze_events 에 answerability / partial_basis /
  suggested_query_count 컬럼 + partial index. /ask 는 이미 ask_events 에
  completeness (full/partial/insufficient) 기록 운영 중이므로, analyze_events
  쪽은 향후 문서 분석에서 answerability 개념 도입 시 활용 예비.
- telemetry record_analyze_event 에 answerability / partial_basis /
  suggested_query_count 파라미터 확장.

기존 /ask 3-state completeness 로직 (classifier_service + 7-tier gate) 은
그대로 유지 — 이미 Phase 3.5a 에서 완성된 상태. B-2 는 LLM 부하 재분배와
관측성 확장에 집중.

MLX 부하 감소 효과: 이전엔 쿼리 1건당 evidence(26B) + synthesis(26B) 2번
MLX 호출. 이제는 evidence(4B Ollama) + synthesis(26B MLX) 로 MLX 호출 절반.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:33:32 +09:00
Hyungi Ahn 6fdc48e5b6 feat(ai): B-1 summary tier 분할 — triage(4B) + deep_summary(26B)
PR-A policy 레이어를 재사용하여 classify_worker 에 tier triage 경로를 추가.
Legacy ai_summary / ai_domain / ai_suggestion 은 유지 (회귀 0), tldr/bullets/
detail/inconsistencies 는 별도 필드로 분리.

Migrations (156~160):
- 156 documents: ai_tldr, ai_bullets, ai_detail_summary, ai_inconsistencies,
  ai_analysis_tier 5컬럼
- 157 process_stage 에 'deep_summary' ADD VALUE 단독 (Postgres 동일 트랜잭션
  제약 회피)
- 158 processing_queue.payload JSONB (envelope 전달)
- 159 analyze_events 에 tier + suppressed_reason
- 160 suppressed_reason partial index

Models/ORM:
- Document: 5컬럼 Mapped 추가
- ProcessingQueue: deep_summary enum 확장 + payload 필드, enqueue_stage 에
  payload 옵션
- AnalyzeEvent: PR-A shadow 6컬럼 + PR-B tier/suppressed_reason

Workers:
- classify_worker: 기존 legacy 경로 뒤에 _run_tier_triage 추가.
  - _match_subject_domain(doc, text): source_channel + 본문 keywords + ai_domain
    prefix 로 PR-A policy 의 subject_domain 이름 결정 (category 매칭 금지).
  - R1 TriageOutput pydantic + JSON 깨짐 fallback (triage_json_invalid).
  - R2 _check_backlog_guard(): 30분 window ratio > threshold OR pending 초과면
    soft escalate suppress. hard escalate 는 통과.
  - R3 _slice_text_ranges(): 260k 초과 시 head 120k + mid 20k + tail 120k 3조각.
  - escalate 시 EscalationEnvelope 구성 + {envelope, subject_domain} payload 로
    deep_summary enqueue.
- deep_summary_worker (신규): queue payload 에서 envelope + subject_domain 읽기 →
  render_26b("p3c_deep_summary", subject_domain) + MLX 호출 (llm_gate Semaphore(1)
  경유) → ai_detail_summary + ai_inconsistencies 저장 + ai_analysis_tier='deep'.
  _filter_inconsistencies 로 허용 kind (version_drift / procedure_conflict /
  source_conflict / missing_basis) 만 통과 — 구매/계약 kind drop.
- queue_consumer: workers dict 에 deep_summary 추가 + BATCH_SIZE=1. next_stages
  는 건드리지 않음 — classify → embed/chunk 는 그대로, deep_summary 는 독립 체인.

Telemetry:
- record_analyze_event: subject_domain / risk_flags / escalation_reasons /
  confidence / policy_version / shadow_would_route_to / tier / escalated_to_26b /
  suppressed_reason 파라미터 확장. classify/deep worker 가 mode="summary_triage"
  또는 "summary_deep" 로 기록.

API:
- DocumentResponse 에 ai_tldr / ai_bullets / ai_detail_summary /
  ai_inconsistencies / ai_analysis_tier 5필드 노출.

Prompts:
- classify.txt 에 DEPRECATED 주석만 추가 (파일 유지 — rollback 경로 보존).
- PR-A 의 app/prompts/policy/p3a_short_summary.txt (4B) 와 p3c_deep_summary.txt
  (26B) 를 그대로 사용. 내 소유의 summary_triage.txt / summary_deep.txt 는 중복
  이라 별도 커밋에서 제거하지 않고 바로 생성 전 삭제.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:22:40 +09:00
Hyungi Ahn 18d684b501 ops(infra): STT Mac mini 이전 + classifier 섹션 복원 (gemma4:e4b)
- docker-compose.yml stt-service 를 profiles:[legacy] 로 이동. GPU 의
  stt-service 는 더 이상 기동하지 않고, fastapi STT_ENDPOINT 가
  Mac mini (기본 100.76.254.116:8804 Tailscale, MAC_MINI_HOST env 로
  LAN IP 주입) 를 바라보도록 변경. 복원 필요 시
  `docker compose --profile legacy up -d stt-service`.
- config.yaml: classifier 섹션을 gemma4:e4b-it-q8_0 으로 복원. 이전
  B-0 커밋이 classifier 를 주석 처리했는데, 실제로는 classifier_service
  가 쓰고 있어 gate 유효. exaone 은 이미 제거됐으니 모델만 gemma4 로
  통일. classifier_service 의 hasattr 체크는 유지되어 fallback 안전.

D13 (STT 이전) drift 를 main 으로 승격. inventory 갱신은 B-3 마감
단계에서 3-tier + STT 경로 묶어서 일괄.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:08:00 +09:00
Hyungi Ahn 490bef1136 feat(ai): B-0 3-tier routing — triage/primary/fallback 슬롯 + AIClient
- config.yaml: ai.models 에 triage (gemma4:e4b-it-q8_0, GPU Ollama,
  context_char_limit=120k, timeout 30s) 신규. primary (MLX gemma-4-26b)
  는 에스컬레이션 전용 역할 명시. fallback 을 gemma4:e4b 로 통일
  (exaone 제거 이미 반영). classifier/verifier 는 optional 유지,
  vision 은 optional 로 완화 (미사용 정리 준비).
- core/config.py: AIConfig 에 triage 필드 추가, vision 은 Optional 로
  전환. AIModelConfig.context_char_limit + DeepSummaryBacklogConfig
  (R2 backlog guard 임계치 ratio 0.3 / pending 5 / window 30min)
  스키마 신설. load_settings 가 models.get("vision") graceful.
- ai/client.py: call_triage / call_primary / call_fallback 3-tier
  진입점 신규. primary 는 caller 가 get_mlx_gate() 블록 안에서 호출
  해야 한다는 계약 docstring. classify/summarize 는 DEPRECATED 주석
  만 추가, 기존 호출부 (eval runner 등) 를 위해 유지.

PR-B B-0 Day 1. 기존 primary 경로 변경 없음 — 회귀 0 기대.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:05:24 +09:00
Hyungi Ahn 628d886cba fix(policy): mount domain_policy.yaml into fastapi + multi-path loader
배포 검증 중 발견: domain_policy.yaml 이 repo root 에 있지만 fastapi
컨테이너의 build context 는 ./app 이라 COPY 가 포함하지 못함. 결과
load_policy() 가 FileNotFoundError.

1. docker-compose.yml: config.yaml 과 동일 패턴으로 읽기전용 bind mount
   - ./domain_policy.yaml:/app/domain_policy.yaml:ro
2. app/policy/loader.py: _resolve_path 에 4 개 후보 검색 추가 —
   cwd / /app / /app/.. / <this>.parent.parent.parent 순으로 파일 존재
   확인. 첫 매칭 반환. 로컬/컨테이너/다른 배포 환경 모두 호환.

CI: pytest tests/policy/ -q → 98 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:45:10 +09:00
Hyungi Ahn 99672292d3 fix(policy): use container-compatible imports (drop app. prefix)
프로덕션 컨테이너는 /app 을 cwd 로 실행하고 import 는 `from api...`,
`from core...`, `from workers...` 처럼 무접두 스타일을 사용한다.
PR-A 내부 import 가 `from app.policy...`, `from app.ai.envelope` 로
되어 있어서 컨테이너에서 ModuleNotFoundError 발생.

변경:
- app/policy/*.py: `from app.policy.X` → `from policy.X`
- app/services/prompt_versions.py: lazy import 도 `from policy.prompt_render`
- app/ai/envelope.py: 영향 없음 (내부 import 없음)
- tests/policy/*.py: 모두 `from policy.X` / `from ai.envelope` 로 통일
- tests/policy/conftest.py: 로컬 pytest 용 sys.path.insert(app/) 추가
  (MacBook 에서 repo-root 기준 실행 시 app/ 를 package root 로 취급)

CI: pytest tests/policy/ -q → 98 passed (로컬, 동일 결과)
프로덕션: docker exec fastapi python -c "from policy.loader import load_policy" → OK

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:42:24 +09:00
Hyungi Ahn c9e8dd0ba1 fix(db): split migration 153 for asyncpg single-statement limit
asyncpg exec_driver_sql 이 prepared statement 로 multiple commands 를
허용하지 않아 배포 시 PostgresSyntaxError: cannot insert multiple commands
into a prepared statement 로 init_db() 실패.

153 를 단일 ALTER TABLE (10 ADD COLUMN) 로 축소하고 2 partial index 를
154/155 로 분리:

- 153_analyze_events_shadow.sql: ALTER TABLE ADD COLUMN (단일 statement)
- 154_analyze_events_shadow_idx_ts.sql: idx_analyze_events_shadow_ts
- 155_analyze_events_policy_violation_idx.sql: idx_analyze_events_policy_violation

배포 test: GPU fastapi 컨테이너 재빌드 후 init_db 가 153/154/155 세 파일을
순차 적용 (asyncpg prepared statement 1 파일 1 문).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:38:40 +09:00
Hyungi Ahn ba97766d45 feat(policy): INV-1~6 테스트 + loader/audit/envelope/shadow 검증
tests/policy/ 7개 테스트 파일 + conftest + __init__. 98 tests passed.

커버:
- test_policy_loader_schema.py (9) — yaml 로드, cross-reference,
  unknown flag reject, invalid UI category reject, synthesis_directive
  500 chars 초과 reject
- test_self_declare_add_only.py (4) — INV-1 invariant 엄격 검증
- test_routing_decisions.py (27) — INV-2~6 + low_confidence +
  도메인 × 시나리오 parametrize (9 도메인 x 기본 시나리오)
- test_audit_patterns.py (11) — detection_patterns 양성/음성,
  도메인 미스매치, 빈 텍스트 엣지
- test_envelope_contract.py (6) — JSON round-trip, invalid
  from_stage reject, tuple 강제
- test_prompt_render.py (16) — 모든 템플릿 렌더, placeholder 치환,
  policy_version deterministic/yaml-sensitive hash
- test_shadow_logger_inmem.py (5) — record/clear/multiple/extra/
  Protocol 호환

conftest.py: autouse _clear_policy_cache fixture — lru_cache 로 인한
테스트 간 오염 방지. policy fixture 는 repo root domain_policy.yaml 로드.

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:49 +09:00
Hyungi Ahn 301867d0ee feat(db): migration 153 — analyze_events shadow 컬럼
ALTER TABLE analyze_events ADD COLUMN IF NOT EXISTS 로 10개 shadow 컬럼:
subject_domain, risk_flags[], high_impact_task, escalated_to_26b,
escalation_reasons[], confidence, policy_violation, policy_violation_ids[],
shadow_would_route_to, policy_version.

+ 2 partial index:
- idx_analyze_events_shadow_ts (shadow_would_route_to IS NOT NULL)
- idx_analyze_events_policy_violation (policy_violation=true)

전부 nullable, 기본값 NULL. 아무도 쓰지 않음 — PR-B 의 DBShadowLogger 가
writer 추가 예정.

번호 153: 152 는 `feat(category): law` 가 점유 (e88640d).

BEGIN/COMMIT 없음 (CLAUDE.md: _run_migrations 단일 outer 트랜잭션).

answerability / new_facts_count 는 PR-B 의 migration 154+ 가 소유.

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:49 +09:00
Hyungi Ahn 23b8a555c2 feat(prompts): policy templates (p1~p6, 9 files)
app/prompts/policy/*.txt — 4B/26B 정책 템플릿. {forbidden_block} /
{subject_description} / {confidence_threshold} / {context_cap} placeholder
포함. 금지 규칙 하드코딩 0 건.

4B (7): p1_triage, p2_nas_rule, p3a_short_summary, p3b_entities,
p4a_advice_trigger, p4b_retrieval, p6_night_sweep
26B (2): p3c_deep_summary, p4b_synthesis

각 템플릿 공통 구조:
- [System] 역할 선언 + subject_description
- forbidden_block (yaml 에서 도메인별 렌더)
- 작업 규칙
- 출력 형식 (JSON only, escalate_to_26b 포함)
- 에스컬레이션 기준
- [User] 실행시 치환 placeholder (이중 중괄호)

render 호출은 PR-A 에서 아무도 하지 않음 — 자산 배치만. PR-B escalation_service
가 실제 worker 에서 render.

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn b9cc7f0ae1 feat(policy): shadow Protocol + InMemoryShadowLogger
ShadowLogger (runtime_checkable Protocol) — PR-B 가 DBShadowLogger 구현
시 준수해야 할 인터페이스. record_would_route(*, doc_id, decision,
actual_model_used, prompt_version, policy_version, extra=None) → None.

InMemoryShadowLogger — 테스트 전용 in-memory 구현. records/count/clear
inspection helpers. Protocol 호환 (isinstance 통과).

PR-B 책임: app/services/policy_shadow_writer.py::DBShadowLogger(ShadowLogger)
구현 — analyze_events 에 INSERT. DB write 실패 시 WARN 로그만, 본 파이프라인
중단 금지 (shadow 기간 제품 영향 0).

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn f51583f9d6 feat(policy): prompt_render + policy_version hash
app/policy/prompt_render.py:
- render_4b(task, subject) / render_26b(task, subject) — template + yaml
  excerpt 주입. {forbidden_block} / {subject_description} /
  {confidence_threshold} / {context_cap} placeholder 치환.
- policy_version(task) → sha256(yaml_bytes + template_bytes)[:12].
  deterministic — yaml 이나 template 이 바뀌면 hash 변경, analyze_events.
  policy_version 컬럼으로 drift 추적.
- KNOWN_4B_TASKS / KNOWN_26B_TASKS — 잘못된 task 호출 ValueError.
- 미정의 subject_domain 은 fallback_domain.description 사용.

app/services/prompt_versions.py:
- compute_policy_version(task) helper 추가. app.policy 지연 import 로
  worker 경로에 정책 dependency 유입 방지 (런타임 격리).
- 기존 ASK_PROMPT_VERSION / ANALYZE_PROMPT_VERSION 상수 미변경.

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn 5057c48ad3 feat(policy): audit — forbidden pattern detection
check_4b_output_violations(text, subject_domain) → list[str]. Python re.search
기반 (Postgres regex 아님). forbidden_for_4b 에서 해당 subject 에 적용되는
rule 만 선택 후 detection_patterns 순회.

컴파일된 패턴 lru_cache 로 반복 호출 비용 감소. escalate_to_26b=False 인
event 에만 호출하여 policy_violation=true 기록 + under_escalation 재처리
후보로 포획.

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn 3314b44918 feat(policy): decide_routing + INV-1~6 invariants
RoutingDecision frozen dataclass. 6 deterministic invariants (code-level HARD):

- INV-1 self_declare_add_only: deterministic=True & self=False → high_impact 유지
- INV-2 risk_flag_requires_26b: any flag.requires_26b=True → 강제 escalate
- INV-3 context_cap: content_chars > 120000 → long_context escalate
- INV-4 multi_doc: evidence_doc_count >= 3 → multi_doc escalate + multi_doc_dependency flag
- INV-5 risk_flags UNION merge: default + self_declared + derived 전부 합집합
- INV-6 fallback_domain: 미정의 subject → fallback_domain 적용 (routing None 방지)

reason 상수 노출 (REASON_HIGH_IMPACT / REASON_RISK_FLAG / REASON_LOW_CONFIDENCE /
REASON_LONG_CONTEXT / REASON_MULTI_DOC / REASON_FALLBACK_DOMAIN) — 테스트 +
PR-B escalation_service 재사용.

synthesis_directives 는 수집된 risk_flags 의 directive 만 자동 집계 (정렬 고정).

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn b401085518 feat(ai): EscalationEnvelope contract (4B→26B handoff)
frozen dataclass with from_stage / escalation_reasons / risk_flags /
distilled_context / original_pointers / synthesis_directives / user_intent /
draft_hint. JSON round-trip (to_json/from_json). to_system_injection() 으로
26B system prompt 에 주입할 텍스트 블록 생성 (risk_flags + directives +
distilled_context 순).

from_stage 는 whitelist 검증 (triage/classify/summarize_short/advice_trigger/
night_sweep/ask_pre/unknown). tuple 타입 강제 (mutability 방지).

PR-B 의 escalation_service 가 이 계약을 사용. PR-A 는 계약만 정의.

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn d23ea48223 feat(policy): pydantic schema + yaml loader
app/policy/schema.py — DomainPolicy, SubjectDomain, FallbackDomain,
RiskFlag, ForbiddenRule, Escalation, Observability (pydantic v2, frozen).
suggested_ui_category 는 실측 doc_category enum (document|library|news|memo|
audio|video|law) 만 허용. synthesis_directive 500 chars 제한. cross-reference
validator — default_risk_flags 가 미정의 flag 참조 시 ValidationError.

app/policy/loader.py — load_policy(path) + functools.lru_cache.
env POLICY_PATH override, read_policy_bytes() helper (policy_version hash 용).

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn fad73ba88c feat(policy): domain_policy.yaml v1 (safety_health + news)
PR-A 의 Single Source of Truth. subject_domains 9개 (safety_reference/
safety_operational/msds/hazard_specific/incident_report/health_record/
safety_video/news_item/news_digest_request) + fallback_domain + risk_flags 10개
+ forbidden_for_4b 6 카테고리 + escalation 임계값 + observability.

Axis 원칙 (feedback_category_vs_ai_domain_axis.md):
- subject_domain 매칭 키 = source_channel/keywords/tags/ai_domain
- documents.category 는 UI 축 (매칭 키로 사용 금지)
- suggested_ui_category 는 OUTPUT 매핑 (분류 제안용)

Scope: safety_health + news 만. 소설은 별도 정책으로 분리.

plan: ~/.claude/plans/wise-gliding-hippo.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:34:48 +09:00
Hyungi Ahn ddfcdbb68a fix(documents): frontend 에 category URL param 지원 추가
`/documents?category=law` 같은 URL 이 프론트에서 무시되던 버그 — `+page.svelte` 의 filter state 에 `category` 가 빠져 있어 API 호출 시 `?category=` 가 서버로 전달 안 됐음. 결과적으로 default 목록 (news/law 만 제외한 전체) 이 반환됐다.

Sidebar '법령 알림' 버튼 (e88640d) + API `category` 필터 (§§2A) 는 이미 반영됨 — 프론트 middleware 만 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:20:21 +09:00
Hyungi Ahn e88640d3d8 feat(category): law 카테고리 분리 — enum + backfill + classify skip
- migrations/152: ALTER TYPE doc_category ADD VALUE 'law' (DDL only; PG16 단일-트랜잭션 제약상 backfill 은 별도)
- models/document.py: Enum 에 'law' 추가 (7 활성 + 3 유보)
- workers/law_monitor.py: Document(..., category='law') — 신규 유입부터 세팅
- workers/classify_worker.py: source_channel='law_monitor' early-return + 최소 필드 (ai_domain='법령', ai_tags=['법령'], importance='medium'). AI classify skip — 법령 구조 고정/외부 source of truth/자동 재수집
- scripts/backfill_category.py: law 분기 + WHERE re-target ((source_channel='law_monitor' AND category='document')) + VERIFY cat_law/law_source_count + fail 조건
- api/documents.py: default 목록 제외에 law_monitor 추가 (news 와 동일 패턴)
- api/dashboard.py: documents count FILTER 에 law_monitor 제외 (category_counts.law 는 기존 GROUP BY category 로 자동 노출)
- frontend/Sidebar.svelte: '법령 알림' 버튼 ?source=law_monitor → ?category=law (explicit category 경로가 default exclusion 을 skip)

plan: ~/.claude/plans/stateless-churning-raccoon.md
axis 원칙: category=UI 축, policy/telemetry=source_channel+ai_domain 축 (feedback_category_vs_ai_domain_axis.md)

배포 순서: push → GPU pull → compose up --build fastapi frontend → backfill --dry-run → --apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:14:56 +09:00
Hyungi Ahn 91d0fcd023 fix(ui): document-caddy trusted_proxies 설정 (mixed-content 해소)
document-caddy 가 home-caddy 로부터 받은 X-Forwarded-Proto: https 를
신뢰하지 않고 incoming scheme (http) 로 덮어써 FastAPI 가 받은 proto 가
http 로 인식 → /api/documents 307 Location 헤더가 http:// 로 나가
HTTPS 페이지에서 mixed-content block.

private_ranges 를 trusted_proxies 로 설정해 docker bridge 내부의
home-caddy 가 전달한 X-Forwarded-* 를 보존.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 07:29:45 +09:00
Hyungi Ahn 5cf7952b33 fix(ui): 카테고리 내비 상단 이동 + uvicorn proxy-headers
- +layout.svelte 상단 nav 에 오디오/비디오 추가 (문서/자료실 옆,
  카테고리 계열 그룹). Sidebar 는 §2 에서 추가했던 카테고리
  블록 제거하고 기존 도메인 트리 전용으로 복구 — 상단 nav 와
  중복되고, 사이드바가 카테고리 탐색 1차 진입점으로 적합하지
  않다는 피드백 반영.
- app/Dockerfile uvicorn 에 --proxy-headers --forwarded-allow-ips=*
  추가. FastAPI 의 trailing-slash 307 리다이렉트가 X-Forwarded-Proto
  를 무시해 Location 헤더를 http:// 로 생성 → HTTPS 페이지에서
  mixed-content block (/video 에서 목격). home-caddy → document-caddy
  → fastapi 체인에서 scheme 복구.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 07:28:04 +09:00
Hyungi Ahn a8e24ab114 fix(documents): accept-suggestion 항상 409 버그 + compose 127.0.0.1 바인딩
- accept-suggestion: documents.updated_at != expected stale 검사 제거.
  classify_worker 가 source_updated_at 을 pre-commit 값으로 저장하는데
  SQLAlchemy onupdate 가 commit 에서 updated_at 을 bump → 항상 불일치 →
  승인 영구 불가. payload 교체 검사 하나만으로 core race 는 막힘.
  사용자 직접 편집 감지는 별도 user_updated_at 컬럼 도입 시 재논의.
- docker-compose.yml: postgres/kordoc/fastapi/frontend 포트 127.0.0.1
  바인딩. GPU 서버 로컬에만 있던 drift 를 main 으로 승격. UFW-Docker
  우회 컨텍스트에서 불필요한 LAN 노출 축소.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 07:20:38 +09:00
Hyungi Ahn 31d76edba0 feat(dashboard): §4 — 카테고리/제안/queue lag 카드 + docs/categories.md
frontend +page.svelte:
- 4-card 메인 row 아래 새 row 추가: 자료실/오디오/비디오 (category_counts) +
  자료실 제안 (library_pending_suggestions). 제안 ≥1 일 때 warning 색 + /library 링크.
- buildPipelineRows 가 pipeline_status (24h 누적) + queue_lag (현재 시점) 머지.
  queue_lag.oldest_pending_age_sec 가 600초 초과면 stage 라벨 옆에 경과시간 표시.
- STAGE_ORDER/LABEL 에 stt/thumbnail 추가 (§3 신규 stage 자동 커버).

docs/categories.md (신규):
- 6 활성 + 3 유보 카테고리 정의 + 저장 경로 + 처리 파이프
- 역할 분리 원칙 (category / user_tags @library/ / facet_doctype / ai_suggestion)
- 업로드 경로 매트릭스 (web/NAS/collector/UI)
- video 채널별 정책 표 (web 거부 vs NAS quarantine)
- 업로드 한도 + error_code 7종 표
- orphan 임시파일 cleanup 정책

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 07:09:37 +09:00
Hyungi Ahn cec464ae2d fix(media): §3 ship-readiness — stt preload + healthcheck + queue enum + dashboard queue_lag
stt:
- services/stt/server.py: lazy → eager preload in FastAPI lifespan.
  STT_PRELOAD=0 으로 lazy 강제 가능 (개발/테스트). preload 실패해도
  프로세스는 살아 있고 /ready false 로 남아 healthcheck 가 unhealthy 처리.
- docker-compose.yml: healthcheck /health → /ready. /health 는 단순
  liveness 라 모델 미적재 상태도 healthy 로 잡혀 운영 신호 부적합.

queue ORM:
- app/models/queue.py: process_stage enum 에 'stt'/'thumbnail' 추가 +
  create_type=False (migration 150/151 가 DB enum 확장 담당). 이게
  없으면 stt_worker INSERT 시 SQLAlchemy 가 enum value 를 거부.

dashboard 강화 (§4 선제, §3 신규 stage 까지 자동 커버):
- app/api/dashboard.py: category_counts + library_pending_suggestions +
  queue_lag (stage 별 pending/processing/failed + oldest_pending_age_sec).
- frontend/src/lib/stores/system.ts: QueueLag 타입 + DashboardSummary 확장.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 07:04:52 +09:00
Hyungi Ahn 8f25d396df feat(upload): §4-독립 — error_code 체계 + .uploading orphan cleanup + 진행률/abort UX
plan: ~/.claude/plans/luminous-sprouting-hamster.md §4 (1GB/stt/dashboard 외 독립 항목)

backend:
- _upload_error(status, code, msg) 헬퍼 정의 (§3 가 호출만 추가했던 누락 수정).
  detail = {error_code, message} — 프론트가 error_code 로 분기.
- upload_document 의 모든 HTTPException 을 _upload_error 로 전환:
  body_too_large / invalid_input / empty_file / unsupported_codec / internal
- ClientDisconnect → 499 network_abort + 임시파일 정리.
  asyncio.TimeoutError → 408 upload_timeout.
- 쓰기 중 .uploading 임시명 → 완료 후 staging.replace(target) atomic rename.
  → 프로세스 크래시 잔존물은 cleanup_orphan_uploads 가 수거.
- file_watcher SKIP_EXTENSIONS 에 .uploading 추가 (오해 픽업 방지).

cleanup scheduler:
- workers/upload_cleanup.py 신규. 10분 주기로 Inbox 하위 *.uploading 중
  mtime > orphan_max_age_sec(3600) 인 파일 삭제.
- 최근 3회 (≈30분) 누적 삭제 수가 cleanup_warn_threshold(10) 이상이면
  WARNING 로그. in-memory deque (재시작 시 리셋) — 집요한 이슈만 잡는 목적.
- core/config.py UploadConfig 에 두 임계치 필드 (defaults — config.yaml override 무관).

frontend:
- api.ts: ApiError 에 optional errorCode/errorMessage 필드 (detail string 유지로
  기존 5+ 소비자 호환). parseDetail() 가 {error_code, message} 객체 응답을 풀어
  정규화. uploadFile(path, formData, {signal, onProgress}) XHR 헬퍼 신규
  (fetch() 가 upload progress 미지원이라 XHR). 401 refresh 1회 정책 동일.
- UploadDropzone.svelte 재작성: 진행률 바, 파일별/전체 abort 버튼, 페이지 이탈
  beforeunload 경고, errorCode 별 토스트 메시지 분기 (7 코드 — body_too_large /
  upload_timeout / network_abort / empty_file / invalid_input / unsupported_codec /
  internal). 컴포넌트 unmount 시 진행 중 업로드 abort.

보류:
- max_bytes 1GB 상향 + Caddyfile 1100MB (별도 결정으로 100MB 유지)
- /dashboard 카테고리 카드 (별도 plan)
- docs/categories.md (§1-3 정의 안착 후)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 06:57:02 +09:00
Hyungi Ahn 1e2c004dd4 feat(media): §3 audio STT + video 재생 인프라
plan: ~/.claude/plans/luminous-sprouting-hamster.md §3

스키마:
- migrations/147_audio_segments_table.sql: audio_segments (STT 타임스탬프
  세그먼트)
- migrations/148_audio_segments_idx.sql: (document_id, start_s) idx
- migrations/149_document_media_cols.sql: documents.thumbnail_path +
  needs_conversion
- migrations/150_queue_stage_stt.sql: process_stage += 'stt'
- migrations/151_queue_stage_thumbnail.sql: process_stage += 'thumbnail'
- app/models/audio_segment.py, document.py (thumbnail_path/needs_conversion)

서비스:
- services/stt/{Dockerfile, requirements.txt, server.py} — faster-whisper
  large-v3 GPU 컨테이너. /transcribe (filePath/langs/beamSize) +
  /health + /ready (cuda device_count + model_loaded). NFC/NFD 경로
  resolver (OCR 교훈).
- docker-compose.yml: stt-service 추가 (GPU 1 예약, :3300, NAS ro mount,
  stt_models volume, start_period 300s), fastapi env 에 STT_ENDPOINT.

파이프라인 (의존 §1 category):
- app/workers/stt_worker.py 신규: stage='stt' pickup → STT_ENDPOINT 호출 →
  extracted_text + audio_segments 저장. Timeout 30분.
- app/workers/thumbnail_worker.py 신규: ffmpeg 50% 지점 1장 →
  PKM/Videos/.thumbs/{id}.jpg + thumbnail_path 세팅.
  needs_conversion=true 는 skip.
- app/workers/file_watcher.py 확장: PKM/{Inbox, Recordings, Videos}
  스캔. 확장자→category, audio→stage=stt, video .mp4/.webm→
  stage=thumbnail, video .mov/.mkv/.avi→needs_conversion=true + stage
  없음. settings.roon_library_path prefix skip.
- app/workers/queue_consumer.py 확장: stt + thumbnail workers 등록,
  BATCH_SIZE(stt=1, thumbnail=3), next_stages 에 stt→[classify] 추가
  (audio 는 extract 건너뜀).
- app/Dockerfile: ffmpeg 추가 (썸네일 subprocess 용).

API (의존 §1):
- /api/audio/{id}/segments — AudioSegment ORDER BY start_s
- /api/video/{id}/thumbnail — thumbnail_path FileResponse (쿼리 토큰)
- /api/documents/{id}/file: media_types 에 audio/video mime 포함 (§2
  커밋에 이미 포함). Starlette FileResponse 가 Range 자동.
- upload_document: .mov/.mkv/.avi 웹 업로드 거부 (error_code
  unsupported_codec). NAS 드롭은 file_watcher 가 quarantine 수용.

프론트:
- AudioPlayer.svelte: HTML5 audio + 전사 세그먼트 sticky 패널 + 줄
  클릭 seek. activeIdx 하이라이트.
- VideoPlayer.svelte: HTML5 video direct play + needs_conversion 안내
  카드. poster 는 thumbnail endpoint.
- /audio (목록 grid) + /audio/[id] (플레이어)
- /video (썸네일 grid + 변환 필요 배지) + /video/[id] (플레이어)
- Sidebar.svelte: Mic/Film 아이콘 + audio/video 네비 활성, count
  배지 (§2 /stats/category-counts 재사용).

설정:
- app/core/config.py: stt_endpoint + roon_library_path.

DoD 배포 후 smoke: /ready cuda:true, 회의 mp3 transcribe, audio
extract 없이 classify 진행(queue 회귀), /audio 재생, .mp4 재생,
.mov 웹 400, .mov NAS quarantine, Sidebar 네비 + count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 06:47:36 +09:00
Hyungi Ahn aceb54e586 fix(migrations): 143 asyncpg multi-statement 분리
asyncpg prepared statement 는 single-command 만 지원 (core/database.py
exec_driver_sql 경로). §1 의 143_category.sql 이 4 statement (TYPE +
ALTER + INDEX×2) 였어서 fastapi 부팅 시 asyncpg.PostgresSyntaxError
"cannot insert multiple commands into a prepared statement" 로 실패
→ 컨테이너 restart 루프.

143 을 4 개 파일로 분리:
  143: CREATE TYPE doc_category
  144: ALTER TABLE documents ADD category / ai_suggestion
  145: CREATE INDEX idx_documents_category
  146: CREATE INDEX idx_documents_has_suggestion (partial)

DB 상태는 깨끗 (migration 143 이 부분 적용 안 됨 — asyncpg 가 batch
자체를 reject). schema_migrations 에 143 도 미기록이라 재실행 안전.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:46:00 +09:00
Hyungi Ahn a93d1689d8 feat(documents): §2 카테고리 전용 페이지 + 승인 UI
plan: ~/.claude/plans/luminous-sprouting-hamster.md §2

- GET /api/documents/stats/category-counts — Sidebar/Dashboard 용
  카테고리별 문서 건수 + library_pending_suggestions
- DocumentResponse 에 category / ai_suggestion 필드 노출 (§1 과 동일
  수정, rebase 시 합쳐짐)
- SuggestionReview.svelte 신규 — ai_suggestion.proposed_category='library'
  제안 카드 리스트. 단건 승인/반려 + 체크박스 대량 승인. 409 stale 시
  warning toast + 자동 refetch
- /library 상단에 SuggestionReview 배치 (자료실 + 승인 대기함 겸).
  승인/반려 후 tree/docs/facet 재조회
- Sidebar 재구성: 카테고리 내비(문서/자료실/뉴스/메모/검색) + 자료실
  pending 배지. /api/documents/stats/category-counts 바인딩. audio/video
  자리는 §3 주석 예약

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:36:22 +09:00
Hyungi Ahn 8fdea88676 feat(documents): §1 category enum + ai_suggestion 승인 파이프
plan: ~/.claude/plans/luminous-sprouting-hamster.md §1

- migrations/143_category.sql: doc_category enum (6 활성 + 3 유보) +
  documents.category + documents.ai_suggestion JSONB + 2 idx.
- app/models/document.py: category (Enum, create_type=False), ai_suggestion (JSONB).
- app/prompts/classify.txt: document_type enum 에 7 실무 doctype 추가
  (발주서/세금계산서/명세표/도면/증명서/계획서/시방서) + facet_doctype
  필드 directive.
- config.yaml: document_types 에 7 항목 추가 (worker 검증 통과).
- app/workers/classify_worker.py: FACET_DOCTYPES / LIBRARY_SUGGESTION_DOCTYPES
  상수, facet_doctype 파싱(기존값 미덮어씀), 발주서/세금계산서/명세표
  감지 시 ai_suggestion={proposed_category=library, proposed_path=@library/
  거래/{YYYY}/{doctype}, source_updated_at=doc.updated_at.isoformat(), ...}.
  category / user_tags 자동 전이 금지 (suggestion-only).
- app/api/documents.py:
  · DocumentResponse 에 category / ai_suggestion 노출
  · GET /documents ?category=<cat> / ?has_suggestion / ?proposed_category
    (category 지정 시 기본 news/memo 제외 해제 — §2 승인 UI 계약)
  · GET /documents/library 를 Document.category=='library' 기반으로 재구현
    (path subquery 는 user_tags 유지 — 분류 내부 서가 경로)
  · POST /documents/{id}/accept-suggestion — FOR UPDATE + idempotent no-op +
    dual 409 stale (payload source_updated_at / documents.updated_at) +
    user_tags idempotent append
  · DELETE /documents/{id}/suggestion — idempotent, stale 검사 없음
- scripts/backfill_category.py: dry-run / apply. 매핑(news/memo/@library/else)
  + 3-way 상대 검증 (all_rows==categorized, uncategorized==0,
  cat_library==has_library_tag — 자동 전이 금지 정책 검증).

남은 DoD (원격 배포 후): docker compose up → migration 143 적용 → backfill
apply → smoke (drive_sync 발주서 업로드 suggestion 생성 / category 유지,
accept-suggestion idempotency + 409 stale 두 벡터, /documents?category=library
== /documents/library 건수 일치).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:32:01 +09:00
Hyungi Ahn e861784c86 fix(ocr): align torch/transformers with native venv (0.17.1 호환 확인된 조합)
이전 base image (pytorch/pytorch:2.5.1-cuda12.4) 가 surya-ocr 0.17.1 설치 시
torch 2.11.0 (PyPI CPU wheel) 로 업그레이드되지만 torchvision 0.20.1+cu124 는
유지돼 ABI 불일치 (torchvision::nms does not exist) → OCR 전체 실패.

native /opt/surya-ocr/venv 에서 검증된 조합으로 복제:
- python:3.12-slim base
- torch 2.11.0+cu126 / torchvision 0.26.0+cu126 (PyTorch cu126 index 고정)
- transformers 4.57.6 (5.x 는 surya detection.processor import 에서 실패)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:59:15 +09:00
Hyungi Ahn f8f72ceae2 fix(ocr): Surya 0.17 API + NFC/NFD path normalize
- services/ocr/server.py: surya 0.17.x predictors 기반으로 재작성
  (구 `from surya.ocr import run_ocr` 제거됨 → import error → 빈 텍스트 반환)
- NFC(DB 경로) vs NFD(NFS 파일시스템) 한글 정규화 mismatch 보정
- surya-ocr 버전 0.17.1 고정 (0.6~1.0 범위는 breaking change 노출)
- AIClient.ocr() NotImplementedError 제거 (호출처 0건, extract_worker 가
  ocr-service HTTP 호출을 직접 사용)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:52:19 +09:00
Hyungi Ahn 51a6f7c9af feat(eval): 발주건 단위 baseline 평가 경로 추가
- run_eval.py: --queries-order / --order-groups / --output-order / --debug
  옵션 추가. 기존 legacy CSV 스키마/값 불변 (출력 소비자 보호).
- Tier 1A/1B/2 지표 구현: cross_format_link_success (top-10 공식 +
  top-5 보조, eligible/success 분수), top_5_document_match (guardrail +
  절대 건수), manual_refind_flag (v0 heuristic), chunk_idx_stddev,
  range/page_citation_available capability flags.
- order_groups.yaml: 발주건 3건 매핑 (TKP-26-0114/0132/0112, 10 docs).
- queries_order_baseline.yaml: 12개 질문 (A:4 B:4 C:3 D:1).

plan: ~/.claude/plans/merry-yawning-owl.md
2026-04-20 15:04:39 +09:00
Hyungi Ahn eb9dc94604 feat(search): E.3 — ask synthesis prompt v2-600char bump
한도 400 → 600 자. baseline 관찰(partial avg 168자 / full 10%)에서
길이 제약이 실제 출력 제약이 되는 현상 확인, 절차·비교 카테고리
답변 깊이 확보 목적.

변경 4 라인:
- search_synthesis.txt:17  answer 400→600 characters max
- prompt_versions.py:20    v1-400char → v2-600char (telemetry)
- synthesis_service.py:42  PROMPT_VERSION v1→v2 (cache key 의미론 동기화)
- synthesis_service.py:46  MAX_ANSWER_CHARS 400→600 (hard clip 동기화)

v1 post-tier0 baseline: 225 rows, partial 51% / insufficient 49% / full 0%
(Tier 0 fix 로 full+refused=True 모순 0 건). E.6 는 이 clean baseline 을
compare-against 로 사용.

향후 티켓: PROMPT_VERSION 과 ASK_PROMPT_VERSION 단일 소스 통합.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:02:51 +09:00
Hyungi Ahn c9f766512d feat(eval): run_eval_ask runner 에 X-Eval-Token/X-Eval-Case-Id 전파 추가
배경: Phase 3.5 fix2 로 서버 /ask 는 X-Source=eval 을 받아들이려면
X-Eval-Token 이 EVAL_RUNNER_TOKEN 와 일치해야 함. runner 에 해당 헤더
주입 경로가 없어 eval 호출이 전부 source='document_server' 로 강등됐음.

변경:
- call_ask / call_analyze: eval_token, eval_case_id 인자 추가. 조건부 헤더 주입
- run_eval: eval_token 파라미터 추가
- CLI: --eval-token 플래그 추가 (env EVAL_RUNNER_TOKEN 자동 fallback)
- main(): --source=eval + --eval-token 미지정 조합에 warning 출력
- eval_case_id 는 item id 자동 전달 → ask_events.eval_case_id join 키로 활용

E.6 재측정의 source='eval' 정확 기록 선결 조건.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 09:12:24 +09:00
Hyungi Ahn c82d52e73f feat(eval): E.6 runner + 평가셋 main 복원 (from feat/eval-infra)
selective checkout (not cherry-pick):
- scripts/run_eval_ask.py (RESULT_FIELDS 21 고정, X-Source:eval 헤더)
- evals/ask_analyze_v1.jsonl (300 case = ask 220 + analyze 80)

E.3/E.6 측정 진입점. feat/eval-infra 의 원본은 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 09:10:18 +09:00
Hyungi Ahn 3971cf08d2 fix(search): re-gate Tier 0 — synthesis self-refuse / timeout / empty answer 일관 처리
이전 버그: synthesis LLM self-refuse(status=completed + refused=True) 또는
timeout/parse_failed/llm_error/empty answer 시 grounding/verifier flag 가 0건이라
re-gate 체인이 `else clean` 분기로 빠지며 `completeness="full"` 초기값이 보존됨.
결과: `completeness=full + refused=True + re_gate=clean` 모순 row 생성.

실측: baseline v1-400char (2026-04-17) 223 row 중 24 (10.8%) 해당.
  - LLM self-refuse: 20 (completed + refused=True)
  - synthesis timeout: 4 (timeout + refused=False + empty answer)

수정: re-gate 최상위에 Tier 0 삽입 + 판정 로직을 `_detect_synthesis_failure()`
helper 로 분리. self-refuse 는 `synthesis_self_refuse`, 메커니즘 실패는
`synthesis_failed({status})` 라벨로 구분. no_reason fallback 도 refuse_reason 우선
활용하도록 보강.

테스트: tests/test_synthesis_failure_regate.py — self-refuse / timeout /
parse_failed / llm_error / empty answer / whitespace / valid answer 총 10 case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:29:49 +09:00
Hyungi Ahn 3780c10f39 fix(scripts): verify_upload_size — processing_queue 컬럼 doc_id → document_id 2026-04-17 08:26:08 +09:00
Hyungi Ahn 2f68fd4196 fix(scripts): verify_upload_size case 6 — CL override 대신 실제 body 크기로 슬랙 초과 테스트
httpx 의 h11 레이어가 Content-Length 와 body 길이 불일치를 client-side 에서
LocalProtocolError 로 거절해서, CL 헤더만 override 해 서버 pre-check 경로를
외부에서 격리 테스트하는 것이 불가능했음. 대신 body 자체가 slack 임계치를
초과하는 케이스로 변경 — multipart CL 이 자동으로 `max_bytes * slack_ratio`
를 넘어 서버 pre-check 가 먼저 catch 함.

또한 기존 case 7 (CL 위조) 는 같은 이유로 실현 불가능해 제거. 5 케이스에서
6 케이스로 조정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:25:30 +09:00
Hyungi Ahn da30eca829 feat(scripts): upload size 경계 검증 스크립트 (수동 on-demand)
Phase B 의 스트리밍 size 검증을 외부에서 확인할 수 있는 스크립트.
pytest 인프라가 Phase 0 상태이므로 full test harness 구축을 미루고,
`scripts/verify_upload_size.py` 단일 파일로 경계 케이스를 즉시 회귀 검증.

7 케이스:
- 0 bytes → 400 (정책)
- 1 byte → 201 (happy path)
- max_bytes - 1 → 201 (경계 하)
- max_bytes 정확 → 201 (경계 상)
- max_bytes + 1 → 413 (스트리밍 차단)
- CL slack 초과 (override 헤더) → 413 (사전 차단)
- CL 위조 (작은 헤더 + 큰 body) → best-effort (서버 거절 status 수용)

`/api/config/public` 에서 max_bytes 를 동적 획득. slack_ratio 는 비공개라
스크립트 상수로 1.05 하드코딩 (config.yaml 과 동기화 유지 주석 명시).

Cleanup: 파일명 prefix `__upload_boundary_test__` + ns timestamp 로
실데이터와 격리. 시작 시 pre-cleanup + 각 케이스 직후 + finally 블록 cleanup.

`docker compose exec fastapi python /app/scripts/verify_upload_size.py` 로 실행.
UPLOAD_TEST_TOKEN + DATABASE_URL 환경 변수 필요. scripts/ 는 이미 read-only
volume 으로 마운트돼 있어 배포·재빌드 불필요.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:23:20 +09:00
Hyungi Ahn 3a3cd832f6 fix(scripts): calibrate_ask.py --since/--until datetime 파싱
asyncpg 이 TIMESTAMPTZ 파라미터에 문자열 대신 datetime 객체를 요구
(DataError: invalid input, expected datetime instance, got str).
argparse type=datetime.fromisoformat 로 CLI 단계에서 파싱.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:22:01 +09:00
Hyungi Ahn 1c502328f1 fix(scripts): calibrate_ask.py None 파라미터 타입 추론 실패 해소
asyncpg 이 $N IS NULL 비교에서 Python None 의 타입 추론 실패
(AmbiguousParameterError: could not determine data type of parameter).
None 인 조건은 WHERE 에서 아예 제외 — clauses 동적 조립.
부수 효과: 조건 0개일 때 "TRUE" 반환으로 quiet fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:21:29 +09:00
Hyungi Ahn 0ab1f888fa fix(scripts): calibrate_ask.py SQL ::timestamptz cast 파싱 충돌 해소
SQLAlchemy text() 의 `:name` 파라미터가 PostgreSQL `::type` cast 와
토큰 경계 충돌로 치환되지 않아 `syntax error at or near ":"` 발생.
`:since::timestamptz` → `CAST(:since AS TIMESTAMPTZ)` 로 변경.

Reproduction: --since/--until 옵션 사용 시 모든 집계 쿼리 실패.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:20:46 +09:00
Hyungi Ahn 893ecacc0e ops(migrations): defer 142 NOT NULL until 1주 운영 관찰 후 활성화
migration 142 ALTER COLUMN source SET NOT NULL 자동 적용 방지.
_run_migrations 의 glob('*.sql') 비재귀 → _deferred/ 무시.

활성화 절차 (D7 참조):
- 138~141 적용 + 7일 운영 후 SELECT COUNT(*) FROM ask_events
  WHERE source IS NULL AND created_at > <deploy> = 0 확인
- git mv migrations/_deferred/142_*.sql migrations/142_*.sql
- docker compose restart fastapi (init_db 가 자동 적용)

이유: 새 코드의 source 누락 가능성 empirical 검증 후 lock.
NOT NULL 적용 후 NULL INSERT 시도 시 ask_events 기록 실패 (data loss).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:11:06 +09:00
Hyungi Ahn 1cd985bc55 ops(repo): results/ artifacts/ gitignore (eval calibration outputs)
Phase 3.5 calibration runner (scripts/run_eval_ask.py, calibrate_ask.py)
가 생성하는 jsonl/log/csv 를 repo 에서 제외. reports/ 는 이미 tracked
파일 있어서 전체 ignore 하지 않음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:11:06 +09:00
Hyungi Ahn 5bfbb79641 feat(verifier): Phase 3.5 B2 — numeric_conflict promote (env flag) + Tier 4
VERIFIER_NUMERIC_PROMOTE 환경변수로 numeric_conflict severity 승격 실험.

verifier_service.py:
- _NUMERIC_PROMOTE = os.getenv('VERIFIER_NUMERIC_PROMOTE', '0') == '1'
  (import time 평가 — env 변경 시 process restart 필수)
- _SEVERITY_MAP['numeric_conflict']: env=1 → critical=strong / minor=medium,
  env=0 (기본) → 둘 다 medium (기존 동작 유지)
- direct_negation 은 env 무관 항상 strong (안전장치)

verifier.txt:
- numeric_conflict 정의에 critical/minor 분리 명시 (core quantity vs peripheral)
- "Range values satisfy any answer within range" rule 추가
- severity mapping 갱신: numeric_conflict 분기 명시

search.py re-gate (Tier 1~7 재번호, B2 신규 Tier 4):
- v_strong_numeric = sum(1 for f in v_strong
                         if f.startswith('verifier_numeric_conflict'))
- Tier 4 (신규): g_strong + v_strong_numeric >= 1 + low_conf → refuse
  re_gate value: 'refuse(grounding+verifier_numeric)'
- 원칙 유지: verifier strong 단독 refuse 금지 — g_strong 교차 필수
- 호환성: 기존 re_gate string literals 그대로 유지, 신규 1개만 추가

credentials.env.example: VERIFIER_NUMERIC_PROMOTE=0 (off, B3 통과 후 production 전환)

tests/test_verifier_numeric_promote.py: 4 케이스 (env off / on / explicit 0 /
direct_negation invariant). monkeypatch.setenv + importlib.reload 패턴.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:11:06 +09:00
Hyungi Ahn 2665d4eb60 feat(grounding): Phase 3.5 B1 — unit-aware fabricated_number + bound semantics
Codex adversarial review (no-ship) 반영:

fix1: unit-aware numeric clearing
- _extract_numeric_corpus(): 단위별 bucket dict (exact_by_unit) +
  ranges_by_unit (양방향 + 단방향 bound 통합)
- _within_unit_range / _close_to_unit_pool: 같은 unit 안에서만 매칭
  bare answer 는 보수적으로 range/tolerance 패스 X
- 2-pass cleared_pairs (unit, digits): cross-unit cleared 절대 skip 안 함.
  bare(None) 답변은 unit-anchored cleared 시 duplicate 로 skip
  (콤마 normalize 부산물 보호 — Codex 케이스는 그대로 flag)

fix3: 최대/최소 bound semantics
- _APPROX_PREFIX_RE 에서 최대/최소 제거 (약/대략/거의/얼추 만 strip)
- _BOUND_PATTERN_RE: 최대 N → range (0, N-1), 최소 N → range (N+1, 1e18)
- 경계값 자체는 cleared 대상 아님 ("최대 100명" + answer "100명" → flag)
- bound span 내 숫자는 exact pool 에서 제외

기존 prefix strip / 콤마 / 부터 separator / 단위 동의어 / tolerance 4자리+ /
식별자성 단위 1자리 flag 동작 모두 유지.

tests/test_grounding_fabricated_number.py: 25 케이스 — 기존 17 + Codex
unit-mismatch 3 (won_vs_myeong_range/tol, pct_vs_myeong_range) + bound 5
(최대/최소 boundary/inner/outer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:11:06 +09:00
Hyungi Ahn 99abd287dc feat(scripts): Phase 3.5 — calibrate_ask.py CLI (Q0~Q8 + render + FP CSV)
scripts/calibrate_ask.py — ask_events 집계 + markdown report 영구 도구.

기능:
- argparse: --source / --prompt-version / --since / --until / --eval-split
  (tuning|confirm|all, id 해시 기반 deterministic split) / --run-label /
  --output / --format md|json / --compare-against / --sample-limit /
  --fp-artifacts / --inspect-shape / --dry-run
- 9개 fetcher (모두 read-only SELECT):
  - Q0 defense_layers shape inspect
  - Q1 re-gate tier 분포
  - Q2 max_rerank_score 히스토그램 (bucket × bin)
  - Q3 classifier 혼동행렬
  - Q4 verifier severity 분포 (cast + COALESCE NULL safe)
  - Q5 hallucination_flags top-K (UNION ALL outer wrap, strong/weak 컬럼 유지)
  - Q6 eval golden mismatch (eval_case_id 기반 join + query string fallback)
  - Q7 FP candidate (case A/B/C 분리 + candidate_reason 컬럼 + LIMIT/3 분배)
  - Q8 answer_length p25/p50/p75 분포 (E.3 v1↔v2 비교 축)
- markdown render + json baseline + delta compare (compare-against)
- FP CSV dump (artifacts/fp_candidates_{run_label}.csv) + is_true_fp 공란
- dry-run: tests/calibrate_fixtures/sample_ask_events.json 로 출력 검증
- --threshold-overrides: Step 0 feasibility 통과 후 v2 (현재 stub raise)

read-only 강제: INSERT/UPDATE/DELETE/ALTER/DROP/TRUNCATE 0건.

tests/calibrate_fixtures/sample_ask_events.json: dry-run snapshot fixture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:11:06 +09:00
Hyungi Ahn 09883d0358 feat(ask): Phase 3.5 A0 — ask_events source/eval_case_id + eval auth boundary
- migrations 138~142: source TEXT DEFAULT 'document_server' + eval_case_id TEXT
  추가, 인덱스 2개, backfill, 1주 관찰 후 NOT NULL (140 적용 분리)
- app/models/ask_event.py: source / eval_case_id ORM 필드 (138~141 단계 nullable)
- app/services/search_telemetry.py: record_ask_event 시그니처에 source / eval_case_id
- app/core/config.py: settings.eval_runner_token + EVAL_RUNNER_TOKEN env 로드
- app/api/search.py:
  - X-Source / X-Eval-Case-Id / X-Eval-Token 헤더 수신
  - _resolve_eval_identity(): hmac.compare_digest 로 token 검증, 실패 시 source
    'document_server' 강등 + warning log + eval_case_id=None
  - 두 record_ask_event 호출에 검증된 source/eval_case_id 전달
- credentials.env.example: EVAL_RUNNER_TOKEN= (empty default = 모든 eval claim 거부)
- tests/test_ask_eval_auth.py: 9 케이스 — token 없음/틀림/일치, env 미설정,
  case_id only, non-eval source forces case_id None

trust boundary: 일반 client 의 X-Source=eval / X-Eval-Case-Id 시도는 무시되어
calibration telemetry 오염 불가. eval runner 만 EVAL_RUNNER_TOKEN 으로 인증.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:11:06 +09:00
Hyungi Ahn 0807574986 docs(upload): 업로드 한도 정책 + 책임 경계 + 정책식 문서화
Phase B 로 확립된 업로드 size 정책을 deploy.md 에 섹션으로 정리:
- config.yaml `upload` 블록 (단일 진실 공급원)
- 4 레이어 책임 경계 (home-caddy / FastAPI / /config/public / UploadDropzone)
- 정책식: `proxy max_size ≥ upload.max_bytes * content_length_slack_ratio`
- 다른 배수("1.1배" 등) 혼용 금지
- /api/config/public scope 제약 (민감정보 금지 / 프론트 필수 기준)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:07:24 +09:00
Hyungi Ahn d2aa6c7c41 refactor(upload): 프론트 pre-check 가 서버 공개 설정을 구독하도록 전환
프론트의 `MAX_UPLOAD_BYTES = 100 * 1000 * 1000` 하드코딩 상수를 제거하고
서버 `GET /api/config/public` 응답을 단일 진실 공급원으로 사용.
pre-check 자체는 그대로 유지 (UX 개선 — 대용량 파일을 edge proxy 까지
올리기 전 클라이언트에서 즉시 차단). 값의 출처만 서버로 이동.

변경:
- frontend/src/lib/stores/config.ts 신규 — publicConfig readable store
  * 첫 구독 시 `/config/public` 1회 fetch
  * fetch 실패 시 fallback 100MB 유지 (서버 enforcement 가 본선이라 안전)
- +layout.svelte onMount 에서 prewarm refresh() 호출
- UploadDropzone.svelte 에서 `$derived` 로 store 값을 반응형 구독
  * `maxBytes` / `maxBytesLabel` 을 파생
  * 에러 토스트 문구도 동적 라벨 사용 (`100MB` 하드코딩 제거)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:05:49 +09:00
Hyungi Ahn 7d2e678ea1 feat(upload): 스트리밍 size 검증 + 0바이트 reject + 고아 레코드 방지
기존 `await file.read()` 는 임의 크기 파일을 메모리에 전부 적재한 후 저장해
디스크 고갈 / OOM 공격 벡터 였음. Caddy/home-caddy 프록시 한도에만 의존했고
FastAPI 측 policy enforcement 가 전무했음. 이 커밋으로 서버가 authoritative
으로 강제 집행.

변경:
- `Request` DI 추가 → Content-Length 사전 차단 (max_bytes * slack_ratio 초과 시 413)
- `await file.read()` → 청크 루프 스트리밍 (stream_chunk_bytes 단위)
- 누적 size > max_bytes 시 스트리밍 중 413 (Content-Length 위조 방어)
- 0바이트 파일 → 400 reject (정책: 유의미한 문서 ingest 대상 아님)
- 파일 저장 완료 + close 이후 에만 file_hash 및 DB 레코드 생성
- Document 레코드 와 processing_queue 는 단일 트랜잭션으로 묶고,
  DB 예외 시 session rollback + partial file unlink 로 원자적 정리
- 예외 시 `except Exception` 으로 cleanup (BaseException 계열은 의도적으로 패스)

설정 값: config.yaml `upload.{max_bytes, content_length_slack_ratio, stream_chunk_bytes}`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:03:43 +09:00
Hyungi Ahn 8622a97e7d feat(upload): backend-owned upload size contract + public config 엔드포인트
업로드 크기 한도를 프론트 하드코딩이 아닌 서버 config 의 단일 진실 공급원
으로 이동. 프론트는 Phase B 후속 커밋에서 이 값을 읽어 pre-check UX 에 사용.

- config.yaml 에 `upload` 블록 추가:
  * max_bytes (authoritative policy)
  * content_length_slack_ratio (multipart 오버헤드 여유)
  * stream_chunk_bytes (스트리밍 IO 단위)
- app/core/config.py 에 UploadConfig pydantic 모델 + Settings.upload 필드
- app/api/config.py 신규 — GET /api/config/public 엔드포인트
  * 민감정보 없는 프론트 필수 설정만 노출
  * 범용 서버 설정 공개 창구로 확대 금지 (docstring 명시)
- /api/config 를 setup redirect bypass 에 추가 (초기 setup 전에도 조회 가능)

이 커밋 자체는 기존 upload 동작에 영향 없음. 후속 커밋에서 enforcement +
프론트 구독을 연결.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:02:19 +09:00
Hyungi Ahn 6a187dbccf fix(upload): 용량 초과 안내를 실제 감시 경로(PKM/Inbox)로 정정, UI에서 SLA 숫자 제거
file_watcher.py:33 이 `Path(settings.nas_mount_path) / "PKM" / "Inbox"` 만
rglob 재귀 스캔함. 그러나 UI 문구는 "NAS의 PKM 폴더" 로 넓게 안내해
사용자가 PKM 바로 아래 다른 폴더(Reports, Archive 등) 에 파일을 두면
조용히 실패하는 silent dead end 가 생기던 문제를 정정.

또한 "5분 이내 자동 인덱싱" 같은 단정적 시간 약속을 제거. watcher 주기
(5분) 와 후속 처리 큐(extract/classify/embed) backlog 는 별개이며,
감시 주기만 5분이지 처리 완료가 5분 내라는 뜻이 아님. 숫자는 운영 지식
이지 UX 계약이 아니므로 UI 에서 제거하고 "감시 주기와 처리 대기열
상황에 따라 반영 시점은 달라질 수 있습니다" 로 정직하게 표현.

주석에서 `home-caddy` 외부 인프라 이름도 제거. 추후 Phase B 에서 이
한도는 서버가 내려주는 단일 계약값으로 이동 예정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 07:53:25 +09:00
Hyungi Ahn afd592c85c fix(upload): MiB/MB 단위 혼용 해소 + 용량 초과 스킵 토스트 반영
Caddy `request_body max_size 100MB`가 go-humanize SI(100,000,000 바이트)로
파싱되는데 클라이언트 pre-check는 `100 * 1024 * 1024`(104,857,600 바이트, MiB)로
비교해 100,000,001–104,857,600 바이트 구간 파일이 사전 차단을 통과한 뒤
서버에서 413을 받던 문제를 수정. 표시 라벨도 `/1024/1024`로 나누고 'MB'라
적어 경계값 파일이 "100MB 초과 … (100.0MB)" 같은 모순 문구를 노출했음.

요약 토스트가 사전 차단된 파일(`tooLarge`)을 카운트에서 제외해 드롭 수량과
불일치하던 문제도 함께 정리. `N건 용량 초과 스킵`을 tail로 붙이고, 전부
스킵된 경우엔 추가 토스트 없이 기존 에러 토스트만 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 07:29:25 +09:00
Hyungi Ahn 8a8096a444 feat(api): Phase E.2 — analyze_events 테이블 + 로깅
POST /documents/{id}/analyze 호출을 DB에 기록. failure mode 분류 + source 식별.

- migrations/137: analyze_events 테이블 (doc_id FK, mode, truncated, layers_returned JSONB, cached, latency_ms, error_code, source TEXT NOT NULL DEFAULT 'document_server', prompt_version)
- ORM: models/analyze_event.py 신규
- services/document_telemetry.py: record_analyze_event() + sanitize_source() 서버 fallback 강제 (enum 외 → unknown, None → document_server)
- app/api/documents.py:
  · X-Source 헤더 + BackgroundTasks 의존성 추가
  · try/finally 패턴으로 성공/cache/에러 모든 exit에서 background insert
  · error_code: None(성공) | not_found | no_text | timeout | llm | parse | missing_summary

Phase F에서 nanoclaude가 X-Source: synology_chat 헤더로 호출하면 source 구분 가능.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:58:58 +09:00
Hyungi Ahn 72b7e65fca fix(migration): asyncpg 다중 statement 분리 (135/136)
a842c65 패턴과 동일. asyncpg는 prepared statement에 단일 SQL만 허용.
- 135: ALTER TABLE만, 세미콜론 제거
- 136: CREATE INDEX 별도 파일

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:54:15 +09:00
Hyungi Ahn 59e38d80b0 feat(api): Phase E.1 — ask_events 측정 필드 확장 (answer_length/prompt_version)
E.3 400→600자 튜닝 전후 비교 + 단계 5 failure mode 분석의 기준 필드 추가.

- migrations/135: answer_length/covered_aspects/missing_aspects/model_name/prompt_version 컬럼 + prompt_version 인덱스
- ORM: ask_event.py에 동일 5개 필드 매핑
- prompt_versions.py: ASK_PROMPT_VERSION="search_synthesis.v1-400char" 상수 + resolve_primary_model() helper
- search_telemetry.record_ask_event: 시그니처에 keyword-only 필드 5개 추가 (하위 호환)
- search.py: refused + success 두 호출사이트에서 새 필드 전달. answer_length는 len(sr.answer or ""), model_name/prompt_version은 상수 모듈 기반

기존 호출 구조(이미 search_telemetry+background_tasks로 DB insert 중)는 유지. 순수 확장 커밋.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:52:14 +09:00
Hyungi Ahn d9c901087b fix(ui): 분석 명칭 '빠른 분석'으로 정직화
현재 /analyze는 12K자까지만 처리하므로 '이 문서 분석'이라는 이름은 오해 여지.
- 패널 제목: '이드 분석' → '빠른 분석'
- 버튼: '이 문서 분석' → '빠른 분석'
- 안내: 12,000자 제한 명시 + '전체 분석 추후 제공' 고지
- truncated 경고: neutral → warning 색상

전체 문서 coverage가 보장되는 '전체 분석'은 다음 iteration에서
백엔드 내부 map-reduce 청킹으로 별도 구현 예정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:44:05 +09:00
Hyungi Ahn ee090089e9 fix(api): D.5 analyze timeout 20→60초, text_limit 15000→12000
doc 5271(29,837자) 등 큰 문서에서 20초 timeout 빈발.
- ANALYZE_TIMEOUT_S: 20 → 60 (safety margin 포함)
- ANALYZE_TEXT_LIMIT: 15000 → 12000 (Gemma 입력 부담 완화)
- 프론트 안내 '10초' → '10~40초 소요'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:39:01 +09:00
Hyungi Ahn 305ae9b322 feat(ui): Phase D.6~D.7 — AnalysisPanel + 문서 상세 통합
D.6: AnalysisPanel 컴포넌트 — 기본 접힌 상태 + '이 문서 분석' 버튼
  - POST /documents/{id}/analyze 호출
  - docId 변경 시 state 완전 리셋 ($effect)
  - 층별 렌더 (근거/해설/사례/요약, 없는 층 생략)
  - 에러 통일 문구 + 재시도/재분석 버튼
D.7: 문서 상세 페이지 우측 editors stack에 Card 래핑으로 삽입
  - AIClassificationEditor 다음, FileInfoView 이전
  - DocumentViewer / PreviewPanel 변경 없음

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:35:04 +09:00
Hyungi Ahn d9caf075e5 feat(api): Phase D.5 — POST /documents/{id}/analyze 문서 분석 엔드포인트
전문 15,000자 → Gemma 4 구조화 분석 (근거/해설/사례/요약 4층).
- MLX gate + 20초 timeout (gate 안쪽)
- 인메모리 캐시 TTL 30분, 키 = doc_id + updated_at(fallback: created_at)
- 층별 최소 50자 + 억지 채움 문구 제거
- summary 필수 (없으면 422)
- 에러: 404 text 없음 / 504 timeout / 502 llm / 422 parse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:32:44 +09:00
Hyungi Ahn 6bc52928b6 fix(ui): 검색 input Enter 시 문서 열림 방지 (stopPropagation)
검색바에서 Enter → submitSearch()만 실행되어야 하는데
useListKeyboardNav의 window 리스너가 Enter를 잡아 selectDoc() 호출.
stopPropagation으로 이벤트 전파 차단.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:28:55 +09:00
Hyungi Ahn f4791cfada feat(ui): Phase D.1~D.4 — 검색 페이지 이드 답변 통합
D.1: documents route 디자인 토큰 정리 (var(--*) → 시맨틱 토큰, 잔여 0)
D.2: isQuestion 질문형 감지 유틸 (? 단일단어 허용, 한/영 6규칙)
D.3: AskAnswerCard 컴팩트 답변 카드 + analyze.ts 타입 정의
D.4: 질문형 검색 시 /search/ask 병렬 호출 + 상단 카드 배치

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:09:58 +09:00
Hyungi Ahn 4f63938a04 feat(api): GET /documents/{id}/content 전문 텍스트 엔드포인트
Tier 2 문서 전문 분석을 위한 서비스 호출용. 15,000자 상한 + truncated 표시.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:35:19 +09:00
Hyungi Ahn 3b8b43cb54 feat(auth): support custom access token expiry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:43:58 +09:00
Hyungi Ahn a842c650d8 fix(migration): asyncpg 다중 statement 분리
asyncpg는 prepared statement에 다중 SQL 불가.
COMMENT 제거하고 ALTER TABLE만 유지.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:10:01 +09:00
Hyungi Ahn 088966bf78 feat(extract): OCR 트리거 규칙 + extract_meta JSONB
스캔 PDF/이미지 자동 OCR 트리거 + 결과 품질 검증 + 1회 제한.

- extract_meta JSONB 컬럼 추가 (migration 134)
  ocr_attempted, ocr_reason, ocr_skip_reason, ocr_terminal, ocr_chars
- PDF OCR 트리거: total_chars < 300 또는 avg < 80 && total < 3000
- 이미지 자동 OCR: jpg/png/tiff/webp 등
- 품질 차등: 이미지 50자, PDF 200자 또는 페이지당 30자
- 상한: pages > 200 또는 file_size > 150MB → 스킵
- OCR 1회 제한: extract_meta.ocr_attempted로 재시도 방지
- extractor_version은 도구명만 (surya_ocr/pymupdf/kordoc)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:04:13 +09:00
Hyungi Ahn 7883ac67b3 feat(ocr): Surya OCR 마이크로서비스 추가
GPU 가속 OCR (Surya, Apache 2.0) 별도 컨테이너로 추가.
스캔 PDF/이미지 파일의 텍스트 추출 지원.

- services/ocr: Dockerfile + server.py + requirements.txt
- /health (liveness) + /ready (readiness, CUDA+모델 상태)
- /ocr: 페이지 단위 스트리밍 처리 (메모리 피크 억제)
- docker-compose: ocr-service + GPU reservation + ocr_models 볼륨

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:03:55 +09:00
Hyungi Ahn 083aa3126a feat(search): retrieval+evidence 품질 개선
- embed_worker: ai_summary 누락 시 text[:800] fallback → ToC 감지 +
  서술형 문단 우선 선택 (보수적 휴리스틱, 강신호 2개 이상 + 스킵 상한)
- retrieval_service: snippet 200자 → 1200자 (리랭커/evidence에 더 넓은 문맥 제공)
- evidence_service: CANDIDATE_SNIPPET_CHARS 800 → 1200 (LLM evidence window 확대)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:56:33 +09:00
Hyungi Ahn 4c442ac776 fix(watcher): file_watcher.py에 sqlalchemy select import 누락 수정
file_watcher.watch_inbox()에서 select(Document)를 사용하지만
sqlalchemy import가 빠져있어 NameError 발생.
이로 인해 큐 컨슈머가 max_instances 도달로 실행 스킵되어
embed(45건) + chunk(8건)이 pending 상태로 정체됨.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:49:46 +09:00
Hyungi Ahn 72910db818 fix(extract): kordoc 실패 시 PyMuPDF fallback 추가
kordoc은 PDF 전체를 메모리에 올려 파싱 → 이미지 PDF에서 OOM.
PyMuPDF는 페이지 단위 스트리밍으로 40MB+ PDF도 수백 MB 내 처리.

- kordoc 시도 → 실패(OOM/timeout/422) → PDF면 PyMuPDF fallback
- PyMuPDF도 텍스트 레이어 없으면 로그 경고 (스캔 전용 PDF)
- HWP/HWPX는 kordoc 전용 (fallback 없음)
- extractor_version으로 어떤 경로로 추출됐는지 추적

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:30:00 +09:00
Hyungi Ahn 32c79740f8 fix(kordoc): 파일 크기 제한 삭제, Docker 메모리 상한 4GiB 적용
25MB 파일 크기 제한은 텍스트 PDF(18MB 성공)까지 차단하는 문제.
실제 원인은 이미지 스캔 PDF의 in-memory 파싱 시 메모리 폭발.

- extract_worker: 25MB 파일 크기 제한 삭제
- docker-compose: kordoc-service mem_limit 4g + memswap_limit 4g
- 텍스트 PDF → 크기 무관 정상 파싱
- 이미지 PDF → 4GiB 초과 시 Docker OOM-kill → 재시작 → 3회 실패 후 failed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:21:08 +09:00
Hyungi Ahn 21931138e3 fix(extract): 25MB 초과 PDF kordoc 파싱 스킵 (OOM 방지)
38.2MB PDF에서 kordoc이 22.8GiB 메모리 사용 후 OOM 크래시 확인.
컨테이너 재시작으로 다른 문서 처리까지 차단되는 문제 방지.

- 25MB 초과 파일: kordoc 호출 없이 스킵 (extractor_version에 크기 기록)
- 25MB 이하 파일: 기존 adaptive timeout으로 정상 처리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:10:18 +09:00
Hyungi Ahn 5070ac45ff fix(extract): LibreOffice 추출 절단 제거 및 요약 입력 확대
- extract_worker: LibreOffice 15000자 절단 제거 (full text 저장 원칙)
- classify_worker/summarize_worker: 요약 입력 15000→50000자 확대
- client.py: 길이 기반 Claude 자동전환 제거 (require_explicit_trigger 정책 준수)
  _call_chat의 primary→fallback(exaone3.5) 체인은 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:00:23 +09:00
Hyungi Ahn 2a240cb9e9 fix(kordoc): adaptive parse timeout + 동시 파싱 제한
kordoc의 30초 하드 타임아웃을 파일 크기 비례 adaptive(60~300초)로 변경.
대형 PDF/HWP가 파싱 타임아웃으로 영구 실패하던 문제 해결.

- getParseTimeoutMs(): 10MB당 60초, 최소 60초, 최대 300초
- parseJobs Map 기반 동시 파싱 2건 제한 (유령 작업 누적 방지)
- 상세 로그: START/DONE/ZOMBIE_DONE/REJECTED + ext/size/elapsed/active
- clearTimeout으로 정상 완료 시 불필요한 타이머 콜백 정리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:00:12 +09:00
Hyungi Ahn ef9687b0bf feat(library): Phase 2B 문서 상세 facet 편집 + 업로드 facet 전달
FileInfoView에 회사/주제/연도/문서유형 select 4개 추가.
facet 옵션은 /api/library/facets에서 로드, 세션 캐시.
업로드 엔드포인트에 facet Form 파라미터 4개 추가.
업로드 시 현재 선택 facet 자동 전달 + 미리보기 텍스트.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:27:03 +09:00
Hyungi Ahn 776734c897 feat(library): Phase 2B facet 필터 패널 + 문서 목록 연동
자료실 좌측에 회사/주제/연도/문서유형 facet pill 패널 추가.
single-select 토글, count 표시, 교차 필터 (자기 축 제외).
URL searchParams 기반 상태 관리 (뒤로가기/새로고침 유지).
loadDocs에 facet 파라미터 전달, loadFacetCounts 분리 (page/sort 제외).
count 0은 dim+disabled, 초기화 버튼 포함.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:25:39 +09:00
Hyungi Ahn ba19c6fb79 feat(library): Phase 2A facet 탐색 기반 — 컬럼 + API + 필터
documents 테이블에 facet_company/topic/year/doctype 4개 축 추가.
facet_values 사전 테이블 + CRUD API.
facet-counts 집계 API (교차 필터링 지원).
문서 목록 API에 facet 필터 파라미터 추가.
DocumentResponse/DocumentUpdate 스키마에 facet 필드 포함.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:09:25 +09:00
Hyungi Ahn 32aab7784b fix(library): 마이그레이션 asyncpg 다중 statement 분리
asyncpg는 prepared statement에 다중 SQL 불가.
120(테이블) → 121(unique idx) → 122(parent idx) → 123(시드) 분리.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:03:00 +09:00
Hyungi Ahn 964d4ffc67 feat(library): 자료실 분류 체계 독립 관리 Phase 1
library_categories 테이블 추가로 빈 카테고리 생성 가능.
CRUD API (생성/leaf rename/leaf delete) + 트리 머지 엔드포인트.
사이드바 트리에 컨텍스트 메뉴 (추가/이름변경/삭제).
LibraryPathEditor를 카테고리 기반 flat selector로 전환.
미분류는 시스템 분류로 보호 (삭제/이름변경 불가).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:01:53 +09:00
Hyungi Ahn 7c78c09046 fix(queue): migration을 단일 statement 파일 3개로 분리
asyncpg prepare가 다중 statement 불가. 117(stale 정리) → 118(constraint 제거)
→ 119(partial unique index 생성) 순차 실행.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:40:19 +09:00
Hyungi Ahn f0c7d4c2c2 fix(queue): migration 117에서 DO $$ BEGIN 제거 (BEGIN 검증 회피)
_validate_sql_content가 PL/pgSQL의 BEGIN을 트랜잭션 제어문으로 오탐.
guard check를 제거하고 CREATE UNIQUE INDEX 자체의 중복 실패에 의존.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:38:49 +09:00
Hyungi Ahn 751cdc5be8 fix(queue): enqueue 경로 중복 방어 — partial unique index + 중앙 enqueue_stage 함수
기존 UNIQUE(document_id, stage, status)는 pending+processing 동시 존재를
허용해서 stale 복구 시 충돌 발생. 2-layer 방어로 근본 차단:

1) DB: partial unique index uq_queue_active — 활성 행(pending/processing)은
   (document_id, stage)당 최대 1개만 허용
2) App: enqueue_stage() 중앙 함수 — INSERT ON CONFLICT DO NOTHING으로
   모든 9개 경로의 check-then-insert TOCTOU race 제거

migration 117은 guard check 포함 — 활성 중복이 남아있으면 RAISE EXCEPTION
으로 중단, 수동 정리 유도.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:37:32 +09:00
Hyungi Ahn 8ec1e53ca4 fix(queue): reset_stale_items UniqueViolationError로 큐 소비 전체 중단 수정
stale processing 행을 pending으로 bulk UPDATE 시 이미 같은
(document_id, stage, pending) 행이 존재하면 unique constraint 위반으로
APScheduler consume_queue 잡 전체가 크래시. 2-step 접근으로 변경:
1) pending 중복 있는 stale processing 행은 DELETE
2) 나머지만 pending으로 UPDATE
+ 예외 삼키기로 stale reset 실패가 전체 큐 소비를 죽이지 않게 방어

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:41:20 +09:00
Hyungi Ahn ef89d48bfe fix(library): 자료실 루트 업로드 시 @library/ 태그 누락 수정
폴더 미선택 상태에서 업로드하면 doc_purpose='business'만 설정되고
@library/ 태그가 빠져서 자료실에 문서가 표시되지 않던 버그 수정.
백엔드: business 업로드에 library_path 없으면 @library/미분류 자동 태깅.
프론트: activePath 없을 때 기본값 '미분류' 전송.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:36:40 +09:00
Hyungi Ahn e89e19f365 feat(library): 자료실 드래그 업로드 + 오버레이
자료실 페이지에서 드래그 앤 드롭 업로드 지원.
업로드 후 자료실 내에서 트리+목록 새로고침 (문서 페이지로 이동하지 않음).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:53:09 +09:00
Hyungi Ahn d6756010b1 fix(memos): 마감일 버튼 줄바꿈 없이 커서 옆에 삽입
insertAtCursor가 자동 줄바꿈 추가해서 마감일이 아랫줄에 생성됨.
직접 삽입으로 변경하여 현재 커서 위치 바로 옆에 @날짜 삽입.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:46:37 +09:00
Hyungi Ahn 804ba1f4c7 feat(memos): 체크박스 수정 + 마감일 badge + 대시보드 인터랙션
공통 유틸 memoRenderer.ts 분리 (drift 방지):
- checkbox regex 속성 순서 독립으로 수정 (버그 원인)
- due date: checkbox line 마지막 @YYYY-MM-DD만 badge 변환
  overdue=빨강, soon(3일)=노랑, normal=dim, checked=dim
- toggleTaskLine: taskIndex 기반 안전한 토글
- 날짜 비교 로컬 기준 (TZ 이슈 회피)

메모 페이지:
- 렌더링/토글 공통 유틸 import
- 툴바에 📅 마감일 버튼 추가

대시보드:
- 핀 메모 체크박스 토글 가능 (optimistic + rollback)
- stopPropagation으로 details 토글 충돌 방지
- renderMdSimple → renderMemoHtml 통일

QuickMemoButton:
- 체크리스트 + 마감일 버튼 2개 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:40:18 +09:00
Hyungi Ahn 9363cdcc61 fix(library): 마이그레이션 2개로 분리 (BEGIN 검증 회피)
DO $$ BEGIN 블록이 트랜잭션 BEGIN으로 오탐됨.
CREATE TYPE / ALTER TABLE을 별도 마이그레이션으로 분리.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:31:16 +09:00
Hyungi Ahn d01617e2bc fix(library): 마이그레이션 asyncpg multiple statement 에러 수정
asyncpg는 prepared statement에 여러 명령을 넣을 수 없음.
CREATE TYPE + ALTER TABLE을 단일 DO $$ 블록으로 합침.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:30:06 +09:00
Hyungi Ahn 5c58778a41 feat(library): doc_purpose 필드 + 자료실 업로드 기능
지식/업무 문서 1차 구분을 위한 doc_purpose(business|knowledge) 추가.
- 마이그레이션: document_purpose enum + 컬럼
- AI 분류: docPurpose 자동 추론 (빈 값만 채움)
- 업로드 API: doc_purpose + library_path Form 파라미터
- 자료실 업로드: business 기본값 + 선택 경로 자동 태깅
- FileInfoView: 용도 select (수동 변경, 실패 롤백)
- DocumentCard: 업무/참조 배지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:26:59 +09:00
Hyungi Ahn 96ab2369a7 fix(memos): 수정 500 에러 + 줄바꿈 렌더링
1. DocumentChunk.document_id → doc_id (실제 컬럼명)
2. marked breaks: true — 단일 줄바꿈이 <br>로 변환

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:24:46 +09:00
Hyungi Ahn 5ce0e848a3 feat(memos): 선택적 제목, 툴바 버튼, 대시보드 핀 펼침
메모 입력/편집:
- 선택적 제목 토글 (기본 숨김, "제목" 버튼으로 활성화)
- 툴바 버튼: 체크리스트/굵게/제목 (모바일에서 마크다운 수동 입력 불필요)
- 편집 모드에도 동일 툴바

대시보드 핀 메모:
- 클릭 시 /memos 이동 대신 인라인 펼침/접힘 (details)
- 제목이 있으면 제목 표시, 없으면 첫 줄
- 펼치면 마크다운 렌더링된 본문 + "메모함에서 보기" 링크

Backend:
- MemoCreate/MemoUpdate에 선택적 title 파라미터 복원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:06:43 +09:00
Hyungi Ahn deb5c1b704 feat(library): 자료실 — 태그 기반 트리 문서 관리 기능
목적성 문서(양식, 템플릿, 연간보고서)를 @library/ 태그로 분류하고
트리 구조로 탐색하는 자료실 페이지 추가.

백엔드: 경로 정규화 유틸, library-tree/library 엔드포인트,
다운로드 Content-Disposition 개선(원본/PDF 분리, 한글 filename*)
프론트: /library 페이지, LibraryPathEditor, 상단 nav/사이드바 링크

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:55:45 +09:00
Hyungi Ahn 6067177913 fix(memos): 모바일 액션 버튼 항상 표시
hover 기반 opacity가 모바일에서 동작하지 않아 편집/삭제/핀 등
액션 버튼 접근 불가. md 이상에서만 hover 숨김, 모바일은 항상 표시.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:47:33 +09:00
Hyungi Ahn c9eeee5fd5 feat(news): 모바일 스플릿뷰 + 책갈피 기능
모바일 풀스크린 오버레이를 제거하고 리스트(35%)+미리보기(65%) 분할뷰로 전환.
pinned 필드를 활용한 책갈피 토글 및 필터 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:43:04 +09:00
Hyungi Ahn 2b5a6d410b refactor(dashboard): 상황판 재설계 — 사용자 지시서 기반 구현
대시보드를 통계판에서 상황판으로 전환:
- 헤더 + 시스템 상태 인라인 (비클릭)
- 핀 메모 최상단 조건부 (컴팩트 띠, 최대 3개)
- 카드 4개 (문서함/메모/뉴스/승인대기) 모바일 2×2
- 최근 활동 전체 너비 7건, 2줄 스캔형 + 법령 배지
- 파이프라인 details 접힘 (실패 시 자동 open)
- 제거: 도메인 분포, 법령/시스템 별도 카드, 8:4 분할

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:42:15 +09:00
Hyungi Ahn 93fcd4cf0b refactor(dashboard): 원래 8:4 2열 레이아웃 복원 + 개선 유지
이전 재설계에서 위젯을 과도하게 제거해 퇴화.
원래 12칸 그리드 + 8:4 2열 구조 복원하면서 개선 유지:
- 행1: 4개 카드 (문서함/메모/뉴스/승인대기)
- 행2: 파이프라인(8) + 도메인 분포(4)
- 행3: 최근 문서(8) + 법령/시스템(4)
- 핀 메모 상단 조건부 표시
- CalDAV stub → 법령 알림 + 시스템 상태 카드

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:30:21 +09:00
Hyungi Ahn 3fe4c16d3a refactor(dashboard): UI/UX 재설계 — 정보 위계 + 모바일 최적화
대시보드 전면 재작성:
- 핀 메모: 최상단 조건부 컴팩트 띠 (pinned=true API 파라미터 추가)
- 4개 핵심 카드: 문서함/메모/뉴스/승인대기 (2×2 모바일, 4열 데스크탑)
- 승인 대기: 액션형 카드 (warning + 검토하기 CTA)
- 최근 활동: 전체 너비, 2줄 스캔형, 법령 알림 뱃지
- 파이프라인: details 기반 접힘 (실패 시 자동 펼침, 수동 접힘 유지)
- 시스템 상태: 헤더 인라인 배지 (비클릭)
- CalDAV stub/도메인 분포 위젯 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:25:19 +09:00
Hyungi Ahn f231dae5af fix(dashboard): 카드 균등 꽉 채우기 — min-w-0 + overflow-hidden
grid 셀이 콘텐츠 최소 너비에 의존해 우측 잘림.
min-w-0으로 shrink 허용, overflow-hidden으로 넘침 방지.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:38:49 +09:00
Hyungi Ahn e0d92a3a28 fix(dashboard): 카드 균등 비율 + 모바일 3열×2행 레이아웃
모바일 3열×2행, 데스크탑 6열×1행. 텍스트 중앙 정렬,
보조 텍스트 높이 통일 (투명 placeholder), 카드 크기 균등.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:33:14 +09:00
Hyungi Ahn e43ea137b6 fix(dashboard): 카드 레이아웃 모바일 반응형 개선
모바일 2열×3행 / md 3열×2행 / lg 6열×1행 그리드.
아이콘을 라벨 옆으로 이동, "오늘 +0" 숨김, 카드 높이 통일.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:23:40 +09:00
Hyungi Ahn fa0175058a feat(dashboard): 카운트 분리 — 문서함/메모/뉴스/승인대기
전체 문서 1개 카드를 6개로 분리: 문서함, 메모, 뉴스, 승인대기,
법령알림, 시스템. 단일 FILTER 쿼리로 효율적 카운트.
각 카드 클릭 시 해당 페이지로 이동.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:19:00 +09:00
Hyungi Ahn 087cbdc900 fix(memos): 뉴스 혼입 방지 + 스크롤 차단 수정
1. 메모 목록 쿼리에 source_channel='memo' 조건 추가.
   뉴스가 file_type='note'로 저장되어 메모 피드에 혼입됨.
2. main 컨테이너 overflow-hidden → overflow-auto.
   메모 페이지가 body 스크롤에 의존하는데 차단되어 있었음.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:09:01 +09:00
Hyungi Ahn e889b33dd6 fix(memos): API 호출에 trailing slash 추가 (Mixed Content 수정)
FastAPI가 /api/memos → /api/memos/ 리다이렉트 시 프록시 뒤라
HTTP URL을 생성하여 HTTPS 페이지에서 Mixed Content 차단됨.
리스트/생성 엔드포인트 호출에 trailing slash 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:42:18 +09:00
Hyungi Ahn e435332ea1 feat(memos): UX 개선 — 편집 수정, 제목 제거, 체크박스, 아카이브
Phase A: 편집 버그 수정 (content만 PATCH, Ctrl+Enter/Esc),
제목 UI 제거 (자동생성 80자, 내부용), 카드 경량화.

Phase B: GFM task list 지원, taskIndex 기반 인터랙티브 토글,
DOMPurify checkbox 최소 허용, optimistic update + 롤백.

Phase C: archived 컬럼 (메모 UX 전용, 문서 미노출),
멱등 세팅 API (토글 아님), 활성/아카이브 뷰 분리 쿼리,
핀은 활성 메모용 (archived 시 무시).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:36:16 +09:00
Hyungi Ahn 70729fd8a3 refactor(frontend): 상단 nav 재구성 — 핵심 기능 중심 4개 고정
상단 nav를 질문|메모|뉴스|Inbox 4개 핵심 기능으로 재정렬.
설정/로그아웃은 더보기(⋮) 드롭다운으로 이동.
메모 링크가 모바일에서 사이드바 없이 바로 접근 가능.
active 상태 표시(startsWith), 접근성 속성, 오버레이 닫기.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:00:54 +09:00
Hyungi Ahn ee74a9ba78 fix(extract): scale kordoc timeout by file size for large PDFs
대형 PDF(14~40MB)에서 kordoc 파싱 timeout(60초) 실패하던 문제.
10MB당 60초 추가, 최소 60초 최대 300초로 조정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 06:47:22 +09:00
Hyungi Ahn 3c5844e287 fix(memos): DROP CONSTRAINT 사용 (UNIQUE constraint는 DROP INDEX 불가)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:03:35 +09:00
Hyungi Ahn e3a065d15d fix(memos): migration을 개별 파일로 분리 (asyncpg multi-statement 미지원)
asyncpg prepared statement가 multi-command를 지원하지 않아 시작 실패.
105 단일 파일을 105-112 개별 statement로 분리.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:02:45 +09:00
Hyungi Ahn b46a75758b feat(memos): 내장 메모 기능 — 파일 없는 문서(file_type='note')
Document Server에 Memos 앱 대체 기능 내장. 메모를 documents 테이블의
file_type='note' 레코드로 관리하여 기존 AI 파이프라인(classify/embed/
chunk/search/ask) 재활용.

Backend:
- migration 105: source_channel 'memo', file_path NULL 허용,
  user_tags/pinned/ask_includable 컬럼, 메모 인덱스
- api/memos.py: CRUD 7개 엔드포인트 + #태그 파싱 + stale AI 초기화
  + 큐 pending 중복 방지
- queue_consumer: note extract/preview skip
- documents API: file_path NULL 가드, 목록에서 메모 제외
- search /ask: ask_includable=false 문서 evidence 제외

Frontend:
- /memos 타임라인 페이지 (빠른 입력 + 피드 + 인라인 편집 + 태그 필터)
- QuickMemoButton FAB (Ctrl+M, 모든 페이지)
- Sidebar 메모 링크

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:00:00 +09:00
Hyungi Ahn 33ce4292ca fix(frontend): add min-w-0 to flex chain for mobile card overflow
flex 체인에서 min-width: auto 기본값이 카드 shrink를 막아
모바일에서 콘텐츠가 뷰포트를 초과하던 문제 수정.
- +page.svelte line 418: flex-1 → flex-1 min-w-0
- +page.svelte line 693: overflow-x-hidden 추가
- DocumentCard.svelte: button에 min-w-0 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:54:58 +09:00
Hyungi Ahn 7fb093884a fix(frontend): mobile responsive document list — hide table columns + card padding
- DocumentTable: 분류/타입/크기 컬럼 모바일 숨김 (hidden md:flex/block)
- DocumentCard: gap/padding 축소 (gap-2 p-2 sm:gap-3 sm:p-3), data_origin 모바일 숨김, 태그 모바일 1개
- 검색바: 검색모드 select 모바일 숨김, AI답변 텍스트 모바일 숨김

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:17:29 +09:00
Hyungi Ahn 141eb77938 fix(news): allow HTTP redirect for HTTP_EXCEPTION_DOMAINS sources
SCMP(www.scmp.com)처럼 HTTPS 원본이 HTTP로 301 redirect하는 소스에서
redirect target이 차단되던 문제 수정. allow_http를 원본 스킴이 아닌
소스 도메인의 allowlist 등록 여부로 판단하도록 변경.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:05:05 +09:00
Hyungi Ahn cbef646a3f fix(news): add SCMP to HTTP exception allowlist for HK news source
SCMP(South China Morning Post) RSS가 HTTPS→HTTP 301 redirect 패턴.
HTTP_EXCEPTION_DOMAINS에 www.scmp.com 추가 (2026-07 재검토)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:02:18 +09:00
Hyungi Ahn 6b189f0d47 fix(digest): multi-word ai_sub_group matching + NYT_API_KEY example
- loader.py: first-token + all-but-last-token 이중 키 매칭 (Le Monde, Der Spiegel 대응)
- chunk_worker.py: startswith 매칭 보강
- credentials.env.example: NYT_API_KEY 항목 추가

핫픽스 — 단계 3에서 news_source_id FK 정규화로 문자열 매칭 제거 예정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:33:04 +09:00
Hyungi Ahn 5038007998 fix(news): SSRF validation + admin auth + API key masking + collect lock + XML safety
- 신규 url_validator.py: SSRF 차단 (private IP/loopback/link-local/reserved/multicast/CGNAT 블록, HTTPS only)
- require_admin dependency 추가 — 소스 CRUD, /collect, /digest/regenerate에 적용
- User.is_admin 컬럼 + migration 104
- NYT API key 로그 마스킹 (쿼리스트링 제거)
- RSS fetch: redirect 수동 처리(3회, target 재검증), 5MB 크기 제한, content-type 허용목록, feed.bozo 체크
- /collect 재진입 차단 (asyncio.Lock, 단일 인스턴스 한정)
- HTTP feed allowlist (코드 레벨 상수, API 미노출)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:32:55 +09:00
Hyungi Ahn e405ed3414 fix(ask): evidence sparse 문제 해결 — 프롬프트 + supplement + source 분리
근본 원인: evidence 프롬프트가 "<0.5 = 탈락" 명시 → LLM 하향 편향 →
candidates 5개 중 4개 탈락 → synthesis 자체 거부.

Change 2: evidence_extract.txt
- relevance 스케일 재정의: "탈락" 라벨 제거
- 0.3~0.5 약한 부분 연관 / 0.5~0.7 명확한 부분 연관 구간 세분화
- "directly answer" → "no connection at all" 완화

Change 3: search_synthesis.txt
- refused 조건: "직접 답 아니면 거부" → "완전 무관일 때만 거부"
- "covered only" 제한: partial evidence로 missing part 추론 금지
- supplement evidence weight 지시 추가 (보조 취급)

Change 1: evidence_service.py
- sparse evidence supplement: kept 1~2 + candidates 3+ → rule-only 보충
- substring + critical token 필터 (recall+precision)
- critical token: 길이 3자+ OR 의미 기반 suffix (조건/기준/처벌 등)
- EvidenceItem.source 필드 ("llm"|"supplement"|"rule_fallback")

Change 4: search.py
- defense_log["evidence"] 추가 (skip_reason, kept_count)

synthesis_service.py
- supplement evidence [n] (보충) 마킹

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:11:57 +09:00
Hyungi Ahn b2306c3afd feat(ask): Phase 3.5b guardrails — verifier + telemetry + grounding 강화
Phase 3.5a(classifier+refusal gate+grounding) 위에 4개 Item 추가:

Item 0: ask_events telemetry 배선
- AskEvent ORM 모델 + record_ask_event() — ask_events INSERT 완성
- defense_layers에 input_snapshot(query, chunks, answer) 저장
- refused/normal 두 경로 모두 telemetry 호출

Item 3: evidence 간 numeric conflict detection
- 동일 단위 다른 숫자 → weak flag
- "이상/이하/초과/미만" threshold 표현 → skip (FP 방지)

Item 4: fabricated_number normalization 개선
- 단위 접미사 건/원 추가, 범위 표현(10~20%) 양쪽 추출
- bare number 2자리 이상만 (1자리 FP 제거)

Item 1: exaone semantic verifier (판단권 잠금 배선)
- verifier_service.py — 3s timeout, circuit breaker, severity 3단계
- direct_negation만 strong, numeric/intent→medium, 나머지→weak
- verifier strong 단독 refuse 금지 — grounding과 교차 필수
- 6-tier re-gate (4라운드 리뷰 확정)
- grounding strong 2+ OR max_score<0.2 → verifier skip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:49:56 +09:00
Hyungi Ahn a0e1717206 fix(grounding): citation marker [n] 을 fabricated_number 에서 제외
[1][2][4] 같은 citation 마커의 숫자가 evidence 에 없다고 판정되어
모든 정상 답변이 refuse(2+strong) 되는 critical bug.
answer 에서 \[\d+\] 제거 후 숫자 추출.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:59:29 +09:00
Hyungi Ahn 1beba3402b fix(migration): split 102 ask_events into single-statement files
asyncpg cannot insert multiple commands into a prepared statement.
102 = CREATE TABLE only, 103 = CREATE INDEX only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:52:26 +09:00
Hyungi Ahn 06443947bf feat(ask): Phase 3.5a guardrails (classifier + refusal gate + grounding + partial)
신규 파일:
- classifier_service.py: exaone binary classifier (sufficient/insufficient)
  parallel with evidence, circuit breaker, timeout 5s
- refusal_gate.py: multi-signal fusion (score + classifier)
  AND 조건, conservative fallback 3-tier (classifier 부재 시)
- grounding_check.py: strong/weak flag 분리
  strong: fabricated_number + intent_misalignment(important keywords)
  weak: uncited_claim + low_overlap + intent_misalignment(generic)
  re-gate: 2+ strong → refuse, 1 strong → partial
- sentence_splitter.py: regex 기반 (Phase 3.5b KSS 업그레이드)
- classifier.txt: exaone Y+ prompt (calibration examples 포함)
- search_synthesis_partial.txt: partial answer 전용 프롬프트
- 102_ask_events.sql: /ask 관측 테이블 (completeness 3-분리 지표)
- queries.yaml: Phase 3.5 smoke test 평가셋 10개

수정 파일:
- search.py /ask: classifier parallel + refusal gate + grounding re-gate
  + defense_layers 로깅 + AskResponse completeness/aspects/confirmed_items
- config.yaml: classifier model 섹션 (exaone3.5:7.8b GPU Ollama)
- config.py: classifier optional 파싱
- AskAnswer.svelte: 4분기 렌더 (full/partial/insufficient/loading)
- ask.ts: Completeness + ConfirmedItem 타입

P1 실측: exaone ternary 불안정 → binary gate 축소. partial은 grounding이 담당.
토론 9라운드 확정. plan: quiet-meandering-nova.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:49:11 +09:00
Hyungi Ahn 0eecf1afca fix(frontend): favicon 404 제거 — SVG favicon + app.html link 추가 2026-04-10 08:41:23 +09:00
Hyungi Ahn 563f54d7d5 fix(upload): 100MB 초과 파일 사전 차단 + NAS file_watcher 안내
home-caddy 의 request_body max_size 100MB 한도 (infra_inventory.md D8 /
Cloudflare 섹션 참조) 에 걸리는 업로드 시 사용자 콘솔에 의미 없는 413 만
나오던 문제. 이제:

1. 클라이언트 사전 검사: 100MB 초과 파일은 업로드 자체를 시도 안 하고
   즉시 toast 로 안내 (파일명 + 크기 + NAS 우회 경로)
2. 서버 fallback: 사전 검사를 통과했으나 인프라 한도에 걸려 413 응답이
   오는 경우에도 같은 안내 메시지

NAS 우회 경로: NAS 의 PKM 폴더에 직접 두면 file_watcher 가 5분 간격으로
자동 인덱싱. 이게 100MB+ 파일의 정식 처리 경로 (infra_inventory.md
Cloudflare 섹션의 413 정책).
2026-04-09 14:26:18 +09:00
Hyungi Ahn 010e25cb23 fix(queue): doc-level embed metadata 기반 + NUL 바이트 strip + 빈 예외 fallback
embed_worker:
- extracted_text[:6000] → title + ai_summary + tags(top 5) metadata 입력
- 500k자 문서의 표지+목차가 임베딩되는 구조적 버그 해결
- Ollama 기본 context 안전 (~1500자 이하), num_ctx 조정 불필요
- ai_summary < 50자 시 본문 800자 fallback
- ai_domain 은 초기 제외 (taxonomy 노이즈 방지)

extract_worker:
- kordoc / 직접 읽기 / LibreOffice 3 경로 모두 \x00 strip
- asyncpg CharacterNotInRepertoireError 재발 방지

queue_consumer:
- str(e) or repr(e) or type(e).__name__ fallback
- 빈 메시지 예외(24건 발생) 다음부터 클래스명이라도 기록

plan: ~/.claude/plans/quiet-meandering-nova.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:45:55 +09:00
Hyungi Ahn bfdf33b442 feat(frontend): Phase 3.4 Ask pipeline UI (/ask 3-panel)
- routes/ask/+page.svelte: URL-driven orchestrator, lastQuery guard
  (hydration 중복 호출 방지), citation scroll 연동
- lib/components/ask/AskAnswer: answer body + clickable [n] +
  confidence/status Badge + warning EmptyState (no_results_reason +
  /documents?q=<same> 역링크)
- lib/components/ask/AskEvidence: span_text ONLY 렌더 (full_snippet
  금지 룰 컴포넌트 주석에 박음) + active highlight + doc-group ordering 유지
- lib/components/ask/AskResults: inline 카드 (DocumentCard 의존 회피)
- lib/types/ask.ts: backend AskResponse 스키마 1:1 매칭
- +layout.svelte: 탑 nav 질문 버튼 추가
- documents/+page.svelte: 검색바 옆 AI 답변 링크 (searchQuery 있을 때만)

plan: ~/.claude/plans/quiet-meandering-nova.md (Phase 3.4)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:45:24 +09:00
Hyungi Ahn 4615fb4ce3 fix(documents): page_size 한도 100 → 500 (inbox 291건 누락 회피)
Inbox 가 review_status=pending 서버 필터로 받는데 pending 이 291 건 이라
page_size 100 으론 191 건 누락. inbox 는 작업 큐 성격이라 한 번에 보는 게 UX.
500 으로 상향: data 폭발 없음(filter 로 boundedness 보장), latency 영향 미미.

전략적 임시 — Phase 4.5 UI 작업에서 inbox 에 infinite scroll 또는 pagination
추가하면 le=100 으로 다시 내려도 됨.
2026-04-09 08:35:58 +09:00
Hyungi Ahn cdcbb07561 fix(inbox): page_size=200 → 422 해결, review_status 서버 필터 추가
Inbox 페이지가 /documents/?page_size=200 를 호출하는데 백엔드 Query 가
le=100 이라 422 발생 — Phase 2 첫 commit(2026-04-02)부터 dormant 버그.
inbox 코드 안에 'TODO(backend): review_status filter 지원 시 page_size 축소'
주석이 있던 상태.

backend:
- list_documents 에 review_status: str | None Query 파라미터 추가
- WHERE 절에 review_status 매칭 분기 추가

frontend:
- /documents/?review_status=pending&page_size=100 으로 변경
- 클라이언트 필터링 코드 제거 (서버 필터로 대체)

100 미만 안전. pending 이 100 넘으면 다음 페이지 로직 추가 필요 (별도 작업).
2026-04-09 08:31:51 +09:00
Hyungi Ahn 46ba9dd231 fix(digest/loader): raw SQL pgvector string 형태 파싱 지원
raw text() SQL + asyncpg 조합에서는 pgvector Vector(1024) 컬럼이
'[0.087,0.305,...]' 형태의 string 으로 반환되며 numpy 변환이 실패함
(ORM 을 쓰면 type 등록되지만 raw SQL 은 안 됨).

_to_numpy_embedding 에서 string 이면 json.loads 로 먼저 파싱한 뒤
numpy.asarray. 변환 실패 시 None 반환 (해당 doc 자동 drop).

Phase 4 deploy 워커 첫 실행 검증 중 발견.
2026-04-09 08:00:43 +09:00
Hyungi Ahn 4468c2baba fix(database): 마이그레이션 실행을 raw driver 로 변경
text(sql) 은 SQLAlchemy 가 :name 패턴을 named bind parameter 로 해석하므로
SQL 주석이나 literal 안에 콜론이 들어가면 InvalidRequestError 발생.
ai_summary[:200] 같은 표기가 들어간 101_global_digests.sql 적용 시 fastapi
startup 자체가 떨어지는 문제가 발생.

exec_driver_sql 은 SQL 을 driver(asyncpg) 에 그대로 전달하므로 콜론 escape 불요.
schema_migrations INSERT 만 named bind 가 필요하므로 그건 그대로 유지.

Phase 4 deploy 검증 중 발견. 다음 마이그레이션부터는 자동 적용 가능.
2026-04-09 07:59:25 +09:00
Hyungi Ahn 9bef049af6 fix(migration): SQLAlchemy text() bind 충돌 회피 — [:200] 표기 제거
migration 101 의 SQL 주석에 '[:200]' 이 들어 있었는데 SQLAlchemy text() 가
:200 을 named bind parameter 로 해석해 init_db() 가 'A value is required for
bind parameter 200' 로 실패. fastapi startup 자체가 떨어지는 문제.

주석을 '첫 200자' 로 고쳐서 콜론+숫자/영문 패턴 제거.
2026-04-09 07:56:50 +09:00
Hyungi Ahn dd9a0f600a fix(database): migrations dir 경로 한 단계 잘못된 버그 수정
_run_migrations 가 Path(__file__).parent.parent.parent / "migrations" 로 계산했는데
/app/core/database.py 기준으로 parent.parent.parent = / (root) 가 되어
실제 경로는 /migrations 였음. 컨테이너 안에는 /app/migrations 에 마운트되므로
디렉토리 부재로 자동 스킵 → 추가 마이그레이션이 자동 적용되지 않는 dormant 버그.

parent.parent (= /app) 로 수정. 회귀 위험 0 (기존엔 어차피 동작 안 했음).
Phase 4 deploy 검증 중 발견 — 직전 commit 의 volume mount 와 함께 동작.
2026-04-09 07:55:10 +09:00
Hyungi Ahn d5f91556e6 fix(deploy): mount migrations into fastapi container
기존 fastapi build context는 ./app이라 부모 디렉토리의 migrations/가
컨테이너에 들어가지 않아 init_db()의 _run_migrations가 디렉토리 부재로 스킵.
016까지는 postgres docker-entrypoint-initdb.d 마운트로 첫 init 시점에만
적용되었고, 이후 추가된 마이그레이션(101 등)이 자동 적용되지 못하는 문제.

./migrations:/app/migrations:ro 한 줄 마운트로 init_db()가 100+ 마이그레이션
추적 + 적용 가능. Phase 4 deploy 검증 중 발견.
2026-04-09 07:53:22 +09:00
Hyungi Ahn 75a1919342 feat(digest): Phase 4 Global News Digest (cluster-level batch summarization)
7일 rolling window 뉴스를 country × topic 2-level로 묶어 매일 04:00 KST 배치 생성.
search 파이프라인 미사용. documents → clustering → cluster-level LLM summarization → digest.

핵심 결정:
- adaptive threshold (0.75/0.78/0.80) + EMA centroid (α=0.7) + time-decay (λ=ln(2)/3)
- min_articles=3, max_topics=10/country, top-5 MMR diversity, ai_summary[:300] truncate
- cluster-level LLM only, drop금지 fallback (topic_label="주요 뉴스 묶음" + top member ai_summary[:200])
- importance_score country별 0~1 normalize + raw_weight_sum 별도 보존, max(score, 0.01) floor
- per-call timeout 25s + pipeline hard cap 600s
- DELETE+INSERT idempotent (UNIQUE digest_date), AIClient._call_chat 직접 호출 (client.py 수정 없음)

신규:
- migrations/101_global_digests.sql (2테이블 정규화)
- app/models/digest.py (GlobalDigest + DigestTopic ORM)
- app/services/digest/{loader,clustering,selection,summarizer,pipeline}.py
- app/workers/digest_worker.py (PIPELINE_HARD_CAP + CLI 진입점)
- app/api/digest.py (/latest, ?date|country, /regenerate, inline Pydantic)
- app/prompts/digest_topic.txt (JSON-only + 절대 금지 블록)

main.py 4줄: import 2 + scheduler add_job 1 + include_router 1.
plan: ~/.claude/plans/quiet-herding-tome.md
2026-04-09 07:45:11 +09:00
Hyungi Ahn 64322e4f6f feat(search): Phase 3 Ask pipeline (evidence + synthesis + /api/search/ask)
- llm_gate.py: MLX single-inference 전역 semaphore (analyzer/evidence/synthesis 공유)
- search_pipeline.py: run_search() 추출, /search 와 /ask 단일 진실 소스
- evidence_service.py: Rule + LLM span select (EV-A), doc-group ordering,
  span too-short 자동 확장(<80자→120자), fallback 은 query 중심 window 강제
- synthesis_service.py: grounded answer + citation 검증 + LRU 캐시(1h/300),
  refused 처리, span_text ONLY 룰 (full_snippet 프롬프트 금지)
- /api/search/ask: 15s timeout, 9가지 failure mode + 한국어 no_results_reason
- rerank_service: rerank_score raw 보존 (display drift 방지)
- query_analyzer: _get_llm_semaphore 를 llm_gate.get_mlx_gate 로 위임
- prompts: evidence_extract.txt, search_synthesis.txt (JSON-only, example 포함)

config.yaml / docker / ollama / infra_inventory 변경 없음.
plan: ~/.claude/plans/quiet-meandering-nova.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:34:08 +09:00
Hyungi Ahn 120db86d74 docs(search): Phase 2 최종 측정 보고서 (phase2_final.md + csv A/B)
## 결과 요약

Phase 1.3 baseline vs Phase 2 final A/B (평가셋 v0.1, 23 쿼리):
 - Recall@10:  0.730 → 0.737 (+0.007)
 - NDCG@10:    0.663 → 0.668 (+0.005)
 - Top-3 hit:  0.900 → 0.900 (0)
 - p95 latency: 171ms → 256ms (+85)
 - news_crosslingual NDCG: 0.27 → 0.37 (+0.10 ✓)
 - exact_keyword / natural_language_ko: 완전 유지 (회귀 0)

## Phase 2 게이트: 2/6 통과
 ✓ news_crosslingual NDCG ≥ 0.30
 ✓ latency p95 < 400ms
  Recall@10 ≥ 0.78 (0.737)
  Top-3 hit ≥ 0.93 (0.900)
  crosslingual_ko_en NDCG ≥ 0.65 (0.53, bge-m3 한계)
  평가셋 v0.2 작성 (후속)

## 핵심 성과 (게이트 미달이지만 견고한 기반)
 1. QueryAnalyzer async-only 아키텍처 (retrieval 차단 0)
 2. semaphore concurrency=1 (MLX single-inference queue 폭발 방지)
 3. multilingual narrowing (news/global 한정 → 회귀 0 + news 개선)
 4. soft_filter boost 보수적 설정 (0.01, domain only)
 5. prewarm 15개 → cache hit rate 70%+

## infra_inventory.md soft lock 준수
 - config.yaml / Ollama / compose restart 변경 0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:52:21 +09:00
Hyungi Ahn 01f144ab25 fix(search): soft_filter boost 약화 (domain 0.01, doctype 제거)
## 1차 측정 결과 (Phase 2.3 초안)

| metric | Phase 2.2 narrow | Phase 2.3 (boost 0.03+0.02) | Δ |
|---|---|---|---|
| Recall@10 | 0.737 | 0.721 | -0.016  |
| NDCG@10 | 0.668 | 0.661 | -0.007 |
| exact_keyword NDCG | 0.96 | 0.93 | -0.03  |

## 진단
- 같은 도메인 doc이 **무차별** boost → exact match doc 상대 우위 손상
- document_type 매칭은 ai_domain/match_reason 휴리스틱 → false positive 다수

## 수정
- SOFT_FILTER_DOMAIN_BOOST 0.03 → **0.01**
- document_type 매칭 로직 제거
- domain 매칭을 "정확 일치 또는 path 포함"으로 좁힘
- max cap 0.05 유지

## Phase 2.3 위치
 - 현재 평가셋(v0.1)에는 filter 쿼리 없음 → 효과 직접 측정 불가
 - Phase 2.4에서 queries_v0.2.yaml 확장 후 재측정 예정
 - 이 커밋의 목적은 "회귀 방지" — boost가 해를 끼치지 않도록만

(+ CLAUDE.md 동기화: infra_inventory.md 참조 / soft lock 섹션 포함)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:40:04 +09:00
Hyungi Ahn e91c199537 feat(search): Phase 2.3 soft_filter boost (domain/doctype)
## 변경

### fusion_service.py
 - SOFT_FILTER_MAX_BOOST = 0.05 (plan 영구 룰, RRF score 왜곡 방지)
 - SOFT_FILTER_DOMAIN_BOOST = 0.03, SOFT_FILTER_DOCTYPE_BOOST = 0.02
 - apply_soft_filter_boost(results, soft_filters) → int
   - ai_domain 부분 문자열 매칭 (path 포함 e.g. "Industrial_Safety/Legislation")
   - document_type 토큰 매칭 (ai_domain + match_reason 헤이스택)
   - 상한선 0.05 강제
   - boost 후 score 기준 재정렬

### api/search.py
 - fusion 직후 호출 조건:
   - analyzer_cache_hit == True
   - analyzer_tier != "ignore" (confidence >= 0.5)
   - query_analysis.soft_filters 존재
 - notes에 "soft_filter_boost applied=N" 기록

## Phase 2.3 범위
 - hard_filter SQL WHERE는 현재 평가셋에 명시 필터 쿼리 없어 효과 측정 불가 → Phase 2.4 v0.2 확장 후
 - document_type의 file_format 직접 매칭은 의미론적 mismatch → 제외
 - hard_filter는 Phase 2.4 이후 iteration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:30:23 +09:00
Hyungi Ahn e595283e27 fix(search): Phase 2.2 multilingual 활성 조건을 news/global 한정으로 좁힘
## 1차 측정 결과

| metric | Phase 1.3 | Phase 2.2 (all domains) | Δ |
|---|---|---|---|
| Recall@10 | 0.730 | 0.683 | -0.047  |
| natural_language_ko NDCG | 0.73 | 0.63 | -0.10  |
| news_crosslingual NDCG | 0.27 | 0.37 | +0.10 ✓ |
| crosslingual_ko_en NDCG | 0.53 | 0.50 | -0.03  |

document 도메인에서 ko→en 번역 쿼리가 한국어 법령 검색에 noise로 작용.
"기계 사고 관련 법령" → "machinery accident laws" 영어 embedding이
한국어 법령 문서와 매칭 약해서 ko 결과를 오히려 밀어냄.

## 수정

use_multilingual 조건 강화:
 - 기존: analyzer_tier == "analyzed" + normalized_queries >= 2
 - 추가: domain_hint == "news" OR language_scope == "global"

즉 document 도메인은 기존 single-query 경로 유지 → 회귀 복구.
news / global 영역만 multilingual → news_crosslingual 개선 유지.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:20:05 +09:00
Hyungi Ahn 21a78fbbf0 fix(search): semaphore로 LLM concurrency=1 강제 + run_eval analyze 파라미터 추가
## 배경
1차 Phase 2.2 eval에서 발견: 23개 쿼리가 순차 호출되지만 각 request의
background analyzer task는 모두 동시에 MLX에 요청 날림 → MLX single-inference
서버 queue 폭발 → 22개가 15초 timeout. cache 채워지지 않음.

## 수정

### query_analyzer.py
 - LLM_CONCURRENCY = 1 상수 추가
 - _LLM_SEMAPHORE: lazy init asyncio.Semaphore (event loop 바인딩)
 - analyze() 내부: semaphore → timeout(실제 LLM 호출만) 이중 래핑
   semaphore 대기 시간이 timeout에 포함되지 않도록 주의

### run_eval.py
 - --analyze true|false 파라미터 추가 (Phase 2.1+ 측정용)
 - call_search / evaluate 시그니처에 analyze 전달

## 기대 효과
 - prewarm/background/동기 호출 모두 1개씩 순차 MLX 호출
 - 23개 대기 시 최악 230초 소요, 단 모두 성공해서 cache 채움
 - MLX 서버 부하 안정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:12:13 +09:00
Hyungi Ahn f5c3dea833 feat(search): Phase 2.2 multilingual vector retrieval + query embed cache
## 변경 사항

### app/services/search/retrieval_service.py
 - **_QUERY_EMBED_CACHE**: 모듈 레벨 LRU (maxsize=500, TTL=24h)
   - sha256(text|bge-m3) 키. fixed query 재호출 시 vector_ms 절반 감소.
 - **_get_query_embedding(client, text)**: cache-first helper. 기존 search_vector()도 이를 사용하도록 교체.
 - **search_vector_multilingual(session, normalized_queries, limit)**: 신규
   - normalized_queries 각 언어별 embedding 병렬 생성 (cache hit 활용)
   - 각 embedding에 대해 docs+chunks hybrid retrieval 병렬
   - weight 기반 score 누적 merge (lang_weight 이미 1.0 정규화)
   - match_reason에 "ml_ko+en" 등 언어 병합 표시
   - 호출 조건 문서화 — cache hit + analyzer_tier=analyzed 시에만

### app/api/search.py
 - use_multilingual 결정 로직:
   - analyzer_cache_hit == True
   - analyzer_tier == "analyzed" (confidence >= 0.85)
   - normalized_queries >= 2 (다언어 버전 실제 존재)
 - 위 3조건 모두 만족할 때만 search_vector_multilingual 호출
 - 그 외 모든 경로 (cache miss, low conf, single lang)는 기존 search_vector 그대로 사용 (회귀 0 보장)
 - notes에 `multilingual langs=[ko, en, ...]` 기록

## 기대 효과
 - crosslingual_ko_en NDCG 0.53 → 0.65+ (Phase 2 목표)
 - 기존 경로 완전 불변 → 회귀 0
 - Phase 2.1 async 구조와 결합해 "cache hit일 때만 활성" 조건 준수

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:59:20 +09:00
Hyungi Ahn 1e80d4c613 fix(search): query_analyzer가 setup_logger 사용하도록 수정
기본 logging.getLogger()는 WARNING 레벨이라 prewarm/analyze 진행 로그가
stdout/파일 어디에도 안 찍혔음. setup_logger("query_analyzer")로 교체하면
logs/query_analyzer.log + stdout 둘 다 INFO 레벨 출력.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:52:46 +09:00
Hyungi Ahn 324537cbc8 fix(search): LLM_TIMEOUT_MS 5000 → 15000 (실측 반영)
축소 프롬프트 재측정:
  - prompt_tok 2406 → 802 (1/3 감소 성공)
  - latency 10.5초 → 7~11초 (generation이 dominant)
  - max_tokens 내려도 무효 (자연 EOS ~289 tok)

5000ms로는 여전히 모든 prewarm timeout. async 구조이므로
background에서 15초 기다려도 retrieval 경로 영향 0.

추가: prewarm delay_between 0.5 → 0.2 (총 prewarm 시간 단축).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:50:56 +09:00
Hyungi Ahn c81b728ddf refactor(search): Phase 2.1 QueryAnalyzer를 async-only 구조로 전환
## 철학 수정 (실측 기반)

gemma-4-26b-a4b-it-8bit MLX 실측:
  - full query_analyze.txt (prompt_tok=2406) → 10.5초
  - max_tokens 축소 무효 (모델 자연 EOS 조기 종료)
  - 쿼리 길이 영향 거의 없음 (프롬프트 자체가 지배)
  → 800ms timeout 가정은 13배 초과. 동기 호출 완전히 불가능.

따라서 QueryAnalyzer는 "즉시 실행하는 기능" → "미리 준비해두는 기능"으로
포지셔닝 변경. retrieval 경로에서 analyzer 동기 호출 **금지**.

## 구조

```
query → retrieval (항상 즉시)
         ↘ trigger_background_analysis (fire-and-forget)
            → analyze() [5초+] → cache 저장

다음 호출 (동일 쿼리) → get_cached() 히트 → Phase 2 파이프라인 활성화
```

## 변경 사항

### app/prompts/query_analyze.txt
 - 5971 chars → 2403 chars (40%)
 - 예시 4개 → 1개, 규칙 설명 축약
 - 목표 prompt_tok 2406 → ~600 (1/4)

### app/services/search/query_analyzer.py
 - LLM_TIMEOUT_MS 800 → 5000 (background이므로 여유 OK)
 - PROMPT_VERSION v1 → v2 (cache auto-invalidate)
 - get_cached / set_cached 유지 — retrieval 경로 O(1) 조회
 - trigger_background_analysis(query) 신규 — 동기 함수, 즉시 반환, task 생성
 - _PENDING set으로 task 참조 유지 (premature GC 방지)
 - _INFLIGHT set으로 동일 쿼리 중복 실행 방지
 - prewarm_analyzer() 신규 — startup에서 15~20 쿼리 미리 분석
 - DEFAULT_PREWARM_QUERIES: 평가셋 fixed 7 + 법령 3 + 뉴스 2 + 실무 3

### app/api/search.py
 - 기존 sync analyzer 호출 완전 제거
 - analyze=True → get_cached(q) 조회만 O(1)
   - hit: query_analysis 활용 (Phase 2.2/2.3 파이프라인 조건부 활성화)
   - miss: trigger_background_analysis(q) + 기존 경로 그대로
 - timing["analyze_ms"] 제거 (경로에 LLM 호출 없음)
 - notes에 analyzer cache_hit/cache_miss 상태 기록
 - debug.query_analysis는 cache hit 시에만 채워짐

### app/main.py
 - lifespan startup에 prewarm_analyzer() background task 추가
 - 논블로킹 — 앱 시작 막지 않음
 - delay_between=0.5로 MLX 부하 완화

## 기대 효과

 - cold 요청 latency: 기존 Phase 1.3 그대로 (회귀 0)
 - warm 요청 + prewarmed: cache hit → query_analysis 활용
 - 예상 cache hit rate: 초기 70~80% (prewarm) + 사용 누적
 - Phase 2.2/2.3 multilingual/filter 기능은 cache hit 시에만 동작

## 참조

 - memory: feedback_analyzer_async_only.md (영구 룰 저장)
 - plan: ~/.claude/plans/zesty-painting-kahan.md ("철학 수정" 섹션)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:47:09 +09:00
Hyungi Ahn d28ef2fca0 feat(search): Phase 2.1 QueryAnalyzer + LRU cache + confidence 3-tier
QueryAnalyzer 스켈레톤 구현. 자연어 쿼리를 구조화된 분석 결과로 변환.
Phase 2.1은 debug 노출 + tier 판정까지만 — retrieval 경로는 변경 X (회귀 0 목표).
multilingual/filter 실제 분기는 2.2/2.3에서 이 분석 결과를 활용.

app/prompts/query_analyze.txt
 - gemma-4 JSON-only 응답 규약
 - intent/query_type/domain_hint/language_scope/normalized_queries/
   hard_filters/soft_filters/expanded_terms/analyzer_confidence
 - 4가지 예시 (자연어 법령, 정확 조항, 뉴스 다국어, 의미 불명)
 - classify.txt 구조 참고

app/services/search/query_analyzer.py
 - LLM_TIMEOUT_MS=800 (MLX 멈춤 시 검색 전체 멈춤 방지, 절대 늘리지 말 것)
 - MAX_NORMALIZED_QUERIES=3 (multilingual explosion 방지)
 - in-memory FIFO LRU (maxsize=1000, TTL=86400)
 - cache key = sha256(query + PROMPT_VERSION + primary.model)
   → 모델/프롬프트 변경 시 자동 invalidate
 - 저신뢰(<0.5) / 실패 결과 캐시 금지
 - weight 합=1.0 정규화 (fusion 왜곡 방지)
 - 실패 시 analyzer_confidence=float 0.0 (None 금지, TypeError 방지)

app/api/search.py
 - ?analyze=true|false 파라미터 (default False — 회귀 영향 0)
 - query_analyzer.analyze() 호출 + timing["analyze_ms"] 기록
 - _analyzer_tier(conf) → "ignore" | "original_fallback" | "merge" | "analyzed"
   (tier 게이트: 0.5 / 0.7 / 0.85)
 - debug.query_analysis 필드 채움 + notes에 tier/fallback_reason
 - logger 라인에 analyzer conf/tier 병기

app/services/search_telemetry.py
 - record_search_event(analyzer_confidence=None) 추가
 - base_ctx에 analyzer_confidence 기록 (다층 confidence 시드)
 - result confidence와 분리된 축 — Phase 2.2+에서 failure 분류에 활용

검증:
 - python3 -m py_compile 통과
 - 런타임 검증은 GPU 재배포 후 수행 (fixed 7 query + 평가셋)

참조: ~/.claude/plans/zesty-painting-kahan.md (Phase 2.1 섹션)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:21:37 +09:00
Hyungi Ahn de08735420 fix(ai): primary -> mlx-proxy 8801 + align model to gemma
- endpoint: 100.76.254.116:8800 -> :8801 (route through mlx-proxy for
  /status observability - active_jobs / total_requests)
- model: Qwen3.5-35B-A3B-4bit -> gemma-4-26b-a4b-it-8bit (match the
  model actually loaded on mlx-proxy)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 04:40:06 +00:00
Hyungi Ahn e3ebbe105b merge: origin/main (search Phase 1.2-G + TEI reranker) → design-system
- 백엔드 hybrid retrieval (doc + chunks) + embedding 입력 강화
- TEI reranker 1.7 배포 수정
- frontend 무관, z-index hotfix 와 충돌 없음
2026-04-08 13:32:26 +09:00
Hyungi Ahn 7cf7662ba5 fix(ui): Tailwind v4 z-index 유틸리티 누락 — @utility 로 등록
## 증상
/documents 페이지에서 사이드바 drawer 를 열면 뒤의 필터 칩 row
(`+ 태그`, `+ 형식`) 와 sticky 선택 toolbar 가 사이드바 위로 **그대로
비쳐 보이는** 시각 버그. 사이드바 tree 내용과 섞여 완전히 사용 불가.

## 근본 원인
`@theme { --z-dropdown: 30; --z-drawer: 40; --z-toast: 60 }` 로 정의했지만,
Tailwind v4 는 `--z-*` 를 utility namespace 로 인식하지 않음. 그래서 Drawer
및 페이지의 `class="... z-drawer"`, `class="... z-dropdown"` 이
컴파일 CSS 에 **아예 없는 클래스 (.z-drawer 등 생성 안 됨)** → `z-index:
auto` 로 fallback.

CSS 2.1 stacking 규칙상 positioned z-auto 끼리는 **DOM order** 로 paint 됨.
layout.svelte 의 Drawer 가 먼저 렌더되고 페이지 `<slot/>` 의 `.relative`
필터 칩 popover 컨테이너가 나중에 렌더 → 필터 칩이 사이드바 위에 그려짐.

`--z-modal` 만 살아남은 이유: Modal.svelte 가 `calc(var(--z-modal) + ...)`
로 inline style 에서 실제 var() 참조해서 Tailwind 가 tree-shaking 에서
제외함.

## 수정
`frontend/src/app.css` 의 `@theme` 블록 바로 아래에 Tailwind v4
`@utility` directive 로 4개 유틸리티 명시 등록:

```css
@utility z-dropdown { z-index: var(--z-dropdown); }
@utility z-drawer   { z-index: var(--z-drawer); }
@utility z-modal    { z-index: var(--z-modal); }
@utility z-toast    { z-index: var(--z-toast); }
```

var() 참조 덕분에 `--z-*` 변수도 tree-shaking 에서 제외됨.

## 다른 파일 변경 없음
Drawer.svelte, documents/+page.svelte, inbox/+page.svelte, Modal.svelte
의 기존 클래스 사용부는 **한 글자도 수정 안 함**. @utility 등록만으로
자동 재활성.

## 검증
- npm run build 통과
- 컴파일 CSS 에 .z-drawer/.z-dropdown/.z-modal 클래스 실제 생성 확인
  (.z-toast 는 소스 사용부가 없어 JIT 제외, 필요 시 자동 생성)
- --z-dropdown/--z-drawer/--z-modal/--z-toast 4개 모두 :root 에 emit
- lint:tokens 168 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:28:54 +09:00
Hyungi Ahn 3bf6193337 fix(deploy): TEI 1.5 → 1.7 (1.5는 reranker 모델 다운로드 버그)
TEI 1.5 첫 시도 시 'builder error: relative URL without a base' 에러로
BAAI/bge-reranker-v2-m3 metadata 다운로드 실패. TEI 1.5의 알려진 버그.

해결: TEI 1.7로 업그레이드 (sequence-classification reranker 모델 지원 개선).
2026-04-08 13:18:37 +09:00
Hyungi Ahn e0f928f429 feat(deploy): Phase 1.3 reranker (TEI bge-reranker-v2-m3) 서비스 추가
docker-compose.yml에 reranker 서비스 추가:
- image: ghcr.io/huggingface/text-embeddings-inference:1.5
- MODEL_ID=BAAI/bge-reranker-v2-m3
- MAX_BATCH_TOKENS=8192, MAX_CONCURRENT_REQUESTS=4
- GPU 1개 할당 (RTX 4070 Ti Super, CUDA 13.0)
- expose 80만 (host 노출 X, internal network 전용)
- reranker_cache volume으로 모델 영속화
- fastapi가 depends_on 안 함 → 단독 시작 가능, reranker 없어도 fastapi 동작
  (rerank_service가 RRF fallback)

다음 단계:
- GPU에서 docker pull로 호환성 확인
- docker compose up -d reranker → warmup
- config.yaml의 rerank.endpoint를 http://reranker:80/rerank로 갱신 (GPU 직접)
- fastapi rebuild + 평가셋 측정 (rerank=true)
2026-04-08 13:16:37 +09:00
Hyungi Ahn 25ef3996ec feat(chunk): Phase 1.2-G embedding 입력 강화 (title + section + text)
Phase 1.2-G hybrid retrieval 측정 결과 Recall 0.66 정체 + 진단:

직접 nl 쿼리 시도 결과 일부 정답 doc(3854, 3981, 3982, 3920, 3921)이
top-100에도 못 들어옴. doc은 corpus + chunks + embedding 모두 정상.

진짜 원인: 자연어 query ↔ 법령 조항 의미 거리 + 짧은 본문 embedding signal 약함.
- query: '유해화학물질을 다루는 회사가 지켜야 할 안전 의무'
- 본문: '화학물질관리법 제4장 유해화학물질 영업자'
- bge-m3 입장: chunk text만으로는 같은 의미인지 못 알아봄

해결: chunks embedding 입력에 doc.title + section_title 포함.
- before: embed(c['text'])
- after:  embed('[제목] {title}\n[섹션] {section}\n[본문] {text}')

기대 효과:
- 짧은 조항 문서 매칭 회복 (3920/3921 등 300자대)
- 자연어 query → 법령 조항 의미 매칭 개선
- Recall 0.66 → 0.72~0.78

영향: chunks embedding 차원/구조 변경 X — 입력 텍스트 prefix만 다름.
재인덱싱 1회로 모든 chunks 재생성 필요.
2026-04-08 13:08:23 +09:00
Hyungi Ahn 2ca67dacea feat(search): Phase 1.2-G hybrid retrieval (doc + chunks)
Phase 1.2-C 평가셋: chunks-only Recall 0.788 → 0.660 catastrophic.
ivfflat probes 1 → 10 → 20 진단 결과 잔여 차이는 chunks vs docs embedding의
본질적 차이 (segment 매칭 vs 전체 본문 평균).

해결: doc + chunks hybrid retrieval (정석).

신규 구조:
- search_vector(): 두 SQL을 asyncio.gather로 병렬 호출
- _search_vector_docs(): documents.embedding cosine top N (recall robust)
- _search_vector_chunks(): document_chunks.embedding window partition
  (doc당 top 2 chunks, ivfflat top inner_k 후 ROW_NUMBER PARTITION)
- _merge_doc_and_chunk_vectors(): 가중치 + dedup
  - chunk score * 1.2 (segment 매칭 더 정확)
  - doc score * 1.0 (recall 보완)
  - doc_id 기준 dedup, chunks 우선

데이터 흐름:
  1. query embedding 1번 (bge-m3)
  2. asyncio.gather([_docs_call(), _chunks_call()])
  3. _merge_doc_and_chunk_vectors → list[SearchResult]
  4. compress_chunks_to_docs (그대로 사용)
  5. fusion (그대로)
  6. (Phase 1.3) chunks_by_doc 회수 → reranker

검증 게이트 (회복 목표):
- Recall@10 ≥ 0.75 (baseline 0.788 - 0.04 이내)
- unique_docs per query ≥ 8
- natural_language_ko Recall ≥ 0.65
- latency p95 < 250ms
2026-04-08 13:02:23 +09:00
Hyungi Ahn 2cfe4b126a merge: origin/main (search Phase 1.2-C) → design-system
- 백엔드 search/chunk 개선 (Phase 1.2-AB → 1.2-C) 통합
- frontend와 충돌 없음 (backend만 변경)
- Phase C/D/F/E 프런트엔드 작업 유지
2026-04-08 12:52:59 +09:00
Hyungi Ahn 4938b25d12 feat(ui): Phase E — PreviewPanel 분할 + detail inline + viewer Tabs
E.1 PreviewPanel 7개 editors/* 분할:
- frontend/src/lib/components/editors/ 신설 (7개 컴포넌트):
  * NoteEditor — 사용자 메모 편집
  * EditUrlEditor — 외부 편집 URL (Synology Drive 등)
  * TagsEditor — 태그 추가/삭제
  * AIClassificationEditor — AI 분류 read-only 표시
    (breadcrumb + document_type + confidence tone Badge + importance)
  * FileInfoView — 파일 메타 dl
  * ProcessingStatusView — 파이프라인 단계 status dl
  * DocumentDangerZone — 삭제 (ConfirmDialog 프리미티브 + id 고유화)
- PreviewPanel.svelte 344줄 → 60줄 얇은 wrapper로 축소
  (header + 7개 editors 조합만)
- DocumentMetaRail (D.1)과 detail 페이지(E.2)가 동일 editors 재사용

E.2 detail 페이지 inline 편집:
- documents/[id]/+page.svelte: 기존 read-only 메타 패널 전면 교체
- 오른쪽 aside = 7개 editors 스택 (Card 프리미티브로 감쌈)
- 왼쪽 affordance row: Synology 편집 / 다운로드 / 링크 복사
- 삭제는 DocumentDangerZone이 담당 (ondelete → goto /documents)
- loading/error 상태도 EmptyState 프리미티브로 교체
- marked/DOMPurify renderer 유지, viewer 분기 그대로

E.3 관련 문서 stub:
- detail 페이지 오른쪽 aside에 "관련 문서" Card
- EmptyState "추후 지원" + TODO(backend) GET /documents/{id}/related

E.4 DocumentViewer Tabs 프리미티브:
- Markdown 편집 모드의 편집/미리보기 토글 → Tabs 프리미티브
- 키보드 nav (←→/Home/End), ARIA tablist/tab/tabpanel 자동 적용

검증:
- npm run build 통과 (editors/* 7개 모두 clean, $state 초기값
  warning은 빈 문자열로 초기화하고 $effect로 doc 동기화해 해결)
- npm run lint:tokens 204 → 168 (detail 페이지 + PreviewPanel 전면
  token 기반 재작성으로 -36)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:51:10 +09:00
Hyungi Ahn f4f9de4402 fix(search): Phase 1.2-C doc-level aggregation으로 다양성 회복
Phase 1.2-C 평가셋: Recall 0.788 → 0.531, natural_language 0.73 → 0.07.

진단:
  단순 chunk top-N(limit*5=25)으로 raw chunks 가져왔는데 같은 doc의
  여러 chunks가 상위에 몰림 → unique doc 다양성 붕괴.
  warm test debug: 'chunks raw=16 compressed=5 unique_docs=10'

해결 (사용자 추천 C):
  Window function ROW_NUMBER() PARTITION BY doc_id로 doc당 top 2 chunks만 반환.

SQL 흐름:
  1. inner CTE topk: ivfflat 인덱스로 top inner_k chunks 빠르게
     (inner_k = max(limit*10, 200))
  2. ranked CTE: PARTITION BY doc_id ORDER BY dist ROW_NUMBER
  3. outer: rn <= 2 (doc당 max 2 chunks) + JOIN documents
  4. limit = limit * 4 (chunks 단위, ~limit*2 unique docs)

reranker 호환:
  doc당 max 2 chunks 그대로 반환 → chunks_by_doc 보존
  compress_chunks_to_docs는 그대로 동작 (best chunk per doc)
  Phase 1.3 reranker가 chunks_by_doc에서 raw chunks 회수 가능

핵심 원칙: vector retrieval은 chunk로 찾고 doc으로 선택해야 한다.
2026-04-08 12:47:22 +09:00
Hyungi Ahn 7a38c95f3f feat(ui): Phase F — Inbox 분류 UX + review_status hotfix
F.1 review_status 버그 fix + 승인 UX 가드:
- PATCH body에 review_status: 'approved' 누락 버그 수정 (hotfix)
  → 기존에는 승인해도 문서가 inbox에서 사라지지 않던 증상 해결
- isApprovable(doc): effective domain(override or original)이 비어 있으면 false
- 미분류 행: 체크박스 disabled + ⚠ "도메인 선택 필요" Badge 인라인 표시
  + 카드 border-warning 강조. 클릭 자체가 막힘 (toast 경고 아님)

F.2 runes 마이그레이션 + 프리미티브 전환:
- let → $state/$derived/$derived.by, onMount 유지
- Card/Button/Select/TextInput/Badge/EmptyState/Skeleton/Modal/
  ConfirmDialog/FormatIcon/TagPill 프리미티브로 전면 재작성
- 기존 bg-[var(--*)] 클러스터 전부 제거

F.3 필터 row:
- source / format Select 드롭다운 (현재 documents에서 동적 집계)
- confidence는 백엔드 ai_confidence 필드 추가 대기 — 주석 TODO(backend)

F.4 처리 단계 가시성:
- extracted_at / ai_processed_at / embedded_at 3개 Badge
  (success tone = 완료, neutral = 대기) + source_channel 표시
- backend 전용 endpoint 없이 기존 응답 필드만으로 stop-gap

F.5 행별 override:
- Map<id, { domain?, tags? }> 로컬 state
- 도메인 select 변경 시 overrides에 기록, 원복 버튼으로 clear
- 승인(approveOne) 시점에 override를 PATCH body에 병합
- 도메인 override로 미분류 → 분류 전환 가능 (바로 승인 가능해짐)

F.6 배치 override + 재시도 stub:
- 선택 toolbar: 일괄 도메인 / 일괄 태그 modal
- 배치 override는 로컬 Map만 갱신, 실제 PATCH는 승인 시 1회
- 재시도 버튼: disabled stub (TODO backend POST /queue/retry)
- 선택 상한 50건, pLimit(5) + Promise.allSettled 일괄 승인

검증:
- npm run build 통과 (a11y 경고 fix: label → span + aria-label)
- npm run lint:tokens 229 → 204 (inbox 레거시 var() 토큰 전부 제거, -25)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:46:03 +09:00
Hyungi Ahn d83842ccd8 feat(ui): Phase D.4/D.5 — keyboard nav + density toggle
D.4 useListKeyboardNav:
- 신규: frontend/src/lib/composables/useListKeyboardNav.svelte.ts
  j/k/Arrow/Enter/Esc, isTypingTarget 가드 (input/textarea/select/
  contenteditable 포커스 시 비활성)
- documents/+page.svelte: kbIndex $state, kbSelectedId $derived,
  items 변경 시 clamp, URL 변경 시 0 리셋
- DocumentTable/DocumentCard: kbSelectedId prop → data-kb-selected
  속성 + ring-accent-ring 시각 표시
- scrollSelectedIntoView: queueMicrotask + querySelector로 현재
  커서를 뷰포트 내로 스크롤 (block: nearest)

D.5 Table density:
- DocumentTable: density prop (compact/comfortable), rowPaddingClass
  ($derived: py-1 | py-2.5), rowTextClass (text-[10px] | text-xs)
- documents/+page.svelte: tableDensity $state, toggleDensity 헬퍼,
  localStorage.tableDensity persistent, 테이블 뷰에서만 토글 버튼
  노출 (Rows2/Rows3 아이콘)
- 뷰 모드 버튼도 token 기반으로 리팩토링

검증:
- npm run build 통과
- npm run lint:tokens 231 → 229 (뷰 모드 버튼 token swap으로 -2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:42:03 +09:00
Hyungi Ahn 76e723cdb1 feat(search): Phase 1.3 TEI reranker 통합 (코드 골격)
데이터 흐름 원칙: fusion=doc 기준 / reranker=chunk 기준 — 절대 섞지 말 것.

신규/수정:
- ai/client.py: rerank() 메서드 추가 (TEI POST /rerank API)
- services/search/rerank_service.py:
    - rerank_chunks() — asyncio.Semaphore(2) + 5s soft timeout + RRF fallback
    - _make_snippet/_extract_window — title + query 중심 200~400 토큰
      (keyword 매치 없으면 첫 800자 fallback)
    - apply_diversity() — max_per_doc=2, top score>=0.90 unlimited
    - warmup_reranker() — 10회 retry + 3초 간격 (TEI 모델 로딩 대기)
    - MAX_RERANK_INPUT=200, MAX_CHUNKS_PER_DOC=2 hard cap
- services/search_telemetry.py: compute_confidence_reranked() — sigmoid score 임계값
- api/search.py:
    - ?rerank=true|false 파라미터 (기본 true, hybrid 모드만)
    - 흐름: fused_docs(limit*5) → chunks_by_doc 회수 → rerank_chunks → apply_diversity
    - text-only 매치 doc은 doc 자체를 chunk처럼 wrap (fallback)
    - rerank 활성 시 confidence는 reranker score 기반
- tests/search_eval/run_eval.py: --rerank true|false 플래그

GPU 적용 보류:
- TEI 컨테이너 추가 (docker-compose.yml) — 별도 작업
- config.yaml rerank.endpoint 갱신 — GPU 직접 (commit 없음)
- 재인덱싱 완료 후 build + warmup + 평가셋 측정
2026-04-08 12:41:47 +09:00
Hyungi Ahn b80116243f feat(search): Phase 1.2-C chunks 기반 vector retrieval + raw chunks 보존
retrieval_service.search_vector를 documents.embedding → document_chunks.embedding로 전환.
fetch_limit = limit*5로 raw chunks를 넓게 가져온 후 doc 기준 압축.

신규: compress_chunks_to_docs(chunks, limit) → (doc_results, chunks_by_doc)
- doc_id 별 best score chunk만 doc_results (fusion 입력)
- 모든 raw chunks는 chunks_by_doc dict에 보존 (Phase 1.3 reranker용)
- '같은 doc 중복으로 RRF가 false boost' 방지

SearchResult: chunk_id / chunk_index / section_title optional 필드 추가.
- text 검색 결과는 None (doc-level)
- vector 검색 결과는 채워짐 (chunk-level)

search.py 흐름:
1. raw_chunks = await search_vector(...)
2. vector_results, chunks_by_doc = compress_chunks_to_docs(raw_chunks, limit)
3. fusion(text_results, vector_results) — doc 기준
4. (Phase 1.3) chunks_by_doc → reranker — chunk 기준

debug notes: raw=N compressed=M unique_docs=K로 흐름 검증.

데이터 의존: 재인덱싱(reindex_all_chunks.py 진행 중) 완료 후 평가셋으로 검증.
2026-04-08 12:36:47 +09:00
Hyungi Ahn 3375a5f1b1 feat(ui): Phase D.3 — multi-select + batch actions (pLimit)
- DocumentTable/DocumentCard: selectable/selectedIds/onselectionchange props
  * Table: 왼쪽 6px 너비 체크박스 컬럼
  * Card: 좌상단 absolute 체크박스 (hover 또는 selected 시 표시)
  * 체크박스 onclick stopPropagation으로 행 select와 분리
- documents/+page.svelte:
  * selectedIds = $state(new Set()), URL/필터 변경 시 자동 초기화
  * sticky 선택 toolbar (selection > 0): N건 / 전체 선택 / 선택 해제 /
    일괄 도메인 / 일괄 태그 / 일괄 삭제
  * 50건 상한 UI 가드 (초과 시 경고 + 모든 bulk 버튼 disabled)
  * Bulk modals:
    - 일괄 도메인: Select (Knowledge/* 6종 + Reference)
    - 일괄 태그: TextInput (기존 ai_tags에 추가, 중복 skip)
    - 일괄 삭제: ConfirmDialog (delete_file=true)
  * runBulk 헬퍼: pLimit(5) + Promise.allSettled로 concurrency 제한,
    성공/실패 카운트 toast (5대 원칙 #4)
  * TODO(backend): POST /documents/batch-update — 단일 트랜잭션으로 교체

검증:
- npm run build 통과 (새 경고 없음, label → span 교체로 a11y clean)
- npm run lint:tokens 231 유지 (신규 코드 위반 0)
- 기존 pLimit.ts (Phase A 머지) 재사용, 외부 의존성 없음

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:34:02 +09:00
Hyungi Ahn 42dfe82c9b feat(chunk): Phase 1.2-E reindex 스크립트 추가
tests/scripts/reindex_all_chunks.py — 전체 documents chunk 재인덱싱 도구.

핵심 요건 (사용자 정의):
- asyncio.Semaphore(N) — 동시 처리 수 제한 (기본 3, Ollama bge-m3 부하 조절)
- checkpoint resume — JSON 파일 atomic swap, 중간 실패/중단 후 재시작 가능
- rate limiting — 작업 간 sleep 0.1초 (Ollama API 보호)
- 진행 로그 — [REINDEX] N/total (P%) ETA: ... fails: N (~2% 단위)

CLI:
- --concurrency, --checkpoint, --rate-limit, --limit (dry-run), --skip-existing

야간 배치 (00:00~06:00):
  PYTHONPATH=app .venv/bin/python tests/scripts/reindex_all_chunks.py \
    --concurrency 3 --checkpoint checkpoints/reindex.json \
    > logs/reindex.log 2>&1 &
2026-04-08 12:31:29 +09:00
Hyungi Ahn 8f312f50a7 feat(ui): Phase D.2 — filter chips + URL sync
- 검색바 아래 새 필터 칩 row: domain/tag/format/source 활성 필터를
  인라인 칩으로 렌더, 각 칩에 X 버튼으로 제거.
- `+ 태그` popover: 현재 결과의 상위 20개 태그 클라이언트 집계
  (items.flatMap(d => d.ai_tags).counts + sort) → 선택 시 ?tag=...
- `+ 형식` popover: FORMATS 화이트리스트 (pdf/hwp/hwpx/md/docx/xlsx/png/jpg)
  → 선택 시 ?format=...
- 바깥 클릭으로 popover 자동 close ($effect + document listener)
- filterFormat $derived + loadDocuments params 확장 + hasActiveFilters 확장
- 결과 헤더는 카운트만 남기고 필터 표시/초기화는 칩 row로 이전 (중복 제거)
- addFilter/removeFilter 헬퍼로 URL 라운드트립 관리 (domain 제거 시 sub_group 함께)
- 백엔드 변경 없음 (GET /documents/가 이미 tag/format 지원)

검증:
- npm run build 통과
- npm run lint:tokens 236 → 231 (신규 코드 0 위반, 결과 헤더 리팩토링으로
  5건 organically 감소)
- popover 키보드 a11y (role=listbox/option, aria-expanded, aria-selected)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:27:13 +09:00
Hyungi Ahn 731d1396e8 fix(chunk): _chunk_legal 영어 법령 sliding window fallback
영어/외국 법령(ai_domain Foreign_Law 등)은 '제N조' 패턴이 없어 split 결과가
1개 element만 나옴 → 서문 chunk(첫 1500자)만 생성되고 본문 대부분 손실.

발견: doc 3759 (Industrial Safety, 93KB 영어) → 1개 chunk만 생성.

수정: parts split 결과가 1개 이하면 _chunk_sliding fallback 호출.
한국어 법령(제N조 패턴 있음)은 기존 분할 로직 그대로 작동.

Phase 1.2-D smoke test에서 발견. 재인덱싱 전 fix 필수.
2026-04-08 12:26:38 +09:00
Hyungi Ahn ffac4975b9 feat(ui): Phase D.1 — 3-panel layout + DocumentMetaRail + useMedia
- 가로 flex 최상위 + 가운데 flex-1 (기존 list/viewer 세로 split 그대로 보존)
- xl+ (≥1280px): 우측 320px persistent rail, 접기 시 40px sliver.
  localStorage.metaRailOpen 으로 상태 유지.
- < xl : 기존 수동 drawer 제거하고 ui/Drawer primitive + uiState 사용.
- 리사이즈 시 xl+ 진입하면 drawer 자동 close (rail로 승계).
- handleKeydown → ui.handleEscape() 로 중앙화.
- ℹ 버튼 token 기반 재작성 (isXl 분기로 rail/drawer 토글).
- PreviewPanel.svelte 한 글자도 수정 없음 (Phase E 영역).

신규:
- frontend/src/lib/composables/useMedia.svelte.ts — matchMedia runes 컴포저블
- frontend/src/lib/components/DocumentMetaRail.svelte — PreviewPanel wrapper

검증:
- npm run build 통과
- npm run lint:tokens 241 → 236 (신규 코드 0 위반, 레거시 drawer/ℹ 버튼
  제거로 5건 organically 감소)
- PreviewPanel diff 0줄

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:18:06 +09:00
Hyungi Ahn b3124928a6 feat(ui): Phase C — 대시보드 위젯 그리드 + dashboardSummary 공유 fetch
- dashboardSummary store 구독으로 SystemStatusDot과 fetch 1회 공유 (60초 폴링)
- Svelte 5 runes + Card/EmptyState/Skeleton/FormatIcon 프리미티브
- 12-col 그리드 (sm 1열 / md 2열 / lg 표 그대로):
  * 행1: stat 4장 (전체/Inbox/법령/시스템 상태)
  * 행2: 파이프라인 가로 막대 차트(8) + 오늘 도메인 누적바(4)
  * 행3: 최근 문서(8) + CalDAV stub(4)
- 신규 util: domainSlug.ts — ai_domain → bg-domain-{slug} + 라벨 매핑
- 새 코드에 bg-[var(--*)] 0건 (lint:tokens 통과)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:13:36 +09:00
Hyungi Ahn f9af8dd355 fix(search): trigram threshold 0.3 → 0.15 (set_limit)
Phase 1.2-B 평가셋 결과 recall 0.788 → 0.750 회귀.
원인: trigram default threshold 0.3이 multi-token 쿼리에서 너무 엄격.

예: '이란 미국 전쟁 글로벌 반응' 같은 5단어 한국어 뉴스 쿼리는
title/ai_summary trigram 매칭이 거의 안 됨.

해결: search_text 시작 시 set_limit(0.15) 호출.
- trigram 매칭 더 관대 (recall ↑)
- precision은 ORDER BY similarity 가중 합산이 보정
- p95 latency 169ms 여유 충분 (목표 500ms)
2026-04-08 11:58:41 +09:00
533 changed files with 67219 additions and 1769 deletions
+15
View File
@@ -17,6 +17,11 @@ logs/
# 데이터 (법령 다운로드 등)
data/
# eval/calibration 실행 결과 (baseline jsonl 등)
# reports/ 는 이미 tracked 파일 있음 → 전체 ignore 하지 않음
results/
artifacts/
# macOS
.DS_Store
._*
@@ -32,3 +37,13 @@ node_modules/
# Docker volumes
pgdata/
caddy_data/
# Host venv (run_eval 등 host에서 실행)
.venv/
# 작업 전 백업 / 롤백 스냅샷 (working tree only, git history 보존이 source of truth)
*.bak
*.bak-*
*.bak_*
*.pre-*
.pre-*/
+106 -151
View File
@@ -1,113 +1,73 @@
# hyungi_Document_Server — Claude Code 작업 가이드
## Infrastructure Reference 📌
운영 사실 (모델명 / 엔드포인트 / IP / 컨테이너 / 포트 / drift) 의 단일 진실 소스(SSOT):
**`~/.claude/projects/-Users-hyungiahn/memory/infra_inventory.md`**
이 파일과 inventory 가 충돌하면 **inventory 가 정답**. 본 CLAUDE.md 는 코딩 규칙·워크플로우·코드 구조에 집중하고 운영 값은 박지 않는다.
운영 변경 정책 (inventory → config → deploy → verify):
1. `infra_inventory.md` 먼저 갱신
2. `config.yaml` / `credentials.env` 갱신
3. deploy (commit → push → GPU pull → `docker compose up -d --build`)
4. verify (smoke endpoint, postgres count, 모니터링)
순서 어기면 drift. 발견 시 inventory `Drift Log` 등록.
**Search experiment soft lock**: Phase 2 search refactor / QueryAnalyzer / run_eval 진행 중일 때 GPU 서버의 `docker compose restart`, `config.yaml` 수정, Ollama pull 금지. flag = `~/.claude/.search-experiment-active`.
---
## 프로젝트 개요
Self-hosted PKM(Personal Knowledge Management) 웹 애플리케이션.
FastAPI + PostgreSQL(pgvector) + SvelteKit + Docker Compose 기반.
GPU 서버를 메인 서버, Mac mini를 AI 추론, Synology NAS를 파일 저장소로 사용.
Self-hosted PKM(Personal Knowledge Management) + 다국 뉴스 비교 분석 웹 애플리케이션.
GPU 서버가 메인 (Docker Compose / DB / 검색 / OCR / 마커), Mac mini = MLX 추론 + Whisper STT, Synology NAS = 파일 원본.
## 핵심 문서
1. `docs/architecture.md` — 전체 시스템 아키텍처 (DB 스키마, AI 전략, 인프라, UI 설계)
2. `docs/deploy.md` — Docker Compose 배포 가이드
3. `docs/development-stages.md` — Phase 0~5 개발 단계별 가이드
1. `README.md` — 외부 소개 (기술 스택 / 주요 기능 / Quick Start)
2. `docs/architecture.md` — 전체 시스템 아키텍처
3. `docs/deploy.md` — Docker Compose 배포 가이드
4. `docs/development-stages.md` — Phase roadmap (역사적 맥락)
## 기술 스택
| 영역 | 기술 |
|------|------|
| 백엔드 | FastAPI (Python 3.11+) |
| 데이터베이스 | PostgreSQL 16 + pgvector + pg_trgm |
| 백엔드 | FastAPI (Python 3.11+), SQLAlchemy 2.0 async, APScheduler |
| DB | PostgreSQL 16 + pgvector + pg_trgm (단일 `pkm` DB) |
| 프론트엔드 | SvelteKit 5 (runes mode) + Tailwind CSS 4 |
| 문서 파싱 | kordoc (HWP/HWPX/PDF → Markdown) + LibreOffice (오피스 → 텍스트/PDF) |
| 리버스 프록시 | Caddy (HTTP only, 앞단 프록시에서 HTTPS 처리) |
| 인증 | JWT + TOTP 2FA |
| 문서 파싱 | kordoc (HWP/HWPX/PDF → MD), LibreOffice headless (오피스), marker (PDF → markdown) |
| OCR | Surya OCR (docker compose `ocr-service`, GPU) |
| STT | MLX Whisper (Mac mini), GPU faster-whisper 는 legacy profile |
| 리버스 프록시 | Caddy (HTTP only, 앞단 home-caddy 가 HTTPS 종료) |
| 인증 | JWT (access) + HttpOnly cookie (refresh) + TOTP 2FA |
| 컨테이너 | Docker Compose |
## 네트워크 환경
## 머신 역할 (자세한 IP / 포트 → inventory)
```
GPU 서버 (RTX 4070 Ti Super, Ubuntu, 메인 서버):
- Docker Compose: FastAPI(:8000), PostgreSQL(:5432), kordoc(:3100),
Caddy(:8080 HTTP only), Ollama(127.0.0.1:11434), AI Gateway(127.0.0.1:8081), frontend(:3000)
- NFS 마운트: /mnt/nas/Document_Server → NAS /volume4/Document_Server
- 외부 접근: document.hyungi.net (Mac mini nginx → Caddy)
- 로컬 IP: 192.168.1.186
| 머신 | 역할 |
|------|------|
| GPU 서버 | Docker Compose 메인: fastapi · frontend · postgres `pkm` · kordoc · ocr-service · marker-service · reranker (TEI) · caddy. Ollama (embedding / 4B 추론). home-gateway 별 compose (ingress + 나노클로 + searxng) |
| Mac mini | MLX 26B 추론 endpoint + MLX Whisper STT. ingress 역할 0 |
| Synology NAS | 파일 원본 (`/volume4/Document_Server/PKM/` → GPU `/mnt/nas/Document_Server` NFS), Synology Office/Drive/Calendar/MailPlus |
| VPS-2 (OVH) | 메일 relay (`relay.hyungi.net:587`), Gitea bare mirror, Secondary MX |
Mac mini M4 Pro (AI 서버 + 앞단 프록시):
- MLX Server: http://100.76.254.116:8800/v1/chat/completions (Qwen3.5-35B-A3B)
- nginx: HTTPS 종료 → GPU 서버 Caddy(:8080)로 프록시
- Tailscale IP: 100.76.254.116
## AI 파이프라인 (역할 기준 — 실제 모델 매핑은 inventory)
Synology NAS (DS1525+):
- LAN IP: 192.168.1.227
- Tailscale IP: 100.101.79.37
- 파일 원본: /volume4/Document_Server/PKM/
- NFS export → GPU 서버
- Synology Drive: https://link.hyungi.net (문서 편집)
- Synology Calendar: CalDAV 태스크 관리
- MailPlus: IMAP(993) + SMTP(465)
```
| 역할 | 위치 |
|------|------|
| 분류/심층 요약 primary | Mac mini MLX 26B |
| Triage (1차 분류) / Fallback / Chat | GPU Ollama 4B |
| Embedding | GPU Ollama (1024d, 다국어) |
| Reranker | GPU TEI 컨테이너 |
| OCR | docker compose `ocr-service` (Surya OCR GPU) — `ai.models.vision` 미사용 |
| STT | Mac mini MLX Whisper large-v3 |
| Premium (수동 trigger) | Anthropic API (`require_explicit_trigger`, 일일 한도) |
## 인증 정보
- 위치: `credentials.env` (프로젝트 루트, .gitignore에 포함)
- 템플릿: `credentials.env.example`
- 스크립트에서 python-dotenv 또는 Docker env_file로 로딩
## AI 모델 구성
```
Primary (Mac mini MLX, Tailscale 경유, 상시, 무료):
mlx-community/Qwen3.5-35B-A3B-4bit — 분류, 태그, 요약
→ http://100.76.254.116:8800/v1/chat/completions
Fallback (GPU Ollama, 같은 Docker 네트워크, MLX 장애 시):
qwen3.5:35b-a3b
→ http://ollama:11434/v1/chat/completions
Premium (Claude API, 종량제, 수동 트리거만):
claude-sonnet — 복잡한 분석, 장문 처리
→ 일일 한도 $5, require_explicit_trigger: true
Embedding (GPU Ollama, 같은 Docker 네트워크):
nomic-embed-text → 벡터 임베딩
Qwen2.5-VL-7B → 이미지/도면 OCR
bge-reranker-v2-m3 → RAG 리랭킹
```
## 프로젝트 구조
```
hyungi_Document_Server/
├── docker-compose.yml
├── Caddyfile ← HTTP only, auto_https off
├── config.yaml ← AI 엔드포인트, NAS 경로, 스케줄
├── credentials.env.example
├── app/ ← FastAPI 백엔드
│ ├── main.py ← 엔트리포인트 + APScheduler (watcher/consumer 포함)
│ ├── Dockerfile ← LibreOffice headless 포함
│ ├── core/ (config, database, auth, utils)
│ ├── models/ (document, task, queue)
│ ├── api/ (documents, search, dashboard, auth, setup)
│ ├── workers/ (file_watcher, extract, classify, embed, preview, law_monitor, mailplus, digest, queue_consumer)
│ ├── prompts/classify.txt
│ └── ai/client.py ← AIClient + parse_json_response (Qwen3.5 thinking 처리)
├── services/kordoc/ ← Node.js 마이크로서비스 (HWP/PDF 파싱)
├── gpu-server/ ← AI Gateway (deprecated, 통합됨)
├── frontend/ ← SvelteKit 5
│ └── src/
│ ├── routes/ ← 페이지 (documents, inbox, settings, login)
│ └── lib/
│ ├── components/ ← Sidebar, DocumentCard, DocumentViewer, PreviewPanel,
│ │ TagPill, FormatIcon, UploadDropzone
│ ├── stores/ ← auth, ui
│ └── api.ts ← fetch wrapper (JWT 토큰 관리)
├── migrations/ ← PostgreSQL 스키마 (schema_migrations로 추적)
├── scripts/
├── docs/
└── tests/
```
호출 시 반드시 `app/ai/client.py``AIClient` 사용 (`call_triage` / `call_primary` / `call_fallback`). 직접 HTTP 호출 금지.
## 문서 처리 파이프라인
@@ -115,82 +75,77 @@ hyungi_Document_Server/
파일 업로드 (드래그 앤 드롭 or file_watcher)
extract (텍스트 추출)
- kordoc: HWP, HWPX, PDF → Markdown
- LibreOffice: xlsx, docx, pptx, odt 등 → txt/csv
- 직접 읽기: md, txt, csv, json, xml, html
↓ ↓
classify (AI 분류) preview (PDF 미리보기 생성)
- Qwen3.5 → domain - LibreOffice → PDF 변환
- tags, summary - 캐시: PKM/.preview/{id}.pdf
- kordoc: HWP, HWPX, PDF → Markdown
- LibreOffice: xlsx, docx, pptx 등 → txt/csv
- 직접 읽기: md, txt, csv, json, xml, html
classify_worker (tier triage) preview / marker
- 4B Ollama → TriageOutput - LibreOffice → PDF 변환
- escalate_to_26b 시 deep_summary - marker → PDF → markdown
- ai_tldr / ai_bullets / inconsistencies
embed (벡터 임베딩)
- nomic-embed-text (768차원)
embed_worker (bge-m3 1024d, doc-level)
chunk_worker (문서 유형별 chunking)
```
**핵심 원칙:**
핵심 원칙:
- 파일은 업로드 위치에 그대로 유지 (물리적 이동 없음)
- 분류(domain/sub_group/tags)는 DB 메타데이터로만 관리
- preview는 classify와 병렬로 실행 (AI 결과 불필요)
- 분류 (`ai_domain` / `ai_sub_group` / `ai_tags` / `category` / `tier`) 는 DB 메타데이터로만 관리
- preview / marker 는 classify 와 병렬
## UI 구조
## 워커 / 스케줄러 (`app/main.py` 의 scheduler.add_job)
```
┌──────────────────────────────────────────────────┐
│ [☰ 사이드바] [PKM / 문서] [ℹ 정보] 버튼│ ← 상단 nav
├──────────────────────────────────────────────────┤
│ [검색바] [모드] [ℹ] │
│ 문서 목록 (30%) — 드래그 업로드 지원 │ ← 상단 영역
│ █ 문서카드 (domain 색상 바 + 포맷 아이콘) │
├──────────────────────────────────────────────────┤
│ 하단 뷰어/편집 (70%) — 전체 너비 │ ← 하단 영역
│ Markdown: split editor (textarea + preview) │
│ PDF: 브라우저 내장 뷰어 │
│ 오피스: PDF 변환 미리보기 + [편집] 새 탭 버튼 │
│ 이미지: img 태그 │
└──────────────────────────────────────────────────┘
- queue_consumer (interval 1m), file_watcher (5m), upload_cleanup (10m)
- study_q_embed (1m), study_q_related_refresh (1m), study_queue (1m), study_session_queue (1m)
- tier_backfill (30m)
- law_monitor (07:00 KST), mailplus_archive (07/18:00 KST)
- daily_digest (20:00 KST)
- **global_digest** (04:00 KST) — Phase 4 country×topic 7일 rolling
- **morning_briefing** (05:10 KST) — 야간 KST 0~5h 수집 뉴스 topic×country 비교
사이드바: 평소 접힘, ☰로 오버레이 (domain 트리 + 스마트 그룹 + Inbox)
정보 패널: ℹ 버튼 → 우측 전체 높이 drawer (메모/태그 편집/메타/처리상태/편집 URL)
```
scheduler timezone = `Asia/Seoul`.
## 데이터 계층
1. **원본 파일** (NAS `/volume4/Document_Server/PKM/`) — 유일한 원본, 위치 변경 없음
2. **가공 데이터** (PostgreSQL) — 텍스트 추출, AI 분류, 검색 인덱스, 메모, 태그
3. **파생물**벡터 임베딩 (pgvector), PDF 미리보기 캐시 (`.preview/`)
1. **원본 파일** NAS `/volume4/Document_Server/PKM/`. 유일한 원본, 위치 변경 없음
2. **가공 데이터** PostgreSQL `pkm` (텍스트, AI 분류, 검색 인덱스, 메모, 태그, briefing, digest, …)
3. **파생물**pgvector embedding, PDF preview 캐시 (`.preview/`), marker 결과 (markdown + extracted_images NAS 저장)
## 코딩 규칙
- Python 3.11+, asyncio, type hints
- SQLAlchemy 2.0+ async 세션
- Svelte 5 runes mode ($state, $derived, $effect — $: 사용 금지)
- 인증 정보는 credentials.env에서 로딩 (하드코딩 금지)
- 로그는 `logs/`에 저장 (Docker 볼륨)
- AI 호출은 반드시 `app/ai/client.py``AIClient`를 통해 (직접 HTTP 호출 금지)
- Svelte 5 runes mode (`$state`, `$derived`, `$effect``$:` 금지)
- 인증 정보는 `credentials.env` 에서 로딩 (하드코딩 금지)
- 로그는 `logs/` (Docker 볼륨)
- AI 호출은 반드시 `app/ai/client.py` `AIClient` 경유
- 한글 주석 사용
- Migration: `migrations/*.sql`에 작성, `init_db()` 자동 실행 (schema_migrations 추적)
- SQL에 BEGIN/COMMIT 금지 (외부 트랜잭션 깨짐)
- 기존 DB에서는 schema_migrations에 수동 이력 등록 필요할 수 있음
- Migration: `migrations/NNN_*.sql`, `init_db()` 자동 실행 (`schema_migrations` 추적)
- SQL `BEGIN/COMMIT` 금지 (외부 트랜잭션 깨짐)
- asyncpg `prepared statement` 가 multi-statement 불허 → 1 statement 1 파일 분리
- 기존 DB 에서는 `schema_migrations` 수동 이력 등록 필요할 수 있음
- 디자인 시스템 토큰 only (`bg-surface`, `text-dim`, `border-default`, `text-accent`, …). `bg-[var(--*)]` 금지 (`lint:tokens` 차단)
- 커밋 메시지: `type(scope): summary` (`feat` / `fix` / `refactor` / `ops` / `incident` / `docs`)
## 개발/배포 워크플로우
## 개발 / 배포 워크플로우
```bash
# 개발 (MacBook Pro)
cd ~/Documents/code/hyungi_Document_Server/
# 코드 작성 → git commit → push (Gitea)
# 배포 (GPU 서버)
ssh gpu
cd ~/Documents/code/hyungi_Document_Server/
git pull
docker compose up -d --build fastapi frontend
```
MacBook Pro (개발) → Gitea push → GPU 서버에서 pull
개발:
cd ~/Documents/code/hyungi_Document_Server/
# 코드 작성 → git commit & push
GPU 서버 배포 (메인):
ssh hyungi@100.111.160.84
cd ~/Documents/code/hyungi_Document_Server/
git pull
docker compose up -d --build fastapi frontend
```
PR 머지는 Gitea UI **Rebase and merge** 기본 (선형 히스토리 + force-push 충돌 회피). 단독 작업 확증 시만 로컬 rebase+FF.
## v1 코드 참조
v1(DEVONthink 기반) 코드는 `v1-final` 태그로 보존:
v1 (DEVONthink 기반) 코드는 `v1-final` 태그로 보존:
```bash
git show v1-final:scripts/law_monitor.py
git show v1-final:scripts/pkm_utils.py
@@ -198,10 +153,10 @@ git show v1-final:scripts/pkm_utils.py
## 주의사항
- credentials.env는 git에 올리지 않음 (.gitignore)
- NAS NFS 마운트 경로: Docker 컨테이너 내 `/documents`
- FastAPI 시작 시 `/documents/PKM` 존재 확인 (NFS 미마운트 방지)
- 법령 API (LAW_OC)는 승인 대기 중
- Ollama/AI Gateway 포트는 127.0.0.1 바인딩 (외부 접근 차단)
- Caddy는 `auto_https off` + `http://` only (HTTPS는 Mac mini nginx에서 처리)
- Synology Office 편집은 새 탭 열기 방식 (iframe 미사용, edit_url 수동 등록)
- `credentials.env` 는 git 에 올리지 않음 (`.gitignore`)
- NAS NFS 마운트: Docker 컨테이너 내 `/documents`. FastAPI 시작 시 `/documents/PKM` 존재 확인
- 법령 API (LAW_OC) 는 승인 대기 중
- Ollama 는 127.0.0.1 바인딩 (외부 접근 차단)
- Caddy 는 `auto_https off` + `http://` only (HTTPS 종료는 앞단 home-caddy 가 처리)
- Synology Office 편집은 새 탭 열기 방식 (iframe 미사용, `edit_url` 수동 등록)
- 한국어 NFS 경로는 NFC↔NFD 비대칭 — 경로 수신 시 NFC→NFD→parent glob fallback 필수
+6
View File
@@ -1,5 +1,11 @@
{
auto_https off
# home-caddy (docker bridge 사설망) 가 TLS 를 종단하고 X-Forwarded-Proto: https
# 를 전달. trusted_proxies 없으면 Caddy 가 incoming scheme (http) 로 덮어써
# FastAPI 307 redirect 의 Location 헤더가 http:// 로 나가 mixed-content block.
servers {
trusted_proxies static private_ranges
}
}
http://document.hyungi.net {
+81 -37
View File
@@ -1,64 +1,108 @@
# hyungi_Document_Server
Self-hosted 개인 지식관리(PKM) 웹 애플리케이션
Self-hosted 개인 지식관리(PKM) + 다국 뉴스 비교 분석 웹 애플리케이션.
> 모델 이름·엔드포인트·머신 정보는 운영 상태에 따라 변하므로 README 에 박지 않습니다.
> 운영 단일 진실 소스(SSOT): `~/.claude/projects/-Users-hyungiahn/memory/infra_inventory.md`.
> 모델/엔드포인트/포트/SSH 어디서든 README 와 inventory 가 충돌하면 **inventory 가 정답**입니다.
## 기술 스택
- **백엔드**: FastAPI + SQLAlchemy (async)
- **데이터베이스**: PostgreSQL 16 + pgvector + pg_trgm
- **프론트엔드**: SvelteKit
- **문서 파싱**: kordoc (HWP/HWPX/PDF → Markdown)
- **AI**: Qwen3.5-35B-A3B (MLX), nomic-embed-text, Claude API (폴백)
- **인프라**: Docker Compose, Caddy, Synology NAS
- **백엔드**: FastAPI + SQLAlchemy 2.0 async, APScheduler cron
- **DB**: PostgreSQL 16 + pgvector + pg_trgm (단일 `pkm` DB)
- **프론트엔드**: SvelteKit 5 (runes mode) + Tailwind CSS 4
- **문서 파싱**: kordoc 마이크로서비스 (HWP/HWPX/PDF → Markdown), LibreOffice headless (오피스), marker (PDF → markdown Phase 1B)
- **AI 파이프라인** (역할별, 자세한 모델 매핑은 inventory):
- 분류/요약 본체: Mac mini MLX 26B (primary)
- Triage / fallback / chat: GPU Ollama 4B
- Embedding: GPU Ollama `bge-m3` (1024d)
- Reranker: GPU TEI 컨테이너 `bge-reranker-v2-m3`
- OCR: docker compose `ocr-service` (Surya OCR GPU)
- STT: Mac mini MLX Whisper large-v3
- Premium (수동 trigger): Anthropic Claude (`require_explicit_trigger`)
- **인증**: JWT (access) + HttpOnly cookie (refresh) + TOTP 2FA
- **인프라**: Docker Compose, Caddy (HTTP only, 앞단 home-caddy 가 HTTPS 종료), Synology NAS NFS
## 주요 기능
- 문서 자동 분류/태그/요약 (AI 기반)
- 전문검색 + 벡터 유사도 검색
- HWP/PDF/Markdown 문서 뷰어
- 법령 변경 모니터링 (산업안전보건법 등)
- 이메일 자동 수집 (MailPlus IMAP)
- 일일 다이제스트
- CalDAV 태스크 연동 (Synology Calendar)
- **문서 자동 분류/태그/요약** — Triage(4B) → Deep summary(26B) tier 분리, 백로그 guard / 텍스트 슬라이스 / inconsistency 감지
- **하이브리드 검색** — pgvector 벡터 + pg_trgm 전문검색 + reranker (bge-reranker-v2-m3) + Ask pipeline (HyDE / evidence_service)
- **다국어 OCR** — Surya OCR GPU (한/영/일/중/독/불 등), NFC/NFD 경로 정규화
- **음성/영상 전사** — MLX Whisper large-v3, `/audio` `/video` 라우트 + direct play
- **법령 변경 모니터링** — `law_monitor` cron, freshness decay (365일 반감기)
- **이메일 자동 수집** — MailPlus IMAP, NFS 저장
- **Phase 4 Global Digest** — 매일 04:00 KST 7일 rolling 뉴스 country×topic 2-level 비교 (`/digest`)
- **야간 뉴스 브리핑** — 매일 05:10 KST KST 자정~05:00 5시간 윈도우, topic×country 비교 분석 1페이지 카드 (`/news`)
- **자료실 (Library)** — 카테고리 facet 분류 + AI 제안 1-click 승인
- **메모/이벤트/공부** — 5초 행동 기록 메모, 일정/할 일/회고 events 도메인, 가스기사 학습 워크스페이스 (274 개념 + 2,100 기출)
- **마크다운 canonical layer** — extracted_images NAS 저장 + `document_images` 메타 + 단기 토큰 인증 (`?token=`)
## Quick Start
```bash
git clone https://git.hyungi.net/hyungi/hyungi_document_server.git hyungi_Document_Server
cd hyungi_Document_Server
git clone https://git.hyungi.net/hyungi/hyungi_document_server.git
cd hyungi_document_server
# 인증 정보 설정
# 인증 정보 (DB 비밀번호, JWT secret, Claude API key 등)
cp credentials.env.example credentials.env
nano credentials.env # 실제 값 입력
$EDITOR credentials.env
# 실행
docker compose up -d
# AI 모델 / 엔드포인트 / 경로
$EDITOR config.yaml # inventory 참조하면서 채움
$EDITOR .env # POSTGRES_PASSWORD, MAC_MINI_HOST, NAS_NFS_PATH 등
docker compose up -d --build
```
`http://localhost:8000/docs` 에서 API 문서 확인
운영 도메인 (GPU 서버 배포 기준): `https://document.hyungi.net`
API 문서: `https://document.hyungi.net/docs`
## 디렉토리 구조
```
├── app/ FastAPI 백엔드 (API, 워커, AI 클라이언트)
├── frontend/ SvelteKit 프론트엔드
├── services/kordoc/ 문서 파싱 마이크로서비스 (Node.js)
├── gpu-server/ GPU 서버 배포 (AI Gateway)
├── migrations/ PostgreSQL 스키마
├── docs/ 설계 문서, 배포 가이드
└── tests/ 테스트 코드
├── app/ FastAPI 백엔드
│ ├── api/ 라우터 (documents, search, briefing, digest, memos, events, study, …)
│ ├── workers/ APScheduler / queue (briefing_worker, digest_worker, classify_worker, …)
│ ├── services/ 도메인 로직 (briefing/, digest/, search/, clustering_common, …)
│ ├── ai/client.py AIClient (call_triage / call_primary / call_fallback, parse_json_response)
│ ├── prompts/ *.txt 프롬프트 (분류, 요약, briefing_comparative, digest_topic, …)
│ ├── policy/ AI envelope + prompt_render
│ └── models/ SQLAlchemy ORM
├── frontend/ SvelteKit 5 (runes mode) + Tailwind
│ └── src/routes/ /news (아침 브리핑) /library /memos /audio /video /study /digest /ask …
├── services/
│ ├── kordoc/ HWP/HWPX/PDF 파싱 (Node.js)
│ ├── ocr/ Surya OCR GPU 서비스 (FastAPI)
│ └── marker/ PDF → markdown Phase 1B
├── migrations/ 255+ SQL migrations (schema_migrations 추적)
├── docs/ 설계 문서
└── tests/ pytest
```
## 인프라 구성
`gpu-server/` 폴더는 v1 잔재로 deprecated (현재 AI Gateway 는 `~/home-gateway/` 별 repo).
| 서버 | 역할 |
|------|------|
| Mac mini M4 Pro | Docker Compose (FastAPI, PostgreSQL, kordoc, Caddy) + MLX AI |
| Synology NAS | 파일 원본 저장, Synology Office/Drive/Calendar/MailPlus |
| GPU 서버 | AI Gateway, 벡터 임베딩, OCR, 리랭킹 |
## 인프라 구성 (운영 기준)
| 머신 | 역할 |
|---|---|
| **GPU 서버** (메인) | Docker Compose (fastapi, frontend, postgres pkm, kordoc, ocr-service, marker-service, reranker(TEI), caddy), Ollama (`bge-m3`, 4B chat), home-gateway 별 compose |
| **Mac mini** | MLX 26B primary 추론 + MLX Whisper STT (HTTP 추론 endpoint only, ingress 역할 0) |
| **Synology NAS** | 파일 원본 (`/volume4/Document_Server/PKM/`), Synology Office/Drive/Calendar/MailPlus, NFS export → GPU |
| **VPS-2** (OVH) | 메일 relay (`relay.hyungi.net:587` SASL+TLS+DKIM+LE), Gitea bare mirror, Secondary MX |
상세 IP / 모델 / 컨테이너 / drift / verify 명령은 `infra_inventory.md` 참조.
## 운영 변경 정책
1. inventory 먼저 갱신
2. `config.yaml` / `credentials.env` 갱신
3. deploy (commit → push Gitea → GPU `git pull && docker compose up -d --build`)
4. verify (smoke endpoints, postgres count, 모니터링)
순서를 어기면 drift. drift 발견 시 `infra_inventory.md` 의 Drift Log 에 등록 후 정정.
## 문서
- [아키텍처](docs/architecture.md) — 전체 시스템 설계
- [배포 가이드](docs/deploy.md) — Docker Compose 배포 방법
- [개발 단계](docs/development-stages.md) — Phase 0~5 개발 계획
- [아키텍처](docs/architecture.md) — DB 스키마, AI 전략, UI 설계
- [배포 가이드](docs/deploy.md) — Docker Compose 배포
- [개발 단계](docs/development-stages.md) — Phase 별 roadmap (Phase 4 Global Digest / 야간 브리핑 등 신규 phase 는 inventory + plan 파일 우선)
+34
View File
@@ -0,0 +1,34 @@
# Third Party Licenses
본 프로젝트는 다음 오픈소스를 사용합니다.
## perfect-freehand
- License: **MIT**
- Repository: https://github.com/steveruizok/perfect-freehand
- Used by: `frontend/src/lib/components/HandwriteCanvas.svelte` — Apple Pencil 압력/tilt
를 반영한 손글씨 stroke 렌더링.
```
MIT License
Copyright (c) 2021 Stephen Ruiz Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
+4 -3
View File
@@ -2,12 +2,13 @@ FROM python:3.11-slim
WORKDIR /app
# LibreOffice headless (PDF 변환용) + 한글/CJK 폰트
# LibreOffice headless (PDF 변환용) + 한글/CJK 폰트 + ffmpeg (비디오 썸네일)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libreoffice-core libreoffice-calc libreoffice-writer libreoffice-impress \
fonts-noto-cjk fonts-noto-cjk-extra fonts-nanum \
fonts-noto-core fonts-noto-extra && \
fonts-noto-core fonts-noto-extra \
ffmpeg && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
@@ -15,4 +16,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]
+159 -16
View File
@@ -21,25 +21,119 @@ def strip_thinking(text: str) -> str:
def parse_json_response(raw: str) -> dict | None:
"""AI 응답에서 JSON 객체 추출 (think 태그, 코드블록 등 제거)"""
"""AI 응답에서 JSON 객체 추출 (think 태그, 코드블록 등 제거).
파싱 시도 순서 (앞 단계가 성공하면 즉시 반환):
1. ``` json fenced 블록 안의 첫 ``{...}`` (DOTALL)
2. balanced 정규식 finditer 의 마지막 매치
3. 전체 cleaned 그대로 json.loads
4. (Phase 4-A 후속) "first ``{`` ~ last ``}``" greedy slice — envelope JSON 안에
내부 따옴표/백틱/뉴라인 때문에 balanced 정규식이 못 잡는 케이스 방어.
raw text 의 첫 ``{`` 부터 마지막 ``}`` 까지 잘라 json.loads. 모델이 JSON 앞뒤
자유 텍스트 섞어도 본체만 추출.
"""
cleaned = strip_thinking(raw)
# 코드블록 내부 JSON 추출
# 1. 코드블록 내부 JSON 추출
code_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", cleaned, re.DOTALL)
if code_match:
cleaned = code_match.group(1)
# 마지막 유효 JSON 객체 찾기
# 2. 마지막 유효 JSON 객체 찾기 (balanced 1단계)
matches = list(re.finditer(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", cleaned, re.DOTALL))
for m in reversed(matches):
try:
return json.loads(m.group())
except json.JSONDecodeError:
continue
# 최후 시도: 전체 텍스트를 JSON으로
# 3. 전체 cleaned
try:
return json.loads(cleaned)
result = json.loads(cleaned)
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass
# 4. greedy slice fallback — first '{' ~ last '}' 까지
first = cleaned.find("{")
last = cleaned.rfind("}")
if first < 0 or last <= first:
return None
candidate = cleaned[first : last + 1]
try:
obj = json.loads(candidate)
return obj if isinstance(obj, dict) else None
except json.JSONDecodeError:
pass
# 5. (Phase 4-A 후속) Markdown 줄바꿈 + LaTeX 수식이 JSON string literal 안에
# raw 로 들어간 케이스 방어. 두 가지 invalid:
# - raw newline (LF/CR/TAB) — JSON 표준 string 안 control char 금지
# - invalid backslash — `\circ`, `\text`, `\,` 같은 LaTeX. JSON valid escape
# 은 `\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX` 만.
# stateful walker — string literal 안에서만 fix. 외부 (object 구조) 의 newline
# 은 valid whitespace 라 보존.
escaped = _fix_json_string_escapes(candidate)
try:
obj = json.loads(escaped)
return obj if isinstance(obj, dict) else None
except json.JSONDecodeError:
return None
_VALID_JSON_ESCAPES = set('"\\/bfnrtu')
def _fix_json_string_escapes(s: str) -> str:
"""JSON string literal 안의 raw newline + invalid backslash 만 escape.
state machine: in_string 토글 (`"` 마주침). string 안에서만:
- raw LF/CR/TAB → ``\\n``/``\\r``/``\\t`` 로 변환
- 백슬래시 다음에 valid escape char (`"\\/bfnrtu`) 면 그대로
- 백슬래시 다음에 invalid char (`\\c`, `\\,`) 면 백슬래시 자체를 ``\\\\`` 로 escape
string 외부 (`{` `,` `:` 사이) 의 raw newline 등은 JSON whitespace 라 보존.
"""
out: list[str] = []
i = 0
n = len(s)
in_string = False
while i < n:
ch = s[i]
if not in_string:
if ch == '"':
in_string = True
out.append(ch)
i += 1
continue
# in_string
if ch == "\\":
nxt = s[i + 1] if i + 1 < n else ""
if nxt in _VALID_JSON_ESCAPES:
out.append(ch)
out.append(nxt)
i += 2
continue
# invalid escape — backslash 자체를 escape
out.append("\\\\")
i += 1
continue
if ch == '"':
in_string = False
out.append(ch)
i += 1
continue
if ch == "\n":
out.append("\\n")
i += 1
continue
if ch == "\r":
out.append("\\r")
i += 1
continue
if ch == "\t":
out.append("\\t")
i += 1
continue
out.append(ch)
i += 1
return "".join(out)
# 프롬프트 로딩
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
@@ -52,24 +146,59 @@ CLASSIFY_PROMPT = _load_prompt("classify.txt") if (PROMPTS_DIR / "classify.txt")
class AIClient:
"""AI Gateway를 통한 통합 클라이언트. 기본값은 항상 Qwen3.5."""
"""AI 모델 통합 클라이언트.
B-0 3-tier routing:
- call_triage(): Mac mini 26B MLX, 상시 호출 (llm_gate 외부 — concurrent 안전성 별 검토)
- call_primary(): Mac mini 26B MLX, 에스컬레이션 전용 (llm_gate Semaphore(1) 는 **caller 책임**)
- call_fallback(): triage/primary 실패 시 최후 방어선. Claude Sonnet 4 API (PR #20 swap 완료)
Legacy: classify() / summarize() 는 기존 호출부(tests/eval runner)를 위해 남겨둠.
신규 worker 경로는 전부 call_triage / call_primary 사용.
"""
def __init__(self):
self.ai = settings.ai
self._http = httpx.AsyncClient(timeout=120)
# ─── 3-tier routing (B-0) ───────────────────────────────────────────────
async def call_triage(self, prompt: str) -> str:
"""Mac mini 26B MLX 직접 호출 (config.yaml ai.models.triage). llm_gate 외부 실행 — PR #20 이후 triage/primary 동일 endpoint 라 concurrent 안전성 별 검토.
timeout 은 config.yaml ai.models.triage.timeout (기본 30s).
실패 시 caller 가 에스컬레이션 또는 fallback 판단.
"""
return await self._request(self.ai.triage, prompt)
async def call_primary(self, prompt: str) -> str:
"""26B MLX 호출. 에스컬레이션 전용.
**caller 가 반드시 `async with get_mlx_gate():` 블록 안에서 호출해야 한다.**
Semaphore(1) 로 동시 호출이 1건으로 제한되어 있고, gate 는 primary 전용.
"""
return await self._request(self.ai.primary, prompt)
async def call_fallback(self, prompt: str) -> str:
"""triage/primary 실패 시 최후 방어선. Claude Sonnet 4 API (config.yaml ai.models.fallback) — PR #20 이후 swap 완료."""
return await self._request(self.ai.fallback, prompt)
# ─── Legacy API (classify_worker 교체 시 제거 예정) ───────────────────
async def classify(self, text: str) -> dict:
"""문서 분류 — 항상 primary(Qwen3.5) 사용"""
"""[DEPRECATED] 기존 classify_worker 전용. B-1 에서 summary_triage 로 대체.
호출부 정리 전 존속. 신규 코드는 call_triage + prompt_render 를 쓸 것.
"""
prompt = CLASSIFY_PROMPT.replace("{document_text}", text)
response = await self._call_chat(self.ai.primary, prompt)
return response
async def summarize(self, text: str, force_premium: bool = False) -> str:
"""문서 요약 — 기본 Qwen3.5, 장문이거나 명시적 요청 시만 Claude"""
model = self.ai.primary
if force_premium or len(text) > 15000:
model = self.ai.premium
return await self._call_chat(model, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
"""[DEPRECATED] 기존 호출부용. B-1 에서 summary_triage 가 tldr 대체."""
if force_premium:
return await self._call_chat(self.ai.premium, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
return await self._call_chat(self.ai.primary, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
async def embed(self, text: str) -> list[float]:
"""벡터 임베딩 — GPU 서버 전용"""
@@ -80,10 +209,24 @@ class AIClient:
response.raise_for_status()
return response.json()["embedding"]
async def ocr(self, image_bytes: bytes) -> str:
"""이미지 OCR — GPU 서버 전용"""
# TODO: Qwen2.5-VL-7B 비전 모델 호출 구현
raise NotImplementedError("OCR는 Phase 1에서 구현")
async def rerank(self, query: str, texts: list[str]) -> list[dict]:
"""TEI bge-reranker-v2-m3 호출 (Phase 1.3).
TEI POST /rerank API:
request: {"query": str, "texts": [str, ...]}
response: [{"index": int, "score": float}, ...] (정렬됨)
timeout은 self.ai.rerank.timeout (config.yaml).
호출자(rerank_service)가 asyncio.Semaphore + try/except로 감쌈.
"""
timeout = float(self.ai.rerank.timeout) if self.ai.rerank.timeout else 5.0
response = await self._http.post(
self.ai.rerank.endpoint,
json={"query": query, "texts": texts},
timeout=timeout,
)
response.raise_for_status()
return response.json()
async def _call_chat(self, model_config, prompt: str) -> str:
"""OpenAI 호환 API 호출 + 자동 폴백"""
+97
View File
@@ -0,0 +1,97 @@
"""EscalationEnvelope — 4B → 26B 핸드오프 계약.
4B 가 "자신이 처리 못한다" 고 판단했을 때 26B 에게 전달하는 구조화 메시지.
26B 는 distilled_context 로 방향을 잡고 original_pointers 로 필요한 원문만 재조회.
PR-A 는 dataclass 계약만 정의. 실제 생성/소비는 PR-B 의 escalation_service 가 담당.
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from typing import Any
ValidFromStage = {
"triage",
"classify",
"summarize_short",
"advice_trigger",
"night_sweep",
"ask_pre",
"unknown", # 호환성용
}
@dataclass(frozen=True)
class EscalationEnvelope:
from_stage: str
escalation_reasons: tuple[str, ...]
risk_flags: tuple[str, ...]
distilled_context: str
original_pointers: dict[str, Any] = field(default_factory=dict)
synthesis_directives: tuple[str, ...] = ()
user_intent: str | None = None
draft_hint: str | None = None
def __post_init__(self) -> None:
if self.from_stage not in ValidFromStage:
raise ValueError(
f"from_stage '{self.from_stage}' not in {ValidFromStage}"
)
if not isinstance(self.escalation_reasons, tuple):
raise TypeError("escalation_reasons must be tuple (for hashability)")
if not isinstance(self.risk_flags, tuple):
raise TypeError("risk_flags must be tuple (for hashability)")
if not isinstance(self.synthesis_directives, tuple):
raise TypeError("synthesis_directives must be tuple (for hashability)")
# -- 26B system prompt 주입용 텍스트 -----------------------------------
def to_system_injection(self) -> str:
lines = [
"=== ESCALATION ENVELOPE (from 4B) ===",
f"from_stage: {self.from_stage}",
f"reasons: {', '.join(self.escalation_reasons) or '(none)'}",
f"risk_flags: {', '.join(self.risk_flags) or '(none)'}",
]
if self.user_intent:
lines.append(f"user_intent: {self.user_intent}")
if self.draft_hint:
lines.append(f"draft_hint: {self.draft_hint}")
if self.synthesis_directives:
lines.append("")
lines.append("synthesis_directives (각 risk_flag 별 지시사항, 반드시 준수):")
for d in self.synthesis_directives:
lines.append(f" - {d}")
if self.distilled_context:
lines.append("")
lines.append("distilled_context (4B 가 압축한 요지 — 참고용, 숫자·인용은 원문 재확인 필수):")
lines.append(self.distilled_context)
if self.original_pointers:
lines.append("")
lines.append("original_pointers (필요 시 재조회):")
lines.append(json.dumps(self.original_pointers, ensure_ascii=False, indent=2))
return "\n".join(lines)
# -- JSON round-trip ---------------------------------------------------
def to_json(self) -> str:
return json.dumps(asdict(self), ensure_ascii=False)
@classmethod
def from_json(cls, s: str) -> EscalationEnvelope:
raw = json.loads(s)
return cls(
from_stage=raw["from_stage"],
escalation_reasons=tuple(raw.get("escalation_reasons", ())),
risk_flags=tuple(raw.get("risk_flags", ())),
distilled_context=raw.get("distilled_context", ""),
original_pointers=raw.get("original_pointers", {}) or {},
synthesis_directives=tuple(raw.get("synthesis_directives", ())),
user_intent=raw.get("user_intent"),
draft_hint=raw.get("draft_hint"),
)
+72
View File
@@ -0,0 +1,72 @@
"""오디오 전사(STT) 조회 API — /api/audio
AudioPlayer 가 줄 단위로 렌더하고 클릭 시 audio.currentTime 으로 점프한다.
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.audio_segment import AudioSegment
from models.document import Document
from models.user import User
router = APIRouter()
class AudioSegmentResponse(BaseModel):
start: float
end: float
text: str
model_config = {"from_attributes": True}
class AudioSegmentsResponse(BaseModel):
document_id: int
language: str | None
duration: float | None
segments: list[AudioSegmentResponse]
@router.get("/{doc_id}/segments", response_model=AudioSegmentsResponse)
async def get_audio_segments(
doc_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""audio 문서의 전사 세그먼트 조회.
category='audio' 가 아닌 문서는 404. 세그먼트가 아직 없는 경우 빈 배열 반환.
language / duration 은 현재 ORM 에 별도 컬럼이 없어 None (필요 시 후속 확장).
"""
doc = await session.get(Document, doc_id)
if not doc or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
if getattr(doc, "category", None) != "audio":
raise HTTPException(status_code=404, detail="오디오 문서가 아닙니다")
result = await session.execute(
select(AudioSegment)
.where(AudioSegment.document_id == doc_id)
.order_by(AudioSegment.start_s.asc())
)
rows = result.scalars().all()
segments = [
AudioSegmentResponse(start=r.start_s, end=r.end_s, text=r.text)
for r in rows
]
return AudioSegmentsResponse(
document_id=doc_id,
language=None,
duration=None,
segments=segments,
)
+9
View File
@@ -16,8 +16,10 @@ from core.auth import (
REFRESH_TOKEN_EXPIRE_DAYS,
create_access_token,
create_refresh_token,
create_voice_memo_bot_token,
decode_token,
get_current_user,
verify_password_changed_at,
hash_password,
verify_password,
verify_totp,
@@ -117,6 +119,11 @@ async def login(
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
# Voice Memo PoC v1 — bot 계정 한정 long-expiry token (env gate). 일반 사용자 흐름 영향 0.
bot_token = create_voice_memo_bot_token(user.username)
if bot_token is not None:
return AccessTokenResponse(access_token=bot_token)
# refresh token → HttpOnly cookie
_set_refresh_cookie(response, create_refresh_token(user.username))
@@ -155,6 +162,7 @@ async def refresh_token(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유저를 찾을 수 없음",
)
verify_password_changed_at(payload, user)
# 새 refresh token → cookie
_set_refresh_cookie(response, create_refresh_token(user.username))
@@ -197,5 +205,6 @@ async def change_password(
)
user.password_hash = hash_password(body.new_password)
user.password_changed_at = datetime.now(timezone.utc)
await session.commit()
return {"message": "비밀번호가 변경되었습니다"}
+323
View File
@@ -0,0 +1,323 @@
"""Morning Briefing API — read-only + 수동 regenerate.
엔드포인트:
- GET /api/briefing/latest : 가장 최근 briefing
- GET /api/briefing?date=YYYY-MM-DD : 특정 날짜 briefing
- POST /api/briefing/regenerate?date=... : 동기 워커 트리거 (admin), DELETE+INSERT tx
응답은 topic 평면 list (axis 반대 — Phase 4 와 달리 country 그룹 X).
각 topic 안에 country_perspectives JSONB 가 들어있어 cross-country 비교 분석을 표현.
"""
from datetime import date as date_type
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from core.auth import get_current_user, require_admin
from core.database import get_session
from models.briefing import BriefingTopic, MorningBriefing
from models.user import User
router = APIRouter()
# ─── Pydantic 응답 모델 ───
class CountryPerspective(BaseModel):
country: str
summary: str
article_ids: list[int] = []
class KeyQuote(BaseModel):
country: str = ""
source: str = ""
quote: str
class TopicResponse(BaseModel):
id: int # 2026-05-13 카드 액션 (read/highlight) 호출용 식별자
topic_rank: int
topic_label: str
headline: str
country_perspectives: list[CountryPerspective]
divergences: list[str]
convergences: list[str]
key_quotes: list[KeyQuote]
historical_context: str | None = None
cluster_members: list[int] = []
article_count: int
country_count: int
importance_score: float
llm_fallback_used: bool
# 2026-05-13 사용자 액션 — UI 의 카드별 토글
is_read: bool = False
read_at: datetime | None = None
highlighted: bool = False
highlighted_at: datetime | None = None
class BriefingResponse(BaseModel):
briefing_date: date_type
window_start: datetime
window_end: datetime
decay_lambda: float
total_articles: int
total_countries: int
total_topics: int
generation_ms: int | None
llm_calls: int
llm_failures: int
status: str
headline_oneliner: str | None = None
topics: list[TopicResponse]
class RegenerateResponse(BaseModel):
status: str
briefing_id: int | None
briefing_date: date_type
total_topics: int
total_articles: int
llm_calls: int
llm_failures: int
generation_ms: int
regenerated: bool
# ─── helpers ───
def _build_response(b: MorningBriefing) -> BriefingResponse:
topics = []
for t in sorted(b.topics, key=lambda x: x.topic_rank):
topics.append(
TopicResponse(
id=t.id,
topic_rank=t.topic_rank,
topic_label=t.topic_label,
headline=t.headline,
country_perspectives=[
CountryPerspective(**cp) for cp in (t.country_perspectives or [])
],
divergences=list(t.divergences or []),
convergences=list(t.convergences or []),
key_quotes=[KeyQuote(**q) for q in (t.key_quotes or [])],
historical_context=t.historical_context,
cluster_members=list(t.cluster_members or []),
article_count=t.article_count,
country_count=t.country_count,
importance_score=t.importance_score,
llm_fallback_used=t.llm_fallback_used,
is_read=t.is_read,
read_at=t.read_at,
highlighted=t.highlighted,
highlighted_at=t.highlighted_at,
)
)
return BriefingResponse(
briefing_date=b.briefing_date,
window_start=b.window_start,
window_end=b.window_end,
decay_lambda=b.decay_lambda,
total_articles=b.total_articles,
total_countries=b.total_countries,
total_topics=b.total_topics,
generation_ms=b.generation_ms,
llm_calls=b.llm_calls,
llm_failures=b.llm_failures,
status=b.status,
headline_oneliner=b.headline_oneliner,
topics=topics,
)
async def _load_briefing(
session: AsyncSession,
target_date: date_type | None,
) -> MorningBriefing | None:
query = select(MorningBriefing).options(selectinload(MorningBriefing.topics))
if target_date is not None:
query = query.where(MorningBriefing.briefing_date == target_date)
else:
query = query.order_by(MorningBriefing.briefing_date.desc())
query = query.limit(1)
result = await session.execute(query)
return result.scalar_one_or_none()
# ─── Routes ───
@router.get("/latest", response_model=BriefingResponse)
async def get_latest(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""가장 최근 morning briefing."""
b = await _load_briefing(session, target_date=None)
if b is None:
raise HTTPException(status_code=404, detail="아직 생성된 briefing 없음")
return _build_response(b)
@router.get("", response_model=BriefingResponse)
async def get_briefing(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
date: date_type | None = Query(default=None, description="YYYY-MM-DD (KST briefing_date)"),
):
"""특정 날짜 briefing (date 미지정 시 최신)."""
b = await _load_briefing(session, target_date=date)
if b is None:
raise HTTPException(
status_code=404,
detail=f"briefing 없음 (date={date})" if date else "아직 생성된 briefing 없음",
)
return _build_response(b)
@router.post("/regenerate", response_model=RegenerateResponse)
async def regenerate(
user: Annotated[User, Depends(require_admin)],
date: date_type | None = Query(default=None, description="YYYY-MM-DD KST 기준 briefing_date"),
):
"""수동 트리거 (admin). 동기 실행 — delete+insert transaction.
date 미지정 시 오늘 KST. 같은 날 row 존재 시 transaction 안에서 삭제 후 신규 생성.
응답 status='success' | 'partial' | 'failed' | 'empty'.
"""
from workers.briefing_worker import run
result = await run(target_date=date)
if result is None:
raise HTTPException(status_code=500, detail="briefing 워커 실행 실패 (로그 확인)")
return RegenerateResponse(
status=result["status"],
briefing_id=result.get("briefing_id"),
briefing_date=date or datetime.now().date(),
total_topics=result["total_topics"],
total_articles=result["total_articles"],
llm_calls=result["llm_calls"],
llm_failures=result["llm_failures"],
generation_ms=result["generation_ms"],
regenerated=result.get("regenerated", True),
)
# ─── 2026-05-13 신규: 날짜 선택 + 카드 액션 ───
class BriefingDateSummary(BaseModel):
briefing_date: date_type
total_topics: int
total_articles: int
status: str
read_count: int # 사용자가 읽음 처리한 토픽 수
highlighted_count: int
class TopicActionRequest(BaseModel):
value: bool
class TopicActionResponse(BaseModel):
id: int
is_read: bool
read_at: datetime | None
highlighted: bool
highlighted_at: datetime | None
@router.get("/dates", response_model=list[BriefingDateSummary])
async def list_dates(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
limit: int = Query(default=60, ge=1, le=365),
):
"""사용 가능한 briefing 날짜 목록 (최신 desc). UI date picker 의 데이터 소스."""
from sqlalchemy import func, case
stmt = (
select(
MorningBriefing.briefing_date,
MorningBriefing.total_topics,
MorningBriefing.total_articles,
MorningBriefing.status,
func.count(case((BriefingTopic.is_read.is_(True), 1))).label("read_count"),
func.count(case((BriefingTopic.highlighted.is_(True), 1))).label("highlighted_count"),
)
.outerjoin(BriefingTopic, BriefingTopic.briefing_id == MorningBriefing.id)
.group_by(MorningBriefing.id)
.order_by(MorningBriefing.briefing_date.desc())
.limit(limit)
)
rows = (await session.execute(stmt)).all()
return [
BriefingDateSummary(
briefing_date=r.briefing_date,
total_topics=r.total_topics,
total_articles=r.total_articles,
status=r.status,
read_count=r.read_count or 0,
highlighted_count=r.highlighted_count or 0,
)
for r in rows
]
@router.patch("/topics/{topic_id}/read", response_model=TopicActionResponse)
async def set_topic_read(
topic_id: int,
body: TopicActionRequest,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""토픽 카드 읽음 토글. value=true → 읽음 + read_at=now / false → 해제 + read_at=NULL."""
topic = await session.get(BriefingTopic, topic_id)
if topic is None:
raise HTTPException(status_code=404, detail=f"topic 없음 id={topic_id}")
topic.is_read = body.value
topic.read_at = datetime.now() if body.value else None
await session.commit()
await session.refresh(topic)
return TopicActionResponse(
id=topic.id,
is_read=topic.is_read,
read_at=topic.read_at,
highlighted=topic.highlighted,
highlighted_at=topic.highlighted_at,
)
@router.patch("/topics/{topic_id}/highlight", response_model=TopicActionResponse)
async def set_topic_highlight(
topic_id: int,
body: TopicActionRequest,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""토픽 카드 하이라이트 토글. value=true → highlighted + highlighted_at=now / false → 해제."""
topic = await session.get(BriefingTopic, topic_id)
if topic is None:
raise HTTPException(status_code=404, detail=f"topic 없음 id={topic_id}")
topic.highlighted = body.value
topic.highlighted_at = datetime.now() if body.value else None
await session.commit()
await session.refresh(topic)
return TopicActionResponse(
id=topic.id,
is_read=topic.is_read,
read_at=topic.read_at,
highlighted=topic.highlighted,
highlighted_at=topic.highlighted_at,
)
+34
View File
@@ -0,0 +1,34 @@
"""공개 설정 엔드포인트
이 엔드포인트의 scope:
- 민감정보 없는, 프론트 동작에 필수인 최소 공개 설정만 제공.
- 임의의 서버 설정을 프론트에 노출하는 범용 창구가 아님.
- 필드 추가 시 "민감정보 여부 + 프론트 필수 여부" 2가지 기준 통과 필요.
"""
from fastapi import APIRouter
from pydantic import BaseModel
from core.config import settings
router = APIRouter()
class UploadPublicConfig(BaseModel):
max_bytes: int
class PublicConfigResponse(BaseModel):
upload: UploadPublicConfig
@router.get("/public", response_model=PublicConfigResponse)
async def get_public_config() -> PublicConfigResponse:
"""프론트가 초기 로드 시 조회하는 공개 설정.
현재 제공: upload.max_bytes (업로드 pre-check UX 용도).
slack_ratio, stream_chunk_bytes 등 서버 내부 정책은 노출하지 않음.
"""
return PublicConfigResponse(
upload=UploadPublicConfig(max_bytes=settings.upload.max_bytes),
)
+167 -5
View File
@@ -35,6 +35,42 @@ class PipelineStatus(BaseModel):
count: int
class QueueLag(BaseModel):
"""파이프라인 stage 별 처리 지연 — 운영 카드용.
pipeline_status 는 24h 누적 통계라 현재 적체 신호로 부족.
queue_lag 는 현재 시점 pending/processing/failed + oldest pending age 로
"지금 막힌 게 있는가" 를 보여준다.
"""
stage: str
pending: int
processing: int
failed: int
oldest_pending_age_sec: int | None # 가장 오래된 pending 의 created_at 기준 경과 (초)
class TierHealthStack(BaseModel):
"""PR-B B-3 — tier 관측성 카드 소스 (24h 윈도우).
대시보드 카드 (Day 4 튜닝 — 2026-04-27 임계치 재조정):
- "에스컬레이션 비율": escalated_total / triage_total
· <80% 적색 (정책 매칭 실패 증가 — 진짜 튜닝 필요)
· 80~99% 정상 (safety/health 정책 의도)
- "triage JSON 건강도": triage_json_invalid / triage_total (>5% 적색)
- "Backlog Suppression": suppressed_total / triage_total (>10% 주황)
- "Deep summary 안정성": deep_err_total / deep_total (>5% 적색)
"""
triage_total: int = 0
escalated_total: int = 0
escalation_by_reason: dict[str, int] = {} # long_context / low_confidence / deep_requested / self_declare
escalation_by_domain: dict[str, int] = {} # safety_reference / news_item / ...
triage_json_invalid: int = 0 # error_code='triage_json_invalid'
suppressed_total: int = 0 # suppressed_reason IS NOT NULL
# Day 4 튜닝 신규 — deep_summary 호출 안정성
deep_total: int = 0 # mode='summary_deep' 전체
deep_err_total: int = 0 # error_code IS NOT NULL (call_failed / parse:*)
class DashboardResponse(BaseModel):
today_added: int
today_by_domain: list[DomainCount]
@@ -44,6 +80,16 @@ class DashboardResponse(BaseModel):
pipeline_status: list[PipelineStatus]
failed_count: int
total_documents: int
# 카운트 분리: 문서함(비-note/비-news) / 메모(memo+note) / 뉴스(news)
documents_count: int = 0
memos_count: int = 0
news_count: int = 0
# §4 — category 기반 카드 + 승인 pending + queue lag
category_counts: dict[str, int] = {}
library_pending_suggestions: int = 0
queue_lag: list[QueueLag] = []
# PR-B B-3 — tier 관측성
tier_health: TierHealthStack = TierHealthStack()
@router.get("/", response_model=DashboardResponse)
@@ -82,11 +128,11 @@ async def get_dashboard(
)
law_alerts = law_result.scalar() or 0
# 최근 문서 5
# 최근 문서 7
recent_result = await session.execute(
select(Document)
.order_by(Document.created_at.desc())
.limit(5)
.limit(7)
)
recent_docs = recent_result.scalars().all()
@@ -108,9 +154,118 @@ async def get_dashboard(
)
failed_count = failed_result.scalar() or 0
# 전체 문서 수
total_result = await session.execute(select(func.count(Document.id)))
total_documents = total_result.scalar() or 0
# 전체 문서 수 + 카테고리별 분리 (단일 쿼리)
# 문서함: 비-note, 비-news / 메모: memo+note / 뉴스: news 유입 경로 기준
count_result = await session.execute(
text("""
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE source_channel NOT IN ('news', 'law_monitor') AND file_type != 'note') AS documents,
COUNT(*) FILTER (WHERE source_channel = 'memo' AND file_type = 'note') AS memos,
COUNT(*) FILTER (WHERE source_channel = 'news') AS news
FROM documents WHERE deleted_at IS NULL
""")
)
counts = count_result.one()
total_documents = counts[0]
documents_count = counts[1]
memos_count = counts[2]
news_count = counts[3]
# §4 — 카테고리별 count (§1 documents.category enum)
cat_result = await session.execute(
text("""
SELECT category, COUNT(*)
FROM documents
WHERE deleted_at IS NULL AND category IS NOT NULL
GROUP BY category
""")
)
category_counts = {row[0]: row[1] for row in cat_result.all()}
# §4 — 승인 대기 (library 제안)
pending_result = await session.execute(
text("""
SELECT COUNT(*)
FROM documents
WHERE deleted_at IS NULL
AND ai_suggestion IS NOT NULL
AND ai_suggestion->>'proposed_category' = 'library'
""")
)
library_pending_suggestions = pending_result.scalar() or 0
# §4 — queue lag (현재 시점 stage 별 적체 신호)
# extract/classify/embed 외에 stt/thumbnail (§3) 도 자동 포함.
lag_result = await session.execute(
text("""
SELECT
stage,
COUNT(*) FILTER (WHERE status='pending') AS pending,
COUNT(*) FILTER (WHERE status='processing') AS processing,
COUNT(*) FILTER (WHERE status='failed') AS failed,
EXTRACT(EPOCH FROM (NOW() - MIN(created_at) FILTER (WHERE status='pending')))::int
AS oldest_pending_age_sec
FROM processing_queue
GROUP BY stage
ORDER BY stage
""")
)
queue_lag = [
QueueLag(
stage=row[0],
pending=row[1] or 0,
processing=row[2] or 0,
failed=row[3] or 0,
oldest_pending_age_sec=row[4],
)
for row in lag_result.all()
]
# ─── PR-B B-3 — tier 관측성 (24h) + Day 4 deep_err 추가 ───
tier_rows = (await session.execute(text("""
SELECT
COUNT(*) FILTER (WHERE mode = 'summary_triage') AS triage_total,
COUNT(*) FILTER (WHERE mode = 'summary_triage' AND escalated_to_26b = true) AS escalated_total,
COUNT(*) FILTER (WHERE mode = 'summary_triage' AND error_code = 'triage_json_invalid') AS json_invalid,
COUNT(*) FILTER (WHERE mode = 'summary_triage' AND suppressed_reason IS NOT NULL) AS suppressed_total,
COUNT(*) FILTER (WHERE mode = 'summary_deep') AS deep_total,
COUNT(*) FILTER (WHERE mode = 'summary_deep' AND error_code IS NOT NULL) AS deep_err_total
FROM analyze_events
WHERE created_at > NOW() - INTERVAL '24 hours'
"""))).one()
reason_rows = await session.execute(text("""
SELECT unnest(escalation_reasons) AS reason, COUNT(*) AS n
FROM analyze_events
WHERE created_at > NOW() - INTERVAL '24 hours'
AND mode = 'summary_triage'
AND escalated_to_26b = true
GROUP BY 1 ORDER BY 2 DESC
"""))
escalation_by_reason = {r[0]: r[1] for r in reason_rows if r[0]}
domain_rows = await session.execute(text("""
SELECT subject_domain, COUNT(*) AS n
FROM analyze_events
WHERE created_at > NOW() - INTERVAL '24 hours'
AND mode = 'summary_triage'
AND escalated_to_26b = true
AND subject_domain IS NOT NULL
GROUP BY 1 ORDER BY 2 DESC
"""))
escalation_by_domain = {r[0]: r[1] for r in domain_rows}
tier_health = TierHealthStack(
triage_total=int(tier_rows.triage_total or 0),
escalated_total=int(tier_rows.escalated_total or 0),
triage_json_invalid=int(tier_rows.json_invalid or 0),
suppressed_total=int(tier_rows.suppressed_total or 0),
deep_total=int(tier_rows.deep_total or 0),
deep_err_total=int(tier_rows.deep_err_total or 0),
escalation_by_reason=escalation_by_reason,
escalation_by_domain=escalation_by_domain,
)
return DashboardResponse(
today_added=today_added,
@@ -135,4 +290,11 @@ async def get_dashboard(
],
failed_count=failed_count,
total_documents=total_documents,
documents_count=documents_count,
memos_count=memos_count,
news_count=news_count,
category_counts=category_counts,
library_pending_suggestions=library_pending_suggestions,
queue_lag=queue_lag,
tier_health=tier_health,
)
+164
View File
@@ -0,0 +1,164 @@
"""Phase 4 Global Digest API — read-only + 디버그 regenerate.
엔드포인트:
- GET /api/digest/latest : 가장 최근 digest
- GET /api/digest?date=YYYY-MM-DD : 특정 날짜 digest
- GET /api/digest?country=KR : 특정 국가만
- POST /api/digest/regenerate : 백그라운드 digest 워커 트리거 (auth 필요)
응답은 country → topic 2-level 구조. country 가 비어있는 경우 응답에서 자동 생략.
"""
import asyncio
from datetime import date as date_type
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from core.auth import get_current_user, require_admin
from core.database import get_session
from models.digest import DigestTopic, GlobalDigest
from models.user import User
router = APIRouter()
# ─── Pydantic 응답 모델 (schemas/ 디렉토리 미사용 → inline 정의) ───
class TopicResponse(BaseModel):
topic_rank: int
topic_label: str
summary: str
article_ids: list[int]
article_count: int
importance_score: float
raw_weight_sum: float
llm_fallback_used: bool
class CountryGroup(BaseModel):
country: str
topics: list[TopicResponse]
class DigestResponse(BaseModel):
digest_date: date_type
window_start: datetime
window_end: datetime
decay_lambda: float
total_articles: int
total_countries: int
total_topics: int
generation_ms: int | None
llm_calls: int
llm_failures: int
status: str
countries: list[CountryGroup]
# ─── helpers ───
def _build_response(digest: GlobalDigest, country_filter: str | None = None) -> DigestResponse:
"""ORM 객체 → DigestResponse. country_filter 가 주어지면 해당 국가만."""
topics_by_country: dict[str, list[TopicResponse]] = {}
for t in sorted(digest.topics, key=lambda x: (x.country, x.topic_rank)):
if country_filter and t.country != country_filter:
continue
topics_by_country.setdefault(t.country, []).append(
TopicResponse(
topic_rank=t.topic_rank,
topic_label=t.topic_label,
summary=t.summary,
article_ids=list(t.article_ids or []),
article_count=t.article_count,
importance_score=t.importance_score,
raw_weight_sum=t.raw_weight_sum,
llm_fallback_used=t.llm_fallback_used,
)
)
countries = [
CountryGroup(country=c, topics=topics_by_country[c])
for c in sorted(topics_by_country.keys())
]
return DigestResponse(
digest_date=digest.digest_date,
window_start=digest.window_start,
window_end=digest.window_end,
decay_lambda=digest.decay_lambda,
total_articles=digest.total_articles,
total_countries=digest.total_countries,
total_topics=digest.total_topics,
generation_ms=digest.generation_ms,
llm_calls=digest.llm_calls,
llm_failures=digest.llm_failures,
status=digest.status,
countries=countries,
)
async def _load_digest(
session: AsyncSession,
target_date: date_type | None,
) -> GlobalDigest | None:
"""date 가 주어지면 해당 날짜, 아니면 최신 digest 1건."""
query = select(GlobalDigest).options(selectinload(GlobalDigest.topics))
if target_date is not None:
query = query.where(GlobalDigest.digest_date == target_date)
else:
query = query.order_by(GlobalDigest.digest_date.desc())
query = query.limit(1)
result = await session.execute(query)
return result.scalar_one_or_none()
# ─── Routes ───
@router.get("/latest", response_model=DigestResponse)
async def get_latest(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""가장 최근 생성된 global digest."""
digest = await _load_digest(session, target_date=None)
if digest is None:
raise HTTPException(status_code=404, detail="아직 생성된 digest 없음")
return _build_response(digest)
@router.get("", response_model=DigestResponse)
async def get_digest(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
date: date_type | None = Query(default=None, description="YYYY-MM-DD (KST)"),
country: str | None = Query(default=None, description="국가 코드 (예: KR)"),
):
"""특정 날짜 또는 국가 필터링된 digest. date 미지정 시 최신."""
digest = await _load_digest(session, target_date=date)
if digest is None:
raise HTTPException(
status_code=404,
detail=f"digest 없음 (date={date})" if date else "아직 생성된 digest 없음",
)
country_filter = country.upper() if country else None
return _build_response(digest, country_filter=country_filter)
@router.post("/regenerate")
async def regenerate(
user: Annotated[User, Depends(require_admin)],
):
"""수동 트리거 — 백그라운드 태스크로 워커 실행 (admin 필요)."""
from workers.digest_worker import run
asyncio.create_task(run())
return {"status": "started", "message": "global_digest 워커 백그라운드 실행 시작"}
+151
View File
@@ -0,0 +1,151 @@
"""자료별 손글씨 노트 API.
흐름:
GET /api/documents/{id}/note → 단건 조회 (없으면 strokes_json=None)
PUT /api/documents/{id}/note → upsert (strokes_json + canvas 크기)
DELETE /api/documents/{id}/note → 노트 삭제
ownership:
- documents 에 user_id 부재 (single-user). document_notes.user_id 만으로 분리.
- GET/PUT/DELETE 모두 WHERE user_id=current_user.id AND document_id=:doc_id.
"""
import logging
from datetime import datetime
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.document import Document
from models.document_note import DocumentNote
from models.user import User
logger = logging.getLogger(__name__)
router = APIRouter()
class NoteResponse(BaseModel):
document_id: int
strokes_json: dict[str, Any] | None
canvas_width: int | None
canvas_height: int | None
schema_version: int
updated_at: datetime | None
created_at: datetime | None
class NoteUpdate(BaseModel):
strokes_json: dict[str, Any] | None = None
canvas_width: int | None = None
canvas_height: int | None = None
async def _verify_document(session: AsyncSession, document_id: int) -> Document:
doc = await session.get(Document, document_id)
if doc is None or getattr(doc, "deleted_at", None) is not None:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
return doc
def _empty_response(document_id: int) -> NoteResponse:
return NoteResponse(
document_id=document_id,
strokes_json=None,
canvas_width=None,
canvas_height=None,
schema_version=1,
updated_at=None,
created_at=None,
)
def _to_response(note: DocumentNote) -> NoteResponse:
return NoteResponse(
document_id=note.document_id,
strokes_json=note.strokes_json,
canvas_width=note.canvas_width,
canvas_height=note.canvas_height,
schema_version=note.schema_version,
updated_at=note.updated_at,
created_at=note.created_at,
)
@router.get("/{document_id}/note", response_model=NoteResponse)
async def get_note(
document_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
await _verify_document(session, document_id)
res = await session.execute(
select(DocumentNote).where(
DocumentNote.user_id == user.id,
DocumentNote.document_id == document_id,
)
)
note = res.scalar_one_or_none()
if note is None:
return _empty_response(document_id)
return _to_response(note)
@router.put("/{document_id}/note", response_model=NoteResponse)
async def upsert_note(
document_id: int,
body: NoteUpdate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""upsert — 같은 (user, document) 면 update, 없으면 insert. PostgreSQL ON CONFLICT."""
await _verify_document(session, document_id)
values: dict[str, Any] = {
"user_id": user.id,
"document_id": document_id,
"strokes_json": body.strokes_json,
"canvas_width": body.canvas_width,
"canvas_height": body.canvas_height,
}
stmt = (
pg_insert(DocumentNote)
.values(**values)
.on_conflict_do_update(
index_elements=["user_id", "document_id"],
set_={
"strokes_json": body.strokes_json,
"canvas_width": body.canvas_width,
"canvas_height": body.canvas_height,
"updated_at": datetime.now(),
},
)
.returning(DocumentNote)
)
result = await session.execute(stmt)
note = result.scalar_one()
await session.commit()
return _to_response(note)
@router.delete("/{document_id}/note", status_code=204)
async def delete_note(
document_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
await _verify_document(session, document_id)
res = await session.execute(
select(DocumentNote).where(
DocumentNote.user_id == user.id,
DocumentNote.document_id == document_id,
)
)
note = res.scalar_one_or_none()
if note is not None:
await session.delete(note)
await session.commit()
+112
View File
@@ -0,0 +1,112 @@
"""자료실 회독 카운트 API — append-only 로그 기반.
동작 규칙 (사용자 명시):
- detail 페이지 진입만으로 자동 +1 금지. 명시 클릭 시에만 호출.
- POST /api/documents/{id}/read → row 1개 insert (회독 +1)
- GET /api/documents/{id}/read-stats → {read_count, last_read_at}
- DELETE /api/documents/{id}/read/last → 현재 사용자의 그 문서 마지막 row 1개만 삭제
ownership:
- documents 테이블에 user_id 없음 (single-user). document_reads.user_id 로
사용자 분리. multi-user 전환 시 documents.user_id 추가 후 ownership check 필요.
"""
import logging
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.document import Document
from models.document_read import DocumentRead
from models.user import User
logger = logging.getLogger(__name__)
router = APIRouter()
class ReadStats(BaseModel):
read_count: int
last_read_at: datetime | None
async def _get_stats(
session: AsyncSession, user_id: int, document_id: int
) -> ReadStats:
row = await session.execute(
select(
func.count(DocumentRead.id),
func.max(DocumentRead.read_at),
).where(
DocumentRead.user_id == user_id,
DocumentRead.document_id == document_id,
)
)
count, last = row.one()
return ReadStats(read_count=int(count or 0), last_read_at=last)
async def _verify_document_visible(
session: AsyncSession, document_id: int
) -> Document:
"""문서 존재 + 미삭제 확인. ownership 은 single-user 가정으로 통과."""
doc = await session.get(Document, document_id)
if doc is None or getattr(doc, "deleted_at", None) is not None:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
return doc
@router.post("/{document_id}/read", response_model=ReadStats, status_code=201)
async def add_read(
document_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""회독 +1 — 사용자 명시 클릭. 같은 날 여러 번 호출 가능 (각각 별개 회독)."""
await _verify_document_visible(session, document_id)
session.add(DocumentRead(user_id=user.id, document_id=document_id))
await session.commit()
return await _get_stats(session, user.id, document_id)
@router.get("/{document_id}/read-stats", response_model=ReadStats)
async def get_read_stats(
document_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""현재 사용자의 그 문서 회독 통계."""
await _verify_document_visible(session, document_id)
return await _get_stats(session, user.id, document_id)
@router.delete("/{document_id}/read/last", response_model=ReadStats)
async def delete_last_read(
document_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""현재 사용자의 그 문서 마지막 회독 row 1개만 삭제 (실수 클릭 취소)."""
await _verify_document_visible(session, document_id)
# 현재 사용자 + 해당 문서의 가장 최근 row 1건만.
last = await session.execute(
select(DocumentRead.id)
.where(
DocumentRead.user_id == user.id,
DocumentRead.document_id == document_id,
)
.order_by(DocumentRead.read_at.desc(), DocumentRead.id.desc())
.limit(1)
)
last_id = last.scalar_one_or_none()
if last_id is not None:
await session.execute(
delete(DocumentRead).where(DocumentRead.id == last_id)
)
await session.commit()
return await _get_stats(session, user.id, document_id)
+1050 -42
View File
File diff suppressed because it is too large Load Diff
+680
View File
@@ -0,0 +1,680 @@
"""events API — 개인 운영 로그 / 일정 / 할 일 / 회고 (PR-1).
PR-1 scope (plan beszel-tingly-sloth.md v6):
- POST /api/events (kind=task/calendar_event/activity_log)
- GET /api/events/{id}
- GET /api/events?kind&status&from&to&project_tag&source
- PATCH /api/events/{id} (허용 필드만, 시간 필드 변경 시 reschedule history)
- POST /api/events/{id}/complete | /cancel | /defer | /reactivate
- GET /api/events/today (timezone 정책 적용)
- GET /api/events/inbox
- GET /api/events/activity?from&to
PR-1 제외: DELETE / log shortcut / upcoming / ingest / iCal / ntfy.
"""
import json
import logging
from datetime import date, datetime, timedelta, timezone
from typing import Annotated, Any
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.event import Event
from models.event_history import EventHistory
from models.user import User
logger = logging.getLogger(__name__)
router = APIRouter()
DEFAULT_TIMEZONE = "Asia/Seoul"
# PATCH 허용 필드 — status/completed_at/cancelled_at/defer_until/source/source_ref/
# raw_metadata/user_id/created_by 는 lifecycle endpoint 또는 시스템 결정.
PATCH_ALLOWED_FIELDS = {
"title",
"description",
"due_at",
"start_at",
"end_at",
"started_at",
"ended_at",
"all_day",
"timezone",
"priority",
"project_tag",
"tags",
"memo_document_id",
}
# 시간 필드 변경 시 reschedule history 1건 자동 기록 (defer_until 은 /defer 전용).
RESCHEDULE_TIME_FIELDS = {
"due_at",
"start_at",
"end_at",
"started_at",
"ended_at",
"all_day",
"timezone",
}
# ─── 스키마 ───
class EventCreate(BaseModel):
title: str
description: str | None = None
kind: str # task | calendar_event | activity_log
status: str | None = None # 미지정 시 kind 별 default
due_at: datetime | None = None
start_at: datetime | None = None
end_at: datetime | None = None
started_at: datetime | None = None
ended_at: datetime | None = None
all_day: bool = False
timezone: str | None = None
priority: int | None = None
project_tag: str | None = None
tags: list[Any] = Field(default_factory=list)
memo_document_id: int | None = None
source: str = "manual"
source_ref: str | None = None
raw_metadata: dict[str, Any] = Field(default_factory=dict)
class EventPatch(BaseModel):
"""PATCH 허용 필드만. status/completed_at 등 lifecycle 필드는 명시 거부."""
title: str | None = None
description: str | None = None
due_at: datetime | None = None
start_at: datetime | None = None
end_at: datetime | None = None
started_at: datetime | None = None
ended_at: datetime | None = None
all_day: bool | None = None
timezone: str | None = None
priority: int | None = None
project_tag: str | None = None
tags: list[Any] | None = None
memo_document_id: int | None = None
model_config = {"extra": "forbid"} # 허용 외 필드 → 422
class DeferRequest(BaseModel):
defer_until: datetime
class EventResponse(BaseModel):
id: int
title: str
description: str | None
kind: str
status: str
due_at: datetime | None
start_at: datetime | None
end_at: datetime | None
started_at: datetime | None
ended_at: datetime | None
all_day: bool
timezone: str | None
defer_until: datetime | None
completed_at: datetime | None
cancelled_at: datetime | None
priority: int | None
project_tag: str | None
tags: list[Any]
source: str
source_ref: str | None
raw_metadata: dict[str, Any]
memo_document_id: int | None
user_id: int
created_by: str
created_at: datetime
updated_at: datetime
class EventListResponse(BaseModel):
items: list[EventResponse]
total: int
class EventHistoryResponse(BaseModel):
id: int
event_id: int
changed_at: datetime
changed_by: str
change_kind: str
before: dict[str, Any] | None
after: dict[str, Any]
class EventHistoryListResponse(BaseModel):
items: list[EventHistoryResponse]
# ─── 헬퍼 ───
def _to_response(ev: Event) -> EventResponse:
return EventResponse.model_validate(ev, from_attributes=True)
def _serialize_for_history(ev: Event) -> dict[str, Any]:
"""events_history.before/after 용 dict snapshot (JSON 친화)."""
payload: dict[str, Any] = {}
for col in (
"id",
"title",
"description",
"kind",
"status",
"due_at",
"start_at",
"end_at",
"started_at",
"ended_at",
"all_day",
"timezone",
"defer_until",
"completed_at",
"cancelled_at",
"priority",
"project_tag",
"tags",
"source",
"source_ref",
"raw_metadata",
"memo_document_id",
"user_id",
"created_by",
):
v = getattr(ev, col, None)
if isinstance(v, datetime):
payload[col] = v.isoformat()
else:
payload[col] = v
return payload
def _actor_for_user(user: User) -> str:
"""사용자 직접 호출 = manual. 향후 이드/email_ingest 는 service token 분기 (PR-3)."""
return "manual"
async def _record_history(
session: AsyncSession,
*,
event: Event,
change_kind: str,
changed_by: str,
before: dict[str, Any] | None,
after: dict[str, Any],
) -> None:
history = EventHistory(
event_id=event.id,
changed_by=changed_by,
change_kind=change_kind,
before=before,
after=after,
)
session.add(history)
async def _load_owned(
session: AsyncSession, event_id: int, user: User
) -> Event:
ev = await session.get(Event, event_id)
if ev is None or ev.user_id != user.id:
raise HTTPException(status_code=404, detail="event not found")
return ev
def _resolve_timezone(tz_name: str | None) -> ZoneInfo:
try:
return ZoneInfo(tz_name or DEFAULT_TIMEZONE)
except Exception:
raise HTTPException(status_code=400, detail=f"invalid timezone: {tz_name}")
def _local_day_bounds(tz_name: str | None) -> tuple[datetime, datetime, datetime]:
"""today 의 [start_utc, end_utc) + now_utc 반환."""
tz = _resolve_timezone(tz_name)
now_local = datetime.now(tz)
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow_local = today_local + timedelta(days=1)
return (
today_local.astimezone(timezone.utc),
tomorrow_local.astimezone(timezone.utc),
now_local.astimezone(timezone.utc),
)
def _apply_activity_log_defaults(payload: dict[str, Any]) -> None:
"""빠른 행동 기록 5초 UX — kind=activity_log 시 status/시간 default."""
if payload.get("kind") != "activity_log":
return
now = datetime.now(timezone.utc)
if not payload.get("status"):
payload["status"] = "done"
if payload.get("ended_at") is None:
payload["ended_at"] = now
if payload.get("started_at") is None:
payload["started_at"] = payload["ended_at"]
if payload.get("status") == "done":
payload.setdefault("completed_at", now)
def _apply_kind_default_status(payload: dict[str, Any]) -> None:
"""kind 별 status default 보정."""
if payload.get("status"):
return
kind = payload.get("kind")
if kind == "calendar_event":
payload["status"] = "scheduled"
elif kind == "task":
payload["status"] = "inbox"
# ─── Create ───
@router.post("/", response_model=EventResponse, status_code=201)
async def create_event(
body: EventCreate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""events 생성. kind=activity_log 면 status=done/ended_at=now() default."""
payload = body.model_dump(exclude_none=False)
_apply_activity_log_defaults(payload)
_apply_kind_default_status(payload)
if payload["kind"] not in ("task", "calendar_event", "activity_log"):
raise HTTPException(status_code=400, detail="invalid kind")
actor = _actor_for_user(user)
ev = Event(
title=payload["title"],
description=payload.get("description"),
kind=payload["kind"],
status=payload.get("status") or "inbox",
due_at=payload.get("due_at"),
start_at=payload.get("start_at"),
end_at=payload.get("end_at"),
started_at=payload.get("started_at"),
ended_at=payload.get("ended_at"),
all_day=payload.get("all_day") or False,
timezone=payload.get("timezone"),
completed_at=payload.get("completed_at"),
priority=payload.get("priority"),
project_tag=payload.get("project_tag"),
tags=payload.get("tags") or [],
source=payload.get("source") or "manual",
source_ref=payload.get("source_ref"),
raw_metadata=payload.get("raw_metadata") or {},
memo_document_id=payload.get("memo_document_id"),
user_id=user.id,
created_by=actor,
)
session.add(ev)
await session.flush()
await _record_history(
session,
event=ev,
change_kind="create",
changed_by=actor,
before=None,
after=_serialize_for_history(ev),
)
await session.commit()
await session.refresh(ev)
return _to_response(ev)
# ─── List / Get ───
@router.get("/", response_model=EventListResponse)
async def list_events(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
kind: str | None = Query(None),
status: str | None = Query(None, description="comma-separated list"),
from_: datetime | None = Query(None, alias="from"),
to: datetime | None = Query(None),
project_tag: str | None = Query(None),
source: str | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
):
"""events 목록 — current_user.id 자동 필터. upcoming 은 ?from=now&to=now+7d 로."""
where = [Event.user_id == user.id]
if kind:
where.append(Event.kind == kind)
if status:
statuses = [s.strip() for s in status.split(",") if s.strip()]
if statuses:
where.append(Event.status.in_(statuses))
if project_tag:
where.append(Event.project_tag == project_tag)
if source:
where.append(Event.source == source)
if from_ is not None:
# task: due_at, calendar_event: start_at, activity_log: started_at
where.append(
or_(
Event.due_at >= from_,
Event.start_at >= from_,
Event.started_at >= from_,
)
)
if to is not None:
where.append(
or_(
Event.due_at < to,
Event.start_at < to,
Event.started_at < to,
)
)
base = select(Event).where(and_(*where))
total_q = await session.execute(
select(Event.id).where(and_(*where))
)
total = len(total_q.scalars().all())
rows = await session.execute(
base.order_by(Event.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = [_to_response(e) for e in rows.scalars().all()]
return EventListResponse(items=items, total=total)
@router.get("/today", response_model=EventListResponse)
async def list_today(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
timezone: str | None = Query(None, description="기본 Asia/Seoul"),
):
"""오늘 해야 할 것 / 예정된 것. timezone 적용.
포함: task(due_at today) / calendar_event(start_at today) / activity_log(started_at today)
status: inbox/next/scheduled/in_progress 또는 deferred (defer_until <= now() 일 때만).
"""
start_utc, end_utc, now_utc = _local_day_bounds(timezone)
today_clause = or_(
and_(Event.kind == "task", Event.due_at >= start_utc, Event.due_at < end_utc),
and_(
Event.kind == "calendar_event",
Event.start_at >= start_utc,
Event.start_at < end_utc,
),
and_(
Event.kind == "activity_log",
Event.started_at >= start_utc,
Event.started_at < end_utc,
),
)
active_clause = or_(
Event.status.in_(("inbox", "next", "scheduled", "in_progress")),
and_(Event.status == "deferred", Event.defer_until <= now_utc),
)
rows = await session.execute(
select(Event)
.where(Event.user_id == user.id, today_clause, active_clause)
.order_by(Event.start_at.asc(), Event.due_at.asc(), Event.started_at.asc())
)
items = [_to_response(e) for e in rows.scalars().all()]
return EventListResponse(items=items, total=len(items))
@router.get("/inbox", response_model=EventListResponse)
async def list_inbox(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""Inbox — 아직 정리 안 된 것."""
rows = await session.execute(
select(Event)
.where(Event.user_id == user.id, Event.status == "inbox")
.order_by(Event.created_at.desc())
)
items = [_to_response(e) for e in rows.scalars().all()]
return EventListResponse(items=items, total=len(items))
@router.get("/activity", response_model=EventListResponse)
async def list_activity(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
from_: datetime | None = Query(None, alias="from"),
to: datetime | None = Query(None),
):
"""Activity timeline — 한 일 (kind=activity_log + status=done). Today 와 분리."""
where = [
Event.user_id == user.id,
Event.kind == "activity_log",
Event.status == "done",
]
if from_ is not None:
where.append(Event.started_at >= from_)
if to is not None:
where.append(Event.started_at < to)
rows = await session.execute(
select(Event).where(and_(*where)).order_by(Event.started_at.desc())
)
items = [_to_response(e) for e in rows.scalars().all()]
return EventListResponse(items=items, total=len(items))
@router.get("/{event_id}", response_model=EventResponse)
async def get_event(
event_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
ev = await _load_owned(session, event_id, user)
return _to_response(ev)
@router.get("/{event_id}/history", response_model=EventHistoryListResponse)
async def get_event_history(
event_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""events_history 조회 — 상세 페이지 timeline. lifecycle op 자동 기록만 (v1)."""
await _load_owned(session, event_id, user) # owner 검증
rows = await session.execute(
select(EventHistory)
.where(EventHistory.event_id == event_id)
.order_by(EventHistory.changed_at.desc())
)
items = [
EventHistoryResponse.model_validate(h, from_attributes=True)
for h in rows.scalars().all()
]
return EventHistoryListResponse(items=items)
# ─── PATCH ───
@router.patch("/{event_id}", response_model=EventResponse)
async def patch_event(
event_id: int,
body: EventPatch,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""PATCH — 허용 필드만. 시간 필드 변경 시 reschedule history 자동 기록.
status/completed_at/cancelled_at/defer_until 등 lifecycle 필드는 별 endpoint 강제.
"""
ev = await _load_owned(session, event_id, user)
patch = body.model_dump(exclude_unset=True)
if not patch:
return _to_response(ev)
# 안전 검사 — extra=forbid 로 막혀 있지만 한 번 더.
for k in patch:
if k not in PATCH_ALLOWED_FIELDS:
raise HTTPException(status_code=400, detail=f"field not patchable: {k}")
time_changed = any(k in RESCHEDULE_TIME_FIELDS for k in patch)
before_snapshot = _serialize_for_history(ev) if time_changed else None
for k, v in patch.items():
setattr(ev, k, v)
await session.flush()
if time_changed:
actor = _actor_for_user(user)
await _record_history(
session,
event=ev,
change_kind="reschedule",
changed_by=actor,
before=before_snapshot,
after=_serialize_for_history(ev),
)
await session.commit()
await session.refresh(ev)
return _to_response(ev)
# ─── Lifecycle ───
async def _transition(
session: AsyncSession,
*,
event: Event,
change_kind: str,
new_status: str,
user: User,
extra_apply: dict[str, Any] | None = None,
) -> Event:
actor = _actor_for_user(user)
before = _serialize_for_history(event)
event.status = new_status
if extra_apply:
for k, v in extra_apply.items():
setattr(event, k, v)
await session.flush()
await _record_history(
session,
event=event,
change_kind=change_kind,
changed_by=actor,
before=before,
after=_serialize_for_history(event),
)
return event
@router.post("/{event_id}/complete", response_model=EventResponse)
async def complete_event(
event_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
ev = await _load_owned(session, event_id, user)
now = datetime.now(timezone.utc)
await _transition(
session,
event=ev,
change_kind="complete",
new_status="done",
user=user,
extra_apply={"completed_at": now},
)
await session.commit()
await session.refresh(ev)
return _to_response(ev)
@router.post("/{event_id}/cancel", response_model=EventResponse)
async def cancel_event(
event_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
ev = await _load_owned(session, event_id, user)
now = datetime.now(timezone.utc)
await _transition(
session,
event=ev,
change_kind="cancel",
new_status="cancelled",
user=user,
extra_apply={"cancelled_at": now},
)
await session.commit()
await session.refresh(ev)
return _to_response(ev)
@router.post("/{event_id}/defer", response_model=EventResponse)
async def defer_event(
event_id: int,
body: DeferRequest,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
ev = await _load_owned(session, event_id, user)
await _transition(
session,
event=ev,
change_kind="defer",
new_status="deferred",
user=user,
extra_apply={"defer_until": body.defer_until},
)
await session.commit()
await session.refresh(ev)
return _to_response(ev)
@router.post("/{event_id}/reactivate", response_model=EventResponse)
async def reactivate_event(
event_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""완료/취소/연기 해제 — kind 따라 기본 status 복귀.
task: inbox, calendar_event: scheduled, activity_log: done 유지 안 함 (activity_log 는 done 이 자연 상태이므로 reactivate 적용 X → 400).
"""
ev = await _load_owned(session, event_id, user)
if ev.kind == "activity_log":
raise HTTPException(
status_code=400, detail="activity_log 는 reactivate 대상 아님"
)
new_status = "scheduled" if ev.kind == "calendar_event" else "inbox"
await _transition(
session,
event=ev,
change_kind="reactivate",
new_status=new_status,
user=user,
extra_apply={"completed_at": None, "cancelled_at": None, "defer_until": None},
)
await session.commit()
await session.refresh(ev)
return _to_response(ev)
+75
View File
@@ -0,0 +1,75 @@
"""PR-MacMini-Derived-Worker-1 internal endpoint.
Mac mini derived-worker 가 study explanation 가공을 위해 호출.
GPU = RAG context provider (LLM generation X), Mac mini = LLM 가공 공장.
Bearer token 보호 (settings.internal_worker_token).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, Header, HTTPException, Path, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import settings
from core.database import async_session
from models.study_question import StudyQuestion
from services.study.explanation_rag import gather_explanation_context, render_evidence_block
from workers.study_explanation_worker import _render_envelope_prompt
logger = logging.getLogger(__name__)
router = APIRouter()
def _verify_token(authorization: str | None = Header(default=None)) -> None:
if not settings.internal_worker_token:
raise HTTPException(status_code=503, detail="internal_worker_token not configured")
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="missing Bearer token")
token = authorization[7:].strip()
if token != settings.internal_worker_token:
raise HTTPException(status_code=403, detail="invalid token")
async def _session() -> AsyncSession:
async with async_session() as s:
yield s
@router.get("/explanation-context/{question_id}")
async def get_explanation_context(
question_id: int = Path(..., ge=1),
_auth: None = Depends(_verify_token),
session: AsyncSession = Depends(_session),
):
question = await session.get(StudyQuestion, question_id)
if question is None or question.deleted_at is not None:
raise HTTPException(status_code=410, detail="question deleted or missing")
if question.ai_explanation_status == "ready":
raise HTTPException(status_code=410, detail="explanation already ready")
ctx = await gather_explanation_context(session, question.user_id, question)
docs_count = len(ctx.documents)
qs_count = len(ctx.questions)
if docs_count == 0 and qs_count == 0:
return Response(status_code=204)
doc_block = render_evidence_block(ctx.documents)
q_block = render_evidence_block(ctx.questions)
rendered_prompt = _render_envelope_prompt(question, doc_block, q_block)
logger.info(
"internal_study_context qid=%s docs=%s questions=%s prompt_len=%s",
question_id, docs_count, qs_count, len(rendered_prompt),
)
return {
"question_id": question.id,
"question_correct_choice": question.correct_choice,
"rendered_prompt": rendered_prompt,
"evidence_summary": {
"documents_count": docs_count,
"questions_count": qs_count,
},
}
+544
View File
@@ -0,0 +1,544 @@
"""자료실 분류 체계 CRUD API — /api/library"""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy import text as sql_text
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from core.library import LIBRARY_PREFIX, MAX_DEPTH, normalize_library_path
from models.category import LibraryCategory
from models.document import Document
from models.facet_value import FacetValue
from models.user import User
FACET_TYPES = ("company", "topic", "doctype") # year는 사전 불필요
router = APIRouter()
# ─── 스키마 ───
class CategoryCreate(BaseModel):
path: str
class CategoryRename(BaseModel):
path: str
new_name: str
class CategoryResponse(BaseModel):
id: int
path: str
name: str
parent_path: str | None
depth: int
is_system: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class CategoryTreeNode(BaseModel):
name: str
path: str
count: int
# 현재 사용자 기준, 해당 경로 (하위 경로 포함) 의 안 본 자료 수.
# 0 이면 모두 1+회독.
unread_count: int = 0
is_category: bool
is_system: bool
has_children: bool
children: list["CategoryTreeNode"]
# ─── 엔드포인트 ───
@router.get("/categories", response_model=list[CategoryResponse])
async def list_categories(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""전체 카테고리 flat 목록 (path 순)"""
result = await session.execute(
select(LibraryCategory).order_by(LibraryCategory.path)
)
return [CategoryResponse.model_validate(c) for c in result.scalars().all()]
@router.post("/categories", response_model=CategoryResponse, status_code=201)
async def create_category(
body: CategoryCreate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""카테고리 생성 (조상 자동 생성 포함)"""
try:
normalized = normalize_library_path(body.path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
segments = normalized.split("/")
if len(segments) > MAX_DEPTH:
raise HTTPException(status_code=400, detail=f"최대 {MAX_DEPTH}단계까지 가능")
# 중복 검사
existing = await session.execute(
select(LibraryCategory).where(LibraryCategory.path == normalized)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="이미 존재하는 분류 경로")
# 조상 자동 생성
for i in range(1, len(segments)):
ancestor_path = "/".join(segments[:i])
ancestor_name = segments[i - 1]
ancestor_parent = "/".join(segments[: i - 1]) or None
exists = await session.execute(
select(LibraryCategory.id).where(
LibraryCategory.path == ancestor_path
)
)
if not exists.scalar_one_or_none():
session.add(LibraryCategory(
path=ancestor_path,
name=ancestor_name,
parent_path=ancestor_parent,
depth=i,
))
# 본 카테고리 생성
category = LibraryCategory(
path=normalized,
name=segments[-1],
parent_path="/".join(segments[:-1]) or None,
depth=len(segments),
)
session.add(category)
await session.commit()
await session.refresh(category)
return CategoryResponse.model_validate(category)
@router.patch("/categories", response_model=CategoryResponse)
async def rename_category(
body: CategoryRename,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""카테고리 이름 변경 (leaf only, path 기반 식별)"""
# 카테고리 조회
result = await session.execute(
select(LibraryCategory).where(LibraryCategory.path == body.path)
)
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="분류를 찾을 수 없습니다")
# 시스템 분류 보호
if category.is_system:
raise HTTPException(status_code=422, detail="시스템 분류는 변경할 수 없습니다")
# leaf 검사
children = await session.execute(
select(func.count()).where(
LibraryCategory.parent_path == category.path
)
)
if children.scalar() > 0:
raise HTTPException(
status_code=422, detail="하위 분류가 있어 이름을 변경할 수 없습니다"
)
# new_name 검증
new_name = body.new_name.strip()
if not new_name:
raise HTTPException(status_code=400, detail="빈 이름")
if len(new_name) > 30:
raise HTTPException(status_code=400, detail="이름은 30자 이하")
# 새 path 계산
new_path = (
f"{category.parent_path}/{new_name}" if category.parent_path else new_name
)
# 중복 검사
dup = await session.execute(
select(LibraryCategory.id).where(LibraryCategory.path == new_path)
)
if dup.scalar_one_or_none():
raise HTTPException(status_code=409, detail="같은 이름의 분류가 이미 존재합니다")
old_tag = f"{LIBRARY_PREFIX}{category.path}"
new_tag = f"{LIBRARY_PREFIX}{new_path}"
# 문서 태그 갱신
await session.execute(
sql_text("""
UPDATE documents
SET user_tags = COALESCE((
SELECT jsonb_agg(
CASE WHEN elem = :old_tag THEN :new_tag ELSE elem END
)
FROM jsonb_array_elements_text(
COALESCE(user_tags, '[]'::jsonb)
) AS elem
), '[]'::jsonb)
WHERE user_tags @> :old_tag_jsonb
""").bindparams(
old_tag=old_tag,
new_tag=new_tag,
old_tag_jsonb=f'["{old_tag}"]',
)
)
# 카테고리 row 갱신 (path, name만. parent_path 유지)
category.path = new_path
category.name = new_name
await session.commit()
await session.refresh(category)
return CategoryResponse.model_validate(category)
@router.delete("/categories", status_code=204)
async def delete_category(
path: str = Query(..., description="삭제할 카테고리 경로"),
user: Annotated[User, Depends(get_current_user)] = None,
session: Annotated[AsyncSession, Depends(get_session)] = None,
):
"""카테고리 삭제 (leaf only, 문서 없는 경우만)"""
result = await session.execute(
select(LibraryCategory).where(LibraryCategory.path == path)
)
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="분류를 찾을 수 없습니다")
if category.is_system:
raise HTTPException(status_code=422, detail="시스템 분류는 삭제할 수 없습니다")
# leaf 검사
children = await session.execute(
select(func.count()).where(
LibraryCategory.parent_path == category.path
)
)
if children.scalar() > 0:
raise HTTPException(
status_code=422, detail="하위 분류가 있어 삭제할 수 없습니다"
)
# 문서 연결 검사
tag = f"{LIBRARY_PREFIX}{category.path}"
doc_count = await session.execute(
sql_text("""
SELECT COUNT(*) FROM documents
WHERE deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(
COALESCE(user_tags, '[]'::jsonb)
) AS t
WHERE t = :tag
)
""").bindparams(tag=tag)
)
if doc_count.scalar() > 0:
raise HTTPException(
status_code=422,
detail="이 분류에 속한 문서가 있어 삭제할 수 없습니다. 문서를 먼저 이동하세요.",
)
await session.delete(category)
await session.commit()
@router.get("/tree", response_model=list[CategoryTreeNode])
async def get_library_tree(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""카테고리 저장소 + 문서 태그 count 머지 트리"""
# 1. 카테고리 전체 fetch
cat_result = await session.execute(
select(LibraryCategory).order_by(LibraryCategory.path)
)
categories = cat_result.scalars().all()
# path → category 매핑
cat_map: dict[str, LibraryCategory] = {c.path: c for c in categories}
# 2. 문서 태그에서 doc count 집계
doc_result = await session.execute(
select(Document.id, Document.user_tags).where(
Document.deleted_at == None, # noqa: E711
Document.user_tags != None, # noqa: E711
)
)
# path → set of doc_ids
path_docs: dict[str, set[int]] = {}
for doc_id, tags in doc_result:
if not tags:
continue
seen_ancestors: set[str] = set()
for tag in tags:
if not isinstance(tag, str) or not tag.startswith(LIBRARY_PREFIX):
continue
path = tag[len(LIBRARY_PREFIX):]
parts = path.split("/")
for i in range(1, len(parts) + 1):
ancestor = "/".join(parts[:i])
if ancestor not in seen_ancestors:
path_docs.setdefault(ancestor, set()).add(doc_id)
seen_ancestors.add(ancestor)
# 2.5 현재 사용자가 1+회독 한 doc_id 집합 (안 본 자료 = 전체 - 읽음)
from models.document_read import DocumentRead
read_result = await session.execute(
select(DocumentRead.document_id)
.where(DocumentRead.user_id == user.id)
.group_by(DocumentRead.document_id)
)
read_doc_ids: set[int] = {r[0] for r in read_result}
# 3. 모든 path 합산 (카테고리 + 태그)
all_paths = set(cat_map.keys()) | set(path_docs.keys())
# 4. 트리 구축
root: dict = {}
for p in sorted(all_paths):
parts = p.split("/")
node = root
for i, part in enumerate(parts):
if part not in node:
node[part] = {"_children": {}}
node = node[part]["_children"] if i < len(parts) - 1 else node[part]
def build_tree(d: dict, prefix: str = "") -> list[dict]:
nodes = []
for name, data in sorted(d.items()):
if name.startswith("_"):
continue
path = f"{prefix}/{name}" if prefix else name
children_dict = data.get("_children", {})
children = build_tree(children_dict, path)
cat = cat_map.get(path)
# path_docs[path] 는 이미 본 노드의 자손 doc 까지 누적되어 있음 (위 ancestor 누적 로직).
# 따라서 unread_count 도 하위 경로 전체 합산 (bottom-up 별도 계산 불필요).
docs_at_path = path_docs.get(path, set())
unread = len(docs_at_path - read_doc_ids)
nodes.append(CategoryTreeNode(
name=name,
path=path,
count=len(docs_at_path),
unread_count=unread,
is_category=path in cat_map,
is_system=cat.is_system if cat else False,
has_children=len(children) > 0,
children=children,
))
return nodes
return build_tree(root)
# ─── Facet API (Phase 2) ───
class FacetValueResponse(BaseModel):
facet_type: str
value: str
model_config = {"from_attributes": True}
class FacetCountItem(BaseModel):
value: str
count: int
class FacetCountsResponse(BaseModel):
company: list[FacetCountItem]
topic: list[FacetCountItem]
year: list[FacetCountItem]
doctype: list[FacetCountItem]
@router.get("/facets", response_model=dict[str, list[str]])
async def get_facet_values(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""facet 축별 허용값 사전 (year는 실제 데이터 기반)"""
result: dict[str, list[str]] = {}
for ft in FACET_TYPES:
rows = await session.execute(
select(FacetValue.value)
.where(FacetValue.facet_type == ft)
.order_by(FacetValue.value)
)
result[ft] = [r[0] for r in rows]
# year는 사전 없이 실제 문서 값에서 추출
year_rows = await session.execute(
select(Document.facet_year)
.where(
Document.deleted_at == None, # noqa: E711
Document.facet_year != None, # noqa: E711
)
.distinct()
.order_by(Document.facet_year.desc())
)
result["year"] = [str(r[0]) for r in year_rows]
return result
@router.post("/facets", response_model=FacetValueResponse, status_code=201)
async def add_facet_value(
body: FacetValueResponse,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""facet 사전에 새 값 추가"""
if body.facet_type not in FACET_TYPES:
raise HTTPException(status_code=400, detail=f"허용 facet: {', '.join(FACET_TYPES)}")
value = body.value.strip()
if not value:
raise HTTPException(status_code=400, detail="빈 값")
existing = await session.execute(
select(FacetValue).where(
FacetValue.facet_type == body.facet_type,
FacetValue.value == value,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="이미 존재하는 값")
fv = FacetValue(facet_type=body.facet_type, value=value)
session.add(fv)
await session.commit()
return FacetValueResponse(facet_type=body.facet_type, value=value)
@router.get("/facet-counts", response_model=FacetCountsResponse)
async def get_facet_counts(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
library_path: str | None = None,
facet_company: str | None = None,
facet_topic: str | None = None,
facet_year: int | None = None,
facet_doctype: str | None = None,
q: str | None = None,
):
"""현재 필터 기준 facet별 집계 count"""
def base_query():
query = select(Document).where(
Document.deleted_at == None, # noqa: E711
Document.doc_purpose == "business",
)
if library_path:
exact = f"{LIBRARY_PREFIX}{library_path}"
prefix = f"{LIBRARY_PREFIX}{library_path}/%"
query = query.where(
sql_text("""
EXISTS (
SELECT 1 FROM jsonb_array_elements_text(
COALESCE(documents.user_tags, '[]'::jsonb)
) AS t
WHERE t = :exact OR t LIKE :prefix
)
""").bindparams(exact=exact, prefix=prefix)
)
if q:
query = query.where(Document.title.ilike(f"%{q}%"))
return query
result = FacetCountsResponse(company=[], topic=[], year=[], doctype=[])
# company counts (다른 facet 필터 적용, 자기 자신 제외)
q_company = base_query()
if facet_topic:
q_company = q_company.where(Document.facet_topic == facet_topic)
if facet_year:
q_company = q_company.where(Document.facet_year == facet_year)
if facet_doctype:
q_company = q_company.where(Document.facet_doctype == facet_doctype)
rows = await session.execute(
select(Document.facet_company, func.count())
.where(Document.facet_company != None) # noqa: E711
.where(Document.id.in_(q_company.with_only_columns(Document.id).subquery().select()))
.group_by(Document.facet_company)
.order_by(func.count().desc())
)
result.company = [FacetCountItem(value=r[0], count=r[1]) for r in rows]
# topic counts
q_topic = base_query()
if facet_company:
q_topic = q_topic.where(Document.facet_company == facet_company)
if facet_year:
q_topic = q_topic.where(Document.facet_year == facet_year)
if facet_doctype:
q_topic = q_topic.where(Document.facet_doctype == facet_doctype)
rows = await session.execute(
select(Document.facet_topic, func.count())
.where(Document.facet_topic != None) # noqa: E711
.where(Document.id.in_(q_topic.with_only_columns(Document.id).subquery().select()))
.group_by(Document.facet_topic)
.order_by(func.count().desc())
)
result.topic = [FacetCountItem(value=r[0], count=r[1]) for r in rows]
# year counts
q_year = base_query()
if facet_company:
q_year = q_year.where(Document.facet_company == facet_company)
if facet_topic:
q_year = q_year.where(Document.facet_topic == facet_topic)
if facet_doctype:
q_year = q_year.where(Document.facet_doctype == facet_doctype)
rows = await session.execute(
select(Document.facet_year, func.count())
.where(Document.facet_year != None) # noqa: E711
.where(Document.id.in_(q_year.with_only_columns(Document.id).subquery().select()))
.group_by(Document.facet_year)
.order_by(Document.facet_year.desc())
)
result.year = [FacetCountItem(value=str(r[0]), count=r[1]) for r in rows]
# doctype counts
q_doctype = base_query()
if facet_company:
q_doctype = q_doctype.where(Document.facet_company == facet_company)
if facet_topic:
q_doctype = q_doctype.where(Document.facet_topic == facet_topic)
if facet_year:
q_doctype = q_doctype.where(Document.facet_year == facet_year)
rows = await session.execute(
select(Document.facet_doctype, func.count())
.where(Document.facet_doctype != None) # noqa: E711
.where(Document.id.in_(q_doctype.with_only_columns(Document.id).subquery().select()))
.group_by(Document.facet_doctype)
.order_by(func.count().desc())
)
result.doctype = [FacetCountItem(value=r[0], count=r[1]) for r in rows]
return result
+798
View File
@@ -0,0 +1,798 @@
"""메모 CRUD API — text 메모(file_type='note') + voice 메모 (file_type='immutable', category='audio', source_channel='voice')
doc_type enum = (immutable, editable, note). 기존 audio 파일이 file_type='immutable' + category='audio'
패턴을 사용하므로 voice 메모도 같은 패턴 따름 (enum 확장 회피).
"""
import hashlib
import logging
import os
import re
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.config import settings
from core.database import get_session
from models.document import Document
from models.event import Event
from models.event_history import EventHistory
from models.queue import ProcessingQueue, enqueue_stage
from models.user import User
# Voice upload 제한 (plan v9 결정 — 10분 / 50MB)
VOICE_MAX_BYTES = 50 * 1024 * 1024
VOICE_ALLOWED_EXTS = {".m4a", ".mp3", ".wav", ".webm", ".ogg", ".opus", ".aac"}
VOICE_ALLOWED_CONTENT_PREFIXES = ("audio/",)
VOICE_NAS_SUBDIR = "PKM/Recordings" # /mnt/nas/Document_Server/PKM/Recordings/{YYYY-MM}/{uuid}.{ext}
logger = logging.getLogger(__name__)
router = APIRouter()
# markdown task line: "- [ ] ..." 또는 "- [x] ..."
TASK_LINE_RE = re.compile(r"^(\s*- \[)([ xX])(\].*)$")
# #태그 파싱 패턴: 한글/영문/숫자/밑줄, 2자 이상
TAG_PATTERN = re.compile(r"(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})")
def _parse_hashtags(content: str) -> list[str]:
"""본문에서 #태그 추출, 중복 제거, 순서 유지"""
seen: set[str] = set()
tags: list[str] = []
for m in TAG_PATTERN.finditer(content):
tag = m.group(1)
if tag not in seen:
seen.add(tag)
tags.append(tag)
return tags
def _content_hash(content: str) -> str:
"""메모 본문의 SHA-256 해시 (note의 file_hash = content hash)"""
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _auto_title(content: str) -> str:
"""첫 줄에서 제목 자동 생성 (80자 절단, 마크다운 헤딩 제거)"""
first_line = content.split("\n", 1)[0].strip()
title = re.sub(r"^#+\s*", "", first_line)[:80] or "메모"
return title
def _toggle_task_line(content: str, target_index: int, checked: bool) -> tuple[str, bool]:
"""N번째 markdown task line을 찾아 checked/unchecked 상태로 설정.
(new_content, found) 반환. found=False면 target_index에 해당하는 task line이 없음
(본문 편집으로 drift된 경우).
"""
lines = content.split("\n")
ti = 0
found = False
for i, line in enumerate(lines):
m = TASK_LINE_RE.match(line)
if not m:
continue
if ti == target_index:
mark = "x" if checked else " "
lines[i] = m.group(1) + mark + m.group(3)
found = True
break
ti += 1
return "\n".join(lines), found
def _sync_task_state_with_content(content: str, existing_state: dict | None) -> dict:
"""content 의 체크리스트 상태를 memo_task_state 와 동기화.
- content 의 `- [x]` 중 state 에 checked_at 이 없으면 현재 시각으로 기록
→ 본문에 `- [x]` 로 직접 입력된 legacy 항목도 저장 시각 기준으로 10초 후 숨김 동작.
- content 의 `- [ ]` 에 해당하는 index 는 state 에서 제거.
- content 에 task 가 줄어들어 사라진 index 도 정리.
"""
state = dict(existing_state or {})
current_keys: set[str] = set()
task_idx = 0
now_iso = datetime.now(timezone.utc).isoformat()
for line in (content or "").split("\n"):
m = TASK_LINE_RE.match(line)
if not m:
continue
key = str(task_idx)
is_checked = m.group(2).lower() == "x"
if is_checked:
current_keys.add(key)
entry = state.get(key) or {}
if not entry.get("checked_at"):
state[key] = {"checked_at": now_iso}
# unchecked 는 current_keys 에 넣지 않음 → 아래에서 제거
task_idx += 1
# content 에서 unchecked 가 됐거나 아예 사라진 index 의 state 정리
for k in list(state.keys()):
if k not in current_keys:
state.pop(k, None)
return state
async def _enqueue_ai_stages(session: AsyncSession, document_id: int):
"""classify + embed + chunk 큐 등록. 기존 pending 건 정리 (중복 방지)."""
stages = ["classify", "embed", "chunk"]
await session.execute(
delete(ProcessingQueue).where(
ProcessingQueue.document_id == document_id,
ProcessingQueue.stage.in_(stages),
ProcessingQueue.status == "pending",
)
)
for stage in stages:
await enqueue_stage(session, document_id, stage)
# ─── 스키마 ───
class MemoCreate(BaseModel):
content: str
title: str | None = None # 선택적 제목 (없으면 첫 줄 자동 생성)
ask_includable: bool = True
# PR-Hermes-Docsrv-Bridge-1: 외부 채널 진입점 식별. default='memo' (web UI 호환).
# 허용 값: memo / voice / hermes / ... (app/models/document.py source_channel enum).
source_channel: str | None = None
# PR-Hermes-Docsrv-Bridge-1: channel/user/message_id/timestamp 등 채널 메타.
source_metadata: dict | None = None
class MemoUpdate(BaseModel):
content: str
title: str | None = None # 명시 제목 변경 (None이면 자동 생성)
class ArchiveSet(BaseModel):
archived: bool
class TaskToggle(BaseModel):
checked: bool
class MemoResponse(BaseModel):
id: int
title: str | None
content: str | None # extracted_text
file_format: str
user_tags: list | None
ai_tags: list | None
ai_domain: str | None
ai_sub_group: str | None
ai_summary: str | None
pinned: bool
archived: bool
ask_includable: bool
memo_task_state: dict # {"<task_index>": {"checked_at": "<ISO8601>"}}
# Memo Intake Upgrade PR-2B — AI 추천 분류 (사용자 1-click promote 의 hint)
ai_event_kind: str | None = None
ai_event_confidence: float | None = None
source_channel: str | None = None # voice/memo/hermes 등 진입점 식별 (UI 배지)
source_metadata: dict = {} # PR-Hermes-Docsrv-Bridge-1: channel/user/message_id/timestamp
file_type: str | None = None # audio (voice 메모) vs note (text 메모)
file_path: str | None = None # voice 메모의 NAS audio 경로 (audio player 용)
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class MemoListResponse(BaseModel):
items: list[MemoResponse]
total: int
page: int
page_size: int
def _to_memo_response(doc: Document) -> MemoResponse:
return MemoResponse(
id=doc.id,
title=doc.title,
content=doc.extracted_text,
file_format=doc.file_format,
user_tags=doc.user_tags,
ai_tags=doc.ai_tags,
ai_domain=doc.ai_domain,
ai_sub_group=doc.ai_sub_group,
ai_summary=doc.ai_summary,
pinned=doc.pinned,
archived=doc.archived,
ask_includable=doc.ask_includable,
memo_task_state=dict(doc.memo_task_state or {}),
ai_event_kind=doc.ai_event_kind,
ai_event_confidence=doc.ai_event_confidence,
source_channel=doc.source_channel,
source_metadata=dict(doc.source_metadata or {}),
file_type=doc.file_type,
file_path=doc.file_path,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
# ─── 엔드포인트 ───
@router.post("/", response_model=MemoResponse, status_code=201)
async def create_memo(
body: MemoCreate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 생성 — file_type='note', 파일 없는 문서"""
content = body.content.strip()
if not content:
raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다")
# PR-Hermes-Docsrv-Bridge-1: source_channel/metadata override 가능. default='memo' (기존 web UI 호환).
channel = body.source_channel or "memo"
if channel not in ("memo", "voice", "hermes"):
raise HTTPException(
status_code=400,
detail=f"source_channel '{channel}' 허용 안 됨 (memo/voice/hermes 만)",
)
doc = Document(
file_path=None,
file_hash=_content_hash(content),
file_format="md",
file_size=len(content.encode("utf-8")),
file_type="note",
title=body.title.strip() if body.title and body.title.strip() else _auto_title(content),
extracted_text=content,
review_status="approved",
source_channel=channel,
source_metadata=body.source_metadata or {},
user_tags=_parse_hashtags(content),
pinned=False,
archived=False,
ask_includable=body.ask_includable,
# 본문에 `- [x]` 로 입력된 체크 항목도 생성 시각 기준 10초 후 자동 숨김 대상이 되도록 sync.
memo_task_state=_sync_task_state_with_content(content, None),
)
session.add(doc)
await session.flush()
await _enqueue_ai_stages(session, doc.id)
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
@router.get("/", response_model=MemoListResponse)
async def list_memos(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
tag: str | None = Query(None, description="user_tags 또는 ai_tags 필터"),
archived: bool = Query(False, description="true면 아카이브 목록"),
pinned: bool | None = Query(None, description="true면 핀 고정된 메모만"),
):
"""메모 목록 — 활성: 핀 우선 + 최신순 / 아카이브: 최신순 (핀 무시)
PR-2C: source_channel='voice' (음성 메모) 도 포함. 사용자 의도 = 메모는 모든 입력의 inbox.
voice 메모는 file_type='immutable' + category='audio' + source_channel='voice' 패턴.
source_channel 만으로 분리 (file_type 필터는 immutable 다른 binary 까지 끌어옴 — 회피).
PR-Hermes-Docsrv-Bridge-1: source_channel='hermes' (Hermes Discord 등 외부 채널 진입) 도 inbox 포함.
"""
base = select(Document).where(
Document.source_channel.in_(("memo", "voice", "hermes")),
Document.deleted_at == None, # noqa: E711
Document.archived == archived,
)
if pinned is not None:
base = base.where(Document.pinned == pinned)
if tag:
base = base.where(
Document.user_tags.op("@>")(f'["{tag}"]')
| Document.ai_tags.op("@>")(f'["{tag}"]')
)
count_query = select(func.count()).select_from(base.subquery())
total = (await session.execute(count_query)).scalar() or 0
# 활성: pinned DESC + created_at DESC / 아카이브: created_at DESC (핀 무시)
if archived:
query = base.order_by(Document.created_at.desc())
else:
query = base.order_by(Document.pinned.desc(), Document.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await session.execute(query)
items = result.scalars().all()
return MemoListResponse(
items=[_to_memo_response(doc) for doc in items],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{memo_id}", response_model=MemoResponse)
async def get_memo(
memo_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 단건 조회"""
doc = await session.get(Document, memo_id)
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
return _to_memo_response(doc)
@router.patch("/{memo_id}", response_model=MemoResponse)
async def update_memo(
memo_id: int,
body: MemoUpdate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 수정 — content 변경 시 AI 데이터 초기화 + 재처리 큐 등록"""
doc = await session.get(Document, memo_id)
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
content = body.content.strip()
if not content:
raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다")
doc.extracted_text = content
doc.file_hash = _content_hash(content)
doc.file_size = len(content.encode("utf-8"))
# 본문 편집으로 task 순서/추가/삭제가 일어났을 수 있으니 state 재동기화.
# `- [x]` 에 checked_at 없으면 이번 수정 시각으로 기록 → 10초 후 자동 숨김 동작.
doc.memo_task_state = _sync_task_state_with_content(content, doc.memo_task_state)
# PATCH semantics: title 필드를 명시적으로 보낸 경우만 덮어쓴다.
# 체크박스 토글 경로처럼 {content}만 PATCH 하면 기존 title을 보존해야 함
# (이전엔 None→_auto_title(content)로 제목이 체크박스 라인으로 덮어씌워지는 버그).
if "title" in body.model_fields_set:
doc.title = body.title.strip() if body.title and body.title.strip() else _auto_title(content)
elif not (doc.title or "").strip():
# 기존 title이 비어 있던 경우만 보강
doc.title = _auto_title(content)
doc.user_tags = _parse_hashtags(content)
# stale AI 데이터 즉시 초기화
doc.ai_summary = None
doc.ai_domain = None
doc.ai_sub_group = None
doc.ai_tags = None
doc.ai_confidence = None
doc.ai_processed_at = None
doc.embedding = None
doc.embedded_at = None
# 기존 chunks 삭제
from models.chunk import DocumentChunk
await session.execute(
delete(DocumentChunk).where(DocumentChunk.doc_id == memo_id)
)
# 재처리 큐 등록
await _enqueue_ai_stages(session, memo_id)
doc.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
@router.patch("/{memo_id}/tasks/{task_index}", response_model=MemoResponse)
async def toggle_memo_task(
memo_id: int,
task_index: int,
body: TaskToggle,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 체크박스 토글 전용 엔드포인트.
N번째 markdown task line의 체크 상태를 설정하고 memo_task_state에 시각 기록.
AI 재처리(classify/embed/chunk)는 **의도적으로 스킵** — 체크박스 한 번에 재분석을 트리거하는 건 과하다.
같은 row를 동시에 토글하는 race 방지를 위해 SELECT ... FOR UPDATE 사용.
"""
# ❶ FOR UPDATE: 같은 row 동시 토글 race 차단 (JSONB 전체 replace라 필수)
doc = await session.get(Document, memo_id, with_for_update=True)
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
state = dict(doc.memo_task_state or {})
key = str(task_index)
# ❷ content의 N번째 task line 토글
new_content, found = _toggle_task_line(doc.extracted_text or "", task_index, body.checked)
if not found:
# drift: 사용자가 본문 편집으로 task_index 매칭이 깨짐 → stale state만 정리하고 200 OK
stale_removed = key in state
if stale_removed:
state.pop(key, None)
doc.memo_task_state = state
await session.commit()
await session.refresh(doc)
logger.info(
"memo_task_toggle_drift memo_id=%s task_index=%s stale_removed=%s",
memo_id, task_index, stale_removed,
)
return _to_memo_response(doc)
doc.extracted_text = new_content
doc.file_hash = _content_hash(new_content)
doc.file_size = len(new_content.encode("utf-8"))
# ❸ task_state 갱신 (JSONB 전체 replace — FOR UPDATE lock 아래라 race safe)
if body.checked:
state[key] = {"checked_at": datetime.now(timezone.utc).isoformat()}
else:
state.pop(key, None)
doc.memo_task_state = state
doc.updated_at = datetime.now(timezone.utc)
# AI 재처리 / user_tags 재파싱 / chunks 삭제 / queue enqueue — 모두 의도적 스킵.
# 왜 스킵하는지 나중에 디버깅하지 않아도 되도록 명시 로그.
logger.info(
"memo_task_toggle_skip_ai memo_id=%s task_index=%s checked=%s",
memo_id, task_index, body.checked,
)
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
@router.delete("/{memo_id}", status_code=204)
async def delete_memo(
memo_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 soft delete"""
doc = await session.get(Document, memo_id)
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
doc.deleted_at = datetime.now(timezone.utc)
await session.commit()
@router.patch("/{memo_id}/pin", response_model=MemoResponse)
async def toggle_pin(
memo_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 핀 토글"""
doc = await session.get(Document, memo_id)
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
doc.pinned = not doc.pinned
doc.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
@router.patch("/{memo_id}/archive", response_model=MemoResponse)
async def set_archive(
memo_id: int,
body: ArchiveSet,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 아카이브 설정 (멱등, 토글 아님)"""
doc = await session.get(Document, memo_id)
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
doc.archived = body.archived
doc.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
@router.patch("/{memo_id}/ask-includable", response_model=MemoResponse)
async def toggle_ask_includable(
memo_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""/ask 합성 포함 여부 토글"""
doc = await session.get(Document, memo_id)
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
doc.ask_includable = not doc.ask_includable
doc.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
# ─── Memo Intake Upgrade PR-2B: promote to event ───
class PromotePayload(BaseModel):
"""메모 → events 승급. kind 미지정 시 documents.ai_event_kind 사용.
AI worker 는 events row 직접 생성 X — 본 endpoint 만이 사용자 의도 channel.
"""
kind: str | None = None # 'task' | 'calendar_event' | 'activity_log'
due_at: datetime | None = None
start_at: datetime | None = None
end_at: datetime | None = None
started_at: datetime | None = None
ended_at: datetime | None = None
priority: int | None = None
project_tag: str | None = None
_PROMOTE_KIND_MAP = {
# AI 추천 (event_kind_hint) → events.kind
"task": "task",
"calendar_event": "calendar_event",
"activity_log": "activity_log",
# 'note' / 'reference' 는 promote 대상 아님 (사용자가 명시 kind 지정 필요)
}
@router.post("/{memo_id}/promote-to-event", status_code=201)
async def promote_memo_to_event(
memo_id: int,
body: PromotePayload,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 1건 → events row 1건 생성. memo_document_id 자동 link.
kind 결정 순서: body.kind > documents.ai_event_kind > 400 거부.
한 메모 → N events 가능 (정책: dedup 없음, 사용자 의도 따라).
"""
doc = await session.get(Document, memo_id)
if (
not doc
or doc.deleted_at is not None
or doc.source_channel not in ("memo", "voice")
):
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
# kind 결정
requested = (body.kind or "").strip().lower() or None
ai_hint = (doc.ai_event_kind or "").strip().lower() or None
chosen = requested or ai_hint
event_kind = _PROMOTE_KIND_MAP.get(chosen or "")
if not event_kind:
raise HTTPException(
status_code=400,
detail="promote 할 kind 가 명확하지 않습니다 (task/calendar_event/activity_log 중 1개 지정 또는 ai_event_kind 필요)",
)
# 시간 필드 default — activity_log 는 빠른 행동 기록 UX 그대로
now = datetime.now(timezone.utc)
started_at = body.started_at
ended_at = body.ended_at
completed_at: datetime | None = None
status_val = "inbox"
if event_kind == "activity_log":
ended_at = ended_at or now
started_at = started_at or ended_at
completed_at = now
status_val = "done"
elif event_kind == "calendar_event":
status_val = "scheduled" if body.start_at else "inbox"
title = (doc.title or "").strip() or "메모"
description = doc.extracted_text
ev = Event(
title=title,
description=description,
kind=event_kind,
status=status_val,
due_at=body.due_at,
start_at=body.start_at,
end_at=body.end_at,
started_at=started_at,
ended_at=ended_at,
completed_at=completed_at,
priority=body.priority,
project_tag=body.project_tag,
source="memo",
source_ref=str(doc.id), # 같은 메모 N promote 시 별 row → dedup 의도 X
raw_metadata={
"memo_id": doc.id,
"ai_event_kind": doc.ai_event_kind,
"ai_event_confidence": doc.ai_event_confidence,
"promoted_at": now.isoformat(),
},
memo_document_id=doc.id,
user_id=user.id,
created_by="manual",
)
session.add(ev)
await session.flush()
# events_history.create row (events 도메인 패턴 — events/api/events.py 의 _record_history 와 동일 형태)
history = EventHistory(
event_id=ev.id,
changed_by="manual",
change_kind="create",
before=None,
after={
"id": ev.id,
"title": ev.title,
"kind": ev.kind,
"status": ev.status,
"source": ev.source,
"memo_document_id": ev.memo_document_id,
},
)
session.add(history)
await session.commit()
await session.refresh(ev)
return {
"event_id": ev.id,
"kind": ev.kind,
"status": ev.status,
"memo_document_id": ev.memo_document_id,
}
@router.post("/{memo_id}/dismiss-event-suggestion", response_model=MemoResponse)
async def dismiss_event_suggestion(
memo_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""'그냥 메모' — AI 추천 무시 + ai_event_kind='note' 강제. 4 버튼 숨김 신호.
MVP: AI 추천값과 사용자 확정값을 같은 컬럼에 저장 (정확도 측정 흐려짐 가능).
백로그: user_event_kind 별 컬럼 분리 (plan Memo Intake Upgrade 백로그).
"""
doc = await session.get(Document, memo_id)
if (
not doc
or doc.deleted_at is not None
or doc.source_channel not in ("memo", "voice")
):
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
doc.ai_event_kind = "note"
doc.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
# ─── Memo Intake Upgrade PR-2C: voice upload ───
@router.post("/voice", response_model=MemoResponse, status_code=201)
async def upload_voice_memo(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
audio: UploadFile = File(...),
recorded_at: str | None = Form(None),
device_hint: str | None = Form(None),
):
"""애플워치 / 모바일 / 기타 음성 메모 업로드 → STT 큐 → 자동 분류.
PR-2C: source_channel='voice' + file_type='audio'. 기존 stt_worker → classify
파이프라인 자동 통과. plan 원칙: AI worker 는 events 직접 생성 X.
"""
# Content-Type 검증
if audio.content_type and not audio.content_type.startswith(VOICE_ALLOWED_CONTENT_PREFIXES):
raise HTTPException(status_code=415, detail=f"지원되지 않는 Content-Type: {audio.content_type}")
# 확장자 결정
orig_name = audio.filename or ""
ext = (Path(orig_name).suffix or "").lower()
if ext and ext not in VOICE_ALLOWED_EXTS:
raise HTTPException(status_code=415, detail=f"지원되지 않는 확장자: {ext}")
if not ext:
# content_type 으로 추정 (audio/m4a 등)
ext = ".m4a"
# 본문 읽기 + size 검증
payload: bytes = await audio.read()
if len(payload) > VOICE_MAX_BYTES:
raise HTTPException(status_code=413, detail=f"50MB 초과 ({len(payload)//1024//1024}MB)")
if len(payload) == 0:
raise HTTPException(status_code=400, detail="빈 audio")
# 저장 경로 (NAS) — fastapi 컨테이너 안 /documents = NAS mount
nas_root = Path(settings.nas_mount_path)
yyyy_mm = datetime.now(timezone.utc).astimezone().strftime("%Y-%m")
target_dir = nas_root / VOICE_NAS_SUBDIR / yyyy_mm
target_dir.mkdir(parents=True, exist_ok=True)
file_uuid = uuid.uuid4().hex
target_path = target_dir / f"{file_uuid}{ext}"
# fsync + rename(atomic) 패턴 — NAS soft mount 안전 (feedback_nfs_korean_path_normalize 결)
tmp_path = target_path.with_suffix(target_path.suffix + ".tmp")
try:
with open(tmp_path, "wb") as fh:
fh.write(payload)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path, target_path)
except OSError as e:
# NAS 쓰기 실패 graceful — DB row 미생성
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
logger.error("voice upload NAS write 실패: %s", e)
raise HTTPException(status_code=503, detail="NAS 저장 실패 (재시도 권장)")
# recorded_at 파싱
rec_at: datetime | None = None
if recorded_at:
try:
rec_at = datetime.fromisoformat(recorded_at.replace("Z", "+00:00"))
except ValueError:
rec_at = None
raw_metadata: dict[str, Any] = {}
if device_hint:
raw_metadata["device_hint"] = device_hint
if rec_at:
raw_metadata["recorded_at"] = rec_at.isoformat()
# file_path 는 NAS root 기준 상대 경로 (다른 documents 컨벤션, /api/documents/{id}/file endpoint 호환)
relative_path = target_path.relative_to(nas_root)
# Document row — file_type='immutable' (binary, doc_type enum 제약) + category='audio' + source_channel='voice'
# 기존 audio 컨테이너 인입과 같은 패턴. source_channel='voice' 로 일반 audio 와 구분.
title_seed = (orig_name or "음성 메모").rsplit(".", 1)[0]
doc = Document(
file_path=str(relative_path),
file_hash=hashlib.sha256(payload).hexdigest(),
file_format=ext.lstrip(".") or "m4a",
file_size=len(payload),
file_type="immutable",
title=title_seed[:80] or "음성 메모",
extracted_text=None, # STT 후 채움
review_status="approved",
source_channel="voice",
category="audio",
ask_includable=True,
pinned=False,
archived=False,
memo_task_state={},
extract_meta=raw_metadata or None,
)
session.add(doc)
await session.flush()
# STT 큐 등록 — 기존 stt_worker → classify → embed → chunk 파이프라인 자동
await enqueue_stage(session, doc.id, "stt")
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
+39 -9
View File
@@ -8,7 +8,7 @@ from pydantic import BaseModel
from sqlalchemy import String, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.auth import get_current_user, require_admin
from core.database import get_session
from models.news_source import NewsSource
from models.user import User
@@ -60,9 +60,14 @@ async def list_sources(
@router.post("/sources")
async def create_source(
body: NewsSourceCreate,
user: Annotated[User, Depends(get_current_user)],
user: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
):
from core.url_validator import validate_feed_url
try:
validate_feed_url(body.feed_url)
except ValueError as e:
raise HTTPException(status_code=422, detail=f"feed_url 검증 실패: {e}")
source = NewsSource(**body.model_dump())
session.add(source)
await session.commit()
@@ -73,12 +78,18 @@ async def create_source(
async def update_source(
source_id: int,
body: NewsSourceUpdate,
user: Annotated[User, Depends(get_current_user)],
user: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
):
source = await session.get(NewsSource, source_id)
if not source:
raise HTTPException(status_code=404)
if body.feed_url is not None:
from core.url_validator import validate_feed_url
try:
validate_feed_url(body.feed_url)
except ValueError as e:
raise HTTPException(status_code=422, detail=f"feed_url 검증 실패: {e}")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(source, field, value)
await session.commit()
@@ -88,7 +99,7 @@ async def update_source(
@router.delete("/sources/{source_id}")
async def delete_source(
source_id: int,
user: Annotated[User, Depends(get_current_user)],
user: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
):
source = await session.get(NewsSource, source_id)
@@ -105,6 +116,7 @@ async def list_articles(
session: Annotated[AsyncSession, Depends(get_session)],
source: str | None = None,
unread_only: bool = False,
pinned_only: bool = False,
page: int = 1,
page_size: int = 30,
):
@@ -127,6 +139,8 @@ async def list_articles(
query = query.where(Document.ai_sub_group == source)
if unread_only:
query = query.where(Document.is_read == False)
if pinned_only:
query = query.where(Document.pinned.is_(True))
count_q = select(func.count()).select_from(query.subquery())
total = (await session.execute(count_q)).scalar()
@@ -162,12 +176,28 @@ async def mark_all_read(
return {"marked": result.rowcount}
import asyncio
_collect_lock = asyncio.Lock()
@router.post("/collect")
async def trigger_collect(
user: Annotated[User, Depends(get_current_user)],
user: Annotated[User, Depends(require_admin)],
):
"""수동 수집 트리거"""
from workers.news_collector import run
import asyncio
asyncio.create_task(run())
"""수동 수집 트리거 (admin 전용).
asyncio.Lock은 단일 프로세스/이벤트루프 기준.
현재 FastAPI 단일 인스턴스 운영이므로 유효하지만,
scale-out 시 DB advisory lock으로 교체 필요.
"""
if _collect_lock.locked():
raise HTTPException(status_code=429, detail="수집이 이미 진행 중입니다")
async def _run_with_lock():
async with _collect_lock:
from workers.news_collector import run
await run()
asyncio.create_task(_run_with_lock())
return {"message": "뉴스 수집 시작됨"}
+766 -78
View File
@@ -1,27 +1,39 @@
"""하이브리드 검색 API — orchestrator (Phase 1.1: thin endpoint).
"""하이브리드 검색 API — thin endpoint (Phase 3.1 이후).
retrieval / fusion / rerank 등 실제 로직은 services/search/* 모듈로 분리.
이 파일은 mode 분기, 응답 직렬화, debug 응답 구성, BackgroundTask dispatch만 담당.
실제 검색 파이프라인(retrieval fusion rerank → diversity → confidence)
은 `services/search/search_pipeline.py::run_search()` 로 분리되어 있다.
이 파일은 다음만 담당:
- Pydantic 스키마 (SearchResult / SearchResponse / SearchDebug / DebugCandidate
/ Citation / AskResponse / AskDebug)
- `/search` endpoint wrapper (run_search 호출 + logger + telemetry + 직렬화)
- `/ask` endpoint wrapper (Phase 3.3 에서 추가)
"""
import asyncio
import hmac
import time
from typing import Annotated
from typing import Annotated, Literal
from fastapi import APIRouter, BackgroundTasks, Depends, Query
from fastapi import APIRouter, BackgroundTasks, Depends, Header, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.config import settings
from core.database import get_session
from core.utils import setup_logger
from models.user import User
from services.search.fusion_service import DEFAULT_FUSION, get_strategy, normalize_display_scores
from services.search.retrieval_service import search_text, search_vector
from services.search_telemetry import (
compute_confidence,
compute_confidence_hybrid,
record_search_event,
)
from services.document_telemetry import sanitize_source
from services.search.classifier_service import ClassifierResult, classify
from services.search.evidence_service import EvidenceItem, extract_evidence
from services.search.fusion_service import DEFAULT_FUSION
from services.search.grounding_check import check as grounding_check
from services.search.refusal_gate import RefusalDecision, decide as refusal_decide
from services.search.search_pipeline import PipelineResult, run_search
from services.search.synthesis_service import SynthesisResult, synthesize
from services.search.verifier_service import VerifierResult, verify
from services.prompt_versions import ASK_PROMPT_VERSION, resolve_primary_model
from services.search_telemetry import record_ask_event, record_search_event
# logs/search.log + stdout 동시 출력 (Phase 0.4)
logger = setup_logger("search")
@@ -30,7 +42,14 @@ router = APIRouter()
class SearchResult(BaseModel):
id: int
"""검색 결과 단일 행.
Phase 1.2-C: chunk-level vector retrieval 도입으로 chunk 메타 필드 추가.
text 검색 결과는 chunk_id 등이 None (doc-level).
vector 검색 결과는 chunk_id 등이 채워짐 (chunk-level).
"""
id: int # doc_id (text/vector 공통)
title: str | None
ai_domain: str | None
ai_summary: str | None
@@ -38,6 +57,17 @@ class SearchResult(BaseModel):
score: float
snippet: str | None
match_reason: str | None = None
# Phase 1.2-C: chunk 메타 (vector 검색 시 채워짐)
chunk_id: int | None = None
chunk_index: int | None = None
section_title: str | None = None
# Phase 3.1: reranker raw score 보존 (display score drift 방지).
# rerank 경로를 탄 chunk에만 채워짐. normalize_display_scores는 이 필드를
# 건드리지 않는다. Phase 3 evidence fast-path 판단에 사용.
rerank_score: float | None = None
# PR-RAG-Time-1: freshness decay 디버그 메타. apply_freshness_decay 가 채움.
# 비적용 row 도 채워짐(freshness_policy=None). base_score 는 항상 보존.
freshness_debug: dict | None = None
# ─── Phase 0.4: 디버그 응답 스키마 ─────────────────────────
@@ -80,6 +110,29 @@ def _to_debug_candidates(rows: list[SearchResult], n: int = 20) -> list[DebugCan
]
def _build_search_debug(pr: PipelineResult) -> SearchDebug:
"""PipelineResult → SearchDebug (기존 search()의 debug 구성 블록 복사)."""
return SearchDebug(
timing_ms=pr.timing_ms,
text_candidates=(
_to_debug_candidates(pr.text_results)
if pr.text_results or pr.mode != "vector"
else None
),
vector_candidates=(
_to_debug_candidates(pr.vector_results)
if pr.vector_results or pr.mode in ("vector", "hybrid")
else None
),
fused_candidates=(
_to_debug_candidates(pr.results) if pr.mode == "hybrid" else None
),
confidence=pr.confidence_signal,
notes=pr.notes,
query_analysis=pr.query_analysis,
)
@router.get("/", response_model=SearchResponse)
async def search(
q: str,
@@ -93,85 +146,720 @@ async def search(
pattern="^(legacy|rrf|rrf_boost)$",
description="hybrid 모드 fusion 전략 (legacy=기존 가중합, rrf=RRF k=60, rrf_boost=RRF+강한신호 boost)",
),
rerank: bool = Query(
True,
description="bge-reranker-v2-m3 활성화 (Phase 1.3, hybrid 모드만 동작)",
),
analyze: bool = Query(
False,
description="QueryAnalyzer 활성화 (Phase 2.1, LLM 호출). Phase 2.1은 debug 노출만, 검색 경로 영향 X",
),
debug: bool = Query(False, description="단계별 candidates + timing 응답에 포함"),
):
"""문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 0.5: RRF fusion)"""
timing: dict[str, float] = {}
notes: list[str] = []
text_results: list[SearchResult] = []
vector_results: list[SearchResult] = []
t_total = time.perf_counter()
if mode == "vector":
t0 = time.perf_counter()
vector_results = await search_vector(session, q, limit)
timing["vector_ms"] = (time.perf_counter() - t0) * 1000
if not vector_results:
notes.append("vector_search_returned_empty (AI client error or no embeddings)")
results = vector_results
else:
t0 = time.perf_counter()
text_results = await search_text(session, q, limit)
timing["text_ms"] = (time.perf_counter() - t0) * 1000
if mode == "hybrid":
t1 = time.perf_counter()
vector_results = await search_vector(session, q, limit)
timing["vector_ms"] = (time.perf_counter() - t1) * 1000
if not vector_results:
notes.append("vector_search_returned_empty — text-only fallback")
t2 = time.perf_counter()
strategy = get_strategy(fusion)
results = strategy.fuse(text_results, vector_results, q, limit)
timing["fusion_ms"] = (time.perf_counter() - t2) * 1000
notes.append(f"fusion={strategy.name}")
else:
results = text_results
# display score 정규화 — 프론트엔드는 score*100을 % 표시.
# fusion 내부 score(RRF는 0.01~0.05 범위)를 그대로 노출하면 표시가 깨짐.
normalize_display_scores(results)
timing["total_ms"] = (time.perf_counter() - t_total) * 1000
# confidence는 fusion 적용 전 raw 신호로 계산 (Phase 0.5 이후 fused score는 절대값 의미 없음)
if mode == "hybrid":
confidence_signal = compute_confidence_hybrid(text_results, vector_results)
elif mode == "vector":
confidence_signal = compute_confidence(vector_results, "vector")
else:
confidence_signal = compute_confidence(text_results, mode)
"""문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 3.1 이후 run_search wrapper)"""
pr = await run_search(
session,
q,
mode=mode, # type: ignore[arg-type]
limit=limit,
fusion=fusion,
rerank=rerank,
analyze=analyze,
)
# 사용자 feedback: 모든 단계 timing은 debug 응답과 별도로 항상 로그로 남긴다
timing_str = " ".join(f"{k}={v:.0f}" for k, v in timing.items())
timing_str = " ".join(f"{k}={v:.0f}" for k, v in pr.timing_ms.items())
fusion_str = f" fusion={fusion}" if mode == "hybrid" else ""
analyzer_str = (
f" analyzer=hit={pr.analyzer_cache_hit}/conf={pr.analyzer_confidence:.2f}/tier={pr.analyzer_tier}"
if analyze
else ""
)
logger.info(
"search query=%r mode=%s%s results=%d conf=%.2f %s",
q[:80], mode, fusion_str, len(results), confidence_signal, timing_str,
"search query=%r mode=%s%s%s results=%d conf=%.2f %s",
q[:80],
pr.mode,
fusion_str,
analyzer_str,
len(pr.results),
pr.confidence_signal,
timing_str,
)
# Phase 0.3: 실패 자동 로깅 (응답 latency에 영향 X — background task)
# Phase 2.1: analyze=true일 때만 analyzer_confidence 전달 (False는 None → 기존 호환)
background_tasks.add_task(
record_search_event, q, user.id, results, mode, confidence_signal
record_search_event,
q,
user.id,
pr.results,
pr.mode,
pr.confidence_signal,
pr.analyzer_confidence if analyze else None,
)
debug_obj: SearchDebug | None = None
if debug:
debug_obj = SearchDebug(
timing_ms=timing,
text_candidates=_to_debug_candidates(text_results) if text_results or mode != "vector" else None,
vector_candidates=_to_debug_candidates(vector_results) if vector_results or mode in ("vector", "hybrid") else None,
fused_candidates=_to_debug_candidates(results) if mode == "hybrid" else None,
confidence=confidence_signal,
notes=notes,
)
debug_obj = _build_search_debug(pr) if debug else None
return SearchResponse(
results=results,
total=len(results),
results=pr.results,
total=len(pr.results),
query=q,
mode=mode,
mode=pr.mode,
debug=debug_obj,
)
# ═══════════════════════════════════════════════════════════
# Phase 3.3: /api/search/ask — Evidence + Grounded Synthesis
# ═══════════════════════════════════════════════════════════
class Citation(BaseModel):
"""answer 본문의 [n] 에 해당하는 근거 단일 행."""
n: int
chunk_id: int | None
doc_id: int
title: str | None
section_title: str | None
span_text: str # evidence LLM 이 추출한 50~300자
full_snippet: str # 원본 800자 (citation 원문 보기 전용)
relevance: float
rerank_score: float
class ConfirmedItem(BaseModel):
"""Partial answer 의 개별 aspect 답변."""
aspect: str
text: str
citations: list[int]
class AskDebug(BaseModel):
"""`/ask?debug=true` 응답 확장."""
timing_ms: dict[str, float]
search_notes: list[str]
query_analysis: dict | None = None
confidence_signal: float
evidence_candidate_count: int
evidence_kept_count: int
evidence_skip_reason: str | None
synthesis_cache_hit: bool
synthesis_prompt_preview: str | None = None
synthesis_raw_preview: str | None = None
hallucination_flags: list[str] = []
# Phase 3.5a: per-layer defense 로깅
defense_layers: dict | None = None
class AskResponse(BaseModel):
"""`/ask` 응답. Phase 3.5a: completeness + aspects 추가."""
results: list[SearchResult]
ai_answer: str | None
citations: list[Citation]
synthesis_status: Literal[
"completed", "timeout", "skipped", "no_evidence", "parse_failed", "llm_error"
]
synthesis_ms: float
confidence: Literal["high", "medium", "low"] | None
refused: bool
no_results_reason: str | None
query: str
total: int
# Phase 3.5a
completeness: Literal["full", "partial", "insufficient"] = "full"
covered_aspects: list[str] | None = None
missing_aspects: list[str] | None = None
confirmed_items: list[ConfirmedItem] | None = None
debug: AskDebug | None = None
def _map_no_results_reason(
pr: PipelineResult,
evidence: list[EvidenceItem],
ev_skip: str | None,
sr: SynthesisResult,
) -> str | None:
"""사용자에게 보여줄 한국어 메시지 매핑.
Failure mode 표 (plan §Failure Modes) 기반.
"""
# LLM 자가 refused → 모델이 준 사유 그대로
if sr.refused and sr.refuse_reason:
return sr.refuse_reason
# synthesis 상태 우선
if sr.status == "no_evidence":
if not pr.results:
return "검색 결과가 없습니다."
return "관련도 높은 근거를 찾지 못했습니다."
if sr.status == "skipped":
return "검색 결과가 없습니다."
if sr.status == "timeout":
return "답변 생성이 지연되어 생략했습니다. 검색 결과를 확인해 주세요."
if sr.status == "parse_failed":
return "답변 형식 오류로 생략했습니다."
if sr.status == "llm_error":
return "AI 서버에 일시적 문제가 있습니다."
# evidence 단계 실패는 fallback 을 탔더라도 notes 용
if ev_skip == "all_low_rerank":
return "관련도 높은 근거를 찾지 못했습니다."
if ev_skip == "empty_retrieval":
return "검색 결과가 없습니다."
return None
def _build_citations(
evidence: list[EvidenceItem], used_citations: list[int]
) -> list[Citation]:
"""answer 본문에 실제로 등장한 n 만 Citation 으로 변환."""
by_n = {e.n: e for e in evidence}
out: list[Citation] = []
for n in used_citations:
e = by_n.get(n)
if e is None:
continue
out.append(
Citation(
n=e.n,
chunk_id=e.chunk_id,
doc_id=e.doc_id,
title=e.title,
section_title=e.section_title,
span_text=e.span_text,
full_snippet=e.full_snippet,
relevance=e.relevance,
rerank_score=e.rerank_score,
)
)
return out
def _build_ask_debug(
pr: PipelineResult,
evidence: list[EvidenceItem],
ev_skip: str | None,
sr: SynthesisResult,
ev_ms: float,
synth_ms: float,
total_ms: float,
) -> AskDebug:
timing: dict[str, float] = dict(pr.timing_ms)
timing["evidence_ms"] = ev_ms
timing["synthesis_ms"] = synth_ms
timing["ask_total_ms"] = total_ms
# candidate count 는 rule filter 통과한 수 (recomputable from results)
# 엄밀히는 evidence_service 내부 숫자인데, evidence 길이 ≈ kept, candidate
# 는 관측이 어려움 → kept 는 evidence 길이, candidate 는 별도 필드 없음.
# 단순화: candidate_count = len(evidence) 를 상한 근사로 둠 (debug 전용).
return AskDebug(
timing_ms=timing,
search_notes=pr.notes,
query_analysis=pr.query_analysis,
confidence_signal=pr.confidence_signal,
evidence_candidate_count=len(evidence),
evidence_kept_count=len(evidence),
evidence_skip_reason=ev_skip,
synthesis_cache_hit=sr.cache_hit,
synthesis_prompt_preview=None, # 현재 synthesis_service 에서 노출 안 함
synthesis_raw_preview=sr.raw_preview,
hallucination_flags=sr.hallucination_flags,
)
def _detect_synthesis_failure(sr: SynthesisResult) -> str | None:
"""Synthesis 가 유효한 답을 못 냈으면 re_gate 라벨, 아니면 None.
판정 우선순위 (Phase 3.5 fix3):
1) sr.refused → LLM self-refuse (status="completed") 또는 mechanical fail 후 refused 전파
- status=="completed" + refused=True → "synthesis_self_refuse"
- 그 외 → f"synthesis_failed({status})"
2) sr.status ∈ {timeout, parse_failed, llm_error} → f"synthesis_failed({status})"
3) answer 공백 → f"synthesis_failed({status})"
4) 유효 → None
"""
if sr.refused:
if sr.status == "completed":
return "synthesis_self_refuse"
return f"synthesis_failed({sr.status})"
if sr.status in ("timeout", "parse_failed", "llm_error"):
return f"synthesis_failed({sr.status})"
if not (sr.answer or "").strip():
return f"synthesis_failed({sr.status})"
return None
def _resolve_eval_identity(
x_source: str | None,
x_eval_case_id: str | None,
x_eval_token: str | None,
) -> tuple[str, str | None]:
"""X-Source/X-Eval-Case-Id 신뢰 검증 (Phase 3.5 fix2).
규칙:
- 기본값: source='document_server', eval_case_id=None
- X-Source=eval 또는 X-Eval-Case-Id 가 들어왔다면 eval claim 으로 간주
- eval claim 은 X-Eval-Token == settings.eval_runner_token 일 때만 수용
(constant-time compare, env 미설정 시 항상 거부)
- 거부 시: 헤더 무시 + warning log + source=sanitize(non-eval) / eval_case_id=None
- 통과 시: source='eval', eval_case_id=x_eval_case_id
반환: (source, eval_case_id)
"""
claimed_source = sanitize_source(x_source)
is_eval_claim = (claimed_source == "eval") or bool(x_eval_case_id)
if not is_eval_claim:
# 일반 호출 — eval_case_id 강제 None (source != 'eval' 이면 case_id 의미 없음)
return claimed_source, None
# eval claim — token 검증
expected = settings.eval_runner_token
presented = x_eval_token or ""
token_valid = bool(expected) and hmac.compare_digest(presented, expected)
if not token_valid:
logger.warning(
"eval header rejected: source=%s case_id=%s token_present=%s expected_set=%s",
x_source, x_eval_case_id, bool(x_eval_token), bool(expected),
)
# 일반 호출로 강등 — source='eval' 주장은 무시, case_id 도 무시
# claimed_source 가 'eval' 이면 default 'document_server' 로
if claimed_source == "eval":
return "document_server", None
return claimed_source, None
# token OK — eval 라벨 수용
return "eval", x_eval_case_id
@router.get("/ask", response_model=AskResponse)
async def ask(
q: str,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
background_tasks: BackgroundTasks,
limit: int = Query(10, ge=1, le=20, description="synthesis 입력 상한"),
debug: bool = Query(False, description="evidence/synthesis 중간 상태 노출"),
x_source: Annotated[str | None, Header(alias="X-Source")] = None,
x_eval_case_id: Annotated[str | None, Header(alias="X-Eval-Case-Id")] = None,
x_eval_token: Annotated[str | None, Header(alias="X-Eval-Token")] = None,
):
"""근거 기반 AI 답변 (Phase 3.5a).
Phase 3.3 기반 + classifier parallel + refusal gate + grounding re-gate.
실패 경로에서도 `results` 는 항상 반환.
Phase 3.5 calibration trust boundary (fix2):
- X-Source / X-Eval-Case-Id 는 X-Eval-Token 이 EVAL_RUNNER_TOKEN 와 일치하는
trusted internal eval runner 에서만 수용된다.
- 일반 client 의 X-Source=eval 시도는 무시되고 source='document_server' 로 강제.
- source != 'eval' 이면 eval_case_id 항상 None.
"""
t_total = time.perf_counter()
defense_log: dict = {} # per-layer flag snapshot
source, eval_case_id = _resolve_eval_identity(x_source, x_eval_case_id, x_eval_token)
# 1. 검색 파이프라인
pr = await run_search(
session, q, mode="hybrid", limit=limit,
fusion=DEFAULT_FUSION, rerank=True, analyze=True,
)
# 1.5. ask_includable=false 문서를 evidence 입력에서 제외
# 검색 결과 자체는 유지 (사용자에게 보여줌), evidence만 필터
if pr.results:
from sqlalchemy import select as sa_select
from models.document import Document as DocModel
ask_doc_ids = set()
excluded_ids = {r.id for r in pr.results}
rows = await session.execute(
sa_select(DocModel.id, DocModel.ask_includable).where(
DocModel.id.in_(excluded_ids)
)
)
for doc_id, includable in rows:
if includable is False:
ask_doc_ids.add(doc_id)
evidence_results = [r for r in pr.results if r.id not in ask_doc_ids]
else:
evidence_results = pr.results
# 2. Evidence + Classifier 병렬
t_ev = time.perf_counter()
evidence_task = asyncio.create_task(extract_evidence(q, evidence_results))
# classifier input: top 3 chunks meta + rerank scores
top_chunks = [
{
"title": r.title or "",
"section": r.section_title or "",
"snippet": (r.snippet or "")[:200],
}
for r in pr.results[:3]
]
rerank_scores_top = [
r.rerank_score if r.rerank_score is not None else r.score
for r in pr.results[:3]
]
classifier_task = asyncio.create_task(
classify(q, top_chunks, rerank_scores_top)
)
evidence, ev_skip = await evidence_task
ev_ms = (time.perf_counter() - t_ev) * 1000
# classifier await (timeout 보호 — classifier_service 내부에도 있지만 여기서 이중 보호)
# 2026-05-17: 6s outer wrapper 가 classifier_service.LLM_TIMEOUT_MS (30s) 를 override → 동시 부하 시
# 거의 모든 classifier 호출 timeout → conservative_refuse(no_classifier) 경로. 15s 로 상향 — classifier
# 가 실제 작동하도록 (단, ask 전체 응답 시간 상한 영향: ev_ms + max(classifier_wait, evidence_extract) +
# synth_ms + verifier 누적).
# 2026-05-17 B-3: 15s 도 동시 부하 시 부족 (classifier_service LLM_TIMEOUT_MS 30s 와 misalign).
# 30s 로 align → classifier 동작 안정. ask 응답 latency 상한 ↑ 의도.
try:
classifier_result = await asyncio.wait_for(classifier_task, timeout=30.0)
except (asyncio.TimeoutError, Exception):
classifier_result = ClassifierResult("timeout", None, [], [], 0.0)
defense_log["classifier"] = {
"status": classifier_result.status,
"verdict": classifier_result.verdict,
"covered_aspects": classifier_result.covered_aspects,
"missing_aspects": classifier_result.missing_aspects,
"elapsed_ms": classifier_result.elapsed_ms,
}
# 3. Refusal gate (multi-signal fusion)
all_rerank_scores = [
e.rerank_score for e in evidence
] if evidence else rerank_scores_top
decision = refusal_decide(all_rerank_scores, classifier_result)
defense_log["score_gate"] = {
"max": max(all_rerank_scores) if all_rerank_scores else 0.0,
"agg_top3": sum(sorted(all_rerank_scores, reverse=True)[:3]),
}
defense_log["refusal"] = {
"refused": decision.refused,
"rule_triggered": decision.rule_triggered,
}
if decision.refused:
total_ms = (time.perf_counter() - t_total) * 1000
no_reason = "관련 근거를 찾지 못했습니다."
if not pr.results:
no_reason = "검색 결과가 없습니다."
logger.info(
"ask REFUSED query=%r rule=%s max_score=%.2f total=%.0f",
q[:80], decision.rule_triggered,
max(all_rerank_scores) if all_rerank_scores else 0.0, total_ms,
)
# telemetry — search + ask_events 두 경로 동시
background_tasks.add_task(
record_search_event, q, user.id, pr.results, "hybrid",
pr.confidence_signal, pr.analyzer_confidence,
)
# input_snapshot (디버깅/재현용)
defense_log["input_snapshot"] = {
"query": q,
"top_chunks_preview": [
{"title": c.get("title", ""), "snippet": c.get("snippet", "")[:100]}
for c in top_chunks[:3]
],
"answer_preview": None,
}
background_tasks.add_task(
record_ask_event,
q, user.id, "insufficient", "skipped", None,
True, classifier_result.verdict,
max(all_rerank_scores) if all_rerank_scores else 0.0,
sum(sorted(all_rerank_scores, reverse=True)[:3]),
[], len(evidence), 0,
defense_log, int(total_ms),
# Phase E.1 측정 필드
answer_length=0,
covered_aspects=classifier_result.covered_aspects or None,
missing_aspects=classifier_result.missing_aspects or None,
model_name=resolve_primary_model(),
prompt_version=ASK_PROMPT_VERSION,
# Phase 3.5 calibration
source=source,
eval_case_id=eval_case_id,
)
debug_obj = None
if debug:
debug_obj = AskDebug(
timing_ms={**pr.timing_ms, "evidence_ms": ev_ms, "ask_total_ms": total_ms},
search_notes=pr.notes,
confidence_signal=pr.confidence_signal,
evidence_candidate_count=len(evidence),
evidence_kept_count=len(evidence),
evidence_skip_reason=ev_skip,
synthesis_cache_hit=False,
hallucination_flags=[],
defense_layers=defense_log,
)
return AskResponse(
results=pr.results,
ai_answer=None,
citations=[],
synthesis_status="skipped",
synthesis_ms=0.0,
confidence=None,
refused=True,
no_results_reason=no_reason,
query=q,
total=len(pr.results),
completeness="insufficient",
covered_aspects=classifier_result.covered_aspects or None,
missing_aspects=classifier_result.missing_aspects or None,
debug=debug_obj,
)
# 4. Synthesis
t_synth = time.perf_counter()
sr = await synthesize(q, evidence, debug=debug)
synth_ms = (time.perf_counter() - t_synth) * 1000
# 5. Grounding check + Verifier (조건부 병렬) + re-gate (Phase 3.5b)
grounding = grounding_check(q, sr.answer or "", evidence)
# verifier skip: grounding strong 2+ OR retrieval 자체가 망함
grounding_only_strong = [
f for f in grounding.strong_flags if not f.startswith("verifier_")
]
max_rerank = max(all_rerank_scores, default=0.0)
if len(grounding_only_strong) >= 2 or max_rerank < 0.2:
verifier_result = VerifierResult("skipped", [], 0.0)
else:
verifier_task = asyncio.create_task(
verify(q, sr.answer or "", evidence)
)
# 2026-05-17 B-3: 4s outer wait_for 가 verifier_service LLM_TIMEOUT_MS (10s) 를 override
# → classifier 와 동일 패턴 (search.py:522 가 6s→15s swap 했던 case). 10s 로 align.
try:
verifier_result = await asyncio.wait_for(verifier_task, timeout=10.0)
except (asyncio.TimeoutError, Exception):
verifier_result = VerifierResult("timeout", [], 0.0)
# Verifier contradictions → grounding flags 머지 (prefix 로 구분, severity 3단계)
for c in verifier_result.contradictions:
if c.severity == "strong":
grounding.strong_flags.append(f"verifier_{c.type}:{c.claim[:30]}")
elif c.severity == "medium":
grounding.weak_flags.append(f"verifier_{c.type}_medium:{c.claim[:30]}")
else:
grounding.weak_flags.append(f"verifier_{c.type}:{c.claim[:30]}")
defense_log["evidence"] = {
"skip_reason": ev_skip,
"kept_count": len(evidence),
}
defense_log["grounding"] = {
"strong": grounding.strong_flags,
"weak": grounding.weak_flags,
}
defense_log["verifier"] = {
"status": verifier_result.status,
"contradictions_count": len(verifier_result.contradictions),
"strong_count": sum(1 for c in verifier_result.contradictions if c.severity == "strong"),
"medium_count": sum(1 for c in verifier_result.contradictions if c.severity == "medium"),
"elapsed_ms": verifier_result.elapsed_ms,
}
# ── Re-gate: 7-tier completeness 결정 (Phase 3.5 B2 — Tier 4 신규 삽입, 재번호) ──
# 기존 6-tier (3.5b 4차 리뷰) + Tier 4(g_strong + v_strong_numeric + low_conf → refuse).
# 호환성: defense_layers["re_gate"] 의 string literal 들은 기존 그대로 유지.
# 신규 "refuse(grounding+verifier_numeric)" 만 추가.
completeness: Literal["full", "partial", "insufficient"] = "full"
covered_aspects = classifier_result.covered_aspects or None
missing_aspects = classifier_result.missing_aspects or None
confirmed_items: list[ConfirmedItem] | None = None
# verifier/grounding strong 구분
g_strong = [f for f in grounding.strong_flags if not f.startswith("verifier_")]
v_strong = [f for f in grounding.strong_flags if f.startswith("verifier_")]
v_medium = [f for f in grounding.weak_flags if f.startswith("verifier_") and "_medium:" in f]
has_direct_negation = any("direct_negation" in f for f in v_strong)
# Phase 3.5 B2: verifier strong flags 중 numeric_conflict 만 카운트.
# promote(VERIFIER_NUMERIC_PROMOTE=1) 활성 시 critical numeric_conflict 가 strong 으로 승격되며
# 여기 카운트에 잡힘. promote off 면 항상 0 → Tier 4 활성 안 됨 (기존 동작 유지).
v_strong_numeric = sum(
1 for f in v_strong if f.startswith("verifier_numeric_conflict")
)
# ── Tier 0 (Phase 3.5 fix3): synthesis 자체 실패 처리 ──
# LLM self-refuse, 메커니즘 실패(timeout/parse_failed/llm_error), answer 공백.
# 빈 답에 대해 grounding/verifier flag 가 0건이라 기존 체인이 "else clean" 으로 빠지며
# completeness="full" 초기값이 보존되던 모순을 여기서 일관되게 차단.
# 과거 baseline(v1-400char) 에서 20(self-refuse)+4(timeout) = 24/223 (10.8%) 해당.
tier0_label = _detect_synthesis_failure(sr)
if tier0_label:
completeness = "insufficient"
sr.answer = None
sr.refused = True
sr.confidence = None
defense_log["re_gate"] = tier0_label
elif len(g_strong) >= 2:
# Tier 1: grounding strong 2+ → refuse
completeness = "insufficient"
sr.answer = None
sr.refused = True
sr.confidence = None
defense_log["re_gate"] = "refuse(grounding_2+strong)"
elif g_strong and has_direct_negation:
# Tier 2: grounding strong + verifier direct_negation → refuse
completeness = "insufficient"
sr.answer = None
sr.refused = True
sr.confidence = None
defense_log["re_gate"] = "refuse(grounding+direct_negation)"
elif g_strong and sr.confidence == "low" and max_rerank < 0.25:
# Tier 3: grounding strong 1 + (low confidence AND weak evidence) → refuse
completeness = "insufficient"
sr.answer = None
sr.refused = True
sr.confidence = None
defense_log["re_gate"] = "refuse(grounding+low_conf+weak_ev)"
elif g_strong and v_strong_numeric >= 1 and sr.confidence == "low":
# Tier 4 (B2 신규): grounding strong + verifier numeric_conflict strong + low conf → refuse.
# verifier strong 단독 refuse 금지 원칙 유지 — g_strong 교차 필수.
completeness = "insufficient"
sr.answer = None
sr.refused = True
sr.confidence = None
defense_log["re_gate"] = "refuse(grounding+verifier_numeric)"
elif g_strong or has_direct_negation:
# Tier 5 (기존 4): grounding strong 1 또는 verifier direct_negation 단독 → partial
completeness = "partial"
sr.confidence = "low"
defense_log["re_gate"] = "partial(strong_or_negation)"
elif v_medium:
# Tier 6 (기존 5): verifier medium 누적 → count 기반 confidence 하향
medium_count = len(v_medium)
if medium_count >= 3:
sr.confidence = "low"
defense_log["re_gate"] = f"conf_low(medium_x{medium_count})"
elif medium_count == 2 and sr.confidence == "high":
sr.confidence = "medium"
defense_log["re_gate"] = "conf_cap_medium(medium_x2)"
else:
defense_log["re_gate"] = f"medium_x{medium_count}(no_action)"
elif grounding.weak_flags:
# Tier 7 (기존 6): weak → confidence 한 단계 하향
if sr.confidence == "high":
sr.confidence = "medium"
defense_log["re_gate"] = "conf_lower(weak)"
else:
defense_log["re_gate"] = "clean"
# Confidence cap from refusal gate (classifier 부재 시 conservative)
if decision.confidence_cap and sr.confidence:
conf_rank = {"low": 0, "medium": 1, "high": 2}
if conf_rank.get(sr.confidence, 0) > conf_rank.get(decision.confidence_cap, 2):
sr.confidence = decision.confidence_cap
# Partial 이면 max confidence = medium
if completeness == "partial" and sr.confidence == "high":
sr.confidence = "medium"
sr.hallucination_flags.extend(
[f"strong:{f}" for f in grounding.strong_flags]
+ [f"weak:{f}" for f in grounding.weak_flags]
)
total_ms = (time.perf_counter() - t_total) * 1000
# 6. 응답 구성
citations = _build_citations(evidence, sr.used_citations)
no_reason = _map_no_results_reason(pr, evidence, ev_skip, sr)
if completeness == "insufficient" and not no_reason:
# Tier 0 경로: synthesis self-refuse 는 LLM 이 준 사유가 가장 정확.
if sr.refused and sr.refuse_reason:
no_reason = sr.refuse_reason
else:
no_reason = "답변 검증에서 복수 오류 감지"
logger.info(
"ask query=%r results=%d evidence=%d cite=%d synth=%s conf=%s completeness=%s "
"refused=%s grounding_strong=%d grounding_weak=%d ev_ms=%.0f synth_ms=%.0f total=%.0f",
q[:80], len(pr.results), len(evidence), len(citations),
sr.status, sr.confidence or "-", completeness,
sr.refused, len(grounding.strong_flags), len(grounding.weak_flags),
ev_ms, synth_ms, total_ms,
)
# 7. telemetry — search + ask_events 두 경로 동시
background_tasks.add_task(
record_search_event, q, user.id, pr.results, "hybrid",
pr.confidence_signal, pr.analyzer_confidence,
)
# input_snapshot (디버깅/재현용)
defense_log["input_snapshot"] = {
"query": q,
"top_chunks_preview": [
{"title": (r.title or "")[:50], "snippet": (r.snippet or "")[:100]}
for r in pr.results[:3]
],
"answer_preview": (sr.answer or "")[:200],
}
background_tasks.add_task(
record_ask_event,
q, user.id, completeness, sr.status, sr.confidence,
sr.refused, classifier_result.verdict,
max(all_rerank_scores) if all_rerank_scores else 0.0,
sum(sorted(all_rerank_scores, reverse=True)[:3]),
sr.hallucination_flags, len(evidence), len(citations),
defense_log, int(total_ms),
# Phase E.1 측정 필드
answer_length=len(sr.answer or ""),
covered_aspects=covered_aspects,
missing_aspects=missing_aspects,
model_name=resolve_primary_model(),
prompt_version=ASK_PROMPT_VERSION,
# Phase 3.5 calibration
source=source,
eval_case_id=eval_case_id,
)
debug_obj = None
if debug:
timing = dict(pr.timing_ms)
timing["evidence_ms"] = ev_ms
timing["synthesis_ms"] = synth_ms
timing["ask_total_ms"] = total_ms
debug_obj = AskDebug(
timing_ms=timing,
search_notes=pr.notes,
query_analysis=pr.query_analysis,
confidence_signal=pr.confidence_signal,
evidence_candidate_count=len(evidence),
evidence_kept_count=len(evidence),
evidence_skip_reason=ev_skip,
synthesis_cache_hit=sr.cache_hit,
synthesis_raw_preview=sr.raw_preview,
hallucination_flags=sr.hallucination_flags,
defense_layers=defense_log,
)
return AskResponse(
results=pr.results,
ai_answer=sr.answer,
citations=citations,
synthesis_status=sr.status,
synthesis_ms=sr.elapsed_ms,
confidence=sr.confidence,
refused=sr.refused,
no_results_reason=no_reason,
query=q,
total=len(pr.results),
completeness=completeness,
covered_aspects=covered_aspects,
missing_aspects=missing_aspects,
confirmed_items=confirmed_items,
debug=debug_obj,
)
+2
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from typing import Annotated
import pyotp
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
@@ -137,6 +138,7 @@ async def create_admin(
username=body.username,
password_hash=hash_password(body.password),
is_active=True,
password_changed_at=datetime.now(timezone.utc),
)
session.add(user)
await session.commit()
+728
View File
@@ -0,0 +1,728 @@
"""학습 진행 상태 (progress) API — review-complete + review-queue + stats.
review-complete: 사용자가 오답/모르겠음 문제를 검토했음을 표시. due_at 최초 부여.
review-queue: 5 (due_today / pending_review / chronic / regressed / mastered) 으로 progress 조회.
stats (Phase 2-D): 통계 대시보드 진척도 / 패턴 분포 / 복습 / 세션 추이 / 일별 풀이량 / 과목별.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import and_, case, cast, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import Date as SQLDate
from core.auth import get_current_user
from core.database import get_session
from models.study_question import StudyQuestion, StudyQuestionAttempt
from models.study_question_progress import StudyQuestionProgress
from models.study_quiz_session import StudyQuizSession
from models.study_topic import StudyTopic
from models.user import User
router = APIRouter(prefix="/study-topics", tags=["study-progress"])
# 1차 due_at 부여 시 디폴트 1일 뒤
DEFAULT_FIRST_DUE_DAYS = 1
def _verify_topic_owner(topic: StudyTopic | None, user: User) -> None:
if topic is None or topic.deleted_at is not None or topic.user_id != user.id:
raise HTTPException(status_code=404, detail="주제를 찾을 수 없습니다")
@router.post("/{topic_id}/questions/{question_id}/review-complete", status_code=204)
async def review_complete(
topic_id: int,
question_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""확인완료 처리 — last_reviewed_at + (wrong/unsure 인 경우) due_at 최초 부여.
이미 due_at 박힌 문제면 due_at 그대로 유지 ( 위치 보존).
정답 맞춘 문제면 due_at 박지 않음 ( 폭발 방지).
"""
topic = await session.get(StudyTopic, topic_id)
_verify_topic_owner(topic, user)
q = await session.get(StudyQuestion, question_id)
if q is None or q.deleted_at is not None or q.user_id != user.id or q.study_topic_id != topic_id:
raise HTTPException(status_code=404, detail="문제를 찾을 수 없습니다")
progress = (
await session.execute(
select(StudyQuestionProgress).where(
StudyQuestionProgress.user_id == user.id,
StudyQuestionProgress.study_topic_id == topic_id,
StudyQuestionProgress.study_question_id == question_id,
)
)
).scalar_one_or_none()
if progress is None:
# attempt 없는데 review-complete 시도. 진척 상태가 없어 의미 없음.
raise HTTPException(status_code=409, detail="아직 시도한 적이 없는 문제입니다")
now = datetime.now(timezone.utc)
progress.last_reviewed_at = now
# due_at 최초 부여는 wrong/unsure 일 때만. 이미 박혀있으면 유지.
if progress.last_outcome in ("wrong", "unsure") and progress.due_at is None:
progress.review_stage = 0
progress.due_at = now + timedelta(days=DEFAULT_FIRST_DUE_DAYS)
await session.commit()
# ─── review-queue ───
class ReviewQueueItem(BaseModel):
question_id: int
question_text: str
subject: str | None
scope: str | None
exam_round: str | None
exam_question_number: int | None
last_outcome: str | None
last_attempted_at: datetime | None
last_reviewed_at: datetime | None
due_at: datetime | None
review_stage: int | None
pattern_state: str | None
class ReviewQueueResponse(BaseModel):
tab: str
total: int
items: list[ReviewQueueItem]
page: int
page_size: int
# Phase 2-F: due_today 탭에서만 채움. due_at < today 0시 (UTC) + stage < 4.
# UI 가 "정체 N건" 경고 + [정리] 버튼 노출 판단에 사용.
overdue_count: int = 0
def _truncate(text: str, n: int = 80) -> str:
if not text:
return ""
s = text.strip()
return s if len(s) <= n else s[:n].rstrip() + ""
@router.get("/{topic_id}/review-queue", response_model=ReviewQueueResponse)
async def review_queue(
topic_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
tab: str = Query(..., pattern="^(due_today|pending_review|chronic|regressed|mastered)$"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
):
"""5 탭 진행 상태 조회.
- due_today: progress.due_at <= now() AND review_stage < 4
- pending_review: last_outcome IN (wrong, unsure)
AND (last_reviewed_at IS NULL OR last_reviewed_at < last_attempted_at)
- chronic: pattern_state = 'chronic_wrong'
- regressed: pattern_state = 'regressed'
- mastered: review_stage >= 4
"""
topic = await session.get(StudyTopic, topic_id)
_verify_topic_owner(topic, user)
base = (
select(StudyQuestionProgress, StudyQuestion)
.join(StudyQuestion, StudyQuestion.id == StudyQuestionProgress.study_question_id)
.where(
StudyQuestionProgress.user_id == user.id,
StudyQuestionProgress.study_topic_id == topic_id,
StudyQuestion.deleted_at.is_(None),
)
)
now = datetime.now(timezone.utc)
if tab == "due_today":
base = base.where(
StudyQuestionProgress.due_at.is_not(None),
StudyQuestionProgress.due_at <= now,
or_(
StudyQuestionProgress.review_stage.is_(None),
StudyQuestionProgress.review_stage < 4,
),
).order_by(StudyQuestionProgress.due_at.asc())
elif tab == "pending_review":
base = base.where(
StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
or_(
StudyQuestionProgress.last_reviewed_at.is_(None),
and_(
StudyQuestionProgress.last_reviewed_at.is_not(None),
StudyQuestionProgress.last_attempted_at.is_not(None),
StudyQuestionProgress.last_reviewed_at
< StudyQuestionProgress.last_attempted_at,
),
),
).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
elif tab == "chronic":
base = base.where(
StudyQuestionProgress.pattern_state == "chronic_wrong",
).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
elif tab == "regressed":
base = base.where(
StudyQuestionProgress.pattern_state == "regressed",
).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
elif tab == "mastered":
base = base.where(
StudyQuestionProgress.review_stage.is_not(None),
StudyQuestionProgress.review_stage >= 4,
).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
# total
total_row = await session.execute(
select(func.count()).select_from(base.subquery())
)
total = int(total_row.scalar() or 0)
# paged
rows = (
await session.execute(
base.offset((page - 1) * page_size).limit(page_size)
)
).all()
items = [
ReviewQueueItem(
question_id=q.id,
question_text=_truncate(q.question_text, 80),
subject=q.subject,
scope=q.scope,
exam_round=q.exam_round,
exam_question_number=q.exam_question_number,
last_outcome=p.last_outcome,
last_attempted_at=p.last_attempted_at,
last_reviewed_at=p.last_reviewed_at,
due_at=p.due_at,
review_stage=p.review_stage,
pattern_state=p.pattern_state,
)
for (p, q) in rows
]
# Phase 2-F: due_today 탭일 때 overdue 카운트 (오늘 0시 UTC 이전 due) — UI 경고 노출용
overdue_count = 0
if tab == "due_today":
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
overdue_row = await session.execute(
select(func.count())
.select_from(StudyQuestionProgress)
.where(
StudyQuestionProgress.user_id == user.id,
StudyQuestionProgress.study_topic_id == topic_id,
StudyQuestionProgress.due_at.is_not(None),
StudyQuestionProgress.due_at < today_start,
or_(
StudyQuestionProgress.review_stage.is_(None),
StudyQuestionProgress.review_stage < 4,
),
)
)
overdue_count = int(overdue_row.scalar() or 0)
return ReviewQueueResponse(
tab=tab, total=total, items=items, page=page, page_size=page_size,
overdue_count=overdue_count,
)
# ─── redistribute (Phase 2-F due_at 정체 정리) ───
class RedistributeRequest(BaseModel):
spread_days: int = 7 # 1~14 일 사이. default 7.
class RedistributeResponse(BaseModel):
redistributed_count: int
spread_days: int
@router.post(
"/{topic_id}/review-queue/redistribute", response_model=RedistributeResponse
)
async def redistribute_overdue(
topic_id: int,
body: RedistributeRequest,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""overdue (due_at < today 0시 UTC + stage < 4) 를 내일~spread_days 일에 round-robin 분산.
동작:
- 오늘 0 이전에 due 항목 모두 fetch (오래된 )
- i % spread_days + 1 자정 + i*7 (분산용 분단위) due_at 갱신
- review_stage 건드리지 않음 (정체 처리는 시간 재배치만)
"""
if not (1 <= body.spread_days <= 14):
raise HTTPException(status_code=400, detail="spread_days 는 1~14 사이여야 합니다")
topic = await session.get(StudyTopic, topic_id)
_verify_topic_owner(topic, user)
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
overdue = (
await session.execute(
select(StudyQuestionProgress)
.where(
StudyQuestionProgress.user_id == user.id,
StudyQuestionProgress.study_topic_id == topic_id,
StudyQuestionProgress.due_at.is_not(None),
StudyQuestionProgress.due_at < today_start,
or_(
StudyQuestionProgress.review_stage.is_(None),
StudyQuestionProgress.review_stage < 4,
),
)
.order_by(StudyQuestionProgress.due_at.asc())
)
).scalars().all()
if not overdue:
return RedistributeResponse(redistributed_count=0, spread_days=body.spread_days)
base_day = today_start # 오늘 0시 기준 — +1일부터 분산
for i, p in enumerate(overdue):
days_offset = (i % body.spread_days) + 1
# 같은 날 안에서도 분산하려고 i*7분 추가 (200건 까지 24시간 안에 겹침 없이 spread)
minute_offset = (i * 7) % (24 * 60)
p.due_at = base_day + timedelta(days=days_offset, minutes=minute_offset)
await session.commit()
return RedistributeResponse(
redistributed_count=len(overdue), spread_days=body.spread_days
)
# ─── stats (Phase 2-D 통계 대시보드) ───
class StatsQuestions(BaseModel):
total: int
attempted: int
unattempted: int
class StatsDue(BaseModel):
today: int
this_week: int
later: int
mastered: int
class StatsSessionTrendItem(BaseModel):
id: int
finished_at: datetime
total: int
correct_count: int
wrong_count: int
unsure_count: int
accuracy: int # 0~100
newly_correct_count: int
relapsed_count: int
recovered_count: int
class StatsDailyAttempt(BaseModel):
date: date
count: int
class StatsSubjectBreakdown(BaseModel):
subject: str
total: int
attempted: int
last_correct: int
accuracy: int # 0~100
pending_review: int
chronic: int
class StatsAiExplanation(BaseModel):
"""Phase 4-A 운영 관찰 — AI 풀이 캐시 진척 + 최근 7일 worker 결과."""
# study_questions.ai_explanation_status 분포 (이 토픽 전체)
status_distribution: dict # 'none' / 'ready' / 'failed' / 'skipped' / 'stale' / 'pending'
# wrong/unsure 중 ready 박힌 비율 (캐시 hit 가능성 추정)
target_total: int # progress.last_outcome IN (wrong, unsure) 의 qid 수
target_ready: int # 그 중 ai_explanation_status='ready' 인 수
# 최근 7일 study_question_jobs 의 (status, error_code) 분포
recent_jobs: dict # {'completed': N, 'failed:guard_fail': N, 'failed:parse_fail': N, 'skipped:evidence_missing': N, 'pending': N, ...}
class StatsResponse(BaseModel):
questions: StatsQuestions
pattern_distribution: dict # state(or "unattempted") → count
review_stage_distribution: dict # "0"/"1"/"2"/"3"/"mastered" → count
due: StatsDue
session_trend: list[StatsSessionTrendItem] # 최근 done 세션 newest→oldest
daily_attempts_30d: list[StatsDailyAttempt]
subject_breakdown: list[StatsSubjectBreakdown]
ai_explanation: StatsAiExplanation
@router.get("/{topic_id}/stats", response_model=StatsResponse)
async def topic_stats(
topic_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
session_trend_limit: int = Query(20, ge=1, le=100),
):
"""통계 대시보드 — progress + quiz_sessions + attempts 한 번에 집계.
가벼운 쿼리 6~7 묶음. 1 운영 + 토픽당 progress 수천 가정 추가 인덱스 없이 OK.
"""
topic = await session.get(StudyTopic, topic_id)
_verify_topic_owner(topic, user)
now = datetime.now(timezone.utc)
# 1. 문제 진척도 — 토픽의 question 총수 + progress 행 수 (attempted)
total_q_row = await session.execute(
select(func.count())
.select_from(StudyQuestion)
.where(
StudyQuestion.user_id == user.id,
StudyQuestion.study_topic_id == topic_id,
StudyQuestion.deleted_at.is_(None),
)
)
total_q = int(total_q_row.scalar() or 0)
attempted_row = await session.execute(
select(func.count())
.select_from(StudyQuestionProgress)
.where(
StudyQuestionProgress.user_id == user.id,
StudyQuestionProgress.study_topic_id == topic_id,
StudyQuestionProgress.last_outcome.is_not(None),
)
)
attempted = int(attempted_row.scalar() or 0)
unattempted = max(0, total_q - attempted)
# 2. pattern_state 분포 (NULL 은 "unattempted" 로)
pattern_rows = (
await session.execute(
select(
func.coalesce(StudyQuestionProgress.pattern_state, "unattempted").label("state"),
func.count().label("cnt"),
)
.where(
StudyQuestionProgress.user_id == user.id,
StudyQuestionProgress.study_topic_id == topic_id,
)
.group_by("state")
)
).all()
pattern_distribution = {r.state: int(r.cnt) for r in pattern_rows}
# 모든 키 default 0 채우기 (UI 가 빈 키 처리 안 해도 되게)
for k in ("stable", "unstable", "unsure", "regressed", "recovered", "chronic_wrong", "unattempted"):
pattern_distribution.setdefault(k, 0)
# 한 번도 시도 안 한 (progress 행 자체 없음) 분량을 unattempted 에 합산
pattern_distribution["unattempted"] += unattempted
# 3. review_stage 분포 — 0/1/2/3/mastered (>=4)
stage_rows = (
await session.execute(
select(
StudyQuestionProgress.review_stage.label("stage"),
func.count().label("cnt"),
)
.where(
StudyQuestionProgress.user_id == user.id,
StudyQuestionProgress.study_topic_id == topic_id,
StudyQuestionProgress.review_stage.is_not(None),
)
.group_by(StudyQuestionProgress.review_stage)
)
).all()
review_stage_distribution = {"0": 0, "1": 0, "2": 0, "3": 0, "mastered": 0}
for r in stage_rows:
st = int(r.stage)
if st >= 4:
review_stage_distribution["mastered"] += int(r.cnt)
elif 0 <= st <= 3:
review_stage_distribution[str(st)] += int(r.cnt)
# 4. due 분류 — today / this_week / later / mastered
end_today = now.replace(hour=23, minute=59, second=59, microsecond=999999)
end_week = end_today + timedelta(days=7)
due_rows = (
await session.execute(
select(
func.count().filter(
and_(
StudyQuestionProgress.due_at.is_not(None),
StudyQuestionProgress.due_at <= end_today,
or_(
StudyQuestionProgress.review_stage.is_(None),
StudyQuestionProgress.review_stage < 4,
),
)
).label("today"),
func.count().filter(
and_(
StudyQuestionProgress.due_at.is_not(None),
StudyQuestionProgress.due_at > end_today,
StudyQuestionProgress.due_at <= end_week,
or_(
StudyQuestionProgress.review_stage.is_(None),
StudyQuestionProgress.review_stage < 4,
),
)
).label("this_week"),
func.count().filter(
and_(
StudyQuestionProgress.due_at.is_not(None),
StudyQuestionProgress.due_at > end_week,
or_(
StudyQuestionProgress.review_stage.is_(None),
StudyQuestionProgress.review_stage < 4,
),
)
).label("later"),
func.count().filter(
and_(
StudyQuestionProgress.review_stage.is_not(None),
StudyQuestionProgress.review_stage >= 4,
)
).label("mastered"),
)
.where(
StudyQuestionProgress.user_id == user.id,
StudyQuestionProgress.study_topic_id == topic_id,
)
)
).first()
due = StatsDue(
today=int(due_rows.today or 0),
this_week=int(due_rows.this_week or 0),
later=int(due_rows.later or 0),
mastered=int(due_rows.mastered or 0),
)
# 5. 최근 done 세션 추이 (Phase 2-B 4 컬럼 활용)
sess_rows = (
await session.execute(
select(StudyQuizSession)
.where(
StudyQuizSession.user_id == user.id,
StudyQuizSession.study_topic_id == topic_id,
StudyQuizSession.status == "done",
StudyQuizSession.finished_at.is_not(None),
)
.order_by(StudyQuizSession.finished_at.desc())
.limit(session_trend_limit)
)
).scalars().all()
session_trend: list[StatsSessionTrendItem] = []
for s in sess_rows:
total_n = len(s.question_ids or [])
acc = round((s.correct_count / total_n) * 100) if total_n > 0 else 0
session_trend.append(StatsSessionTrendItem(
id=s.id,
finished_at=s.finished_at,
total=total_n,
correct_count=s.correct_count,
wrong_count=s.wrong_count,
unsure_count=s.unsure_count,
accuracy=acc,
newly_correct_count=s.newly_correct_count,
relapsed_count=s.relapsed_count,
recovered_count=s.recovered_count,
))
# 6. 일별 풀이량 30일 (date 기준 — UTC, 시간대 차이는 Phase 5 후보)
start_30d = (now - timedelta(days=29)).replace(hour=0, minute=0, second=0, microsecond=0)
daily_rows = (
await session.execute(
select(
cast(StudyQuestionAttempt.answered_at, SQLDate).label("d"),
func.count().label("cnt"),
)
.where(
StudyQuestionAttempt.user_id == user.id,
StudyQuestionAttempt.study_topic_id == topic_id,
StudyQuestionAttempt.answered_at >= start_30d,
)
.group_by("d")
.order_by("d")
)
).all()
daily_attempts_30d = [StatsDailyAttempt(date=r.d, count=int(r.cnt)) for r in daily_rows]
# 7. 과목별 약점
subj_rows = (
await session.execute(
select(
func.coalesce(StudyQuestion.subject, "(미분류)").label("subject"),
func.count(StudyQuestion.id.distinct()).label("total"),
func.count(StudyQuestionProgress.id.distinct()).filter(
StudyQuestionProgress.last_outcome.is_not(None)
).label("attempted"),
func.count(StudyQuestionProgress.id.distinct()).filter(
StudyQuestionProgress.last_outcome == "correct"
).label("last_correct"),
func.count(StudyQuestionProgress.id.distinct()).filter(
and_(
StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
or_(
StudyQuestionProgress.last_reviewed_at.is_(None),
and_(
StudyQuestionProgress.last_reviewed_at.is_not(None),
StudyQuestionProgress.last_attempted_at.is_not(None),
StudyQuestionProgress.last_reviewed_at
< StudyQuestionProgress.last_attempted_at,
),
),
)
).label("pending_review"),
func.count(StudyQuestionProgress.id.distinct()).filter(
StudyQuestionProgress.pattern_state == "chronic_wrong"
).label("chronic"),
)
.select_from(StudyQuestion)
.outerjoin(
StudyQuestionProgress,
and_(
StudyQuestionProgress.user_id == StudyQuestion.user_id,
StudyQuestionProgress.study_topic_id == StudyQuestion.study_topic_id,
StudyQuestionProgress.study_question_id == StudyQuestion.id,
),
)
.where(
StudyQuestion.user_id == user.id,
StudyQuestion.study_topic_id == topic_id,
StudyQuestion.deleted_at.is_(None),
)
.group_by("subject")
.order_by(func.count(StudyQuestion.id.distinct()).desc())
)
).all()
subject_breakdown = [
StatsSubjectBreakdown(
subject=r.subject,
total=int(r.total),
attempted=int(r.attempted),
last_correct=int(r.last_correct),
accuracy=round((int(r.last_correct) / int(r.attempted)) * 100) if int(r.attempted) > 0 else 0,
pending_review=int(r.pending_review),
chronic=int(r.chronic),
)
for r in subj_rows
]
# 8. Phase 4-A: AI 풀이 캐시 진척 + 최근 7일 worker 결과
# 8a. study_questions.ai_explanation_status 분포 (토픽 전체)
ai_status_rows = (
await session.execute(
select(
func.coalesce(StudyQuestion.ai_explanation_status, "none").label("st"),
func.count().label("cnt"),
)
.where(
StudyQuestion.user_id == user.id,
StudyQuestion.study_topic_id == topic_id,
StudyQuestion.deleted_at.is_(None),
)
.group_by("st")
)
).all()
ai_status_distribution = {r.st: int(r.cnt) for r in ai_status_rows}
for k in ("none", "ready", "failed", "skipped", "stale", "pending"):
ai_status_distribution.setdefault(k, 0)
# 8b. wrong/unsure 의 ready 비율 (캐시 hit 가능성)
target_total_row = await session.execute(
select(func.count())
.select_from(StudyQuestionProgress)
.where(
StudyQuestionProgress.user_id == user.id,
StudyQuestionProgress.study_topic_id == topic_id,
StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
)
)
target_total = int(target_total_row.scalar() or 0)
target_ready_row = await session.execute(
select(func.count())
.select_from(StudyQuestionProgress)
.join(
StudyQuestion,
and_(
StudyQuestion.id == StudyQuestionProgress.study_question_id,
StudyQuestion.deleted_at.is_(None),
),
)
.where(
StudyQuestionProgress.user_id == user.id,
StudyQuestionProgress.study_topic_id == topic_id,
StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
StudyQuestion.ai_explanation_status == "ready",
)
)
target_ready = int(target_ready_row.scalar() or 0)
# 8c. 최근 7일 study_question_jobs 분포 — terminal status × error_code
from models.study_question_job import StudyQuestionJob
recent_cutoff = now - timedelta(days=7)
job_rows = (
await session.execute(
select(
StudyQuestionJob.status.label("st"),
func.coalesce(StudyQuestionJob.error_code, "").label("err"),
func.count().label("cnt"),
)
.join(
StudyQuestion,
and_(
StudyQuestion.id == StudyQuestionJob.study_question_id,
StudyQuestion.study_topic_id == topic_id,
StudyQuestion.user_id == user.id,
),
)
.where(
StudyQuestionJob.user_id == user.id,
StudyQuestionJob.created_at >= recent_cutoff,
)
.group_by("st", "err")
)
).all()
recent_jobs: dict[str, int] = {}
for r in job_rows:
key = f"{r.st}:{r.err}" if r.err else r.st
recent_jobs[key] = int(r.cnt)
return StatsResponse(
questions=StatsQuestions(
total=total_q, attempted=attempted, unattempted=unattempted
),
pattern_distribution=pattern_distribution,
review_stage_distribution=review_stage_distribution,
due=due,
session_trend=session_trend,
daily_attempts_30d=daily_attempts_30d,
subject_breakdown=subject_breakdown,
ai_explanation=StatsAiExplanation(
status_distribution=ai_status_distribution,
target_total=target_total,
target_ready=target_ready,
recent_jobs=recent_jobs,
),
)
File diff suppressed because it is too large Load Diff
+927
View File
@@ -0,0 +1,927 @@
"""학습 세션 API — Phase 1 MVP (자격증 + 어학 일반화)
iPad 손글씨 필사 / 모바일 암기노트 / 모바일 퀴즈 같은 study_sessions 데이터를
공유. 모듈은 Phase 1 = iPad 필사 세션 + DB/API 일반화 까지만 다룬다.
핵심:
- study_type 'certification' | 'language' 분기. metadata jsonb 도메인별 자유 메타.
- 단일 *_document_id 컬럼 . 모든 미디어 연결은 study_session_assets 통일.
- documents 본체는 절대 삭제하지 않음 (assets 연결만 해제).
- ownership 검증: study_sessions.user_id == current_user.id (필수).
documents single-user 시스템이라 컬럼 부재 미래 multi-user 대비
`getattr(doc, 'user_id', None)` 부드럽게 검증 ( 있으면 비교, 없으면 통과).
- 409 중복: UNIQUE(study_session_id, document_id, asset_type, role) 위반.
Phase 2~4 미사용 필드 (review_state / quiz / ocr_text / ai_summary / prompt )
스키마에만 존재, 자동 로직 없음. 별도 PR 에서 활성.
"""
import asyncio
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated, Any
from fastapi import (
APIRouter,
Depends,
Form,
HTTPException,
Query,
Request,
UploadFile,
)
from pydantic import BaseModel, Field
from sqlalchemy import and_, delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from starlette.requests import ClientDisconnect
from core.auth import get_current_user
from core.config import settings
from core.database import get_session
from core.utils import file_hash
from models.document import Document
from models.queue import enqueue_stage
from models.study_session import StudySession, StudySessionAsset
from models.user import User
logger = logging.getLogger(__name__)
router = APIRouter()
# ─── Enum 검증 상수 ───
VALID_STUDY_TYPES: set[str] = {"certification", "language"}
VALID_MODES: set[str] = {
"copy", "trace", "blank-repeat",
"dictation", "shadowing",
"quiz", "flashcard", # Phase 2~4 활성, schema 만 수용
}
VALID_ASSET_TYPES: set[str] = {
"source_scan", "handwriting_png", "audio", "video", "transcript", "reference",
}
VALID_ROLES: set[str | None] = {
None,
"prompt", "answer", "pronunciation", "lecture",
"listening_source", "shadowing_source", "reference",
}
VALID_REVIEW_STATES: set[str | None] = {
None, "new", "learning", "weak", "mastered",
}
VALID_ORDERS: set[str] = {"created_at", "next_review_at", "last_quiz_at"}
# ─── Helpers ───
def _upload_error(status_code: int, error_code: str, message: str) -> HTTPException:
"""업로드 실패 응답 — documents.py 와 동일한 패턴."""
return HTTPException(
status_code=status_code,
detail={"error_code": error_code, "message": message},
)
def _verify_session_ownership(
sess: StudySession | None, user: User
) -> StudySession:
"""세션 ownership 검증. 정보 누설 방지로 mismatch 도 404."""
if sess is None or sess.user_id != user.id:
raise HTTPException(status_code=404, detail="학습 세션을 찾을 수 없습니다")
return sess
def _verify_document_ownership(doc: Document | None, user: User) -> Document:
"""문서 ownership 검증.
documents.user_id 컬럼은 현재 single-user 시스템이라 부재.
미래 multi-user 대비 `getattr` 안전하게 비교.
"""
if doc is None or getattr(doc, "deleted_at", None) is not None:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
doc_user_id = getattr(doc, "user_id", None)
if doc_user_id is not None and doc_user_id != user.id:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
return doc
# ─── Pydantic Schemas ───
class StudySessionAssetCreate(BaseModel):
document_id: int
asset_type: str
role: str | None = None
sort_order: int = 0
class StudySessionAssetResponse(BaseModel):
id: int
document_id: int
asset_type: str
role: str | None
sort_order: int
created_at: datetime
class Config:
from_attributes = True
class StudySessionCreate(BaseModel):
study_type: str = "certification"
certification: str | None = None
language_code: str | None = None
learning_level: str | None = None
subject: str | None = None
topic: str | None = None
source_text: str | None = None
source_page: int | None = None
mode: str = "copy"
prompt_question: str | None = None
expected_answer: str | None = None
metadata: dict[str, Any] | None = None
target_count: int | None = None
canvas_width: int | None = None
canvas_height: int | None = None
strokes_json: dict[str, Any] | None = None
# 학습 워크스페이스 묶음. 미지정 시 미분류.
study_topic_id: int | None = None
class StudySessionUpdate(BaseModel):
"""PATCH 부분 업데이트 — 명시 set 된 필드만 반영."""
certification: str | None = None
language_code: str | None = None
learning_level: str | None = None
subject: str | None = None
topic: str | None = None
source_text: str | None = None
source_page: int | None = None
mode: str | None = None
prompt_question: str | None = None
expected_answer: str | None = None
metadata: dict[str, Any] | None = None
target_count: int | None = None
repetition_count: int | None = None
canvas_width: int | None = None
canvas_height: int | None = None
strokes_json: dict[str, Any] | None = None
ocr_text: str | None = None
user_corrected_text: str | None = None
review_state: str | None = None
next_review_at: datetime | None = None
# 주제 재할당 (NULL 로 분리도 가능)
study_topic_id: int | None = None
class StudySessionResponse(BaseModel):
id: int
user_id: int
study_type: str
certification: str | None
language_code: str | None
learning_level: str | None
subject: str | None
topic: str | None
source_text: str | None
source_page: int | None
mode: str
prompt_question: str | None
expected_answer: str | None
metadata: dict[str, Any] | None = Field(default=None)
target_count: int | None
repetition_count: int
canvas_width: int | None
canvas_height: int | None
schema_version: int
strokes_json: dict[str, Any] | None
ocr_text: str | None
user_corrected_text: str | None
ai_summary: str | None
review_state: str | None
next_review_at: datetime | None
last_quiz_at: datetime | None
correct_count: int
incorrect_count: int
study_topic_id: int | None = None
assets: list[StudySessionAssetResponse]
created_at: datetime
updated_at: datetime
class StudySessionListResponse(BaseModel):
items: list[StudySessionResponse]
total: int
limit: int
offset: int
def _to_session_response(sess: StudySession) -> StudySessionResponse:
return StudySessionResponse(
id=sess.id,
user_id=sess.user_id,
study_type=sess.study_type,
certification=sess.certification,
language_code=sess.language_code,
learning_level=sess.learning_level,
subject=sess.subject,
topic=sess.topic,
source_text=sess.source_text,
source_page=sess.source_page,
mode=sess.mode,
prompt_question=sess.prompt_question,
expected_answer=sess.expected_answer,
metadata=sess.metadata_json,
target_count=sess.target_count,
repetition_count=sess.repetition_count,
canvas_width=sess.canvas_width,
canvas_height=sess.canvas_height,
schema_version=sess.schema_version,
strokes_json=sess.strokes_json,
ocr_text=sess.ocr_text,
user_corrected_text=sess.user_corrected_text,
ai_summary=sess.ai_summary,
review_state=sess.review_state,
next_review_at=sess.next_review_at,
last_quiz_at=sess.last_quiz_at,
correct_count=sess.correct_count,
incorrect_count=sess.incorrect_count,
study_topic_id=sess.study_topic_id,
assets=[
StudySessionAssetResponse.model_validate(a) for a in (sess.assets or [])
],
created_at=sess.created_at,
updated_at=sess.updated_at,
)
def _validate_create_payload(body: StudySessionCreate) -> None:
if body.study_type not in VALID_STUDY_TYPES:
raise HTTPException(
status_code=422,
detail=f"study_type 은 {sorted(VALID_STUDY_TYPES)} 중 하나여야 합니다",
)
if body.mode not in VALID_MODES:
raise HTTPException(
status_code=422,
detail=f"mode 는 {sorted(VALID_MODES)} 중 하나여야 합니다",
)
# ─── 엔드포인트 ───
@router.post("/", response_model=StudySessionResponse, status_code=201)
async def create_study_session(
body: StudySessionCreate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""새 학습 세션 생성.
자격증 : study_type='certification', certification='산업안전기사',
subject='산업안전보건법', topic='안전보건관리책임자의 직무', mode='copy'
어학 : study_type='language', language_code='ja', learning_level='JLPT N3',
subject='漢字', topic='安全', source_text='安全',
metadata={'reading':'あんぜん','meaning':'안전','unit_type':'kanji'}
"""
_validate_create_payload(body)
# study_topic_id 가 주어지면 소유 검증 (다른 사용자의 주제로 매핑 차단)
if body.study_topic_id is not None:
from models.study_topic import StudyTopic as _Topic
topic = await session.get(_Topic, body.study_topic_id)
if topic is None or topic.user_id != user.id or topic.deleted_at is not None:
raise HTTPException(status_code=404, detail="학습 주제를 찾을 수 없습니다")
sess = StudySession(
user_id=user.id,
study_type=body.study_type,
certification=body.certification,
language_code=body.language_code,
learning_level=body.learning_level,
subject=body.subject,
topic=body.topic,
source_text=body.source_text,
source_page=body.source_page,
mode=body.mode,
prompt_question=body.prompt_question,
expected_answer=body.expected_answer,
metadata_json=body.metadata,
target_count=body.target_count,
canvas_width=body.canvas_width,
canvas_height=body.canvas_height,
strokes_json=body.strokes_json,
study_topic_id=body.study_topic_id,
)
session.add(sess)
await session.flush()
await session.commit()
# 새 세션은 assets 가 비어있지만 async session lazy load 우회를 위해 명시 refresh
await session.refresh(sess, attribute_names=["assets"])
return _to_session_response(sess)
@router.get("/", response_model=StudySessionListResponse)
async def list_study_sessions(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
study_type: str | None = Query(None),
certification: str | None = Query(None),
language_code: str | None = Query(None),
learning_level: str | None = Query(None),
subject: str | None = Query(None),
topic: str | None = Query(None),
review_state: str | None = Query(None),
document_id: int | None = Query(None, description="이 문서가 연결된 세션만"),
asset_type: str | None = Query(None, description="이 asset_type 보유 세션만"),
mode: str | None = Query(None),
due_before: datetime | None = Query(None, description="next_review_at <= due_before"),
study_topic_id: int | None = Query(None, description="학습 워크스페이스(주제) id"),
order: str = Query("created_at"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""학습 세션 목록 — Phase 1 부터 모든 filter 수용 (Phase 3/4 활성 대비)."""
if study_type is not None and study_type not in VALID_STUDY_TYPES:
raise HTTPException(status_code=422, detail="study_type 값이 올바르지 않습니다")
if review_state is not None and review_state not in VALID_REVIEW_STATES:
raise HTTPException(status_code=422, detail="review_state 값이 올바르지 않습니다")
if asset_type is not None and asset_type not in VALID_ASSET_TYPES:
raise HTTPException(status_code=422, detail="asset_type 값이 올바르지 않습니다")
if mode is not None and mode not in VALID_MODES:
raise HTTPException(status_code=422, detail="mode 값이 올바르지 않습니다")
if order not in VALID_ORDERS:
raise HTTPException(status_code=422, detail="order 값이 올바르지 않습니다")
base = select(StudySession).where(StudySession.user_id == user.id)
if study_type is not None:
base = base.where(StudySession.study_type == study_type)
if certification is not None:
base = base.where(StudySession.certification == certification)
if language_code is not None:
base = base.where(StudySession.language_code == language_code)
if learning_level is not None:
base = base.where(StudySession.learning_level == learning_level)
if subject is not None:
base = base.where(StudySession.subject == subject)
if topic is not None:
base = base.where(StudySession.topic == topic)
if review_state is not None:
base = base.where(StudySession.review_state == review_state)
if mode is not None:
base = base.where(StudySession.mode == mode)
if due_before is not None:
base = base.where(StudySession.next_review_at <= due_before)
if study_topic_id is not None:
base = base.where(StudySession.study_topic_id == study_topic_id)
# assets join filter — EXISTS 서브쿼리
if document_id is not None or asset_type is not None:
asset_conditions = [StudySessionAsset.study_session_id == StudySession.id]
if document_id is not None:
asset_conditions.append(StudySessionAsset.document_id == document_id)
if asset_type is not None:
asset_conditions.append(StudySessionAsset.asset_type == asset_type)
base = base.where(
select(StudySessionAsset.id)
.where(and_(*asset_conditions))
.exists()
)
count_query = select(func.count()).select_from(base.subquery())
total = (await session.execute(count_query)).scalar() or 0
if order == "next_review_at":
ordered = base.order_by(StudySession.next_review_at.asc().nullslast(), StudySession.id.desc())
elif order == "last_quiz_at":
ordered = base.order_by(StudySession.last_quiz_at.desc().nullslast(), StudySession.id.desc())
else:
ordered = base.order_by(StudySession.created_at.desc(), StudySession.id.desc())
ordered = (
ordered.options(selectinload(StudySession.assets))
.offset(offset)
.limit(limit)
)
rows = (await session.execute(ordered)).scalars().all()
return StudySessionListResponse(
items=[_to_session_response(s) for s in rows],
total=total,
limit=limit,
offset=offset,
)
@router.get("/groups")
async def get_study_groups(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""도메인별 그룹 카운트 (Phase 3 모바일 카드 메뉴 대비, Phase 1 부터 endpoint 제공).
응답: {by_type: {certification: {...}, language: {...}}}
"""
# certification 그룹: certification → subject → topic
cert_query = (
select(
StudySession.certification,
StudySession.subject,
StudySession.topic,
func.count().label("session_count"),
func.count().filter(StudySession.review_state == "weak").label("weak_count"),
func.count()
.filter(
and_(
StudySession.next_review_at.is_not(None),
StudySession.next_review_at <= datetime.now(timezone.utc),
)
)
.label("due_count"),
)
.where(
StudySession.user_id == user.id,
StudySession.study_type == "certification",
)
.group_by(StudySession.certification, StudySession.subject, StudySession.topic)
)
cert_rows = (await session.execute(cert_query)).all()
# language 그룹: language_code → learning_level → subject → topic + assets 보유 여부
lang_query = (
select(
StudySession.language_code,
StudySession.learning_level,
StudySession.subject,
StudySession.topic,
func.count().label("session_count"),
func.count().filter(StudySession.review_state == "weak").label("weak_count"),
func.count()
.filter(
and_(
StudySession.next_review_at.is_not(None),
StudySession.next_review_at <= datetime.now(timezone.utc),
)
)
.label("due_count"),
)
.where(
StudySession.user_id == user.id,
StudySession.study_type == "language",
)
.group_by(
StudySession.language_code,
StudySession.learning_level,
StudySession.subject,
StudySession.topic,
)
)
lang_rows = (await session.execute(lang_query)).all()
# 어학 그룹의 has_audio / has_video — 별도 카운트 (assets 와 join)
media_query = (
select(
StudySession.language_code,
StudySession.learning_level,
StudySession.subject,
StudySession.topic,
StudySessionAsset.asset_type,
func.count().label("c"),
)
.join(StudySessionAsset, StudySessionAsset.study_session_id == StudySession.id)
.where(
StudySession.user_id == user.id,
StudySession.study_type == "language",
StudySessionAsset.asset_type.in_(["audio", "video"]),
)
.group_by(
StudySession.language_code,
StudySession.learning_level,
StudySession.subject,
StudySession.topic,
StudySessionAsset.asset_type,
)
)
media_rows = (await session.execute(media_query)).all()
media_map: dict[tuple, dict[str, int]] = {}
for r in media_rows:
key = (r.language_code, r.learning_level, r.subject, r.topic)
media_map.setdefault(key, {"audio": 0, "video": 0})[r.asset_type] = r.c
# certification 트리 빌드
cert_groups: dict[str | None, dict[str | None, dict[str | None, dict]]] = {}
for r in cert_rows:
cert_groups.setdefault(r.certification, {}).setdefault(r.subject, {})[r.topic] = {
"session_count": r.session_count,
"weak_count": r.weak_count,
"due_count": r.due_count,
}
cert_out = []
for cert_name, subjects in cert_groups.items():
subj_list = []
sess_total = weak_total = due_total = 0
for subj_name, topics in subjects.items():
topic_list = []
s_count = w_count = d_count = 0
for topic_name, stats in topics.items():
topic_list.append({
"topic": topic_name,
"session_count": stats["session_count"],
"weak_count": stats["weak_count"],
"due_count": stats["due_count"],
})
s_count += stats["session_count"]
w_count += stats["weak_count"]
d_count += stats["due_count"]
subj_list.append({
"subject": subj_name,
"topics": topic_list,
"session_count": s_count,
"weak_count": w_count,
"due_count": d_count,
})
sess_total += s_count
weak_total += w_count
due_total += d_count
cert_out.append({
"certification": cert_name,
"subjects": subj_list,
"session_count": sess_total,
"weak_count": weak_total,
"due_count": due_total,
})
# language 트리 빌드
lang_groups: dict[str | None, dict[str | None, dict[str | None, dict[str | None, dict]]]] = {}
for r in lang_rows:
media = media_map.get(
(r.language_code, r.learning_level, r.subject, r.topic),
{"audio": 0, "video": 0},
)
(
lang_groups
.setdefault(r.language_code, {})
.setdefault(r.learning_level, {})
.setdefault(r.subject, {})[r.topic]
) = {
"session_count": r.session_count,
"weak_count": r.weak_count,
"due_count": r.due_count,
"has_audio": media["audio"] > 0,
"has_video": media["video"] > 0,
}
lang_out = []
for lang_code, levels in lang_groups.items():
for level_name, subjects in levels.items():
subj_list = []
for subj_name, topics in subjects.items():
topic_list = []
for topic_name, stats in topics.items():
topic_list.append({
"topic": topic_name,
"session_count": stats["session_count"],
"weak_count": stats["weak_count"],
"due_count": stats["due_count"],
"has_audio": stats["has_audio"],
"has_video": stats["has_video"],
})
subj_list.append({"subject": subj_name, "topics": topic_list})
lang_out.append({
"language_code": lang_code,
"learning_level": level_name,
"subjects": subj_list,
})
return {
"by_type": {
"certification": {"groups": cert_out},
"language": {"groups": lang_out},
}
}
@router.get("/{session_id}", response_model=StudySessionResponse)
async def get_study_session(
session_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
sess = await session.get(
StudySession, session_id, options=[selectinload(StudySession.assets)]
)
sess = _verify_session_ownership(sess, user)
return _to_session_response(sess)
@router.patch("/{session_id}", response_model=StudySessionResponse)
async def update_study_session(
session_id: int,
body: StudySessionUpdate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
sess = await session.get(
StudySession, session_id, options=[selectinload(StudySession.assets)]
)
sess = _verify_session_ownership(sess, user)
# 명시 set 된 필드만 적용
fields_set = body.model_fields_set
if "mode" in fields_set:
if body.mode not in VALID_MODES:
raise HTTPException(status_code=422, detail="mode 값이 올바르지 않습니다")
sess.mode = body.mode
if "review_state" in fields_set:
if body.review_state not in VALID_REVIEW_STATES:
raise HTTPException(status_code=422, detail="review_state 값이 올바르지 않습니다")
sess.review_state = body.review_state
# study_topic_id 변경 시 소유 검증
if "study_topic_id" in fields_set and body.study_topic_id is not None:
from models.study_topic import StudyTopic as _Topic
topic = await session.get(_Topic, body.study_topic_id)
if topic is None or topic.user_id != user.id or topic.deleted_at is not None:
raise HTTPException(status_code=404, detail="학습 주제를 찾을 수 없습니다")
# 단순 매핑 필드 (검증 불필요)
SIMPLE_FIELDS = {
"certification", "language_code", "learning_level", "subject", "topic",
"source_text", "source_page", "prompt_question", "expected_answer",
"target_count", "repetition_count",
"canvas_width", "canvas_height", "strokes_json",
"ocr_text", "user_corrected_text", "next_review_at",
"study_topic_id",
}
for fname in SIMPLE_FIELDS & fields_set:
setattr(sess, fname, getattr(body, fname))
if "metadata" in fields_set:
sess.metadata_json = body.metadata
sess.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(sess, attribute_names=["assets"])
return _to_session_response(sess)
@router.delete("/{session_id}", status_code=204)
async def delete_study_session(
session_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""학습 세션 삭제. 연관 assets 도 cascade 로 함께 제거 (DB ON DELETE CASCADE).
documents 본체는 유지 assets row 사라진다.
"""
sess = await session.get(StudySession, session_id)
sess = _verify_session_ownership(sess, user)
await session.delete(sess)
await session.commit()
# ─── Assets 엔드포인트 ───
@router.post(
"/{session_id}/assets",
response_model=StudySessionAssetResponse,
status_code=201,
)
async def link_study_asset(
session_id: int,
body: StudySessionAssetCreate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""기존 documents 의 id 를 study_session 에 asset 으로 연결.
409: 같은 (session, document, asset_type, role) 조합 이미 존재.
"""
if body.asset_type not in VALID_ASSET_TYPES:
raise HTTPException(
status_code=422,
detail=f"asset_type 은 {sorted(VALID_ASSET_TYPES)} 중 하나여야 합니다",
)
if body.role not in VALID_ROLES:
raise HTTPException(
status_code=422,
detail=f"role 은 {sorted(r for r in VALID_ROLES if r is not None)} 중 하나 또는 NULL 이어야 합니다",
)
sess = await session.get(StudySession, session_id)
sess = _verify_session_ownership(sess, user)
doc = await session.get(Document, body.document_id)
_verify_document_ownership(doc, user)
# 사전 SELECT 로 중복 검사 + DB UNIQUE 제약 둘 다 — race condition 안전.
existing = await session.execute(
select(StudySessionAsset).where(
StudySessionAsset.study_session_id == session_id,
StudySessionAsset.document_id == body.document_id,
StudySessionAsset.asset_type == body.asset_type,
StudySessionAsset.role.is_(body.role) if body.role is None
else StudySessionAsset.role == body.role,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409,
detail={
"error_code": "asset_already_linked",
"message": "해당 문서가 이미 같은 asset_type/role 로 연결되어 있습니다",
},
)
asset = StudySessionAsset(
study_session_id=session_id,
document_id=body.document_id,
asset_type=body.asset_type,
role=body.role,
sort_order=body.sort_order,
)
session.add(asset)
try:
await session.commit()
except IntegrityError:
await session.rollback()
# UNIQUE 위반 — 위 사전 SELECT 와 race 했을 가능성. 동일 메시지로 응답.
raise HTTPException(
status_code=409,
detail={
"error_code": "asset_already_linked",
"message": "해당 문서가 이미 같은 asset_type/role 로 연결되어 있습니다",
},
)
await session.refresh(asset)
return StudySessionAssetResponse.model_validate(asset)
@router.delete(
"/{session_id}/assets/{asset_id}", status_code=204
)
async def unlink_study_asset(
session_id: int,
asset_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""asset 연결 해제. documents 본체는 유지."""
sess = await session.get(StudySession, session_id)
sess = _verify_session_ownership(sess, user)
asset = await session.get(StudySessionAsset, asset_id)
if asset is None or asset.study_session_id != session_id:
raise HTTPException(status_code=404, detail="asset 을 찾을 수 없습니다")
await session.delete(asset)
await session.commit()
# ─── Snapshot (PNG 업로드) ───
@router.post("/{session_id}/snapshot", response_model=StudySessionAssetResponse, status_code=201)
async def upload_handwriting_snapshot(
session_id: int,
request: Request,
file: UploadFile,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
sort_order: int = Form(0),
):
"""캔버스 PNG 업로드 → documents 등록 + handwriting_png asset 연결.
documents.py upload_document atomic rename + error_code 패턴을 PNG 전용으로 차용.
동일 세션에 여러 snapshot 누적 가능 (UNIQUE 제약은 (session, document, type, role) 단위라
document_id 매번 새로 생기므로 충돌 없음).
"""
sess = await session.get(StudySession, session_id)
sess = _verify_session_ownership(sess, user)
if not file.filename:
raise _upload_error(400, "invalid_input", "파일명이 필요합니다")
safe_name = Path(file.filename).name
if not safe_name or safe_name.startswith("."):
raise _upload_error(400, "invalid_input", "유효하지 않은 파일명")
ext = Path(safe_name).suffix.lower()
if ext != ".png":
raise _upload_error(
400, "invalid_input", "snapshot 은 PNG 파일만 지원합니다",
)
max_bytes = settings.upload.max_bytes
slack_ratio = settings.upload.content_length_slack_ratio
chunk_size = settings.upload.stream_chunk_bytes
# Content-Length 사전 차단
cl_header = request.headers.get("content-length")
if cl_header:
try:
cl = int(cl_header)
if cl > int(max_bytes * slack_ratio):
raise _upload_error(413, "body_too_large", "파일이 너무 큽니다")
except ValueError:
pass
# NAS Inbox 경로 결정 + 충돌 회피
inbox_dir = Path(settings.nas_mount_path) / "PKM" / "Inbox"
inbox_dir.mkdir(parents=True, exist_ok=True)
target = (inbox_dir / safe_name).resolve()
if not str(target).startswith(str(inbox_dir.resolve())):
raise _upload_error(400, "invalid_input", "잘못된 파일 경로")
counter = 1
stem, suffix = target.stem, target.suffix
staging = target.with_name(target.name + ".uploading")
while target.exists() or staging.exists():
target = inbox_dir.resolve() / f"{stem}_{counter}{suffix}"
staging = target.with_name(target.name + ".uploading")
counter += 1
# 스트리밍 저장 + 누적 사이즈 검증
written = 0
try:
with staging.open("wb") as f:
while chunk := await file.read(chunk_size):
written += len(chunk)
if written > max_bytes:
raise _upload_error(413, "body_too_large", "파일이 너무 큽니다")
f.write(chunk)
if written == 0:
raise _upload_error(400, "empty_file", "빈 파일은 업로드할 수 없습니다")
except ClientDisconnect:
staging.unlink(missing_ok=True)
logger.info("snapshot aborted by client: %s (written=%d)", safe_name, written)
raise _upload_error(499, "network_abort", "업로드가 취소되었습니다")
except asyncio.TimeoutError:
staging.unlink(missing_ok=True)
logger.warning("snapshot timeout: %s (written=%d)", safe_name, written)
raise _upload_error(408, "upload_timeout", "업로드 시간 초과")
except HTTPException:
staging.unlink(missing_ok=True)
raise
except Exception:
staging.unlink(missing_ok=True)
logger.exception("snapshot internal error: %s (written=%d)", safe_name, written)
raise _upload_error(500, "internal", "업로드 처리 중 오류가 발생했습니다")
# atomic rename → 최종 경로
try:
staging.replace(target)
except OSError:
staging.unlink(missing_ok=True)
logger.exception("snapshot rename failed: %s -> %s", staging, target)
raise _upload_error(500, "internal", "파일 저장 후 정리 중 오류가 발생했습니다")
# Document + ProcessingQueue('extract') + StudySessionAsset 단일 트랜잭션
rel_path = str(target.relative_to(Path(settings.nas_mount_path)))
fhash = file_hash(target)
# 학습 세션 메타에서 user_tags 합성
domain_tag = sess.certification or sess.language_code or "general"
user_tags = ["handwriting", domain_tag]
if sess.subject:
user_tags.append(sess.subject)
title = f"필기 — {sess.topic or sess.subject or 'study session'} #{session_id}"
try:
doc = Document(
file_path=rel_path,
file_hash=fhash,
file_format="png",
file_size=written,
file_type="immutable",
title=title,
user_tags=user_tags,
)
session.add(doc)
await session.flush()
await enqueue_stage(session, doc.id, "extract")
asset = StudySessionAsset(
study_session_id=session_id,
document_id=doc.id,
asset_type="handwriting_png",
role="answer",
sort_order=sort_order,
)
session.add(asset)
await session.commit()
await session.refresh(asset)
except Exception:
# DB 트랜잭션은 자동 rollback. 파일은 별도 자원 → 명시 unlink.
target.unlink(missing_ok=True)
raise
return StudySessionAssetResponse.model_validate(asset)
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
"""비디오 썸네일 서빙 API — /api/video
ffmpeg 썸네일 생성은 thumbnail_worker 에서 수행. 라우터는 저장된 파일만 서빙.
"""
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import decode_token, get_current_user
from core.database import get_session
from models.document import Document
from models.user import User
router = APIRouter()
@router.get("/{doc_id}/thumbnail")
async def get_video_thumbnail(
doc_id: int,
session: Annotated[AsyncSession, Depends(get_session)],
token: str | None = Query(None, description="Bearer token (img src 용)"),
user: User | None = Depends(lambda: None),
):
"""비디오 썸네일 jpg 서빙. `<img src="...?token=...">` 바인딩 가능.
쿼리 토큰 또는 Authorization 헤더 하나로 인증. /file 엔드포인트와 동일 정책.
"""
# 쿼리 토큰 검증 (img src 용) — /file 과 동일 패턴
if not token:
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
payload = decode_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
doc = await session.get(Document, doc_id)
if not doc or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
thumb = getattr(doc, "thumbnail_path", None)
if not thumb:
raise HTTPException(status_code=404, detail="썸네일이 아직 생성되지 않았습니다")
path = Path(thumb)
if not path.exists():
raise HTTPException(status_code=404, detail="썸네일 파일이 없습니다")
return FileResponse(
path=str(path),
media_type="image/jpeg",
headers={"Content-Disposition": "inline"},
)
+51 -5
View File
@@ -1,5 +1,6 @@
"""JWT + TOTP 2FA 인증"""
import os
from datetime import datetime, timedelta, timezone
from typing import Annotated
@@ -30,15 +31,30 @@ def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": subject, "exp": expire, "type": "access"}
def create_access_token(subject: str, expires_minutes: int | None = None) -> str:
minutes = expires_minutes if expires_minutes is not None else ACCESS_TOKEN_EXPIRE_MINUTES
now = datetime.now(timezone.utc)
expire = now + timedelta(minutes=minutes)
payload = {"sub": subject, "exp": expire, "iat": int(now.timestamp()), "type": "access"}
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
def create_voice_memo_bot_token(username: str) -> str | None:
# Voice Memo PoC v1 — bot 계정 한정 long-expiry access token (env gate + username hard-match).
# 일반 사용자 호출 시 None 반환. 정식 service-account/api_keys 는 Phase 2.
if os.getenv("VOICE_MEMO_BOT_TOKEN_ENABLED", "false").lower() != "true":
return None
bot_username = os.getenv("VOICE_MEMO_BOT_USERNAME", "voice-memo-bot")
if username != bot_username:
return None
expire_days = int(os.getenv("VOICE_MEMO_BOT_TOKEN_EXPIRE_DAYS", "365"))
return create_access_token(username, expires_minutes=expire_days * 24 * 60)
def create_refresh_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
payload = {"sub": subject, "exp": expire, "type": "refresh"}
now = datetime.now(timezone.utc)
expire = now + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
payload = {"sub": subject, "exp": expire, "iat": int(now.timestamp()), "type": "refresh"}
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
@@ -49,6 +65,21 @@ def decode_token(token: str) -> dict | None:
return None
def verify_password_changed_at(payload: dict, user) -> None:
# legacy 호환: password_changed_at NULL 이면 검증 skip (migration 전 발급 token 유지)
# password 변경 후 발급 token 만 검증 — iat (int 초) >= int(password_changed_at.timestamp())
if user.password_changed_at is None:
return
iat = payload.get("iat")
pwd_changed_int = int(user.password_changed_at.timestamp())
if iat is None or pwd_changed_int > int(iat):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="비밀번호 변경 후 재로그인 필요",
)
def verify_totp(code: str, secret: str | None = None) -> bool:
"""TOTP 코드 검증 (유저별 secret 또는 글로벌 설정)"""
totp_secret = secret or settings.totp_secret
@@ -82,4 +113,19 @@ async def get_current_user(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유저를 찾을 수 없음",
)
verify_password_changed_at(payload, user)
return user
async def require_admin(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""관리자 권한 확인 — 뉴스 소스 CRUD, 수집 트리거, digest 재생성 등"""
user = await get_current_user(credentials, session)
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자 권한 필요",
)
return user
+106 -7
View File
@@ -7,6 +7,15 @@ import yaml
from pydantic import BaseModel
class UploadConfig(BaseModel):
max_bytes: int = 100_000_000
content_length_slack_ratio: float = 1.05
stream_chunk_bytes: int = 1_048_576
# orphan cleanup (`*.uploading` — 크래시/abort 후 잔존물)
orphan_max_age_sec: int = 3600
cleanup_warn_threshold: int = 10
class AIModelConfig(BaseModel):
endpoint: str
model: str
@@ -14,16 +23,36 @@ class AIModelConfig(BaseModel):
timeout: int = 60
daily_budget_usd: float | None = None
require_explicit_trigger: bool = False
# B-0: 4B/26B 에 부여한 실사용 컨텍스트 상한 (char). triage=120k, primary=260k.
# classify_worker 가 에스컬레이션 판정 시 참고. 0/None 이면 상한 무시.
context_char_limit: int | None = None
class DeepSummaryBacklogConfig(BaseModel):
"""B-1 R2 — deep_summary enqueue 폭발 억제 임계치."""
ratio_threshold: float = 0.3 # 지난 window 의 deep_n/classify_n
pending_threshold: int = 5 # deep_summary pending+processing
window_minutes: int = 30
class AIConfig(BaseModel):
gateway_endpoint: str
# B-0: 3-tier routing. triage/primary = Mac mini 26B MLX (PR #20 endpoint 통합). fallback = Claude Sonnet 4 API.
triage: AIModelConfig
primary: AIModelConfig
fallback: AIModelConfig
premium: AIModelConfig
embedding: AIModelConfig
vision: AIModelConfig
rerank: AIModelConfig
# Phase 3.5a: answerability classifier (optional — 없으면 score-only gate). PR #20 이후 Mac mini 26B MLX endpoint (initial = exaone3.5).
classifier: AIModelConfig | None = None
# Phase 3.5b: semantic verifier (optional — 없으면 grounding-only). PR #20 이후 Mac mini 26B MLX endpoint (initial = exaone3.5).
verifier: AIModelConfig | None = None
# Legacy: vision 슬롯 (현재 사용처 0 — Document Server 는 OCR/STT 별도 서비스).
# 제거 진행 중이므로 optional 로 관대한 로딩 유지.
vision: AIModelConfig | None = None
# B-1 R2: backlog guard 임계치
deep_summary_backlog: DeepSummaryBacklogConfig = DeepSummaryBacklogConfig()
class Settings(BaseModel):
@@ -41,21 +70,62 @@ class Settings(BaseModel):
jwt_secret: str = ""
totp_secret: str = ""
# Phase 3.5: eval runner shared secret — X-Source=eval / X-Eval-Case-Id 헤더 신뢰 검증.
# 비어있으면 모든 eval 헤더 거부 (부재 = 비활성).
eval_runner_token: str = ""
# kordoc
kordoc_endpoint: str = "http://kordoc-service:3100"
# OCR (Surya)
ocr_endpoint: str = "http://ocr-service:3200"
# STT (faster-whisper, §3)
stt_endpoint: str = "http://stt-service:3300"
# §3 file_watcher: Roon 음원 경로 (prefix match 로 skip).
# 빈 문자열이면 skip 없음. 예: "/documents/PKM/../Music/roon-library" 또는
# NFS 경유 별도 마운트된 Roon 라이브러리.
roon_library_path: str = ""
# KGS Code 등 외부 작성 마크다운 자료 추가 스캔 경로 (PKM 상대 경로, 쉼표 구분).
# env: ADDITIONAL_WATCH_TARGETS=Knowledge/Industrial_Safety/가스기사/KGS_Code,...
# 모두 expected_category="library" 로 처리 (md/pdf/docx 등 문서 확장자만 수락).
# Inbox/Recordings/Videos 기본 스캔 외에 추가만 허용.
additional_watch_targets: list[str] = []
# 분류 체계
taxonomy: dict = {}
document_types: list[str] = []
# 업로드 한도 (authoritative policy)
upload: UploadConfig = UploadConfig()
# PR-MacMini-Derived-Worker-1: study explanation owner = Mac mini
# GPU 측은 false 로 설정 (.env), explanation 분기 skip guard 트리거.
study_explanation_enabled: bool = True
# internal endpoint Bearer token (Mac mini derived-worker 호출용)
internal_worker_token: str = ""
def load_settings() -> Settings:
"""config.yaml + 환경변수에서 설정 로딩"""
# 환경변수 (docker-compose에서 주입)
database_url = os.getenv("DATABASE_URL", "")
study_explanation_enabled = os.getenv("STUDY_EXPLANATION_ENABLED", "true").lower() in ("1", "true", "yes")
internal_worker_token = os.getenv("INTERNAL_WORKER_TOKEN", "")
jwt_secret = os.getenv("JWT_SECRET", "")
totp_secret = os.getenv("TOTP_SECRET", "")
eval_runner_token = os.getenv("EVAL_RUNNER_TOKEN", "")
kordoc_endpoint = os.getenv("KORDOC_ENDPOINT", "http://kordoc-service:3100")
ocr_endpoint = os.getenv("OCR_ENDPOINT", "http://ocr-service:3200")
stt_endpoint = os.getenv("STT_ENDPOINT", "http://stt-service:3300")
roon_library_path = os.getenv("ROON_LIBRARY_PATH", "")
# ADDITIONAL_WATCH_TARGETS — 쉼표 구분 (공백 제거)
awt_raw = os.getenv("ADDITIONAL_WATCH_TARGETS", "")
additional_watch_targets = [p.strip() for p in awt_raw.split(",") if p.strip()]
# config.yaml — Docker 컨테이너 내부(/app/config.yaml) 또는 프로젝트 루트
config_path = Path("/app/config.yaml")
@@ -71,14 +141,30 @@ def load_settings() -> Settings:
if "ai" in raw:
ai_raw = raw["ai"]
models = ai_raw.get("models", {})
# B-0: triage 는 config.yaml 에 없을 수도 있는 신규 슬롯. 구버전 호환을 위해
# 없으면 fallback 를 triage 로 대체 (동일 모델 재사용).
triage_raw = models.get("triage") or models.get("fallback")
if triage_raw is None:
raise ValueError("config.yaml: ai.models.triage (or fallback) required")
ai_config = AIConfig(
gateway_endpoint=ai_raw.get("gateway", {}).get("endpoint", ""),
primary=AIModelConfig(**ai_raw["models"]["primary"]),
fallback=AIModelConfig(**ai_raw["models"]["fallback"]),
premium=AIModelConfig(**ai_raw["models"]["premium"]),
embedding=AIModelConfig(**ai_raw["models"]["embedding"]),
vision=AIModelConfig(**ai_raw["models"]["vision"]),
rerank=AIModelConfig(**ai_raw["models"]["rerank"]),
triage=AIModelConfig(**triage_raw),
primary=AIModelConfig(**models["primary"]),
fallback=AIModelConfig(**models["fallback"]),
premium=AIModelConfig(**models["premium"]),
embedding=AIModelConfig(**models["embedding"]),
rerank=AIModelConfig(**models["rerank"]),
vision=(AIModelConfig(**models["vision"]) if "vision" in models else None),
classifier=(
AIModelConfig(**models["classifier"]) if "classifier" in models else None
),
verifier=(
AIModelConfig(**models["verifier"]) if "verifier" in models else None
),
deep_summary_backlog=DeepSummaryBacklogConfig(
**ai_raw.get("deep_summary_backlog", {})
),
)
if "nas" in raw:
@@ -87,6 +173,11 @@ def load_settings() -> Settings:
taxonomy = raw.get("taxonomy", {}) if config_path.exists() and raw else {}
document_types = raw.get("document_types", []) if config_path.exists() and raw else []
upload_cfg = (
UploadConfig(**raw["upload"])
if config_path.exists() and raw and "upload" in raw
else UploadConfig()
)
return Settings(
database_url=database_url,
@@ -95,9 +186,17 @@ def load_settings() -> Settings:
nas_pkm_root=nas_pkm,
jwt_secret=jwt_secret,
totp_secret=totp_secret,
eval_runner_token=eval_runner_token,
kordoc_endpoint=kordoc_endpoint,
ocr_endpoint=ocr_endpoint,
stt_endpoint=stt_endpoint,
roon_library_path=roon_library_path,
additional_watch_targets=additional_watch_targets,
taxonomy=taxonomy,
document_types=document_types,
upload=upload_cfg,
study_explanation_enabled=study_explanation_enabled,
internal_worker_token=internal_worker_token,
)
+10 -2
View File
@@ -95,7 +95,8 @@ async def _run_migrations(conn) -> None:
applied = {row[0] for row in result}
# migration 파일 스캔
migrations_dir = Path(__file__).resolve().parent.parent.parent / "migrations"
# /app/core/database.py → parent.parent = /app → /app/migrations (volume mount 위치)
migrations_dir = Path(__file__).resolve().parent.parent / "migrations"
if not migrations_dir.is_dir():
logger.info("[migration] migrations/ 디렉토리 없음, 스킵")
return
@@ -113,8 +114,15 @@ async def _run_migrations(conn) -> None:
for version, name, path in pending:
sql = path.read_text(encoding="utf-8")
_validate_sql_content(name, sql)
if "schema_migrations" in sql.lower():
raise ValueError(
f"Migration {name} must not modify schema_migrations table"
)
logger.info(f"[migration] {name} 실행 중...")
await conn.execute(text(sql))
# raw driver SQL 사용 — text() 의 :name bind parameter 해석으로
# SQL 주석/literal 에 콜론이 들어가면 InvalidRequestError 발생.
# exec_driver_sql 은 SQL 을 driver(asyncpg) 에 그대로 전달.
await conn.exec_driver_sql(sql)
await conn.execute(
text("INSERT INTO schema_migrations (version, name) VALUES (:v, :n)"),
{"v": version, "n": name},
+80
View File
@@ -0,0 +1,80 @@
"""자료실 경로 유틸.
user_tags @library/ 접두사 태그를 정규화·검증·추출한다.
"""
LIBRARY_PREFIX = "@library/"
DEFAULT_LIBRARY_PATH = "미분류"
MAX_DEPTH = 5
MAX_SEGMENT_LEN = 30
def normalize_library_path(raw: str) -> str:
"""경로 정규화. 엄격 정책 — 규칙 위반 시 ValueError 즉시 raise.
규칙:
- 앞뒤 공백·슬래시 제거
- segment별 trim
- segment(// 또는 공백만) ValueError
- segment 30 초과 ValueError
- 5단계 초과 ValueError
GET /documents/library?path= 쿼리에도 동일하게 적용.
"""
stripped = raw.strip().strip("/")
if not stripped:
raise ValueError("빈 경로")
segments = stripped.split("/")
normalized: list[str] = []
for s in segments:
s = s.strip()
if not s:
raise ValueError("빈 세그먼트 (// 또는 공백만 있는 구간)")
if len(s) > MAX_SEGMENT_LEN:
raise ValueError(f"세그먼트 '{s}'{MAX_SEGMENT_LEN}자 초과")
normalized.append(s)
if len(normalized) > MAX_DEPTH:
raise ValueError(f"최대 {MAX_DEPTH}단계까지 가능")
return "/".join(normalized)
def extract_library_paths(user_tags: list[str] | None) -> list[str]:
"""user_tags에서 @library/ 경로만 추출 (prefix 포함)."""
if not user_tags:
return []
return [t for t in user_tags if t.startswith(LIBRARY_PREFIX)]
def validate_user_tags(tags: list) -> list[str]:
"""user_tags 전체 검증. 입력 순서 보존, 중복 제거.
- 문자열이 아닌 원소 TypeError
- 문자열 / 공백만 있는 태그 제거
- 일반 태그 strip() 통과
- @library/ 태그 normalize_library_path() 적용
- 중복 출현만 유지 (입력 순서 보존)
"""
result: list[str] = []
for tag in tags:
if not isinstance(tag, str):
raise TypeError(f"태그는 문자열이어야 합니다: {tag!r}")
tag = tag.strip()
if not tag:
continue
if tag.startswith(LIBRARY_PREFIX):
path = tag[len(LIBRARY_PREFIX):]
normalized = normalize_library_path(path)
tag = f"{LIBRARY_PREFIX}{normalized}"
result.append(tag)
# 중복 제거 (입력 순서 보존)
seen: set[str] = set()
deduped: list[str] = []
for t in result:
if t not in seen:
seen.add(t)
deduped.append(t)
return deduped
+62
View File
@@ -0,0 +1,62 @@
"""외부 피드 URL 검증 — SSRF 차단 + redirect target 재검증
등록 validate_feed_url() 1 검증, fetch redirect target마다
동일 함수로 재검증. 완전한 TOCTOU 방어는 httpx transport 레벨 후킹이
필요하므로 이중 검증이 현재 현실적 상한선.
"""
import ipaddress
import socket
from urllib.parse import urlparse
ALLOWED_SCHEMES = {"https"}
# HTTP 예외 도메인 — 여기에 없으면 HTTPS만 허용
# 추가 시 사유/승인일/재검토일을 주석에 기록
HTTP_EXCEPTION_DOMAINS: set[str] = {
"www.scmp.com", # 2026-04-13 승인, HTTPS→HTTP 301 redirect. 2026-07 재검토
}
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""ipaddress 내장 속성으로 넓게 차단 (단순 대역 비교보다 안전)"""
return (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
or ip.is_unspecified
# Tailscale CGNAT 대역 (is_private에 포함 안 됨)
or ip in ipaddress.ip_network("100.64.0.0/10")
)
def validate_feed_url(url: str, allow_http: bool = False) -> str:
"""URL 검증. 실패 시 ValueError raise.
allow_http는 HTTP_EXCEPTION_DOMAINS allowlist 연동 시에만 사용.
API 파라미터로 노출하지 않는다.
"""
parsed = urlparse(url)
allowed = ALLOWED_SCHEMES | ({"http"} if allow_http else set())
if parsed.scheme not in allowed:
raise ValueError(f"허용되지 않은 스킴: {parsed.scheme}")
if not parsed.hostname:
raise ValueError("호스트명 누락")
# DNS 해석 후 IP 차단
try:
addrs = socket.getaddrinfo(parsed.hostname, None)
except socket.gaierror:
raise ValueError(f"DNS 해석 실패: {parsed.hostname}")
for _, _, _, _, sockaddr in addrs:
ip = ipaddress.ip_address(sockaddr[0])
if _is_blocked_ip(ip):
# IP 자체를 에러에 노출하지 않음 — hostname만
raise ValueError(f"차단된 네트워크: {parsed.hostname}")
return url
+81 -5
View File
@@ -6,12 +6,27 @@ from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select, text
from api.audio import router as audio_router
from api.internal_study import router as internal_study_router
from api.auth import router as auth_router
from api.briefing import router as briefing_router
from api.config import router as config_router
from api.dashboard import router as dashboard_router
from api.digest import router as digest_router
from api.document_notes import router as document_notes_router
from api.document_reads import router as document_reads_router
from api.documents import router as documents_router
from api.events import router as events_router
from api.library import router as library_router
from api.memos import router as memos_router
from api.news import router as news_router
from api.search import router as search_router
from api.setup import router as setup_router
from api.study_question_progress import router as study_question_progress_router
from api.study_questions import router as study_questions_router
from api.study_sessions import router as study_sessions_router
from api.study_topics import router as study_topics_router
from api.video import router as video_router
from core.config import settings
from core.database import async_session, engine, init_db
from models.user import User
@@ -20,14 +35,30 @@ from models.user import User
@asynccontextmanager
async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행되는 lifespan 핸들러"""
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from zoneinfo import ZoneInfo
KST = ZoneInfo("Asia/Seoul")
from services.search.query_analyzer import prewarm_analyzer
from workers.briefing_worker import run as morning_briefing_run
from workers.daily_digest import run as daily_digest_run
from workers.digest_worker import run as global_digest_run
from workers.file_watcher import watch_inbox
from workers.law_monitor import run as law_monitor_run
from workers.mailplus_archive import run as mailplus_run
from workers.news_collector import run as news_collector_run
from workers.queue_consumer import consume_queue
from workers.study_queue_consumer import consume_study_queue
from workers.study_session_queue_consumer import consume_study_session_queue
from workers.study_question_embed_worker import (
refresh_stale_related as study_q_related_refresh,
run as study_q_embed_run,
)
from workers.tier_backfill import run as tier_backfill_run
from workers.upload_cleanup import cleanup_orphan_uploads
# 시작: DB 연결 확인
await init_db()
@@ -46,14 +77,41 @@ async def lifespan(app: FastAPI):
# 상시 실행
scheduler.add_job(consume_queue, "interval", minutes=1, id="queue_consumer")
scheduler.add_job(watch_inbox, "interval", minutes=5, id="file_watcher")
scheduler.add_job(cleanup_orphan_uploads, "interval", minutes=10, id="upload_cleanup")
# PR-4: study_questions 자동 임베딩 (status='none/failed/stale' 행을 batch=10 처리).
# 별도 큐 테이블 없이 status 자체가 큐. backfill 도 cron 이 'none' 행을 자연스럽게 처리.
scheduler.add_job(study_q_embed_run, "interval", minutes=1, id="study_q_embed")
# PR-12-A 후속: related-types 캐시 stale 행 재계산. 임베딩 워커와 분리한 별도 cron.
# 새 문제 ready / 같은 토픽 invalidation / 임계값 변경 시 NULL 마킹된 행을 batch=20 처리.
scheduler.add_job(study_q_related_refresh, "interval", minutes=1, id="study_q_related_refresh")
# Phase 4-A: study_question_jobs 처리 — wrong/unsure AI 풀이 prefetch.
# MLX gate 직렬화 + BATCH_SIZE=1 로 GPU 부하 통제. STALE_MINUTES=10 자체 복구.
scheduler.add_job(consume_study_queue, "interval", minutes=1, id="study_queue_consumer")
# Phase 4-B v1: study_quiz_session_jobs 처리 — 세션 단위 자유 마크다운 분석.
# 4-A 와 같은 MLX gate 공유 — 4-A 처리 중이면 직렬 대기.
scheduler.add_job(consume_study_session_queue, "interval", minutes=1, id="study_session_queue_consumer")
# PR-B 레거시 tier 백필 — 30분 주기로 호출되지만 KST 00:00~06:00 시간대만 실제 enqueue.
# safety > law > manual 우선순위로 25건씩. 6720 레거시 → 야간당 ~150건 → 약 45일 소화.
scheduler.add_job(tier_backfill_run, "interval", minutes=30, id="tier_backfill")
# 일일 스케줄 (KST)
scheduler.add_job(law_monitor_run, CronTrigger(hour=7), id="law_monitor")
scheduler.add_job(mailplus_run, CronTrigger(hour=7), id="mailplus_morning")
scheduler.add_job(mailplus_run, CronTrigger(hour=18), id="mailplus_evening")
scheduler.add_job(daily_digest_run, CronTrigger(hour=20), id="daily_digest")
scheduler.add_job(law_monitor_run, CronTrigger(hour=7, timezone=KST), id="law_monitor")
scheduler.add_job(mailplus_run, CronTrigger(hour=7, timezone=KST), id="mailplus_morning")
scheduler.add_job(mailplus_run, CronTrigger(hour=18, timezone=KST), id="mailplus_evening")
scheduler.add_job(daily_digest_run, CronTrigger(hour=20, timezone=KST), id="daily_digest")
scheduler.add_job(global_digest_run, CronTrigger(hour=4, minute=0, timezone=KST), id="global_digest")
scheduler.add_job(morning_briefing_run, CronTrigger(hour=5, minute=10, timezone=KST), id="morning_briefing")
scheduler.add_job(news_collector_run, "interval", hours=6, id="news_collector")
scheduler.start()
# Phase 2.1 (async 구조): QueryAnalyzer prewarm.
# 대표 쿼리 15~20개를 background task로 분석해 cache 적재.
# 첫 사용자 요청부터 cache hit rate 70~80% 목표.
# 논블로킹 — startup을 막지 않음. MLX 부하 완화 위해 delay_between=0.5.
prewarm_task = asyncio.create_task(prewarm_analyzer())
prewarm_task.add_done_callback(
lambda t: t.exception() and None # 예외는 query_analyzer 내부에서 로깅
)
yield
# 종료: 스케줄러 → DB 순서로 정리
@@ -70,12 +128,30 @@ app = FastAPI(
# ─── 라우터 등록 ───
app.include_router(setup_router, prefix="/api/setup", tags=["setup"])
app.include_router(config_router, prefix="/api/config", tags=["config"])
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
# 회독 카운트 — /api/documents/{id}/read* 경로. documents_router 와 prefix 같아 충돌 없음.
app.include_router(document_reads_router, prefix="/api/documents", tags=["document-reads"])
app.include_router(document_notes_router, prefix="/api/documents", tags=["document-notes"])
app.include_router(search_router, prefix="/api/search", tags=["search"])
app.include_router(memos_router, prefix="/api/memos", tags=["memos"])
app.include_router(events_router, prefix="/api/events", tags=["events"])
app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"])
app.include_router(library_router, prefix="/api/library", tags=["library"])
app.include_router(news_router, prefix="/api/news", tags=["news"])
app.include_router(digest_router, prefix="/api/digest", tags=["digest"])
app.include_router(briefing_router, prefix="/api/briefing", tags=["briefing"])
app.include_router(audio_router, prefix="/api/audio", tags=["audio"])
app.include_router(internal_study_router, prefix="/internal/study", tags=["internal-study"])
app.include_router(video_router, prefix="/api/video", tags=["video"])
app.include_router(study_sessions_router, prefix="/api/study-sessions", tags=["study-sessions"])
app.include_router(study_topics_router, prefix="/api/study-topics", tags=["study-topics"])
# study_questions: 라우터 안에서 /study-topics/{id}/questions 와 /study-questions/{id} 두 줄기를 모두 정의하므로 prefix=/api 로 등록
app.include_router(study_questions_router, prefix="/api", tags=["study-questions"])
# Phase 1: 학습 진행 상태 (review-complete + review-queue). prefix=/api/study-topics 안에 정의됨.
app.include_router(study_question_progress_router, prefix="/api", tags=["study-progress"])
# TODO: Phase 5에서 추가
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
@@ -84,7 +160,7 @@ app.include_router(news_router, prefix="/api/news", tags=["news"])
# ─── 셋업 미들웨어: 유저 0명이면 /setup으로 리다이렉트 ───
SETUP_BYPASS_PREFIXES = (
"/api/setup", "/setup", "/health", "/docs", "/openapi.json", "/redoc",
"/api/setup", "/api/config", "/setup", "/health", "/docs", "/openapi.json", "/redoc",
)
+63
View File
@@ -0,0 +1,63 @@
"""analyze_events 테이블 ORM — POST /documents/{id}/analyze 호출 관측 (Phase E.2)
목적: 분석 failure mode 분류 (timeout / parse / llm / missing_summary) +
source 사용 패턴 (document_server / synology_chat / ui_search / ui_detail / eval).
단계 3 snapshot DB 설계 입력이 .
"""
from datetime import datetime
from typing import Any
from sqlalchemy import ARRAY, BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class AnalyzeEvent(Base):
__tablename__ = "analyze_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
doc_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="SET NULL")
)
mode: Mapped[str] = mapped_column(Text, default="quick", nullable=False) # quick / full / summary_triage / summary_deep / retrieval_select / synthesis
text_limit: Mapped[int | None] = mapped_column(Integer)
truncated: Mapped[bool] = mapped_column(Boolean, default=False)
layers_returned: Mapped[list[Any] | None] = mapped_column(JSONB, default=list)
cached: Mapped[bool] = mapped_column(Boolean, default=False)
latency_ms: Mapped[int | None] = mapped_column(Integer)
model_name: Mapped[str | None] = mapped_column(Text)
prompt_version: Mapped[str | None] = mapped_column(Text)
# None (success) | "timeout" | "llm" | "parse" | "missing_summary" | "no_text"
error_code: Mapped[str | None] = mapped_column(Text)
# document_server / synology_chat / ui_search / ui_detail / eval / unknown
source: Mapped[str] = mapped_column(Text, default="document_server", nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
# PR-A (migration 153) — routing shadow observability
subject_domain: Mapped[str | None] = mapped_column(Text)
risk_flags: Mapped[list[str] | None] = mapped_column(ARRAY(Text))
high_impact_task: Mapped[bool | None] = mapped_column(Boolean)
escalated_to_26b: Mapped[bool | None] = mapped_column(Boolean)
escalation_reasons: Mapped[list[str] | None] = mapped_column(ARRAY(Text))
confidence: Mapped[float | None] = mapped_column(Float)
policy_violation: Mapped[bool | None] = mapped_column(Boolean)
policy_violation_ids: Mapped[list[str] | None] = mapped_column(ARRAY(Text))
shadow_would_route_to: Mapped[str | None] = mapped_column(Text)
policy_version: Mapped[str | None] = mapped_column(Text)
# PR-B (migration 159) — 실제 호출 tier 와 R2 backlog guard 이벤트
tier: Mapped[str | None] = mapped_column(Text) # 'triage' | 'primary' | 'fallback'
suppressed_reason: Mapped[str | None] = mapped_column(Text) # 'backlog_guard(ratio=0.42,pending=7)'
# PR-B B-2 (migration 161) — /ask 3-state answerability 독립 컬럼
answerability: Mapped[str | None] = mapped_column(Text) # 'direct' | 'partial' | 'insufficient'
partial_basis: Mapped[bool | None] = mapped_column(Boolean) # partial 답변이 실제 생성됐는지
suggested_query_count: Mapped[int | None] = mapped_column(Integer)
+48
View File
@@ -0,0 +1,48 @@
"""ask_events 테이블 ORM — /ask 호출 관측 (Phase 3.5a migration 102, Phase 3.5b 배선)
threshold calibration + verifier FP 분석 + defense layer 디버깅 데이터.
"""
from datetime import datetime
from typing import Any
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class AskEvent(Base):
__tablename__ = "ask_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
query: Mapped[str] = mapped_column(Text, nullable=False)
user_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="SET NULL")
)
completeness: Mapped[str | None] = mapped_column(Text) # full / partial / insufficient
synthesis_status: Mapped[str | None] = mapped_column(Text)
confidence: Mapped[str | None] = mapped_column(Text) # high / medium / low
refused: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
classifier_verdict: Mapped[str | None] = mapped_column(Text) # sufficient / insufficient
max_rerank_score: Mapped[float | None] = mapped_column(Float)
aggregate_score: Mapped[float | None] = mapped_column(Float)
hallucination_flags: Mapped[list[Any] | None] = mapped_column(JSONB, default=list)
evidence_count: Mapped[int | None] = mapped_column(Integer)
citation_count: Mapped[int | None] = mapped_column(Integer)
defense_layers: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
total_ms: Mapped[int | None] = mapped_column(Integer)
# Phase E.1: 측정 필드 확장 (answer_length가 E.3 400→600자 비교 핵심)
answer_length: Mapped[int | None] = mapped_column(Integer)
covered_aspects: Mapped[list[Any] | None] = mapped_column(JSONB)
missing_aspects: Mapped[list[Any] | None] = mapped_column(JSONB)
model_name: Mapped[str | None] = mapped_column(Text)
prompt_version: Mapped[str | None] = mapped_column(Text)
# Phase 3.5 calibration: eval/production 분리 + golden join 키
# 138~141 단계: nullable. 142 적용 후 source 는 NOT NULL (DB 강제, 앱은 항상 채움).
source: Mapped[str | None] = mapped_column(Text)
eval_case_id: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
+18
View File
@@ -0,0 +1,18 @@
"""audio_segments 테이블 ORM — STT 전사 결과의 타임스탬프 세그먼트."""
from sqlalchemy import BigInteger, Float, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class AudioSegment(Base):
__tablename__ = "audio_segments"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
document_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
)
start_s: Mapped[float] = mapped_column(Float, nullable=False)
end_s: Mapped[float] = mapped_column(Float, nullable=False)
text: Mapped[str] = mapped_column(Text, nullable=False)
+103
View File
@@ -0,0 +1,103 @@
"""morning_briefings + briefing_topics 테이블 ORM (야간 수집 뉴스 브리핑).
axis 반대: Phase 4 = country×topic / Briefing = topic×country.
country_perspectives JSONB 안에 topic 여러 국가 관점 array.
"""
from datetime import date, datetime
from sqlalchemy import (
BigInteger,
Boolean,
Date,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
class MorningBriefing(Base):
"""하루 단위 브리핑 메타데이터 (KST 자정~05:00 윈도우)"""
__tablename__ = "morning_briefings"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
briefing_date: Mapped[date] = mapped_column(Date, nullable=False, unique=True)
window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
window_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
decay_lambda: Mapped[float] = mapped_column(Float, nullable=False)
total_articles: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_countries: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_topics: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
generation_ms: Mapped[int | None] = mapped_column(Integer)
llm_calls: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
llm_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="success")
headline_oneliner: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=datetime.now
)
topics: Mapped[list["BriefingTopic"]] = relationship(
back_populates="briefing",
cascade="all, delete-orphan",
order_by="BriefingTopic.topic_rank",
)
class BriefingTopic(Base):
"""1 briefing 안 topic_rank 순 cross-country 비교 분석 결과"""
__tablename__ = "briefing_topics"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
briefing_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("morning_briefings.id", ondelete="CASCADE"),
nullable=False,
)
topic_rank: Mapped[int] = mapped_column(Integer, nullable=False)
topic_label: Mapped[str] = mapped_column(String(120), nullable=False)
headline: Mapped[str] = mapped_column(Text, nullable=False)
country_perspectives: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
divergences: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
convergences: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
key_quotes: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
historical_article_ids: Mapped[list | None] = mapped_column(JSONB)
historical_context: Mapped[str | None] = mapped_column(Text)
historical_window_days: Mapped[int | None] = mapped_column(Integer)
cluster_members: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
article_count: Mapped[int] = mapped_column(Integer, nullable=False)
country_count: Mapped[int] = mapped_column(Integer, nullable=False)
importance_score: Mapped[float] = mapped_column(Float, nullable=False)
raw_weight_sum: Mapped[float] = mapped_column(Float, nullable=False)
llm_model: Mapped[str | None] = mapped_column(String(100))
llm_fallback_used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# 2026-05-13 카드별 사용자 액션 (date picker 와 동반).
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
highlighted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
highlighted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=datetime.now
)
briefing: Mapped["MorningBriefing"] = relationship(back_populates="topics")
+25
View File
@@ -0,0 +1,25 @@
"""library_categories 테이블 ORM — 자료실 분류 체계 독립 관리"""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class LibraryCategory(Base):
__tablename__ = "library_categories"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
name: Mapped[str] = mapped_column(Text, nullable=False)
parent_path: Mapped[str | None] = mapped_column(Text, nullable=True)
depth: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now
)
+87
View File
@@ -0,0 +1,87 @@
"""global_digests + digest_topics 테이블 ORM (Phase 4)"""
from datetime import date, datetime
from sqlalchemy import (
BigInteger,
Boolean,
Date,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
class GlobalDigest(Base):
"""하루 단위 digest run 메타데이터"""
__tablename__ = "global_digests"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
digest_date: Mapped[date] = mapped_column(Date, nullable=False, unique=True)
window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
window_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
decay_lambda: Mapped[float] = mapped_column(Float, nullable=False)
total_articles: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_countries: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_topics: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
generation_ms: Mapped[int | None] = mapped_column(Integer)
llm_calls: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
llm_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="success")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=datetime.now
)
topics: Mapped[list["DigestTopic"]] = relationship(
back_populates="digest",
cascade="all, delete-orphan",
order_by="DigestTopic.country, DigestTopic.topic_rank",
)
class DigestTopic(Base):
"""country × topic 단위 cluster 결과"""
__tablename__ = "digest_topics"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
digest_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("global_digests.id", ondelete="CASCADE"),
nullable=False,
)
country: Mapped[str] = mapped_column(String(10), nullable=False)
topic_rank: Mapped[int] = mapped_column(Integer, nullable=False)
topic_label: Mapped[str] = mapped_column(Text, nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
article_ids: Mapped[list] = mapped_column(JSONB, nullable=False)
article_count: Mapped[int] = mapped_column(Integer, nullable=False)
importance_score: Mapped[float] = mapped_column(Float, nullable=False)
raw_weight_sum: Mapped[float] = mapped_column(Float, nullable=False)
centroid_sample: Mapped[dict | None] = mapped_column(JSONB)
llm_model: Mapped[str | None] = mapped_column(String(100))
llm_fallback_used: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=datetime.now
)
digest: Mapped["GlobalDigest"] = relationship(back_populates="topics")
+90 -3
View File
@@ -3,10 +3,12 @@
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import BigInteger, Boolean, DateTime, Enum, String, Text
from sqlalchemy import BigInteger, Boolean, DateTime, Enum, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
# Note: file_type='note' (메모) 문서는 file_path=NULL, file_hash=content SHA-256
from core.database import Base
@@ -16,7 +18,7 @@ class Document(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
# 1계층: 원본 파일
file_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
file_hash: Mapped[str] = mapped_column(String(64), nullable=False)
file_format: Mapped[str] = mapped_column(String(20), nullable=False)
file_size: Mapped[int | None] = mapped_column(BigInteger)
@@ -31,6 +33,9 @@ class Document(Base):
extracted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
extractor_version: Mapped[str | None] = mapped_column(String(50))
# 2계층: 추출 메타 (OCR 판정/실행)
extract_meta: Mapped[dict | None] = mapped_column(JSONB, default=dict)
# 2계층: AI 가공
ai_summary: Mapped[str | None] = mapped_column(Text)
ai_tags: Mapped[dict | None] = mapped_column(JSONB, default=[])
@@ -42,6 +47,15 @@ class Document(Base):
importance: Mapped[str | None] = mapped_column(String(20), default="medium")
ai_confidence: Mapped[float | None] = mapped_column()
# Memo Intake Upgrade PR-2B — Gemma 4B triage 가 추론한 메모 의도 분류 hint
# ('note' | 'task' | 'calendar_event' | 'activity_log' | 'reference')
# AI 자동 events 생성 X — 사용자 1-click promote 시점에만 events row 생성 (안전 boundary).
ai_event_kind: Mapped[str | None] = mapped_column(
Enum("note", "task", "calendar_event", "activity_log", "reference",
name="event_kind_hint")
)
ai_event_confidence: Mapped[float | None] = mapped_column()
# 3계층: 벡터 임베딩
embedding = mapped_column(Vector(1024), nullable=True)
embed_model_version: Mapped[str | None] = mapped_column(String(50))
@@ -50,6 +64,22 @@ class Document(Base):
# 사용자 메모
user_note: Mapped[str | None] = mapped_column(Text)
# 사용자 태그 (ai_tags와 분리, #태그 파싱 결과 또는 수동 입력)
user_tags: Mapped[list | None] = mapped_column(JSONB, default=[])
# 핀 고정
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
# /ask 합성 포함 여부 (false면 검색은 되지만 evidence에서 제외)
ask_includable: Mapped[bool] = mapped_column(Boolean, default=True)
# 아카이브 (현재 메모 UX 전용, 문서 쪽에는 노출하지 않음)
archived: Mapped[bool] = mapped_column(Boolean, default=False)
# 메모 체크박스별 메타 — {"<task_index>": {"checked_at": "<ISO8601 UTC>"}}
# UI에서 체크 후 10초 경과 항목 숨김 판정에 사용. file_type='note'에서만 의미 있음.
memo_task_state: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# ODF 변환
derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/)
original_format: Mapped[str | None] = mapped_column(String(20))
@@ -73,14 +103,71 @@ class Document(Base):
# 메타데이터
source_channel: Mapped[str | None] = mapped_column(
Enum("law_monitor", "devonagent", "email", "web_clip",
"tksafety", "inbox_route", "manual", "drive_sync", "news",
"tksafety", "inbox_route", "manual", "drive_sync", "news", "memo",
"voice", "hermes",
name="source_channel")
)
# 외부 채널 (Hermes Discord 등) 의 channel/user/message_id/timestamp 메타.
# extract_meta (OCR 전용) 와 분리.
source_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
data_origin: Mapped[str | None] = mapped_column(
Enum("work", "external", name="data_origin")
)
# 용도 구분 (우선순위: 수동 수정 > 업로드 명시값 > AI 추론)
doc_purpose: Mapped[str | None] = mapped_column(
Enum("business", "knowledge", name="document_purpose")
)
title: Mapped[str | None] = mapped_column(Text)
# 카테고리 (1차 진입점 — UI 탭/라우트 분기)
# 7 활성: document / library / news / memo / audio / video / law
# 3 유보: mail / calendar / plex
category: Mapped[str | None] = mapped_column(
Enum("document", "library", "news", "memo", "audio", "video", "law",
"mail", "calendar", "plex",
name="doc_category", create_type=False)
)
# AI 가 제안했지만 미승인된 변경 후보 (category / path / doctype)
# /accept-suggestion 승인 시에만 category / user_tags 반영 (자동 전이 금지)
ai_suggestion: Mapped[dict | None] = mapped_column(JSONB)
# PR-B B-1: summary_triage (4B, 상시) / summary_deep (26B, 에스컬레이션) 분할 산출
ai_tldr: Mapped[str | None] = mapped_column(Text) # ≤60자 TL;DR
ai_bullets: Mapped[list | None] = mapped_column(JSONB) # 3~5개 핵심 bullets
ai_detail_summary: Mapped[str | None] = mapped_column(Text) # 26B 2~3문단
ai_inconsistencies: Mapped[list | None] = mapped_column(JSONB) # [{kind, desc}]
# 'triage' | 'deep' | NULL — 현재 문서가 어느 tier 까지 분석 완료됐는지
ai_analysis_tier: Mapped[str | None] = mapped_column(String(10))
# 비디오 썸네일 (§3) — ffmpeg 50% 지점 1장. PKM/Videos/.thumbs/{id}.jpg 절대경로.
thumbnail_path: Mapped[str | None] = mapped_column(Text)
# NAS 드롭된 mov/mkv/avi quarantine 플래그 (§3). true 면 재생 불가 안내만 표시.
needs_conversion: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# facet 탐색 축 (Phase 2)
facet_company: Mapped[str | None] = mapped_column(Text)
facet_topic: Mapped[str | None] = mapped_column(Text)
facet_year: Mapped[int | None] = mapped_column(Integer)
facet_doctype: Mapped[str | None] = mapped_column(Text)
# === Phase 1A canonical Markdown layer columns (migrations 211~219) ===
# plan: ~/.claude/plans/plan-idempotent-sundae.md
md_content: Mapped[str | None] = mapped_column(Text)
md_frontmatter: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
md_format_version: Mapped[str] = mapped_column(Text, nullable=False, default='1.0')
md_status: Mapped[str] = mapped_column(Text, nullable=False, default='pending')
md_extraction_engine: Mapped[str | None] = mapped_column(Text)
md_extraction_engine_version: Mapped[str | None] = mapped_column(Text)
md_extraction_quality: Mapped[dict | None] = mapped_column(JSONB)
md_extraction_error: Mapped[str | None] = mapped_column(Text)
md_content_hash: Mapped[str | None] = mapped_column(Text)
md_source_hash: Mapped[str | None] = mapped_column(Text)
md_generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
content_origin: Mapped[str] = mapped_column(Text, nullable=False, default='extracted')
md_draft_status: Mapped[str | None] = mapped_column(Text)
# 타임스탬프
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now
+42
View File
@@ -0,0 +1,42 @@
"""document_images ORM (Phase 1B.5) — marker 추출 이미지 메타.
저장: NAS `/documents/extracted_images/{document_id}/{image_key}.{ext}`
표시: GET /api/documents/{doc_id}/images/{image_key}/raw (인증 필요)
md_content ref `![alt](docimg:img_001)` 형식 image_key sequence 기반 결정적이라
재변환 idempotent.
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class DocumentImage(Base):
__tablename__ = "document_images"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
document_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
)
image_key: Mapped[str] = mapped_column(String(32), nullable=False)
relative_path: Mapped[str] = mapped_column(Text, nullable=False)
file_path: Mapped[str] = mapped_column(Text, nullable=False)
mime_type: Mapped[str] = mapped_column(Text, nullable=False)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
width: Mapped[int | None] = mapped_column(Integer)
height: Mapped[int | None] = mapped_column(Integer)
page_index: Mapped[int | None] = mapped_column(Integer)
alt_text: Mapped[str | None] = mapped_column(Text)
source_slug: Mapped[str | None] = mapped_column(Text)
extraction_engine: Mapped[str] = mapped_column(
String(32), nullable=False, default="marker"
)
extraction_engine_version: Mapped[str | None] = mapped_column(String(32))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
+44
View File
@@ -0,0 +1,44 @@
"""document_notes 테이블 ORM — 자료별 손글씨 노트 (자료 1:1).
설계:
- user×document UNIQUE 자료당 사용자별 캔버스.
- upsert 방식. PUT /api/documents/{id}/note strokes_json 전체 갱신.
- 회독 (document_reads, append-only log) 별개.
NOTE: documents user_id 부재 (single-user). document_notes.user_id
ownership. multi-user 전환 documents.user_id 추가 별도 check 필요.
"""
from datetime import datetime
from typing import Any
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class DocumentNote(Base):
__tablename__ = "document_notes"
__table_args__ = (
UniqueConstraint("user_id", "document_id", name="document_notes_user_id_document_id_key"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
document_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
)
strokes_json: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
canvas_width: Mapped[int | None] = mapped_column(Integer)
canvas_height: Mapped[int | None] = mapped_column(Integer)
schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
+33
View File
@@ -0,0 +1,33 @@
"""document_reads 테이블 ORM — 자료실 회독 추적.
NOTE: documents 테이블에 user_id 컬럼이 없음 (single-user 가정).
회독 ownership document_reads.user_id 만으로 추적.
multi-user 전환 documents.user_id 추가 별도 ownership check 필요.
설계:
- append-only log. 회독 횟수 = COUNT(*), 마지막 시각 = MAX(read_at).
- 사용자 명시 행동 (버튼 클릭) 으로만 row insert. 자동 +1 금지.
- 같은 user/document 여러 row 허용 (회독 카운트 누적).
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class DocumentRead(Base):
__tablename__ = "document_reads"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
document_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
)
read_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
+113
View File
@@ -0,0 +1,113 @@
"""events 1차 컨테이너 ORM (개인 운영 로그 / 일정 / 할 일 / 회고)
PR-1 (migrations 239~247) 본체. kind enum 으로 task/calendar_event/activity_log
변형을 통합 관리. memo_document_id 메모 link (optional).
"""
from datetime import datetime
from typing import Any
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
ForeignKey,
SmallInteger,
String,
Text,
)
from sqlalchemy.dialects.postgresql import ENUM as PgEnum
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
# Postgres enum 재선언 X (create_type=False) — migration 239~243 이 권위.
EventKindEnum = PgEnum(
"task",
"calendar_event",
"activity_log",
name="event_kind",
create_type=False,
)
EventStatusEnum = PgEnum(
"inbox",
"next",
"scheduled",
"in_progress",
"done",
"cancelled",
"deferred",
name="event_status",
create_type=False,
)
EventSourceEnum = PgEnum(
"manual",
"memo",
"email",
"chat",
"webhook",
"git_commit",
"claude_code",
name="event_source",
create_type=False,
)
EventActorEnum = PgEnum(
"manual",
"eid",
"email_ingest",
"system",
name="event_actor",
create_type=False,
)
class Event(Base):
__tablename__ = "events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
title: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
kind: Mapped[str] = mapped_column(EventKindEnum, nullable=False)
status: Mapped[str] = mapped_column(EventStatusEnum, nullable=False, default="inbox")
# 시간 필드 — kind 별 의미가 다름 (CHECK 제약은 migration 244)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
all_day: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
timezone: Mapped[str | None] = mapped_column(Text)
# lifecycle
defer_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
priority: Mapped[int | None] = mapped_column(SmallInteger)
project_tag: Mapped[str | None] = mapped_column(String(64))
tags: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
# 출처 / 외부 식별자
source: Mapped[str] = mapped_column(EventSourceEnum, nullable=False, default="manual")
source_ref: Mapped[str | None] = mapped_column(Text)
raw_metadata: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
# 메모 link (optional, ON DELETE SET NULL)
memo_document_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="SET NULL")
)
# 인증 / actor
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id"), nullable=False
)
created_by: Mapped[str] = mapped_column(EventActorEnum, nullable=False, default="manual")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
+43
View File
@@ -0,0 +1,43 @@
"""events_history ORM — events 의 lifecycle 변경 이력 (append-only).
PR-1 (migrations 248~249). FK ON DELETE RESTRICT 부모 events row 직접 삭제 차단
(feedback_history_table_fk_restrict.md 이력은 시점 사실).
"""
from datetime import datetime
from typing import Any
from sqlalchemy import BigInteger, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import ENUM as PgEnum
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
from models.event import EventActorEnum
HistoryChangeKindEnum = PgEnum(
"create",
"reschedule",
"defer",
"reactivate",
"complete",
"cancel",
name="history_change_kind",
create_type=False,
)
class EventHistory(Base):
__tablename__ = "events_history"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
event_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("events.id", ondelete="RESTRICT"), nullable=False
)
changed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
changed_by: Mapped[str] = mapped_column(EventActorEnum, nullable=False)
change_kind: Mapped[str] = mapped_column(HistoryChangeKindEnum, nullable=False)
before: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
after: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
+20
View File
@@ -0,0 +1,20 @@
"""facet_values 테이블 ORM — facet 축별 허용값 사전"""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class FacetValue(Base):
__tablename__ = "facet_values"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
facet_type: Mapped[str] = mapped_column(Text, nullable=False) # company, topic, doctype
value: Mapped[str] = mapped_column(Text, nullable=False)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now
)
+47 -4
View File
@@ -2,7 +2,9 @@
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, SmallInteger, Text, UniqueConstraint
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, SmallInteger, Text, text
from sqlalchemy.dialects.postgresql import JSONB, insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
@@ -14,7 +16,16 @@ class ProcessingQueue(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
document_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("documents.id"), nullable=False)
stage: Mapped[str] = mapped_column(
Enum("extract", "classify", "summarize", "embed", "chunk", "preview", name="process_stage"), nullable=False
# 'stt' (audio): migration 150 / 'thumbnail' (video): queue_consumer 가 enqueue.
# 'deep_summary' (PR-B B-1): classify_worker 가 에스컬레이션 시 enqueue.
# DB enum 변경은 마이그레이션이 처리하므로 create_type=False.
Enum(
"extract", "classify", "summarize", "embed", "chunk", "preview",
"stt", "thumbnail", "deep_summary", "markdown",
name="process_stage",
create_type=False,
),
nullable=False,
)
status: Mapped[str] = mapped_column(
Enum("pending", "processing", "completed", "failed", name="process_status"),
@@ -23,12 +34,44 @@ class ProcessingQueue(Base):
attempts: Mapped[int] = mapped_column(SmallInteger, default=0)
max_attempts: Mapped[int] = mapped_column(SmallInteger, default=3)
error_message: Mapped[str | None] = mapped_column(Text)
# B-1: deep_summary stage 가 EscalationEnvelope 를 payload 로 싣는다. 다른 stage 는 NULL.
payload: Mapped[dict | None] = mapped_column(JSONB)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
UniqueConstraint("document_id", "stage", "status"),
# DB 제약은 partial unique index uq_queue_active로 관리 (migration 117)
async def enqueue_stage(
session: AsyncSession,
document_id: int,
stage: str,
*,
status: str = "pending",
payload: dict | None = None,
) -> bool:
"""ProcessingQueue에 행 추가 (DB 레벨 중복 방어).
같은 (document_id, stage) 활성 (pending/processing) 이미 있으면
아무것도 하지 않고 False 반환.
B-1: payload 옵션으로 deep_summary EscalationEnvelope JSON 실을 있다.
같은 문서 deep_summary 재제안될 경우 on_conflict_do_nothing 으로 기존 payload
유지 (최초 envelope 원본). 이후 재처리 재분석은 classify 트리거.
"""
values: dict = {"document_id": document_id, "stage": stage, "status": status}
if payload is not None:
values["payload"] = payload
stmt = (
pg_insert(ProcessingQueue)
.values(**values)
.on_conflict_do_nothing(
index_elements=["document_id", "stage"],
index_where=text("status IN ('pending', 'processing')"),
)
)
result = await session.execute(stmt)
return result.rowcount > 0
+134
View File
@@ -0,0 +1,134 @@
"""study_questions / study_question_attempts ORM — 학습 워크스페이스의 문제은행 트랙
PR-2 가드레일:
- study_topic 1 컨테이너에 자산 타입별 조인 테이블 추가 방식. polymorphic 단일 테이블 영구 금지.
- subject/scope 강한 enum 미사용 (jlpt 어학 분류 확장 여지).
- 문제 삭제는 API 에서 soft delete only. attempts FK ON DELETE RESTRICT DB 레벨 보호 (hard delete 실수 차단, 이력 보존).
- correct_choice 변경 기존 attempt.is_correct 재계산 (기록은 시점의 사실).
"""
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, SmallInteger, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
class StudyQuestion(Base):
__tablename__ = "study_questions"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
study_topic_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False
)
question_text: Mapped[str] = mapped_column(Text, nullable=False)
choice_1: Mapped[str] = mapped_column(Text, nullable=False)
choice_2: Mapped[str] = mapped_column(Text, nullable=False)
choice_3: Mapped[str] = mapped_column(Text, nullable=False)
choice_4: Mapped[str] = mapped_column(Text, nullable=False)
correct_choice: Mapped[int] = mapped_column(SmallInteger, nullable=False)
subject: Mapped[str | None] = mapped_column(String(120))
scope: Mapped[str | None] = mapped_column(String(200))
exam_name: Mapped[str | None] = mapped_column(String(120))
exam_round: Mapped[str | None] = mapped_column(String(120))
explanation: Mapped[str | None] = mapped_column(Text)
source_note: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# PR-6: 회차 안 문항 번호 (1~exam_round_size). NULL 허용 — 기존 행 + 회차 미설정 입력
exam_question_number: Mapped[int | None] = mapped_column(SmallInteger)
# PR-3: AI 풀이 캐시 (수동 트리거)
# status: none | pending | ready | failed | stale (강한 enum 미사용, VARCHAR 권장값)
ai_explanation: Mapped[str | None] = mapped_column(Text)
ai_explanation_status: Mapped[str] = mapped_column(
String(20), default="none", nullable=False
)
ai_explanation_generated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True)
)
ai_explanation_model: Mapped[str | None] = mapped_column(String(120))
# PR-4: 자동 임베딩 (bge-m3 1024차원). status 가 큐 역할.
# 재계산 트리거 = question_text / choice_1~4 변경.
# correct_choice / subject / scope / explanation 변경은 재계산 안 함.
embedding = mapped_column(Vector(1024), nullable=True)
embedding_status: Mapped[str] = mapped_column(
String(20), default="none", nullable=False
)
embedding_updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True)
)
embedding_model: Mapped[str | None] = mapped_column(String(120))
# PR-12-A 후속: related-types 영속 캐시. 임베딩 ready 워커가 채우고,
# 같은 토픽 다른 문제 ready 시 related_computed_at=NULL 마킹 → 다음 cron 재계산.
related_repeat: Mapped[list | None] = mapped_column(JSONB)
related_similar: Mapped[list | None] = mapped_column(JSONB)
related_repeat_round_count: Mapped[int | None] = mapped_column(Integer)
related_similar_round_count: Mapped[int | None] = mapped_column(Integer)
related_repeat_grade: Mapped[str | None] = mapped_column(String(50))
related_computed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
related_threshold_version: Mapped[str | None] = mapped_column(String(20))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 연관 — 통합 뷰/통계 조회 시 selectinload 으로 끌어옴
topic: Mapped["StudyTopic | None"] = relationship( # type: ignore[name-defined] # noqa: F821
"StudyTopic", back_populates="questions", lazy="noload"
)
attempts: Mapped[list["StudyQuestionAttempt"]] = relationship(
back_populates="question",
cascade="all, delete-orphan", # ORM 레벨 cascade — 실 hard delete 는 RESTRICT FK 가 막음
order_by="StudyQuestionAttempt.answered_at.desc()",
lazy="noload",
)
class StudyQuestionAttempt(Base):
__tablename__ = "study_question_attempts"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
study_question_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("study_questions.id", ondelete="RESTRICT"),
nullable=False,
)
study_topic_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False
)
# PR-9: selected_choice 는 NULL 허용 (unsure 케이스). is_correct 는 false 로 박힘.
selected_choice: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
correct_choice: Mapped[int] = mapped_column(SmallInteger, nullable=False)
is_correct: Mapped[bool] = mapped_column(Boolean, nullable=False)
# PR-9: outcome 권장값 (correct/wrong/unsure). 강한 enum 미사용.
outcome: Mapped[str] = mapped_column(String(20), nullable=False)
answered_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
# PR-10: 어떤 quiz 세션의 attempt 인지 (NULL = 세션 외 직접 입력 또는 세션 삭제됨).
quiz_session_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("study_quiz_sessions.id", ondelete="SET NULL"), nullable=True
)
# PR-10: 결과 카드에서 "학습완료" 체크 시 박힘. NULL = 미확인.
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
question: Mapped["StudyQuestion"] = relationship(back_populates="attempts")
+31
View File
@@ -0,0 +1,31 @@
"""study_question_images ORM (PR-8) — 문제별 첨부 이미지.
저장: NAS /documents/study_question_images/{topic_id}/{qid}/{img_id}.{ext}
표시: GET /api/study-questions/{qid}/images/{img_id}/raw (인증 필요)
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class StudyQuestionImage(Base):
__tablename__ = "study_question_images"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
study_question_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_questions.id", ondelete="CASCADE"), nullable=False
)
file_path: Mapped[str] = mapped_column(Text, nullable=False)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
mime_type: Mapped[str] = mapped_column(String(80), nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
+87
View File
@@ -0,0 +1,87 @@
"""study_question_jobs ORM (Phase 4-A) — study 도메인 전용 비동기 작업 큐.
processing_queue documents.id FK study_questions 직접 재사용 불가.
별도 테이블 + 별도 consumer (study_queue_consumer.py).
kind 권장값:
- 'explanation' (Phase 4-A): wrong/unsure 문제의 AI 풀이 prefetch
- 'session_summary' (Phase 4-B 예약): 세션 단위 종합 분석. session_summary question
단위에 얹기 어색해 Phase 4-B 구현 study_quiz_session_jobs 별도 분리 검토.
terminal status (completed/failed/skipped) completed_at 항상 기록.
failed 재시도는 기존 row pending 으로 되살리지 않고 row 생성 이력 누적.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import BigInteger, DateTime, ForeignKey, SmallInteger, String, Text, text
from sqlalchemy.dialects.postgresql import JSONB, insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class StudyQuestionJob(Base):
__tablename__ = "study_question_jobs"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
study_question_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_questions.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
kind: Mapped[str] = mapped_column(String(40), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
attempts: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=2)
error_code: Mapped[str | None] = mapped_column(String(40))
error_message: Mapped[str | None] = mapped_column(Text)
payload: Mapped[dict | None] = mapped_column(JSONB)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# active partial unique idx 는 migration 232 가 관리.
async def enqueue_study_question_job(
session: AsyncSession,
*,
study_question_id: int,
user_id: int,
kind: str,
payload: dict[str, Any] | None = None,
) -> bool:
"""study_question_jobs 에 행 추가 (DB 레벨 중복 방어).
같은 (study_question_id, kind) 활성 (pending/processing) 이미 있으면
아무것도 하지 않고 False 반환. terminal 이력은 별도 row 누적되므로 이번 호출이
failed/skipped/completed row 무관하게 active 행을 만들 있다.
Returns: True = enqueue 발생, False = 중복으로 건너뜀.
"""
values: dict[str, Any] = {
"study_question_id": study_question_id,
"user_id": user_id,
"kind": kind,
"status": "pending",
}
if payload is not None:
values["payload"] = payload
stmt = (
pg_insert(StudyQuestionJob)
.values(**values)
.on_conflict_do_nothing(
index_elements=["study_question_id", "kind"],
index_where=text("status IN ('pending', 'processing')"),
)
)
result = await session.execute(stmt)
return result.rowcount > 0
+73
View File
@@ -0,0 +1,73 @@
"""study_question_progress — 사용자 × 토픽 × 문제 단위 현재 상태 캐시 (Phase 1).
attempts (append-only 원본 로그) 분리. 박힌 attempts 절대 update .
progress 마지막 시도 / 사용자 검토 / 복습 / 패턴 분류 derived 4 차원 메타.
세션 종료 finalize 다음 갱신:
- last_outcome / last_attempted_at / last_attempt_id
- pattern_state / pattern_updated_at / pattern_window_attempts
- (이미 due_at 박힌 행만) review_stage / due_at 복습 stage 갱신
review-complete 다음 갱신:
- last_reviewed_at
- (wrong/unsure 경우) due_at 최초 부여
study_question_id 단일 topic 소속 전제 (현재 가스기사 토픽 4 단일 운영). 향후 question
재사용/N:M 가능성 대비 unique 키는 (user_id, study_topic_id, study_question_id) 3 .
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, SmallInteger, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class StudyQuestionProgress(Base):
__tablename__ = "study_question_progress"
__table_args__ = (
UniqueConstraint(
"user_id", "study_topic_id", "study_question_id",
name="uq_progress_user_topic_question",
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
study_topic_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False
)
study_question_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_questions.id", ondelete="RESTRICT"), nullable=False
)
# 마지막 시도 요약
last_outcome: Mapped[str | None] = mapped_column(String(20))
last_attempted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_attempt_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("study_question_attempts.id", ondelete="SET NULL")
)
# 사용자 검토 상태
last_reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 복습 큐
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
review_stage: Mapped[int | None] = mapped_column(SmallInteger)
# 패턴 분류 (derived)
pattern_state: Mapped[str | None] = mapped_column(String(30))
pattern_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
pattern_window_attempts: Mapped[int | None] = mapped_column(SmallInteger)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
+58
View File
@@ -0,0 +1,58 @@
"""study_quiz_sessions ORM (PR-10) — 문제풀이 세션 기록 + 이어풀기.
토픽의 회차 풀이 = . question_ids 출제 순서 스냅샷.
status: in_progress / done / abandoned (강한 enum 미사용 VARCHAR 권장값).
토픽당 in_progress 1 강제는 partial unique idx (마이그레이션 207).
"""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class StudyQuizSession(Base):
__tablename__ = "study_quiz_sessions"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
study_topic_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False
)
target_per_subject: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
subject_filter: Mapped[str | None] = mapped_column(String(120))
wrong_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# PR-12-B: 출제 모드. 권장값 = random (1차) / frequent_focus / wrong_variants (예약).
quiz_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="random")
# 출제 순서 스냅샷 — list[int] (question id). 출제 후 변경 안 됨.
question_ids: Mapped[list] = mapped_column(JSONB, nullable=False)
# {subject: count} 분포. 결과 카드 통계 표시용.
subject_distribution: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="in_progress")
cursor: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
correct_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
wrong_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
unsure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Phase 2-B: finalize 결과 요약 스냅샷. 세션 종료 시점에 박혀 결과 화면 헤더에 노출.
newly_correct_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
relapsed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
recovered_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
chronic_remaining_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
+35
View File
@@ -0,0 +1,35 @@
"""study_quiz_session_analysis ORM (Phase 4-B v1) — 세션 단위 분석 결과 캐시.
session_id PK 세션 = 분석 결과. worker ON CONFLICT DO UPDATE UPSERT.
job 이력은 study_quiz_session_jobs 별도 누적, 결과 캐시는 1 row.
is_stale=TRUE [재생성] 클릭 worker 처리 끝까지만.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class StudyQuizSessionAnalysis(Base):
__tablename__ = "study_quiz_session_analysis"
study_quiz_session_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("study_quiz_sessions.id", ondelete="CASCADE"),
primary_key=True,
)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
summary_md: Mapped[str] = mapped_column(Text, nullable=False)
confidence: Mapped[str | None] = mapped_column(String(10))
model_name: Mapped[str | None] = mapped_column(String(120))
generated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
is_stale: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+80
View File
@@ -0,0 +1,80 @@
"""study_quiz_session_jobs ORM (Phase 4-B v1) — 세션 단위 분석 작업 큐.
study_question_jobs 분리 FK 단일 의미 (study_quiz_session_id NOT NULL)
+ 운영 SQL 명확성 + 4-A/4-B 가드/재시도 정책 차이.
terminal status (completed/failed/skipped) completed_at 항상 기록.
재시도는 기존 row pending 으로 되살리지 않고 row 생성 이력 누적.
v1 단일 작업 종류 ('analysis') kind 컬럼 없이 session_id .
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import BigInteger, DateTime, ForeignKey, SmallInteger, String, Text, text
from sqlalchemy.dialects.postgresql import JSONB, insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class StudyQuizSessionJob(Base):
__tablename__ = "study_quiz_session_jobs"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
study_quiz_session_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("study_quiz_sessions.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
attempts: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=2)
error_code: Mapped[str | None] = mapped_column(String(40))
error_message: Mapped[str | None] = mapped_column(Text)
payload: Mapped[dict | None] = mapped_column(JSONB)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
async def enqueue_session_analysis_job(
session: AsyncSession,
*,
study_quiz_session_id: int,
user_id: int,
payload: dict[str, Any] | None = None,
) -> bool:
"""study_quiz_session_jobs 에 row 추가 (DB 레벨 중복 방어).
같은 session_id 활성 (pending/processing) 이미 있으면 False 반환.
terminal 이력은 별도 row 누적되므로 이번 호출이 failed/skipped/completed row
무관하게 active 행을 만들 있다.
Returns: True = enqueue 발생, False = 중복으로 건너뜀.
"""
values: dict[str, Any] = {
"study_quiz_session_id": study_quiz_session_id,
"user_id": user_id,
"status": "pending",
}
if payload is not None:
values["payload"] = payload
stmt = (
pg_insert(StudyQuizSessionJob)
.values(**values)
.on_conflict_do_nothing(
index_elements=["study_quiz_session_id"],
index_where=text("status IN ('pending', 'processing')"),
)
)
result = await session.execute(stmt)
return result.rowcount > 0
+144
View File
@@ -0,0 +1,144 @@
"""study_sessions / study_session_assets 테이블 ORM — Phase 1 MVP
목적: iPad 손글씨 학습 세션 (자격증 + 어학) + 모바일 암기노트/퀴즈를 위한 일반 학습 세션.
설계 원칙:
- study_type 으로 certification / language 분기. metadata jsonb 도메인별 자유 메타.
- 단일 audio_document_id / video_document_id / source_document_id / handwriting_document_id
컬럼 만들지 . 모든 미디어 연결은 study_session_assets 통일.
- documents 본체는 절대 삭제하지 않음. assets cascade sessions 또는 documents 삭제 .
- Phase 1 미사용 필드 (review_state / quiz / ocr / ai_summary / prompt) NULL 허용,
자동 로직은 Phase 2~4 에서 별도 PR 활성.
"""
from datetime import datetime
from typing import Any
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
class StudySession(Base):
__tablename__ = "study_sessions"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# 도메인 분기: 'certification' | 'language'
study_type: Mapped[str] = mapped_column(
String(30), default="certification", nullable=False
)
# 자격증/어학 메타
certification: Mapped[str | None] = mapped_column(String(120))
language_code: Mapped[str | None] = mapped_column(String(20))
learning_level: Mapped[str | None] = mapped_column(String(80))
# 공통 과목/주제
subject: Mapped[str | None] = mapped_column(String(120))
topic: Mapped[str | None] = mapped_column(String(200))
# 원문 텍스트 snapshot (assets 의 source_scan 과 별개로 발췌 텍스트만 보존)
source_text: Mapped[str | None] = mapped_column(Text)
source_page: Mapped[int | None] = mapped_column(Integer)
# 학습 모드: 'copy'/'trace'/'blank-repeat'/'dictation'/'shadowing'/'quiz'/'flashcard'
mode: Mapped[str] = mapped_column(String(30), default="copy", nullable=False)
prompt_question: Mapped[str | None] = mapped_column(Text)
expected_answer: Mapped[str | None] = mapped_column(Text)
# 도메인별 자유 메타 (어학 reading/meaning, 자격증 law_article 등)
metadata_json: Mapped[dict[str, Any] | None] = mapped_column(
"metadata", JSONB
)
# 횟수 카운트 (보조)
target_count: Mapped[int | None] = mapped_column(Integer)
repetition_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# 필기 데이터 (원본) — Phase 1 핵심
strokes_json: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
canvas_width: Mapped[int | None] = mapped_column(Integer)
canvas_height: Mapped[int | None] = mapped_column(Integer)
schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
# 필기 파생 텍스트 — Phase 2 채움 (Phase 1 NULL)
ocr_text: Mapped[str | None] = mapped_column(Text)
user_corrected_text: Mapped[str | None] = mapped_column(Text)
ai_summary: Mapped[str | None] = mapped_column(Text)
# SRS / 퀴즈 통계 — Phase 4 활성, Phase 1 NULL
review_state: Mapped[str | None] = mapped_column(String(20))
next_review_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_quiz_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
correct_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
incorrect_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# 학습 워크스페이스(study_topic) 1:N. NULL 허용 — 미분류 세션이 정상 상태.
study_topic_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="SET NULL")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
# 연관 assets — 세션 삭제 시 함께 삭제 (DB ON DELETE CASCADE 와 일치)
assets: Mapped[list["StudySessionAsset"]] = relationship(
back_populates="session",
cascade="all, delete-orphan",
order_by="StudySessionAsset.sort_order",
)
# 연관 학습 워크스페이스
study_topic: Mapped["StudyTopic | None"] = relationship(
"StudyTopic", back_populates="sessions", lazy="noload"
)
class StudySessionAsset(Base):
__tablename__ = "study_session_assets"
__table_args__ = (
# POST /assets 의 409 근거. NULL role 끼리는 Postgres 기본대로 다른 값으로 취급.
UniqueConstraint(
"study_session_id", "document_id", "asset_type", "role",
name="study_session_assets_session_id_document_id_asset_type_rol_key",
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
study_session_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_sessions.id", ondelete="CASCADE"), nullable=False
)
document_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
)
# 'source_scan' | 'handwriting_png' | 'audio' | 'video' | 'transcript' | 'reference'
asset_type: Mapped[str] = mapped_column(String(30), nullable=False)
# 'prompt' | 'answer' | 'pronunciation' | 'lecture' | 'listening_source'
# | 'shadowing_source' | 'reference'
role: Mapped[str | None] = mapped_column(String(40))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
session: Mapped["StudySession"] = relationship(back_populates="assets")
+88
View File
@@ -0,0 +1,88 @@
"""study_topics / study_topic_documents 테이블 ORM — 학습 워크스페이스 1차 컨테이너
목적: 필기 세션(StudySession) 자료(documents) 학습 주제(: 가스기사)
아래로 묶는 컨테이너. 향후 단어장/오디오/문제세트 같은 학습 자산이 같은
컨테이너 아래로 들어올 있도록 설계.
설계 원칙:
- documents.category(자료실 UI ) 직교한 별도 분류 . 자료실 facet/카테고리 미터치.
- StudySession.certification/subject/topic 컬럼은 보존, 컨테이너 직교 세부 메타.
- study_type 느슨한 분류. DB/Pydantic 강한 enum 미사용. 권장값: certification /
language / school / work / general (UI 드롭다운에서만 안내).
- soft delete (deleted_at). 동일 user_id+name active 행만 partial unique index
중복 방지 삭제된 주제명 재생성 가능.
- 자산 다대다 매핑: PR documents (study_topic_documents). 향후 자산 타입별
조인 테이블 추가 (study_topic_audio_assets ). polymorphic 단일 테이블 금지.
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
class StudyTopic(Base):
__tablename__ = "study_topics"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
color: Mapped[str | None] = mapped_column(String(20))
# 느슨한 분류 (certification/language/school/work/general 권장)
study_type: Mapped[str | None] = mapped_column(String(40))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# PR-6: 시험 메타 (회차당 문항 수 + 과목 리스트)
exam_round_size: Mapped[int | None] = mapped_column(Integer)
exam_subjects: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 연관 — 세션 (1:N), 자료 매핑 (N:M), 문제 (1:N PR-2)
sessions: Mapped[list["StudySession"]] = relationship( # type: ignore[name-defined] # noqa: F821
"StudySession", back_populates="study_topic", lazy="noload"
)
document_links: Mapped[list["StudyTopicDocument"]] = relationship(
back_populates="topic",
cascade="all, delete-orphan",
order_by="StudyTopicDocument.sort_order",
lazy="noload",
)
questions: Mapped[list["StudyQuestion"]] = relationship( # type: ignore[name-defined] # noqa: F821
"StudyQuestion", back_populates="topic", lazy="noload"
)
class StudyTopicDocument(Base):
__tablename__ = "study_topic_documents"
study_topic_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), primary_key=True
)
document_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
topic: Mapped["StudyTopic"] = relationship(back_populates="document_links")
+38
View File
@@ -0,0 +1,38 @@
"""study_topic_subject_notes ORM (PR-9) — 분야 설명 캐시.
(user, study_topic, subject, scope) 단위 unique. AI 즉석 생성 + 캐시.
사용자가 풀이 결과 화면에서 "모르겠음" 카드 클릭 호출.
status: none/pending/ready/failed/stale (PR-3 패턴 동일).
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class StudyTopicSubjectNote(Base):
__tablename__ = "study_topic_subject_notes"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
study_topic_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False
)
subject: Mapped[str] = mapped_column(String(120), nullable=False)
scope: Mapped[str] = mapped_column(String(200), nullable=False, default="")
content: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), default="none", nullable=False)
generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
model: Mapped[str | None] = mapped_column(String(120))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
+2
View File
@@ -16,7 +16,9 @@ class User(Base):
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
totp_secret: Mapped[str | None] = mapped_column(String(64))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now
)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+5
View File
@@ -0,0 +1,5 @@
"""AI policy layer — pure-function judgment engine.
Runtime 동작 변경 없음. 패키지를 app/workers app/api 에서 import 하지
(PR-A CI gate: import 격리 검증).
"""
+56
View File
@@ -0,0 +1,56 @@
"""Audit — 4B 가 자체 답변한 경우 금지 패턴 검출.
escalate_to_26b=False 이벤트에만 호출. 위반 검출 policy_violation=true
analyze_events 기록되고 야간 sweep 에서 under_escalation 후보로 포획된다.
detection_patterns Python re.search() 평가 (Postgres regex 아님).
"""
from __future__ import annotations
import re
from functools import lru_cache
from typing import Iterable
from policy.loader import load_policy
from policy.schema import DomainPolicy, ForbiddenRule
@lru_cache(maxsize=256)
def _compiled_patterns(pattern_tuple: tuple[str, ...]) -> tuple[re.Pattern[str], ...]:
return tuple(re.compile(p) for p in pattern_tuple)
def _rules_for_subject(
policy: DomainPolicy, subject_domain: str
) -> Iterable[ForbiddenRule]:
for rule in policy.forbidden_for_4b:
if subject_domain in rule.applies_when_subject_in:
yield rule
def check_4b_output_violations(
output_text: str,
subject_domain: str,
*,
policy: DomainPolicy | None = None,
) -> list[str]:
"""Return list of violated forbidden-rule IDs (빈 리스트면 위반 없음).
Parameters
----------
output_text: 4B 생성한 자체 답변 텍스트.
subject_domain: routing 에서 결정된 도메인 이름. fallback 도메인은 `generic`.
policy: 주입용 (테스트). None 이면 load_policy().
"""
if not output_text:
return []
if policy is None:
policy = load_policy()
violations: list[str] = []
for rule in _rules_for_subject(policy, subject_domain):
patterns = _compiled_patterns(tuple(rule.detection_patterns))
if any(p.search(output_text) for p in patterns):
violations.append(rule.id)
return violations
+67
View File
@@ -0,0 +1,67 @@
"""domain_policy.yaml loader with lru_cache."""
from __future__ import annotations
import os
from functools import lru_cache
from pathlib import Path
import yaml
from policy.schema import DomainPolicy
DEFAULT_POLICY_FILENAME = "domain_policy.yaml"
POLICY_PATH_ENV = "POLICY_PATH"
def _resolve_path(path: str | None) -> Path:
if path is not None:
return Path(path)
env_path = os.environ.get(POLICY_PATH_ENV)
if env_path:
return Path(env_path)
# 검색 순서 (multi-env 호환):
# 1. cwd / domain_policy.yaml 로컬 pytest (repo-root 실행)
# 2. /app / domain_policy.yaml container bind-mount 경로
# 3. /app/../domain_policy.yaml container: /app 의 parent
# 4. <this>.parent.parent.parent / yaml policy 패키지 기준 repo-root
candidates = [
Path.cwd() / DEFAULT_POLICY_FILENAME,
Path("/app") / DEFAULT_POLICY_FILENAME,
Path("/app").parent / DEFAULT_POLICY_FILENAME,
Path(__file__).resolve().parent.parent.parent / DEFAULT_POLICY_FILENAME,
]
for c in candidates:
if c.is_file():
return c
# 찾지 못한 경우 첫 후보 반환 → 나중에 FileNotFoundError 로 명확히 실패
return candidates[0]
@lru_cache(maxsize=8)
def _load_cached(resolved: str) -> DomainPolicy:
text = Path(resolved).read_text(encoding="utf-8")
raw = yaml.safe_load(text)
return DomainPolicy.model_validate(raw)
def load_policy(path: str | None = None) -> DomainPolicy:
"""Load policy yaml and validate via pydantic.
Cache key = resolved absolute path (문자열). 테스트에서 다른 path 주면 별도 캐시.
"""
resolved = str(_resolve_path(path).resolve())
return _load_cached(resolved)
def clear_cache() -> None:
"""테스트용 — 연속 호출 시 서로 다른 yaml 을 반영해야 할 때."""
_load_cached.cache_clear()
def read_policy_bytes(path: str | None = None) -> bytes:
"""policy_version hash 계산용 — yaml 원본 바이트."""
resolved = _resolve_path(path).resolve()
return resolved.read_bytes()
+153
View File
@@ -0,0 +1,153 @@
"""Prompt rendering — yaml excerpt 를 template placeholder 에 주입.
템플릿에는 다음 placeholder 있다:
{forbidden_block} subject forbidden_for_4b 블록 주입
{subject_description} subject_domains[domain].description
{confidence_threshold} escalation.confidence_threshold
{context_cap} escalation.context_char_cap_4b
{context_cap_doc_count} P6 전용 (batch 문서 cap, 기본 500)
policy_version() = sha256(yaml_bytes + template_bytes)[:12].
yaml 또는 template 바뀌면 자동 bump analyze_events.policy_version 으로 추적.
"""
from __future__ import annotations
import hashlib
from functools import lru_cache
from pathlib import Path
from policy.loader import load_policy, read_policy_bytes
from policy.schema import DomainPolicy
# 기본 템플릿 경로 — repo root 기준
TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "prompts" / "policy"
# 4B / 26B 구분 (관측성 + 테스트 편의)
KNOWN_4B_TASKS = {
"p1_triage",
"p2_nas_rule",
"p3a_short_summary",
"p3b_entities",
"p4a_advice_trigger",
"p4b_retrieval",
"p6_night_sweep",
}
KNOWN_26B_TASKS = {
"p3c_deep_summary",
"p4b_synthesis",
}
def _template_path(task: str) -> Path:
return TEMPLATE_DIR / f"{task}.txt"
@lru_cache(maxsize=64)
def _read_template(task: str) -> str:
path = _template_path(task)
if not path.exists():
raise FileNotFoundError(f"policy template '{task}' not found at {path}")
return path.read_text(encoding="utf-8")
@lru_cache(maxsize=64)
def _read_template_bytes(task: str) -> bytes:
return _template_path(task).read_bytes()
def _forbidden_block_for(
policy: DomainPolicy, subject_domain: str
) -> str:
"""해당 도메인에 적용되는 forbidden_for_4b 규칙을 프롬프트 블록으로 렌더."""
lines = ["=== 4B 절대 금지 작업 ===",
"다음에 해당하면 자체 답변 금지, escalate_to_26b=true + envelope 만 응답.",
""]
count = 0
for rule in policy.forbidden_for_4b:
if subject_domain in rule.applies_when_subject_in:
count += 1
lines.append(f"{count}. [{rule.id}] {rule.description}")
if count == 0:
lines.append("(해당 도메인에 등록된 금지 항목 없음 — 일반 규칙만 적용)")
lines.append("")
lines.append("금지 위반 시 사후 audit (check_4b_output_violations) 에서 탐지되어")
lines.append("policy_violation=true 로 기록 + under_escalation 큐로 재처리.")
return "\n".join(lines)
def render_4b(
task: str,
subject_domain: str,
*,
policy: DomainPolicy | None = None,
) -> str:
"""4B 용 템플릿에 정책 excerpt 를 주입하고 반환.
사용자 input placeholder ({{filename}}, {{extracted_text}} , 이중중괄호)
그대로 남는다. PR-B worker str.format 또는 Template 으로 최종 주입.
"""
if task not in KNOWN_4B_TASKS:
raise ValueError(f"'{task}' is not a 4B task (known: {KNOWN_4B_TASKS})")
if policy is None:
policy = load_policy()
template = _read_template(task)
domain_spec = (
policy.subject_domains.get(subject_domain)
or policy.fallback_domain
)
return template.format(
forbidden_block=_forbidden_block_for(policy, subject_domain),
subject_description=domain_spec.description,
confidence_threshold=policy.escalation.confidence_threshold,
context_cap=policy.escalation.context_char_cap_4b,
context_cap_doc_count=500,
)
def render_26b(
task: str,
subject_domain: str,
*,
policy: DomainPolicy | None = None,
) -> str:
"""26B 용 템플릿 렌더."""
if task not in KNOWN_26B_TASKS:
raise ValueError(f"'{task}' is not a 26B task (known: {KNOWN_26B_TASKS})")
if policy is None:
policy = load_policy()
template = _read_template(task)
domain_spec = (
policy.subject_domains.get(subject_domain)
or policy.fallback_domain
)
return template.format(
forbidden_block=_forbidden_block_for(policy, subject_domain),
subject_description=domain_spec.description,
confidence_threshold=policy.escalation.confidence_threshold,
context_cap=policy.escalation.context_char_cap_26b,
context_cap_doc_count=500,
)
def policy_version(task: str, *, policy_path: str | None = None) -> str:
"""Return sha256(yaml_bytes + template_bytes)[:12].
Deterministic 같은 (yaml, template) 같은 hash. 쪽만 변경돼도 변경됨.
analyze_events.policy_version 저장되어 drift 추적.
"""
yaml_bytes = read_policy_bytes(policy_path)
template_bytes = _read_template_bytes(task)
h = hashlib.sha256(yaml_bytes + template_bytes).hexdigest()
return h[:12]
def clear_cache() -> None:
"""테스트용 — 템플릿 재읽기."""
_read_template.cache_clear()
_read_template_bytes.cache_clear()
+178
View File
@@ -0,0 +1,178 @@
"""Routing engine — 4B 출력 + 상황을 받아 26B 에스컬레이션 여부를 결정.
6 invariants (모두 deterministic, code-level HARD rules):
INV-1 self_declare_add_only
deterministic_high_impact=True AND self_declare=False high_impact_task=True
(self_declare ADD only; OFF 불가)
INV-2 risk_flag_requires_26b_forces_escalation
any(flag where policy.risk_flags[flag].requires_26b) escalate=True
INV-3 context_cap_forces_escalation
content_chars > policy.escalation.context_char_cap_4b escalate=True, reason="long_context"
INV-4 multi_doc_forces_escalation
evidence_doc_count >= policy.escalation.escalate_on_multi_doc_count
escalate=True, reason="multi_doc", add "multi_doc_dependency" to risk_flags
INV-5 risk_flags_union
final risk_flags = UNION(domain.default_risk_flags, self_declared, derived)
self_declared ADD only; default 있어도 self 추가 flag 붙이면 합집합
INV-6 fallback_domain for unknown
subject_domain not in policy.subject_domains use policy.fallback_domain
(routing None/undefined 빠지는 edge case 0)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable
from policy.loader import load_policy
from policy.schema import DomainPolicy, SubjectDomain, FallbackDomain
# --- Reason 문자열 상수 (tests 에서 참조) -----------------------------------
REASON_HIGH_IMPACT = "high_impact"
REASON_RISK_FLAG = "risk_flag_requires_26b"
REASON_LOW_CONFIDENCE = "low_confidence"
REASON_LONG_CONTEXT = "long_context"
REASON_MULTI_DOC = "multi_doc"
REASON_FALLBACK_DOMAIN = "fallback_domain"
@dataclass(frozen=True)
class RoutingDecision:
escalate_to_26b: bool
escalation_reasons: tuple[str, ...]
risk_flags: tuple[str, ...]
high_impact_task: bool
synthesis_directives: tuple[str, ...]
subject_domain_used: str # 실제 적용된 도메인 이름 (fallback 인 경우 fallback_domain.name)
used_fallback: bool = False
def _resolve_domain(
policy: DomainPolicy, subject_domain: str
) -> tuple[SubjectDomain | FallbackDomain, str, bool]:
"""INV-6 — 매칭 실패 시 fallback_domain."""
spec = policy.subject_domains.get(subject_domain)
if spec is not None:
return spec, subject_domain, False
return policy.fallback_domain, policy.fallback_domain.name, True
def decide_routing(
*,
subject_domain: str,
content_chars: int,
deterministic_keyword_hits: Iterable[str] = (),
self_declared_high_impact: bool = False,
self_declared_risk_flags: Iterable[str] = (),
confidence: float = 1.0,
evidence_doc_count: int = 0,
policy: DomainPolicy | None = None,
) -> RoutingDecision:
"""Pure function — yaml 과 입력만으로 결정론적 결과.
Parameters
----------
subject_domain: upstream (keyword/source_channel 매칭) 정한 도메인 이름.
content_chars: 4B 들어간 본문 문자 .
deterministic_keyword_hits: upstream keyword 매칭 결과 (비어있어도 domain.high_impact
True INV 그대로 작동).
self_declared_high_impact: 4B 출력의 high_impact_self_declared 필드.
self_declared_risk_flags: 4B 출력의 risk_flags 자기선언.
confidence: 4B 출력의 confidence (0.0~1.0).
evidence_doc_count: /ask 경로 등에서 합성 대상 문서 .
policy: 주입용 (테스트). None 이면 loader.load_policy().
"""
if policy is None:
policy = load_policy()
domain_spec, domain_name, used_fallback = _resolve_domain(policy, subject_domain)
reasons: list[str] = []
flags: set[str] = set()
# --- INV-1: high_impact (deterministic → self_declare 는 ADD only) -----
deterministic_high_impact = (
bool(list(deterministic_keyword_hits))
or domain_spec.high_impact
)
high_impact = deterministic_high_impact
if self_declared_high_impact:
high_impact = True # ADD only — False 로 되돌릴 수 없음
if high_impact:
reasons.append(REASON_HIGH_IMPACT)
# --- INV-5: risk_flags UNION merge -------------------------------------
# (a) domain 기본
flags.update(domain_spec.default_risk_flags)
# (b) 4B 자기선언 (ADD only)
flags.update(self_declared_risk_flags)
# --- INV-3: long_context (derived flag 추가 전에 판정) ----------------
if content_chars > policy.escalation.context_char_cap_4b:
reasons.append(REASON_LONG_CONTEXT)
# --- INV-4: multi_doc (derived flag 추가) -----------------------------
if evidence_doc_count >= policy.escalation.escalate_on_multi_doc_count:
reasons.append(REASON_MULTI_DOC)
flags.add("multi_doc_dependency")
# --- low_confidence (derived flag 추가) --------------------------------
if confidence < policy.escalation.confidence_threshold:
reasons.append(REASON_LOW_CONFIDENCE)
flags.add("low_confidence_reasoning")
# --- INV-2: risk_flag_requires_26b -------------------------------------
requires_26b_flag = any(
policy.risk_flags[f].requires_26b
for f in flags
if f in policy.risk_flags and policy.risk_flags[f].requires_26b
)
if requires_26b_flag:
reasons.append(REASON_RISK_FLAG)
# --- INV-6: fallback 사용 사실 기록 -----------------------------------
if used_fallback:
# 에스컬레이션 자체를 강제하진 않지만 visibility 위해 reason 에 추가
reasons.append(REASON_FALLBACK_DOMAIN)
# --- synthesis directives 수집 (26B 에 전달될 규칙) -------------------
directives: list[str] = []
for f in sorted(flags):
rf = policy.risk_flags.get(f)
if rf is not None and rf.synthesis_directive:
directives.append(rf.synthesis_directive)
# --- 최종 escalate 판정 ---------------------------------------------
escalate = (
high_impact
or requires_26b_flag
or content_chars > policy.escalation.context_char_cap_4b
or evidence_doc_count >= policy.escalation.escalate_on_multi_doc_count
or confidence < policy.escalation.confidence_threshold
)
# 중복 reason 제거 (순서 유지)
seen: set[str] = set()
dedup_reasons: list[str] = []
for r in reasons:
if r not in seen:
seen.add(r)
dedup_reasons.append(r)
return RoutingDecision(
escalate_to_26b=escalate,
escalation_reasons=tuple(dedup_reasons),
risk_flags=tuple(sorted(flags)),
high_impact_task=high_impact,
synthesis_directives=tuple(directives),
subject_domain_used=domain_name,
used_fallback=used_fallback,
)
+133
View File
@@ -0,0 +1,133 @@
"""Pydantic v2 models for domain_policy.yaml.
Loader yaml DomainPolicy 파싱. Schema 위반 ValidationError 배포 차단.
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# documents.category enum (migration 143 + 152)
UICategory = Literal["document", "library", "news", "memo", "audio", "video", "law"]
SelfDeclareSemantics = Literal["additive_trigger_only"]
class SubjectDomain(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
description: str
suggested_ui_category: UICategory
high_impact: bool = False
default_risk_flags: tuple[str, ...] = ()
deep_summary_risk_flags: tuple[str, ...] = ()
keywords: tuple[str, ...] = ()
note: str | None = None
class FallbackDomain(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
name: str
description: str
suggested_ui_category: UICategory
high_impact: bool = False
default_risk_flags: tuple[str, ...] = ()
requires_human_review: bool = True
class RiskFlag(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
description: str
requires_26b: bool
synthesis_directive: str | None = None
output_mask_required: bool = False
@field_validator("synthesis_directive")
@classmethod
def _directive_length(cls, v: str | None) -> str | None:
if v is not None and len(v) > 500:
raise ValueError("synthesis_directive must be <= 500 chars")
return v
class ForbiddenRule(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
id: str
description: str
applies_when_subject_in: tuple[str, ...]
detection_patterns: tuple[str, ...] = ()
class Escalation(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
confidence_threshold: float = Field(ge=0.0, le=1.0)
context_char_cap_4b: int = Field(gt=0)
context_char_cap_26b: int = Field(gt=0)
escalate_on_multi_doc_count: int = Field(ge=1)
class HealthRange(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
min: float | None = None
max: float | None = None
class Observability(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
required_event_fields: tuple[str, ...]
health_ranges: dict[str, HealthRange]
class DomainPolicy(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
version: int
last_updated: str
scope: tuple[str, ...]
self_declare_semantics: SelfDeclareSemantics
subject_domains: dict[str, SubjectDomain]
fallback_domain: FallbackDomain
risk_flags: dict[str, RiskFlag]
forbidden_for_4b: tuple[ForbiddenRule, ...]
escalation: Escalation
observability: Observability
@model_validator(mode="after")
def _cross_reference_check(self) -> "DomainPolicy":
"""Cross-field validation — yaml 내부 일관성."""
known_flags = set(self.risk_flags.keys())
# 1. 모든 subject_domain.default_risk_flags 가 risk_flags 에 정의돼 있어야 함
for name, dom in self.subject_domains.items():
for flag in (*dom.default_risk_flags, *dom.deep_summary_risk_flags):
if flag not in known_flags:
raise ValueError(
f"subject_domain '{name}' references unknown risk_flag '{flag}'"
)
for flag in self.fallback_domain.default_risk_flags:
if flag not in known_flags:
raise ValueError(
f"fallback_domain references unknown risk_flag '{flag}'"
)
# 2. forbidden_for_4b.applies_when_subject_in 의 도메인이 subject_domains 에 있어야 함
known_domains = set(self.subject_domains.keys())
for rule in self.forbidden_for_4b:
for dom_name in rule.applies_when_subject_in:
if dom_name not in known_domains:
raise ValueError(
f"forbidden rule '{rule.id}' references unknown subject_domain '{dom_name}'"
)
return self
+90
View File
@@ -0,0 +1,90 @@
"""ShadowLogger — Protocol + in-memory implementation.
Live 전환 1 shadow 기간에 "만약 이 정책이면 어디로 라우팅했을지" 기록.
실제 DB writer (DBShadowLogger) PR-B 책임. PR-A :
1. Protocol 인터페이스 확정.
2. InMemoryShadowLogger 테스트 가능한 fake 제공.
PR-B Protocol 시그니처를 변경하지 않는 것이 불변식.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Protocol, runtime_checkable
from policy.routing import RoutingDecision
@dataclass(frozen=True)
class ShadowRecord:
"""단일 shadow 이벤트 — InMemoryShadowLogger 가 dict 로 보관."""
doc_id: str
decision: RoutingDecision
actual_model_used: str
prompt_version: str
policy_version: str
recorded_at: datetime
extra: dict[str, Any] = field(default_factory=dict)
@runtime_checkable
class ShadowLogger(Protocol):
"""PR-A 가 정의하는 shadow 기록 인터페이스.
PR-B DBShadowLogger(ShadowLogger) 구현할 시그니처를 그대로 준수.
"""
async def record_would_route(
self,
*,
doc_id: str,
decision: RoutingDecision,
actual_model_used: str,
prompt_version: str,
policy_version: str,
extra: dict[str, Any] | None = None,
) -> None:
...
class InMemoryShadowLogger:
"""테스트 전용 구현. PR-B 의 DBShadowLogger 와 시그니처 호환."""
def __init__(self) -> None:
self._records: list[ShadowRecord] = []
async def record_would_route(
self,
*,
doc_id: str,
decision: RoutingDecision,
actual_model_used: str,
prompt_version: str,
policy_version: str,
extra: dict[str, Any] | None = None,
) -> None:
self._records.append(
ShadowRecord(
doc_id=doc_id,
decision=decision,
actual_model_used=actual_model_used,
prompt_version=prompt_version,
policy_version=policy_version,
recorded_at=datetime.now(timezone.utc),
extra=dict(extra or {}),
)
)
# --- Inspection helpers (테스트 전용) ----------------------------------
@property
def records(self) -> tuple[ShadowRecord, ...]:
return tuple(self._records)
def clear(self) -> None:
self._records.clear()
def count(self) -> int:
return len(self._records)
+46
View File
@@ -0,0 +1,46 @@
너는 다국적 뉴스 비교 분석가다.
아래는 같은 주제로 군집된 야간 수집 뉴스들 — 각 줄 앞 (국가코드 · 소스) 표시로 출처가 표시되어 있다.
이 정보만으로 cross-country 비교 분석을 JSON 으로만 출력하라.
목표:
- 같은 사건을 각 나라가 어떻게 다르게 다루는지 / 무엇이 공통인지를 1페이지 카드 형태로 정리.
- 사용자는 한국어 독자. 한국어로 출력.
절대 금지:
- 제공된 summary 에 없는 사실 추가
- 추측 표현 ("보인다", "~할 것이다", "~할 전망" 등)
- JSON 외의 모든 텍스트 (설명, 마크다운, 코드블록 금지)
- 인용부호 안 원문에 없던 단어 생성 (key_quotes 는 원문 그대로만)
분량 cap (반드시 지킬 것):
- country_perspectives: 최대 10개, 각 summary 는 1~2문장 (한국어 120자 이내)
- divergences: 최대 3개, 각 200자 이내
- convergences: 최대 2개, 각 200자 이내
- key_quotes: 최대 5개, 각 quote 240자 이내
- historical_context: 1~2문장 (한국어 120자 이내), 의미 있을 때만 채우고 아니면 null
출력 형식 (JSON 객체 하나만 출력, 위 cap 초과 금지):
{
"topic_label": "5~10 단어의 한국어 토픽 제목",
"headline": "전체를 한 줄로 압축한 한국어 headline (≤80자)",
"country_perspectives": [
{"country": "KR", "summary": "...", "article_ids": []},
{"country": "US", "summary": "...", "article_ids": []}
],
"divergences": ["A국=X 강조 / B국=Y 비판 / C국=Z 부각"],
"convergences": ["모든 매체가 Z 사실은 일치"],
"key_quotes": [{"country": "US", "source": "NYT", "quote": "..."}],
"historical_context": null
}
규칙:
- country_perspectives 의 country 는 입력 기사의 국가코드 그대로 (대문자).
- article_ids 는 비워둬도 됨 (서버가 채움).
- 단일 국가만 다룬 경우 divergences 는 빈 배열.
- historical_context 는 아래 "이전 흐름 참고" 섹션이 비어있으면 반드시 null.
오늘 새벽 기사 묶음:
{articles_block}
이전 흐름 참고 (직접 인용 금지, 맥락 파악 용도):
{historical_block}
+33
View File
@@ -0,0 +1,33 @@
You are an answerability judge. Given a query and evidence chunks, determine if the evidence can answer the query. Respond ONLY in JSON.
## CALIBRATION (CRITICAL)
- verdict=full: evidence is SUFFICIENT to answer the CORE of the query. Missing minor details does NOT make it insufficient.
- verdict=partial: evidence covers SOME major aspects but CLEARLY MISSES others the user explicitly asked about.
- verdict=insufficient: evidence has NO relevant information for the query, or is completely off-topic.
Example: Query="제6장 주요 내용", Evidence covers 제6장 definition+scope → verdict=full (core is covered).
Example: Query="제6장 처벌 조항", Evidence covers 제6장 definition but NOT 처벌 → verdict=partial.
Example: Query="감귤 출하량", Evidence about 산업안전보건법 → verdict=insufficient.
## Rules
1. Your "verdict" must be based ONLY on whether the CONTENT semantically answers the query. Ignore retrieval scores for this field.
2. "covered_aspects": query aspects that evidence covers. Korean labels for Korean queries.
3. "missing_aspects": query aspects that evidence does NOT cover. Korean labels.
4. Keep aspects concise (2-5 words each), non-overlapping.
## Output Schema
{
"verdict": "full" | "partial" | "insufficient",
"covered_aspects": ["aspect1"],
"missing_aspects": ["aspect2"],
"confidence": "high" | "medium" | "low"
}
## Query
{query}
## Evidence chunks:
{chunks}
## Retrieval scores (for reference only, NOT for verdict):
[{scores}]
+29 -3
View File
@@ -1,14 +1,20 @@
[DEPRECATED 2026-04-24] — summary_triage.txt 로 이관됨 (PR-B B-1 tier routing).
이 파일은 B-1 안정화 기간 동안 rollback 경로를 위해 유지. 신규 호출 경로는
summary_triage.txt + summary_deep.txt 조합 사용. 실제 삭제는 별도 cleanup PR.
You are a document classification AI. Analyze the document below and respond ONLY in JSON format. No other text.
## Response Format
{
"domain": "Level1/Level2/Level3",
"document_type": "one of document_types",
"facet_doctype": "one of facet_doctypes or null",
"confidence": 0.85,
"tags": ["tag1", "tag2"],
"importance": "medium",
"sourceChannel": "inbox_route",
"dataOrigin": "work or external"
"dataOrigin": "work or external",
"docPurpose": "business or knowledge"
}
## Domain Taxonomy (select the most specific leaf node)
@@ -56,7 +62,7 @@ General/
- 2-level paths allowed ONLY when no leaf exists (e.g., Engineering/Civil)
## Document Types (select exactly ONE)
Reference, Standard, Manual, Drawing, Template, Note, Academic_Paper, Law_Document, Report, Memo, Checklist, Meeting_Minutes, Specification
Reference, Standard, Manual, Drawing, Template, Note, Academic_Paper, Law_Document, Report, Memo, Checklist, Meeting_Minutes, Specification, 발주서, 세금계산서, 명세표, 도면, 증명서, 계획서, 시방서
### Document Type Detection Rules
- Step-by-step instructions → Manual
@@ -65,9 +71,22 @@ Reference, Standard, Manual, Drawing, Template, Note, Academic_Paper, Law_Docume
- Meeting discussion → Meeting_Minutes
- Checklist format → Checklist
- Academic/research format → Academic_Paper
- Technical drawings → Drawing
- Technical drawings → Drawing / 도면
- 발주 내역, 품목·수량·단가 표 → 발주서
- 공급자/공급받는자/세액 양식 → 세금계산서
- 거래 명세/납품 명세 → 명세표
- 자격 증빙·수료·재직 → 증명서
- 업무·프로젝트 추진안 → 계획서
- 공사 시방·재료 기준 → 시방서
- If unclear → Note
## facet_doctype (실무 문서 유형 식별 신호)
Select ONE of: 발주서, 세금계산서, 명세표, 도면, 증명서, 계획서, 시방서
If the document clearly does NOT fit any of the above, return null.
- This field is independent of document_type — use it to flag business-document types
that drive 자료실(library) 자동 분류 제안.
- 발주서 / 세금계산서 / 명세표 는 자료실 "거래" 분류의 승인 대기 제안으로 연결된다.
## Confidence (0.0 ~ 1.0)
- How confident are you in the domain classification?
- 0.85+ = high confidence, 0.6~0.85 = moderate, <0.6 = uncertain
@@ -89,5 +108,12 @@ Reference, Standard, Manual, Drawing, Template, Note, Academic_Paper, Law_Docume
- work: company-related (TK, Technicalkorea, factory, production)
- external: external reference (news, papers, laws, general info)
## docPurpose
- business: 업무 수행에 직접 사용 (양식, 보고서, 체크리스트, 제출물, 계획서)
- knowledge: 참조·학습·보관 목적 (법령, 논문, 기사, 레퍼런스, 기술 문서, 교육 자료)
- Template, Checklist, Report, Specification → business 가능성 높음
- Academic_Paper, Law_Document, Reference, Standard → knowledge 가능성 높음
- Meeting_Minutes, Memo → 문맥 판단 (실행 기록이면 business, 참조용이면 knowledge)
## Document to classify
{document_text}
+19
View File
@@ -0,0 +1,19 @@
너는 팩트 기반 뉴스 토픽 요약 도우미다.
아래는 같은 사건으로 군집된 기사들의 ai_summary다.
이 정보만으로 다음을 JSON으로만 출력하라.
절대 금지:
- 제공된 summary에 없는 사실 추가
- 해석/비교/예측/의견
- "보인다", "~할 것이다", "~할 전망" 같은 추측 표현
- 인용부호 안 원문 외 단어 생성
- JSON 외의 모든 텍스트 (설명, 마크다운, 코드블록 금지)
출력 형식 (JSON 객체 하나만 출력):
{
"topic_label": "5~10 단어의 한국어 제목",
"summary": "1~2 문장, 사실만, 수동태 허용"
}
기사 요약:
{articles_block}
+30
View File
@@ -0,0 +1,30 @@
You are a document analyzer. Respond ONLY in JSON. No markdown wrapping, no explanation.
## Task
Given a document, produce a structured analysis with up to 4 layers.
Skip any layer that does not apply. Always include "summary".
## Output Schema
{
"layers": [
{"layer": "evidence", "title": "근거", "content": "..."},
{"layer": "explanation", "title": "해설", "content": "..."},
{"layer": "examples", "title": "사례", "content": "..."},
{"layer": "summary", "title": "요약", "content": "..."}
]
}
## Rules
- Each content: 200~400 characters, in the same language as the document (Korean documents → Korean).
- "evidence": Key factual claims or data points stated in the document. Skip for narrative/opinion documents.
- "explanation": Why the facts matter, context, or interpretation. Skip for pure data/tables.
- "examples": Concrete cases, scenarios, or instances explicitly mentioned. Skip if none exist.
- "summary": Always present. 2-3 sentences capturing the document's core message.
- Use ONLY information in the document. No outside knowledge.
- If a layer does not apply, OMIT it entirely from the layers array. Do NOT write "해당 없음", "정보 없음", "N/A" — just skip.
- Maximum 4 layers. Minimum 1 (summary).
## Document
Title: {document_title}
Content:
{document_text}
+77
View File
@@ -0,0 +1,77 @@
You are an evidence span extractor. Respond ONLY in JSON. No markdown, no explanation.
## Task
For each numbered candidate, extract the most query-relevant span from the original text (copy verbatim, 50-200 chars) and rate relevance 0.0~1.0. If the candidate has no connection at all to the query topic, set span=null, relevance=0.0, skip_reason. Partial or indirect relevance should still get a span and relevance >= 0.3.
## Output Schema
{
"items": [
{
"n": 1,
"span": "...",
"relevance": 0.0,
"skip_reason": null
}
]
}
## Rules
- `n`: candidate 번호 (1-based, 입력 순서와 동일). **모든 n을 반환** (skip된 것도 포함).
- `span`: 원문에서 **그대로 복사한** 50~200자. 요약/변형 금지. 원문에 없는 단어는 절대 포함하지 말 것. 여러 문장이어도 무방.
- 관련 span이 없으면 `span: null`, `relevance: 0.0`, `skip_reason`에 한 줄 사유.
- `relevance`: 0.0~1.0 float
- 0.9+ query에 직접 답함
- 0.7~0.9 강한 연관
- 0.5~0.7 명확한 부분 연관 (query의 핵심 측면 일부를 커버)
- 0.3~0.5 약한 부분 연관 (query 주제에 관련되나 직접 답은 아님)
- <0.3 무관
- `skip_reason`: span=null 일 때만 필수. 예: "no_direct_relevance", "off_topic", "generic_boilerplate"
- **원문 그대로 복사 강제**: 번역/paraphrase/요약 모두 금지. evidence span은 citation 원문이 되어야 한다.
## Example 1 (hit)
query: `산업안전보건법 제6장 주요 내용`
candidates:
[1] title: 산업안전보건법 해설 / text: 제6장은 "안전보건관리체제"에 관한 장으로, 사업주의 안전보건관리책임자 선임 의무와 관리감독자 지정 등을 규정한다. 제15조부터 제19조까지 구성된다...
[2] title: 회사 복지 규정 / text: 직원의 연차휴가 사용 규정과 경조사 지원 내용을 담고 있다...
{
"items": [
{
"n": 1,
"span": "제6장은 \"안전보건관리체제\"에 관한 장으로, 사업주의 안전보건관리책임자 선임 의무와 관리감독자 지정 등을 규정한다. 제15조부터 제19조까지 구성된다",
"relevance": 0.95,
"skip_reason": null
},
{
"n": 2,
"span": null,
"relevance": 0.0,
"skip_reason": "off_topic"
}
]
}
## Example 2 (partial)
query: `Python async best practice`
candidates:
[1] title: FastAPI tutorial / text: FastAPI supports both async and sync endpoints. For I/O-bound operations, use async def with await for database and HTTP calls. Avoid blocking calls in async functions or use run_in_executor...
{
"items": [
{
"n": 1,
"span": "For I/O-bound operations, use async def with await for database and HTTP calls. Avoid blocking calls in async functions or use run_in_executor",
"relevance": 0.82,
"skip_reason": null
}
]
}
## Query
{query}
## Candidates
{numbered_candidates}
+41
View File
@@ -0,0 +1,41 @@
[System]
너는 Document Server 의 업로드 라우터다. 업로드된 파일의 메타데이터와 (있다면) 텍스트 preview 를 보고, 어떤 처리 파이프라인이 필요한지만 결정한다. 문서 내용을 요약하거나 태깅하지 않는다.
subject_description: {subject_description}
규칙:
- mime/확장자가 명확하면 그대로 따른다. 모르겠으면 "unknown" 으로 표시하고 needs_ocr=true.
- 이미지·PDF 의 text_density < 0.3 → needs_ocr=true.
- 오디오(m4a/mp3/wav)·비디오(mp4/webm) → needs_stt=true.
- 확신도 낮으면 priority="needs_human" 로만 표시하고 추측하지 않는다.
{forbidden_block}
출력 (JSON only, 다른 텍스트 금지):
{{
"subject_domain": "safety_reference|safety_operational|msds|hazard_specific|incident_report|health_record|safety_video|news_item|news_digest_request|generic",
"needs_ocr": bool,
"needs_stt": bool,
"needs_summary": bool,
"summary_tier": "short|standard|deep|none",
"priority": "normal|high|needs_human",
"high_impact_self_declared": bool,
"high_impact_reason": "한 줄 한국어",
"confidence": 0.0~1.0,
"escalate_to_26b": bool,
"escalation_reason": "한 줄 한국어 (escalate=true 일 때만)"
}}
에스컬레이션 기준 (one-of):
- 입력 preview > {context_cap} chars
- confidence < {confidence_threshold}
- 규칙 충돌 / 다중 도메인 혼재
- 사용자 대면 자연어 응답 필요 (여긴 해당 없음)
[User]
파일명: {{filename}}
MIME: {{mime}}
크기: {{size_bytes}} bytes
소스: {{source}} (upload | nas_watcher | law_monitor | news_collector)
Text preview (처음 2000자):
{{text_preview_or_empty}}
+40
View File
@@ -0,0 +1,40 @@
[System]
너는 Document Server 의 자료 분류 어시스턴트다. 문서 메타데이터 + 짧은 요약 + 추출 태그를 보고 사용자 승인용 UI 카테고리/파일명/태그 제안을 생성한다.
**자동 이동 금지** — 네 출력은 승인 대기용 제안일 뿐, 즉시 DB category 를 변경하지 않는다 (PR-B 의 ai_suggestion 플로우에서 사용자 승인 후 반영).
subject_description: {subject_description}
{forbidden_block}
제약:
- suggested_ui_category 는 {{document, library, news, memo, audio, video, law}} 중에서만 선택.
- 규칙에 없는 카테고리는 만들지 않는다. 애매하면 needs_human_review=true.
- cat_library=1 과 has_library_tag=1 자동 전이 금지 (정책).
- 개인정보 (주민번호/계좌/전화/차량번호) 가 본문에 보이면 tags 에 "pii" 추가 + confidence 감점.
- category 매칭을 subject_domain 판정 키로 절대 역산하지 말 것 (UI 축과 정책 축 분리 원칙).
출력 (JSON only):
{{
"suggested_ui_category": "document|library|news|memo|audio|video|law",
"target_subfolder": "...",
"suggested_filename": "...",
"tags_auto": ["tag1", "tag2"],
"library_suggestion": bool,
"confidence": 0.0~1.0,
"needs_human_review": bool,
"reason": "한 줄 한국어",
"escalate_to_26b": bool
}}
에스컬레이션:
- 입력 > {context_cap} chars → escalate
- confidence < {confidence_threshold} → escalate
- 도메인·카테고리 조합에 대한 룰이 상충 → escalate
[User]
파일명: {{filename}}
subject_domain: {{subject_domain}}
추출 요약 (P3a short tier): {{short_summary}}
추출 태그 후보: {{extracted_tags}}
유사 기존 문서 top3: {{similar_docs_titles}}
+60
View File
@@ -0,0 +1,60 @@
[System]
너는 한국어 문서 태거 + 짧은 요약기다. 입력 본문을 읽고 TL;DR + 핵심 bullets + tags 만 생성한다. **상세 문단·entities 는 생성하지 않는다** (깊은 요약은 26B, entity 는 P3b 담당).
subject_description: {subject_description}
{forbidden_block}
태깅 원칙:
- 태그 5~12개, 명사구. 동사/조사 금지.
- "문서 종류" 태그 1개 필수 (예: 법령, MSDS, 회의록, 보고서, 메모, 뉴스, 영상전사).
- 시점 태그 (YYYY-QN / YYYY-MM) 추출 가능 시 포함.
- 중복 의미 태그 금지 ("계약" + "계약서" → "계약서" 하나).
- pii 감지 시 "pii" 추가 + confidence 감점.
요약 규칙:
- **TL;DR**: 1문장, 최대 60자.
- **Bullets**: 정확히 5개, 각 30~60자.
- 본문에 없는 정보 추가 금지 (hallucination 금지).
- 숫자·날짜·고유명사는 원문 그대로.
출력 (JSON only):
{{
"tldr": "1문장 최대 60자",
"bullets": ["...", "...", "...", "...", "..."],
"tags": ["..."],
"doc_type": "...",
"time_scope": "YYYY-QN|YYYY-MM|null",
"confidence": 0.0~1.0,
"high_impact_self_declared": bool,
"high_impact_reason": "한 줄",
"recommend_deep_summary": bool,
"recommend_entity_pass": bool,
"escalate_to_26b": bool,
"risk_flags": ["..."],
"event_kind_hint": "note|task|calendar_event|activity_log|reference|null",
"event_kind_confidence": 0.0~1.0
}}
event_kind_hint 분류 (사용자 메모 inbox triage 용 — AI 가 events row 직접 생성하지 않고 사용자 1-click promote 의 추천만 제공):
- "task": 사용자가 미래에 해야 할 일 (예: "내일 견적 요청", "세무사 전화하기"). due 시각 있어도 task 가능.
- "calendar_event": 시간/날짜가 고정된 일정 (예: "5/15 14:00 회의", "내일 2시 세무사 전화"). 본문에 명시적 시간 단서.
- "activity_log": 이미 한 행동 기록 (예: "방금 PR 머지 완료", "오늘 GPU 서버 점검함"). 과거형 또는 "방금/오늘/지금" 표지.
- "reference": 나중에 참조할 자료/링크/요약 (예: 웹 클립, 외부 자료, "이거 나중에 봐야 함").
- "note": 위 4개 어디에도 명확하지 않은 일반 메모/생각 (default).
- event_kind_confidence: 0.01.0. 명확하지 않으면 낮게 (< 0.5). 사용자가 결정.
- 본문이 짧거나 의도 불명이면 "note" + confidence 낮게.
recommend_deep_summary=true 조건:
- 본문 > 40,000 chars
- 다수 당사자 또는 시계열 전개가 있는 법령/절차/보고서
- 사용자가 이 문서를 기반으로 결정을 내려야 할 가능성
에스컬레이션 (escalate_to_26b=true):
- 본문 > {context_cap} chars
- confidence < {confidence_threshold}
- subject_domain 의 high_impact=true 이고 판단 정확성이 중요
- 5개 이상 핵심 주장 교차 — 상세 분석 필요
[User]
{{extracted_text}}
+42
View File
@@ -0,0 +1,42 @@
[System]
너는 고유명사 추출기다. 본문에서 인물/조직/프로젝트명만 추출한다.
subject_description: {subject_description}
{forbidden_block}
원칙:
- 추측·유추·번역 금지. 본문에 문자 그대로 등장하는 것만.
- 각 entity 는 원문 근접 5단어를 evidence 로 제공 (fabrication 방지).
- 확신 없으면 빈 배열 + abstained=true. 과추출 페널티 > 과소추출 페널티.
- 동의어·별칭 병합 금지 (원문 그대로 두 개 각각 기록).
abstained=true 가 되는 경우 (P3c 26B 가 재추출):
- 이름 후보가 10개 이상인데 문맥 구분 불가
- 익명 주체가 주요 행위자인 문서
- 번역·음역으로 표기 불일치 심한 경우
출력 (JSON only):
{{
"people": [
{{"name": "...", "evidence": "원문 그대로 주변 5단어"}}
],
"orgs": [
{{"name": "...", "evidence": "..."}}
],
"projects": [
{{"name": "...", "evidence": "..."}}
],
"confidence": 0.0~1.0,
"abstained": bool,
"abstain_reason": "한 줄 한국어 (abstained=true 일 때만)",
"escalate_to_26b": bool
}}
에스컬레이션:
- 본문 > {context_cap} chars
- confidence < {confidence_threshold}
- subject_domain 의 high_impact=true (안전/법령/MSDS 등 — entity 오독 실무 피해)
[User]
{{extracted_text}}
+51
View File
@@ -0,0 +1,51 @@
[System]
너는 긴 문서·문서 묶음 분석가다. 4B 가 넘긴 envelope 를 먼저 읽고, original_pointers 로 원문 범위를 재조회하여 최종 분석을 작성한다.
subject_description: {subject_description}
{forbidden_block}
envelope 를 읽는 순서:
1. risk_flags 를 먼저 본다. 어떤 위험 때문에 올라온 것인지 파악.
2. synthesis_directives 를 system 지시로 간주하여 반드시 준수.
3. distilled_context 는 "참고 요지"일 뿐, 숫자·조문·인용은 original 에서 재확인.
단일 문서:
- TL;DR (1문장, 최대 60자)
- 핵심 (bullets 5개, 각 30~80자)
- 상세 (2 문단, 각 3~5문장, 원문 흐름 유지)
문서 묶음 (법령 연대기 / 회의록 시리즈 / 사고 보고 계열):
- 묶음 개요 (1문단)
- 시계열 또는 논리 흐름 (3~7 단계)
- 각 문서 역할 1줄
- 일관성 이슈 (수치 모순, 날짜 모순) — 있을 때만
제약:
- 본문에 없는 정보 금지 (hallucination 금지).
- synthesis_directives 의 문구 규칙 ("원인은 ~" 금지 등) 반드시 준수.
- multi_reference_synthesis flag 있으면 레퍼런스별 입장 분리 기술, 종합 권고 금지.
출력 (JSON only):
{{
"mode": "single|bundle",
"tldr": "...",
"bullets": ["..."],
"detail": "...\\n\\n...",
"bundle_flow": ["..."] | null,
"inconsistencies": ["..."] | null,
"entities_confirmed": {{
"people": [{{"name": "...", "evidence": "..."}}],
"orgs": [...],
"projects": [...]
}},
"directives_applied": ["..."],
"confidence": 0.0~1.0
}}
[User]
Envelope:
{{escalation_envelope_json}}
원문 (ranges — original_pointers 기반 슬라이스):
{{original_text_slices}}
+43
View File
@@ -0,0 +1,43 @@
[System]
너는 Document Server 의 선제적 조언 탐지기다. 조언이 **실제로 사용자에게 유용한 시점** 만 감지한다. 모든 문서마다 조언하지 않는다 — **침묵이 기본값**이다.
subject_description: {subject_description}
{forbidden_block}
트리거 조건 (하나 이상 충족해야 should_advise=true):
1. reference_version_drift
같은 주제의 레퍼런스가 2개 이상이고, 개정일이 1년 이상 차이
2. safety_reference_vs_news
최근 뉴스 digest 에 보유 레퍼런스 주제의 법령 개정 시그널 탐지
3. conflict_in_refs
동일 위험 유형(예: 밀폐공간)에 대해 보유 문서들이 서로 다른 절차 제시
4. unsummarized_long_video
STT 끝난 safety_video 중 챕터는 분리됐으나 deep summary 없음
5. news_cluster_needs_synthesis
같은 이벤트 cluster 에 국가/출처 3개 이상 누적
트리거 없으면 should_advise=false, 다른 필드는 null.
출력 (JSON only):
{{
"should_advise": bool,
"trigger_type": "reference_version_drift|safety_reference_vs_news|conflict_in_refs|unsummarized_long_video|news_cluster_needs_synthesis|none",
"evidence_doc_ids": ["..."],
"urgency": "low|medium|high",
"draft_hint": "26B 에게 전달할 한 줄 컨텍스트",
"confidence": 0.0~1.0,
"escalate_to_26b": bool
}}
에스컬레이션:
- should_advise=true → 자연어 문장 작성은 26B 담당 (항상 escalate=true)
- 입력 > {context_cap} chars → escalate
- confidence < {confidence_threshold} → escalate
[User]
최근 이벤트:
{{event_context}}
관련 문서 메타:
{{docs_metadata_json}}
+44
View File
@@ -0,0 +1,44 @@
[System]
너는 질문-근거 매칭기다. 사용자 질문과 검색 후보 snippet (이미 bge-m3 + reranker 통과) 을 받아, 실제로 질문에 답하는 데 쓸 근거만 추려낸다. **최종 답변은 쓰지 않는다** — 26B synthesis 가 쓴다.
subject_description: {subject_description}
{forbidden_block}
규칙:
- 각 snippet 이 질문의 어느 부분에 답하는지 1문장으로 기술.
- 무관한 snippet 은 제외 (개수 늘리려 억지 포함 금지).
- 근거들 간 모순이 보이면 conflicts 에 기록.
- 근거 부족 시 answerability=insufficient + suggested_queries.
answerability 3-state:
- direct = 질문의 모든 측면이 근거에 있음 → 26B synthesis full-answer
- partial = 일부만 있음 (중요한 측면 1개 누락) → 26B synthesis "제한적 답변"
- insufficient = 핵심 측면 대부분 누락 → 26B 호출 안 함, 사용자에 suggested_queries 리턴
출력 (JSON only):
{{
"answerability": "direct|partial|insufficient",
"selected_evidence": [
{{"doc_id": "...", "snippet": "...", "relevance": "질문의 X 부분에 답함"}}
],
"coverage_analysis": {{
"answered_aspects": ["..."],
"unanswered_aspects": ["..."]
}},
"conflicts": ["..."] | null,
"suggested_queries": ["..."] | null,
"draft_hint": "26B 에게 줄 답변 방향 1~2 줄",
"confidence": 0.0~1.0,
"escalate_to_26b": bool
}}
에스컬레이션:
- answerability in {{direct, partial}} → 26B synthesis 호출 (escalate=true)
- answerability=insufficient → 26B 호출 안 함 (escalate=false, 사용자에게 추가 쿼리 제안)
- confidence < {confidence_threshold} → escalate (answerability 재검토)
[User]
질문: {{question}}
후보 snippets:
{{candidates_json}}
+31
View File
@@ -0,0 +1,31 @@
[System]
너는 근거 기반 답변 작성자다. 한국어로 존댓말, 이모지 금지.
subject_description: {subject_description}
envelope 가 먼저 제공된다. risk_flags 와 synthesis_directives 를 **반드시 준수**.
{forbidden_block}
응답 형식:
- 1~2문단. 구어체 금지, 문어체.
- 인용은 [doc_id:N] 형태 인라인 표기.
- 숫자·날짜·조문·고유명사는 evidence 의 snippet 그대로 복제.
- evidence 밖 정보 인용 절대 금지 (hallucination 금지).
- conflicts 있으면 마지막 문장에 "근거 간 모순" 명시.
mode 별 분기:
- full : 완전 답변. 모든 측면 커버.
- limited : 제한적 답변. 답변 마지막에 반드시
"다만 {{unanswered_aspects}} 에 대해서는 문서에 근거가 부족합니다." 삽입.
multi_reference_synthesis flag 있으면 종합 결론 금지, 레퍼런스별 분리 기술.
medical_health_judgment flag 있으면 "전문의 상담 권장" 문구 포함.
[User]
mode: {{mode}} (full | limited)
질문: {{question}}
Envelope:
{{escalation_envelope_system_injection}}
Evidence (4B 선별):
{{selected_evidence_json}}
+60
View File
@@ -0,0 +1,60 @@
[System]
너는 Document Server 의 야간 점검 봇이다. 전일 색인된 문서 배치를 받아 이상 징후를 탐지한다. **개선 행동을 자동 실행하지 않는다 — 보고만 한다.**
subject_description: {subject_description}
{forbidden_block}
점검 항목:
파이프라인 실패:
1. ocr_failed : ocr_attempted=1, text_length < 100
2. stt_timeout : duration 있는데 transcript 없음
3. missing_summary : summary IS NULL, classify 완료
4. missing_tags : tags 0개
5. missing_embedding : embedding IS NULL
6. duplicate_filename : 같은 파일명, 다른 hash (버전 추정)
7. unknown_category_24h: category="unknown" 이 24h 이상 유지
품질:
8. summary_quality_low : bullets < 3 OR avg bullet len < 20 chars OR tldr == doc title
9. tags_low_entropy : tags 전부 {{문서, 정보, 자료, 파일, 기타}} 등 generic 집합
Escalation 감사 (관측성):
10. over_escalation : 26B 호출됐으나 4B draft 대비 new facts 0 (wasted)
11. under_escalation : high_impact_task=true 인데 26B 미경유 (위험!)
12. entity_abstain_high: 특정 doc_type 에서 P3b abstain 비율 > 40% (프롬프트 튜닝 시그널)
각 이상건은 한 줄로 집계. 이상 없으면 빈 배열.
출력 (JSON only):
{{
"swept_at": "ISO8601",
"total_docs": N,
"anomalies": [
{{
"doc_id": "...",
"issue": "ocr_failed|stt_timeout|missing_summary|missing_tags|missing_embedding|duplicate_filename|unknown_category_24h|summary_quality_low|tags_low_entropy|over_escalation|under_escalation|entity_abstain_high",
"severity": "low|medium|high",
"escalate_to_26b": bool,
"note": "한 줄"
}}
],
"summary_stats": {{
"ocr_fail_rate": 0.0,
"missing_summary_count": 0,
"under_escalation_count": 0,
"over_escalation_count": 0
}},
"confidence": 0.0~1.0,
"escalate_to_26b": bool
}}
에스컬레이션:
- 개별 anomaly 에 severity=high → escalate_to_26b=true 로 개별 flag
- under_escalation 1건이라도 발견 → 전체 sweep escalate=true (26B 가 원인 분석)
- total_docs > {context_cap_doc_count} → escalate (배치 크기 초과)
[User]
점검 대상 문서 메타 (NDJSON):
{{docs_meta_ndjson}}
+53
View File
@@ -0,0 +1,53 @@
You are a search query analyzer. Respond ONLY in JSON. No markdown, no explanation.
## Output Schema
{
"intent": "fact_lookup | semantic_search | filter_browse",
"query_type": "natural_language | keyword | phrase",
"domain_hint": "document | news | mixed",
"language_scope": "limited | global",
"keywords": [],
"must_terms": [],
"optional_terms": [],
"hard_filters": {},
"soft_filters": {"domain": [], "document_type": []},
"normalized_queries": [{"lang": "ko", "text": "...", "weight": 1.0}],
"expanded_terms": [],
"synonyms": {},
"analyzer_confidence": 0.0
}
## Rules
- `intent`: fact_lookup (사실/조항/이름), semantic_search (주제/개념), filter_browse (필터 중심)
- `query_type`: natural_language (문장형), keyword (단어 나열), phrase (따옴표/고유명사/법조항)
- `domain_hint`: document (소유 문서/법령/매뉴얼), news (시사/뉴스), mixed (불명)
- `language_scope`: limited (ko+en), global (다국어 필요)
- `hard_filters`: 쿼리에 **명시된** 것만. 추론 금지. 키: file_format, year, country
- `soft_filters.domain`: Industrial_Safety, Programming, Engineering, Philosophy, Language, General. 2-level 허용(e.g. Industrial_Safety/Legislation)
- `soft_filters.document_type`: Law_Document, Manual, Report, Academic_Paper, Standard, Specification, Meeting_Minutes, Checklist, Note, Memo, Reference, Drawing, Template
- `normalized_queries`: 원문 언어 1.0 가중치 필수. 교차언어 1개 추가 권장(ko↔en, weight 0.8). news + global 인 경우만 ja/zh 추가(weight 0.5~0.6). **최대 3개**.
- `analyzer_confidence`: 0.9+ 명확, 0.7~0.9 대체로 명확, 0.5~0.7 모호, <0.5 분석 불가
## Example
query: `기계 사고 관련 법령`
{
"intent": "semantic_search",
"query_type": "natural_language",
"domain_hint": "document",
"language_scope": "limited",
"keywords": ["기계", "사고", "법령"],
"must_terms": [],
"optional_terms": ["안전", "규정"],
"hard_filters": {},
"soft_filters": {"domain": ["Industrial_Safety/Legislation"], "document_type": ["Law_Document"]},
"normalized_queries": [
{"lang": "ko", "text": "기계 사고 관련 법령", "weight": 1.0},
{"lang": "en", "text": "machinery accident related laws", "weight": 0.8}
],
"expanded_terms": ["산업안전", "기계안전"],
"synonyms": {},
"analyzer_confidence": 0.88
}
## Query
{query}
+82
View File
@@ -0,0 +1,82 @@
You are a grounded answer synthesizer. Respond ONLY in JSON. No markdown, no explanation.
## Task
Given a query and numbered evidence spans, write a short answer that cites specific evidence by [n]. **You may only use facts that appear in the evidence.** If the evidence does not directly answer the query, set `refused: true`.
## Output Schema
{
"answer": "...",
"used_citations": [1, 2],
"confidence": "high",
"refused": false,
"refuse_reason": null
}
## Rules
- `answer`: **600 characters max**. Must contain inline `[n]` citations. Every claim sentence ends with at least one `[n]`. Multiple sources: `[1][3]`. **Only use facts present in evidence. No outside knowledge, no guessing, no paraphrasing what is not there.**
- `used_citations`: integer list of `n` values that actually appear in `answer` (for cross-check). Must be sorted ascending, no duplicates.
- `confidence`:
- `high`: 3+ evidence items with strong relevance
- `medium`: 2 items match, or 1 strong match
- `low`: 1-2 weak/partial items
- `refused`: set to `true` ONLY if evidence is completely off-topic (e.g., query about 연차휴가 but evidence only about 산업안전). If evidence is partially relevant or covers a related aspect, attempt an answer with low confidence instead of refusing. When refused:
- `answer`: empty string `""`
- `used_citations`: `[]`
- `confidence`: `"low"`
- `refuse_reason`: one sentence explaining why (will be shown to the user)
- **Language**: Korean query → Korean answer. English query → English answer. Match query language.
- **Absolute prohibition**: Do NOT introduce entities, numbers, dates, or claims that are not verbatim in the evidence. If you are unsure whether a fact is in evidence, treat it as not present and either omit it or refuse.
- **Partial coverage**: If evidence covers only PART of the query, answer ONLY the covered part. Do NOT infer or guess missing parts. Explicitly state what the evidence covers.
- **Supplementary evidence**: Evidence marked (보충) is supplementary context, less reliable than primary evidence. Use it only as supporting detail. If primary and supplementary evidence conflict, trust primary.
## Example 1 (happy path, high confidence)
query: `산업안전보건법 제6장 주요 내용`
evidence:
[1] 산업안전보건법 해설: 제6장은 "안전보건관리체제"에 관한 장으로, 사업주의 안전보건관리책임자 선임 의무와 관리감독자 지정 등을 규정한다
[2] 시행령 해설: 제6장은 제15조부터 제19조까지로 구성되며 안전보건관리책임자의 업무 범위를 세부 규정한다
[3] 법령 체계도: 안전보건관리책임자 선임은 상시근로자 50명 이상 사업장에 적용된다
{
"answer": "산업안전보건법 제6장은 안전보건관리체제에 관한 장으로, 사업주의 안전보건관리책임자 선임 의무와 관리감독자 지정을 규정한다[1]. 제15조부터 제19조까지 구성되며 관리책임자의 업무 범위를 세부 규정한다[2]. 상시근로자 50명 이상 사업장에 적용된다[3].",
"used_citations": [1, 2, 3],
"confidence": "high",
"refused": false,
"refuse_reason": null
}
## Example 2 (partial, medium confidence)
query: `Python async best practice`
evidence:
[1] FastAPI tutorial: For I/O-bound operations, use async def with await for database and HTTP calls. Avoid blocking calls in async functions or use run_in_executor
{
"answer": "For I/O-bound operations, use async def with await for database and HTTP calls, and avoid blocking calls inside async functions (use run_in_executor instead) [1].",
"used_citations": [1],
"confidence": "low",
"refused": false,
"refuse_reason": null
}
## Example 3 (refused — evidence does not answer query)
query: `회사 연차 휴가 사용 규정`
evidence:
[1] 산업안전보건법 해설: 제6장은 "안전보건관리체제"에 관한 장으로, 사업주의 안전보건관리책임자 선임 의무와 관리감독자 지정 등을 규정한다
[2] 회사 복지 안내: 직원 경조사 지원 내용 포함
{
"answer": "",
"used_citations": [],
"confidence": "low",
"refused": true,
"refuse_reason": "연차 휴가 사용 규정에 대한 직접적인 근거가 evidence에 없습니다."
}
## Query
{query}
## Evidence
{numbered_evidence}
+34
View File
@@ -0,0 +1,34 @@
You are a grounded answer synthesizer handling a PARTIAL answer case. Some aspects of the query CAN be answered, others CANNOT. Respond ONLY in JSON.
## Task
Answer ONLY the covered aspects. Do NOT attempt to answer missing aspects.
## Output Schema
{
"confirmed_items": [
{"aspect": "aspect label", "text": "1~2 sentence answer", "citations": [1, 2]}
],
"confidence": "medium" | "low",
"refused": false
}
## Rules
- Each confirmed_item: aspect label + 1~2 sentences + inline [n] citations
- ONLY use facts present in evidence. No outside knowledge, no guessing.
- Do NOT mention or address missing_aspects in your text.
- Korean query → Korean answer / English → English
- confidence: medium (2+ strong evidence matches) / low (1 or weak)
- Max total text: 400 chars across all items
- 모든 주장 문장 끝에 [n] 필수
## Covered aspects (answer these):
{covered_aspects}
## Missing aspects (do NOT answer these):
{missing_aspects}
## Query
{query}
## Evidence
{numbered_evidence}
@@ -0,0 +1,49 @@
당신은 한국 기사시험(가스기사·산업안전기사 등) 필기 학습 보조 AI 입니다.
4지선다 객관식 문제를 분석하고 정답 풀이를 작성합니다.
【문제】
{question_text}
【보기】
1. {choice_1}
2. {choice_2}
3. {choice_3}
4. {choice_4}
【사용자가 입력한 정답】
{correct_choice}번
【참고 자료 — 우선순위 순서】
▼ 자료 (1순위: 자료실 매핑 문서)
{documents_evidence_block}
▼ 같은 주제의 다른 문제 (2순위: 보조 근거)
{questions_evidence_block}
【지침】
1. 자료를 1순위 근거로 사용. 다른 문제는 보조 근거로만.
2. 자료 인용은 [자료: 제목] 형태. 문제 인용은 [관련: Q<id>] 형태.
3. 정답이 왜 맞는지 핵심 개념 → 오답 보기가 왜 틀렸는지 짧게 → 정리 순서.
4. 자료에 직접 근거가 없으면 "자료 근거 부족" 으로 명시하고, 일반 상식 풀이는 별도 단락에 표시.
5. **할루시네이션 방지 (절대 규칙)**:
- 자료 근거가 부족하면 법령명·조항·수치·기준값을 새로 만들어내지 않는다.
- 근거 없는 수치(예: "0.5 MPa", "10 mg/L")·공식·표준 번호(예: "KS B 6750")·통계는 작성하지 않는다.
- 자료에서 확인되지 않는 내용은 "자료에서 확인되지 않음" 이라고 명시한다.
- "보통 ~이다", "일반적으로 ~이다" 같은 모호한 단정도 자료 근거가 없으면 사용하지 않는다.
6. confidence 는 풀이 근거 강도에 따라 high/medium/low 중 하나로 선택:
- high: 자료에 직접 근거가 있고 정답이 명확
- medium: 자료가 부분 근거이고 일반 지식 보강 필요
- low: 자료 근거 부족하여 추론에 의존
7. **answer_choice 는 반드시 위 "사용자가 입력한 정답" 의 번호 ({correct_choice}) 를 그대로 박는다.**
- 사용자 정답이 정해진 객관식 문제이며, 너의 역할은 그 정답을 풀이하는 것이지 정답 자체를 바꾸는 것이 아니다.
- 자료 근거가 사용자 정답과 다르게 보여도 explanation_md 안에 "자료 근거에 따르면 다른 해석도 가능하다" 라고 짧게 명시할 뿐, answer_choice 는 사용자 정답 그대로 유지한다.
- 정답을 추측하거나 다른 번호로 바꾸는 것은 환각 가드에 차단된다.
8. explanation_md 는 사용자 정답이 왜 맞는지 풀이. 한국어 **300~600자 권장, 최대 900자**. 굵게·리스트 가능.
- 핵심 개념과 정답 근거 위주. 모든 오답 보기를 다 풀이할 필요 없고 가장 헷갈리는 1~2개만.
- **explanation_md 안에서 줄바꿈은 최소화** (꼭 필요한 단락 분리만).
- **LaTeX 수식 사용 자제**. 쓰더라도 짧은 인라인 (`$...$`) 만, `\circ`/`\text{}`/`\,` 같은 매크로는 가능하면 평문으로 ("0°C", "C", " ").
9. **출력은 raw JSON 한 객체만**. 메타 설명·인사·코드 펜스 (` ``` `)·thinking 텍스트 없이.
【출력 형식】
{{"answer_choice": <1|2|3|4>, "explanation_md": "<풀이 본문 마크다운>", "confidence": "<high|medium|low>"}}
@@ -0,0 +1,38 @@
당신은 한국 기사시험(가스기사·산업안전기사 등) 필기 학습 보조 AI 입니다.
4지선다 객관식 문제를 분석하고 정답 풀이를 작성합니다.
【문제】
{question_text}
【보기】
1. {choice_1}
2. {choice_2}
3. {choice_3}
4. {choice_4}
【사용자가 입력한 정답】
{correct_choice}번
【참고 자료 — 우선순위 순서】
▼ 자료 (1순위: 자료실 매핑 문서)
{documents_evidence_block}
▼ 같은 주제의 다른 문제 (2순위: 보조 근거)
{questions_evidence_block}
【지침】
1. 자료를 1순위 근거로 사용. 다른 문제는 보조 근거로만.
2. 자료 인용은 [자료: 제목] 형태. 문제 인용은 [관련: Q<id>] 형태.
3. 정답이 왜 맞는지 핵심 개념 → 오답 보기가 왜 틀렸는지 짧게 → 정리 순서.
4. 자료에 직접 근거가 없으면 "자료 근거 부족" 으로 명시하고, 일반 상식 풀이는 별도 단락에 표시.
5. 사용자 입력 정답과 자료 근거가 충돌하면 "근거에 따르면 정답이 X번일 가능성이 있습니다" 라고 충돌을 명시 (자동으로 다른 답으로 단정 금지).
6. **할루시네이션 방지 (절대 규칙)**:
- 자료 근거가 부족하면 법령명·조항·수치·기준값을 새로 만들어내지 않는다.
- 근거 없는 수치(예: "0.5 MPa", "10 mg/L")·공식·표준 번호(예: "KS B 6750", "ASME Section VIII")·통계는 작성하지 않는다.
- 자료에서 확인되지 않는 내용은 "자료에서 확인되지 않음" 이라고 명시한다.
- "보통 ~이다", "일반적으로 ~이다" 같은 모호한 단정도 자료 근거가 없으면 사용하지 않는다.
7. 한국어. 분량 200~400자. 마크다운(굵게·리스트) 사용 가능.
8. 메타 설명·인사 없이 풀이만 출력.
【풀이】
@@ -0,0 +1,37 @@
당신은 한국 기사시험 학습 보조 AI 입니다.
사용자가 한 풀이 세션을 막 끝냈고, 그 결과를 짧게 정리하는 역할입니다.
【세션 정량 데이터 — 이 값들만 인용 가능】
- 총 {total}문제 / 정답 {correct}건 / 오답 {wrong}건 / 모르겠음 {unsure}건
- 새로 맞힘 {newly_correct}건 / 다시 틀림 {relapsed}건
- 회복 {recovered}건 / 누적 반복 오답 {chronic_remaining}건
【과목 분포】
{subject_distribution_block}
【이번 세션의 오답·모르겠음 문제들 (qid 별)】
{wrong_unsure_block}
【참고 자료 (있는 경우만)】
{documents_evidence_block}
【지침】
1. 이번 세션에서 사용자가 어느 영역에서 흔들렸는지 200~400자 마크다운으로 요약.
2. 톤은 "판정" 보다 "흔들린 것으로 보입니다", "관련 해설을 먼저 확인한 뒤 ..." 같이 부드럽게.
3. **위 정량 데이터에 박힌 정수 (예: 오답 1건, 모르겠음 83건) 외의 수치는 절대 언급 금지**:
- 정답률 N% 같은 비율
- 최근/지난 N일 추세
- X~Y 문항 같은 범위 추천
- 회차 카운트 추정
- 날짜 표현
4. 참고 자료 블록이 비어있거나 부족하면 그 사실을 짧게 명시 ("자료 근거가 부족합니다").
자료가 없어도 세션 기록 자체로 흔들린 영역 요약은 작성한다.
5. confidence 는 출력 근거 강도에 따라 high/medium/low 중 하나:
- high: 자료 + 다른 ai_explanation 으로 패턴이 명확
- medium: 일부 근거 + 일반 지식 보강
- low: 자료 부족, 세션 기록만 기반
6. 추천은 "관련 해설을 다시 보세요" / "같은 영역 문제를 더 풀어보세요" 같은 일반 권장만.
구체 행동 지시 (몇 분 / 몇 문항 / 며칠 후) 는 금지.
【출력 형식 — raw JSON 한 객체. 메타 설명 / 코드 펜스 / 인사 없이.】
{{"summary_md": "<200~400자 마크다운>", "confidence": "<high|medium|low>"}}
+28
View File
@@ -0,0 +1,28 @@
당신은 한국 기사시험(가스기사·산업안전기사 등) 학습 보조 AI 입니다.
사용자가 모르겠다고 표시한 문제의 분야에 대한 학습 자료를 작성합니다.
【분야】
과목: {subject}
범위: {scope}
【참고 자료 — 우선순위】
▼ 자료 (1순위: 자료실 매핑 문서)
{documents_evidence_block}
▼ 같은 분야의 다른 문제·해설 (2순위: 보조 근거)
{questions_evidence_block}
【지침】
1. 분야 핵심 개념을 200~500자로 정리.
2. 자주 등장하는 공식·표준값·법령 조항이 자료에 있으면 인용 ([자료: 제목]).
3. 학습 노트 형태 — 이 분야 처음 접하는 사용자가 "큰 그림"을 잡을 수 있게.
4. 정답을 단정하지 말고 개념 위주로 (특정 문제 풀이가 아닌 분야 설명).
5. **할루시네이션 방지 (절대 규칙)**:
- 자료에 없는 수치(예: "0.5 MPa", "10 mg/L")·공식·표준 번호(예: "KS B 6750", "ASME Section VIII")·법령 조항은 새로 만들어내지 않는다.
- 자료에서 확인되지 않는 내용은 "자료에서 확인되지 않음" 으로 명시한다.
- "보통 ~이다", "일반적으로 ~이다" 같은 모호한 단정도 자료 근거가 없으면 사용하지 않는다.
6. 한국어. 마크다운(굵게·리스트) 사용 가능.
7. 메타 설명·인사 없이 학습 자료만 출력.
【학습 자료】
+42
View File
@@ -0,0 +1,42 @@
You are a grounding verifier. Given an answer and its evidence sources, check if the answer contradicts or fabricates information. Respond ONLY in JSON.
## Contradiction Types (IMPORTANT — severity depends on type)
- **direct_negation** (CRITICAL): Answer directly contradicts evidence. Examples: evidence "의무" but answer "권고"; evidence "금지" but answer "허용"; negation reversal ("~해야 한다" vs "~할 필요 없다").
- **numeric_conflict**: Answer states a number different from evidence. "50명" in evidence but "100명" in answer. Only flag if the same concept is referenced. severity=critical when the number is the CORE answered quantity (amount/count/rate/date/duration that the query asked for); severity=minor when the number is peripheral (e.g., example/footnote).
- **intent_core_mismatch**: Answer addresses a fundamentally different topic than the query asked about.
- **nuance**: Answer overgeneralizes or adds qualifiers not in evidence (e.g., "모든" when evidence says "일부").
- **unsupported_claim**: Answer makes a factual claim with no basis in any evidence.
## Rules
1. Compare each claim in the answer against the cited evidence. A claim with [n] citation should be checked against evidence [n].
2. NOT a contradiction: Paraphrasing, summarizing, or restating the same fact in different words. Korean formal/informal style (합니다/한다) differences.
3. Numbers must match exactly after normalization (1,000 = 1000). Range values (e.g., "100~200명") satisfy any answer within range.
4. Legal/regulatory terms must preserve original meaning (의무 ≠ 권고, 금지 ≠ 제한, 허용 ≠ 금지).
5. Maximum 5 contradictions (most severe first: direct_negation > numeric_conflict > intent_core_mismatch > nuance > unsupported_claim).
## Output Schema
{
"contradictions": [
{
"type": "direct_negation" | "numeric_conflict" | "intent_core_mismatch" | "nuance" | "unsupported_claim",
"severity": "critical" | "minor",
"claim": "answer 내 해당 구절 (50자 이내)",
"evidence_ref": "대응 근거 내용 (50자 이내, [n] 포함)",
"explanation": "모순 이유 (한국어, 30자 이내)"
}
],
"verdict": "clean" | "minor_issues" | "major_issues"
}
severity mapping:
- direct_negation → "critical"
- numeric_conflict → "critical" if the number is the CORE answered quantity, else "minor"
- All other types → "minor"
If no contradictions: {"contradictions": [], "verdict": "clean"}
## Answer
{answer}
## Evidence
{numbered_evidence}
+5
View File
@@ -16,3 +16,8 @@ markdown>=3.5.0
python-multipart>=0.0.9
jinja2>=3.1.0
feedparser>=6.0.0
pymupdf>=1.24.0
# Web/Blog ingest (devonagent 트랙) — HTML 본문 정화 4-tier fallback
trafilatura>=1.12.0
readability-lxml>=0.8.1
markdownify>=0.13.1
View File
+80
View File
@@ -0,0 +1,80 @@
"""야간 뉴스 topic-first 클러스터링.
Phase 4 axis 반대: country cluster 아닌 **전체 doc 합쳐서 topic cluster**.
cluster 안에 country 분포가 자동으로 들어감 (doc dict country field).
파라미터 (5h 윈도우용):
- LAMBDA = ln(2)/2h 0.347 (2시간 반감기, 야간 5h 윈도우라 빠른 감쇠)
- threshold = 0.70 (2026-05-13 조정 0.78 에서 spread case kept=1 발생 완화)
- MIN_ARTICLES_PER_TOPIC = 2 (야간 sparse 대비 완화)
- MIN_COUNTRIES_PER_TOPIC = 2 (cross-country 가치 핵심)
- MAX_TOPICS = 7 (1페이지 분량)
"""
import math
from core.utils import setup_logger
from services.clustering_common import (
greedy_assign_cluster,
normalize_importance_scores,
)
logger = setup_logger("briefing_clustering")
LAMBDA = math.log(2) / (2.0 / 24.0) # 2시간 반감기 (단위: 일)
THRESHOLD = 0.70
CENTROID_ALPHA = 0.7
MIN_ARTICLES_PER_TOPIC = 2
MIN_COUNTRIES_PER_TOPIC = 2
MAX_TOPICS = 7
def _count_distinct_countries(cluster: dict) -> int:
return len({m.get("country") for m in cluster["members"] if m.get("country")})
def cluster_global(docs: list[dict]) -> list[dict]:
"""모든 country docs 를 합쳐 topic cluster 생성.
Args:
docs: loader.load_night_window 출력 ( dict country field 포함).
Returns:
[{centroid, members, weight_sum, raw_weight_sum, importance_score, country_count}, ...]
- MIN_ARTICLES + MIN_COUNTRIES 충족 cluster
- importance_score 내림차순, MAX_TOPICS cap
"""
if not docs:
logger.info("[briefing] docs=0 → skip")
return []
clusters, raw_count = greedy_assign_cluster(
docs,
threshold=THRESHOLD,
centroid_alpha=CENTROID_ALPHA,
min_articles=MIN_ARTICLES_PER_TOPIC,
max_topics=MAX_TOPICS * 4, # MIN_COUNTRIES 필터 전 buffer
lambda_val=LAMBDA,
)
# MIN_COUNTRIES_PER_TOPIC 필터 — single-country cluster drop
pre_country_filter = len(clusters)
filtered = []
for c in clusters:
cc = _count_distinct_countries(c)
if cc >= MIN_COUNTRIES_PER_TOPIC:
c["country_count"] = cc
filtered.append(c)
clusters = filtered[:MAX_TOPICS]
dropped_country = pre_country_filter - len(clusters)
dropped_min_articles = raw_count - pre_country_filter
# MIN_COUNTRIES + MAX_TOPICS 필터 후 importance 재정규화 (briefing 내 0~1)
normalize_importance_scores(clusters)
logger.info(
f"[briefing] docs={len(docs)} threshold={THRESHOLD} "
f"raw_clusters={raw_count} dropped_min_articles={dropped_min_articles} "
f"dropped_single_country={dropped_country} kept={len(clusters)}"
)
return clusters
+307
View File
@@ -0,0 +1,307 @@
"""Cluster → 26B MLX 비교 분석 호출 + JSON envelope + historical context + fallback row.
Plan §"LLM Parse 실패 시 Fallback Topic Row (고정 형태)":
LLM JSON parse 2 재시도 실패 고정 형태 fallback 저장 (drop 금지).
Plan §"Historical Context":
BRIEFING_HISTORICAL_ENABLED=true cluster centroid historical candidate
cosine top-K 5 (similarity 0.70) 추출 프롬프트 {historical_block} 주입.
LLM 응답 envelope historical_context 옵션 필드.
"""
import asyncio
import json
import os
from pathlib import Path
from typing import Any
import numpy as np
from ai.client import parse_json_response
from core.utils import setup_logger
from services.clustering_common import normalize_vector
logger = setup_logger("briefing_comparator")
LLM_CALL_TIMEOUT = 25 # 초. Phase 4 와 동일
HISTORICAL_TOP_K = 5
HISTORICAL_SIMILARITY_MIN = 0.70
HISTORICAL_WINDOW_DAYS = 30
# JSON envelope cap (프롬프트 + 후처리 양쪽 강제)
MAX_PERSPECTIVES = 10
MAX_DIVERGENCES = 3
MAX_CONVERGENCES = 2
MAX_KEY_QUOTES = 5
MAX_PERSPECTIVE_SUMMARY_LEN = 240 # 한국어 1~2문장 ≤120자 × 2
MAX_HISTORICAL_CONTEXT_LEN = 240
MAX_ARTICLE_IDS_PER_COUNTRY = 5 # country_perspectives[].article_ids 후처리 cap
FALLBACK_HEADLINE = "LLM 분석 실패로 원문 기사 묶음만 표시합니다."
FALLBACK_TOPIC_LABEL = "주요 뉴스 묶음"
_llm_sem = asyncio.Semaphore(1)
_PROMPT_PATH = Path(__file__).resolve().parent.parent.parent / "prompts" / "briefing_comparative.txt"
_PROMPT_TEMPLATE: str | None = None
def historical_enabled() -> bool:
return os.environ.get("BRIEFING_HISTORICAL_ENABLED", "false").lower() in {"1", "true", "yes"}
def _load_prompt() -> str:
global _PROMPT_TEMPLATE
if _PROMPT_TEMPLATE is None:
_PROMPT_TEMPLATE = _PROMPT_PATH.read_text(encoding="utf-8")
return _PROMPT_TEMPLATE
def _build_articles_block(selected: list[dict]) -> str:
lines = []
for i, m in enumerate(selected, start=1):
country = m.get("country") or "??"
source = m.get("ai_sub_group") or ""
text = (m.get("ai_summary_truncated") or m.get("ai_summary") or m.get("title") or "").strip()
lines.append(f"[{i}] ({country} · {source}) {text}")
return "\n".join(lines)
def _build_historical_block(historical_docs: list[dict]) -> str:
if not historical_docs:
return "(과거 참고 자료 없음)"
lines = ["※ 이전 30일 흐름 참고용 — 본 분석에서 직접 인용 금지, 맥락 파악 용도."]
for i, d in enumerate(historical_docs, start=1):
text = (d.get("ai_summary") or d.get("title") or "").strip()
# historical 은 ai_summary 가 길 수 있어 200자 cap
if len(text) > 200:
text = text[:200] + ""
lines.append(f"[H{i}] {text}")
return "\n".join(lines)
def build_prompt(selected: list[dict], historical_docs: list[dict]) -> str:
template = _load_prompt()
articles_block = _build_articles_block(selected)
historical_block = _build_historical_block(historical_docs)
return template.replace("{articles_block}", articles_block).replace(
"{historical_block}", historical_block
)
def retrieve_historical(
cluster: dict,
candidates: list[dict],
*,
top_k: int = HISTORICAL_TOP_K,
sim_min: float = HISTORICAL_SIMILARITY_MIN,
) -> list[dict]:
"""cluster centroid 와 candidate pool 의 cosine top-K (sim ≥ sim_min).
candidates 비어있거나 sim 미달 list.
"""
if not candidates:
return []
centroid = cluster["centroid"]
scored = []
for d in candidates:
v = normalize_vector(d["embedding"])
sim = float(np.dot(centroid, v))
if sim >= sim_min:
scored.append((sim, d))
scored.sort(key=lambda x: -x[0])
return [d for _, d in scored[:top_k]]
async def _try_call_llm(client: Any, prompt: str) -> str:
async with _llm_sem:
return await asyncio.wait_for(
client.call_primary(prompt),
timeout=LLM_CALL_TIMEOUT,
)
def _truncate_str(s: Any, limit: int) -> str:
if not isinstance(s, str):
return ""
s = s.strip()
if len(s) > limit:
s = s[:limit].rstrip() + ""
return s
def _country_article_id_map(cluster: dict) -> dict[str, list[int]]:
"""cluster.members 를 country 별 article_id list 로 그룹 (weight 내림차순).
Phase 4 selection 단계에서 m['weight'] 채워져 있음. 누락 0.0 으로 fallback.
"""
grouped: dict[str, list[tuple[float, int]]] = {}
for m in cluster.get("members", []):
country = (m.get("country") or "").upper()
if not country:
continue
weight = float(m.get("weight", 0.0))
grouped.setdefault(country, []).append((weight, int(m["id"])))
out: dict[str, list[int]] = {}
for country, pairs in grouped.items():
pairs.sort(key=lambda x: -x[0])
out[country] = [doc_id for _, doc_id in pairs]
return out
def _resolve_article_ids(
raw_ids: list,
country: str,
cluster_country_ids: dict[str, list[int]],
) -> list[int]:
"""country_perspectives[].article_ids 후처리.
1) LLM id cluster member 교집합인 것만 유지 (엉뚱한 id 차단).
2) 비어있으면 같은 country cluster member top weight N 자동 주입.
3) 그래도 없으면 [] (country 매핑된 member 부재).
"""
cluster_ids = cluster_country_ids.get(country, [])
cluster_id_set = set(cluster_ids)
# 1) LLM id ∩ cluster
cleaned = []
if isinstance(raw_ids, list):
for x in raw_ids:
try:
doc_id = int(x)
except (TypeError, ValueError):
continue
if doc_id in cluster_id_set and doc_id not in cleaned:
cleaned.append(doc_id)
if cleaned:
return cleaned[:MAX_ARTICLE_IDS_PER_COUNTRY]
# 2) Country fallback top-N
return cluster_ids[:MAX_ARTICLE_IDS_PER_COUNTRY]
def _sanitize_envelope(parsed: dict, cluster: dict) -> dict | None:
"""LLM 응답 envelope 검증 + cap 강제 + article_ids 후처리. None → fallback."""
if not isinstance(parsed, dict):
return None
topic_label = _truncate_str(parsed.get("topic_label"), 120)
headline = _truncate_str(parsed.get("headline"), 200)
if not topic_label or not headline:
return None
# cluster.members 의 country → [id] 매핑을 미리 만들어 후처리 input 으로 사용
country_ids_map = _country_article_id_map(cluster)
# country_perspectives
raw_persp = parsed.get("country_perspectives")
perspectives = []
if isinstance(raw_persp, list):
for p in raw_persp[:MAX_PERSPECTIVES]:
if not isinstance(p, dict):
continue
country = _truncate_str(p.get("country"), 10).upper()
summary = _truncate_str(p.get("summary"), MAX_PERSPECTIVE_SUMMARY_LEN)
raw_ids = p.get("article_ids") or []
article_ids = _resolve_article_ids(raw_ids, country, country_ids_map)
if country and summary:
perspectives.append({
"country": country,
"summary": summary,
"article_ids": article_ids,
})
if not perspectives:
return None
def _str_array(key: str, cap: int, item_limit: int) -> list[str]:
raw = parsed.get(key)
if not isinstance(raw, list):
return []
out = []
for it in raw[:cap]:
t = _truncate_str(it, item_limit)
if t:
out.append(t)
return out
divergences = _str_array("divergences", MAX_DIVERGENCES, 200)
convergences = _str_array("convergences", MAX_CONVERGENCES, 200)
# key_quotes: [{country, source, quote}]
raw_quotes = parsed.get("key_quotes")
quotes = []
if isinstance(raw_quotes, list):
for q in raw_quotes[:MAX_KEY_QUOTES]:
if not isinstance(q, dict):
continue
entry = {
"country": _truncate_str(q.get("country"), 10).upper(),
"source": _truncate_str(q.get("source"), 60),
"quote": _truncate_str(q.get("quote"), 240),
}
if entry["quote"]:
quotes.append(entry)
historical_context = _truncate_str(parsed.get("historical_context"), MAX_HISTORICAL_CONTEXT_LEN) or None
return {
"topic_label": topic_label,
"headline": headline,
"country_perspectives": perspectives,
"divergences": divergences,
"convergences": convergences,
"key_quotes": quotes,
"historical_context": historical_context,
"llm_fallback_used": False,
}
def _make_fallback(cluster: dict) -> dict:
"""Plan §"Fallback Topic Row (고정 형태)". drop 금지, country_perspectives 빈 list."""
return {
"topic_label": FALLBACK_TOPIC_LABEL,
"headline": FALLBACK_HEADLINE,
"country_perspectives": [],
"divergences": [],
"convergences": [],
"key_quotes": [],
"historical_context": None,
"llm_fallback_used": True,
}
async def compare_cluster_with_fallback(
client: Any,
cluster: dict,
selected: list[dict],
historical_docs: list[dict] | None = None,
) -> dict:
"""1 cluster 비교 분석. LLM 2회 재시도 → 실패 시 fallback row.
Returns:
sanitized envelope dict (Plan §"LLM 프롬프트 출력 envelope") + llm_fallback_used.
"""
historical_docs = historical_docs or []
prompt = build_prompt(selected, historical_docs)
for attempt in range(2):
try:
raw = await _try_call_llm(client, prompt)
except asyncio.TimeoutError:
logger.warning(
f"LLM timeout {LLM_CALL_TIMEOUT}s "
f"(attempt={attempt + 1}, cluster size={len(cluster['members'])})"
)
continue
except Exception as e:
logger.warning(f"LLM 호출 실패 attempt={attempt + 1}: {e}")
continue
parsed = parse_json_response(raw)
sanitized = _sanitize_envelope(parsed, cluster) if parsed else None
if sanitized:
return sanitized
logger.warning(
f"envelope 검증 실패 attempt={attempt + 1} "
f"(raw_len={len(raw) if raw else 0}, parsed_keys={list(parsed.keys()) if isinstance(parsed, dict) else None})"
)
return _make_fallback(cluster)
+199
View File
@@ -0,0 +1,199 @@
"""야간 5h 수집 뉴스 윈도우 로드 + country 정규화 + (옵션) 과거 N일 후보 로드.
- KST 자정~05:00 사이 수집된 documents (source_channel='news' OR ai_domain='News').
- country canonical = document_chunks.country first non-null news_sources prefix fallback (Phase 4 동일).
- ai_summary/embedding NULL 제외 (재요약/재임베딩 0 원칙).
- 반환: doc dict list (topic-first cluster 입력. country dict field).
- 과거 retrieval historical doc 후보는 별도 함수 (BRIEFING_HISTORICAL_ENABLED on ).
"""
from datetime import datetime
from typing import Any
import numpy as np
from sqlalchemy import text
from core.database import async_session
from core.utils import setup_logger
logger = setup_logger("briefing_loader")
_NEWS_WINDOW_SQL = text("""
SELECT
d.id,
d.title,
d.ai_summary,
d.embedding,
d.created_at,
d.edit_url,
d.ai_sub_group,
(
SELECT c.country
FROM document_chunks c
WHERE c.doc_id = d.id AND c.country IS NOT NULL
LIMIT 1
) AS chunk_country
FROM documents d
WHERE (d.source_channel = 'news' OR d.ai_domain = 'News')
AND d.deleted_at IS NULL
AND d.created_at >= :window_start
AND d.created_at < :window_end
AND d.embedding IS NOT NULL
AND d.ai_summary IS NOT NULL
""")
_SOURCE_COUNTRY_SQL = text("""
SELECT name, country FROM news_sources WHERE country IS NOT NULL
""")
_HISTORICAL_CANDIDATES_SQL = text("""
SELECT
d.id,
d.title,
d.ai_summary,
d.embedding,
d.created_at
FROM documents d
WHERE (d.source_channel = 'news' OR d.ai_domain = 'News')
AND d.deleted_at IS NULL
AND d.created_at >= :hist_start
AND d.created_at < :hist_end
AND d.embedding IS NOT NULL
AND d.ai_summary IS NOT NULL
""")
def _to_numpy_embedding(raw: Any) -> np.ndarray | None:
if raw is None:
return None
if isinstance(raw, str):
import json
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return None
try:
arr = np.asarray(raw, dtype=np.float32)
except (TypeError, ValueError):
return None
if arr.size == 0:
return None
return arr
async def _load_source_country_map(session) -> dict[str, str]:
"""news_sources name → country prefix 매핑 (Phase 4 패턴 미러)."""
rows = await session.execute(_SOURCE_COUNTRY_SQL)
mapping: dict[str, str] = {}
for name, country in rows:
if not name or not country:
continue
prefix = name.split(" ")[0].strip()
if prefix and prefix not in mapping:
mapping[prefix] = country
tokens = name.split(" ")
if len(tokens) >= 3:
source_prefix = " ".join(tokens[:-1]).strip()
if source_prefix and source_prefix not in mapping:
mapping[source_prefix] = country
return mapping
async def load_night_window(
window_start: datetime,
window_end: datetime,
) -> list[dict]:
"""야간 윈도우 뉴스 docs 를 country 채워진 list 로 반환.
Returns:
[{id, title, ai_summary, embedding, created_at, edit_url, ai_sub_group, country}, ...]
country 매핑 실패한 doc drop (cross-country 비교가 핵심이므로).
"""
docs: list[dict] = []
null_country = 0
async with async_session() as session:
source_country = await _load_source_country_map(session)
result = await session.execute(
_NEWS_WINDOW_SQL,
{"window_start": window_start, "window_end": window_end},
)
for row in result.mappings():
embedding = _to_numpy_embedding(row["embedding"])
if embedding is None:
continue
country = row["chunk_country"]
if not country:
ai_sub_group = (row["ai_sub_group"] or "").strip()
if ai_sub_group:
country = source_country.get(ai_sub_group)
if not country:
null_country += 1
continue
docs.append({
"id": int(row["id"]),
"title": row["title"] or "",
"ai_summary": row["ai_summary"] or "",
"embedding": embedding,
"created_at": row["created_at"],
"edit_url": row["edit_url"] or "",
"ai_sub_group": row["ai_sub_group"] or "",
"country": country.upper(),
})
if null_country:
logger.warning(
f"[loader] country 매핑 실패 drop {null_country}"
f"(chunk_country + news_sources prefix 둘 다 fail)"
)
logger.info(
f"[loader] night window {window_start} ~ {window_end}"
f"{len(docs)}건 ({len({d['country'] for d in docs})}개 국가)"
)
return docs
async def load_historical_candidates(
hist_start: datetime,
hist_end: datetime,
exclude_ids: set[int],
) -> list[dict]:
"""과거 N일 doc 후보 (BRIEFING_HISTORICAL_ENABLED=true 시만 호출).
cluster centroid cosine 비교용 raw candidate pool. country 매핑
(LLM 분석 input 으로만 사용하고 표시 ).
Args:
exclude_ids: 오늘 윈도우 article id (중복 retrieval 회피).
Returns:
[{id, title, ai_summary, embedding, created_at}, ...]
"""
out: list[dict] = []
async with async_session() as session:
result = await session.execute(
_HISTORICAL_CANDIDATES_SQL,
{"hist_start": hist_start, "hist_end": hist_end},
)
for row in result.mappings():
doc_id = int(row["id"])
if doc_id in exclude_ids:
continue
embedding = _to_numpy_embedding(row["embedding"])
if embedding is None:
continue
out.append({
"id": doc_id,
"title": row["title"] or "",
"ai_summary": row["ai_summary"] or "",
"embedding": embedding,
"created_at": row["created_at"],
})
logger.info(f"[loader] historical candidates: {len(out)} docs (window {hist_start.date()} ~ {hist_end.date()})")
return out
+261
View File
@@ -0,0 +1,261 @@
"""야간 수집 뉴스 브리핑 파이프라인 (Plan §"PR-MorningBriefing-1 Backend").
흐름: load_night_window cluster_global select_for_llm (k=7)
(옵션) historical retrieval compare_cluster_with_fallback DB save.
regenerate 정책: briefing_date UNIQUE 충돌 transaction 안에서 DELETE+INSERT.
"""
import time
from datetime import date, datetime, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import delete
from ai.client import AIClient
from core.database import async_session
from core.utils import setup_logger
from models.briefing import BriefingTopic, MorningBriefing
from services.briefing.clustering import LAMBDA, cluster_global
from services.briefing.comparator import (
HISTORICAL_WINDOW_DAYS,
compare_cluster_with_fallback,
historical_enabled,
retrieve_historical,
)
from services.briefing.loader import load_historical_candidates, load_night_window
from services.digest.selection import select_for_llm
logger = setup_logger("briefing_pipeline")
KST = ZoneInfo("Asia/Seoul")
NIGHT_WINDOW_HOURS = 5 # KST 00:00 ~ 05:00
SELECT_K = 7 # Plan §"Clustering 파라미터" briefing K_PER_CLUSTER=7
SELECT_LAMBDA_MMR = 0.6 # Plan briefing MMR lambda 0.6
PIPELINE_HARD_CAP = 600 # 초. Phase 4 와 동일
def _compute_window(target_date: date | None = None) -> tuple[datetime, datetime, date]:
"""target_date (KST 자정 시작일) → (window_start_utc, window_end_utc, kst_date).
target_date=None 오늘 KST.
"""
if target_date is None:
target_date = datetime.now(KST).date()
start_kst = datetime.combine(target_date, datetime.min.time(), tzinfo=KST)
end_kst = start_kst + timedelta(hours=NIGHT_WINDOW_HOURS)
return start_kst.astimezone(timezone.utc), end_kst.astimezone(timezone.utc), target_date
def _is_usable_topic(envelope: dict, topic_label: str) -> bool:
"""fallback row 가 아닌 진짜 LLM 결과인지 판정."""
if envelope.get("llm_fallback_used"):
return False
if not envelope.get("country_perspectives"):
return False
if topic_label == "주요 뉴스 묶음":
return False
return True
def _compute_status(llm_calls: int, fallback_count: int, usable_count: int, has_topics: bool) -> str:
"""Plan §"Status 4-state 판정표"."""
if not has_topics or llm_calls == 0:
return "empty"
if usable_count == 0:
return "failed"
fallback_pct = (fallback_count / llm_calls) if llm_calls else 0.0
if fallback_pct >= 0.5:
return "failed"
if fallback_count > 0 or usable_count < llm_calls:
return "partial"
return "success"
def _build_topic_row(
rank: int,
cluster: dict,
envelope: dict,
historical_docs: list[dict] | None,
primary_model: str,
) -> BriefingTopic:
historical_ids = None
historical_window = None
if historical_enabled():
historical_ids = [d["id"] for d in (historical_docs or [])]
historical_window = HISTORICAL_WINDOW_DAYS
return BriefingTopic(
topic_rank=rank,
topic_label=envelope["topic_label"],
headline=envelope["headline"],
country_perspectives=envelope["country_perspectives"],
divergences=envelope["divergences"],
convergences=envelope["convergences"],
key_quotes=envelope["key_quotes"],
historical_article_ids=historical_ids,
historical_context=envelope.get("historical_context"),
historical_window_days=historical_window,
cluster_members=[m["id"] for m in cluster["members"]],
article_count=len(cluster["members"]),
country_count=cluster.get("country_count", 0),
importance_score=cluster.get("importance_score", 0.0),
raw_weight_sum=cluster.get("raw_weight_sum", 0.0),
llm_model=primary_model,
llm_fallback_used=envelope.get("llm_fallback_used", False),
)
async def _save_briefing(
briefing_date: date,
window_start: datetime,
window_end: datetime,
total_articles: int,
total_countries: int,
topic_rows: list[BriefingTopic],
llm_calls: int,
llm_failures: int,
generation_ms: int,
status: str,
) -> int:
"""briefing_date UNIQUE 충돌은 DELETE+INSERT transaction 으로 처리."""
async with async_session() as session:
await session.execute(
delete(MorningBriefing).where(MorningBriefing.briefing_date == briefing_date)
)
new = MorningBriefing(
briefing_date=briefing_date,
window_start=window_start,
window_end=window_end,
decay_lambda=LAMBDA,
total_articles=total_articles,
total_countries=total_countries,
total_topics=len(topic_rows),
generation_ms=generation_ms,
llm_calls=llm_calls,
llm_failures=llm_failures,
status=status,
)
new.topics = topic_rows
session.add(new)
await session.commit()
return new.id
async def run_briefing_pipeline(target_date: date | None = None) -> dict[str, Any]:
"""야간 뉴스 브리핑 1회 실행. cron 또는 수동 regenerate API 에서 호출.
Returns:
{briefing_id, status, total_topics, total_articles, llm_calls, llm_failures, generation_ms, regenerated}
"""
start = time.time()
window_start, window_end, briefing_date = _compute_window(target_date)
logger.info(
f"[briefing] start date={briefing_date} window {window_start} ~ {window_end} "
f"decay_lambda={LAMBDA:.4f} historical={'on' if historical_enabled() else 'off'}"
)
# 1. Load night window
docs = await load_night_window(window_start, window_end)
total_articles = len(docs)
total_countries_in_window = len({d["country"] for d in docs})
# 2. Cluster (topic-first)
clusters = cluster_global(docs)
if not clusters:
briefing_id = await _save_briefing(
briefing_date=briefing_date,
window_start=window_start,
window_end=window_end,
total_articles=total_articles,
total_countries=total_countries_in_window,
topic_rows=[],
llm_calls=0,
llm_failures=0,
generation_ms=int((time.time() - start) * 1000),
status="empty",
)
logger.info(f"[briefing] empty (no usable clusters) → briefing_id={briefing_id}")
return {
"briefing_id": briefing_id,
"status": "empty",
"total_topics": 0,
"total_articles": total_articles,
"llm_calls": 0,
"llm_failures": 0,
"generation_ms": int((time.time() - start) * 1000),
"regenerated": True,
}
# 3. (옵션) Historical candidate pool 1회 로드
historical_candidates: list[dict] = []
if historical_enabled():
hist_end = window_start # 오늘 윈도우 직전까지
hist_start = hist_end - timedelta(days=HISTORICAL_WINDOW_DAYS)
exclude = {d["id"] for d in docs}
historical_candidates = await load_historical_candidates(hist_start, hist_end, exclude)
# 4. cluster 별 LLM 호출
client = AIClient()
primary_model = client.ai.primary.model
topic_rows: list[BriefingTopic] = []
llm_calls = 0
llm_failures = 0
usable_count = 0
try:
for rank, cluster in enumerate(clusters, start=1):
selected = select_for_llm(cluster, k=SELECT_K, lambda_mmr=SELECT_LAMBDA_MMR)
historical_docs = (
retrieve_historical(cluster, historical_candidates)
if historical_enabled() else []
)
llm_calls += 1
envelope = await compare_cluster_with_fallback(
client, cluster, selected, historical_docs=historical_docs
)
if envelope.get("llm_fallback_used"):
llm_failures += 1
if _is_usable_topic(envelope, envelope["topic_label"]):
usable_count += 1
topic_rows.append(
_build_topic_row(rank, cluster, envelope, historical_docs, primary_model)
)
finally:
await client.close()
generation_ms = int((time.time() - start) * 1000)
status = _compute_status(llm_calls, llm_failures, usable_count, has_topics=bool(topic_rows))
briefing_id = await _save_briefing(
briefing_date=briefing_date,
window_start=window_start,
window_end=window_end,
total_articles=total_articles,
total_countries=total_countries_in_window,
topic_rows=topic_rows,
llm_calls=llm_calls,
llm_failures=llm_failures,
generation_ms=generation_ms,
status=status,
)
fallback_pct = (llm_failures / llm_calls * 100.0) if llm_calls else 0.0
logger.info(
f"[briefing] done id={briefing_id} status={status} topics={len(topic_rows)} "
f"usable={usable_count}/{llm_calls} fallback={llm_failures}/{llm_calls} ({fallback_pct:.1f}%) "
f"elapsed={generation_ms / 1000:.1f}s"
)
return {
"briefing_id": briefing_id,
"status": status,
"total_topics": len(topic_rows),
"total_articles": total_articles,
"llm_calls": llm_calls,
"llm_failures": llm_failures,
"generation_ms": generation_ms,
"regenerated": True,
}
+124
View File
@@ -0,0 +1,124 @@
"""Cluster 알고리즘 공통 util — digest(country×topic) / briefing(topic×country) 양쪽이 import.
추출 원칙:
- digest.clustering.cluster_country / briefing.clustering.cluster_global country 축은 caller 책임.
- 모듈은 docs list (이미 분류된 슬라이스 또는 전체) 대한 순수 greedy assign + normalize.
- LAMBDA / threshold / EMA alpha / MIN_ARTICLES caller 주입 (Phase 4 = 3 / Briefing = 2시간 ).
"""
import math
from datetime import datetime, timezone
import numpy as np
SCORE_FLOOR = 0.01
def normalize_vector(v: np.ndarray) -> np.ndarray:
norm = float(np.linalg.norm(v))
if norm == 0.0:
return v
return v / norm
def time_decay_weight(now: datetime, created_at: datetime, lambda_val: float) -> float:
"""exp(-λ · days_ago). created_at naive → UTC 가정."""
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
days = (now - created_at).total_seconds() / 86400.0
if days < 0:
days = 0.0
return math.exp(-lambda_val * days)
def adaptive_threshold_by_density(
n_docs: int,
*,
low_n: int = 50,
high_n: int = 200,
t_low: float = 0.75,
t_mid: float = 0.78,
t_high: float = 0.80,
) -> float:
"""문서 밀도 기반 동적 threshold — fragmentation / blob 동시 방어."""
if n_docs > high_n:
return t_high
if n_docs < low_n:
return t_low
return t_mid
def greedy_assign_cluster(
docs: list[dict],
*,
threshold: float,
centroid_alpha: float = 0.7,
min_articles: int = 3,
max_topics: int = 10,
now: datetime | None = None,
lambda_val: float,
) -> tuple[list[dict], int]:
"""time-decay weight 적용 + greedy cosine assign + EMA centroid + MIN drop.
Args:
docs: [{embedding: np.ndarray, created_at: datetime, ...}]. 함수가 in-place `weight` 추가.
threshold: cosine 유사도 cluster 병합 임계.
centroid_alpha: EMA 계수 (0.7 = 기존 70% 유지).
min_articles: cluster 최소 article (미만 drop).
max_topics: 상위 cluster 보존 개수.
now: 기준 시각 (default = datetime.now(UTC)).
lambda_val: time-decay λ (caller 윈도우 폭에 맞게 주입).
Returns:
(clusters, raw_cluster_count_before_drop)
clusters = [{centroid, members, weight_sum, raw_weight_sum, importance_score}, ...]
"""
if not docs:
return [], 0
now = now or datetime.now(timezone.utc)
for d in docs:
d["weight"] = time_decay_weight(now, d["created_at"], lambda_val)
docs_sorted = sorted(docs, key=lambda d: -d["weight"])
clusters: list[dict] = []
for d in docs_sorted:
v = normalize_vector(d["embedding"])
best_idx, best_sim = -1, 0.0
for i, c in enumerate(clusters):
sim = float(np.dot(c["centroid"], v))
if sim > best_sim and sim >= threshold:
best_sim, best_idx = sim, i
if best_idx >= 0:
c = clusters[best_idx]
c["centroid"] = centroid_alpha * c["centroid"] + (1.0 - centroid_alpha) * v
c["centroid"] = normalize_vector(c["centroid"])
c["members"].append(d)
c["weight_sum"] += d["weight"]
else:
clusters.append({
"centroid": v,
"members": [d],
"weight_sum": d["weight"],
})
raw_count = len(clusters)
clusters = [c for c in clusters if len(c["members"]) >= min_articles]
clusters.sort(key=lambda c: -c["weight_sum"])
clusters = clusters[:max_topics]
normalize_importance_scores(clusters)
return clusters, raw_count
def normalize_importance_scores(clusters: list[dict], *, floor: float = SCORE_FLOOR) -> None:
"""cluster.weight_sum 을 0~1 로 정규화 + floor. in-place. raw_weight_sum 보존."""
if not clusters:
return
max_w = max(c["weight_sum"] for c in clusters)
for c in clusters:
normalized = (c["weight_sum"] / max_w) if max_w > 0 else 0.0
c["raw_weight_sum"] = c["weight_sum"]
c["importance_score"] = max(normalized, floor)
+1
View File
@@ -0,0 +1 @@
"""Phase 4 Global Digest 서비스 레이어 — 7일 뉴스 batch clustering + summarization."""
+52
View File
@@ -0,0 +1,52 @@
"""Phase 4 Global Digest — country 내 topic cluster (time-decay + EMA + adaptive threshold).
알고리즘 코어는 `app/services/clustering_common.py` 추출되어 briefing 모듈과 공유.
파일은 Phase 4 고유 파라미터 (LAMBDA = ln(2)/3 , MIN 3, MAX 10) country 호출만 담당.
"""
import math
from core.utils import setup_logger
from services.clustering_common import (
adaptive_threshold_by_density,
greedy_assign_cluster,
)
logger = setup_logger("digest_clustering")
LAMBDA = math.log(2) / 3 # 3일 반감기 — 사용자 확정값
CENTROID_ALPHA = 0.7 # EMA: 기존 중심 70% 유지, 새 멤버 30% 반영
MIN_ARTICLES_PER_TOPIC = 3
MAX_TOPICS_PER_COUNTRY = 10
def adaptive_threshold(n_docs: int) -> float:
"""Phase 4 임계 (0.75 / 0.78 / 0.80). 외부 import 호환용 alias."""
return adaptive_threshold_by_density(n_docs)
def cluster_country(country: str, docs: list[dict]) -> list[dict]:
"""단일 country 의 docs 를 cluster 로 묶어 정렬 + normalize 후 반환.
공통 util `greedy_assign_cluster` 위에 country 라벨 로깅만 추가.
"""
if not docs:
logger.info(f"[{country}] docs=0 → skip")
return []
threshold = adaptive_threshold(len(docs))
clusters, raw_count = greedy_assign_cluster(
docs,
threshold=threshold,
centroid_alpha=CENTROID_ALPHA,
min_articles=MIN_ARTICLES_PER_TOPIC,
max_topics=MAX_TOPICS_PER_COUNTRY,
lambda_val=LAMBDA,
)
dropped = raw_count - len(clusters)
logger.info(
f"[{country}] docs={len(docs)} threshold={threshold} "
f"raw_clusters={raw_count} dropped={dropped} kept={len(clusters)}"
)
return clusters
+160
View File
@@ -0,0 +1,160 @@
"""뉴스 7일 window 로드 + country 정규화
- documents 테이블엔 country 컬럼이 없으므로 document_chunks.country first non-null 조인.
- chunk-level country NULL 이면 news_sources.name prefix(ai_sub_group) 매칭으로 fallback.
- 그래도 NULL 이면 drop(로그 경고).
- ai_summary / embedding NULL 이면 처음부터 제외 (재요약/재임베딩 0 원칙).
"""
from collections import defaultdict
from datetime import datetime
from typing import Any
import numpy as np
from sqlalchemy import text
from core.database import async_session
from core.utils import setup_logger
logger = setup_logger("digest_loader")
_NEWS_WINDOW_SQL = text("""
SELECT
d.id,
d.title,
d.ai_summary,
d.embedding,
d.created_at,
d.edit_url,
d.ai_sub_group,
(
SELECT c.country
FROM document_chunks c
WHERE c.doc_id = d.id AND c.country IS NOT NULL
LIMIT 1
) AS chunk_country
FROM documents d
WHERE d.source_channel = 'news'
AND d.deleted_at IS NULL
AND d.created_at >= :window_start
AND d.created_at < :window_end
AND d.embedding IS NOT NULL
AND d.ai_summary IS NOT NULL
""")
_SOURCE_COUNTRY_SQL = text("""
SELECT name, country FROM news_sources WHERE country IS NOT NULL
""")
def _to_numpy_embedding(raw: Any) -> np.ndarray | None:
"""pgvector 컬럼을 numpy array(float32)로 정규화.
raw SQL + asyncpg 조합에서 pgvector type 등록 되어 있으면
embedding '[0.1,0.2,...]' 같은 string 으로 반환된다. ORM 쓰므로
경우 직접 파싱해야 한다.
"""
if raw is None:
return None
if isinstance(raw, str):
import json
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return None
try:
arr = np.asarray(raw, dtype=np.float32)
except (TypeError, ValueError):
return None
if arr.size == 0:
return None
return arr
async def _load_source_country_map(session) -> dict[str, str]:
"""news_sources name → country 매핑 (핫픽스).
문자열 기반 매칭 단계 3에서 news_source_id FK로 교체 예정.
first-token + all-but-last-token 이중 키로 multi-word source 대응.
"""
rows = await session.execute(_SOURCE_COUNTRY_SQL)
mapping: dict[str, str] = {}
for name, country in rows:
if not name or not country:
continue
# first token: "Le", "Der", "경향신문", "NYT"
prefix = name.split(" ")[0].strip()
if prefix and prefix not in mapping:
mapping[prefix] = country
# all-but-last-token: "Le Monde", "Der Spiegel" (마지막 = 카테고리)
tokens = name.split(" ")
if len(tokens) >= 3:
source_prefix = " ".join(tokens[:-1]).strip()
if source_prefix and source_prefix not in mapping:
mapping[source_prefix] = country
# 임시 디버그 — entry 수만 로그 (mapping 전체 출력은 운영 노이즈)
# 단계 3-1 news_source_id 전환 후 이 함수 자체 삭제
import logging
logging.getLogger("digest_loader").debug(f"source_country_map: {len(mapping)} entries")
return mapping
async def load_news_window(
window_start: datetime,
window_end: datetime,
) -> dict[str, list[dict]]:
"""주어진 윈도우 안의 뉴스 documents 를 country 별 dict 로 반환.
Returns:
{"KR": [doc_dict, ...], "US": [...], ...}
"""
docs_by_country: dict[str, list[dict]] = defaultdict(list)
null_country_count = 0
total = 0
async with async_session() as session:
source_country = await _load_source_country_map(session)
result = await session.execute(
_NEWS_WINDOW_SQL,
{"window_start": window_start, "window_end": window_end},
)
for row in result.mappings():
embedding = _to_numpy_embedding(row["embedding"])
if embedding is None:
continue
country = row["chunk_country"]
if not country:
# news_sources prefix fallback
ai_sub_group = (row["ai_sub_group"] or "").strip()
if ai_sub_group:
country = source_country.get(ai_sub_group)
if not country:
null_country_count += 1
continue
country = country.upper()
docs_by_country[country].append({
"id": int(row["id"]),
"title": row["title"] or "",
"ai_summary": row["ai_summary"] or "",
"embedding": embedding,
"created_at": row["created_at"],
"edit_url": row["edit_url"] or "",
"ai_sub_group": row["ai_sub_group"] or "",
})
total += 1
if null_country_count:
logger.warning(
f"[loader] country 분류 실패로 drop된 문서 {null_country_count}"
f"(chunk_country + news_sources fallback 모두 실패)"
)
logger.info(
f"[loader] window {window_start.date()} ~ {window_end.date()}"
f"{total}건 ({len(docs_by_country)}개 국가)"
)
return dict(docs_by_country)

Some files were not shown because too many files have changed in this diff Show More