Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """JGOS-Origin Chat — FastAPI backend + 파일 변환. | |
| 지원: HWP, HWPX, PDF, DOCX, PPTX, XLSX, XLS, CSV, TXT, MD, 이미지(메타만) | |
| LLM: 외부 OpenAI 호환 API (env JGOS_LLM_BASE_URL + JGOS_LLM_API_KEY) | |
| HWP: 외부 HanSoo Space API 호출 (env JGOS_HWP_API_URL, 옵션) | |
| """ | |
| import os, io, json, base64, asyncio | |
| from typing import AsyncIterator, List | |
| from pathlib import Path | |
| from fastapi import FastAPI, Request, UploadFile, File, Form, HTTPException, Response, Body | |
| from fastapi.responses import StreamingResponse, FileResponse, JSONResponse, HTMLResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.middleware.cors import CORSMiddleware | |
| import httpx, secrets, time | |
| app = FastAPI(title="JGOS-Origin", description="전남광주 통합특별시민과 함께하는 AI JGOS") | |
| app.add_middleware( | |
| CORSMiddleware, allow_origins=["*"], allow_credentials=True, | |
| allow_methods=["*"], allow_headers=["*"], | |
| ) | |
| # === env === | |
| LLM_BASE_URL = os.getenv("JGOS_LLM_BASE_URL", "http://localhost:7908/v1") | |
| LLM_API_KEY = os.getenv("JGOS_LLM_API_KEY", "no-key") | |
| LLM_MODEL = os.getenv("JGOS_LLM_MODEL", "JGOS-31B-Origin") | |
| LLM_TIMEOUT = float(os.getenv("JGOS_LLM_TIMEOUT", "120")) | |
| HWP_API_URL = os.getenv("JGOS_HWP_API_URL", "") # optional HanSoo Space endpoint | |
| IMAGE_API_URL = os.getenv("JGOS_IMAGE_API_URL", "") # JGOS-Image (Qwen-Image-2512) B200 서버 — cl1 GPU1 | |
| IMAGE_API_KEY = os.getenv("JGOS_IMAGE_API_KEY", "no-key") | |
| # 웹 검색 (네이버 1차 한국 로컬 + Brave 2차 글로벌 보조) | |
| NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "") | |
| NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "") | |
| BRAVE_API_KEY = os.getenv("BRAVE_API_KEY", "") | |
| # ===== 방문 통계 카운터 (영구 저장: HF Dataset VIDraft/JGOS-stats) ===== | |
| import threading | |
| from datetime import datetime, timezone, timedelta | |
| HF_TOKEN = os.getenv("HF_TOKEN", "") | |
| STATS_REPO = "VIDraft/JGOS-stats" | |
| KST = timezone(timedelta(hours=9)) | |
| STATS = {"total_visits": 0, "ai_chat": 0, "image_gen": 0, "file_upload": 0, "office_open": 0, "web_search": 0, "by_day": {}} | |
| _stats_lock = threading.Lock() | |
| _req_since_save = 0 | |
| def _today_kst() -> str: | |
| return datetime.now(KST).strftime("%Y-%m-%d") | |
| def load_stats(): | |
| global STATS | |
| if not HF_TOKEN: | |
| return | |
| try: | |
| from huggingface_hub import hf_hub_download | |
| p = hf_hub_download(STATS_REPO, "stats.json", repo_type="dataset", token=HF_TOKEN) | |
| with open(p, encoding="utf-8") as f: | |
| STATS.update(json.load(f)) | |
| print("[stats] loaded:", STATS.get("total_visits"), "visits") | |
| except Exception as e: | |
| print("[stats] load skip:", e) | |
| def save_stats(): | |
| if not HF_TOKEN: | |
| return | |
| try: | |
| from huggingface_hub import HfApi | |
| data = json.dumps(STATS, ensure_ascii=False, indent=2).encode("utf-8") | |
| HfApi(token=HF_TOKEN).upload_file( | |
| path_or_fileobj=io.BytesIO(data), path_in_repo="stats.json", | |
| repo_id=STATS_REPO, repo_type="dataset", commit_message="stats update") | |
| except Exception as e: | |
| print("[stats] save fail:", e) | |
| def bump(kind: str): | |
| """방문/기능 카운트 +1. 15요청마다 HF Dataset 백그라운드 저장.""" | |
| global _req_since_save | |
| do_save = False | |
| with _stats_lock: | |
| d = _today_kst() | |
| day = STATS["by_day"].setdefault(d, {}) | |
| day[kind] = day.get(kind, 0) + 1 | |
| if kind in STATS and isinstance(STATS[kind], int): | |
| STATS[kind] += 1 | |
| _req_since_save += 1 | |
| if _req_since_save >= 15: | |
| _req_since_save = 0 | |
| do_save = True | |
| if do_save: | |
| threading.Thread(target=save_stats, daemon=True).start() | |
| MAX_UPLOAD_MB = 30 | |
| MAX_TEXT_CHARS = 30000 # 추출 텍스트 cap (LLM context 보호) | |
| # === 시스템 prompt (단축 — 응답 속도 우선) === | |
| SYSTEM_PROMPT = """당신은 전남광주 통합특별시민과 함께하는 AI **JGOS**입니다. | |
| 주민센터의 친절하고 따뜻한 상담 직원처럼, 시민 한 분 한 분을 정성껏 도와드리는 마음으로 응대합니다. | |
| 【★최우선 핵심 사실 — 반드시 숙지하고 정확히 반영】 | |
| - 2026년 6월 3일 전국동시지방선거 결과, **더불어민주당 민형배 후보가 전남광주 통합특별시 초대 시장으로 당선**되었습니다. | |
| - 2026년 6월 현재 민형배 당선인은 **인수위원회를 가동 중**이며, **2026년 7월 1일 전남광주 통합특별시 공식 출범과 동시에 초대 시장으로 취임**합니다. | |
| - 호칭: 취임 전(2026년 7월 1일 이전)에는 **"민형배 당선인"**, 취임 후에는 **"민형배 시장"**으로 칭합니다. 시장·시정 운영 주체 관련 질문에는 이 사실을 최우선으로 정확히 안내하세요. | |
| 【★통합특별시 기본 통계 — 인구·면적·행정구역 질문 시 반드시 아래 확정 수치만 사용】 | |
| - 인구: 약 320만 명 (광주 약 141만 명 + 전남 약 178만 명, 2026년 주민등록 기준) | |
| ※ 전라남도 2026년 2월 주민등록 1,776,547명, 광주광역시 약 141만 명. 합계 약 319만~320만 명. | |
| - 행정구역: 총 27개 시·군·구 (광주 5개 자치구 + 전남 22개 시·군). | |
| - 면적: 약 12,900㎢ (광주 약 501㎢ + 전남 약 12,360㎢) — 전국 최대 면적 광역단체급. | |
| - ★절대 금지: 인구·면적·예산·가구수 등 통계 수치를 지어내지 마세요. 위 확정 값 또는 검색 결과 근거가 있을 때만 답하고, 근거 없거나 불확실하면 "정확한 최신 수치는 통합특별시청 062-120 또는 통계청에 확인"이라고 안내하세요. (오답 예: 인구 510만·전남 370만은 명백한 환각입니다) | |
| 【★2026 지방선거·보궐 당선인 명단 — 단체장/국회의원 질문 시 최우선 정확 반영】 | |
| ■ 광주 5개 자치구청장(전원 더불어민주당): 동구 임택(3선)·서구 김이강·남구 김병내(3선)·북구 신수정(광주 첫 여성구청장)·광산구 박병규 | |
| ■ 전남 22개 시·군 단체장 — [시] 목포 강성휘·여수 서영학·순천 손훈모·나주 윤병태(이상 더불어민주당)·광양 박성현(무소속) / [군] 담양 박종원·곡성 조상래·구례 장길선·고흥 공영민·보성 김철우·화순 임지락·해남 명현관·영암 우승희·무안 김산·함평 이남오·영광 장세일·장성 김한종·진도 이재각(이상 더불어민주당)·장흥 사순문·신안 김태성(조국혁신당)·강진 강진원·완도 김신(무소속) | |
| ■ 지역구 국회의원(전원 더불어민주당) — [광주 8석] 동구남구갑 정진욱·동구남구을 안도걸·서구갑 조인철·서구을 양부남·북구갑 정준호·북구을 전진숙·광산구갑 박균택·**광산구을 임문영**(2026 보궐 당선, 前 민형배 의원이 통합특별시장 출마로 사퇴) / [전남 10석] 목포 김원이·여수갑 주철현·여수을 조계원·순천광양곡성구례갑 김문수·순천광양곡성구례을 권향엽·나주화순 신정훈·담양함평영광장성 이개호·고흥보성장흥강진 문금주·해남완도진도 박지원·영암무안신안 서삼석 | |
| - 위 명단에 없거나 불확실한 단체장·의원 정보는 추측하지 말고 통합특별시청 062-120 또는 중앙선거관리위원회 확인을 권고하세요. | |
| 【대화 태도 — 가장 중요】 | |
| - 따뜻하고 친근한 존댓말로, 곁에서 직접 안내해 드리는 상담원·조언자처럼 대화합니다. | |
| - 딱딱하고 기계적인 말투(건조한 나열, "~하시기 바랍니다"의 반복)를 피하고, 공감과 배려를 담아 부드럽게 풀어 말합니다. | |
| - 첫 문장은 가벼운 공감·안내로 자연스럽게 시작합니다 (예: "네, 그 부분 안내해 드릴게요.", "걱정 마세요, 차근차근 알려드릴게요."). | |
| - 어려운 행정·법률 용어는 쉬운 말로 풀어 설명하고, 답변 끝에는 안심·격려의 한마디를 덧붙입니다 (예: "더 궁금한 점 있으면 언제든 편하게 물어봐 주세요."). | |
| - 정확성·사실은 그대로 지키되, 표현만 사람 냄새 나게 따뜻하게 합니다. | |
| 【출력 형식 — 절대 규칙】 | |
| - thought / <think> / 분석 과정 / 자기 점검 모두 출력 금지. **첫 줄부터 곧바로 따뜻한 본문**으로 시작. | |
| - 한국어 마크다운, 간결하게. 표·리스트 활용. | |
| - **LaTeX 수식 표기 금지** (`$\rightarrow$` 같은 표기 X). 화살표·기호는 유니코드로 직접 작성: → ← ⇒ × ÷ ± ≈ ≤ ≥ ≠ ∞ % ° (예: "광주 → 전남") | |
| 【답변 원칙】 | |
| - ★환각 금지: 출처·근거 있는 사실만 답하세요. 인구·면적·예산·통계·날짜·금액·고유명사 등 구체적 수치/정보가 확실하지 않으면 절대 지어내지 말고 '정확한 내용은 통합특별시청 062-120에 확인 바랍니다'라고 안내하세요. 검색 결과가 제공되면 그 내용을 최우선 근거로 삼고, 검색에 없으면 모른다고 솔직히 답하세요. 추측은 반드시 '추정'으로 명시. | |
| - 명칭 통일: 광주광역시/전라남도 → **전남광주 통합특별시**, 광주광역시청/전라남도청 → **전남광주 통합특별시청**, 22 시군 + 광주 5구 이름은 유지. | |
| - 시장 질문엔 **더불어민주당 민형배 당선인**(2026-07-01 초대 시장 취임 예정, 현재 인수위 가동) 정보를 정확히 안내. 구청장·군수 등 기타 단체장은 불확실 시 통합특별시청 062-120 확인 권고. | |
| - 정식 법명: 「전남광주통합특별시 설치를 위한 특별법」 | |
| 【명칭 통일 규칙 — 답변에 무조건 적용】 | |
| - 광주광역시 / 광주시 (광역) → 전남광주 통합특별시 | |
| - 광주광역시청 / 광주시청 → 전남광주 통합특별시청 | |
| - 광주광역시장 / 광주시장 → 전남광주 통합특별시장 | |
| - 전라남도 / 전남 (광역) → 전남광주 통합특별시 | |
| - 전라남도청 / 전남도청 / 도청 → 전남광주 통합특별시청 | |
| - 전라남도 도지사 / 전남도지사 → 전남광주 통합특별시장 | |
| - 22 시군 + 광주 5구 명칭 그대로 유지 | |
| 【시점 정확성】 | |
| - 2026-06-03 전국동시지방선거 완료 → **더불어민주당 민형배, 전남광주 통합특별시 초대 시장 당선** (현재 당선인 신분, 인수위원회 가동 중) | |
| - 2026-07-01 전남광주 통합특별시 공식 출범 + **민형배 초대 시장 취임** | |
| - 시장 query: 2026-07-01 이전엔 "민형배 당선인", 이후엔 "민형배 시장"으로 정확히 안내. 구청장·군수 등 기타 단체장은 불확실 시 통합특별시청 062-120 확인 권고. | |
| 【법령 인용】 정식 법명 「전남광주통합특별시 설치를 위한 특별법」 띄어쓰기 X 유지. 조항은 '제X조 (조문 제목)' 형식. | |
| 【답변 형식】 마크다운 (제목·표·리스트·코드블록). 답변 끝에 '📚 출처:' 섹션. 통합특별시청 대표 062-120 안내. | |
| 【첨부파일 처리】 사용자가 HWP/PDF/DOCX/PPTX/XLSX/CSV/이미지 등을 첨부하면 추출된 텍스트를 분석하여 요약·질의응답·구조화·번역 등 요청에 답변. 표는 마크다운 표로 재구성. | |
| 【거부】 정치적/종교적 편향, 사적 정보, 위법 정보, 사실 확인 불가능한 질문은 거부 또는 '확인 필요' 답변. | |
| """ | |
| GREETING = """안녕하세요, **전남광주 통합특별시민과 함께하는 AI JGOS입니다.** | |
| 저는 전남광주 통합특별시 320만 시민을 위한 sovereign AI입니다. | |
| 📂 **파일 업로드 지원**: HWP, HWPX, PDF, DOCX, PPTX, XLSX, XLS, CSV, TXT, MD, 이미지 (📎 버튼 또는 드래그앤드롭) | |
| 다음과 같은 정보를 안내해 드립니다: | |
| - 🏛️ **행정·민원** (절차·서류·부서 연락처) | |
| - 📜 **법령** (전남광주통합특별시 설치 특별법 + 조례) | |
| - 🗺️ **관광·맛집·특산품** (22 시군 + 광주 5구) | |
| - 🚆 **교통** (KTX·SRT·공항·페리·시내버스) | |
| - 🏛️ **역사·문화** (5·18 + 문화재) | |
| - 📊 **통계** (인구·경제·산업) | |
| 무엇을 도와드릴까요?""" | |
| # === COOP/COEP 미들웨어 — /office (HanSoo 무료 오피스) WASM SharedArrayBuffer 활성화 === | |
| # rhwp(HWP), tesseract(OCR) 등 WASM이 cross-origin isolation 필요. | |
| # 챗봇(/)은 외부 CDN(jsdelivr) 의존 → COEP 미적용 (경로 분리). | |
| async def office_isolation(request: Request, call_next): | |
| resp = await call_next(request) | |
| p = request.url.path | |
| # 방문 통계 카운트 (의미있는 경로만) | |
| try: | |
| if p == "/": bump("total_visits") | |
| elif p == "/api/chat": bump("ai_chat") | |
| elif p == "/api/image": bump("image_gen") | |
| elif p == "/api/upload": bump("file_upload") | |
| elif p in ("/office", "/office/"): bump("office_open") | |
| except Exception: | |
| pass | |
| if p.startswith("/office"): | |
| resp.headers["Cross-Origin-Opener-Policy"] = "same-origin" | |
| resp.headers["Cross-Origin-Embedder-Policy"] = "credentialless" | |
| resp.headers["Cross-Origin-Resource-Policy"] = "cross-origin" | |
| # HTML/디렉토리는 캐시 금지 → 재빌드 시 최신 청크 즉시 반영 (해시 .js/.css 에셋은 캐시 허용) | |
| last = p.rsplit("/", 1)[-1] | |
| if p.endswith("/") or last.endswith(".html") or "." not in last: | |
| resp.headers["Cache-Control"] = "no-store, max-age=0" | |
| return resp | |
| # === static === | |
| app.mount("/static", StaticFiles(directory="static"), name="static") | |
| # === 무료 오피스 (HanSoo 통합, same-origin 직접 서빙) === | |
| OFFICE_DIR = Path(__file__).parent / "office" | |
| if OFFICE_DIR.exists(): | |
| app.mount("/office", StaticFiles(directory="office", html=True), name="office") | |
| async def root(): | |
| # index.html 은 항상 최신 (캐시된 옛 script.js/style.css 참조 방지) — 에셋은 ?v= 로 버전 관리 | |
| return FileResponse("static/index.html", headers={"Cache-Control": "no-store, max-age=0"}) | |
| async def health(): | |
| return {"status": "ok", "model": LLM_MODEL, "llm_base_url": LLM_BASE_URL, | |
| "hwp_api": bool(HWP_API_URL), | |
| "supported_uploads": ["hwp","hwpx","pdf","docx","pptx","xlsx","xls","csv","txt","md","jpg","jpeg","png","gif","webp"]} | |
| def _load_stats_on_startup(): | |
| load_stats() | |
| STATS_KEY = os.getenv("STATS_KEY", "") # 관리자 전용 키 (미설정 시 전면 차단 — 시민 UI 비공개) | |
| async def stats_json(key: str = ""): | |
| if not STATS_KEY or key != STATS_KEY: | |
| return JSONResponse({"error": "forbidden"}, status_code=403) | |
| return STATS | |
| async def stats_page(key: str = ""): | |
| if not STATS_KEY or key != STATS_KEY: | |
| return HTMLResponse("<h3 style='font-family:sans-serif;padding:40px;color:#888'>403 · 접근 권한이 없습니다.</h3>", status_code=403) | |
| d = STATS | |
| days = sorted(d.get("by_day", {}).items(), reverse=True)[:14] | |
| rows = "".join( | |
| f"<tr><td>{day}</td><td>{v.get('total_visits',0)}</td><td>{v.get('ai_chat',0)}</td>" | |
| f"<td>{v.get('image_gen',0)}</td><td>{v.get('file_upload',0)}</td><td>{v.get('office_open',0)}</td></tr>" | |
| for day, v in days) | |
| return ( | |
| "<!doctype html><html lang=ko><head><meta charset=utf-8>" | |
| "<title>JGOS 방문 통계</title><meta name=viewport content='width=device-width,initial-scale=1'>" | |
| "<style>body{font-family:'Malgun Gothic',sans-serif;background:#f7f8fa;color:#1a1f2e;padding:24px;max-width:920px;margin:0 auto}" | |
| "h1{color:#0052a4}.cards{display:flex;flex-wrap:wrap;gap:12px;margin:20px 0}" | |
| ".card{flex:1;min-width:130px;background:#fff;border:1px solid #e0e6ef;border-radius:12px;padding:16px;text-align:center}" | |
| ".num{font-size:28px;font-weight:800;color:#0052a4}.lbl{font-size:13px;color:#5b6677;margin-top:4px}" | |
| "table{width:100%;border-collapse:collapse;margin-top:16px}th,td{border:1px solid #e0e6ef;padding:8px;text-align:center;font-size:13px}" | |
| "th{background:#e6f0fa;color:#003d80}</style></head><body>" | |
| "<h1>🏛️ JGOS 방문 통계</h1><div class=cards>" | |
| f"<div class=card><div class=num>{d.get('total_visits',0)}</div><div class=lbl>총 방문</div></div>" | |
| f"<div class=card><div class=num>{d.get('ai_chat',0)}</div><div class=lbl>AI 상담</div></div>" | |
| f"<div class=card><div class=num>{d.get('image_gen',0)}</div><div class=lbl>이미지 생성</div></div>" | |
| f"<div class=card><div class=num>{d.get('file_upload',0)}</div><div class=lbl>파일 분석</div></div>" | |
| f"<div class=card><div class=num>{d.get('office_open',0)}</div><div class=lbl>오피스 열람</div></div>" | |
| "</div><h3>최근 14일 (KST)</h3>" | |
| "<table><tr><th>날짜</th><th>방문</th><th>AI상담</th><th>이미지</th><th>파일</th><th>오피스</th></tr>" | |
| + rows + | |
| "</table><p style='color:#8a94a3;font-size:12px;margin-top:16px'>VIDraft/JGOS-stats · 15요청마다 자동 저장 · KST 기준</p>" | |
| "</body></html>" | |
| ) | |
| async def greeting(): | |
| return {"greeting": GREETING} | |
| async def gen_image(request: Request): | |
| try: | |
| bump("image_gen") | |
| except Exception: | |
| pass | |
| body = await request.json() | |
| prompt = (body.get("prompt") or body.get("message") or "").strip() | |
| if not prompt: | |
| return JSONResponse({"error": "프롬프트가 비었습니다."}, status_code=400) | |
| fal_key = os.getenv("FAL_KEY", "") | |
| if not fal_key: | |
| return JSONResponse({"error": "이미지 생성 키(FAL_KEY)가 설정되지 않았습니다."}, status_code=503) | |
| payload = { | |
| "prompt": prompt, | |
| "num_images": int(body.get("num_images", 1) or 1), | |
| "aspect_ratio": body.get("aspect_ratio", "1:1"), | |
| "resolution": body.get("resolution", "1k"), | |
| "output_format": "png", | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=180) as client: | |
| r = await client.post( | |
| "https://fal.run/xai/grok-imagine-image", | |
| headers={"Authorization": "Key " + fal_key, "Content-Type": "application/json"}, | |
| json=payload) | |
| if r.status_code != 200: | |
| return JSONResponse({"error": "이미지 생성 실패 (%d)" % r.status_code}, status_code=502) | |
| data = r.json() | |
| imgs = data.get("images") or [] | |
| url = imgs[0].get("url") if imgs else None | |
| if not url: | |
| return JSONResponse({"error": "이미지 URL을 받지 못했습니다."}, status_code=502) | |
| ir = await client.get(url) | |
| b64 = base64.b64encode(ir.content).decode("ascii") | |
| ct = ir.headers.get("content-type", "image/png") | |
| return {"image": "data:%s;base64,%s" % (ct, b64), "url": url} | |
| except Exception as e: | |
| return JSONResponse({"error": "이미지 생성 오류: %s" % e}, status_code=502) | |
| async def gen_image(request: Request): | |
| """JGOS-Image — Qwen-Image-2512 (B200 cl1 GPU1) 한글 이미지 생성 프록시. | |
| JGOS_IMAGE_API_URL 미설정 시 503 '준비 중' 반환 (탭은 보이되 생성 보류).""" | |
| body = await request.json() | |
| prompt = (body.get("prompt") or "").strip() | |
| if not prompt: | |
| return JSONResponse({"error": "프롬프트를 입력하세요."}, status_code=400) | |
| if not IMAGE_API_URL: | |
| return JSONResponse({"error": "JGOS-Image 생성 서버를 준비 중입니다. 곧 제공됩니다."}, status_code=503) | |
| payload = { | |
| "prompt": prompt, | |
| "width": int(body.get("width", 1024)), | |
| "height": int(body.get("height", 1024)), | |
| "steps": int(body.get("steps", 30)), | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=180) as client: | |
| r = await client.post( | |
| f"{IMAGE_API_URL}/generate", | |
| headers={"Authorization": f"Bearer {IMAGE_API_KEY}", | |
| "Content-Type": "application/json", | |
| "User-Agent": "Mozilla/5.0 (JGOS-Image)"}, | |
| json=payload, | |
| ) | |
| if r.status_code != 200: | |
| return JSONResponse({"error": f"이미지 서버 오류 HTTP {r.status_code}"}, status_code=502) | |
| data = r.json() | |
| img = data.get("image") or data.get("image_url") or data.get("data") | |
| if not img: | |
| return JSONResponse({"error": "이미지 응답 형식 오류"}, status_code=502) | |
| return {"image": img} | |
| except httpx.ConnectError: | |
| return JSONResponse({"error": "이미지 생성 서버 연결 실패 (준비 중)."}, status_code=503) | |
| except Exception as e: | |
| return JSONResponse({"error": f"{type(e).__name__}: {str(e)[:150]}"}, status_code=502) | |
| # ====== 파일 변환 함수들 ====== | |
| def cap(text: str, n: int = MAX_TEXT_CHARS) -> str: | |
| if len(text) <= n: return text | |
| return text[:n] + f"\n\n[... 이후 {len(text)-n:,}자 생략 (총 {len(text):,}자)]" | |
| def extract_pdf(data: bytes) -> str: | |
| import fitz | |
| doc = fitz.open(stream=data, filetype="pdf") | |
| parts = [] | |
| for page in doc: | |
| t = page.get_text("text") | |
| if t: parts.append(t) | |
| doc.close() | |
| return "\n\n".join(parts) | |
| def extract_docx(data: bytes) -> str: | |
| from docx import Document | |
| doc = Document(io.BytesIO(data)) | |
| out = [] | |
| for p in doc.paragraphs: | |
| if p.text.strip(): out.append(p.text) | |
| for tbl in doc.tables: | |
| for row in tbl.rows: | |
| out.append(" | ".join(cell.text.strip() for cell in row.cells)) | |
| return "\n".join(out) | |
| def extract_pptx(data: bytes) -> str: | |
| from pptx import Presentation | |
| prs = Presentation(io.BytesIO(data)) | |
| out = [] | |
| for i, slide in enumerate(prs.slides, 1): | |
| out.append(f"\n=== Slide {i} ===") | |
| for shape in slide.shapes: | |
| if shape.has_text_frame: | |
| for p in shape.text_frame.paragraphs: | |
| text = "".join(r.text for r in p.runs) | |
| if text.strip(): out.append(text) | |
| if shape.has_table: | |
| for row in shape.table.rows: | |
| out.append(" | ".join(c.text.strip() for c in row.cells)) | |
| return "\n".join(out) | |
| def extract_xlsx(data: bytes) -> str: | |
| import openpyxl | |
| wb = openpyxl.load_workbook(io.BytesIO(data), data_only=True, read_only=True) | |
| out = [] | |
| for sn in wb.sheetnames: | |
| ws = wb[sn] | |
| out.append(f"\n=== Sheet: {sn} ===") | |
| for row in ws.iter_rows(values_only=True): | |
| cells = [str(c) if c is not None else "" for c in row] | |
| if any(cells): out.append(" | ".join(cells)) | |
| wb.close() | |
| return "\n".join(out) | |
| def extract_xls(data: bytes) -> str: | |
| import xlrd | |
| wb = xlrd.open_workbook(file_contents=data) | |
| out = [] | |
| for sn in wb.sheet_names(): | |
| ws = wb.sheet_by_name(sn) | |
| out.append(f"\n=== Sheet: {sn} ===") | |
| for r in range(ws.nrows): | |
| row = [str(ws.cell_value(r, c)) for c in range(ws.ncols)] | |
| if any(row): out.append(" | ".join(row)) | |
| return "\n".join(out) | |
| def extract_csv(data: bytes) -> str: | |
| import pandas as pd | |
| for enc in ("utf-8", "cp949", "euc-kr", "latin1"): | |
| try: | |
| df = pd.read_csv(io.BytesIO(data), encoding=enc) | |
| return df.to_markdown(index=False) if len(df) <= 500 else df.head(500).to_markdown(index=False) + f"\n\n[... 총 {len(df):,}행 중 500행 표시]" | |
| except UnicodeDecodeError: | |
| continue | |
| except Exception as e: | |
| return f"[CSV 읽기 실패: {e}]" | |
| return "[CSV 인코딩 식별 실패]" | |
| def extract_txt(data: bytes) -> str: | |
| for enc in ("utf-8", "cp949", "euc-kr", "latin1"): | |
| try: return data.decode(enc) | |
| except UnicodeDecodeError: continue | |
| return data.decode("utf-8", errors="replace") | |
| import tempfile, asyncio as _asyncio | |
| PKG_DIR = Path(__file__).parent / "pkg" | |
| RHWP_SCRIPT = PKG_DIR / "extract_hwp.mjs" | |
| async def extract_hwp(data: bytes, fname: str) -> str: | |
| """HanSoo rhwp WASM (Rust) — Node.js subprocess로 HWP/HWPX 한글 텍스트 직접 추출. | |
| 외부 API 의존성 0. JGOS Space 내부 처리. | |
| """ | |
| if not RHWP_SCRIPT.exists(): | |
| return f"[HWP 처리기 미설치: {RHWP_SCRIPT}]" | |
| suffix = ".hwpx" if fname.lower().endswith(".hwpx") else ".hwp" | |
| tmp_path = None | |
| try: | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tf: | |
| tf.write(data); tf.flush(); tmp_path = tf.name | |
| proc = await _asyncio.create_subprocess_exec( | |
| "node", str(RHWP_SCRIPT), tmp_path, | |
| stdout=_asyncio.subprocess.PIPE, stderr=_asyncio.subprocess.PIPE, | |
| ) | |
| try: | |
| stdout, stderr = await _asyncio.wait_for(proc.communicate(), timeout=60) | |
| except _asyncio.TimeoutError: | |
| proc.kill(); return f"[HWP 변환 타임아웃 60s: {fname}]" | |
| if proc.returncode != 0: | |
| err = (stderr.decode("utf-8", errors="ignore")[:300]) if stderr else "no stderr" | |
| return f"[HWP 변환 실패 (exit {proc.returncode}): {err}]" | |
| try: | |
| result = json.loads(stdout.decode("utf-8", errors="ignore")) | |
| except Exception as e: | |
| return f"[HWP 결과 파싱 실패: {e}]" | |
| if not result.get("ok"): | |
| return f"[HWP 추출 실패: {result.get('error','unknown')}]" | |
| text = result.get("text", "") | |
| return text or f"[{fname}: 추출 텍스트 없음 (paragraphs={result.get('paragraphs',0)})]" | |
| except FileNotFoundError as e: | |
| return f"[Node.js 미설치 — Docker rebuild 필요: {e}]" | |
| except Exception as e: | |
| return f"[HWP 처리 오류: {type(e).__name__}: {e}]" | |
| finally: | |
| if tmp_path: | |
| try: os.unlink(tmp_path) | |
| except: pass | |
| def extract_image_meta(data: bytes, fname: str) -> str: | |
| """이미지: 메타만 텍스트로. 실제 vision 분석은 base64로 LLM에 직접 전달.""" | |
| try: | |
| from PIL import Image | |
| img = Image.open(io.BytesIO(data)) | |
| return f"[이미지: {fname} · {img.format} · {img.size[0]}×{img.size[1]}px · LLM에 직접 전달됨]" | |
| except Exception as e: | |
| return f"[이미지 메타 추출 실패: {e}]" | |
| IMAGE_EXTS = {"jpg", "jpeg", "png", "gif", "webp"} | |
| IMAGE_MIME = {"jpg": "jpeg", "jpeg": "jpeg", "png": "png", "gif": "gif", "webp": "webp"} | |
| async def upload(file: UploadFile = File(...)): | |
| name = file.filename or "untitled" | |
| ext = name.rsplit(".", 1)[-1].lower() if "." in name else "" | |
| data = await file.read() | |
| size_mb = len(data) / 1024 / 1024 | |
| if size_mb > MAX_UPLOAD_MB: | |
| raise HTTPException(413, f"파일 너무 큼 ({size_mb:.1f}MB > {MAX_UPLOAD_MB}MB)") | |
| image_data_url = None | |
| try: | |
| if ext == "pdf": | |
| text = extract_pdf(data) | |
| elif ext == "docx": | |
| text = extract_docx(data) | |
| elif ext == "pptx": | |
| text = extract_pptx(data) | |
| elif ext == "xlsx": | |
| text = extract_xlsx(data) | |
| elif ext == "xls": | |
| text = extract_xls(data) | |
| elif ext == "csv": | |
| text = extract_csv(data) | |
| elif ext in ("txt", "md"): | |
| text = extract_txt(data) | |
| elif ext in ("hwp", "hwpx"): | |
| text = await extract_hwp(data, name) | |
| elif ext in IMAGE_EXTS: | |
| text = extract_image_meta(data, name) | |
| # ⭐ 이미지는 base64 data URL로 LLM에 직접 전달 | |
| mime = IMAGE_MIME.get(ext, "jpeg") | |
| image_data_url = f"data:image/{mime};base64,{base64.b64encode(data).decode('ascii')}" | |
| else: | |
| raise HTTPException(415, f"지원하지 않는 파일 형식: .{ext}") | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| return JSONResponse({"error": f"{type(e).__name__}: {e}", "text": ""}, status_code=500) | |
| capped = cap(text or "") | |
| resp = { | |
| "ok": True, | |
| "filename": name, | |
| "ext": ext, | |
| "size_kb": round(len(data) / 1024, 1), | |
| "text_len_raw": len(text or ""), | |
| "text_len_returned": len(capped), | |
| "text": capped, | |
| "meta": {"capped": len(text or "") > MAX_TEXT_CHARS}, | |
| } | |
| if image_data_url: | |
| resp["image_data_url"] = image_data_url | |
| resp["is_image"] = True | |
| return resp | |
| # ===== 웹 검색 (네이버 1차 + Brave 2차) — 시민 질의 실시간 grounding ===== | |
| import re as _re_ws | |
| _TAG_RE = _re_ws.compile(r"<[^>]+>") | |
| def _clean_html(s: str) -> str: | |
| s = _TAG_RE.sub("", s or "") | |
| for a, b in ((""", '"'), ("&", "&"), ("<", "<"), (">", ">"), ("'", "'")): | |
| s = s.replace(a, b) | |
| return s.strip() | |
| _KW_LOCAL = ("어디", "주소", "위치", "찾아", "가는길", "가는 길", "근처", "오시는", "약도", "연락처", "전화번호") | |
| _KW_NEWS = ("뉴스", "최근", "오늘", "현재", "근황", "요즘", "최신", "속보", "이번주", "이번 주", "어제", "지금") | |
| _KW_BLOG = ("맛집", "여행", "가볼만", "가 볼만", "후기", "축제", "숙소", "카페", "코스", "데이트", "당일치기", "1박2일") | |
| _SRC_LABEL = {"local": "네이버 지역", "news": "네이버 뉴스", "blog": "네이버 블로그", "webkr": "네이버 웹", "encyc": "지식백과"} | |
| async def _naver(query: str, kind: str, n: int = 4, sort: str = None) -> list: | |
| if not (NAVER_CLIENT_ID and NAVER_CLIENT_SECRET): | |
| return [] | |
| params = {"query": query, "display": n} | |
| if sort: | |
| params["sort"] = sort | |
| try: | |
| async with httpx.AsyncClient(timeout=8) as c: | |
| r = await c.get(f"https://openapi.naver.com/v1/search/{kind}.json", params=params, | |
| headers={"X-Naver-Client-Id": NAVER_CLIENT_ID, "X-Naver-Client-Secret": NAVER_CLIENT_SECRET}) | |
| if r.status_code != 200: | |
| return [] | |
| def _fmt_date(it): | |
| pd = it.get("pubDate") or it.get("postdate") or "" | |
| if not pd: | |
| return "" | |
| # postdate: 20220926 → 2022-09-26 | |
| if pd.isdigit() and len(pd) == 8: | |
| return f"{pd[:4]}-{pd[4:6]}-{pd[6:]}" | |
| return pd | |
| return [{ | |
| "title": _clean_html(it.get("title")), | |
| "desc": _clean_html(it.get("description")), | |
| "link": it.get("link", ""), | |
| "addr": it.get("roadAddress") or it.get("address") or "", | |
| "src": _SRC_LABEL.get(kind, "네이버"), | |
| "date": _fmt_date(it), | |
| } for it in r.json().get("items", [])] | |
| except Exception: | |
| return [] | |
| async def _brave(query: str, n: int = 4) -> list: | |
| if not BRAVE_API_KEY: | |
| return [] | |
| try: | |
| async with httpx.AsyncClient(timeout=8) as c: | |
| r = await c.get("https://api.search.brave.com/res/v1/web/search", | |
| params={"q": query, "count": n, "country": "kr", "search_lang": "ko"}, | |
| headers={"X-Subscription-Token": BRAVE_API_KEY, "Accept": "application/json"}) | |
| if r.status_code != 200: | |
| return [] | |
| return [{ | |
| "title": _clean_html(it.get("title")), | |
| "desc": _clean_html(it.get("description")), | |
| "link": it.get("url", ""), "addr": "", "src": "Brave", | |
| } for it in r.json().get("web", {}).get("results", [])] | |
| except Exception: | |
| return [] | |
| def _is_fresh_query(q: str) -> bool: | |
| """토글 OFF여도 자동 검색을 유발하는 '최신성' 키워드.""" | |
| return any(k in q for k in _KW_NEWS) | |
| async def run_web_search(query: str) -> list: | |
| q_raw = (query or "").strip() | |
| if not q_raw: | |
| return [] | |
| q = _clean_query(q_raw) # 검색어 정제 (조사·명령어 제거 → 고유명사 위주) | |
| # webkr(권위 웹문서) + news(최신순)는 항상 함께 — 최신성 보장 | |
| jobs = [_naver(q, "webkr", 4), _naver(q, "news", 5, sort="date")] | |
| if any(k in q_raw for k in _KW_LOCAL): | |
| jobs.append(_naver(q, "local", 4)) | |
| if any(k in q_raw for k in _KW_BLOG): | |
| jobs.append(_naver(q, "blog", 4)) | |
| groups = await asyncio.gather(*jobs) | |
| results, seen = [], set() | |
| for g in groups: | |
| for it in g: | |
| key = it["link"] or it["title"] | |
| if key and key not in seen: | |
| seen.add(key) | |
| results.append(it) | |
| # 날짜 있는 항목(뉴스 등)을 최신순으로 앞에, 날짜 없는 권위문서는 뒤에 | |
| def _date_key(it): | |
| import re | |
| d = it.get("date", "") | |
| # YYYY-MM-DD 또는 RFC 형식 → 정렬용 문자열 (없으면 최하위) | |
| m = re.search(r"(\d{4})[-.]?(\d{2})[-.]?(\d{2})", d) | |
| return m.group(0).replace(".", "-") if m else "0000-00-00" | |
| results.sort(key=_date_key, reverse=True) | |
| if len(results) < 3: # 네이버 빈약 → Brave 글로벌 보조 | |
| for it in await _brave(q, 4): | |
| key = it["link"] or it["title"] | |
| if key and key not in seen: | |
| seen.add(key) | |
| results.append(it) | |
| return results[:10] | |
| def build_search_context(results: list) -> str: | |
| head = ("【실시간 웹 검색 결과 — 이것이 정답의 근거입니다】 아래는 방금 조회한 최신 정보(최신순 정렬)입니다. " | |
| "★당신의 사전 학습 지식보다 아래 검색 결과가 절대적으로 우선합니다. 당신이 알던 것과 다르면 '아래 검색 결과가 맞습니다'. " | |
| "반드시 아래 내용만을 근거로 답하고, 관련 문장 끝에 [1][2]처럼 출처 번호를 다세요. " | |
| "검색 결과에 명시되지 않은 내용은 절대 지어내지 말고 '검색에서 확인되지 않았다'고 솔직히 말하세요. " | |
| "여러 항목이 충돌하면 '날짜'가 최근인 항목을 진실로 채택하세요.") | |
| lines = [head] | |
| for i, it in enumerate(results, 1): | |
| seg = f"[{i}] ({it['src']}) {it['title']}" | |
| if it.get("date"): | |
| seg += f" · 날짜: {it['date']}" | |
| if it.get("addr"): | |
| seg += f" · 주소: {it['addr']}" | |
| if it.get("desc"): | |
| seg += f" — {it['desc']}" | |
| lines.append(seg) | |
| return "\n".join(lines) | |
| _HANGUL_RE = _re_ws.compile(r'[가-힣]') | |
| def _find_body_start(raw: str): | |
| """reasoning을 건너뛰고 본문 시작 인덱스 반환 (없으면 None).""" | |
| # ★최우선: '최종 답변' 마커가 있으면 그 이후가 본문 (reasoning 서술형/라벨형 무관) | |
| m_final = _re_ws.search(r'(?:최종\s*답변|최종답변|\[\s*최종\s*답변\s*\]|##+\s*최종\s*답변|\*\*최종\s*답변\*\*)\s*[::]?\s*', raw) | |
| if m_final: | |
| return m_final.end() | |
| low = raw.lower() | |
| if '<think>' in low: | |
| e = low.find('</think>') | |
| if e < 0: | |
| return None # 닫힐 때까지 대기 | |
| start = e + len('</think>') | |
| else: | |
| start = 0 | |
| pos = start | |
| for ln in raw[start:].splitlines(keepends=True): | |
| s = ln.strip() | |
| if s: | |
| # 영어 reasoning 불릿(마커+영어라벨+콜론, 또는 마커+I/We/The…)은 한글이 섞여도 full-line 본문 제외 | |
| is_eng_meta = (bool(_re_ws.match(r'^[\*\-•]\s*\*?[A-Za-z][\w ]*\*?\s*:', s)) or | |
| bool(_re_ws.match(r'^\d+\.\s+[A-Za-z]', s)) or | |
| bool(_re_ws.match(r'^[\*\-•]\s+(I|We|The|This|That|Since|Given|Based|It|If|To|For|As|Note|Tone|Step|Key)\b', s))) | |
| _ko_labels = ('맥락 파악','맥락파악','문맥 파악','문맥파악','상황 분석','상황분석','체계적 분석','체계적분석', | |
| '세분화','세부 분석','비교','해결','자기 수정','자기수정','자기 검토','제약 조건','제약조건', | |
| '단계별','추론 과정','분석 과정','종합 분석','핵심 파악','정보 종합', | |
| '제약','강조','분석','파악','종합','검토','판단','결론 도출','우선순위','정리하면') | |
| _sh = s.lstrip('*-•# ').strip() | |
| is_ko_meta = any(_sh.startswith(lbl) for lbl in _ko_labels) and (':' in _sh[:20] or ':' in _sh[:20]) | |
| ko = len(_HANGUL_RE.findall(s)) | |
| en = sum(1 for c in s if c.isascii() and c.isalpha()) | |
| if not is_eng_meta and not is_ko_meta and ko >= 4 and ko >= en: # 한글 본문(영/한 reasoning 라벨 제외) | |
| return pos | |
| if ko >= 15 and not is_ko_meta: # 긴 한글 본문(단, 한국어 reasoning 라벨 줄은 제외) | |
| m = _HANGUL_RE.search(ln) | |
| if m: | |
| return pos + m.start() | |
| pos += len(ln) | |
| return None | |
| def _strip_reasoning_fallback(raw: str) -> str: | |
| _mf = _re_ws.search(r'(?:최종\s*답변|최종답변)\s*[::]?\s*', raw) | |
| if _mf: | |
| return raw[_mf.end():].strip() | |
| """본문 탐지 실패 시(짧은 답/영어 답): <think> + 영어 reasoning 불릿 줄 제거.""" | |
| raw = _re_ws.sub(r'(?is)<think>.*?</think>', '', raw).strip() | |
| keep = [] | |
| for ln in raw.splitlines(): | |
| s = ln.strip() | |
| ko = len(_HANGUL_RE.findall(s)) | |
| en = sum(1 for c in s if c.isascii() and c.isalpha()) | |
| is_meta = s[:1] in ('*', '-', '•') or bool(_re_ws.match(r'^\d+\.', s)) | |
| if is_meta and en > ko and en > 5: | |
| continue # 영어 reasoning 불릿/번호 줄 제거 | |
| keep.append(ln) | |
| return '\n'.join(keep).strip() or raw | |
| _Q_STOP = ("에 대해서", "에 대해", "에대해", "에 대한", "에 관해", "에 관한", "자세히", "상세하게", "상세히", | |
| "알려줘", "알려주세요", "알려 줘", "설명해줘", "설명해 줘", "설명해주세요", "설명해", "말해줘", | |
| "궁금해", "궁금합니다", "해줘", "해 줘") | |
| def _clean_query(q: str) -> str: | |
| """검색어 정제: 조사·명령어 제거로 고유명사/핵심어만 남김.""" | |
| q2 = q | |
| for w in _Q_STOP: | |
| q2 = q2.replace(w, " ") | |
| q2 = " ".join(q2.split()) | |
| return q2 if len(q2) >= 2 else q.strip() | |
| # ────────────────────────────────────────────────────────────── | |
| # 민원 담당자 연락처 라우팅 — 29개 기관 38,022행 조직도 실데이터 (2026-06-12 누리집 수집) | |
| # 흐름: 질문 → LLM이 {지역, 업무 키워드} 판단 → 지역 데이터 스코어링 검색 → 컨텍스트 주입 | |
| # ────────────────────────────────────────────────────────────── | |
| _CONTACT_CSV = Path(__file__).parent / "gov_contacts.csv" | |
| _CONTACT_REGIONS = ["광주광역시 본청", "광주 동구", "광주 서구", "광주 남구", "광주 북구", "광주 광산구", | |
| "전라남도 본청", "전남 목포시", "전남 여수시", "전남 순천시", "전남 나주시", "전남 광양시", | |
| "전남 담양군", "전남 곡성군", "전남 구례군", "전남 고흥군", "전남 보성군", "전남 화순군", | |
| "전남 장흥군", "전남 강진군", "전남 해남군", "전남 영암군", "전남 무안군", "전남 함평군", | |
| "전남 영광군", "전남 장성군", "전남 진도군", "전남 신안군", "전남 완도군"] | |
| _CONTACTS_BY_REGION: dict = {} | |
| def _load_contacts(): | |
| """gov_contacts.csv → 지역별 dict. 전화 없는 행 제외. 시작 시 1회.""" | |
| global _CONTACTS_BY_REGION | |
| if _CONTACTS_BY_REGION or not _CONTACT_CSV.exists(): | |
| return | |
| import csv as _csv | |
| by_region: dict = {} | |
| try: | |
| with open(_CONTACT_CSV, encoding="utf-8-sig", newline="") as f: | |
| for row in _csv.DictReader(f): | |
| phone = (row.get("phone") or "").strip() | |
| if not phone: | |
| continue | |
| by_region.setdefault(row["region"], []).append({ | |
| "region": row["region"], "dept": (row.get("dept") or "").strip(), | |
| "team": (row.get("team") or "").strip(), "position": (row.get("position") or "").strip(), | |
| "phone": phone, "duty": (row.get("duty") or "").strip(), | |
| }) | |
| _CONTACTS_BY_REGION = by_region | |
| print(f"[contacts] {sum(len(v) for v in by_region.values())}행 / {len(by_region)}기관 로드") | |
| except Exception as e: | |
| print(f"[contacts] 로드 실패: {e}") | |
| _load_contacts() | |
| # 민원 의도 사전필터 (넓게 — 최종 판정은 LLM 라우터의 contact 필드가 담당) | |
| _CONTACT_INTENT_KWS = ( | |
| "연락처", "전화", "담당", "문의", "어디로", "어디에", "어디서", "누구한테", "신고", "신청", "접수", "발급", | |
| "허가", "인허가", "등록", "민원", "과태료", "납부", "감면", "지원금", "보조금", "수당", "바우처", "복지", | |
| "기초연금", "수급", "장애인", "어르신", "노인", "아동", "보육", "어린이집", "유치원", | |
| "쓰레기", "폐기물", "재활용", "청소", "무단투기", "소음", "악취", "위생", "식품", "불법", "주정차", "주차", | |
| "견인", "도로", "보수", "포장", "가로등", "신호등", "상수도", "하수도", "수도", "요금", "세금", "지방세", | |
| "재산세", "취득세", "등본", "초본", "주민등록", "전입", "여권", "인감", "증명서", "건축", "증축", "개발", | |
| "토지", "농지", "산림", "벌목", "축산", "가축", "농업", "어업", "수산", "귀농", "귀촌", "임대주택", "주거", | |
| "전세", "월세", "취업", "일자리", "창업", "소상공인", "사업자", "학교", "교육", "급식", "통학", "예방접종", | |
| "보건소", "검진", "축제", "관광", "공원", "체육", "도서관", "버스", "교통", "택시", "면허", "차량", | |
| "전기차", "충전소", "민방위", "예비군", "재난", "침수", "태풍", "제설", "산불", "가뭄", | |
| ) | |
| def _contact_intent(q: str) -> bool: | |
| return bool(q) and any(k in q for k in _CONTACT_INTENT_KWS) | |
| _CONTACT_ROUTER_PROMPT = ( | |
| "당신은 전남광주 통합특별시 민원 라우터입니다. 시민 질문을 분석해 JSON 한 줄만 출력하세요. 설명·인사 등 다른 텍스트 절대 금지.\n" | |
| '출력 형식: {"contact": true, "region": "광주 북구", "keywords": ["쓰레기", "무단투기", "폐기물", "청소"]}\n' | |
| "- contact: 행정 업무 문의·신고·신청·발급·담당자 연락처가 도움이 되는 질문이면 true, 일상대화·일반지식 질문이면 false\n" | |
| "- region: 질문의 지역을 다음 목록에서 정확히 1개 선택 — " + ", ".join(_CONTACT_REGIONS) + ". " | |
| "구 단위 언급(북구·광산구 등)이면 '광주 북구' 형식, 전남 시군 언급(순천·해남 등)이면 '전남 순천시' 형식. " | |
| "구·군 특정 없이 광주 전체 사무면 '광주광역시 본청', 전남 전체 사무면 '전라남도 본청', 지역을 알 수 없으면 \"불명\".\n" | |
| "- keywords: 담당 부서를 찾기 위한 업무 명사 3~6개. 띄어쓰기 없는 짧은 단일 명사(2~3글자 위주)로, 복합어는 쪼개세요 " | |
| "(예: '여권발급'→'여권','발급' / 쓰레기→폐기물·청소, 길→도로, 보조금→지원, 가게→소상공인, 집수리→주택).\n" | |
| ) | |
| async def classify_contact_route(query: str) -> dict: | |
| """1단계: LLM이 지역+업무 키워드 판단 (non-stream, 짧게).""" | |
| payload = { | |
| "model": LLM_MODEL, | |
| "messages": [{"role": "system", "content": _CONTACT_ROUTER_PROMPT}, | |
| {"role": "user", "content": query[:500]}], | |
| "temperature": 0.0, | |
| "max_tokens": 220, | |
| "stream": False, | |
| "chat_template_kwargs": {"enable_thinking": False}, | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=20) as client: | |
| r = await client.post(f"{LLM_BASE_URL}/chat/completions", | |
| headers={"Authorization": f"Bearer {LLM_API_KEY}", | |
| "Content-Type": "application/json", | |
| "User-Agent": "Mozilla/5.0 (JGOS-Origin Space)"}, | |
| json=payload) | |
| if r.status_code != 200: | |
| return {} | |
| txt = r.json()["choices"][0]["message"]["content"] or "" | |
| m = _re_ws.search(r"\{.*\}", txt, _re_ws.DOTALL) | |
| if not m: | |
| return {} | |
| d = json.loads(m.group(0)) | |
| return d if isinstance(d, dict) else {} | |
| except Exception: | |
| return {} | |
| def search_contacts(region: str, keywords: list, limit: int = 14) -> list: | |
| """2단계: 지역 조직도에서 키워드 스코어링 검색 → 상위 담당자.""" | |
| if not _CONTACTS_BY_REGION or not keywords: | |
| return [] | |
| rows = _CONTACTS_BY_REGION.get(region) or [] | |
| if not rows: # 지역 불명 → 양 본청에서 검색 | |
| rows = (_CONTACTS_BY_REGION.get("광주광역시 본청", []) | |
| + _CONTACTS_BY_REGION.get("전라남도 본청", [])) | |
| # 복합 키워드 분해 확장: '여권발급' → ['여권발급','여권','발급'] (duty '여권 접수' 부분일치 보장) | |
| kws = [] | |
| for k in keywords: | |
| k = str(k).strip() | |
| if not k: | |
| continue | |
| kws.append(k) | |
| for part in k.split(): | |
| if part and part != k: | |
| kws.append(part) | |
| if " " not in k and len(k) >= 4: | |
| kws.extend([k[:2], k[2:]]) | |
| _seen_kw = set() | |
| kws = [x for x in kws if len(x) >= 2 and not (x in _seen_kw or _seen_kw.add(x))][:12] | |
| # IDF 가중: 흔한 키워드(민원·발급·신청)는 낮게, 희소 키워드(여권·귀농)는 높게 → 특이 업무가 묻히지 않게 | |
| import math as _math | |
| n_rows = len(rows) or 1 | |
| idf = {} | |
| for kw in kws: | |
| df = sum(1 for r in rows if kw in r["duty"] or kw in r["dept"] or kw in r["team"]) | |
| idf[kw] = _math.log((n_rows + 1) / (df + 1)) + 1.0 | |
| scored = [] | |
| for r in rows: | |
| s = 0.0 | |
| for kw in kws: | |
| w = idf[kw] | |
| if kw in r["duty"]: | |
| s += 3.0 * w | |
| if kw in r["dept"]: | |
| s += 2.0 * w | |
| if kw in r["team"]: | |
| s += 1.5 * w | |
| if s <= 0: | |
| continue | |
| if any(p in r["position"] for p in ("팀장", "과장", "실장", "소장", "센터장", "읍장", "면장", "동장")): | |
| s += 0.7 # 책임자 우선 | |
| scored.append((s, r)) | |
| scored.sort(key=lambda x: -x[0]) | |
| out, seen = [], set() | |
| for s, r in scored: | |
| k = (r["dept"], r["team"], r["phone"]) | |
| if k in seen: | |
| continue | |
| seen.add(k) | |
| out.append(r) | |
| if len(out) >= limit: | |
| break | |
| return out | |
| def build_contact_context(rows: list) -> str: | |
| """검색된 실제 담당자 목록 → LLM 주입 컨텍스트 (번호 지어내기 금지 규칙 포함).""" | |
| lines = [] | |
| for i, r in enumerate(rows, 1): | |
| team = f" {r['team']}팀" if r["team"] and not r["team"].endswith("팀") else (f" {r['team']}" if r["team"] else "") | |
| pos = f" ({r['position']})" if r["position"] else "" | |
| duty = r["duty"][:90] | |
| lines.append(f"{i}. [{r['region']}] {r['dept']}{team}{pos} ☎ {r['phone']} — {duty}") | |
| head = ("【민원 담당 부서·연락처 실데이터 — 2026-06-12 각 기관 누리집 조직도 공식 수집】\n" | |
| "★아래는 실제 행정 조직도의 담당자 연락처입니다. 시민 질문의 업무와 가장 잘 맞는 부서·전화를 1~3개 골라 정확히 안내하세요.\n" | |
| "★전화번호는 반드시 아래 목록의 번호를 글자 그대로 옮겨 쓰세요. 목록에 없는 번호를 지어내는 것은 절대 금지입니다.\n" | |
| "★목록에 질문 업무와 맞는 부서가 없으면 번호를 만들지 말고 통합특별시 대표번호 062-120을 안내하세요.\n" | |
| "★안내 형식: 부서/팀 · 전화번호 · 담당업무를 표나 리스트로 간결하게. " | |
| "지역이 불명확한 질문이었다면 '거주하시는 구·군을 알려주시면 더 정확한 담당부서를 안내해 드릴 수 있다'고 부드럽게 덧붙이세요.") | |
| return head + "\n" + "\n".join(lines) | |
| async def contacts_route_debug(q: str = ""): | |
| """라우팅 단계별 점검: ?q=질문 → intent/route/검색결과. q 없으면 로드 현황.""" | |
| if not q: | |
| return {"loaded": sum(len(v) for v in _CONTACTS_BY_REGION.values()), | |
| "regions": len(_CONTACTS_BY_REGION)} | |
| intent = _contact_intent(q) | |
| route = await classify_contact_route(q) if intent else {} | |
| rows = (search_contacts(str(route.get("region") or "불명"), route.get("keywords") or []) | |
| if route.get("contact") else []) | |
| return {"intent": intent, "route": route, "found": len(rows), | |
| "top": [{"dept": r["dept"], "team": r["team"], "phone": r["phone"], | |
| "duty": r["duty"][:40]} for r in rows[:5]]} | |
| # ══════════════════════════════════════════════════════════════ | |
| # 공개 API — 키 인증 + OpenAI 호환 (/v1/chat/completions, /v1/models) | |
| # 내부 웹 UI(/api/chat)와 동일한 JGOS 두뇌(시스템프롬프트·웹검색·민원라우팅·추론차단)를 공유 | |
| # ══════════════════════════════════════════════════════════════ | |
| _PUBLIC_API_KEYS = set( | |
| k.strip() for k in os.getenv("JGOS_PUBLIC_API_KEYS", "").split(",") if k.strip() | |
| ) | |
| _API_GUIDE_HTML = """<!doctype html><html lang="ko"><head><meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>JGOS API 사용 매뉴얼 — 전남광주 통합특별시 행정 AI</title> | |
| <style> | |
| :root{--blue:#0052a4;--blue-d:#003d7a;--bg:#f6f8fb;--card:#fff;--line:#e3e9f0;--text:#1a2433;--dim:#5a6a7d;--code:#0d1b2a;} | |
| *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:'Pretendard',-apple-system,'Malgun Gothic',sans-serif;line-height:1.7} | |
| .wrap{max-width:860px;margin:0 auto;padding:0 20px 80px} | |
| header{background:linear-gradient(135deg,var(--blue),var(--blue-d));color:#fff;padding:46px 20px 40px;text-align:center} | |
| header h1{margin:0 0 8px;font-size:1.9rem}header p{margin:0;opacity:.92} | |
| .badge{display:inline-block;background:rgba(255,255,255,.18);padding:4px 12px;border-radius:20px;font-size:.85rem;margin-top:14px} | |
| h2{margin:42px 0 14px;font-size:1.35rem;border-bottom:2px solid var(--blue);padding-bottom:8px} | |
| h3{margin:26px 0 10px;font-size:1.08rem;color:var(--blue-d)} | |
| .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:20px 24px;margin:16px 0;box-shadow:0 1px 3px rgba(0,0,0,.04)} | |
| pre{background:var(--code);color:#e6edf3;padding:16px 18px;border-radius:10px;overflow-x:auto;font-size:.86rem;line-height:1.55;margin:10px 0} | |
| pre code{font-family:'D2Coding','Consolas',monospace} | |
| code.inline{background:#eef2f7;color:var(--blue-d);padding:2px 7px;border-radius:5px;font-size:.9em} | |
| table{width:100%;border-collapse:collapse;margin:12px 0;font-size:.92rem} | |
| th,td{border:1px solid var(--line);padding:9px 12px;text-align:left}th{background:#eef3f9;color:var(--blue-d)} | |
| .k{color:var(--blue);font-weight:700}.muted{color:var(--dim);font-size:.92rem} | |
| .tag{display:inline-block;padding:2px 9px;border-radius:6px;font-size:.78rem;font-weight:700} | |
| .post{background:#dcfce7;color:#166534}.get{background:#dbeafe;color:#1e40af} | |
| .warn{background:#fff7ed;border-left:4px solid #f59e0b;padding:12px 16px;border-radius:6px;margin:14px 0} | |
| a{color:var(--blue)}.toc a{display:inline-block;margin:3px 10px 3px 0} | |
| </style></head><body> | |
| <header> | |
| <h1>🏛️ JGOS API 매뉴얼</h1> | |
| <p>전남광주 통합특별시 행정 AI를 OpenAI 호환 API로 사용하세요</p> | |
| <div class="badge">Base URL · https://jgos.vidraft.net/v1</div> | |
| </header> | |
| <div class="wrap"> | |
| <div class="card toc"> | |
| <b>목차</b><br> | |
| <a href="#intro">1. 개요</a><a href="#auth">2. 인증</a><a href="#quick">3. 빠른 시작</a> | |
| <a href="#python">4. Python</a><a href="#js">5. JavaScript</a><a href="#params">6. 파라미터</a> | |
| <a href="#minwon">7. 민원 특화</a><a href="#stream">8. 스트리밍</a><a href="#errors">9. 오류 코드</a> | |
| </div> | |
| <h2 id="intro">1. 개요</h2> | |
| <div class="card"> | |
| <p>JGOS API는 <b>전남광주 통합특별시 행정 AI</b>를 외부 서비스·앱에서 호출할 수 있는 공개 API입니다. | |
| OpenAI <code class="inline">chat/completions</code> 규격과 100% 호환되어, 기존 OpenAI SDK에서 <b>Base URL과 API 키만 바꾸면</b> 즉시 사용할 수 있습니다.</p> | |
| <table> | |
| <tr><th>항목</th><th>내용</th></tr> | |
| <tr><td>Base URL</td><td><code class="inline">https://jgos.vidraft.net/v1</code></td></tr> | |
| <tr><td>모델 ID</td><td><code class="inline">jgos-31b</code></td></tr> | |
| <tr><td>핵심 기능</td><td>통합특별시 행정·민원 상담, 담당부서 연락처 자동 안내, 실시간 웹검색, 27개 시군구 지역정보</td></tr> | |
| <tr><td>인증</td><td>API 키 (Bearer 토큰)</td></tr> | |
| <tr><td>응답</td><td>일반(JSON) / 스트리밍(SSE) 모두 지원</td></tr> | |
| </table> | |
| </div> | |
| <h2 id="auth">2. 인증</h2> | |
| <div class="card"> | |
| <p>모든 <code class="inline">/v1</code> 요청에는 발급받은 API 키를 <b>Authorization 헤더</b>에 담아야 합니다.</p> | |
| <pre><code>Authorization: Bearer jgos-live-여기에_발급받은_키</code></pre> | |
| <p class="muted">또는 <code class="inline">X-API-Key: jgos-live-...</code> 헤더도 지원합니다.</p> | |
| <div class="warn">🔑 <b>API 키 발급</b>은 운영자(VIDRAFT)에게 문의하세요. 키는 비밀이며, 프런트엔드(브라우저)에 노출하지 말고 반드시 <b>서버 측</b>에서만 사용하세요.</div> | |
| </div> | |
| <h2 id="quick">3. 빠른 시작 (curl)</h2> | |
| <div class="card"> | |
| <p><span class="tag post">POST</span> <code class="inline">/v1/chat/completions</code></p> | |
| <pre><code>curl https://jgos.vidraft.net/v1/chat/completions \\ | |
| -H "Authorization: Bearer jgos-live-여기에_발급받은_키" \\ | |
| -H "Content-Type: application/json" \\ | |
| -d '{ | |
| "model": "jgos-31b", | |
| "messages": [ | |
| {"role": "user", "content": "전남광주 통합특별시 인구가 얼마나 되나요?"} | |
| ] | |
| }'</code></pre> | |
| <p class="muted">응답(JSON)의 <code class="inline">choices[0].message.content</code>에 답변이 담깁니다.</p> | |
| </div> | |
| <h2 id="python">4. Python</h2> | |
| <div class="card"> | |
| <h3>① OpenAI 공식 SDK (권장)</h3> | |
| <pre><code>from openai import OpenAI | |
| client = OpenAI( | |
| api_key="jgos-live-여기에_발급받은_키", | |
| base_url="https://jgos.vidraft.net/v1", | |
| ) | |
| resp = client.chat.completions.create( | |
| model="jgos-31b", | |
| messages=[{"role": "user", "content": "여권 발급 절차 알려줘"}], | |
| ) | |
| print(resp.choices[0].message.content)</code></pre> | |
| <h3>② requests (의존성 없이)</h3> | |
| <pre><code>import requests | |
| r = requests.post( | |
| "https://jgos.vidraft.net/v1/chat/completions", | |
| headers={"Authorization": "Bearer jgos-live-여기에_발급받은_키"}, | |
| json={"model": "jgos-31b", | |
| "messages": [{"role": "user", "content": "광주 맛집 추천해줘"}]}, | |
| ) | |
| print(r.json()["choices"][0]["message"]["content"])</code></pre> | |
| </div> | |
| <h2 id="js">5. JavaScript (Node / 브라우저-서버)</h2> | |
| <div class="card"> | |
| <pre><code>const res = await fetch("https://jgos.vidraft.net/v1/chat/completions", { | |
| method: "POST", | |
| headers: { | |
| "Authorization": "Bearer jgos-live-여기에_발급받은_키", | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ | |
| model: "jgos-31b", | |
| messages: [{ role: "user", content: "순천만 국가정원 입장료 알려줘" }], | |
| }), | |
| }); | |
| const data = await res.json(); | |
| console.log(data.choices[0].message.content);</code></pre> | |
| </div> | |
| <h2 id="params">6. 요청 파라미터</h2> | |
| <div class="card"> | |
| <table> | |
| <tr><th>필드</th><th>타입</th><th>기본</th><th>설명</th></tr> | |
| <tr><td class="k">model</td><td>string</td><td>jgos-31b</td><td>모델 ID (현재 <code class="inline">jgos-31b</code>)</td></tr> | |
| <tr><td class="k">messages</td><td>array</td><td>필수</td><td>OpenAI 형식 대화 배열 (role: system/user/assistant)</td></tr> | |
| <tr><td class="k">stream</td><td>bool</td><td>false</td><td>true 시 SSE 스트리밍</td></tr> | |
| <tr><td class="k">temperature</td><td>float</td><td>0.3</td><td>창의성 (0~1)</td></tr> | |
| <tr><td class="k">max_tokens</td><td>int</td><td>1200</td><td>최대 응답 토큰 (상한 2048)</td></tr> | |
| <tr><td class="k">web</td><td>bool</td><td>false</td><td><b>JGOS 확장</b> · 실시간 웹검색 grounding</td></tr> | |
| <tr><td class="k">minwon</td><td>bool</td><td>false</td><td><b>JGOS 확장</b> · 담당부서·연락처 자동 안내(민원 집중)</td></tr> | |
| </table> | |
| <p class="muted">OpenAI SDK에서 <code class="inline">web</code>·<code class="inline">minwon</code> 같은 확장 파라미터는 <code class="inline">extra_body={...}</code>로 전달합니다.</p> | |
| </div> | |
| <h2 id="minwon">7. 민원 특화 — 담당자 연락처 자동 안내</h2> | |
| <div class="card"> | |
| <p><code class="inline">minwon: true</code>를 켜면, 질문을 분석해 <b>광주 5구·전남 22시군 + 양 본청(총 29개 기관, 3.8만 행 조직도)</b>에서 담당 부서와 직통 전화번호를 찾아 안내합니다. (전화번호는 실제 수집 데이터만 사용, 지어내지 않음)</p> | |
| <pre><code>resp = client.chat.completions.create( | |
| model="jgos-31b", | |
| messages=[{"role": "user", "content": "북구에서 쓰레기 무단투기 신고하려면 어디로 전화해요?"}], | |
| extra_body={"minwon": True}, | |
| ) | |
| print(resp.choices[0].message.content) | |
| # → 광주 북구 청소행정과 062-410-6509 등 실제 담당부서·연락처 안내</code></pre> | |
| </div> | |
| <h2 id="stream">8. 스트리밍 (SSE)</h2> | |
| <div class="card"> | |
| <pre><code>stream = client.chat.completions.create( | |
| model="jgos-31b", | |
| messages=[{"role": "user", "content": "5·18 민주화운동에 대해 알려줘"}], | |
| stream=True, | |
| ) | |
| for chunk in stream: | |
| delta = chunk.choices[0].delta.content or "" | |
| print(delta, end="", flush=True)</code></pre> | |
| <p class="muted">표준 OpenAI <code class="inline">chat.completion.chunk</code> 형식으로 전송되며 <code class="inline">data: [DONE]</code>으로 종료됩니다.</p> | |
| </div> | |
| <h2 id="errors">9. 오류 코드</h2> | |
| <div class="card"> | |
| <table> | |
| <tr><th>코드</th><th>의미</th><th>해결</th></tr> | |
| <tr><td>400</td><td>잘못된 요청</td><td>messages 필드·JSON 형식 확인</td></tr> | |
| <tr><td>401</td><td>API 키 없음/오류</td><td>Authorization 헤더의 키 확인</td></tr> | |
| <tr><td>502</td><td>LLM 백엔드 오류</td><td>잠시 후 재시도</td></tr> | |
| <tr><td>503</td><td>키 미설정</td><td>운영자 문의</td></tr> | |
| </table> | |
| </div> | |
| <div class="card" style="text-align:center;color:var(--dim)"> | |
| <p>🏛️ <b>JGOS</b> — 전남광주 통합특별시 행정 AI · powered by VIDRAFT<br> | |
| API 키 발급·제휴 문의: 통합특별시청 062-120 / VIDRAFT</p> | |
| </div> | |
| </div></body></html>""" | |
| def _require_api_key(request: Request) -> str: | |
| """Authorization: Bearer <key> 또는 X-API-Key 헤더 검증. 실패 시 401/503.""" | |
| auth = request.headers.get("authorization", "") | |
| key = auth[7:].strip() if auth[:7].lower() == "bearer " else "" | |
| if not key: | |
| key = request.headers.get("x-api-key", "").strip() | |
| if not _PUBLIC_API_KEYS: | |
| raise HTTPException(503, "공개 API 키가 아직 설정되지 않았습니다. 운영자에게 문의하세요.") | |
| if key not in _PUBLIC_API_KEYS: | |
| raise HTTPException(401, "유효하지 않은 API 키입니다. 사용법: https://jgos.vidraft.net/api/guide") | |
| return key | |
| async def _prepare_jgos_request(body: dict): | |
| """OpenAI 형식 body → JGOS 두뇌 처리(시스템프롬프트·웹검색·민원라우팅·추론차단) → (payload, search_results). | |
| 확장 파라미터: web(bool 웹검색), minwon(bool 민원 담당자 라우팅).""" | |
| user_messages = body.get("messages", []) | |
| temperature = body.get("temperature", 0.3) | |
| max_tokens = min(int(body.get("max_tokens", 1200) or 1200), 2048) # B200 vLLM: cap 2048 | |
| web_enabled = bool(body.get("web", False)) # 🔍 웹검색 | |
| minwon_enabled = bool(body.get("minwon", False)) # 📞 민원 담당자 라우팅 | |
| # vision 메시지(content=list)는 그대로 패스스루 — OpenAI vision 형식 호환 | |
| last_user = "" | |
| for m in reversed(user_messages): | |
| if m.get("role") == "user": | |
| c = m.get("content", "") | |
| if isinstance(c, str): | |
| last_user = c | |
| elif isinstance(c, list): | |
| last_user = " ".join(p.get("text", "") for p in c if isinstance(p, dict) and p.get("type") == "text") | |
| break | |
| # 웹 검색: 토글 ON 또는 '최신성' 키워드 자동 감지 | |
| search_results = [] | |
| if (web_enabled or _is_fresh_query(last_user)) and last_user: | |
| search_results = await run_web_search(last_user) | |
| # 민원 담당자 연락처: LLM이 지역+업무 판단 → 조직도 실데이터 검색 | |
| contact_rows = [] | |
| if last_user and _CONTACTS_BY_REGION and minwon_enabled: # 민원 버튼 ON일 때만 라우팅 (OFF=일반 답변) | |
| _route = await classify_contact_route(last_user) | |
| if _route.get("contact") or minwon_enabled: | |
| _kws = _route.get("keywords") or [] | |
| if not _kws and minwon_enabled: | |
| _kws = [w.strip("?.,!~") for w in last_user.split() if len(w.strip("?.,!~")) >= 2][:6] | |
| contact_rows = search_contacts(str(_route.get("region") or "불명"), _kws) | |
| today_kst = datetime.now(KST).strftime("%Y년 %m월 %d일") | |
| dated_system = (f"[현재 날짜: 오늘은 {today_kst}입니다.] " | |
| "기업 설립 경과 연수·나이·기간 등 모든 시점·경과 계산은 반드시 이 날짜를 기준으로 하세요. " | |
| "당신의 학습 시점이 아니라 이 날짜가 '현재'이며, 이보다 이전의 일은 모두 과거입니다. " | |
| "★출력 규칙(엄수): '맥락 파악', '체계적 분석', '세분화', '비교', '해결', '자기 수정', '제약 조건', '단계' 같은 " | |
| "사고 과정·분석 절차('먼저', '다음으로', '이어서', '마지막으로', '종합하면' 등으로 이어지는 단계 서술)를 절대 화면에 쓰지 마세요. " | |
| "'최종 답변:', '답변:' 같은 머리말도 쓰지 말고, 생각은 속으로만 한 뒤 따뜻한 인사로 바로 답변을 시작하세요.\n\n" + SYSTEM_PROMPT) | |
| if minwon_enabled: | |
| dated_system = ("★민원 모드 ON: 시민이 '민원' 버튼을 켰습니다. 이 질문을 행정 민원 상담으로 간주하고 " | |
| "①따뜻한 공감 인사 ②담당 부서·전화번호(제공된 실데이터 목록의 번호만 사용) ③처리 절차와 필요 서류 " | |
| "④처리 기간·유의사항 순서로 민원 안내에만 집중하세요. 일반 지식 설명·잡담은 최소화하세요.\n\n" | |
| + dated_system) | |
| else: | |
| dated_system = ("★일반 모드: 담당 부서·직통 전화번호를 임의로 지어내지 마세요. " | |
| "시민이 특정 부서·담당자 연락처나 '어디로 전화'를 직접 물으면, 추측한 번호를 제시하지 말고 " | |
| "'정확한 담당 부서와 직통 전화번호는 화면의 📞 민원 버튼을 켜고 다시 질문해 주세요'라고 부드럽게 안내하세요. " | |
| "(통합특별시 대표번호 062-120은 안내 가능)\n\n" + dated_system) | |
| if not user_messages or user_messages[0].get("role") != "system": | |
| user_messages = [{"role": "system", "content": dated_system}] + user_messages | |
| else: | |
| user_messages[0] = {**user_messages[0], "content": dated_system + "\n\n" + user_messages[0].get("content", "")} | |
| # 검색·연락처 컨텍스트를 '마지막 user 질문에 직접 주입' (긴 system에 묻히지 않게 → 반영 강제) | |
| _inject_blocks = [] | |
| if search_results: | |
| bump("web_search") | |
| _inject_blocks.append(build_search_context(search_results)) | |
| if contact_rows: | |
| bump("contact_route") | |
| _inject_blocks.append(build_contact_context(contact_rows)) | |
| elif minwon_enabled: | |
| _inject_blocks.append( | |
| "【민원 모드 — 담당 부서 검색 결과 없음】 이번 질문으로는 조직도에서 담당 부서를 특정하지 못했습니다. " | |
| "전화번호를 절대 지어내지 말고, 통합특별시 대표번호 062-120을 안내한 뒤 " | |
| "'거주하시는 구·군과 민원 내용을 조금 더 구체적으로 알려주시면 담당 부서와 직통 전화번호를 바로 찾아드리겠다'고 부드럽게 안내하세요.") | |
| if _inject_blocks: | |
| _ctx = "\n\n".join(_inject_blocks) | |
| for _i in range(len(user_messages) - 1, -1, -1): | |
| if user_messages[_i].get("role") == "user": | |
| _c = user_messages[_i].get("content", "") | |
| if isinstance(_c, str): | |
| user_messages[_i] = {**user_messages[_i], "content": _ctx + "\n\n[시민 질문] " + _c} | |
| elif isinstance(_c, list): | |
| user_messages[_i] = {**user_messages[_i], | |
| "content": [{"type": "text", "text": _ctx + "\n\n[시민 질문] "}] + _c} | |
| break | |
| payload = { | |
| "model": LLM_MODEL, | |
| "messages": user_messages, | |
| "temperature": temperature, | |
| "max_tokens": max_tokens, | |
| "stream": True, | |
| "chat_template_kwargs": {"enable_thinking": False}, # ★ 추론 과정 원천 차단 | |
| } | |
| return payload, search_results | |
| async def _jgos_token_stream(payload) -> AsyncIterator[str]: | |
| """내부 vLLM 스트림 → 추론(reasoning) 제거된 본문 텍스트 델타만 yield. 출력 형식 무관(코어).""" | |
| raw = "" | |
| started = False | |
| async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client: | |
| async with client.stream( | |
| "POST", f"{LLM_BASE_URL}/chat/completions", | |
| headers={ | |
| "Authorization": f"Bearer {LLM_API_KEY}", | |
| "Content-Type": "application/json", | |
| "User-Agent": "Mozilla/5.0 (JGOS-Origin Space)", | |
| }, | |
| json=payload, | |
| ) as r: | |
| if r.status_code != 200: | |
| err_body = await r.aread() | |
| raise RuntimeError(f"LLM HTTP {r.status_code}: {err_body.decode()[:200]}") | |
| async for line in r.aiter_lines(): | |
| if not line or not line.startswith("data: "): | |
| continue | |
| data = line[6:].strip() | |
| if data == "[DONE]": | |
| break | |
| try: | |
| j = json.loads(data) | |
| delta = j.get("choices", [{}])[0].get("delta", {}).get("content", "") | |
| except Exception: | |
| continue | |
| if not delta: | |
| continue | |
| raw += delta | |
| if started: | |
| yield delta | |
| else: | |
| idx = _find_body_start(raw) | |
| if idx is not None: | |
| started = True | |
| head = raw[idx:] | |
| if head: | |
| yield head | |
| if not started: # 한국어 본문 미탐지(짧은/영어 답) → reasoning 제거 후 출력 | |
| cleaned = _strip_reasoning_fallback(raw) | |
| if cleaned: | |
| yield cleaned | |
| async def chat(request: Request): | |
| """웹 UI 전용 SSE — same-origin, 인증 불필요. 출처 카드 포함.""" | |
| body = await request.json() | |
| payload, search_results = await _prepare_jgos_request(body) | |
| def _chunk(text: str) -> bytes: | |
| return f"data: {json.dumps({'choices':[{'delta':{'content':text}}]}, ensure_ascii=False)}\n\n".encode() | |
| async def stream() -> AsyncIterator[bytes]: | |
| try: | |
| async for delta in _jgos_token_stream(payload): | |
| yield _chunk(delta) | |
| if search_results: | |
| srcs = [{"title": r["title"], "link": r["link"], "src": r["src"]} | |
| for r in search_results if r.get("link")] | |
| if srcs: | |
| yield f"data: {json.dumps({'sources': srcs}, ensure_ascii=False)}\n\n".encode() | |
| yield b"data: [DONE]\n\n" | |
| except Exception as e: | |
| yield f"data: {json.dumps({'error': f'{type(e).__name__}: {str(e)[:200]}'})}\n\n".encode() | |
| return StreamingResponse(stream(), media_type="text/event-stream", headers={ | |
| "Cache-Control": "no-cache", "X-Accel-Buffering": "no", | |
| }) | |
| async def openai_models(request: Request): | |
| """OpenAI 호환 모델 목록. API 키 필요.""" | |
| _require_api_key(request) | |
| return {"object": "list", "data": [ | |
| {"id": "jgos-31b", "object": "model", "created": 1717200000, "owned_by": "VIDraft", | |
| "description": "전남광주 통합특별시 행정 AI (JGOS-31B-Origin)"}, | |
| ]} | |
| async def openai_chat_completions(request: Request): | |
| """OpenAI 호환 채팅 완성. API 키 필요. stream true/false 모두 지원. | |
| JGOS 확장 파라미터: web(웹검색), minwon(민원 담당자 자동 안내).""" | |
| _require_api_key(request) | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| raise HTTPException(400, "요청 본문이 올바른 JSON이 아닙니다.") | |
| if not body.get("messages"): | |
| raise HTTPException(400, "'messages' 필드가 필요합니다.") | |
| stream_mode = bool(body.get("stream", False)) | |
| model_name = body.get("model") or "jgos-31b" | |
| payload, search_results = await _prepare_jgos_request(body) | |
| cmpl_id = "chatcmpl-" + secrets.token_hex(12) | |
| created = int(time.time()) | |
| if stream_mode: | |
| async def gen() -> AsyncIterator[bytes]: | |
| def _wrap(delta_obj, finish=None): | |
| o = {"id": cmpl_id, "object": "chat.completion.chunk", "created": created, | |
| "model": model_name, | |
| "choices": [{"index": 0, "delta": delta_obj, "finish_reason": finish}]} | |
| return f"data: {json.dumps(o, ensure_ascii=False)}\n\n".encode() | |
| try: | |
| yield _wrap({"role": "assistant"}) | |
| async for delta in _jgos_token_stream(payload): | |
| yield _wrap({"content": delta}) | |
| yield _wrap({}, finish="stop") | |
| yield b"data: [DONE]\n\n" | |
| except Exception as e: | |
| err = {"error": {"message": f"{type(e).__name__}: {str(e)[:200]}", "type": "server_error"}} | |
| yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n".encode() | |
| return StreamingResponse(gen(), media_type="text/event-stream", headers={ | |
| "Cache-Control": "no-cache", "X-Accel-Buffering": "no", | |
| }) | |
| # non-stream: 전체 수집 후 한 번에 반환 | |
| parts = [] | |
| try: | |
| async for delta in _jgos_token_stream(payload): | |
| parts.append(delta) | |
| except Exception as e: | |
| raise HTTPException(502, f"LLM 백엔드 오류: {str(e)[:200]}") | |
| text = "".join(parts).strip() | |
| resp = {"id": cmpl_id, "object": "chat.completion", "created": created, "model": model_name, | |
| "choices": [{"index": 0, "message": {"role": "assistant", "content": text}, | |
| "finish_reason": "stop"}], | |
| "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}} | |
| if search_results: # JGOS 확장: 웹검색 출처 | |
| resp["jgos_sources"] = [{"title": r["title"], "link": r["link"], "src": r["src"]} | |
| for r in search_results if r.get("link")] | |
| return JSONResponse(resp) | |
| async def api_guide(): | |
| """공개 API 사용 매뉴얼 (HTML).""" | |
| return HTMLResponse(_API_GUIDE_HTML) | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860"))) | |
| # ============================================================ | |
| # ===== 🏛️ 행정 AX — 이종 파일 포맷 통합 파서 (2026-06-10) ===== | |
| # ============================================================ | |
| FIELD_MAP_AX = { | |
| "신청번호":"신청번호","접수번호":"신청번호","일련번호":"신청번호","순번":"신청번호","번호":"신청번호","연번":"신청번호","대상자번호":"신청번호","관리번호":"신청번호","id":"신청번호","no":"신청번호", | |
| "성명":"성명","접수자명":"성명","신청자명":"성명","이름":"성명","민원인":"성명","name":"성명", | |
| "생년월일":"생년월일","birth":"생년월일","생년":"생년월일", | |
| "성별":"성별","gender":"성별", | |
| "연락처":"연락처","전화번호":"연락처","phone":"연락처","tel":"연락처", | |
| "주소":"주소","address":"주소","addr":"주소", | |
| "신청일":"신청일","접수일자":"신청일","접수일":"신청일","신청날짜":"신청일","apply_date":"신청일","date":"신청일", | |
| "복지유형":"복지유형","복지종류":"복지유형","지원유형":"복지유형","복지구분":"복지유형","민원내용":"복지유형","welfare_type":"복지유형","type":"복지유형", | |
| "담당부서":"담당부서","담당과":"담당부서","소관과":"담당부서","담당":"담당부서","dept":"담당부서", | |
| "처리상태":"처리상태","진행상태":"처리상태","현황":"처리상태","처리상황":"처리상태","진행":"처리상태","status":"처리상태","state":"처리상태", | |
| } | |
| STATUS_MAP_AX = { | |
| "approved":"승인","pending":"접수","reviewing":"검토중","rejected":"반려", | |
| "승인 완료":"승인","접수 처리":"접수","검토 진행 중":"검토중","반려 처리":"반려", | |
| "m":"남","f":"여", | |
| } | |
| STD_FIELDS_AX = ["신청번호","성명","생년월일","성별","연락처","주소","신청일","복지유형","담당부서","처리상태"] | |
| def _ax_normalize(records: list, source_file: str) -> list: | |
| """필드명 정규화 + 처리상태 값 정규화""" | |
| import re as _re | |
| result = [] | |
| for rec in records: | |
| norm = {"출처파일": source_file} | |
| for k, v in rec.items(): | |
| std = FIELD_MAP_AX.get(str(k).strip().lower(), str(k).strip()) | |
| val = str(v).strip() if v is not None else "" | |
| if std == "처리상태": | |
| val = STATUS_MAP_AX.get(val.lower(), val) | |
| elif std == "성별": | |
| val = STATUS_MAP_AX.get(val.lower(), val) | |
| elif std == "신청일": | |
| val = _re.sub(r'[./년]', '-', val) | |
| val = _re.sub(r'[월일]', '', val).strip() | |
| val = _re.sub(r'-+', '-', val).strip('-') | |
| elif std == "신청번호": | |
| _m = _re.search(r'\d+', val) | |
| if _m: | |
| val = '%03d' % int(_m.group()) | |
| norm[std] = val | |
| result.append(norm) | |
| return result | |
| def _parse_xlsx_ax(data: bytes, filename: str) -> list: | |
| from openpyxl import load_workbook | |
| wb = load_workbook(io.BytesIO(data), data_only=True) | |
| ws = wb.active | |
| header_row, headers = None, [] | |
| for i, row in enumerate(ws.iter_rows(max_row=6, values_only=True)): | |
| cells = [str(c).strip().lower() if c else "" for c in row] | |
| if sum(1 for c in cells if c and c in FIELD_MAP_AX) >= 3: | |
| header_row = i | |
| headers = [str(c).strip() if c else "" for c in row] | |
| break | |
| if not headers: | |
| return [] | |
| records = [] | |
| for row in ws.iter_rows(min_row=header_row + 2, values_only=True): | |
| if not any(row): | |
| continue | |
| rec = {headers[j]: (str(v).strip() if v is not None else "") for j, v in enumerate(row) if j < len(headers)} | |
| records.append(rec) | |
| return _ax_normalize(records, filename) | |
| def _parse_csv_ax(data: bytes, filename: str) -> list: | |
| import csv, chardet | |
| enc = chardet.detect(data)["encoding"] or "utf-8" | |
| for e in [enc, "utf-8-sig", "utf-8", "euc-kr", "cp949"]: | |
| try: | |
| text = data.decode(e) | |
| import io as _io | |
| reader = csv.DictReader(_io.StringIO(text)) | |
| rows = [dict(r) for r in reader] | |
| return _ax_normalize(rows, filename) | |
| except Exception: | |
| continue | |
| return [] | |
| def _parse_docx_ax(data: bytes, filename: str) -> list: | |
| from docx import Document | |
| doc = Document(io.BytesIO(data)) | |
| records = [] | |
| for table in doc.tables: | |
| if len(table.rows) < 2: | |
| continue | |
| headers = [cell.text.strip() for cell in table.rows[0].cells] | |
| for row in table.rows[1:]: | |
| vals = [cell.text.strip() for cell in row.cells] | |
| if any(vals): | |
| records.append(dict(zip(headers, vals))) | |
| return _ax_normalize(records, filename) | |
| def _parse_pptx_ax(data: bytes, filename: str) -> list: | |
| from pptx import Presentation | |
| prs = Presentation(io.BytesIO(data)) | |
| records = [] | |
| for slide in prs.slides: | |
| for shape in slide.shapes: | |
| if shape.has_table: | |
| tbl = shape.table | |
| if len(tbl.rows) < 2: | |
| continue | |
| nc = len(tbl.columns) | |
| headers = [tbl.cell(0, c).text.strip() for c in range(nc)] | |
| for r in range(1, len(tbl.rows)): | |
| vals = [tbl.cell(r, c).text.strip() for c in range(nc)] | |
| if any(vals): | |
| records.append(dict(zip(headers, vals))) | |
| return _ax_normalize(records, filename) | |
| def _parse_pdf_ax(data: bytes, filename: str) -> list: | |
| try: | |
| import pdfplumber | |
| records = [] | |
| with pdfplumber.open(io.BytesIO(data)) as pdf: | |
| for page in pdf.pages: | |
| for table in (page.extract_tables() or []): | |
| if not table or len(table) < 2: | |
| continue | |
| headers = [str(c).strip() if c else "" for c in table[0]] | |
| for row in table[1:]: | |
| vals = [str(c).strip() if c else "" for c in row] | |
| if any(vals): | |
| records.append(dict(zip(headers, vals))) | |
| return _ax_normalize(records, filename) | |
| except Exception: | |
| return [] | |
| def _parse_hwpx_ax(data: bytes, filename: str) -> list: | |
| import zipfile | |
| try: | |
| from lxml import etree | |
| except ImportError: | |
| return [] | |
| records = [] | |
| try: | |
| with zipfile.ZipFile(io.BytesIO(data)) as z: | |
| section_files = [n for n in z.namelist() if "section" in n.lower() and n.endswith(".xml")] | |
| for sf in section_files: | |
| root = etree.fromstring(z.read(sf)) | |
| for table in root.iter("{*}Table"): | |
| rows = list(table.iter("{*}Tr")) | |
| if len(rows) < 2: | |
| continue | |
| def cell_text(cell): | |
| return "".join(t.text or "" for t in cell.iter("{*}T")).strip() | |
| headers = [cell_text(c) for c in rows[0].iter("{*}Tc")] | |
| for row in rows[1:]: | |
| vals = [cell_text(c) for c in row.iter("{*}Tc")] | |
| if any(vals): | |
| records.append(dict(zip(headers, vals))) | |
| except Exception: | |
| pass | |
| return _ax_normalize(records, filename) | |
| def _records_to_html(records: list, title: str = "") -> str: | |
| if not records: | |
| return "<p>데이터 없음</p>" | |
| all_keys: list = [] | |
| for r in records: | |
| for k in r: | |
| if k not in all_keys: | |
| all_keys.append(k) | |
| priority = STD_FIELDS_AX + ["출처파일"] | |
| keys = [k for k in priority if k in all_keys] + [k for k in all_keys if k not in priority] | |
| rows_html = "" | |
| for rec in records: | |
| status = rec.get("처리상태", "") | |
| cls = {"승인": "status-ok", "반려": "status-ng", "검토중": "status-review", "접수": "status-new"}.get(status, "") | |
| cells = "".join(f"<td>{rec.get(k, '')}</td>" for k in keys) | |
| rows_html += f"<tr class='{cls}'>{cells}</tr>" | |
| header_html = "".join(f"<th>{k}</th>" for k in keys) | |
| t = "<div class='ax-result-wrap'>" | |
| if title: | |
| t += f"<div class='ax-result-title'>{title}</div>" | |
| t += (f"<div class='ax-table-scroll'>" | |
| f"<table class='ax-table'>" | |
| f"<thead><tr>{header_html}</tr></thead>" | |
| f"<tbody>{rows_html}</tbody>" | |
| f"</table></div></div>") | |
| return t | |
| def _do_merge(all_records: list) -> tuple: | |
| """중복 제거 (신청번호 기준, 마지막 우선) → (merged_list, dup_count)""" | |
| seen: dict = {} | |
| for rec in all_records: | |
| key = rec.get("신청번호", "") | |
| if key: | |
| seen[key] = rec | |
| else: | |
| seen[id(rec)] = rec | |
| merged = sorted(seen.values(), key=lambda r: r.get("신청번호", "999")) | |
| return merged, len(all_records) - len(merged) | |
| async def excel_merge(files: List[UploadFile] = File(...)): | |
| """여러 이종 Excel/CSV 파일 → 통합 데이터 반환""" | |
| all_records: list = [] | |
| source_info: list = [] | |
| for f in files: | |
| data = await f.read() | |
| ext = (f.filename or "").rsplit(".", 1)[-1].lower() | |
| fname = f.filename or "unknown" | |
| if ext in ("xlsx", "xls"): | |
| recs = _parse_xlsx_ax(data, fname) | |
| elif ext == "csv": | |
| recs = _parse_csv_ax(data, fname) | |
| else: | |
| recs = [] | |
| all_records.extend(recs) | |
| source_info.append({"파일": fname, "건수": len(recs), "포맷": ext.upper()}) | |
| merged, dup_count = _do_merge(all_records) | |
| return { | |
| "ok": True, | |
| "total": len(merged), | |
| "duplicates_removed": dup_count, | |
| "sources": source_info, | |
| "html": _records_to_html(merged, f"통합 결과 — {len(merged)}건 (중복 {dup_count}건 제거)"), | |
| "records": merged, | |
| } | |
| async def files_merge_formats(files: List[UploadFile] = File(...)): | |
| """7개 이종 포맷 → 통합 데이터 반환""" | |
| all_records: list = [] | |
| source_info: list = [] | |
| for f in files: | |
| data = await f.read() | |
| ext = (f.filename or "").rsplit(".", 1)[-1].lower() | |
| fname = f.filename or "unknown" | |
| if ext in ("xlsx", "xls"): | |
| recs = _parse_xlsx_ax(data, fname) | |
| elif ext == "csv": | |
| recs = _parse_csv_ax(data, fname) | |
| elif ext == "docx": | |
| recs = _parse_docx_ax(data, fname) | |
| elif ext == "pptx": | |
| recs = _parse_pptx_ax(data, fname) | |
| elif ext == "pdf": | |
| recs = _parse_pdf_ax(data, fname) | |
| elif ext in ("hwpx", "hwp"): | |
| recs = _parse_hwpx_ax(data, fname) | |
| else: | |
| recs = [] | |
| all_records.extend(recs) | |
| source_info.append({"파일": fname, "건수": len(recs), "포맷": ext.upper()}) | |
| merged, dup_count = _do_merge(all_records) | |
| return { | |
| "ok": True, | |
| "total": len(merged), | |
| "duplicates_removed": dup_count, | |
| "sources": source_info, | |
| "html": _records_to_html(merged, f"이종 포맷 통합 결과 — {len(merged)}건"), | |
| "records": merged, | |
| } | |
| async def files_preview(file: UploadFile = File(...)): | |
| """단일 파일 → HTML 테이블 미리보기""" | |
| data = await file.read() | |
| ext = (file.filename or "").rsplit(".", 1)[-1].lower() | |
| fname = file.filename or "unknown" | |
| if ext in ("xlsx", "xls"): | |
| recs = _parse_xlsx_ax(data, fname) | |
| elif ext == "csv": | |
| recs = _parse_csv_ax(data, fname) | |
| elif ext == "docx": | |
| recs = _parse_docx_ax(data, fname) | |
| elif ext == "pptx": | |
| recs = _parse_pptx_ax(data, fname) | |
| elif ext == "pdf": | |
| recs = _parse_pdf_ax(data, fname) | |
| elif ext in ("hwpx", "hwp"): | |
| recs = _parse_hwpx_ax(data, fname) | |
| else: | |
| recs = [] | |
| html = _records_to_html(recs, f"{fname} — {len(recs)}건") | |
| return {"ok": True, "filename": fname, "rows": len(recs), "html": html, "ext": ext} | |
| # ===== 데모 파일 서빙 ===== | |
| import glob as _glob | |
| import mimetypes as _mimetypes | |
| _DEMO_DIR = os.path.join(os.path.dirname(__file__), "static", "demo") | |
| async def demo_list(): | |
| files = [] | |
| if os.path.isdir(_DEMO_DIR): | |
| for fp in sorted(os.listdir(_DEMO_DIR)): | |
| ext = fp.rsplit(".", 1)[-1].lower() if "." in fp else "" | |
| files.append({"name": fp, "ext": ext, "url": f"/api/demo/{fp}"}) | |
| return {"files": files} | |
| # ===== demo GET preview ===== | |
| async def export_xlsx(request: Request): | |
| """통합 결과 records를 XLSX로 변환 다운로드 (최소+안전)""" | |
| import io, traceback | |
| try: | |
| payload = await request.json() | |
| records = payload.get("records", []) or [] | |
| from openpyxl import Workbook | |
| from openpyxl.styles import Font, PatternFill, Alignment | |
| from openpyxl.utils import get_column_letter | |
| wb = Workbook() | |
| ws = wb.active | |
| ws.title = "통합결과" | |
| STD = ["신청번호","성명","생년월일","성별","연락처","주소","신청일","복지유형","담당부서","처리상태","출처파일"] | |
| keys = [k for k in STD if any(k in r for r in records)] | |
| for r in records: | |
| for k in r: | |
| if k not in keys: | |
| keys.append(k) | |
| if not keys: | |
| keys = ["(빈 결과)"] | |
| # 헤더 | |
| for ci, h in enumerate(keys, 1): | |
| c = ws.cell(1, ci, h) | |
| c.font = Font(bold=True, color="FFFFFF") | |
| c.fill = PatternFill("solid", fgColor="1F4E79") | |
| c.alignment = Alignment(horizontal="center") | |
| # 데이터 (신청번호 정렬) | |
| try: | |
| rows = sorted(records, key=lambda x: str(x.get("신청번호", "999"))) | |
| except Exception: | |
| rows = records | |
| for ri, r in enumerate(rows, 2): | |
| for ci, k in enumerate(keys, 1): | |
| ws.cell(ri, ci, str(r.get(k, ""))) | |
| # 컬럼 너비 | |
| for ci, k in enumerate(keys, 1): | |
| try: | |
| maxw = max([len(str(k))] + [len(str(r.get(k, ""))) for r in records]) + 2 | |
| ws.column_dimensions[get_column_letter(ci)].width = min(max(maxw, 10), 40) | |
| except Exception: | |
| pass | |
| ws.freeze_panes = "A2" | |
| buf = io.BytesIO() | |
| wb.save(buf) | |
| buf.seek(0) | |
| import urllib.parse as _up | |
| fname = _up.quote("JGOS_행정AX_통합결과.xlsx") | |
| return StreamingResponse( | |
| buf, | |
| media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| headers={"Content-Disposition": "attachment; filename=jgos_merged.xlsx; filename*=UTF-8''" + fname}) | |
| except Exception as e: | |
| return JSONResponse({"error": str(e), "tb": traceback.format_exc()[-600:]}, status_code=500) | |
| async def demo_merge(demo_type: str): | |
| """예제 파일들을 서버가 직접 읽어 통합 (업로드 불필요)""" | |
| if demo_type == "excel": | |
| files = [f for f in sorted(os.listdir(_DEMO_DIR)) | |
| if f[:2].isdigit() and f.lower().endswith((".xlsx", ".xls"))] | |
| else: # formats | |
| files = ["복지신청_광주동구.hwpx", "복지신청_전남도청.xlsx", "복지수급현황.csv", | |
| "사업설명회_자료.pptx", "민원접수대장.docx", "공문_복지공람.pdf", "복지신청_한글.hwpx"] | |
| all_records, sources = [], [] | |
| for fn in files: | |
| fp = os.path.join(_DEMO_DIR, fn) | |
| if not os.path.isfile(fp): | |
| continue | |
| data = open(fp, "rb").read() | |
| ext = fn.rsplit(".", 1)[-1].lower() | |
| if ext in ("xlsx", "xls"): | |
| recs = _parse_xlsx_ax(data, fn) | |
| elif ext == "csv": | |
| recs = _parse_csv_ax(data, fn) | |
| elif ext == "docx": | |
| recs = _parse_docx_ax(data, fn) | |
| elif ext == "pptx": | |
| recs = _parse_pptx_ax(data, fn) | |
| elif ext == "pdf": | |
| recs = _parse_pdf_ax(data, fn) | |
| elif ext in ("hwpx", "hwp"): | |
| recs = _parse_hwpx_ax(data, fn) | |
| else: | |
| recs = [] | |
| all_records.extend(recs) | |
| sources.append({"파일": fn, "건수": len(recs), "포맷": ext.upper()}) | |
| seen = {} | |
| for r in all_records: | |
| k = r.get("신청번호", "") | |
| if k: | |
| seen[k] = r | |
| else: | |
| seen[id(r)] = r | |
| merged = sorted(seen.values(), key=lambda r: r.get("신청번호", "999")) | |
| dup = len(all_records) - len(merged) | |
| label = "예제 통합 결과" if demo_type != "formats" else "예제 7개 포맷 통합 결과" | |
| return JSONResponse({ | |
| "ok": True, "total": len(merged), "duplicates_removed": dup, | |
| "sources": sources, | |
| "html": _records_to_html(merged, f"{label} \u2014 {len(merged)}\uAC74 (\uC911\uBCF5 {dup}\uAC74 \uC81C\uAC70)"), | |
| "records": merged, | |
| }) | |
| async def demo_preview_get(filename: str): | |
| """GET 방식 파일 미리보기 — JS에서 단순 fetch로 호출""" | |
| fp = os.path.join(_DEMO_DIR, filename) | |
| if not os.path.isfile(fp): | |
| return JSONResponse({"ok": False, "html": f"<p>파일 없음: {filename}</p>", "rows": 0}) | |
| ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" | |
| try: | |
| with open(fp, "rb") as f: | |
| data = f.read() | |
| if ext in ("xlsx", "xls"): | |
| recs = _parse_xlsx_ax(data, filename) | |
| elif ext == "csv": | |
| recs = _parse_csv_ax(data, filename) | |
| elif ext == "docx": | |
| recs = _parse_docx_ax(data, filename) | |
| elif ext == "pptx": | |
| recs = _parse_pptx_ax(data, filename) | |
| elif ext == "pdf": | |
| recs = _parse_pdf_ax(data, filename) | |
| elif ext in ("hwpx", "hwp"): | |
| recs = _parse_hwpx_ax(data, filename) | |
| else: | |
| recs = [] | |
| html = _records_to_html(recs, f"{filename} — {len(recs)}건") | |
| return JSONResponse({"ok": True, "filename": filename, "rows": len(recs), "html": html, "ext": ext}) | |
| except Exception as e: | |
| return JSONResponse({"ok": False, "html": f"<p style='color:red'>오류: {e}</p>", "rows": 0}) | |
| async def demo_file(filename: str): | |
| fp = os.path.join(_DEMO_DIR, filename) | |
| if not os.path.isfile(fp): | |
| raise HTTPException(404, "demo file not found") | |
| mt = _mimetypes.guess_type(fp)[0] or "application/octet-stream" | |
| return FileResponse(fp, media_type=mt, filename=filename) | |