Spaces:
Runtime error
Runtime error
Download utils.py from VioletWizard/Talk-Trace-AI: direct link, hf CLI and curl.
- Browser
- Download file 12.7 kB
-
https://huggingface.co/spaces/VioletWizard/Talk-Trace-AI/resolve/main/utils.py
- Command line
-
hf download hf://spaces/VioletWizard/Talk-Trace-AI/utils.py
-
curl -L -o utils.py https://huggingface.co/spaces/VioletWizard/Talk-Trace-AI/resolve/main/utils.py
12.7 kB
| import io | |
| import logging | |
| import re | |
| from datetime import datetime, timedelta, timezone | |
| import streamlit as st | |
| logger = logging.getLogger("talk_trace_ai") | |
| DATETIME_FMT = "%Y-%m-%d %H:%M:%S" | |
| DISPLAY_DATETIME_FMT = "%Y-%m-%d %p %I:%M:%S" | |
| KST = timezone(timedelta(hours=9)) | |
| def get_kst_now(): | |
| return datetime.now(tz=KST) | |
| def get_kst_now_str(): | |
| return get_kst_now().strftime(DATETIME_FMT) | |
| def format_kst_datetime(value): | |
| if value is None: | |
| return "-" | |
| kst_tz = timezone(timedelta(hours=9)) | |
| parsed_dt = None | |
| should_assume_utc = False | |
| if isinstance(value, datetime): | |
| parsed_dt = value | |
| else: | |
| raw = str(value).strip() | |
| if not raw: | |
| return "-" | |
| if "T" in raw and "+" not in raw and "Z" not in raw: | |
| should_assume_utc = True | |
| iso_candidate = raw.replace("Z", "+00:00") | |
| try: | |
| parsed_dt = datetime.fromisoformat(iso_candidate) | |
| except ValueError: | |
| for fmt in ("%Y-%m-%d %H:%M:%S.%f", DATETIME_FMT): | |
| try: | |
| parsed_dt = datetime.strptime(raw, fmt) | |
| break | |
| except ValueError: | |
| continue | |
| if parsed_dt is None: | |
| return str(value) | |
| if parsed_dt.tzinfo is not None: | |
| parsed_dt = parsed_dt.astimezone(kst_tz) | |
| elif should_assume_utc: | |
| parsed_dt = parsed_dt.replace(tzinfo=timezone.utc).astimezone(kst_tz) | |
| return parsed_dt.strftime(DISPLAY_DATETIME_FMT) | |
| def get_client_ip(): | |
| try: | |
| headers = st.context.headers | |
| except Exception: | |
| return "" | |
| if not headers: | |
| return "" | |
| for key in ["x-forwarded-for", "x-real-ip", "cf-connecting-ip", "fly-client-ip", | |
| "X-Forwarded-For", "X-Real-Ip", "CF-Connecting-IP", "True-Client-Ip", | |
| "true-client-ip"]: | |
| raw_ip = headers.get(key) | |
| if raw_ip: | |
| return str(raw_ip).split(",")[0].strip() | |
| try: | |
| all_keys = list(headers.keys()) if headers else [] | |
| logger.info("get_client_ip: IP 헤더 없음. 수신된 헤더 키: %s", all_keys) | |
| except Exception: | |
| pass | |
| return "" | |
| def anonymize_ip(raw_ip: str) -> str | None: | |
| """IP를 익명화하여 반환합니다. 저장 불가 시 None 반환. | |
| IPv4: 첫 번째·마지막 옥텟 유지, 중간 0으로 대체 (예: 165.0.0.41) | |
| IPv6: 앞 4그룹·마지막 그룹 유지, 중간 :: 처리 (예: 2406:5900:117c:424b::4444) | |
| → 같은 Wi-Fi라도 기기별 구분 가능 | |
| """ | |
| ip = str(raw_ip or "").strip() | |
| if not ip: | |
| return None | |
| if ":" in ip: # IPv6 | |
| groups = [g for g in ip.split(":") if g] # 빈 그룹 제거 | |
| if len(groups) >= 5: | |
| return ":".join(groups[:4]) + "::" + groups[-1] | |
| if len(groups) >= 4: | |
| return ":".join(groups[:4]) + "::" | |
| return None | |
| parts = ip.split(".") # IPv4 | |
| if len(parts) == 4: | |
| return f"{parts[0]}.0.0.{parts[3]}" | |
| return None | |
| def log_audit(event, room_name="", actor_name="", role="", **extra): | |
| logger.info( | |
| "AUDIT event=%s room=%s actor=%s role=%s extra=%s", | |
| event, room_name, actor_name, role, extra, | |
| ) | |
| def to_bool_flag(value): | |
| if isinstance(value, bool): | |
| return value | |
| if value is None: | |
| return False | |
| return str(value).strip().lower() in {"true", "t", "1", "yes", "y", "on"} | |
| _FONT_CANDIDATES = [ | |
| "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", | |
| "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", | |
| "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", | |
| ] | |
| _FONT_BOLD_CANDIDATES = [ | |
| "/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf", | |
| "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", | |
| "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", | |
| ] | |
| _AI_SUBSECTION_LABELS = ("배움의 변화:", "성장한 점:", "한 줄 요약:") | |
| def _get_pil_font(size: int): | |
| from PIL import ImageFont | |
| for path in _FONT_CANDIDATES: | |
| try: | |
| return ImageFont.truetype(path, size) | |
| except Exception: | |
| continue | |
| return ImageFont.load_default() | |
| def _get_pil_font_bold(size: int): | |
| from PIL import ImageFont | |
| for path in _FONT_BOLD_CANDIDATES: | |
| try: | |
| return ImageFont.truetype(path, size) | |
| except Exception: | |
| continue | |
| return _get_pil_font(size) | |
| _EMOJI_TEXT_SUBS = [ | |
| # AI 피드백 카드 레이블 | |
| ("✅", "[완료]"), | |
| ("🌱", "[성장]"), | |
| # 섹션 마커 | |
| ("📌", ">>"), | |
| ("🔄", ">>"), | |
| ("🤖", "[AI]"), | |
| ("👨🏫", "[교사]"), | |
| # 기타 자주 쓰이는 이모지 | |
| ("💡", "[아이디어]"), | |
| ("➕", "[보충]"), | |
| ("❓", "[질문]"), | |
| ("🔵", "[찬성]"), | |
| ("🔴", "[반대]"), | |
| ("👍", "[좋아요]"), | |
| ("🎉", ""), | |
| ("⚠️", "[주의]"), | |
| ] | |
| def _strip_non_renderable(text: str) -> str: | |
| """이미지 렌더링 시 깨질 수 있는 이모지/심볼을 텍스트로 대체하거나 제거.""" | |
| # 1단계: 알려진 이모지를 텍스트 레이블로 치환 | |
| for emoji, replacement in _EMOJI_TEXT_SUBS: | |
| text = (text or "").replace(emoji, replacement) | |
| # 2단계: 나머지 렌더링 불가 문자 제거 | |
| result = [] | |
| for ch in (text or ""): | |
| code = ord(ch) | |
| # 4바이트 유니코드 (이모지 대부분) | |
| if code > 0xFFFF or 0xD800 <= code <= 0xDFFF: | |
| continue | |
| # BMP 내 이모지/기호 블록 | |
| if 0x2190 <= code <= 0x27FF: # 화살표, 기술기호, 딩뱃(✅ 등) | |
| continue | |
| if 0x2B00 <= code <= 0x2BFF: # 기타 기호·화살표 | |
| continue | |
| if 0xFE00 <= code <= 0xFEFF: # 변형 선택자 등 | |
| continue | |
| result.append(ch) | |
| return "".join(result) | |
| def _wrap_to_width(draw, text: str, font, max_width: int) -> list: | |
| lines = [] | |
| for para in _strip_non_renderable(text).split("\n"): | |
| if not para.strip(): | |
| lines.append("") | |
| continue | |
| words = para.split() | |
| line: list = [] | |
| for word in words: | |
| test = " ".join(line + [word]) | |
| try: | |
| w = draw.textbbox((0, 0), test, font=font)[2] | |
| except Exception: | |
| w = len(test) * (font.size // 2 if hasattr(font, "size") else 9) | |
| if w <= max_width: | |
| line.append(word) | |
| else: | |
| if line: | |
| lines.append(" ".join(line)) | |
| line = [word] | |
| else: | |
| lines.append(word) | |
| if line: | |
| lines.append(" ".join(line)) | |
| return lines or [""] | |
| def create_analysis_image( | |
| student_name: str, | |
| topic: str, | |
| pre_opinion: str, | |
| post_opinion: str, | |
| ai_analysis: str, | |
| ai_feedback: str = "", | |
| ) -> bytes: | |
| """AI 배움 분석 결과를 PNG 이미지 bytes로 반환합니다.""" | |
| from PIL import Image, ImageDraw | |
| W, PAD = 800, 40 | |
| CONTENT_W = W - PAD * 2 | |
| C_BG = (255, 255, 255) | |
| C_HDR_BG = (25, 80, 160) | |
| C_HDR_FG = (255, 255, 255) | |
| C_HDR_SUB = (170, 200, 240) | |
| C_ACCENT = (25, 80, 160) | |
| C_TEXT = (30, 30, 30) | |
| C_MUTED = (110, 110, 110) | |
| C_DIVIDER = (220, 220, 220) | |
| C_FTR_BG = (245, 245, 248) | |
| f_title = _get_pil_font(22) | |
| f_label = _get_pil_font(15) | |
| f_sublabel = _get_pil_font_bold(16) | |
| f_body = _get_pil_font(17) | |
| f_small = _get_pil_font(13) | |
| LH_BODY = 27 # body line height px | |
| LH_LABEL = 22 | |
| LH_SUBLBL = 24 | |
| SEC_GAP = 16 | |
| SUBSEC_GAP = 14 # extra gap between AI subsections | |
| HEADER_H = 74 | |
| FOOTER_H = 38 | |
| DIV_H = 20 | |
| def _ai_paragraphs(text): | |
| """AI 분석 텍스트를 (is_label, label_str, body_str) 튜플 리스트로 파싱.""" | |
| result = [] | |
| for para in _strip_non_renderable(text or "").split("\n"): | |
| para = para.strip() | |
| if not para: | |
| continue | |
| matched = next((lbl for lbl in _AI_SUBSECTION_LABELS if para.startswith(lbl)), None) | |
| if matched: | |
| result.append((True, matched, para[len(matched):].strip())) | |
| else: | |
| result.append((False, "", para)) | |
| return result or [(False, "", "(분석 없음)")] | |
| # ── measure total height with dummy canvas ────────────────────────── | |
| dummy = Image.new("RGB", (W, 10)) | |
| d = ImageDraw.Draw(dummy) | |
| def section_h(text): | |
| return LH_LABEL + 10 + len(_wrap_to_width(d, text, f_body, CONTENT_W)) * LH_BODY + SEC_GAP | |
| def ai_section_h(text): | |
| h = LH_LABEL + 10 # "AI 배움 분석" 헤더 | |
| paras = _ai_paragraphs(text) | |
| for i, (is_lbl, lbl, body) in enumerate(paras): | |
| if is_lbl and i > 0: | |
| h += SUBSEC_GAP | |
| if is_lbl: | |
| h += LH_SUBLBL + 6 | |
| h += len(_wrap_to_width(d, body if is_lbl else lbl + body, f_body, CONTENT_W)) * LH_BODY | |
| return h + SEC_GAP | |
| total_h = ( | |
| HEADER_H + 18 | |
| + LH_LABEL + 14 # info line | |
| + DIV_H | |
| + section_h(pre_opinion) | |
| + section_h(post_opinion) | |
| + DIV_H | |
| + ai_section_h(ai_analysis) | |
| + (DIV_H + section_h(ai_feedback) if ai_feedback else 0) | |
| + FOOTER_H | |
| ) | |
| # ── render ────────────────────────────────────────────────────────── | |
| img = Image.new("RGB", (W, total_h), C_BG) | |
| draw = ImageDraw.Draw(img) | |
| # Header | |
| draw.rectangle([0, 0, W, HEADER_H], fill=C_HDR_BG) | |
| draw.text((PAD, 14), "말자취 Talk-Trace AI", font=f_small, fill=C_HDR_SUB) | |
| draw.text((PAD, 34), "AI 배움 분석 결과", font=f_title, fill=C_HDR_FG) | |
| y = HEADER_H + 18 | |
| # Student / topic info line | |
| info = f"학생: {_strip_non_renderable(student_name)} | 주제: {_strip_non_renderable(topic)}" | |
| draw.text((PAD, y), info, font=f_label, fill=C_MUTED) | |
| y += LH_LABEL + 14 | |
| def divider(): | |
| nonlocal y | |
| draw.line([PAD, y, W - PAD, y], fill=C_DIVIDER, width=1) | |
| y += DIV_H | |
| def section(label, text): | |
| nonlocal y | |
| draw.text((PAD, y), label, font=f_label, fill=C_ACCENT) | |
| y += LH_LABEL + 10 | |
| for line in _wrap_to_width(draw, text, f_body, CONTENT_W): | |
| draw.text((PAD, y), line, font=f_body, fill=C_TEXT) | |
| y += LH_BODY | |
| y += SEC_GAP | |
| def ai_section(text): | |
| nonlocal y | |
| draw.text((PAD, y), "AI 배움 분석", font=f_label, fill=C_ACCENT) | |
| y += LH_LABEL + 10 | |
| for i, (is_lbl, lbl, body) in enumerate(_ai_paragraphs(text)): | |
| if is_lbl and i > 0: | |
| y += SUBSEC_GAP | |
| if is_lbl: | |
| # 소제목: 굵은 강조색 | |
| draw.text((PAD, y), lbl, font=f_sublabel, fill=C_ACCENT) | |
| y += LH_SUBLBL + 6 | |
| for line in _wrap_to_width(draw, body, f_body, CONTENT_W): | |
| draw.text((PAD + 8, y), line, font=f_body, fill=C_TEXT) | |
| y += LH_BODY | |
| else: | |
| full = (lbl + body).strip() | |
| for line in _wrap_to_width(draw, full, f_body, CONTENT_W): | |
| draw.text((PAD, y), line, font=f_body, fill=C_TEXT) | |
| y += LH_BODY | |
| y += SEC_GAP | |
| divider() | |
| section("토론 전 생각", pre_opinion or "(없음)") | |
| section("토론 후 생각", post_opinion or "(없음)") | |
| divider() | |
| ai_section(ai_analysis) | |
| if ai_feedback: | |
| divider() | |
| section("AI 피드백 카드 (잘한 점 / 발전할 점)", ai_feedback) | |
| # Footer | |
| draw.rectangle([0, total_h - FOOTER_H, W, total_h], fill=C_FTR_BG) | |
| now_str = datetime.now(tz=KST).strftime("%Y-%m-%d %H:%M") | |
| draw.text((PAD, total_h - FOOTER_H + 13), f"생성: {now_str}", font=f_small, fill=C_MUTED) | |
| buf = io.BytesIO() | |
| img.save(buf, format="PNG", optimize=True) | |
| return buf.getvalue() | |
| def compact_ai_report_output(text): | |
| raw_lines = [str(line).strip() for line in str(text or "").splitlines()] | |
| cleaned = [line for line in raw_lines if line and not line.startswith("#")] | |
| if not cleaned: | |
| return "" | |
| normalized_text = " ".join(cleaned) | |
| normalized_text = re.sub(r"\s*(핵심요약 [123]:|베스트 학생:|선정 이유:)", r"\n\1", normalized_text) | |
| normalized_lines = [line.strip() for line in normalized_text.splitlines() if line.strip()] | |
| return "\n".join(normalized_lines[:5]) | |