Talk-Trace-AI / utils.py
Claude
fix: IPv6 익명화 앞4그룹+마지막그룹 유지 (기기 구분 가능)
009a807 unverified
Raw History Blame Contribute Delete
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])