feat(hier): 크로스-doc 절 라벨 조회 엔드포인트 (U-1, 'UG-79 보여줘' 진입점)

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) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-20 08:16:26 +09:00
parent b6ce228f6e
commit 3e2fa16e1d
+54
View File
@@ -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) @router.get("/{doc_id}", response_model=DocumentDetailResponse)
async def get_document( async def get_document(
doc_id: int, doc_id: int,