From 1529ef9e12c60dbc9993dd583c22f3df7aa15954 Mon Sep 17 00:00:00 2001 From: kimssirr Date: Thu, 7 May 2026 12:00:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EA=B3=B5=EC=A7=80=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=ED=8C=8C=EC=8B=B1=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/notice_crawler.py | 50 ++++++++++++++++++++++++++++++---- tests/test_notice_crawler.py | 27 ++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/app/services/notice_crawler.py b/app/services/notice_crawler.py index 17e1a23..aa226b5 100644 --- a/app/services/notice_crawler.py +++ b/app/services/notice_crawler.py @@ -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) @@ -156,11 +172,7 @@ 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") @@ -168,7 +180,8 @@ def parse_notice_detail_page(html: str, title: str) -> str: 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() @@ -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) + ] + + def _extract_posted_at(text: str) -> Optional[datetime]: for token in text.split(): if len(token) == 10 and token[4] == "." and token[7] == ".": diff --git a/tests/test_notice_crawler.py b/tests/test_notice_crawler.py index 8fc2a8d..3fe5fd9 100644 --- a/tests/test_notice_crawler.py +++ b/tests/test_notice_crawler.py @@ -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( + """ + + +
등록일
+
2026.04.10
+

학생생활관 중간고사 통금시간 일정 안내

+
학생생활관 중간고사 통금시간 일정 안내
+
+ 2026 + 년 1 + 학기 중간고사 내 출입 통제 시간 + 24 + 시간 개방으로 시행합니다 + . +
+
첨부파일
+ + + """, + 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" From 98e6d44e3174b203b989ca8368551c25b4e71e3f Mon Sep 17 00:00:00 2001 From: kimssirr Date: Fri, 8 May 2026 12:00:00 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EA=B3=B5=EC=A7=80=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EC=A0=95=EB=B3=B4=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/summarizer.py | 47 +++++++++++++++++++++++++++++++++----- tests/test_summarizer.py | 43 +++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/app/services/summarizer.py b/app/services/summarizer.py index f359f67..2e80d3e 100644 --- a/app/services/summarizer.py +++ b/app/services/summarizer.py @@ -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 "" @@ -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) + + 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 같은 키 이름을 내용에 포함하지 마. +- 요일과 시간만 적어. 양식은 ####년 ##월 ##일, 시간은 오전 또는 오후 #시 이렇게. +- 생활관 별로 시간이 다르면 생활간 별 시간으로 적어. + +caution_info 작성 규칙: +- 반드시 문자열 또는 null로 반환해. 객체나 배열로 반환하지 마. +- caution, warning 같은 키 이름을 내용에 포함하지 마. +- 불이익, 제출 방법, 준비물, 제한사항처럼 사용자가 주의해야 할 내용만 넣어. 제목: {title} @@ -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, ) diff --git a/tests/test_summarizer.py b/tests/test_summarizer.py index 7a388c9..df33afa 100644 --- a/tests/test_summarizer.py +++ b/tests/test_summarizer.py @@ -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, "공백": " "}) == "대상: 전체"