Commit Graph

69 Commits

Author SHA1 Message Date
hyungi 51c3f6df10 feat(search): /ask/react endpoint with Qwen native tool calling ReAct loop
PR-DocSrv-Ask-ToolCalling-ReAct-1 — Qwen3.6-27B-8bit 의 native tool calling
으로 ReAct loop 도입. 기존 /api/search/ask 무수정. 트랙 B (frontend /ask SSE)
와 파일 단위 충돌 0 (search.py 의 ask() 함수 line diff = 0, 순수 추가).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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