Metadata-Version: 2.4
Name: thesis-review-agent
Version: 0.2.3
Summary: AI-assisted HWPX thesis citation and reference validation
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: openai-agents>=0.22.3
Requires-Dist: langgraph>=1.2.11
Requires-Dist: python-hwpx>=6.4.0
Requires-Dist: python-dotenv>=1.0.0
Provides-Extra: dev
Requires-Dist: pytest>=9.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=1.0.0; extra == "dev"

# Thesis Review Agent

HWPX 논문에서 본문 인용과 참고문헌을 추출하고, AI 분류와 규칙 검증으로 표기 오류 후보를 반환하는 Python 패키지입니다. 검토된 문서에는 확인된 위반 범위에 HWPX 형광펜 마크와 범위 ID용 `CLICK_HERE` 필드가 적용됩니다. Python에서는 `result.document`로, JSON/SSE에서는 Base64 `result.reviewed_hwpx_bytes`로 받을 수 있습니다. 프론트는 이 검토된 파일을 rhwp에 열어야 원문 파일이 아닌 표시 적용본을 확인할 수 있습니다.

현재 소스의 패키지 버전은 `0.2.3`입니다(`pyproject.toml` 기준). NOTE 검증을 각주(NOTE-001~007, NOTE-014)와 표·그림 주(NOTE-008~013)로 나누고, 추출된 구조에서 판정 가능한 규칙을 `note_issues`로 반환합니다. 표와 각주는 매 실행에서 자동 추출·분류·검증하며, 최종 리뷰도 항상 생성합니다. 위반 위치가 확인되면 각주 본문이나 표 셀·캡션의 원문 HWPX에도 네이티브 형광펜과 `hl-####` 표시 필드를 적용합니다.

검토 보조 도구이므로 결과를 원문 및 적용할 학술 작성 지침과 대조해 확인하세요. AI 분류나 자동 검증은 완전한 학술적 판단을 보장하지 않습니다.

## 설치 및 준비

- Python 3.12 이상
- OpenAI API 키 또는 OpenAI 호환 API 제공자의 키
- 검토할 HWPX 파일

```bash
python -m pip install thesis-review-agent
export OPENAI_API_KEY="your-api-key"
```

`OPENAI_API_KEY`는 OpenAI API 기본 클라이언트에 사용합니다. ChatGPT 웹사이트의 로그인·구독과 API 키는 별도입니다. 다른 클라우드 제공자나 로컬 LLM 서버를 쓸 때는 아래의 `AgentModelConfig` 설정을 사용하세요.

이 저장소의 최신 변경을 사용하려면 저장소 루트에서 설치합니다.

```bash
python -m pip install -e .
```

Windows PowerShell에서는 API 키를 다음처럼 설정할 수 있습니다.

```powershell
$env:OPENAI_API_KEY = "your-api-key"
```

기본 OpenAI 클라이언트를 사용할 때는 실행 환경에 `OPENAI_API_KEY`가 필요합니다. 커스텀 제공자를 사용할 때는 키를 `model_config.api_key`로 전달할 수 있습니다. 실제 AI 호출을 포함하는 통합 테스트는 기본 테스트 묶음에서는 건너뛰며, 실행하려면 `RUN_AI_INTEGRATION_TESTS=1`도 설정해야 합니다.

기본 모델은 소스 설정(`thesis_review_agent/config/agent_model_config.py`)의 `gpt-6-luna`입니다. AI 호출 시 문장과 참고문헌 등 문서 내용이 API로 전달되고 사용료가 발생할 수 있습니다. 민감한 문서를 처리하기 전 조직의 보안 정책과 API 사용 조건을 확인하세요.

## 바로 사용하기

아래 코드는 파일을 검토하고 분석 결과를 JSON으로 출력합니다. 검토된 HWPX 문서 객체는 Python에서 직접 다루거나, 선택적으로 호출자가 저장할 수 있습니다.

```python
import asyncio
import json
from pathlib import Path

from thesis_review_agent import ThesisReviewAgent


async def main() -> None:
    hwpx_bytes = Path("thesis.hwpx").read_bytes()
    agent = ThesisReviewAgent()

    result = await agent.run(hwpx_bytes)
    # 필요할 때만 호출자가 검토 문서를 저장합니다.
    if result.document is not None:
        result.document.save_to_path("reviewed.hwpx")

    # 원본 파일 바이트를 제외하고 분석 결과를 출력합니다.
    payload = result.model_dump(mode="json", exclude={"source_hwpx_bytes"})
    print(json.dumps(payload, ensure_ascii=False, indent=2))


asyncio.run(main())
```

