Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions app/services/notice_crawler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@
NOTICE_LIST_URL = "https://www.gachon.ac.kr/dormitory/2351/subview.do"
NOTICE_ARTICLE_LIMIT = 10
NOTICE_DATE_FORMAT = "%Y.%m.%d"
DETAIL_TEXT_BLOCK_TAGS = {
"article",
"dd",
"div",
"dt",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"li",
"p",
"td",
"th",
}


@dataclass(frozen=True)
Expand Down Expand Up @@ -156,19 +172,16 @@ def parse_notice_detail_page(html: str, title: str) -> str:
"""상세 HTML에서 본문 텍스트만 추출합니다."""

soup = BeautifulSoup(html, "html.parser")
text_lines = [
_normalize_text(line)
for line in soup.get_text("\n", strip=True).splitlines()
if _normalize_text(line)
]
text_lines = _extract_detail_text_lines(soup)
if not text_lines:
raise ValueError("failed to extract notice detail content")

content_start_index = _find_content_start_index(text_lines)
content_end_index = _find_content_end_index(text_lines)
content_lines = text_lines[content_start_index:content_end_index]

if content_lines and content_lines[0] == _normalize_text(title):
normalized_title = _normalize_text(title)
while content_lines and content_lines[0] == normalized_title:
content_lines = content_lines[1:]

content = "\n".join(content_lines).strip()
Expand All @@ -177,6 +190,31 @@ def parse_notice_detail_page(html: str, title: str) -> str:
return content


def _extract_detail_text_lines(soup: BeautifulSoup) -> list[str]:
"""블록 경계는 보존하고, 블록 내부의 조각난 inline 텍스트는 한 줄로 합칩니다."""

text_lines: list[str] = []

for element in soup.find_all(DETAIL_TEXT_BLOCK_TAGS):
if element.find(DETAIL_TEXT_BLOCK_TAGS):
continue

text = _normalize_text(element.get_text(" ", strip=True))
if not text:
continue

text_lines.append(text)

if text_lines:
return text_lines

return [
_normalize_text(line)
for line in soup.get_text("\n", strip=True).splitlines()
if _normalize_text(line)
]
Comment on lines +196 to +215

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

현재 구현된 _extract_detail_text_lines 로직은 데이터 유실 위험이 있습니다. element.find(DETAIL_TEXT_BLOCK_TAGS)를 통해 자식 블록 요소가 있는 부모 요소를 건너뛰게 되면, 부모 요소의 직접적인 자식인 텍스트 노드들이 무시됩니다.

예를 들어 <div>본문 시작 <p>중간 내용</p> 본문 끝</div>과 같은 구조에서 "본문 시작"과 "본문 끝"은 유실되고 "중간 내용"만 추출됩니다. 블록 요소들에 명시적으로 개행 문자를 삽입한 뒤 텍스트를 추출하는 방식이 더 안전하고 의도한 대로 인라인 태그들을 합칠 수 있습니다.

    for element in soup.find_all(DETAIL_TEXT_BLOCK_TAGS):
        element.append("\n")

    text = soup.get_text(" ", strip=True)
    return [
        _normalize_text(line)
        for line in text.splitlines()
        if _normalize_text(line)
    ]



def _extract_posted_at(text: str) -> Optional[datetime]:
for token in text.split():
if len(token) == 10 and token[4] == "." and token[7] == ".":
Expand Down
47 changes: 41 additions & 6 deletions app/services/summarizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
settings = get_settings()
client = OpenAI(api_key=settings.openai_api_key)


def _to_text(value) -> str:
if value is None:
return ""
Expand All @@ -26,16 +25,52 @@ def _to_text(value) -> str:
return str(value)


def _format_schedule_info(value) -> str:
if value is None:
return ""

if isinstance(value, str):
return value.strip()

if isinstance(value, list):
return "\n".join(text for item in value if (text := _format_schedule_info(item)))

if isinstance(value, dict):
dormitory = _to_text(value.get("dormitory") or value.get("생활관"))
time = _to_text(value.get("time") or value.get("시간"))
if dormitory and time:
return f"{dormitory} {time}"

return "\n".join(text for val in value.values() if (text := _format_schedule_info(val)))

return str(value)


def _format_caution_info(value) -> str:
return _to_text(value)
Comment on lines +49 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

_format_caution_info가 사용하는 _to_text는 딕셔너리 처리 시 키 이름을 포함합니다 (예: {"caution": "..."} -> "caution: ..."). 이는 72번 라인의 프롬프트 지침("caution, warning 같은 키 이름을 내용에 포함하지 마")과 충돌할 수 있습니다. LLM이 지침을 어기고 객체 형태로 응답할 경우를 대비해, _format_schedule_info처럼 키를 제외하고 값만 추출하도록 개선하는 것이 좋습니다.

def _format_caution_info(value) -> str:
    if value is None:
        return ""

    if isinstance(value, str):
        return value.strip()

    if isinstance(value, list):
        return "\n".join(text for item in value if (text := _format_caution_info(item)))

    if isinstance(value, dict):
        return "\n".join(text for val in value.values() if (text := _format_caution_info(val)))

    return str(value)



