Commit Graph

862 Commits

Author SHA1 Message Date
hyungi af74312a57 Merge pull request 'Feat/backend audit r3' (#43) from feat/backend-audit-r3 into main
Reviewed-on: #43
2026-06-16 16:12:54 +09:00
hyungi 381fcfc675 ops(ci): 전체 app 부팅 스모크 (boot_smoke.py) — GPU 격리 deploy-blocker 게이트
lifespan 실 경로(init_db + 전 worker import + 전 add_job)를 prod 이미지 컨테이너 +
ephemeral PG 로 실행해 router/worker import 오류·잡 등록 오류를 검출. NAS/scheduler.start/
prewarm 3개 부작용만 중립화(prod/AI 무접촉). GPU 실측 PASS: routes=173·jobs=34·schema 361·health ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:49:28 +09:00
hyungi 3ff1d7c65d fix(migrations): R1 baseline 런타임 버그 3건 — init_db asyncpg 경로 (R1 fix)
★실제 init_db() 런타임 검증(psql migration_smoke 가 못 잡는 asyncpg 경로)에서 발견·수정:
1. baseline 덤프에 CREATE TABLE schema_migrations 포함 → init_db 가 IF NOT EXISTS 로 선-CREATE
   후 baseline 이 재-CREATE 충돌. --exclude-table=schema_migrations 재덤프(init_db 가 소유).
2. baseline 은 multi-statement 인데 exec_driver_sql(asyncpg prepared)은 multi-statement 불허
   ('cannot insert multiple commands into a prepared statement'). raw asyncpg simple 프로토콜
   execute() 로 적재(같은 connection = 트랜잭션 내).
3. 마이그 360(10 DROP)·361(DELETE+CREATE)이 multi-statement → init_db 적용 실패. 360=콤마구분
   단일 DROP, 361=단일 CREATE UNIQUE INDEX(prod 중복0·fresh 빈테이블이라 dedup DELETE 불요).

★검증: scripts/ci/initdb_runtime_test.py 로 실제 init_db 2회 — 1st(fresh: baseline 262 스탬프 +
359/360/361 적용, documents·purge_col·cand_drop·attempt_unique 전부 확인), 2nd(멱등 skip) PASS.
psql migration_smoke 도 PASS 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:59:47 +09:00
hyungi 884ea1e669 docs(news): url normalizer 채널별 의도적 divergence 명시 — 통합 금지 박제 (R11b)
audit 의 dup-url-normalizer-divergent 는 design intent 오탐: news._normalize_url 은 query-식별
사이트(hada.io?id=·HN item?id=) 별개 기사 붕괴 방지 위해 보수적(query 보존·sort/trailing-slash/
소문자화 안 함), file_watcher._canonicalize_url 은 web_clip dedup 위해 공격적 정규화 — 채널별
의도된 차이. 통합하면 news dedup 가 깨진다(docstring 경고). 두 함수 docstring 에 상호 cross-ref +
'통합 금지' 명시해 미래 잘못된 통합 차단. 동작 변경 0(주석만).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:40:56 +09:00
hyungi 523c509954 refactor(news): 3 fetch Document 빌드 _build_news_doc 헬퍼 통합 (R11c)
_fetch_rss/_fetch_api_guardian/_fetch_api_nyt 의 22필드 Document 빌드가 정적 동일
(필드키 22개 동순서 실측) — 채널별 차이는 body(NYT=summary)·extractor_version·ident(category
계산)뿐이라 인자화. _build_news_doc 헬퍼로 통합 = 동작 보존(정적 검증). news_collector
god-file 중복 30줄×3 → 1 헬퍼.

검증: py_compile 통과, doc=Document( 직접빌드 0건. ★채널별 ingest smoke(staging)로 3 경로
동등 확인 권장.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:39:27 +09:00
hyungi 205a7bf3d5 fix(study): attempt (quiz_session_id, study_question_id) partial UNIQUE (R9)
submit_attempt FOR UPDATE(3ba9537) 1차 방어에 더해 DB 레벨 belt-and-suspenders — 모바일
더블탭/재시도가 어떤 경로로든 이중 attempt INSERT 에 도달해도 차단. prod 실측 중복 0
(GROUP BY HAVING count>1 = 0)이라 안전 — dedup DELETE 멱등 precaution + partial UNIQUE
(quiz_session_id IS NOT NULL). 세션 외 직접입력(NULL)은 비대상.

검증: migration_smoke PASS(post-baseline 361 적용). ★FOR UPDATE 가 정상경로선 막으므로
이 제약은 거의 트리거 안 됨 — 트리거 시 IntegrityError→500(should-never-happen 가시화);
graceful 409 변환이 필요하면 submit_attempt 에 try/except 추가 가능(별도).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:36:24 +09:00
hyungi 4d5f35b26e refactor(news): 3 fetch 공통 존재체크 _already_ingested 헬퍼 추출 (R11c)
_fetch_rss/_fetch_api_guardian/_fetch_api_nyt 가 복제하던 동일 존재체크
(file_hash 또는 edit_url.in_([normalized,link]) 매칭) 를 단일 헬퍼로 — byte-identical
블록이라 동작 100% 보존. news_collector god-file 중복 일부 감소.

(채널별 Document 빌드 30줄 3중복 통합은 채널별 필드 차이 검증 필요 → staging/별도.)
검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:30:47 +09:00
hyungi df4b07d29c refactor(library): facet-counts 4블록 헬퍼 추출 — 중복 제거 (R10)
company/topic/year/doctype 4 facet 집계 블록이 거의 동일 복붙(각 base_query 재구성 +
다른 3축 필터 적용). 적용된 facet 필터를 applied dict 로 모으고 '자기 자신 축 제외' 헬퍼
_facet_count(name, col, order_by, value_fn)로 추출 — 쿼리/자기제외/order_by(year=desc·others=count)/
value 매핑(year=str) 모두 동일 보존. 동작 무변경(staging 에서 facet 카운트 동등성 확인 권장).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:28:33 +09:00
hyungi 3729083dc0 perf(search): synthesis 캐시 TTL enforce + chunk news_source DB 필터 (R10)
- synthesis_service: _CACHE 가 ts 없이 result 만 저장해 CACHE_TTL(1h) 미적용 → 원문 수정돼도
  CACHE_MAXSIZE 찰 때까지 stale answer 반환. (ts, result) tuple + get_cached 에서 만료 pop
  (query_rewriter expire_at 정본 복제).
- chunk_worker: 문서마다 news_sources 전량 로드 후 Python prefix 루프 → DB 필터 푸시다운
  ((name==source_name) | startswith(source_name+' ')). split[0]==source_name 과 동치, autoescape.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:24:03 +09:00
hyungi 455a5a66ff fix(classify): ai_suggestion library 블록 is None 가드 — material 제안 clobber 방지 (R9)
거래문서(LIBRARY_SUGGESTION_DOCTYPES) 제안이 doc.ai_suggestion is None 체크 없이 덮어써,
material 제안 블록(material_type 제안)이 이미 점유한 ai_suggestion 을 clobber 하던 비대칭.
material 블록과 동일하게 is None 가드 추가 — 주석의 '기존 제안 우선' 사상 일치.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:19:20 +09:00
hyungi 124b50af53 perf(events): list_events total 을 DB COUNT 푸시다운 (R10)
전체 Event.id 를 메모리 로딩 후 len() 하던 것을 select(func.count(Event.id)) 로 전환 —
행 수에 선형이던 메모리/전송 비용 제거. 결과 동등(단순 카운트라 golden-diff 불요).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:18:12 +09:00
hyungi 0d3c841577 feat(migrations): 스키마 baseline 스냅샷 — fresh-DB/DR 부팅 fix (R1)
R0 가 입증했듯 migrations/ 전체 replay 는 011(view active_documents 가 documents.embedding
의존, DROP COLUMN CASCADE 부재)·326(enum-same-txn) 등 누적 비-replayable 로 깨져 신규/DR
환경 init_db 부팅이 불가능했다. 표준 squash baseline 로 해소:
- migrations/_baseline/0358_schema_baseline.sql: prod 스키마 스냅샷(pg_dump --schema-only
  --no-owner --no-privileges, psql 메타·search_path='' 정리 = asyncpg exec_driver_sql 호환).
- init_db._load_baseline_if_fresh: documents 테이블 부재(fresh) 시 baseline 적재 +
  schema_migrations 1..358 스탬프 → 이후 post-baseline(359/360)만 적용. ★기존 DB(documents
  존재)는 skip = prod 무영향(additive). baseline 부재 시 기존 replay 경로(하위호환).
- migration_smoke: baseline 경로 검증. ★실측 — 이전 FAIL(011 abort) → 이제 FRESH/INCREMENTAL
  모두 PASS (pg16.14). cutoff(_BASELINE_CUTOFF=358) 갱신 시 baseline 재생성.

검증: py_compile + migration_smoke PASS. ★boot-path 변경이라 deploy 전 staging 부팅 검증 필수.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:16:21 +09:00
hyungi 690b22fe58 fix(hardening): collect-lock TOCTOU 제거 (R9) + tier_backfill fstring allowlist (R12)
- news.collect: locked() 체크 후 실제 acquire 가 별도 task 안에서 일어나 그 사이 다른 요청이
  끼어들어 이중 수집 task 가 생기던 TOCTOU. 핸들러에서 동기 acquire + task finally release 로 원자화.
- tier_backfill._enqueue_domain: filter_clause 가 SQL 에 직접 보간되나 allowlist 가드 부재
  (retrieval_service _VALID_DOCS_TABLE 정본 대비 비대칭). DOMAIN_PRIORITY 출처 allowlist final
  gate 추가 — 현재 모듈 상수라 injection 0 이나 외부 입력화 시 즉시 차단.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:07:07 +09:00
hyungi 3565ef9ac4 fix(digest): daily_digest 산출물 이모지 제거 — no-emoji 규칙 (R11a)
실패 강조 라인의 ⚠️ → **[주의]** 텍스트 마커. 산출물(다이제스트 markdown) no-emoji 준수.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:03:31 +09:00
hyungi 719c35afbc refactor(models): ai_tags/user_tags 공유 가변 default 제거 + 주석 정정 (R11a)
- ai_tags: 주석/Mapped 타입이 dict 인데 실제 list 적재 → list 로 정정.
- ai_tags/user_tags: default=[] (정의 시점 1회 평가되는 공유 가변 인스턴스) → default=list
  (callable, 행마다 새 리스트). SQLAlchemy column default 관용 idiom.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:02:56 +09:00
hyungi e664d7b187 perf(setup): setup 미들웨어 user COUNT 캐시 — per-request 쿼리 제거 (R10)
setup 완료 후에도 모든 비-bypass 요청이 select count(User.id) 를 실행하던 per-request
비용. 셋업 완료(user 존재)는 monotonic 이라 1회 확인 후 _setup_complete 플래그로 영구
skip(이후 요청 DB 쿼리 0). global 선언은 함수 첫 줄(read+assign 혼용 UnboundLocalError 방지).

R10 잔여(library-tree jsonb 집계 golden-diff·facet-counts·events-count·synthesis cache TTL)는
결과 동등성 검증 동반이라 후속. 검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:01:25 +09:00
hyungi 3ba9537515 fix(study): submit_attempt FOR UPDATE 행 잠금 — 동시 이중제출 race 차단 (R9)
quiz_session 을 session.get(잠금 없음)으로 읽어 모바일 더블탭/재시도 시 동시 제출 둘 다
cursor=N 을 보고 cursor+1·correct/wrong/unsure count 를 이중 가산하던 race. select +
with_for_update() 로 행 잠금 → 직렬화. 두 번째 제출은 첫 commit 후 cursor=N+1 을 읽고
cursor 위치 불일치 409 로 거부된다.

belt-and-suspenders 인 attempt UNIQUE 제약은 기존 중복 dup-backfill 마이그가 선행조건이라
별도(R9 후속). 검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:59:35 +09:00
hyungi d58565ef38 refactor(search): Phase 2A cand 슬러그·테이블 제거 (R13)
Phase 2A 임베딩 후보(me5_large_inst·snowflake_l_v2·qwen06·qwen4·qwen4m) no-go 종결
(2026-06-12, 후보 전부 -0.03~-0.04) + phase2a_cand_backfill 워커 dormant(미스케줄·미import).
- retrieval_service.CANDIDATE_BACKEND_MAP: 5 cand 엔트리 제거(baseline 만 잔존) — read-path
  슬러그를 먼저 빼야 embedding_backend=cand_X /search 가 dropped 테이블 읽어 500 안 남.
- api.search allowed 하드코딩 리스트 → ["baseline"] (R12 search-error-allowed dangling 동반 제거).
- phase2a_cand_backfill.py 삭제(dead code, 드롭될 테이블 참조 — R12 config-bypass 동반 해소).
- 마이그 360: cand 10테이블 DROP TABLE IF EXISTS(멱등, 환경별 존재차 흡수).

검증: py_compile 통과, 슬러그 잔존 참조 0. migration txn 제어문 없음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:56:42 +09:00
hyungi 70f90bc914 fix(digest): daily_digest KST↔UTC 경계 정렬 + blocking I/O off-thread (R8)
- func.date(created_at) == today(KST) 비교는 pg TimeZone(UTC) 기준 날짜라 KST 0~9시
  생성 문서(UTC 전날)가 오늘 다이제스트에서 누락되던 경계 버그. KST 하루를 UTC 범위
  (start_utc~end_utc)로 변환해 created_at(UTC저장) 범위 비교로 전환(3곳).
- NAS 저장/glob/stat/rename blocking 파일 I/O 를 _write_and_rotate 헬퍼 + asyncio.to_thread
  오프로드(이벤트 루프 점유 방지, R5 일관).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:53:08 +09:00
hyungi 688532b1fa fix(briefing): held→409 표면화 + study attempt naive datetime→UTC (R8)
- briefing.regenerate: held(정책상 정상 보류)를 digest.py 정본처럼 409 로 표면화. 이전엔
  briefing_worker.run() 이 held/timeout/exception 셋 다 None 반환 → API 가 셋 다 500 으로
  오보(silent-state-conflation). 진입부 'briefing' in pipeline_held_stages 가드.
- study_question.answered_at: naive default datetime.now → lambda datetime.now(timezone.utc).
  컨테이너=UTC 실측이라 값 동일·백필 불요, 컨테이너 TZ 바뀌면 9h 어긋나던 잠복 의존 제거.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:51:42 +09:00
hyungi 3a22d225a0 feat(documents): delete_file=true 큐드-감사삭제 — purge 마커 + retention sweep (R7)
delete_file 파라미터가 광고만 하고 본문에서 0회 참조(soft-delete만, 파일 영구 잔존 +
프론트가 실제 호출)되던 거짓 계약 구현. (c) 큐드삭제:
- 마이그 359: documents.purge_requested_at 컬럼(ADD COLUMN IF NOT EXISTS, replayable).
- delete_document: delete_file=true 시 purge_requested_at 마커 set(deleted_at 과 별도).
- document_purge_sweep cron(03:20 KST): purge_requested_at + grace(30일) 경과 + 파일 존재
  시 NAS 원본 unlink + AUDIT 로그. ★sweep 는 deleted_at 아니라 purge_requested_at 기준 —
  일반 숨김(delete_file=false)은 파일 보존(undelete 가능), 명시 purge 만 물리삭제(데이터 안전).
- DELETE 요청 경로엔 동기 비가역 op 0. 파일 존재 체크로 멱등. unlink 는 to_thread(R5 일관).

검증: py_compile 통과. migration txn 제어문 없음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:48:25 +09:00
hyungi 8a625bfb27 fix(security): soft-delete 가드 구조화 — get_live_document 헬퍼 + paper-holder (R7)
조회/수정 경로는 deleted_at 을 일관 가드하나 파일/콘텐츠 서빙 5엔드포인트
(get_document_file·image_raw·save_content·preview·content)가 'if not doc' 만 검사 →
삭제 문서 원본/preview/전문/마커이미지가 doc_id(+토큰)만으로 노출·삭제 문서 NAS 재기록.
get_live_document(session, doc_id) 헬퍼(없거나 deleted_at 이면 404)로 통일 — '경로마다
deleted_at 기억' 대신 구조 강제(추가될 서빙 경로 자동 보호). save_content 는 삭제 문서
쓰기 차단까지. find_paper_holder 도 deleted_at IS NULL 필터 추가(dedup.find_canonical 대칭).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:45:33 +09:00
hyungi 844a5e0204 fix(security): internal 토큰 상수시간 비교 + memo tag 파라미터 바인딩 (R7)
- internal_study._verify_token: != 비교는 첫 불일치 단락으로 prefix 길이 timing
  side-channel(RAG 정답 endpoint 보호 토큰) → hmac.compare_digest(search.py 정본 일치).
- memos tag 필터: f-string 으로 사용자 tag 를 JSON 배열 리터럴에 직접 삽입 → tag 안
  "/] 가 JSON 깨 500 + 필터 변형. func.jsonb_build_array(tag) 바인드 파라미터로.

검증: py_compile 통과. R7 나머지(get_live_document·paper-holder deleted_at·delete_file
purge 마커+retention sweep·fetch-page·save-content)는 이어서.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:40:35 +09:00
hyungi 456dfaa9f2 fix(ai): _call_chat 무동의 Claude egress 자동폴백 제거 (R6)
primary(맥미니) Timeout/ConnectError 시 동의·과금 통제 없이 ai.fallback(Claude API)으로
자동 전환 → 개인 문서/쿼리/메모가 Anthropic 으로 silent egress 되던 프라이버시 결함 봉쇄.
실패는 전파 — 배치 워커는 재시도/StageDeferred(R3), interactive 는 호출자 5xx 표면화
(documents.analyze 이미 502/504). 클라우드는 premium explicit-trigger / call_fallback
명시 호출로만 (자동 진입 금지).

참고: uncoordinated-mlx-semaphores 는 gitea/main 최신에서 digest/briefing 이 이미
acquire_mlx_gate 사용(감사 20커밋 stale 탓 오탐) — 변경 불요. rerank silent-identity 의
rerank_skipped notes 플래그는 시그니처 변경 동반이라 별도 후속(Low).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:38:46 +09:00
hyungi cb7c0fdc4f fix(workers): blocking I/O off-thread — watch_inbox·getaddrinfo·file stream (R5)
AsyncIOScheduler 가 FastAPI lifespan 과 같은 이벤트 루프를 공유하는데 동기 blocking
I/O 가 루프를 점유 → 같은 루프의 모든 1분 주기 consumer + FastAPI 요청 동시 정지.
- watch_inbox: NFS rglob walk + GB 파일 SHA-256(file_hash)을 asyncio.to_thread 오프로드.
  스캔 루프가 순차라 file_hash 직렬화 유지(병렬 해싱 X = NFS 2.5GbE 대역폭·메모리 blowup 방지).
- news create_source: validate_feed_url 의 getaddrinfo(blocking DNS) off-thread.
- storage/local stream: 청크 f.read off-thread.
marker_worker/mailplus to_thread 컨벤션 재사용. daily_digest blocking 은 R8(TZ)과 한 패스.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:35:44 +09:00
hyungi 2e19dc3d37 fix(collectors): kosha 부분실패 per-case commit — 전체 rollback 방지 (R4)
kosha run() 이 소스별 단일 세션으로 collector 전체를 돌리고 예외 시 rollback →
페이지 _api_get 실패가 앞서 적재한 케이스/항목을 전부 폐기(부분 적재 손실 + 매번
같은 지점 실패 시 영구 미적재). disaster_cases/fatal_accidents/guide 의 케이스·항목
단위로 session.commit() 경계 추가(csb/api_standards idiom) — 실패 이전 적재분 보존,
dedup 으로 다음 run 이 이어받음. 첨부 실패는 기존대로 격리(변경 없음).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:32:07 +09:00
hyungi 2ad32c5c84 fix(collectors): 워터마크 cap 절단 시 미전진 — silent backlog loss 차단 (R4)
arxiv/openalex 수집기가 run_cap 도달로 카테고리/시드 중도 절단돼도 워터마크를
newest 로 전진시켜, [oldest-ingested, 옛 watermark] 사이 미적재 항목이 다음 run 의
watermark 필터에 영구 배제되던 silent data loss 수정.

capped 플래그: cap 으로 루프 절단 시 set → 워터마크 미전진. 미전진하면 다음 run 이
최신부터 재스캔하며 적재분은 dedup-skip(cap 미소모)하고 gap 까지 내려가 이어 적재
→ 백로그 run 당 cap 소화(livelock 회피). 정상 완주(watermark 도달/cursor 소진) 시에만
전진. bulk(CLI)은 cap 무관. docstring 의 '다음 run 이월' 약속을 실제 동작과 일치.

검증: py_compile 통과. kosha 부분실패 per-case commit 은 R4 후속.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:28:04 +09:00
hyungi c11f113cf1 fix(workers): silent completion 차단 — transient re-raise + enqueue 격리 (R3)
worker_fn 이 transient 실패를 삼켜 정상 반환하면 queue_consumer 가 status=completed
로 확정 → 영구 데이터 손실 + 재시도/추적 0. 정본(extract/marker/fulltext/stt 는
re-raise)과 어긋난 곳을 통일:
- deep_summary: 호출 실패(call_failed)를 삼키지 않고 raise → 재시도→failed dead-letter
  (이전엔 ai_detail_summary 영구 누락 + tier triage 고착).
- thumbnail: _extract_thumbnail 실패를 silent return → raise (썸네일 영구 누락 방지).
- queue_consumer: 완료 커밋 후 enqueue_next_stage(정상·skip-note 2곳)를 자체 try 로
  격리 — enqueue 실패가 outer except 로 전파돼 completed 항목을 재오픈(stage 재실행)
  하던 결함 차단. 실패는 ERROR 로 가시화.
- broad except 에 asyncio.CancelledError 명시 통과(embed worker / ask classifier·verifier).

dead-letter = ProcessingQueue.status='failed'(기존 attempts/max_attempts 머신 재사용,
신규 컬럼 불필요). 검증: py_compile 통과. 큐 재시도 의미 synthetic smoke(staging) 예정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:24:25 +09:00
hyungi 9c22337647 fix(search): 공유 AsyncSession 동시 쿼리 직렬화/세션 분리 + rewrite axis 누락 (R2)
asyncio.gather 가 단일 AsyncSession 에 동시 execute 를 진입시켜 부하 의존적
'another operation in progress' 비결정 크래시 (정상 순차 경로에서만 검증돼 잠복).
사이트별 처방(균일 처방 회피):
- search_with_rewrite._variant_retrieve: variant 마다 독립 async_session() fan-out
  (사용자 대면 — N variant 병렬 유지)
- study explanation_rag / subject_note_rag: 백그라운드 prefetch 라 순차 직렬화
  (rerank 도 순차 — DB 순차+rerank gather 분할은 _gather_* 4곳 침습이라 보류,
   배경 작업의 rerank 병렬 이득 미미)

추가: rewrite(multi-query) 경로가 axis 필터(material_type/jurisdiction/year)를
single-query path 와 달리 조용히 누락 — search_with_rewrite 에 axis 인자 + _variant_retrieve
가 search_text/search_vector 에 전달.

검증: py_compile 통과. 동시 N variant 부하 테스트(staging)로 크래시 소거 확인 예정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:18:17 +09:00
hyungi d8ad097a3a ops(migrations): fresh-DB/DR replay·enum 스모크 게이트 (R0)
init_db 의 단일 트랜잭션 적용 경로(engine.begin)를 미러해 migrations/ 전체가
빈 DB / DR(pre-320 → catch-up) 업그레이드에서 한 트랜잭션으로 적용 가능한지 검증.
pg16(pgvector/pgvector:pg16) 핀, ephemeral 컨테이너 자동 기동/정리.

현재 두 시나리오 모두 011_embedding_1024 에서 FAIL — view active_documents 가
documents.embedding 의존(DROP COLUMN CASCADE 부재). enum(326) 이전 지점.
fresh replay 가 한 번도 검증된 적 없어 누적 비-replayable cruft 다수 확인.
R1(스키마 baseline 스냅샷)으로 fix 후 PASS 가 게이트 기준.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:11:55 +09:00
hyungi 3a780c0d06 Merge pull request 'feat(review): 검토 대기 자동검토 워커 — 고신뢰 자동승인 + 저신뢰 잔류' (#42) from feat/auto-review-pending into main
Reviewed-on: #42
2026-06-15 16:33:25 +09:00
hyungi ac7de71ecd feat(review): 검토 대기 자동검토 워커 — 고신뢰 자동승인 + 저신뢰 잔류
auto_review_worker(interval 3분·배치 300): review_status='pending' + ai_domain
+ ai_confidence>=0.9 인 문서를 review_status='approved' 자동승인 + audit
(source_metadata.auto_reviewed). 저신뢰/미분류는 수동 큐 잔류. 재-LLM 호출 없음
(classify confidence 게이트 = 맥미니 부하 0). review_status 는 검색/RAG/digest 필터
미사용(게이트 실측) → 노출 변동 없이 검토 큐만 드레인. 되돌리기=audit 마커로 식별.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:36:56 +09:00
hyungi 35d7c7eab7 Merge pull request 'Feat/memo to document' (#41) from feat/memo-to-document into main
Reviewed-on: #41
2026-06-15 15:21:57 +09:00
hyungi ffe4c776e9 Revert "feat(viewer): md 본문 외부 링크 새 탭 + rel 보안"
This reverts commit 60f3b25.

병렬 세션이 동일 P0(외부 링크 새 탭+rel)를 feat/memo-to-document 브랜치에
docMarkdown.ts link 렌더러 + ADD_ATTR 방식으로 이미 구현(SSR 적용·memos 번들).
중복 회피 위해 본 $effect 구현(redundant)을 canonical 에서 되돌리고 그쪽에 양보.
분석 산출물/측정 결과는 PKM learning 문서로 기록 보존.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:17:18 +09:00
hyungi 60f3b259df feat(viewer): md 본문 외부 링크 새 탭 + rel 보안
문서 본문 markdown 의 외부 http(s) 링크(코퍼스 실측 521문서/9,496개)가
target/rel 없이 같은 탭으로 열려 SPA 를 이탈하던 문제 수정.

MarkdownDoc 에 heading-anchor 와 동일한 DOM 후처리 $effect 추가 —
sanitize 후 라이브 DOM 의 a[href^=http(s)] 에 target="_blank" +
rel="noopener noreferrer" 부여. marked 렌더러/DOMPurify(전역 hook)·
ADD_ATTR 무수정. 앵커(#)·상대경로·mailto 는 미변경(SPA 내부 항법 보존).

내부 위키링크([[...]])·백링크 그래프는 코퍼스 실측상 실신호 ~8개로
데이터 미지원이라 본 PR 범위에서 제외(보류, 내부 링크 증가가 트리거).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:07:39 +09:00
hyungi fabbca64e9 feat(markdown): 외부 링크 새 탭 + rel=noopener noreferrer (P0)
docMarked link 렌더러: http/https 링크에 target=_blank rel=noopener noreferrer
(탭내빙 차단, 코퍼스 521건). 내부/'#'프래그먼트/상대/mailto 는 무손 — outline
gfmHeadingId 경로 유지(클릭 인터셉터 없음=충돌 0). marked15 토큰객체 시그니처.
SANITIZE_OPTS ADD_ATTR 에 target/rel.

load-bearing 게이트: 상대 .md=코퍼스 0건·doc_key 부재 → path→id prop/document_links
미구현(dead). [[..]]=13건 대부분 인용 노이즈([[3\]]) → resolution/스트립 미구현.
외부 링크 하드닝만 정당화됨.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:06:58 +09:00
hyungi a6d5734f6c feat(memos): 자료로 보내기 P2 — 메모→문서 26B 문서화 워커
memo_draft_worker(interval 2분): promote 가 찍은 source_metadata.needs_draft=true
문서를 26B(call_primary, acquire_mlx_gate BACKGROUND)로 구조화 마크다운(md_content)
생성. content_origin='ai_drafted'+md_draft_status='draft'(mig212 제약 준수), 원본은
extracted_text 보존. promote 엔드포인트에 needs_draft 마커 + main.py add_job.
큐 enum/컨슈머 무변경(derived-worker 패턴) = 저위험.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:50:44 +09:00
hyungi fe8235d726 feat(memos): 자료로 보내기 — 메모를 문서함 정식 문서로 승격 (P1)
새 POST /memos/{id}/promote-to-document: in-place 승격(별 row X) —
source_channel→manual, file_type note→editable, category=library,
content_origin=manual + classify/embed/chunk 재큐(도메인 재부여·요약·심층분석).
메모 카드에 always-visible '자료로 보내기' 버튼(지식 메모=ai_event_kind note 포함).
P2(거친 메모→구조화 마크다운 draft 워커)는 후속.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:32:04 +09:00
hyungi 4927c585c7 Merge pull request 'Fix/md render katex tldr' (#40) from fix/md-render-katex-tldr into main
Reviewed-on: #40
2026-06-15 14:17:44 +09:00
hyungi b0a73f8506 feat(nav): 상단 nav 질문·이드 제거 + 메모 추가
데스크탑 상단 nav 와 모바일 하단 탭바 모두에서 질문(/ask)·이드(/chat) 링크 제거,
메모(/memos) 추가(모바일은 기존 존재). 라우트 코드는 보존(nav 노출만 제거).
미사용 아이콘 import(HelpCircle·MessageCircle) 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:16:23 +09:00
hyungi 2d6d1b8e8a fix(markdown): 수식 pre-render(katex 직접) + TL;DR 마크다운 렌더
본문 $$수식$$가 raw로 노출되던 문제: marked-katex 토크나이저가 개요 anchor
splice/런타임 환경 영향으로 미발화 → marked 이전에 katex.renderToString 으로
직접 렌더 후 placeholder 복원(위치·인접 무관). TL;DR(ai_tldr)도 plain-text
보간이라 마크다운 미렌더 → renderDocMarkdown 경유로 교체(+summary-md 스타일).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:05:35 +09:00
hyungi 4c111ca7f2 fix(observability): BackgroundJobItem 응답 모델에 machine 필드 추가 (직렬화 누락 수정)
f325bd0 이 서비스 payload·frontend 타입엔 machine 을 넣었으나 API Pydantic
response_model(BackgroundJobItem)에 누락 → FastAPI 가 직렬화 시 탈락. 한 줄 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 03:43:55 +00:00
hyungi f325bd0509 feat(observability): digest/briefing 을 처리 보드에 맥미니 작업으로 노출 (background_jobs)
큐 밖 cron 생성 작업(global_digest/morning_briefing)이 processing_queue stage 가
아니라 보드에 안 잡혀, 맥미니가 11분짜리 digest 를 돌려도 idle 처럼 보였다.
ebbcaf8 의 background_jobs 메커니즘 재사용:
- digest_worker/briefing_worker = start_job→finish_job (best-effort, 본작업 무해)
- pipeline = cluster 완료마다 heartbeat(processed/total) → 진행바
- queue_overview = kind→machine 맵으로 payload 에 machine 필드 (맥미니 귀속)
- 보드 = 머신 레인에 dot 점등 + "생성 중: <label> N/T" 표시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 03:36:57 +00:00
hyungi d4e1f76e81 fix(news)!: mlx_gate_concurrency 4→2 롤백 — gate=4 가 대형 프롬프트(digest/briefing+deep 6764tok) 동시성으로 맥미니 mlx_vlm OOM/연결드롭 유발(08:45 서버 크래시·재시작 실측). digest cap 3000→5400(gate=2 보정). timeout/deep-split 유지
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:55:44 +00:00
hyungi a82b0724df fix(news): digest/briefing 생성 LLM 타임아웃 게이트 단일소스화 + deep_summary 컨슈머 분리
2026-06-11 맥미니 모델 교체(Gemma4 26B→Qwen3.6-27B-6bit, 콜당 ~90~300s)의
타임아웃 상향 sweep 이 config.yaml/synthesis 만 갱신하고 digest/briefing 코드의
하드코딩 LLM_CALL_TIMEOUT=25(빠른 Gemma 기준)를 누락 → digest 600s 하드캡 초과로
06-10 이후 미생성, briefing 4/4 LLM 폴백(status=failed). (적대 리뷰로 블로커 정정:
concurrency=1 사설 세마포로는 digest 44~68 클러스터가 하드캡에 여전히 걸림 + llm_gate
영구 룰 위반.)

- 타임아웃·재시도·하드캡을 config.pipeline 단일소스로 이관(digest_llm_timeout_s=300,
  attempts=2, pipeline_hard_cap_s=3000). 다음 모델 교체 때 재발 차단.
- digest/briefing LLM 호출을 사설 Semaphore 제거하고 전역 MLX gate(BACKGROUND)
  경유로 변경 — llm_gate 영구 룰(같은 endpoint 단일 게이트, 새 Semaphore 금지) 준수 +
  ask/eid(FOREGROUND)와 조율. 동시성 lever = 기존 mlx_gate_concurrency 2→4
  (continuous batching 실측 — 3동시콜 wall 121s ≈ 단일콜, 직렬 대비 ~3배).
- digest/briefing pipeline cluster 루프를 asyncio.gather 동시 실행으로 전환
  (실동시성은 게이트가 제한, rank/순서 보존).
- deep_summary(70~300s)를 메인 consume_queue 에서 분리해 consume_deep_queue 신설
  (markdown/fast split 선례) — 단일 deep 호출이 1분 틱 초과로 메인 큐를 영구 coalesce
  시키던 문제 제거.
- 죽은 PIPELINE_HARD_CAP=600(briefing/pipeline.py) 제거, summarizer docstring 갱신,
  deep 컨슈머 disjoint/hold 테스트 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:29:56 +00:00
hyungi b2949d26ff fix(search): documents.embedding HNSW 인덱스(마이그 358) + ef_search=100 — docs vector leg seq scan 제거
documents.embedding 에 벡터 인덱스가 없어 검색마다 40k row Parallel Seq Scan
(콜드 448ms, 코퍼스 성장에 선형 악화)이었음. study_questions 와 동일 패턴의
HNSW 부분 인덱스 추가 → docs vector leg 448ms → 7.9ms (EXPLAIN Index Scan 확인).

docs vector leg LIMIT=limit*4(기본 80)이라 HNSW recall 위해 ef_search>=80 필요 →
ivfflat.probes 와 동일하게 ALTER DATABASE pkm SET hnsw.ef_search=100.

PROD 적용: CREATE INDEX CONCURRENTLY 로 수동 빌드(무중단, /dev/shm 회피 위해
max_parallel_maintenance_workers=0 단일 스레드, 316MB) + schema_migrations(358)
수동 기록. runner 는 단일 트랜잭션이라 CONCURRENTLY 불가 → 본 파일은 fresh-init
재현용 non-concurrent IF NOT EXISTS.

검증(snapshot freeze 43958/195671, eval both, exact vs HNSW):
- graded NDCG 0.575 → 0.575 (±0.000, 전 카테고리·Recall byte-identical)
- ef_search=100 이 top-80 에 사실상 exact recall → 랭킹 손실 0
- prod smoke 'pressure vessel design code ASME' 작업전 5263ms → 650ms

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 23:18:36 +00:00
hyungi 151c1ee518 fix(search): text-leg 본문 스코어링 2000자 절단 + bge-m3 keep_alive 로 검색 latency 개선
코퍼스 ~52배 성장(코드 가정 765 → 실제 40k docs) 후 search_text ORDER BY 가
후보 행마다 extracted_text(평균 3.7KB·최대 1.6MB) 전체에 similarity() +
to_tsvector() 재토큰화를 재연산 → broad/영어 쿼리 text_ms 최대 4960ms.
scoring/match_reason 의 extracted_text 를 left(...,2000) 으로 절단(후보 CTE 의
FTS 매칭은 전체 본문 유지 → recall 불변). embed() 요청에 keep_alive:-1 추가로
ollama bge-m3 GPU 상주 → sparse 검색의 cold reload(~6s) 제거.

검증(snapshot freeze docs 43958/chunks 195671, 51 case, eval-version both):
- graded NDCG 0.575 → 0.575 (±0.000, 전 카테고리 byte-identical)
- Recall g>=2 0.691 / g>=3 0.739 불변, v0.1 NDCG/Recall/Top-3 불변
- latency p50 760→586ms (-23%) / p95 5230→832ms (-84%)
- EXPLAIN 단일쿼리: V0 4917ms → left(2000) 285ms (17x)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 04:34:24 +00:00
hyungi ebbcaf86d8 feat(observability): 큐 밖 백그라운드 작업(backfill)을 처리 머신 보드에 노출
processing_queue 는 파이프라인 stage 전용이라 hier_overnight_backfill 같은 off-queue
관리 스크립트 작업이 대시보드 보드에 안 잡혀, 다른 세션이 모르고 fastapi 를 재생성해
in-flight 재분해를 끊는 사고가 발생(2026-06-14). 사각지대 해소.

- migrations/357_background_jobs.sql: background_jobs 테이블(kind/label/state/processed/
  total/heartbeat). worker_jobs(user_id 필수, worker-pool 전용)와 별개.
- services/background_jobs.py: start/heartbeat/finish 헬퍼 — 자율 트랜잭션(즉시 commit →
  실시간 가시화) + best-effort(관측 실패가 본작업 안 깸).
- hier_overnight_backfill: 작업 시작/절 ~10개마다 heartbeat/종료 계측.
- queue_overview: /api/queue/overview 응답에 background_jobs 추가(running + 최근 6h 완료,
  stale=heartbeat 끊김 추정). SAVEPOINT 로 테이블 부재/오류 시 보드 본체 무영향.
- ProcessingFlowBoard: "백그라운드 작업" 패널(진행/경과/state, stale 끊김 경고).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 12:27:18 +09:00
Claude Code 6d978289b8 feat(papers): B-3 P2-PR1 oa_url 승격 분기 (arXiv 외 doi.org/KISTI/PMC OA)
arxiv_id 없는 OA 논문(oa_status gold/hybrid/green/diamond + oa_url)도 전문 승격 대상에 포함.
url = arxiv.org/pdf 또는 oa_url(friendly OA host). paywall/비-PDF 는 헤더검증서 skip(실패 격리).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:16:47 +00:00
Claude Code 73c6f123b8 feat(papers): B-3 P2-PR1 — arXiv 논문 전문 in-place 승격 + classify paper 요약-스킵
plan safety-library-b3-1 Phase-2. 논문을 초록(signal-only)에서 전문 md/검색으로 승격.
- paper_fulltext_promote.py: 미승격 arXiv 논문(file_format='article') → arxiv.org/pdf/{id} 다운로드
  (kosha 패턴·50MB cap·PDF 헤더검증) → NAS crawl_raw/papers/arxiv/ → in-place 갱신
  (file_format=pdf·file_type=immutable·file_path·md_status=pending, file_hash·extract_meta.paper 보존)
  → 'extract' enqueue. 1-Document(2행 분리 회피, 기존 display 스택 재사용). per-run cap 10(GPU 보호).
  arXiv=공개 프리프린트라 전문 검색/RAG 무난(restricted 불요; 유료 구매분만 Papers_Purchased restricted).
- classify_worker: material_type='paper' 가드 추가 — 요약/분류 LLM 스킵(맥미니 큐 무접촉),
  queue_consumer 가 embed/chunk/markdown 은 chain. law_monitor 스킵 패턴 동형.

CLI 전용(Phase-2 deliberate 승격·GPU 부하 사용자 통제). 파이프라인=extract→classify[skip]→embed/chunk/markdown,
marker 표시 md + hier 절구조 + 전문 검색 청크. 배포 후 라이브 검증.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:04:02 +00:00