`run()`은 내부 그래프 State 대신 공개 응답 모델인 `ThesisReviewResult`를 반환합니다. `quotation_issues`에는 본문 인용 규칙(CIT) 위반 후보가, `reference_issues`에는 참고문헌 일반 규칙(REF)과 자료유형별 규칙(TYP) 위반 후보가 있습니다. `note_issues`에는 추출된 각주·표 주에서 구조적으로 확인 가능한 NOTE 위반 후보가 포함됩니다. 본문 문장은 문장 분류와 함께 오타도 검수하며, 결과는 `typo_issues`에 정확한 오타 문자열 범위(문단 내 UTF-16 시작·끝 오프셋), 문서 위치, 수정 제안, 사유, 심각도(`serious_typo` 또는 `suspected_typo`), 후속 검토 의견으로 담깁니다. 표·각주 내부라면 `location.table_cell_path` 또는 `location.footnote_path`도 제공합니다. 오타는 자동 수정하지 않으며, 표시 가능한 오타는 검토본에서 연두색으로 표시됩니다. 목록이 비어 있으면 해당 실행에서 후보를 찾지 못했다는 뜻이지, 문서가 모든 기준을 충족한다고 보증하는 것은 아닙니다.

호출별 설정은 `options` 딕셔너리로 전달합니다. `max_concurrent_requests`의 기본값은 `10`이며, `extract_only`를 켜면 표·각주를 포함한 추출 직후 분류·검증 전에 종료해 `result.extracted_document`에서 결과를 확인할 수 있습니다. 일반 실행에서는 표 캡션·셀 문단과 각주·본문 앵커를 항상 추출합니다. 표·각주 안 문장도 문장별 분류 및 오타 검수 대상이며, 해당 내용의 NOTE 규칙과 위치가 확인된 HWPX 표시도 처리합니다. 최종 리뷰는 검증 결과를 바탕으로 항상 생성되어 `result.final_review`에 포함됩니다. 이 때문에 AI 호출 비용과 실행 시간이 늘어날 수 있습니다. NOTE 검증은 구조가 제공하는 범위에서만 수행하며, 페이지 배치나 그림 범례처럼 입력 모델이 제공하지 않는 정보는 판정하지 않습니다. `SentenceClassifyAgent`가 각 문장을 개별 요청으로 분류하고 오타도 함께 검수하므로 별도 오타 검수 Agent가 본문을 다시 받지는 않습니다. `max_concurrent_requests`는 문장 요청의 최대 동시 실행 수를 조절합니다.

```python
result = await agent.run(
    hwpx_bytes,
    options={
        "max_concurrent_requests": 10,
    },
)
print(result.final_review)
```

현재 지원하는 `options` 키는 추출 전용 실행을 위한 `extract_only`와 문장 요청 수를 조절하는 `max_concurrent_requests`입니다. 이전의 `extract_tables`, `extract_footnotes`, `generate_final_review` 선택 키는 제거되었습니다. 표·각주 추출과 최종 리뷰 생성은 항상 수행되며, 기존 키를 전달하면 알 수 없는 옵션 오류가 발생합니다. 잘못된 타입, 1 미만의 동시 요청 수 역시 거부합니다. `ReviewOptions` 타입은 자동완성·정적 타입 검사에 사용할 수 있습니다.

실제 HWPX에서 표를 추출하고 구조를 콘솔로 확인하려면 아래 통합 테스트를 실행합니다. 이 테스트는 `extract_only`를 사용하므로 분류 Agent/API를 호출하지 않습니다.

```bash
.venv/bin/pytest tests/test_thesis_review_agent.py::test_extract_hwpx_tables_without_classification -m integration -s
.venv/bin/pytest tests/test_thesis_review_agent.py::test_extract_hwpx_footnotes_without_classification -m integration -s
```

기본 입력은 프로젝트 루트의 `test.hwpx`입니다. 다른 파일은 `THESIS_REVIEW_HWPX=/path/to/file.hwpx`로 지정할 수 있습니다. 테스트는 추출된 표 개수, 캡션 텍스트, 행·열·셀 텍스트를 JSON으로 출력합니다.
각주 테스트도 추출된 각주 문단과 본문 앵커를 JSON으로 출력합니다. 입력 문서에 실제 각주가 없으면 테스트는 그 사실과 대체 입력 방법을 안내하며 건너뜁니다. `extract_only=True`로 실행하므로 두 테스트 모두 AI 분류·검증은 호출하지 않습니다. 일반 검토에서는 표와 각주가 자동으로 분류·검증됩니다.

