From 3e2fa16e1dcce0a33f3b149a67007a32b253d94a Mon Sep 17 00:00:00 2001 From: hyungi Date: Sat, 20 Jun 2026 08:16:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(hier):=20=ED=81=AC=EB=A1=9C=EC=8A=A4-doc?= =?UTF-8?q?=20=EC=A0=88=20=EB=9D=BC=EB=B2=A8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20(U-1,=20'UG-79?= =?UTF-8?q?=20=EB=B3=B4=EC=97=AC=EC=A4=98'=20=EC=A7=84=EC=9E=85=EC=A0=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/documents/clause-lookup?label=UG-79 → 절 식별자로 크로스-doc 위치 해소. 절(node_type=clause/clause_split)은 in_corpus=false(검색 비활성)라 의미검색으론 못 찾으므로, 라벨 prefix 정확매칭으로 (doc, char_start)를 직접 반환해 읽기뷰 점프 가능케 함. 라벨 중복도 실측: 1335 라벨 중 다중-doc 10건(0.7%, 부록 A-/E-/F- 한정) → 에디션 UI 불요, 단순 조회 + 드문 다중반환. /{doc_id} 앞 선언(라우트 매칭 순서). document_chunks 직접 조회는 정확지목(retrieval 아님)이라 코퍼스 격리의 의도적 예외(/sections 와 동일). A-1 후속: 절 타이핑(5180=550·5210=862·5209=43 라이브)으로 채워진 절을 사용자가 호출 가능케 함. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/documents.py | 54 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/app/api/documents.py b/app/api/documents.py index f9fc615..ce168f7 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -672,6 +672,60 @@ async def list_duplicates( ) +class ClauseHit(BaseModel): + doc_id: int + doc_title: str + section_title: str | None = None + char_start: int | None = None + chunk_id: int + node_type: str | None = None + + +class ClauseLookupResponse(BaseModel): + label: str + hits: list[ClauseHit] + + +# NOTE: '/{doc_id}' (int path param) 라우트보다 먼저 선언해야 '/clause-lookup' 이 doc_id 로 +# 잘못 매칭되지 않는다 (FastAPI 선언 순서 매칭). 이동 금지. +@router.get("/clause-lookup", response_model=ClauseLookupResponse) +async def clause_lookup( + label: str, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """절 식별자(예: UG-79)로 크로스-doc 절 위치 조회 — 'UG-79 보여줘' 진입점 (U-1). + + 절(node_type=clause/clause_split)은 in_corpus=false(검색 비활성)라 의미검색으론 못 찾으므로, + 라벨 prefix 정확매칭으로 (doc, char_start) 를 직접 해소해 읽기뷰 점프를 가능케 한다. + 대부분 1건; 부록(A-/E-/F-) 등 doc 간 공유 라벨만 다중 반환(에디션 선택). /sections 와 동일하게 + document_chunks 직접 조회 — corpus_chunks 우회는 retrieval 아닌 정확지목이므로 의도적 예외. + """ + from sqlalchemy import text as sql_text + + lab = (label or "").strip() + if not lab: + return ClauseLookupResponse(label=label, hits=[]) + rows = ( + await session.execute( + sql_text( + """ + SELECT c.doc_id, d.title AS doc_title, c.section_title, + c.char_start, c.id AS chunk_id, c.node_type + FROM document_chunks c + JOIN documents d ON d.id = c.doc_id + WHERE c.node_type IN ('clause', 'clause_split') + AND (c.section_title ILIKE :lab_sp OR c.section_title ILIKE :lab_eq) + AND d.deleted_at IS NULL + ORDER BY c.doc_id, c.char_start NULLS LAST + LIMIT 50 + """ + ).bindparams(lab_sp=lab + " %", lab_eq=lab) + ) + ).mappings().all() + return ClauseLookupResponse(label=lab, hits=[ClauseHit(**dict(r)) for r in rows]) + + @router.get("/{doc_id}", response_model=DocumentDetailResponse) async def get_document( doc_id: int,