"""KPI 챗봇 — 38개 핵심성과지표(對外秘) 컨텍스트 기반 답변. VOC 챗봇과 같은 Space에서 운영. VOC가 ChromaDB RAG라면 이쪽은 정형 표 데이터를 시스템 프롬프트에 통째로 주입(prompt cache 적용). 질문이 들어오면 자동 라우터가 VOC vs KPI 모드를 결정. 명시적 모드 선택도 가능 (app.py에서 라디오로 노출). """ from __future__ import annotations import json import re from pathlib import Path from typing import Iterator from anthropic import Anthropic ROOT = Path(__file__).resolve().parent KPI_DIR = ROOT / "kpi_data" KPI_MODEL = "claude-haiku-4-5-20251001" # 비용·속도 우선. 더 정확한 분석이 필요하면 "claude-opus-4-7" # Keywords that strongly suggest KPI mode rather than VOC mode KPI_HINTS = [ "충원율", "재학률", "재학생", "신입생", "편입", "외국인학생", "계약학과", "중도탈락", "취업률", "정주율", "진학률", "전임교원", "1인당", "저역서", "교내연구비", "교외연구비", "기술이전", "특허", "교원창업", "학생창업", "창업강좌", "적립금", "기부금", "수익용", "교육비", "법인전입금", "법정부담금", "인건비비율", "강사강의료", "등록금비율", "확보율", "K-MOOC", "산업체경력", "공동활용", "자료구입비", "교지", "교사확보", "기숙사수용", "충청권", "전국순위", "충청순위", "지표", "성과지표", "KPI", "핵심성과", "對外秘", "보고서", "학생영역", "성과영역", "재정영역", "여건영역", ] KPI_CODE_RE = re.compile(r"(학생|성과|재정|여건)[ ]*-[ ]*\d+") def is_kpi_question(text: str) -> bool: if not text: return False if KPI_CODE_RE.search(text): return True return any(kw in text for kw in KPI_HINTS) def _trim_rows(rows, max_rows=30): if not isinstance(rows, list): return rows return rows[:max_rows] def _hoseo_row(rows): if not isinstance(rows, list): return None for r in rows: if isinstance(r, list) and any(c and ("호서대" in str(c) or "호 서" in str(c)) for c in r): return r return None class KpiChatbot: def __init__(self) -> None: if not KPI_DIR.exists(): raise RuntimeError(f"KPI 데이터 없음: {KPI_DIR}") self._master = json.loads((KPI_DIR / "master.json").read_text(encoding="utf-8")) self._index = json.loads((KPI_DIR / "indicators_index.json").read_text(encoding="utf-8")) # Build a compact corpus for prompt injection: master + per-indicator excerpts. corpus_indicators = [] for f in sorted((KPI_DIR / "indicators").glob("*.json")): d = json.loads(f.read_text(encoding="utf-8")) corpus_indicators.append({ "id": d.get("id"), "code": d.get("code"), "area": d.get("area"), "title": d.get("title"), "formula": d.get("formula"), "description": d.get("description"), "page": d.get("page"), "subs": d.get("subs"), "hoseo_trends": [ {"page": t.get("page"), "n_rows": t.get("n_rows"), "rows": _trim_rows(t.get("rows"), 30)} for t in (d.get("hoseo_trends") or []) ], "comparison_excerpt": [ { "page": t.get("page"), "n_rows": t.get("n_rows"), "head": _trim_rows(t.get("rows"), 6), "hoseo_row": _hoseo_row(t.get("rows")), } for t in (d.get("comparison_tables") or []) ], "status": d.get("status_blocks") or [], }) self._corpus = {"master": self._master, "indicators": corpus_indicators} self._anthropic = Anthropic() def _system_blocks(self) -> list[dict]: intro = ( "당신은 호서대학교 IR(Institutional Research) 대시보드의 KPI 분석 어시스턴트입니다.\n" "총장·부총장·처장 등 의사결정권자와 38개 핵심성과지표를 검토하기 위한 도구입니다.\n\n" "# 행동 원칙\n" "- 답변은 한국어로 작성합니다.\n" "- 모든 수치 인용 시 보고서 페이지 번호를 [p.X] 형태로 명시합니다. 예: '재학생충원율 94.1% [p.25]'\n" "- 추측하지 말고, 컨텍스트에 명시된 데이터로만 답변합니다. 데이터에 없으면 '보고서에 명시된 값이 없습니다'라고 답하세요.\n" "- 영역(학생/성과/재정/여건)과 코드(예: [학생-2])를 가능한 한 명시합니다.\n" "- 호서대 행과 충청권/전국 순위는 핵심 지표이므로 답변 시 함께 인용합니다.\n" "- 답변은 3~7문장 간결체. 표 비교가 필요하면 마크다운 표 사용.\n\n" "# 對外秘 가드 (IR-2025-01)\n" "- 본 자료는 對外秘 자료입니다. 외부 공개·언론 인용·제3자 공유에 사용될 가능성이 있는 답변은 거부하고, 내부 의사결정 검토용으로 전환할 것을 안내합니다.\n" "- 보도자료/홍보 문구/외부 인용 가능한 형태로 작성을 요청받으면 정중히 거부합니다.\n\n" "# 데이터 구조\n" "- master: 38개 지표의 영역별 인덱스, 호서대 2024/2025 값, 충청권/전국 순위, 추세\n" "- indicators[]: 각 지표의 메타정보, 호서대 5년 추이 표, 비교표 발췌, 현황분석/대응전략\n" ) return [ {"type": "text", "text": intro}, { "type": "text", "text": "## MASTER\n```json\n" + json.dumps(self._corpus["master"], ensure_ascii=False, indent=2) + "\n```", }, { "type": "text", "text": "## INDICATORS\n```json\n" + json.dumps(self._corpus["indicators"], ensure_ascii=False, indent=2) + "\n```", "cache_control": {"type": "ephemeral"}, }, ] def answer_stream(self, question: str, history: list[dict] | None = None) -> Iterator[str]: messages = [] if history: for m in history: role = m.get("role") content = m.get("content") if role in ("user", "assistant") and content: messages.append({"role": role, "content": str(content)}) messages.append({"role": "user", "content": question}) with self._anthropic.messages.stream( model=KPI_MODEL, max_tokens=4096, system=self._system_blocks(), messages=messages, ) as stream: for text in stream.text_stream: yield text