### 실행 모델 지정하기

기본적으로 `ThesisReviewAgent()`는 OpenAI SDK의 기본 클라이언트와 패키지 기본 모델(`gpt-6-luna`)을 사용합니다. 이때 `OPENAI_API_KEY`를 설정합니다. 다른 클라우드 제공자나 로컬 LLM 서버를 사용한다면 키, Base URL, 서버가 제공하는 모델 ID를 `AgentModelConfig`에 지정합니다. 이 설정은 문장·인용·참고문헌 분류와 최종 리뷰 등 모든 Agent에 전달됩니다.

다음 예시는 `.env`에서 읽은 설정을 어떤 OpenAI 호환 서버에도 전달하는 방식입니다. 서버마다 Base URL과 모델 ID가 다르므로 해당 서버 설정에 맞춰 지정하세요.

```python
import os

from thesis_review_agent import ThesisReviewAgent

agent = ThesisReviewAgent(
    model_config={
        "base_url": os.environ["AGENT_BASE_URL"],
        "model": os.environ["AGENT_MODEL"],
        "api_key": os.environ["AGENT_API_KEY"],
    }
)
result = await agent.run(hwpx_bytes)
```

API 키는 코드에 직접 쓰지 말고 환경변수나 Secret 관리 도구에서 읽으세요. 커스텀 Base URL 또는 Token을 설정하면 해당 설정으로 OpenAI 호환 Chat Completions 요청을 보내고, 실행이 끝나면 연결을 닫습니다. 둘 다 설정하지 않으면 OpenAI 기본 클라이언트를 사용합니다. `model_config`의 모델은 모든 실행의 공통 기본값이고, 필요하면 실행별 `model` 인수로 모델 이름만 덮어쓸 수 있습니다.

제공자별 설정 예시는 다음과 같습니다.

| 사용처 | `AGENT_BASE_URL` | `AGENT_MODEL` | `AGENT_API_KEY` |
| --- | --- | --- | --- |
| OpenAI 기본 클라이언트 | 지정하지 않음 | 지정하지 않으면 패키지 기본 모델 | `OPENAI_API_KEY` 사용 |
| OpenRouter | `https://openrouter.ai/api/v1` | OpenRouter 모델 목록의 전체 ID (예: `provider/model`) | OpenRouter에서 발급한 키 |
| 로컬 OpenAI 호환 서버 | 서버가 안내하는 API 주소 (예: `http://127.0.0.1:8000/v1`) | 로컬 서버에 올려 둔 모델 ID | 서버가 인증을 요구하면 해당 키; 인증을 쓰지 않는 서버는 `local` 같은 자리표시 키 |

로컬 서버는 OpenAI 호환 Chat Completions 경로(`/chat/completions`)와 JSON Schema 구조화 출력(`response_format: json_schema`)을 지원해야 합니다. 지원 범위는 서버와 모델에 따라 다를 수 있습니다. `AGENT_MODEL`은 실제 서버에 등록된 ID로 지정하고, 기본값 `gpt-6-luna`가 로컬에서 자동으로 제공된다고 가정하지 마세요.

```python
result = await agent.run(hwpx_bytes, model="another-model")
```

```python
result = await agent.run(hwpx_bytes, model="gpt-6-luna")
```

`model_config`와 실행별 `model`을 모두 생략하면 패키지 기본 모델을 사용합니다.

### 진행 상황 스트리밍

`stream()`은 내부 그래프 상태를 노출하지 않고, 다음 세 종류의 Pydantic 이벤트를 순서대로 반환합니다. 각 이벤트는 `.event`, 타입이 지정된 `.data`, SSE 직렬화 메서드 `.to_sse()`를 제공합니다.

| SSE event | Python 타입 | `data` 내용 |
| --- | --- | --- |
| `progress` | `ProgressStreamEvent` | `node_name`, `message`, `unmatched_violation_count` (마지막 필드는 해당 시점에만 값이 있음) |
| `result` | `ResultStreamEvent` | 최종 `ThesisReviewResult`의 JSON 표현. `quotations`, `references`, `reference_scope_matches`, `quotation_issues`, `reference_issues`, `note_issues`, `typo_issues` 포함 |
| `done` | `DoneStreamEvent` | 정상 완료를 나타내는 `finish_reason: "stop"` |

