feat(documents): S1 dedup·office-md·storage scaffold (B/C/D/E)
plan ds-s1-backend-1 잔여 구현 (A·C-1 은 16b0fe1):
- B 중복검사: services/dedup.py (OFF-list law_monitor 공용) + 업로드 채움(B-1)
+ GET /documents/duplicates(B-2) + post-upload near-dup 비동기(B-3)
+ backfill_dedup.py(B-4) + 야간 dedup_reconcile 잡(03:30 KST 멱등 재계산)
- C MD-first: marker_worker office/hwp 분기 _process_office(C-2) + md_status
상태머신 postcondition success|failed(C-5) + backfill_nonpdf_markdown.py(C-4)
+ requirements markitdown
- D 스토리지: services/storage ABC+Range 계약 / LocalBackend / NasApiBackend 503
(D-1) + /file resolver 경유, 로컬 동작 불변(D-2)
- E 운영: pre-change pg_dump + rollback_287.sql + apply runbook(E-3) + 테스트(E-1)
비파괴 불변식 유지(기존 응답 shape 무변경, md_status success→completed read-time 매핑).
어드버서리얼 리뷰 확정 1건(soft-delete canonical 승격 시 stale duplicate_of) → B-1
승격 정규화 + 야간 재계산으로 정합.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
"""기존 file_hash 중복 그룹 backfill — plan ds-s1-backend-1 B-4.
|
||||
|
||||
목적:
|
||||
A-1 migration 287 로 추가된 duplicate_of / duplicate_count 를 *기존* 중복 그룹에 채운다.
|
||||
migration(단일 트랜잭션)과 분리한 별 배치(database.py:29-30 정책 — 대량 UPDATE 를
|
||||
startup migration 에 넣지 않는다). 업로드 시점 채움(B-1)은 신규 행만 다루므로 과거는 이 스크립트.
|
||||
|
||||
판정:
|
||||
- file_hash exact 그룹(OFF-whitelist=law_monitor 제외, deleted 제외, count>1).
|
||||
near_duplicate 는 영속화 보류(on-the-fly) — 여기서 다루지 않는다.
|
||||
- canonical = 그룹 최古(min id). canonical.duplicate_of=NULL, duplicate_count=group_size-1.
|
||||
- 비-canonical 멤버 = duplicate_of=canonical, duplicate_count=0.
|
||||
|
||||
안전:
|
||||
- 멱등 — 이미 목표값인 행은 UPDATE 안 함(재실행 안전). --dry-run 이 적용될 정확한 set 미리보기.
|
||||
- --chunk(기본 500)행/txn 청크 커밋 — 28,941행 단일 트랜잭션 lock 회피.
|
||||
|
||||
실행:
|
||||
docker compose exec fastapi python /app/scripts/backfill_dedup.py --dry-run
|
||||
docker compose exec fastapi python /app/scripts/backfill_dedup.py --apply
|
||||
# 변경 전 안전망은 E-3 pre-B-4 pg_dump (별 단계).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from services.dedup import reconcile_dedup # 코어 재계산 (야간 잡과 공유)
|
||||
|
||||
|
||||
async def run(*, apply: bool, chunk_size: int) -> int:
|
||||
database_url = os.getenv(
|
||||
"DATABASE_URL", "postgresql+asyncpg://pkm:pkm@localhost:5432/pkm"
|
||||
)
|
||||
engine = create_async_engine(database_url)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
result = await reconcile_dedup(session, apply=apply, chunk_size=chunk_size)
|
||||
|
||||
print(f"=== dedup 그룹 {result['groups']}개 · 관련 문서 {result['docs']}건 ===")
|
||||
if result["groups"] == 0:
|
||||
print("dedup 그룹 없음(OFF-whitelist 제외 후 count>1 없음) — 종료.")
|
||||
return 0
|
||||
|
||||
already = result["docs"] - result["changes"]
|
||||
print(f"변경 필요 {result['changes']}건 / 이미 목표값 {already}건 (멱등)")
|
||||
if result["changes"] == 0:
|
||||
print("모두 목표값 — 적용할 변경 없음.")
|
||||
return 0
|
||||
|
||||
# 적용될/된 정확한 UPDATE set 미리보기 (상위 40건)
|
||||
print("\n=== UPDATE set (id → duplicate_of / duplicate_count) ===")
|
||||
for s in result["sample"]:
|
||||
role = "canonical" if s["duplicate_of"] is None else f"dup→{s['duplicate_of']}"
|
||||
print(
|
||||
f" id={s['id']:>7} duplicate_of={s['duplicate_of']} "
|
||||
f"duplicate_count={s['duplicate_count']} [{role}]"
|
||||
)
|
||||
if result["changes"] > len(result["sample"]):
|
||||
print(f" ... 외 {result['changes'] - len(result['sample'])}건")
|
||||
|
||||
if not apply:
|
||||
print(f"\n[dry-run] {result['changes']}건 변경 예정. --apply 로 실제 적용.")
|
||||
else:
|
||||
print(f"\n[apply] 완료 — {result['applied']}건 갱신.")
|
||||
return 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--apply", action="store_true", help="실제 적용 (기본 dry-run)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="명시적 dry-run (default 동등)")
|
||||
parser.add_argument("--chunk", type=int, default=500, help="txn 당 UPDATE 행 수 (기본 500)")
|
||||
args = parser.parse_args()
|
||||
if args.apply and args.dry_run:
|
||||
parser.error("--apply 와 --dry-run 동시 지정 불가")
|
||||
return asyncio.run(run(apply=args.apply, chunk_size=args.chunk))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,146 @@
|
||||
"""과거 office/hwp pending 문서 markdown stage 백필 — plan ds-s1-backend-1 C-4.
|
||||
|
||||
신규 ingest 는 classify→markdown 전이(queue_consumer.py:142)로 자동 도달하므로 이 스크립트는
|
||||
*과거* office/hwp 행만 다룬다. C-2 가 office_md 변환을 붙이기 전까지 markdown stage 에서
|
||||
skip 된 행들을 다시 큐에 넣어 md_content 를 생성한다.
|
||||
|
||||
대상 (WHERE):
|
||||
- file_format IN (office_md 지원 실값: docx, xlsx, pptx, hwp, hwpx)
|
||||
★ 제외 축 = file_format. INCLUDE 필터가 article(file_format='article')을 구조적으로 배제
|
||||
→ P0-3 가드(md 없는 article 이 completed 도달 금지, correctness-critical). source_channel 절 불필요.
|
||||
★ 레거시 바이너리(.doc/.xls/.ppt)는 markitdown 미지원 → 기본 목록 제외(넣어도 marker 가 skip).
|
||||
- md_status = 'pending' (이미 success/failed/skipped 는 건드리지 않음)
|
||||
- extracted_text IS NOT NULL (폴백 존재 모집단)
|
||||
C-5 failed-postcondition 상속: 변환 실패는 md_status='failed' 로 시끄럽게 남는다(앱이
|
||||
'변환 실패' 표시). extracted_text NULL office(폴백 없음)는 배제 — 실패 시 더 시끄러운
|
||||
별 집합이라 phase2 재검토(C-4 배제 honest).
|
||||
|
||||
스케줄:
|
||||
★ C-2 라이브 office ingestion 과 백필 창 비중첩 — markdown 워커는 BATCH=1 직렬이라
|
||||
야간 단발로 돌려 라이브 office 업로드 stall 회피(plan C-2 reflection).
|
||||
|
||||
실행:
|
||||
docker compose exec fastapi python /app/scripts/backfill_nonpdf_markdown.py --dry-run
|
||||
docker compose exec fastapi python /app/scripts/backfill_nonpdf_markdown.py --apply
|
||||
docker compose exec fastapi python /app/scripts/backfill_nonpdf_markdown.py --apply --limit 50
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
||||
|
||||
from sqlalchemy import bindparam, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
# office_md 가 실제 변환하는 file_format(확장자 소문자, 점 없음). 단일 source.
|
||||
DEFAULT_FORMATS = ("docx", "xlsx", "pptx", "hwp", "hwpx")
|
||||
|
||||
CANDIDATES_SQL = text(
|
||||
"""
|
||||
SELECT id, file_format, title, file_path
|
||||
FROM documents
|
||||
WHERE deleted_at IS NULL
|
||||
AND md_status = 'pending'
|
||||
AND extracted_text IS NOT NULL
|
||||
AND file_format IN :formats
|
||||
ORDER BY id
|
||||
"""
|
||||
).bindparams(bindparam("formats", expanding=True))
|
||||
|
||||
# 활성 markdown 큐 행이 없는 doc 만 통과 (UNIQUE 부분 인덱스). 충돌 = silent skip.
|
||||
ENQUEUE_SQL = text(
|
||||
"""
|
||||
INSERT INTO processing_queue (document_id, stage, status, payload)
|
||||
VALUES (:doc_id, 'markdown', 'pending', CAST(:payload AS jsonb))
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _chunks(seq, size):
|
||||
for i in range(0, len(seq), size):
|
||||
yield seq[i : i + size]
|
||||
|
||||
|
||||
async def run(*, apply: bool, formats: tuple[str, ...], limit: int | None, chunk_size: int) -> int:
|
||||
database_url = os.getenv(
|
||||
"DATABASE_URL", "postgresql+asyncpg://pkm:pkm@localhost:5432/pkm"
|
||||
)
|
||||
engine = create_async_engine(database_url)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
await session.execute(CANDIDATES_SQL, {"formats": list(formats)})
|
||||
).all()
|
||||
if limit:
|
||||
rows = rows[:limit]
|
||||
|
||||
print(f"=== office/hwp pending 후보 = {len(rows)}건 (formats={','.join(formats)}) ===")
|
||||
if not rows:
|
||||
print("후보 없음 — 종료.")
|
||||
return 0
|
||||
|
||||
by_fmt: dict[str, int] = {}
|
||||
for r in rows:
|
||||
by_fmt[r.file_format] = by_fmt.get(r.file_format, 0) + 1
|
||||
print("포맷별:", ", ".join(f"{k}={v}" for k, v in sorted(by_fmt.items())))
|
||||
for r in rows[:20]:
|
||||
print(f" id={r.id:>7} {r.file_format:<5} {(r.title or '')[:70]}")
|
||||
if len(rows) > 20:
|
||||
print(f" ... 외 {len(rows) - 20}건")
|
||||
|
||||
if not apply:
|
||||
print(f"\n[dry-run] {len(rows)}건 markdown 큐 enqueue 예정. --apply 로 실제 적용.")
|
||||
print(" (적용 전 C-2 라이브 office ingestion 과 비중첩 야간창 확인.)")
|
||||
return 0
|
||||
|
||||
payload = json.dumps(
|
||||
{"force_reprocess": True, "reason": "c4_nonpdf_markdown_backfill"}
|
||||
)
|
||||
inserted = 0
|
||||
processed = 0
|
||||
for batch in _chunks(rows, chunk_size):
|
||||
for r in batch:
|
||||
result = await session.execute(
|
||||
ENQUEUE_SQL, {"doc_id": r.id, "payload": payload}
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
inserted += 1
|
||||
await session.commit()
|
||||
processed += len(batch)
|
||||
print(f"[apply] {processed}/{len(rows)} 처리 (enqueue 누적 {inserted})")
|
||||
|
||||
print(f"\n[apply] 완료 — {inserted}/{len(rows)} 신규 markdown 큐 추가.")
|
||||
print(" (skip = 이미 활성 markdown 큐 행이 있는 문서)")
|
||||
return 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--apply", action="store_true", help="실제 enqueue (기본 dry-run)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="명시적 dry-run (default 동등)")
|
||||
parser.add_argument(
|
||||
"--formats", type=str, default=",".join(DEFAULT_FORMATS),
|
||||
help=f"쉼표 구분 file_format (기본 {','.join(DEFAULT_FORMATS)})",
|
||||
)
|
||||
parser.add_argument("--limit", type=int, default=None, help="후보 상한(샘플 검증용)")
|
||||
parser.add_argument("--chunk", type=int, default=200, help="enqueue txn 청크 크기")
|
||||
args = parser.parse_args()
|
||||
if args.apply and args.dry_run:
|
||||
parser.error("--apply 와 --dry-run 동시 지정 불가")
|
||||
formats = tuple(f.strip().lower() for f in args.formats.split(",") if f.strip())
|
||||
return asyncio.run(
|
||||
run(apply=args.apply, formats=formats, limit=args.limit, chunk_size=args.chunk)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,18 @@
|
||||
-- rollback_287.sql — plan ds-s1-backend-1 E-3. migration 287(dedup 3컬럼) 되돌림.
|
||||
--
|
||||
-- ★ migrations/ 밖에 둔다 — init_db() 자동 스캔(NNN_*.sql) 대상이 아니므로 자동 적용되지 않는다.
|
||||
-- 수동 실행 전용:
|
||||
-- docker compose cp scripts/rollback_287.sql postgres:/tmp/rollback_287.sql
|
||||
-- docker compose exec -T postgres psql -U pkm -d pkm -f /tmp/rollback_287.sql
|
||||
-- (또는) docker compose exec -T postgres psql -U pkm -d pkm < scripts/rollback_287.sql
|
||||
--
|
||||
-- 주의: original_filename / duplicate_of / duplicate_count 데이터 영구 삭제(B-1 채움·B-4 backfill 결과 포함).
|
||||
-- schema_migrations 의 287 행도 함께 제거해야 재적용(다음 startup)이 가능하다.
|
||||
-- 전체 복원이 필요하면 E-3 pre-change pg_dump 를 쓴다(이 스크립트는 '컬럼만 빠른 롤백').
|
||||
|
||||
ALTER TABLE documents
|
||||
DROP COLUMN IF EXISTS duplicate_of,
|
||||
DROP COLUMN IF EXISTS duplicate_count,
|
||||
DROP COLUMN IF EXISTS original_filename;
|
||||
|
||||
DELETE FROM schema_migrations WHERE version = 287;
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre-change pg_dump — plan ds-s1-backend-1 E-3.
|
||||
# A-1(migration 287) / B-4 backfill 적용 *전* 안전망. repo cp -p 가 아니라 진짜 DB 덤프.
|
||||
#
|
||||
# 사용 (GPU 서버, repo 루트에서):
|
||||
# bash scripts/s1_pre_change_backup.sh # pre-A-1
|
||||
# bash scripts/s1_pre_change_backup.sh pre-b4 # pre-B-4 (라벨만 다름)
|
||||
#
|
||||
# 백업 위치 = repo 밖 (feedback_backup_outside_repo): $HOME/.local/share/ds-s1-backups/
|
||||
set -euo pipefail
|
||||
|
||||
LABEL="${1:-pre-a1}"
|
||||
DATE="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP_DIR="${BACKUP_DIR:-$HOME/.local/share/ds-s1-backups}"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
OUT="$BACKUP_DIR/pkm-${LABEL}-${DATE}.sql.gz"
|
||||
|
||||
echo "[s1-backup] pg_dump pkm → $OUT"
|
||||
# 단일 pkm DB 덤프(pg_dumpall 아님). gzip 은 redirect(파일명 추측 함정 회피).
|
||||
docker compose exec -T postgres pg_dump -U pkm -d pkm | gzip > "$OUT"
|
||||
|
||||
echo "[s1-backup] done: $(du -h "$OUT" | cut -f1)"
|
||||
echo -n "[s1-backup] gzip 무결성: "
|
||||
gzip -t "$OUT" && echo "OK"
|
||||
|
||||
echo
|
||||
echo "[s1-backup] 롤백 옵션:"
|
||||
echo " (a) 287 컬럼만 되돌림(빠름): scripts/rollback_287.sql 수동 실행"
|
||||
echo " (b) 전체 복원: gunzip -c '$OUT' | docker compose exec -T postgres psql -U pkm -d pkm"
|
||||
echo "[s1-backup] 보존 7일 권장. (DR-grade 검증은 ephemeral restore — D5 트랙, 본 안전망 범위 밖.)"
|
||||
Reference in New Issue
Block a user