def summarize_notice(title: str, content: str) -> NoticeSummaryData:
settings = get_settings()
prompt = f"""
다음 공지사항을 읽고 JSON만 반환해줘.

반드시 아래 키를 포함해:
- summary: 2~4문장 핵심 요약
- summary: 3~6문장 핵심만 요약
- target_info: 대상자 정보가 있으면 정리, 없으면 null
- schedule_info: 주요 일정 정보가 있으면 정리, 없으면 null
- caution_info: 유의사항 정보가 있으면 정리, 없으면 null
- schedule_info: 주요 일정 정보가 있으면 문자열로 정리, 없으면 null
- caution_info: 유의사항 정보가 있으면 문자열로 정리, 없으면 null

schedule_info 작성 규칙:
- 반드시 문자열 또는 null로 반환해. 객체나 배열로 반환하지 마.
- date, details 같은 키 이름을 내용에 포함하지 마.
- 요일과 시간만 적어. 양식은 ####년 ##월 ##일, 시간은 오전 또는 오후 #시 이렇게.
- 생활관 별로 시간이 다르면 생활간 별 시간으로 적어.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

프롬프트 내에 오타가 있습니다. 생활간생활관으로 수정하는 것이 좋습니다. LLM이 문맥상 이해할 수는 있겠지만, 정확한 용어 전달이 요약 품질 유지에 도움이 됩니다.

Suggested change
- 생활관 별로 시간이 다르면 생활간 시간으로 적어.
- 생활관 별로 시간이 다르면 생활관 시간으로 적어.


caution_info 작성 규칙:
- 반드시 문자열 또는 null로 반환해. 객체나 배열로 반환하지 마.
- caution, warning 같은 키 이름을 내용에 포함하지 마.
- 불이익, 제출 방법, 준비물, 제한사항처럼 사용자가 주의해야 할 내용만 넣어.

제목:
{title}
Expand All @@ -59,7 +94,7 @@ def summarize_notice(title: str, content: str) -> NoticeSummaryData:
return NoticeSummaryData(
summary=_to_text(payload.get("summary")),
target_info=_to_text(payload.get("target_info")),
schedule_info=_to_text(payload.get("schedule_info")),
caution_info=_to_text(payload.get("caution_info")),
schedule_info=_format_schedule_info(payload.get("schedule_info")),
caution_info=_format_caution_info(payload.get("caution_info")),
generated_model=response.model,
)
27 changes: 27 additions & 0 deletions tests/test_notice_crawler.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,33 @@ def test_parse_notice_detail_page_extracts_body_without_metadata_and_attachments
assert "2. 참여보고서 제출 : 해당 생활관 사감실 제출" in content


def test_parse_notice_detail_page_joins_fragmented_inline_text() -> None:
content = parse_notice_detail_page(
"""
<html>
<body>
<div>등록일</div>
<div>2026.04.10</div>
<h2>학생생활관 중간고사 통금시간 일정 안내</h2>
<div>학생생활관 중간고사 통금시간 일정 안내</div>
<div>
<span>2026</span>
<span>년 1</span>
<span>학기 중간고사 내 출입 통제 시간</span>
<span>24</span>
<span>시간 개방으로 시행합니다</span>
<span>.</span>
</div>
<div>첨부파일</div>
</body>
</html>
""",
title="학생생활관 중간고사 통금시간 일정 안내",
)

assert content == "2026 년 1 학기 중간고사 내 출입 통제 시간 24 시간 개방으로 시행합니다 ."


def test_crawl_recent_notice_payloads_filters_old_notices_and_fetches_details() -> None:
page_1_url = (
"https://www.gachon.ac.kr/dormitory/2351/subview.do"
Expand Down
43 changes: 42 additions & 1 deletion tests/test_summarizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,52 @@ def test_summarize_notice_normalizes_structured_llm_fields(monkeypatch) -> None:

assert result.summary == "핵심: 생활관 소독 안내입니다."
assert result.target_info == "소독 대상: 전체 생활관"
assert result.schedule_info == "일시: 2026년 5월 8일 10시"
assert result.schedule_info == "2026년 5월 8일 10시"
assert result.caution_info == "귀중품은 보관해 주세요.\n소독 시간에는 협조 바랍니다."
assert result.generated_model == "fake-model"


def test_summarize_notice_removes_structured_schedule_keys(monkeypatch) -> None:
payload = {
"summary": "요약",
"target_info": None,
"schedule_info": {
"control_period": "2026년 5월 13일 수요일 오전 11시 이후",
"details": [
{"dormitory": "제1학생생활관", "time": "오후 1시부터"},
{"dormitory": "제2학생생활관", "time": "오전 9시부터"},
],
},
"caution_info": "신청 기간을 확인해 주세요.",
}

fake_response = SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(content=json.dumps(payload, ensure_ascii=False))
)
],
model="fake-model",
)
fake_client = SimpleNamespace(
chat=SimpleNamespace(
completions=SimpleNamespace(create=lambda **_kwargs: fake_response)
)
)
monkeypatch.setattr(summarizer, "client", fake_client)

result = summarizer.summarize_notice("일정 안내", "2026. 5. 13(수) 11:00 이후")

assert result.schedule_info == (
"2026년 5월 13일 수요일 오전 11시 이후\n"
"제1학생생활관 오후 1시부터\n"
"제2학생생활관 오전 9시부터"
)
assert "control_period" not in result.schedule_info
assert "details" not in result.schedule_info
assert result.caution_info == "신청 기간을 확인해 주세요."


def test_to_text_omits_empty_nested_values() -> None:
assert summarizer._to_text(["A", None, " ", "B"]) == "A\nB"
assert summarizer._to_text({"대상": "전체", "비고": None, "공백": " "}) == "대상: 전체"
Loading