`result` 이벤트의 `data.document`는 Python 객체이므로 SSE JSON에는 포함하지 않습니다. 입력 원본 바이트는 결과 payload에서 제외하지만, 검토된 문서 바이트는 `reviewed_hwpx_bytes`에 Base64로 포함합니다. 프론트는 이 값을 디코딩해 원본 대신 rhwp에 열어야 합니다. 위반 문자열 구간에는 rhwp가 렌더링하는 글자 배경색(`shadeColor`)도 적용하며, `CLICK_HERE` 필드는 범위를 식별하는 표식입니다. `highlight_ids`와 `document_ranges`는 뷰어 오버레이 구현에도 사용할 수 있습니다. 스트림에서 예외가 발생하면 `stream()`은 예외를 호출자에게 전달하며, `error` SSE 이벤트로 바꾸지는 않습니다.

#### FastAPI 서버 예제

아래 예제는 요청 body로 받은 HWPX 바이트를 검토하고 각 타입 이벤트를 SSE 형식으로 반환하는 POST 엔드포인트입니다. FastAPI는 이 패키지의 필수 의존성이 아니므로 서버에 별도로 설치합니다.

```bash
python -m pip install fastapi uvicorn
```

```python
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse

from thesis_review_agent import ThesisReviewAgent

app = FastAPI()
review_agent = ThesisReviewAgent()


@app.post("/review/stream")
async def review_stream(request: Request) -> StreamingResponse:
    hwpx_bytes = await request.body()

    async def event_stream():
        async for event in review_agent.stream(hwpx_bytes):
            yield event.to_sse()

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        },
    )
```

서버를 실행하고 HWPX 파일을 POST로 전송합니다. `-N`은 curl의 출력 버퍼링을 끕니다.

```bash
uvicorn app:app --reload
curl -N -X POST \
  -H "Content-Type: application/octet-stream" \
  --data-binary @thesis.hwpx \
  http://127.0.0.1:8000/review/stream
```

SSE wire 형식은 이벤트마다 `event:` 줄, JSON `data:` 줄, 빈 줄 순서입니다. 예를 들어 완료된 응답은 아래처럼 진행 이벤트가 여러 개 온 뒤 결과와 완료 이벤트로 끝납니다.

```text
event: progress
data: {"node_name":"sentence_classify_node","message":"문장과 소속 텍스트 조각을 분류 중입니다.","unmatched_violation_count":null}

event: result
data: {"reviewed_hwpx_bytes":"<base64 HWPX>","quotations":[],"references":[],"reference_scope_matches":[],"quotation_issues":[],"reference_issues":[],"typo_issues":[{"sentence":"이 문장은 틀린 맞춤법임니다.","original_text":"임니다","suggested_text":"입니다","message":"맞춤법 오류입니다.","severity":"serious_typo","review_note":"명백한 오타이므로 수정이 필요합니다.","location":{"section_number":1,"paragraph_number":1,"run_number":1,"content_number":1,"sentence_number":1,"table_cell_path":[],"footnote_path":[]},"text_range":{"section_number":1,"paragraph_number":1,"start_offset":13,"end_offset":16}}]}

event: done
data: {"finish_reason":"stop"}

```

프로그래밍 방식으로 사용하면 SSE 문자열 대신 타입이 유지된 이벤트 객체를 직접 받을 수 있습니다.

```python
async for event in review_agent.stream(
    hwpx_bytes,
    model="gpt-6-luna",
):
    if event.event == "progress":
        print(event.data.message)
    elif event.event == "result":
        result = event.data  # ThesisReviewResult
        if result.document is not None:
            result.document.save_to_path("reviewed.hwpx")
    elif event.event == "done":
        print("검토 스트림 완료")
```

`EventSource`는 브라우저에서 GET 연결에 주로 쓰입니다. 위 업로드 예제는 POST이므로 브라우저에서 사용할 때는 `fetch()`의 응답 body를 읽어 SSE frame을 처리하거나, 업로드 후 GET 스트림을 여는 별도 API 구조를 사용하세요. `ProgressStreamEvent`, `ResultStreamEvent`, `DoneStreamEvent`와 `ReviewStreamEvent` 타입은 `thesis_review_agent`에서 가져올 수 있습니다.

## 결과 읽기

`ThesisReviewResult`는 그래프 State 자체를 반환하지 않고, 사용자가 결과를 확인하거나 후처리할 때 필요한 입력 원본과 분석 결과만 공개합니다. 공개 모델은 `thesis_review_agent.results` 패키지에 클래스별 파일로 분리되어 있고 모두 Pydantic `BaseModel`입니다.

