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,