"""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 / / 분석 과정 / 자기 점검 모두 출력 금지. **첫 줄부터 곧바로 따뜻한 본문**으로 시작. - 한국어 마크다운, 간결하게. 표·리스트 활용. - **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 미적용 (경로 분리). @app.middleware("http") 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") @app.get("/") async def root(): # index.html 은 항상 최신 (캐시된 옛 script.js/style.css 참조 방지) — 에셋은 ?v= 로 버전 관리 return FileResponse("static/index.html", headers={"Cache-Control": "no-store, max-age=0"}) @app.get("/api/health") 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"]} @app.on_event("startup") def _load_stats_on_startup(): load_stats() STATS_KEY = os.getenv("STATS_KEY", "") # 관리자 전용 키 (미설정 시 전면 차단 — 시민 UI 비공개) @app.get("/api/stats") async def stats_json(key: str = ""): if not STATS_KEY or key != STATS_KEY: return JSONResponse({"error": "forbidden"}, status_code=403) return STATS @app.get("/stats", response_class=HTMLResponse) async def stats_page(key: str = ""): if not STATS_KEY or key != STATS_KEY: return HTMLResponse("

403 · 접근 권한이 없습니다.

", status_code=403) d = STATS days = sorted(d.get("by_day", {}).items(), reverse=True)[:14] rows = "".join( f"{day}{v.get('total_visits',0)}{v.get('ai_chat',0)}" f"{v.get('image_gen',0)}{v.get('file_upload',0)}{v.get('office_open',0)}" for day, v in days) return ( "" "JGOS 방문 통계" "" "

🏛️ JGOS 방문 통계

" f"
{d.get('total_visits',0)}
총 방문
" f"
{d.get('ai_chat',0)}
AI 상담
" f"
{d.get('image_gen',0)}
이미지 생성
" f"
{d.get('file_upload',0)}
파일 분석
" f"
{d.get('office_open',0)}
오피스 열람
" "

최근 14일 (KST)

" "" + rows + "
날짜방문AI상담이미지파일오피스

VIDraft/JGOS-stats · 15요청마다 자동 저장 · KST 기준

" "" ) @app.get("/api/greeting") async def greeting(): return {"greeting": GREETING} @app.post("/api/image") 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"} @app.post("/api/upload") 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 '' in low: e = low.find('') if e < 0: return None # 닫힐 때까지 대기 start = e + len('
') 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() """본문 탐지 실패 시(짧은 답/영어 답): + 영어 reasoning 불릿 줄 제거.""" raw = _re_ws.sub(r'(?is).*?', '', 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) @app.get("/api/contacts/route") 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 = """ JGOS API 사용 매뉴얼 — 전남광주 통합특별시 행정 AI

🏛️ JGOS API 매뉴얼

전남광주 통합특별시 행정 AI를 OpenAI 호환 API로 사용하세요

Base URL · https://jgos.vidraft.net/v1

1. 개요

JGOS API는 전남광주 통합특별시 행정 AI를 외부 서비스·앱에서 호출할 수 있는 공개 API입니다. OpenAI chat/completions 규격과 100% 호환되어, 기존 OpenAI SDK에서 Base URL과 API 키만 바꾸면 즉시 사용할 수 있습니다.

항목내용
Base URLhttps://jgos.vidraft.net/v1
모델 IDjgos-31b
핵심 기능통합특별시 행정·민원 상담, 담당부서 연락처 자동 안내, 실시간 웹검색, 27개 시군구 지역정보
인증API 키 (Bearer 토큰)
응답일반(JSON) / 스트리밍(SSE) 모두 지원

2. 인증

모든 /v1 요청에는 발급받은 API 키를 Authorization 헤더에 담아야 합니다.

Authorization: Bearer jgos-live-여기에_발급받은_키

또는 X-API-Key: jgos-live-... 헤더도 지원합니다.

🔑 API 키 발급은 운영자(VIDRAFT)에게 문의하세요. 키는 비밀이며, 프런트엔드(브라우저)에 노출하지 말고 반드시 서버 측에서만 사용하세요.

3. 빠른 시작 (curl)

POST /v1/chat/completions

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": "전남광주 통합특별시 인구가 얼마나 되나요?"}

    ]

  }'

응답(JSON)의 choices[0].message.content에 답변이 담깁니다.

4. Python

① OpenAI 공식 SDK (권장)

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)

② requests (의존성 없이)

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"])

5. JavaScript (Node / 브라우저-서버)

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);

6. 요청 파라미터

필드타입기본설명
modelstringjgos-31b모델 ID (현재 jgos-31b)
messagesarray필수OpenAI 형식 대화 배열 (role: system/user/assistant)
streamboolfalsetrue 시 SSE 스트리밍
temperaturefloat0.3창의성 (0~1)
max_tokensint1200최대 응답 토큰 (상한 2048)
webboolfalseJGOS 확장 · 실시간 웹검색 grounding
minwonboolfalseJGOS 확장 · 담당부서·연락처 자동 안내(민원 집중)

OpenAI SDK에서 web·minwon 같은 확장 파라미터는 extra_body={...}로 전달합니다.

7. 민원 특화 — 담당자 연락처 자동 안내

minwon: true를 켜면, 질문을 분석해 광주 5구·전남 22시군 + 양 본청(총 29개 기관, 3.8만 행 조직도)에서 담당 부서와 직통 전화번호를 찾아 안내합니다. (전화번호는 실제 수집 데이터만 사용, 지어내지 않음)

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 등 실제 담당부서·연락처 안내

8. 스트리밍 (SSE)

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)

표준 OpenAI chat.completion.chunk 형식으로 전송되며 data: [DONE]으로 종료됩니다.

9. 오류 코드

코드의미해결
400잘못된 요청messages 필드·JSON 형식 확인
401API 키 없음/오류Authorization 헤더의 키 확인
502LLM 백엔드 오류잠시 후 재시도
503키 미설정운영자 문의

🏛️ JGOS — 전남광주 통합특별시 행정 AI · powered by VIDRAFT
API 키 발급·제휴 문의: 통합특별시청 062-120 / VIDRAFT

""" def _require_api_key(request: Request) -> str: """Authorization: Bearer 또는 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 @app.post("/api/chat") 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", }) @app.get("/v1/models") 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)"}, ]} @app.post("/v1/chat/completions") 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) @app.get("/api/guide", response_class=HTMLResponse) 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 "

데이터 없음

" 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"{rec.get(k, '')}" for k in keys) rows_html += f"{cells}" header_html = "".join(f"{k}" for k in keys) t = "
" if title: t += f"
{title}
" t += (f"
" f"" f"{header_html}" f"{rows_html}" f"
") 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) @app.post("/api/excel/merge") 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, } @app.post("/api/files/merge-formats") 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, } @app.post("/api/files/preview") 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") @app.get("/api/demo/list") 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 ===== @app.post("/api/export/xlsx") 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) @app.get("/api/demo/merge/{demo_type}") 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, }) @app.get("/api/demo/preview/{filename}") 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"

파일 없음: {filename}

", "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"

오류: {e}

", "rows": 0}) @app.get("/api/demo/{filename}") 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)