| 응답 필드 | 내용 |
| --- | --- |
| `source_hwpx_bytes` | 입력한 원본 파일 바이트. JSON에서는 Base64 문자열 |
| `reviewed_hwpx_bytes` | 위반 표시를 적용한 HWPX 바이트. JSON/SSE에서는 Base64 문자열이며 프론트에서 표시 적용본을 열 때 사용 |
| `document` | 검토된 `python-hwpx.HwpxDocument` 객체. Python 속성으로 제공되며 JSON에서는 제외 |
| `quotations` | 추출한 인용 원문·저자·연도·인용 분류 결과 |
| `references` | 참고문헌 원문·상위 자료유형·REF 및 유형별 세부 분류·추출 서식 |
| `quotation_issues` | CIT 규칙의 위반 후보 |
| `reference_issues` | REF·TYP 규칙의 위반 후보 |
| `note_issues` | 추출된 각주·표 주에서 확인한 NOTE 규칙의 위반 후보 |
| `typo_issues` | 오타 원문·수정 제안·사유, 심각도(`serious_typo`/`suspected_typo`), 후속 검토 의견, 표·각주 경로를 포함한 문서 위치, 정확한 UTF-16 범위 |
| `final_review` | 위반 후보를 종합한 한국어 최종 검토 의견. 전체 검토에서는 항상 생성 |
| `extracted_document` | `options={"extract_only": True}`인 경우 분류 전 추출 구조. 그 외에는 `null` |
| `reference_scope_matches` | 본문 인용과 참고문헌의 대응 결과: `matched`, `unmatched`, `undetermined` |

JSON 직렬화에는 `result.model_dump(mode="json")`을 사용합니다. 직렬화된 JSON에는 원본과 검토된 HWPX 바이트가 모두 Base64로 들어가 크기가 커질 수 있습니다. 분석 결과만 직렬화하려면 `exclude={"source_hwpx_bytes", "reviewed_hwpx_bytes"}`를 지정하세요. `HwpxDocument`는 JSON에서 제외되며, Python에서는 `result.document.save_to_path(path)`로 저장하거나 `result.reviewed_hwpx_bytes`를 직접 전달할 수 있습니다.

### 위반 내용과 문서 위치

| 필드 | 의미 |
| --- | --- |
| `id` | 위반 규칙 ID (`CIT-001`, `REF-021`, `TYP-009` 등). `rule_id`와 같은 값 |
| `rule_id` | `id`와 같은 규칙 ID. 기존 API 호출자와의 호환을 위해 유지 |
| `message` | 위반으로 판단한 이유와 수정 안내 |
| `quotation` | 원문에서 확인된 정확한 오류 문자열. 누락 오류처럼 표시할 실제 문자열이 없으면 `null`; 참고문헌 이슈에서는 `null` |
| `text` | 인용이 포함된 원문 문장 또는 참고문헌 항목 |
| `severity` | `serious`(위반) 또는 `suspect`(위반 의심: 규정상 위반인지 확정할 수 없어 사람이 확인할 항목). 기본값 `serious`. NOTE 이슈는 항상 `serious` |
| `location` | 원문 위치. 구역·문단·run·content·문장 번호를 사용자용 1부터 시작하는 번호로 제공 |
| `highlight_id` | 대표 HWPX 표시 ID (`hl-0001` 등). 여러 범위가 있으면 첫 번째 ID이며, 표시가 없으면 `null` |
| `document_ranges` | 본문 문단의 위반 문자열이 차지하는 정확한 범위. 문단 번호는 1부터 시작하며 `start_offset` 포함, `end_offset` 미포함의 UTF-16 코드 유닛 오프셋. 각주·표 내부 NOTE는 평면 좌표 대신 구조 경로를 사용 |
| `highlight_ids` | 위반에 연결된 모든 HWPX 표시 ID. 일반 본문 범위에서는 각 ID가 `document_ranges[].range_id`와 같고, 각주·표 NOTE에서는 중첩 HWPX 필드 이름과 연결 |

오타 결과의 `highlight_id`와 `text_range.range_id`도 실제 HWPX 표시가 적용된 경우 같은 ID를 제공합니다. 표시 ID는 지원되는 위반·오타 범위에 유일한 `hl-0001`, `hl-0002` 형식으로 부여됩니다. CIT·REF·TYP·NOTE 위반은 노란색, 오타는 연두색으로 HWPX에 표시합니다. NOTE가 각주·표 셀·캡션 내부에 있으면 `location.footnote_path` 또는 `location.table_cell_path`가 범위를 찾는 데 사용되며, 일반 본문의 평면 `document_ranges`는 제공되지 않습니다. 표 셀·캡션·각주 안의 오타 위치는 현재 네이티브 표시를 지원하지 않아 해당 ID가 `null`일 수 있습니다.

