From ab0f7c7e9f3738e389dfe7a7820fe72fe8bbff92 Mon Sep 17 00:00:00 2001 From: Jacob Date: Wed, 2 Sep 2026 23:22:01 +0900 Subject: [PATCH 1/2] docs: openwiki auto-update --- openwiki/.last-update.json | 4 ++-- openwiki/api.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openwiki/.last-update.json b/openwiki/.last-update.json index 02f583d..3b1f8a3 100644 --- a/openwiki/.last-update.json +++ b/openwiki/.last-update.json @@ -1,6 +1,6 @@ { - "updatedAt": "2026-08-30T14:24:28.855Z", + "updatedAt": "2026-09-02T14:22:01.117Z", "command": "update", - "gitHead": "3916b2f5a44de605bf2e604c7cd04cd7c459a2fd", + "gitHead": "8f27862e3e7e1d239b8010b65fea71e06f913907", "model": "openai/gpt-oss-120b" } diff --git a/openwiki/api.md b/openwiki/api.md index 9818543..e4f29c3 100644 --- a/openwiki/api.md +++ b/openwiki/api.md @@ -6,6 +6,7 @@ This repository exposes a **FastAPI** server under the `/api/v1` prefix. The API * **Path Prefix** – All routes start with `/api/v1`. * **Response Models** – Pydantic models are used for request validation and response schemas (see the source files). +* **Rate Limiting** – Global IP‑based request throttling is enforced by `backend/app/middleware/rate_limit.py`. See the middleware source for configuration (`RATE_LIMIT`, `RATE_LIMIT_WINDOW`). * **Authentication** – Auth routes are available (`/api/v1/auth/login`). The UI can obtain a JWT token via login; if `AUTH_ENABLED` is false, the auth layer is bypassed and `user_uuid` may be passed directly. * **Streaming** – The chat endpoint supports Server‑Sent Events (SSE) for token‑by‑token streaming. From 11c37f54cf344b156cd1fa24e37632cfc41d5dfb Mon Sep 17 00:00:00 2001 From: Jacob Date: Thu, 3 Sep 2026 20:58:57 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(agent):=20NIM=20LLM=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=EC=9D=84=20gemma-4-31b-it=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=ED=95=98=EA=B3=A0=20=EC=84=B8=EC=9C=A8=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EC=B6=9C=20API=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NVIDIA NIM openai/gpt-oss-120b EOL(410) 대응: google/gemma-4-31b-it로 업그레이드 - /api/v1/tax-rates/extract 텍스트 추출 엔드포인트 복원 및 프론트엔드 1클릭 추출 지원 - intent 라우터 json_mode 지정 및 피싱 키워드 폴백 보강 - OpenWiki 및 관련 문서 동기화 --- README.md | 2 +- backend/app/api/tax_rates.py | 17 ++++++ backend/app/services/agent/llm.py | 4 +- backend/app/services/agent/nodes/intent.py | 18 +++++- docs/competition/proposal-draft.md | 2 +- frontend/src/app/tax-rates/page.tsx | 67 +++++++++++++++------- frontend/src/lib/api.ts | 8 +++ openwiki/api.md | 43 ++++++-------- openwiki/architecture.md | 2 +- openwiki/frontend.md | 26 +++------ tests/test_llm_retry.py | 4 +- 11 files changed, 117 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 06d7ca5..d7f7400 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ uv run pytest * **Frontend**: Next.js 16 (React 19), TypeScript, Tailwind CSS v4 * **Backend**: Python 3.12, FastAPI, Uvicorn, LangGraph, LangChain, Alembic * **Database**: PostgreSQL 17 (pgvector), Neo4j 5 Graph DB -* **AI & Data**: NVIDIA NIM (`openai/gpt-oss-120b`, `BAAI/bge-m3`), Tavily, yfinance, 청약홈 공공데이터 API +* **AI & Data**: NVIDIA NIM (`google/gemma-4-31b-it`, `BAAI/bge-m3`), Tavily, yfinance, 청약홈 공공데이터 API * **Infra**: 오라클 VM(systemd + Caddy/Let's Encrypt) · Vercel · GitHub Actions * **Tooling**: uv, ruff, pytest, ESLint diff --git a/backend/app/api/tax_rates.py b/backend/app/api/tax_rates.py index 81c5566..3a47dcc 100644 --- a/backend/app/api/tax_rates.py +++ b/backend/app/api/tax_rates.py @@ -11,6 +11,7 @@ from pathlib import Path from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from pydantic import BaseModel from backend.app.api.uploads import read_upload_capped from backend.app.services.tax.rate_diff import diff_against_current @@ -74,6 +75,22 @@ def _pdf_to_text(raw: bytes) -> str: ) from exc +class ExtractTextRequest(BaseModel): + text: str + year: str = "2026" + use_llm: bool = True + + +@router.post("/extract") +def extract_text(req: ExtractTextRequest) -> dict: + """개정안 원문 텍스트에서 세율을 추출해 현행과 비교·검증한다.""" + text = req.text.strip() + if not text: + raise HTTPException(status_code=400, detail="텍스트가 비어 있습니다.") + proposed = extract_rate_set(text[:_MAX_TEXT_CHARS], year=req.year, use_llm=req.use_llm) + return _diff_payload(proposed) + + @router.post("/extract/upload") async def extract_upload( file: UploadFile = File(...), diff --git a/backend/app/services/agent/llm.py b/backend/app/services/agent/llm.py index 72644bb..84e89c7 100644 --- a/backend/app/services/agent/llm.py +++ b/backend/app/services/agent/llm.py @@ -182,7 +182,5 @@ def build_chat_model(temperature: float = 0.7, max_tokens: int = 4000) -> NIMCha max_tokens=max_tokens, timeout=REQUEST_TIMEOUT, max_retries=0, # 같은 키로 재시도해봐야 429는 그대로 — 위 루프가 키를 바꿔가며 재시도한다 - # 구 모델(qwen3-next-80b)은 스트리밍이 delta 4개로만 쪼개져 오면서 비스트리밍보다 - # 2.5배 느려 disable_streaming=True로 껐었다. gpt-oss-120b로 교체 후 실측하니 - # 정상 토큰 단위로 흐르므로(93청크/700토큰) 다시 켠다. + # 정상 토큰 단위로 스트리밍되는 gemma-4-31b-it 모델 사용 ) diff --git a/backend/app/services/agent/nodes/intent.py b/backend/app/services/agent/nodes/intent.py index bc9fb39..307ad32 100644 --- a/backend/app/services/agent/nodes/intent.py +++ b/backend/app/services/agent/nodes/intent.py @@ -36,7 +36,10 @@ tax_and_market_lookup을 골랐고 질문이 특정 자산 종류(예: 주식·채권·예금·부동산)에 한정되면, asset_types에 해당 자산 종류명을 적으십시오(세법 조회를 그 자산으로 좁힘). 자산을 특정하지 않은 -일반 질문이면 asset_types는 비워 두십시오(전체 세법 조회).""" +일반 질문이면 asset_types는 비워 두십시오(전체 세법 조회). + +반드시 다음 JSON 형식(객체)으로만 응답하십시오: +{"tools": ["도구명1", "도구명2"], "asset_types": [], "ticker": ""}""" # 데이터 검색이 필요함을 시사하는 토큰(짧은 메시지라도 이게 있으면 잡담이 아니다). @@ -101,7 +104,14 @@ def _keyword_route(text: str) -> list[str]: if any(k in text for k in ("계산", "얼마야", "얼마나 내", "비과세")): route.append("tax_calculator") # 사기 메시지 검증 - if any(k in text for k in ("사기", "피싱", "스미싱", "믿어도 되", "괜찮을까", "의심")): + if any( + k in text + for k in ( + "사기", "피싱", "스미싱", "믿어도 되", "괜찮을까", "의심", + "사고났어", "이체해줘", "선입금", "수수료 먼저", "입금하면", + "보증금", "환급금", "계좌정보를 입력", "액정 깨져", + ) + ): route.append("fraud_check") # 라이브 웹 리서치(현재 금리/상품·금리 동향·국세청 해석) if any(k in text for k in ("예금", "적금", "연금저축", "국채", "상품", "가입", "이율", "우대")): @@ -181,7 +191,9 @@ class _Route(BaseModel): # 라우터 출력은 도구 이름 몇 개짜리 JSON이라 300토큰이면 충분하다. 기본값(4000)을 # 두면 모델이 structured output에서 폭주할 때 4000토큰을 다 태우고서야 끝난다 # (실측 45초). 상한을 낮추면 폭주해도 금방 끝나고 아래 _keyword_route로 폴백된다. - router = build_chat_model(temperature=0.0, max_tokens=300).with_structured_output(_Route) + router = build_chat_model(temperature=0.0, max_tokens=300).with_structured_output( + _Route, method="json_mode" + ) prompt_input = f"[대화 맥락]\n{dialogue_context}\n\n[현재 사용자 발화]\n{user_text}" result = router.invoke( [SystemMessage(content=_INTENT_PROMPT), HumanMessage(content=prompt_input)] diff --git a/docs/competition/proposal-draft.md b/docs/competition/proposal-draft.md index c6b6f9e..9de7339 100644 --- a/docs/competition/proposal-draft.md +++ b/docs/competition/proposal-draft.md @@ -160,7 +160,7 @@ - **Frontend**: Next.js 16(App Router), TypeScript, Tailwind, GSAP/D3/WebGL 시각화 - **Backend**: FastAPI, LangGraph(StateGraph + PostgresSaver 체크포인터) -- **AI**: NVIDIA NIM(gpt-oss-120b / bge-m3 임베딩), Tavily·Naver 웹검색 도구 +- **AI**: NVIDIA NIM(google/gemma-4-31b-it / bge-m3 임베딩), Tavily·Naver 웹검색 도구 - **Data**: PostgreSQL(pgvector) · Neo4j · yfinance · 청약홈 공공데이터 - **Infra**: Docker Compose, Alembic 마이그레이션, GitHub Actions CI - 전체 다이어그램: `architecture.svg` 참조 diff --git a/frontend/src/app/tax-rates/page.tsx b/frontend/src/app/tax-rates/page.tsx index 112d9c6..0bc11b7 100644 --- a/frontend/src/app/tax-rates/page.tsx +++ b/frontend/src/app/tax-rates/page.tsx @@ -1,14 +1,20 @@ "use client"; import { useRef, useState } from "react"; -import { ArrowRight, CheckCircle, Warning, FilePdf, ShieldCheck } from "@phosphor-icons/react"; +import { ArrowRight, CheckCircle, Warning, FilePdf, ShieldCheck, Sparkle } from "@phosphor-icons/react"; import { + extractRates, extractRatesUpload, type RateDiffRow, type RateExtractResult, } from "@/lib/api"; import { Card, PageTitle, SectionLabel, fmtKRW } from "@/components/ui"; +const SAMPLE_AMENDMENT = `2026년 귀속 세법개정안 요약 +- 해외주식 양도소득세율을 20%에서 22%로 상향한다. +- 이자·배당 분리과세율은 15.4%로 유지한다. +- 금융소득종합과세 기준을 2,000만원에서 3,000만원으로 상향한다.`; + /** 세율값을 종류에 맞게 렌더 — rate는 퍼센트, amount는 원화. */ function fmtRateValue(kind: "rate" | "amount", value: number): string { return kind === "rate" ? `${(value * 100).toFixed(2).replace(/\.?0+$/, "")}%` : fmtKRW(value); @@ -33,12 +39,27 @@ function DiffRow({ row }: { row: RateDiffRow }) { } export default function TaxRatesPage() { + const [text, setText] = useState(SAMPLE_AMENDMENT); const [year, setYear] = useState("2026"); const [result, setResult] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const fileRef = useRef(null); + async function onExtract() { + if (!text.trim()) return; + setBusy(true); + setError(null); + setResult(null); + try { + setResult(await extractRates(text, year, false)); + } catch (e) { + setError(e instanceof Error ? e.message : "세율 추출에 실패했습니다."); + } finally { + setBusy(false); + } + } + async function onUpload(file: File) { setBusy(true); setError(null); @@ -58,12 +79,19 @@ export default function TaxRatesPage() { - {/* ── 입력 (파일 업로드) ── */} + {/* ── 입력 (텍스트 붙여넣기 + 파일 업로드) ── */} - 개정안 파일 업로드 + 개정안 텍스트 입력 +