`document_ranges`는 항목별 범위 배열이며 각 원소에 `section_number`, `paragraph_number`, `start_offset`, `end_offset`, `range_id`가 있습니다. 좌표 텍스트는 해당 문단의 텍스트를 run 순서대로 이어 붙인 문자열이고, 오프셋은 JavaScript 문자열 인덱스와 같은 UTF-16 코드 유닛 기준입니다. 이모지처럼 보조 평면 문자는 2칸으로 계산합니다. `range_id`는 문서 순서대로 `hl-0001`, `hl-0002`처럼 증가하며, 원문 범위 시작점의 이름 있는 `CLICK_HERE` 누름틀 필드 `hp:fieldBegin/@name`과 연결됩니다. 검증기에서 오류 부분을 유일하고 정확하게 찾지 못하면 빈 배열을 반환하므로, 프론트엔드는 범위를 다시 검색하거나 추정하지 않아야 합니다.

```json
{
  "id": "CIT-006",
  "rule_id": "CIT-006",
  "highlight_id": "hl-0001",
  "quotation": "and",
  "location": { "section_number": 1, "paragraph_number": 12 },
  "document_ranges": [
    { "section_number": 1, "paragraph_number": 12, "start_offset": 10, "end_offset": 13, "range_id": "hl-0001" }
  ]
}
```

`reference_scope_matches` 각 항목에는 대조 대상 종류(`reference` 또는 `quotation`), 원문, 비교 가능한 저자·연도, 상태, 대응 원문 목록, 설명, 문서 위치가 들어갑니다. 본문 인용과 참고문헌의 정규화된 첫 저자·연도가 유일하게 일치할 때 매칭합니다. 키가 없거나 중복되어 안전하게 대조할 수 없는 경우에는 AI를 호출하지 않고 `undetermined`로 남깁니다. 따라서 이 대조 단계는 별도의 AI 토큰을 사용하지 않습니다.

`result.has_issues`는 인용·참고문헌·주석·오타 이슈 목록 중 하나라도 비어 있지 않은지 나타냅니다. 규칙 ID는 인용 검증의 CIT, 일반 참고문헌 검증의 REF, 자료유형별 참고문헌 검증의 TYP, 주석 검증의 NOTE로 구분됩니다.

NOTE 규칙은 자동 추출된 각주와 표 구조에서 가능한 패턴·구조 검사를 수행하며, 발견 결과는 `note_issues`로 반환합니다. 현재 구현된 검사와 no-op 규칙은 각주 번호 연속성·문단 수·완전 중복, 표 주의 종류 순서·잘못된 시작 표기·일부 확률 주 형식 등을 포함합니다. 쪽 배치, 그림 범례, 학생/전문가 논문 구분 등 입력 모델에 없는 정보는 판정하지 않습니다. 세부 범위와 정규식 한계는 [`note_validate_node/README.md`](thesis_review_agent/graph/nodes/validation/note_validate_node/README.md)에 정리되어 있습니다.

### 위반 범위가 형광펜 표시된 문서 읽기

위반 표시에는 HWPX 네이티브 형광펜 요소와 범위 시작점에 이름을 둔 `CLICK_HERE` 필드(`hp:fieldBegin name="hl-..."`)를 사용합니다. 필드 시작·끝 컨트롤은 텍스트가 없는 별도 run에 짝으로 삽입하므로 문서 텍스트와 문자 오프셋은 바뀌지 않습니다. 검토된 문서는 다음처럼 저장할 수 있습니다.

```python
if result.document is not None:
    # 선택적으로 HWPX 파일로 저장
    result.document.save_to_path("reviewed.hwpx")
```

프론트/SSE 소비자는 결과 이벤트의 `reviewed_hwpx_bytes` Base64 값을 디코딩한 뒤, 원본 업로드 파일 대신 해당 HWPX 바이트를 rhwp에 열어야 합니다. 위반 구간은 HWPX `charPr`의 `shadeColor` 글자 배경색으로 표시합니다. `CLICK_HERE` 필드 이름은 구간 연결용 ID이고 색을 표시하지 않습니다. 이 `shadeColor` 표시를 포함한 새 검토 파일을 rhwp에서 열어 시각적으로 확인하세요.

평문 추출 결과에는 표시용 문자열이 섞이지 않습니다. 위반 범위에는 `<hp:markpenBegin>`/`<hp:markpenEnd>` 형광펜과 범위 시작의 `range_id`를 가진 `<hp:fieldBegin type="CLICK_HERE" name="hl-...">`, 범위 끝의 짝이 맞는 `<hp:fieldEnd>`를 적용합니다. 필드는 위반 텍스트를 감싸며, 필드 안에는 표시 텍스트나 안내문을 넣지 않습니다. 따라서 원문 문자는 추가되지 않습니다. 수정한 문단의 줄 배치는 편집기에서 다시 계산됩니다.

| 위반 종류·상황 | 표시 범위 |
| --- | --- |
| CIT 위반 | 지정된 문맥 안에서 찾은 인용 문자열 |
| 묶음 인용의 재구성된 괄호 문자열 | 괄호 전체가 원문에 없으면 해당 괄호 안 문자열 |
| 인용 문자열을 찾지 못했지만 위반 문맥은 확인됨 | 위반 문맥 전체 |
| REF·TYP 위반 | 위반 참고문헌 항목 전체. REF-002처럼 본문 위치를 가진 위반은 해당 본문 문맥 |
| NOTE 위반 | 각주 본문 또는 표 셀·캡션에서 확인된 오류 조각. 특정 조각이 없고 구조상 위반이면 해당 문단 |
| 제목·주석 누락 등 목록 전체의 문제 | 검증기가 위치로 지정한 대표 항목 |
| 중복되거나 겹치는 위반 | 범위를 합쳐 한 번만 표시 |
| 여러 run·문장 조각에 걸친 항목 | 각 원문 조각에 native 형광펜을 적용 |
| 위치가 잘못됐거나 위반 원문을 유일하게 찾지 못함 | 표시를 적용하지 않고 위반 목록은 유지 |

위치 매칭은 위반에 기록된 문서 위치와 해당 문단 원문을 사용합니다. 문서 전체에서 같은 문자열을 찾아 일괄 표시하지 않습니다. 현재 위반 모델에는 정확한 오류 글자 범위가 없으므로 참고문헌의 잘못된 쉼표나 단어 하나만 따로 강조하는 기능은 아닙니다. 찾지 못한 위반 개수는 표시 노드의 진행 이벤트 `unmatched_violation_count`에서 확인할 수 있습니다. 이미 생성된 검토 파일은 자동으로 갱신되지 않으므로 새 동작을 적용하려면 원본 HWPX를 다시 검토해 저장해야 합니다.

### 형광펜 표시와 원문 보존

| 데이터 | 최종 반환 시 기준 |
| --- | --- |
| `result.document` | 위반 범위에 HWPX 네이티브 형광펜을 적용한 `python-hwpx.HwpxDocument` 객체 |
| `result.document` 저장 | 호출자가 `save_to_path()` 또는 `to_bytes()`를 명시적으로 호출 |
| 추출·분류 결과와 위반 목록의 원문·서식 | 검증 당시의 태그 없는 원문 기준 |
| `source_hwpx_bytes` | 입력한 원본 파일 그대로 |

표시 노드는 내부 분석 모델에서 위반 위치를 찾고 보존해 둔 원본 run 인덱스를 통해 `python-hwpx` 문서에 HWPX 네이티브 형광펜만 반영합니다. 본문 텍스트나 추출·검증 모델은 바뀌지 않습니다. 라이브러리는 파일을 자동 저장하지 않으며, `result.document`는 호출자가 저장할 때 사용할 수 있습니다. [표시 노드 상세 안내](thesis_review_agent/graph/nodes/annotation/highlight_violations_node/README.md)에서 위치 매칭과 처리 범위를 확인할 수 있습니다.

## 검증 흐름

```text
HWPX 변환 → 문장 분류
  ├─ 본문 인용 추출·분류
  └─ 참고문헌 추출
       ├─ 자료유형 분류 → 유형별 TYP 검증
       └─ 일반 REF 규칙용 특성 분류
  본문 인용·참고문헌 대응 대조
  TYP 검증·REF 특성 분류·대응 대조 결과 → REF 검증 → CIT 검증
  → violation_judge_node (정규식이 확정하지 못한 위반 의심만 AI 판정)
  → review_node (항상 AI 최종 리뷰) → highlight_violations_node
  → 형광펜이 적용된 `HwpxDocument` 반환
```

분류가 필요한 적용 조건이나 자료 특성은 내부 AI 노드에서 판별하고, 해당 분류 결과를 바탕으로 검증 노드가 규칙을 적용합니다. 검증 뒤 `review_node`는 위반 후보만 바탕으로 최종 의견을 항상 생성한 다음 표시 노드가 `HwpxDocument`에 native 형광펜을 적용합니다. 최종 리뷰를 위해 추가 AI 호출이 발생합니다. 표시 노드와 REF-001·002용 대응 대조는 AI를 호출하지 않습니다. 노드별 내부 동작은 [그래프 실행 흐름](thesis_review_agent/graph/README.md), [최종 리뷰 노드](thesis_review_agent/graph/nodes/reviewer/review_node/README.md), [유형별 TYP 실행 조건](thesis_review_agent/graph/nodes/validation/reference_type_validation_node/README.md), [표시 노드 상세 안내](thesis_review_agent/graph/nodes/annotation/highlight_violations_node/README.md)를 참고하세요.

참고문헌 자료유형 검증은 항목마다 상위 유형과 세부 유형 하나를 선택합니다. 도서·회의·학위논문의 세부 분류는 여러 `is_*` 유형 flag 대신 `material_kind` 하나로 반환합니다. 정기간행물의 독자적 DB도 `periodical_type=dedicated_content_database`로 표현합니다. 선택한 유형에 필요한 규칙들은 함께 적용합니다. 예를 들어 TV 에피소드는 저자 역할과 에피소드 형식을 함께 검사하지만 영화나 팟캐스트 형식은 검사하지 않습니다. [유형별 실행 조건](thesis_review_agent/graph/nodes/validation/reference_type_validation_node/README.md)에서 구체적인 경로를 확인할 수 있습니다.

## 테스트

개발 의존성을 설치한 뒤 기본 테스트를 실행합니다. 이 명령은 네트워크/API를 사용하지 않는 테스트를 실행하고 실제 AI 통합 테스트는 건너뜁니다.

```bash
python -m pip install -e '.[dev]'
python -m pytest
```

실제 HWPX 파일과 AI API를 사용하는 통합 테스트는 프로젝트 루트의 `.env`에서 설정을 읽습니다. OpenAI 기본 경로를 테스트하려면 `OPENAI_API_KEY`만 지정하고 `AGENT_*` 설정은 비워 둡니다. 다른 클라우드 제공자나 로컬 서버를 테스트하려면 `AGENT_BASE_URL`, `AGENT_MODEL`, `AGENT_API_KEY`를 해당 서버에 맞춰 지정합니다. `AGENT_TOKEN`은 `AGENT_API_KEY`의 별칭이며, `OPENAI_BASE_URL`, `OPENAI_MODEL`, `OPENAI_API_KEY`는 각 `AGENT_*` 값의 fallback입니다. 커스텀 서버에서는 모델 ID를 지정하세요. 기본 입력 파일은 프로젝트 루트의 `para.hwpx`이며, 다른 파일은 `THESIS_REVIEW_HWPX`로 지정할 수 있습니다.

```dotenv
# OpenAI 기본 클라이언트 사용 시에는 이 키만 설정
# OPENAI_API_KEY=your-openai-api-key

# OpenRouter 또는 로컬 서버 사용 시 아래 값을 해당 서버에 맞게 설정
AGENT_BASE_URL=https://openrouter.ai/api/v1
AGENT_MODEL=provider/model-slug
AGENT_API_KEY=your-provider-api-key

# 로컬 서버 예시 (위 AGENT_* 값 대신 사용)
# AGENT_BASE_URL=http://127.0.0.1:8000/v1
# AGENT_MODEL=local-model-id
# AGENT_API_KEY=local
```

OpenRouter 무료 모델에서 `429`와 `free-models-per-day` 오류가 나면 무료 모델 일일 한도를 모두 사용한 것입니다. 한도 초기화를 기다리거나 크레딧을 추가하고, 필요하면 무료 제한이 없는 유료 모델 ID로 바꾸세요. 설정을 확인하기 전에 테스트를 반복 실행하면 실패 요청도 한도에 포함될 수 있습니다. [OpenRouter 요금 및 한도 안내](https://openrouter.ai/pricing)를 확인하세요.

```bash
RUN_AI_INTEGRATION_TESTS=1 python -m pytest -m integration -s
```

## 제한 사항

- 지원 입력은 HWPX 바이트이며, DOCX/PDF 입력은 지원하지 않습니다. 수정 문서는 `result.document`로 반환하며 파일 저장은 호출자가 수행합니다.
- 형광펜과 범위 시작점의 `CLICK_HERE` 필드 이름(`hp:fieldBegin/@name="hl-..."`)은 HWPX에 저장됩니다. 모든 위반이 정확한 글자 단위로 표시되는 것은 아니며, 표시 여부와 무관하게 위반 목록을 함께 확인해야 합니다.
- 추출·분류가 잘못되거나 문서 서식 정보가 충분하지 않으면 위반을 놓치거나 오탐할 수 있습니다.
- 그래프 실행 중 발생한 예외는 호출자에게 전달됩니다.
