feat: AI 서비스 및 AI 어시스턴트 전용 페이지 추가
- ai-service: Ollama 기반 AI 서비스 (분류, 시맨틱 검색, RAG Q&A, 패턴 분석) - AI 어시스턴트 페이지: 채팅형 Q&A, 시맨틱 검색, 패턴 분석, 분류 테스트 - 권한 시스템에 ai_assistant 페이지 등록 (기본 비활성) - 기존 페이지에 AI 기능 통합 (대시보드, 수신함, 관리함) - docker-compose, gateway, nginx 설정 업데이트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,14 @@ PMA_USER=root
|
||||
PMA_PASSWORD=change_this_root_password_min_12_chars
|
||||
UPLOAD_LIMIT=50M
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# AI Service
|
||||
# -------------------------------------------------------------------
|
||||
OLLAMA_BASE_URL=http://your-ollama-server:11434
|
||||
OLLAMA_TEXT_MODEL=qwen2.5:14b-instruct-q4_K_M
|
||||
OLLAMA_EMBED_MODEL=bge-m3
|
||||
OLLAMA_TIMEOUT=120
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Cloudflare Tunnel
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
9
ai-service/Dockerfile
Normal file
9
ai-service/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y gcc build-essential && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
RUN mkdir -p /app/data/chroma
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
24
ai-service/config.py
Normal file
24
ai-service/config.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
|
||||
|
||||
class Settings:
|
||||
OLLAMA_BASE_URL: str = os.getenv("OLLAMA_BASE_URL", "http://100.111.160.84:11434")
|
||||
OLLAMA_TEXT_MODEL: str = os.getenv("OLLAMA_TEXT_MODEL", "qwen2.5:14b-instruct-q4_K_M")
|
||||
OLLAMA_EMBED_MODEL: str = os.getenv("OLLAMA_EMBED_MODEL", "bge-m3")
|
||||
OLLAMA_TIMEOUT: int = int(os.getenv("OLLAMA_TIMEOUT", "120"))
|
||||
|
||||
DB_HOST: str = os.getenv("DB_HOST", "mariadb")
|
||||
DB_PORT: int = int(os.getenv("DB_PORT", "3306"))
|
||||
DB_USER: str = os.getenv("DB_USER", "hyungi_user")
|
||||
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "")
|
||||
DB_NAME: str = os.getenv("DB_NAME", "hyungi")
|
||||
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "")
|
||||
ALGORITHM: str = "HS256"
|
||||
|
||||
SYSTEM1_API_URL: str = os.getenv("SYSTEM1_API_URL", "http://system1-api:3005")
|
||||
CHROMA_PERSIST_DIR: str = os.getenv("CHROMA_PERSIST_DIR", "/app/data/chroma")
|
||||
METADATA_DB_PATH: str = os.getenv("METADATA_DB_PATH", "/app/data/metadata.db")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
0
ai-service/db/__init__.py
Normal file
0
ai-service/db/__init__.py
Normal file
39
ai-service/db/metadata_store.py
Normal file
39
ai-service/db/metadata_store.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import sqlite3
|
||||
from config import settings
|
||||
|
||||
|
||||
class MetadataStore:
|
||||
def __init__(self):
|
||||
self.db_path = settings.METADATA_DB_PATH
|
||||
|
||||
def initialize(self):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS sync_state ("
|
||||
" key TEXT PRIMARY KEY,"
|
||||
" value TEXT"
|
||||
")"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_last_synced_id(self) -> int:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cur = conn.execute(
|
||||
"SELECT value FROM sync_state WHERE key = 'last_synced_id'"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
def set_last_synced_id(self, issue_id: int):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sync_state (key, value) VALUES ('last_synced_id', ?)",
|
||||
(str(issue_id),),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
metadata_store = MetadataStore()
|
||||
76
ai-service/db/vector_store.py
Normal file
76
ai-service/db/vector_store.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import chromadb
|
||||
from config import settings
|
||||
|
||||
|
||||
class VectorStore:
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self.collection = None
|
||||
|
||||
def initialize(self):
|
||||
self.client = chromadb.PersistentClient(path=settings.CHROMA_PERSIST_DIR)
|
||||
self.collection = self.client.get_or_create_collection(
|
||||
name="qc_issues",
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
)
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
doc_id: str,
|
||||
document: str,
|
||||
embedding: list[float],
|
||||
metadata: dict = None,
|
||||
):
|
||||
self.collection.upsert(
|
||||
ids=[doc_id],
|
||||
documents=[document],
|
||||
embeddings=[embedding],
|
||||
metadatas=[metadata] if metadata else None,
|
||||
)
|
||||
|
||||
def query(
|
||||
self,
|
||||
embedding: list[float],
|
||||
n_results: int = 5,
|
||||
where: dict = None,
|
||||
) -> list[dict]:
|
||||
kwargs = {
|
||||
"query_embeddings": [embedding],
|
||||
"n_results": n_results,
|
||||
"include": ["documents", "metadatas", "distances"],
|
||||
}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
try:
|
||||
results = self.collection.query(**kwargs)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
items = []
|
||||
if results and results["ids"] and results["ids"][0]:
|
||||
for i, doc_id in enumerate(results["ids"][0]):
|
||||
item = {
|
||||
"id": doc_id,
|
||||
"document": results["documents"][0][i] if results["documents"] else "",
|
||||
"distance": results["distances"][0][i] if results["distances"] else 0,
|
||||
"metadata": results["metadatas"][0][i] if results["metadatas"] else {},
|
||||
}
|
||||
# cosine distance → similarity
|
||||
item["similarity"] = round(1 - item["distance"], 4)
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
def delete(self, doc_id: str):
|
||||
self.collection.delete(ids=[doc_id])
|
||||
|
||||
def count(self) -> int:
|
||||
return self.collection.count()
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"total_documents": self.count(),
|
||||
"collection_name": "qc_issues",
|
||||
}
|
||||
|
||||
|
||||
vector_store = VectorStore()
|
||||
41
ai-service/main.py
Normal file
41
ai-service/main.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from routers import health, embeddings, classification, daily_report, rag
|
||||
from db.vector_store import vector_store
|
||||
from db.metadata_store import metadata_store
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
vector_store.initialize()
|
||||
metadata_store.initialize()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="TK AI Service",
|
||||
description="AI 서비스 (유사 검색, 분류, 보고서)",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health.router, prefix="/api/ai")
|
||||
app.include_router(embeddings.router, prefix="/api/ai")
|
||||
app.include_router(classification.router, prefix="/api/ai")
|
||||
app.include_router(daily_report.router, prefix="/api/ai")
|
||||
app.include_router(rag.router, prefix="/api/ai")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "TK AI Service", "version": "1.0.0"}
|
||||
18
ai-service/prompts/classify_issue.txt
Normal file
18
ai-service/prompts/classify_issue.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
당신은 공장 품질관리(QC) 전문가입니다. 아래 부적합 신고 내용을 분석하여 판별하세요.
|
||||
|
||||
부적합 내용:
|
||||
{description}
|
||||
|
||||
상세 내용:
|
||||
{detail_notes}
|
||||
|
||||
다음 JSON 형식으로만 응답하세요:
|
||||
{{
|
||||
"category": "material_missing|design_error|incoming_defect|inspection_miss|기타",
|
||||
"category_confidence": 0.0~1.0,
|
||||
"responsible_department": "production|quality|purchasing|design|sales",
|
||||
"department_confidence": 0.0~1.0,
|
||||
"severity": "low|medium|high|critical",
|
||||
"summary": "한줄 요약 (30자 이내)",
|
||||
"reasoning": "판단 근거 (2-3문장)"
|
||||
}}
|
||||
22
ai-service/prompts/daily_report.txt
Normal file
22
ai-service/prompts/daily_report.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
당신은 공장 관리 보고서 작성자입니다. 아래 데이터를 바탕으로 일일 브리핑을 작성하세요.
|
||||
|
||||
날짜: {date}
|
||||
|
||||
[근태 현황]
|
||||
{attendance_data}
|
||||
|
||||
[작업 현황]
|
||||
{work_report_data}
|
||||
|
||||
[부적합 현황]
|
||||
{qc_issue_data}
|
||||
|
||||
[순회점검 현황]
|
||||
{patrol_data}
|
||||
|
||||
다음 형식으로 작성하세요:
|
||||
|
||||
1. 오늘의 요약 (2-3문장)
|
||||
2. 주요 이슈 및 관심사항
|
||||
3. 부적합 현황 (신규/진행/지연)
|
||||
4. 내일 주의사항
|
||||
23
ai-service/prompts/rag_classify.txt
Normal file
23
ai-service/prompts/rag_classify.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
당신은 공장 품질관리(QC) 전문가입니다. 아래 부적합 신고를 분류하세요.
|
||||
|
||||
[신고 내용]
|
||||
{description}
|
||||
|
||||
[상세 내용]
|
||||
{detail_notes}
|
||||
|
||||
[참고: 과거 유사 사례]
|
||||
{retrieved_cases}
|
||||
|
||||
위 과거 사례의 분류 패턴을 참고하여, 현재 부적합을 판별하세요.
|
||||
|
||||
다음 JSON 형식으로만 응답하세요:
|
||||
{{
|
||||
"category": "material_missing|design_error|incoming_defect|inspection_miss|기타",
|
||||
"category_confidence": 0.0~1.0,
|
||||
"responsible_department": "production|quality|purchasing|design|sales",
|
||||
"department_confidence": 0.0~1.0,
|
||||
"severity": "low|medium|high|critical",
|
||||
"summary": "한줄 요약 (30자 이내)",
|
||||
"reasoning": "판단 근거 — 과거 사례 참고 내용 포함 (2-3문장)"
|
||||
}}
|
||||
16
ai-service/prompts/rag_pattern.txt
Normal file
16
ai-service/prompts/rag_pattern.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
당신은 공장 품질관리(QC) 데이터 분석가입니다. 아래 부적합에 대해 패턴을 분석하세요.
|
||||
|
||||
[분석 대상]
|
||||
{description}
|
||||
|
||||
[유사 부적합 {total_similar}건]
|
||||
{retrieved_cases}
|
||||
|
||||
다음을 분석하세요:
|
||||
|
||||
1. **반복 여부**: 이 문제가 과거에도 발생했는지, 반복 빈도는 어느 정도인지
|
||||
2. **공통 패턴**: 유사 사례들의 공통 원인, 공통 부서, 공통 시기 등
|
||||
3. **근본 원인 추정**: 반복되는 원인이 있다면 근본 원인은 무엇인지
|
||||
4. **개선 제안**: 재발 방지를 위한 구조적 개선 방안
|
||||
|
||||
데이터 기반으로 객관적으로 분석하세요.
|
||||
14
ai-service/prompts/rag_qa.txt
Normal file
14
ai-service/prompts/rag_qa.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
당신은 공장 품질관리(QC) 데이터 분석가입니다. 아래 질문에 대해 과거 부적합 데이터를 기반으로 답변하세요.
|
||||
|
||||
[질문]
|
||||
{question}
|
||||
|
||||
[관련 부적합 데이터]
|
||||
{retrieved_cases}
|
||||
|
||||
위 데이터를 근거로 질문에 답변하세요.
|
||||
- 제공된 데이터를 적극적으로 활용하여 답변하세요
|
||||
- 관련 사례를 구체적으로 인용하며 분석하세요
|
||||
- 패턴이나 공통점이 있다면 정리하세요
|
||||
- 숫자나 통계가 있다면 포함하세요
|
||||
- 간결하되 유용한 답변을 하세요
|
||||
18
ai-service/prompts/rag_suggest_solution.txt
Normal file
18
ai-service/prompts/rag_suggest_solution.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
당신은 공장 품질관리(QC) 전문가입니다. 아래 부적합 이슈에 대한 해결방안을 제안하세요.
|
||||
|
||||
[현재 부적합]
|
||||
분류: {category}
|
||||
내용: {description}
|
||||
상세: {detail_notes}
|
||||
|
||||
[과거 유사 사례]
|
||||
{retrieved_cases}
|
||||
|
||||
위 과거 사례들을 참고하여 다음을 제안하세요:
|
||||
|
||||
1. **권장 해결방안**: 과거 유사 사례에서 효과적이었던 해결 방법을 기반으로 구체적인 조치를 제안
|
||||
2. **예상 원인**: 유사 사례에서 확인된 원인 패턴을 바탕으로 가능한 원인 분석
|
||||
3. **담당 부서**: 어느 부서에서 처리해야 하는지
|
||||
4. **주의사항**: 과거 사례에서 배운 교훈이나 주의할 점
|
||||
|
||||
간결하고 실용적으로 작성하세요. 과거 사례가 없는 부분은 일반적인 QC 지식으로 보완하세요.
|
||||
17
ai-service/prompts/summarize_issue.txt
Normal file
17
ai-service/prompts/summarize_issue.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
당신은 공장 품질관리(QC) 전문가입니다. 아래 부적합 이슈를 간결하게 요약하세요.
|
||||
|
||||
부적합 내용:
|
||||
{description}
|
||||
|
||||
상세 내용:
|
||||
{detail_notes}
|
||||
|
||||
해결 방법:
|
||||
{solution}
|
||||
|
||||
다음 JSON 형식으로만 응답하세요:
|
||||
{{
|
||||
"summary": "핵심 요약 (50자 이내)",
|
||||
"key_points": ["요점1", "요점2", "요점3"],
|
||||
"suggested_action": "권장 조치사항 (선택)"
|
||||
}}
|
||||
10
ai-service/requirements.txt
Normal file
10
ai-service/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
httpx==0.27.0
|
||||
chromadb==0.4.22
|
||||
numpy==1.26.2
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
pymysql==1.1.0
|
||||
sqlalchemy==2.0.23
|
||||
0
ai-service/routers/__init__.py
Normal file
0
ai-service/routers/__init__.py
Normal file
47
ai-service/routers/classification.py
Normal file
47
ai-service/routers/classification.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from services.classification_service import (
|
||||
classify_issue,
|
||||
summarize_issue,
|
||||
classify_and_summarize,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["classification"])
|
||||
|
||||
|
||||
class ClassifyRequest(BaseModel):
|
||||
description: str
|
||||
detail_notes: str = ""
|
||||
|
||||
|
||||
class SummarizeRequest(BaseModel):
|
||||
description: str
|
||||
detail_notes: str = ""
|
||||
solution: str = ""
|
||||
|
||||
|
||||
@router.post("/classify")
|
||||
async def classify(req: ClassifyRequest):
|
||||
try:
|
||||
result = await classify_issue(req.description, req.detail_notes)
|
||||
return {"available": True, **result}
|
||||
except Exception as e:
|
||||
return {"available": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/summarize")
|
||||
async def summarize(req: SummarizeRequest):
|
||||
try:
|
||||
result = await summarize_issue(req.description, req.detail_notes, req.solution)
|
||||
return {"available": True, **result}
|
||||
except Exception as e:
|
||||
return {"available": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/classify-and-summarize")
|
||||
async def classify_and_summarize_endpoint(req: ClassifyRequest):
|
||||
try:
|
||||
result = await classify_and_summarize(req.description, req.detail_notes)
|
||||
return {"available": True, **result}
|
||||
except Exception as e:
|
||||
return {"available": False, "error": str(e)}
|
||||
33
ai-service/routers/daily_report.py
Normal file
33
ai-service/routers/daily_report.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
from services.report_service import generate_daily_report
|
||||
from datetime import date
|
||||
|
||||
router = APIRouter(tags=["daily_report"])
|
||||
|
||||
|
||||
class DailyReportRequest(BaseModel):
|
||||
date: str | None = None
|
||||
project_id: int | None = None
|
||||
|
||||
|
||||
@router.post("/report/daily")
|
||||
async def daily_report(req: DailyReportRequest, request: Request):
|
||||
report_date = req.date or date.today().isoformat()
|
||||
token = request.headers.get("authorization", "").replace("Bearer ", "")
|
||||
try:
|
||||
result = await generate_daily_report(report_date, req.project_id, token)
|
||||
return {"available": True, **result}
|
||||
except Exception as e:
|
||||
return {"available": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/report/preview")
|
||||
async def report_preview(req: DailyReportRequest, request: Request):
|
||||
report_date = req.date or date.today().isoformat()
|
||||
token = request.headers.get("authorization", "").replace("Bearer ", "")
|
||||
try:
|
||||
result = await generate_daily_report(report_date, req.project_id, token)
|
||||
return {"available": True, "preview": True, **result}
|
||||
except Exception as e:
|
||||
return {"available": False, "error": str(e)}
|
||||
77
ai-service/routers/embeddings.py
Normal file
77
ai-service/routers/embeddings.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Query
|
||||
from pydantic import BaseModel
|
||||
from services.embedding_service import (
|
||||
sync_all_issues,
|
||||
sync_single_issue,
|
||||
sync_incremental,
|
||||
search_similar_by_id,
|
||||
search_similar_by_text,
|
||||
)
|
||||
from db.vector_store import vector_store
|
||||
|
||||
router = APIRouter(tags=["embeddings"])
|
||||
|
||||
|
||||
class SyncSingleRequest(BaseModel):
|
||||
issue_id: int
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
query: str
|
||||
n_results: int = 5
|
||||
project_id: int | None = None
|
||||
category: str | None = None
|
||||
|
||||
|
||||
@router.post("/embeddings/sync")
|
||||
async def sync_embeddings(background_tasks: BackgroundTasks):
|
||||
background_tasks.add_task(sync_all_issues)
|
||||
return {"status": "sync_started", "message": "전체 임베딩 동기화가 시작되었습니다"}
|
||||
|
||||
|
||||
@router.post("/embeddings/sync-full")
|
||||
async def sync_embeddings_full():
|
||||
result = await sync_all_issues()
|
||||
return {"status": "completed", **result}
|
||||
|
||||
|
||||
@router.post("/embeddings/sync-single")
|
||||
async def sync_single(req: SyncSingleRequest):
|
||||
result = await sync_single_issue(req.issue_id)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/embeddings/sync-incremental")
|
||||
async def sync_incr():
|
||||
result = await sync_incremental()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/similar/{issue_id}")
|
||||
async def get_similar(issue_id: int, n_results: int = Query(default=5, le=20)):
|
||||
try:
|
||||
results = await search_similar_by_id(issue_id, n_results)
|
||||
return {"available": True, "results": results, "query_issue_id": issue_id}
|
||||
except Exception as e:
|
||||
return {"available": False, "results": [], "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/similar/search")
|
||||
async def search_similar(req: SearchRequest):
|
||||
filters = {}
|
||||
if req.project_id is not None:
|
||||
filters["project_id"] = str(req.project_id)
|
||||
if req.category:
|
||||
filters["category"] = req.category
|
||||
try:
|
||||
results = await search_similar_by_text(
|
||||
req.query, req.n_results, filters or None
|
||||
)
|
||||
return {"available": True, "results": results}
|
||||
except Exception as e:
|
||||
return {"available": False, "results": [], "error": str(e)}
|
||||
|
||||
|
||||
@router.get("/embeddings/stats")
|
||||
async def embedding_stats():
|
||||
return vector_store.stats()
|
||||
21
ai-service/routers/health.py
Normal file
21
ai-service/routers/health.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter
|
||||
from services.ollama_client import ollama_client
|
||||
from db.vector_store import vector_store
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
ollama_status = await ollama_client.check_health()
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "tk-ai-service",
|
||||
"ollama": ollama_status,
|
||||
"embeddings": vector_store.stats(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def list_models():
|
||||
return await ollama_client.check_health()
|
||||
57
ai-service/routers/rag.py
Normal file
57
ai-service/routers/rag.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from services.rag_service import (
|
||||
rag_suggest_solution,
|
||||
rag_ask,
|
||||
rag_analyze_pattern,
|
||||
rag_classify_with_context,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["rag"])
|
||||
|
||||
|
||||
class AskRequest(BaseModel):
|
||||
question: str
|
||||
project_id: int | None = None
|
||||
|
||||
|
||||
class PatternRequest(BaseModel):
|
||||
description: str
|
||||
n_results: int = 10
|
||||
|
||||
|
||||
class ClassifyRequest(BaseModel):
|
||||
description: str
|
||||
detail_notes: str = ""
|
||||
|
||||
|
||||
@router.post("/rag/suggest-solution/{issue_id}")
|
||||
async def suggest_solution(issue_id: int):
|
||||
try:
|
||||
return await rag_suggest_solution(issue_id)
|
||||
except Exception as e:
|
||||
return {"available": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/rag/ask")
|
||||
async def ask_question(req: AskRequest):
|
||||
try:
|
||||
return await rag_ask(req.question, req.project_id)
|
||||
except Exception as e:
|
||||
return {"available": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/rag/pattern")
|
||||
async def analyze_pattern(req: PatternRequest):
|
||||
try:
|
||||
return await rag_analyze_pattern(req.description, req.n_results)
|
||||
except Exception as e:
|
||||
return {"available": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/rag/classify")
|
||||
async def classify_with_rag(req: ClassifyRequest):
|
||||
try:
|
||||
return await rag_classify_with_context(req.description, req.detail_notes)
|
||||
except Exception as e:
|
||||
return {"available": False, "error": str(e)}
|
||||
0
ai-service/services/__init__.py
Normal file
0
ai-service/services/__init__.py
Normal file
60
ai-service/services/classification_service.py
Normal file
60
ai-service/services/classification_service.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
from services.ollama_client import ollama_client
|
||||
from config import settings
|
||||
|
||||
|
||||
CLASSIFY_PROMPT_PATH = "prompts/classify_issue.txt"
|
||||
SUMMARIZE_PROMPT_PATH = "prompts/summarize_issue.txt"
|
||||
|
||||
|
||||
def _load_prompt(path: str) -> str:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
async def classify_issue(description: str, detail_notes: str = "") -> dict:
|
||||
template = _load_prompt(CLASSIFY_PROMPT_PATH)
|
||||
prompt = template.format(
|
||||
description=description or "",
|
||||
detail_notes=detail_notes or "",
|
||||
)
|
||||
raw = await ollama_client.generate_text(prompt)
|
||||
try:
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(raw[start:end])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"raw_response": raw, "parse_error": True}
|
||||
|
||||
|
||||
async def summarize_issue(
|
||||
description: str, detail_notes: str = "", solution: str = ""
|
||||
) -> dict:
|
||||
template = _load_prompt(SUMMARIZE_PROMPT_PATH)
|
||||
prompt = template.format(
|
||||
description=description or "",
|
||||
detail_notes=detail_notes or "",
|
||||
solution=solution or "",
|
||||
)
|
||||
raw = await ollama_client.generate_text(prompt)
|
||||
try:
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(raw[start:end])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"summary": raw.strip()}
|
||||
|
||||
|
||||
async def classify_and_summarize(
|
||||
description: str, detail_notes: str = ""
|
||||
) -> dict:
|
||||
classification = await classify_issue(description, detail_notes)
|
||||
summary_result = await summarize_issue(description, detail_notes)
|
||||
return {
|
||||
"classification": classification,
|
||||
"summary": summary_result.get("summary", ""),
|
||||
}
|
||||
97
ai-service/services/db_client.py
Normal file
97
ai-service/services/db_client.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from config import settings
|
||||
|
||||
|
||||
def get_engine():
|
||||
password = quote_plus(settings.DB_PASSWORD)
|
||||
url = (
|
||||
f"mysql+pymysql://{settings.DB_USER}:{password}"
|
||||
f"@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}"
|
||||
)
|
||||
return create_engine(url, pool_pre_ping=True, pool_size=5)
|
||||
|
||||
|
||||
engine = get_engine()
|
||||
|
||||
|
||||
def get_all_issues() -> list[dict]:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT id, category, description, detail_notes, "
|
||||
"final_description, final_category, solution, "
|
||||
"management_comment, cause_detail, project_id, "
|
||||
"review_status, report_date, responsible_department, "
|
||||
"location_info "
|
||||
"FROM qc_issues ORDER BY id"
|
||||
)
|
||||
)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
def get_issue_by_id(issue_id: int) -> dict | None:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT id, category, description, detail_notes, "
|
||||
"final_description, final_category, solution, "
|
||||
"management_comment, cause_detail, project_id, "
|
||||
"review_status, report_date, responsible_department, "
|
||||
"location_info "
|
||||
"FROM qc_issues WHERE id = :id"
|
||||
),
|
||||
{"id": issue_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
def get_issues_since(last_id: int) -> list[dict]:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT id, category, description, detail_notes, "
|
||||
"final_description, final_category, solution, "
|
||||
"management_comment, cause_detail, project_id, "
|
||||
"review_status, report_date, responsible_department, "
|
||||
"location_info "
|
||||
"FROM qc_issues WHERE id > :last_id ORDER BY id"
|
||||
),
|
||||
{"last_id": last_id},
|
||||
)
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
def get_daily_qc_stats(date_str: str) -> dict:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT "
|
||||
" COUNT(*) as total, "
|
||||
" SUM(CASE WHEN DATE(report_date) = :d THEN 1 ELSE 0 END) as new_today, "
|
||||
" SUM(CASE WHEN review_status = 'in_progress' THEN 1 ELSE 0 END) as in_progress, "
|
||||
" SUM(CASE WHEN review_status = 'completed' THEN 1 ELSE 0 END) as completed, "
|
||||
" SUM(CASE WHEN review_status = 'pending_review' THEN 1 ELSE 0 END) as pending "
|
||||
"FROM qc_issues"
|
||||
),
|
||||
{"d": date_str},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return dict(row._mapping) if row else {}
|
||||
|
||||
|
||||
def get_issues_for_date(date_str: str) -> list[dict]:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT id, category, description, detail_notes, "
|
||||
"review_status, responsible_department, solution "
|
||||
"FROM qc_issues "
|
||||
"WHERE DATE(report_date) = :d "
|
||||
"ORDER BY id"
|
||||
),
|
||||
{"d": date_str},
|
||||
)
|
||||
return [dict(row._mapping) for row in result]
|
||||
144
ai-service/services/embedding_service.py
Normal file
144
ai-service/services/embedding_service.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from services.ollama_client import ollama_client
|
||||
from db.vector_store import vector_store
|
||||
from db.metadata_store import metadata_store
|
||||
from services.db_client import get_all_issues, get_issue_by_id, get_issues_since
|
||||
|
||||
|
||||
def build_document_text(issue: dict) -> str:
|
||||
parts = []
|
||||
if issue.get("description"):
|
||||
parts.append(issue["description"])
|
||||
if issue.get("final_description"):
|
||||
parts.append(issue["final_description"])
|
||||
if issue.get("detail_notes"):
|
||||
parts.append(issue["detail_notes"])
|
||||
if issue.get("solution"):
|
||||
parts.append(f"해결: {issue['solution']}")
|
||||
if issue.get("management_comment"):
|
||||
parts.append(f"의견: {issue['management_comment']}")
|
||||
if issue.get("cause_detail"):
|
||||
parts.append(f"원인: {issue['cause_detail']}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def build_metadata(issue: dict) -> dict:
|
||||
meta = {"issue_id": issue["id"]}
|
||||
for key in [
|
||||
"category", "project_id", "review_status",
|
||||
"responsible_department", "location_info",
|
||||
]:
|
||||
val = issue.get(key)
|
||||
if val is not None:
|
||||
meta[key] = str(val)
|
||||
rd = issue.get("report_date")
|
||||
if rd:
|
||||
meta["report_date"] = str(rd)[:10]
|
||||
meta["has_solution"] = "true" if issue.get("solution") else "false"
|
||||
return meta
|
||||
|
||||
|
||||
async def sync_all_issues() -> dict:
|
||||
issues = get_all_issues()
|
||||
synced = 0
|
||||
skipped = 0
|
||||
for issue in issues:
|
||||
doc_text = build_document_text(issue)
|
||||
if not doc_text.strip():
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
embedding = await ollama_client.generate_embedding(doc_text)
|
||||
vector_store.upsert(
|
||||
doc_id=f"issue_{issue['id']}",
|
||||
document=doc_text,
|
||||
embedding=embedding,
|
||||
metadata=build_metadata(issue),
|
||||
)
|
||||
synced += 1
|
||||
except Exception as e:
|
||||
skipped += 1
|
||||
if issues:
|
||||
max_id = max(i["id"] for i in issues)
|
||||
metadata_store.set_last_synced_id(max_id)
|
||||
return {"synced": synced, "skipped": skipped, "total": len(issues)}
|
||||
|
||||
|
||||
async def sync_single_issue(issue_id: int) -> dict:
|
||||
issue = get_issue_by_id(issue_id)
|
||||
if not issue:
|
||||
return {"status": "not_found"}
|
||||
doc_text = build_document_text(issue)
|
||||
if not doc_text.strip():
|
||||
return {"status": "empty_text"}
|
||||
embedding = await ollama_client.generate_embedding(doc_text)
|
||||
vector_store.upsert(
|
||||
doc_id=f"issue_{issue['id']}",
|
||||
document=doc_text,
|
||||
embedding=embedding,
|
||||
metadata=build_metadata(issue),
|
||||
)
|
||||
return {"status": "synced", "issue_id": issue_id}
|
||||
|
||||
|
||||
async def sync_incremental() -> dict:
|
||||
last_id = metadata_store.get_last_synced_id()
|
||||
issues = get_issues_since(last_id)
|
||||
synced = 0
|
||||
for issue in issues:
|
||||
doc_text = build_document_text(issue)
|
||||
if not doc_text.strip():
|
||||
continue
|
||||
try:
|
||||
embedding = await ollama_client.generate_embedding(doc_text)
|
||||
vector_store.upsert(
|
||||
doc_id=f"issue_{issue['id']}",
|
||||
document=doc_text,
|
||||
embedding=embedding,
|
||||
metadata=build_metadata(issue),
|
||||
)
|
||||
synced += 1
|
||||
except Exception:
|
||||
pass
|
||||
if issues:
|
||||
max_id = max(i["id"] for i in issues)
|
||||
metadata_store.set_last_synced_id(max_id)
|
||||
return {"synced": synced, "new_issues": len(issues)}
|
||||
|
||||
|
||||
async def search_similar_by_id(issue_id: int, n_results: int = 5) -> list[dict]:
|
||||
issue = get_issue_by_id(issue_id)
|
||||
if not issue:
|
||||
return []
|
||||
doc_text = build_document_text(issue)
|
||||
if not doc_text.strip():
|
||||
return []
|
||||
embedding = await ollama_client.generate_embedding(doc_text)
|
||||
results = vector_store.query(
|
||||
embedding=embedding,
|
||||
n_results=n_results + 1,
|
||||
)
|
||||
# exclude self
|
||||
filtered = []
|
||||
for r in results:
|
||||
if r["id"] != f"issue_{issue_id}":
|
||||
filtered.append(r)
|
||||
return filtered[:n_results]
|
||||
|
||||
|
||||
async def search_similar_by_text(query: str, n_results: int = 5, filters: dict = None) -> list[dict]:
|
||||
embedding = await ollama_client.generate_embedding(query)
|
||||
where = None
|
||||
if filters:
|
||||
conditions = []
|
||||
for k, v in filters.items():
|
||||
if v is not None:
|
||||
conditions.append({k: str(v)})
|
||||
if len(conditions) == 1:
|
||||
where = conditions[0]
|
||||
elif len(conditions) > 1:
|
||||
where = {"$and": conditions}
|
||||
return vector_store.query(
|
||||
embedding=embedding,
|
||||
n_results=n_results,
|
||||
where=where,
|
||||
)
|
||||
57
ai-service/services/ollama_client.py
Normal file
57
ai-service/services/ollama_client.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import httpx
|
||||
from config import settings
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
def __init__(self):
|
||||
self.base_url = settings.OLLAMA_BASE_URL
|
||||
self.timeout = httpx.Timeout(float(settings.OLLAMA_TIMEOUT), connect=10.0)
|
||||
|
||||
async def generate_embedding(self, text: str) -> list[float]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/embeddings",
|
||||
json={"model": settings.OLLAMA_EMBED_MODEL, "prompt": text},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["embedding"]
|
||||
|
||||
async def batch_embeddings(self, texts: list[str]) -> list[list[float]]:
|
||||
results = []
|
||||
for text in texts:
|
||||
emb = await self.generate_embedding(text)
|
||||
results.append(emb)
|
||||
return results
|
||||
|
||||
async def generate_text(self, prompt: str, system: str = None) -> str:
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json={
|
||||
"model": settings.OLLAMA_TEXT_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.3, "num_predict": 2048},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["message"]["content"]
|
||||
|
||||
async def check_health(self) -> dict:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
models = response.json().get("models", [])
|
||||
return {
|
||||
"status": "connected",
|
||||
"models": [m["name"] for m in models],
|
||||
}
|
||||
except Exception:
|
||||
return {"status": "disconnected"}
|
||||
|
||||
|
||||
ollama_client = OllamaClient()
|
||||
164
ai-service/services/rag_service.py
Normal file
164
ai-service/services/rag_service.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from services.ollama_client import ollama_client
|
||||
from services.embedding_service import search_similar_by_text, build_document_text
|
||||
from services.db_client import get_issue_by_id
|
||||
|
||||
|
||||
def _load_prompt(path: str) -> str:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _format_retrieved_issues(results: list[dict]) -> str:
|
||||
if not results:
|
||||
return "관련 과거 사례가 없습니다."
|
||||
lines = []
|
||||
for i, r in enumerate(results, 1):
|
||||
meta = r.get("metadata", {})
|
||||
similarity = round(r.get("similarity", 0) * 100)
|
||||
doc = (r.get("document", ""))[:500]
|
||||
cat = meta.get("category", "")
|
||||
dept = meta.get("responsible_department", "")
|
||||
status = meta.get("review_status", "")
|
||||
has_sol = meta.get("has_solution", "false")
|
||||
date = meta.get("report_date", "")
|
||||
issue_id = meta.get("issue_id", r["id"])
|
||||
lines.append(
|
||||
f"[사례 {i}] No.{issue_id} (유사도 {similarity}%)\n"
|
||||
f" 분류: {cat} | 부서: {dept} | 상태: {status} | 날짜: {date} | 해결여부: {'O' if has_sol == 'true' else 'X'}\n"
|
||||
f" 내용: {doc}"
|
||||
)
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
async def rag_suggest_solution(issue_id: int) -> dict:
|
||||
"""과거 유사 이슈의 해결 사례를 참고하여 해결방안을 제안"""
|
||||
issue = get_issue_by_id(issue_id)
|
||||
if not issue:
|
||||
return {"available": False, "error": "이슈를 찾을 수 없습니다"}
|
||||
|
||||
doc_text = build_document_text(issue)
|
||||
if not doc_text.strip():
|
||||
return {"available": False, "error": "이슈 내용이 비어있습니다"}
|
||||
|
||||
# 해결 완료된 유사 이슈 검색
|
||||
similar = await search_similar_by_text(
|
||||
doc_text, n_results=5, filters={"has_solution": "true"}
|
||||
)
|
||||
# 해결 안 된 것도 포함 (참고용)
|
||||
if len(similar) < 3:
|
||||
all_similar = await search_similar_by_text(doc_text, n_results=5)
|
||||
seen = {r["id"] for r in similar}
|
||||
for r in all_similar:
|
||||
if r["id"] not in seen:
|
||||
similar.append(r)
|
||||
if len(similar) >= 5:
|
||||
break
|
||||
|
||||
context = _format_retrieved_issues(similar)
|
||||
template = _load_prompt("prompts/rag_suggest_solution.txt")
|
||||
prompt = template.format(
|
||||
description=issue.get("description", ""),
|
||||
detail_notes=issue.get("detail_notes", ""),
|
||||
category=issue.get("category", ""),
|
||||
retrieved_cases=context,
|
||||
)
|
||||
|
||||
response = await ollama_client.generate_text(prompt)
|
||||
return {
|
||||
"available": True,
|
||||
"issue_id": issue_id,
|
||||
"suggestion": response,
|
||||
"referenced_issues": [
|
||||
{
|
||||
"id": r.get("metadata", {}).get("issue_id", r["id"]),
|
||||
"similarity": round(r.get("similarity", 0) * 100),
|
||||
"has_solution": r.get("metadata", {}).get("has_solution", "false") == "true",
|
||||
}
|
||||
for r in similar
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def rag_ask(question: str, project_id: int = None) -> dict:
|
||||
"""부적합 데이터를 기반으로 자연어 질문에 답변"""
|
||||
# 프로젝트 필터 없이 전체 데이터에서 검색 (과거 미지정 데이터 포함)
|
||||
results = await search_similar_by_text(
|
||||
question, n_results=15, filters=None
|
||||
)
|
||||
context = _format_retrieved_issues(results)
|
||||
|
||||
template = _load_prompt("prompts/rag_qa.txt")
|
||||
prompt = template.format(
|
||||
question=question,
|
||||
retrieved_cases=context,
|
||||
)
|
||||
|
||||
response = await ollama_client.generate_text(prompt)
|
||||
return {
|
||||
"available": True,
|
||||
"answer": response,
|
||||
"sources": [
|
||||
{
|
||||
"id": r.get("metadata", {}).get("issue_id", r["id"]),
|
||||
"similarity": round(r.get("similarity", 0) * 100),
|
||||
"snippet": (r.get("document", ""))[:100],
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def rag_analyze_pattern(description: str, n_results: int = 10) -> dict:
|
||||
"""유사 부적합 패턴 분석 — 반복되는 문제인지, 근본 원인은 무엇인지"""
|
||||
results = await search_similar_by_text(description, n_results=n_results)
|
||||
context = _format_retrieved_issues(results)
|
||||
|
||||
template = _load_prompt("prompts/rag_pattern.txt")
|
||||
prompt = template.format(
|
||||
description=description,
|
||||
retrieved_cases=context,
|
||||
total_similar=len(results),
|
||||
)
|
||||
|
||||
response = await ollama_client.generate_text(prompt)
|
||||
return {
|
||||
"available": True,
|
||||
"analysis": response,
|
||||
"similar_count": len(results),
|
||||
"sources": [
|
||||
{
|
||||
"id": r.get("metadata", {}).get("issue_id", r["id"]),
|
||||
"similarity": round(r.get("similarity", 0) * 100),
|
||||
"category": r.get("metadata", {}).get("category", ""),
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def rag_classify_with_context(description: str, detail_notes: str = "") -> dict:
|
||||
"""과거 사례를 참고하여 더 정확한 분류 수행 (기존 classify 강화)"""
|
||||
query = f"{description} {detail_notes}".strip()
|
||||
similar = await search_similar_by_text(query, n_results=5)
|
||||
context = _format_retrieved_issues(similar)
|
||||
|
||||
template = _load_prompt("prompts/rag_classify.txt")
|
||||
prompt = template.format(
|
||||
description=description,
|
||||
detail_notes=detail_notes,
|
||||
retrieved_cases=context,
|
||||
)
|
||||
|
||||
raw = await ollama_client.generate_text(prompt)
|
||||
import json
|
||||
try:
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
result = json.loads(raw[start:end])
|
||||
result["rag_enhanced"] = True
|
||||
result["referenced_count"] = len(similar)
|
||||
return {"available": True, **result}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"available": True, "raw_response": raw, "rag_enhanced": True}
|
||||
122
ai-service/services/report_service.py
Normal file
122
ai-service/services/report_service.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import httpx
|
||||
from services.ollama_client import ollama_client
|
||||
from services.db_client import get_daily_qc_stats, get_issues_for_date
|
||||
from config import settings
|
||||
|
||||
|
||||
REPORT_PROMPT_PATH = "prompts/daily_report.txt"
|
||||
|
||||
|
||||
def _load_prompt(path: str) -> str:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
async def _fetch_system1_data(date_str: str, token: str) -> dict:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = {"attendance": None, "work_reports": None, "patrol": None}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
# 근태
|
||||
try:
|
||||
r = await client.get(
|
||||
f"{settings.SYSTEM1_API_URL}/api/attendance/daily-status",
|
||||
params={"date": date_str},
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data["attendance"] = r.json()
|
||||
except Exception:
|
||||
pass
|
||||
# 작업보고
|
||||
try:
|
||||
r = await client.get(
|
||||
f"{settings.SYSTEM1_API_URL}/api/daily-work-reports/summary",
|
||||
params={"date": date_str},
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data["work_reports"] = r.json()
|
||||
except Exception:
|
||||
pass
|
||||
# 순회점검
|
||||
try:
|
||||
r = await client.get(
|
||||
f"{settings.SYSTEM1_API_URL}/api/patrol/today-status",
|
||||
params={"date": date_str},
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data["patrol"] = r.json()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def _format_attendance(data) -> str:
|
||||
if not data:
|
||||
return "데이터 없음"
|
||||
if isinstance(data, dict):
|
||||
parts = []
|
||||
for k, v in data.items():
|
||||
parts.append(f" {k}: {v}")
|
||||
return "\n".join(parts)
|
||||
return str(data)
|
||||
|
||||
|
||||
def _format_work_reports(data) -> str:
|
||||
if not data:
|
||||
return "데이터 없음"
|
||||
return str(data)
|
||||
|
||||
|
||||
def _format_qc_issues(issues: list[dict], stats: dict) -> str:
|
||||
lines = []
|
||||
lines.append(f"전체: {stats.get('total', 0)}건")
|
||||
lines.append(f"금일 신규: {stats.get('new_today', 0)}건")
|
||||
lines.append(f"진행중: {stats.get('in_progress', 0)}건")
|
||||
lines.append(f"완료: {stats.get('completed', 0)}건")
|
||||
lines.append(f"미검토: {stats.get('pending', 0)}건")
|
||||
if issues:
|
||||
lines.append("\n금일 신규 이슈:")
|
||||
for iss in issues[:10]:
|
||||
cat = iss.get("category", "")
|
||||
desc = (iss.get("description") or "")[:50]
|
||||
status = iss.get("review_status", "")
|
||||
lines.append(f" - [{cat}] {desc} (상태: {status})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_patrol(data) -> str:
|
||||
if not data:
|
||||
return "데이터 없음"
|
||||
return str(data)
|
||||
|
||||
|
||||
async def generate_daily_report(
|
||||
date_str: str, project_id: int = None, token: str = ""
|
||||
) -> dict:
|
||||
system1_data = await _fetch_system1_data(date_str, token)
|
||||
qc_stats = get_daily_qc_stats(date_str)
|
||||
qc_issues = get_issues_for_date(date_str)
|
||||
|
||||
template = _load_prompt(REPORT_PROMPT_PATH)
|
||||
prompt = template.format(
|
||||
date=date_str,
|
||||
attendance_data=_format_attendance(system1_data["attendance"]),
|
||||
work_report_data=_format_work_reports(system1_data["work_reports"]),
|
||||
qc_issue_data=_format_qc_issues(qc_issues, qc_stats),
|
||||
patrol_data=_format_patrol(system1_data["patrol"]),
|
||||
)
|
||||
|
||||
report_text = await ollama_client.generate_text(prompt)
|
||||
return {
|
||||
"date": date_str,
|
||||
"report": report_text,
|
||||
"stats": {
|
||||
"qc": qc_stats,
|
||||
"new_issues_count": len(qc_issues),
|
||||
},
|
||||
}
|
||||
@@ -307,6 +307,40 @@ services:
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
# =================================================================
|
||||
# AI Service
|
||||
# =================================================================
|
||||
|
||||
ai-service:
|
||||
build:
|
||||
context: ./ai-service
|
||||
dockerfile: Dockerfile
|
||||
container_name: tk-ai-service
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "30400:8000"
|
||||
environment:
|
||||
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://100.111.160.84:11434}
|
||||
- OLLAMA_TEXT_MODEL=${OLLAMA_TEXT_MODEL:-qwen2.5:14b-instruct-q4_K_M}
|
||||
- OLLAMA_EMBED_MODEL=${OLLAMA_EMBED_MODEL:-bge-m3}
|
||||
- OLLAMA_TIMEOUT=${OLLAMA_TIMEOUT:-120}
|
||||
- DB_HOST=mariadb
|
||||
- DB_PORT=3306
|
||||
- DB_USER=${MYSQL_USER:-hyungi_user}
|
||||
- DB_PASSWORD=${MYSQL_PASSWORD}
|
||||
- DB_NAME=${MYSQL_DATABASE:-hyungi}
|
||||
- SECRET_KEY=${SSO_JWT_SECRET}
|
||||
- SYSTEM1_API_URL=http://system1-api:3005
|
||||
- CHROMA_PERSIST_DIR=/app/data/chroma
|
||||
- TZ=Asia/Seoul
|
||||
volumes:
|
||||
- ai_data:/app/data
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tk-network
|
||||
|
||||
# =================================================================
|
||||
# Gateway
|
||||
# =================================================================
|
||||
@@ -382,6 +416,7 @@ volumes:
|
||||
system3_uploads:
|
||||
external: true
|
||||
name: tkqc-package_uploads
|
||||
ai_data:
|
||||
networks:
|
||||
tk-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -53,6 +53,18 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ===== AI Service API =====
|
||||
location /ai-api/ {
|
||||
proxy_pass http://ai-service:8000/api/ai/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# ===== System 1 Web (나머지 모든 경로) =====
|
||||
location / {
|
||||
proxy_pass http://system1-web:80;
|
||||
|
||||
@@ -52,6 +52,7 @@ DEFAULT_PAGES = {
|
||||
'issues_dashboard': {'title': '현황판', 'default_access': True},
|
||||
'reports': {'title': '보고서', 'default_access': False},
|
||||
'reports_daily': {'title': '일일보고서', 'default_access': False},
|
||||
'ai_assistant': {'title': 'AI 어시스턴트', 'default_access': False},
|
||||
}
|
||||
|
||||
|
||||
|
||||
284
system3-nonconformance/web/ai-assistant.html
Normal file
284
system3-nonconformance/web/ai-assistant.html
Normal file
@@ -0,0 +1,284 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI 어시스턴트</title>
|
||||
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tkqc-common.css?v=20260306">
|
||||
<link rel="stylesheet" href="/static/css/ai-assistant.css?v=20260306">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 로딩 스크린 -->
|
||||
<div id="loadingScreen" class="fixed inset-0 bg-white z-50 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600 mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">AI 어시스턴트를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<div id="mainContent" class="min-h-screen">
|
||||
<!-- 공통 헤더 -->
|
||||
<div id="commonHeader"></div>
|
||||
|
||||
<main class="container mx-auto px-4 py-8 content-fade-in" style="padding-top: 72px;">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-robot text-purple-500 mr-3"></i>
|
||||
AI 어시스턴트
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-1">AI 기반 부적합 분석, 검색, 질의응답을 한곳에서 사용하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. 상태 카드 (3열 그리드) -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="status-card bg-white rounded-xl shadow-sm p-5 border-l-4 border-purple-500">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">AI 서비스</p>
|
||||
<p class="text-lg font-bold mt-1" id="aiStatusText">확인 중...</p>
|
||||
</div>
|
||||
<div id="aiStatusIcon" class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<i class="fas fa-spinner fa-spin text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card bg-white rounded-xl shadow-sm p-5 border-l-4 border-indigo-500">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">임베딩 데이터</p>
|
||||
<p class="text-lg font-bold mt-1" id="aiEmbeddingCount">-</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center">
|
||||
<i class="fas fa-database text-indigo-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card bg-white rounded-xl shadow-sm p-5 border-l-4 border-blue-500">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">AI 모델</p>
|
||||
<p class="text-lg font-bold mt-1" id="aiModelName">-</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center">
|
||||
<i class="fas fa-brain text-blue-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. AI Q&A (메인 — 채팅형) -->
|
||||
<div class="section-card bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-comments text-purple-500 mr-2"></i>
|
||||
<h2 class="text-lg font-semibold text-gray-800">AI Q&A</h2>
|
||||
<span class="ml-2 text-xs bg-purple-100 text-purple-600 px-2 py-0.5 rounded-full">과거 사례 기반</span>
|
||||
</div>
|
||||
<button onclick="clearChat()" class="text-sm text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-trash-alt mr-1"></i>대화 초기화
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 채팅 히스토리 -->
|
||||
<div id="chatContainer" class="chat-container bg-gray-50 rounded-lg p-4 mb-4 min-h-[200px]">
|
||||
<div class="text-center text-gray-400 text-sm py-8" id="chatPlaceholder">
|
||||
<i class="fas fa-robot text-4xl mb-3 text-gray-300"></i>
|
||||
<p>부적합 관련 질문을 입력하세요.</p>
|
||||
<p class="text-xs mt-1">과거 사례를 분석하여 답변합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 빠른 질문 템플릿 -->
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<button onclick="setQuickQuestion('최근 가장 많이 발생하는 부적합 유형은?')"
|
||||
class="quick-question-btn text-xs px-3 py-1.5 rounded-full bg-white text-gray-600">
|
||||
<i class="fas fa-chart-pie mr-1 text-purple-400"></i>많이 발생하는 유형
|
||||
</button>
|
||||
<button onclick="setQuickQuestion('용접 불량의 주요 원인과 해결방법은?')"
|
||||
class="quick-question-btn text-xs px-3 py-1.5 rounded-full bg-white text-gray-600">
|
||||
<i class="fas fa-fire mr-1 text-orange-400"></i>용접 불량 원인
|
||||
</button>
|
||||
<button onclick="setQuickQuestion('자재 관련 부적합을 줄이려면 어떻게 해야 하나요?')"
|
||||
class="quick-question-btn text-xs px-3 py-1.5 rounded-full bg-white text-gray-600">
|
||||
<i class="fas fa-box mr-1 text-blue-400"></i>자재 부적합 개선
|
||||
</button>
|
||||
<button onclick="setQuickQuestion('반복적으로 발생하는 부적합 패턴이 있나요?')"
|
||||
class="quick-question-btn text-xs px-3 py-1.5 rounded-full bg-white text-gray-600">
|
||||
<i class="fas fa-redo mr-1 text-red-400"></i>반복 패턴 분석
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 입력 영역 -->
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
<div class="flex-1">
|
||||
<textarea id="qaQuestion" rows="3"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none text-sm"
|
||||
placeholder="질문을 입력하세요... (Ctrl+Enter로 전송)"
|
||||
onkeydown="if(event.ctrlKey && event.key==='Enter') submitQuestion()"></textarea>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 md:w-48">
|
||||
<select id="qaProjectFilter" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">전체 프로젝트</option>
|
||||
</select>
|
||||
<button onclick="submitQuestion()"
|
||||
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-paper-plane mr-1"></i>질문하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 시맨틱 검색 -->
|
||||
<div class="section-card bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-search text-indigo-500 mr-2"></i>
|
||||
<h2 class="text-lg font-semibold text-gray-800">시맨틱 검색</h2>
|
||||
<span class="ml-2 text-xs bg-indigo-100 text-indigo-600 px-2 py-0.5 rounded-full">유사 부적합 찾기</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-2 mb-4">
|
||||
<input type="text" id="searchQuery"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm"
|
||||
placeholder="부적합 내용을 자연어로 검색하세요... (예: 볼트 누락, 용접 불량)"
|
||||
onkeydown="if(event.key==='Enter') executeSemanticSearch()">
|
||||
<select id="searchProjectFilter" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 md:w-40">
|
||||
<option value="">전체 프로젝트</option>
|
||||
</select>
|
||||
<select id="searchCategoryFilter" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 md:w-40">
|
||||
<option value="">전체 카테고리</option>
|
||||
<option value="civil">토목</option>
|
||||
<option value="architecture">건축</option>
|
||||
<option value="mechanical">기계</option>
|
||||
<option value="electrical">전기</option>
|
||||
<option value="piping">배관</option>
|
||||
<option value="instrument">계장</option>
|
||||
<option value="painting">도장</option>
|
||||
<option value="insulation">보온</option>
|
||||
<option value="fireproof">내화</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
<select id="searchResultCount" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 md:w-28">
|
||||
<option value="5">5건</option>
|
||||
<option value="10" selected>10건</option>
|
||||
<option value="20">20건</option>
|
||||
</select>
|
||||
<button onclick="executeSemanticSearch()"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm whitespace-nowrap">
|
||||
<i class="fas fa-search mr-1"></i>검색
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="searchLoading" class="hidden text-center py-4">
|
||||
<i class="fas fa-spinner fa-spin text-indigo-500 mr-1"></i>
|
||||
<span class="text-sm text-gray-500">AI 검색 중...</span>
|
||||
</div>
|
||||
<div id="searchResults" class="space-y-2">
|
||||
<!-- 검색 결과 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 패턴 분석 -->
|
||||
<div class="section-card bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-chart-bar text-green-500 mr-2"></i>
|
||||
<h2 class="text-lg font-semibold text-gray-800">패턴 분석</h2>
|
||||
<span class="ml-2 text-xs bg-green-100 text-green-600 px-2 py-0.5 rounded-full">부적합 패턴 파악</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-2 mb-4">
|
||||
<textarea id="patternInput" rows="2"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none text-sm"
|
||||
placeholder="분석할 부적합 내용을 입력하세요... (예: 배관 용접부 결함)"></textarea>
|
||||
<button onclick="executePatternAnalysis()"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm whitespace-nowrap self-end">
|
||||
<i class="fas fa-chart-bar mr-1"></i>패턴 분석
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="patternLoading" class="hidden text-center py-4">
|
||||
<i class="fas fa-spinner fa-spin text-green-500 mr-1"></i>
|
||||
<span class="text-sm text-gray-500">패턴 분석 중...</span>
|
||||
</div>
|
||||
<div id="patternResults" class="hidden">
|
||||
<!-- 패턴 분석 결과 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. AI 분류 테스트 -->
|
||||
<div class="section-card bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-tags text-amber-500 mr-2"></i>
|
||||
<h2 class="text-lg font-semibold text-gray-800">AI 분류 테스트</h2>
|
||||
<span class="ml-2 text-xs bg-amber-100 text-amber-600 px-2 py-0.5 rounded-full">기본 vs RAG 비교</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">부적합 설명</label>
|
||||
<textarea id="classifyDescription" rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500 resize-none text-sm"
|
||||
placeholder="부적합 설명을 입력하세요..."></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">상세 내용 (선택)</label>
|
||||
<textarea id="classifyDetail" rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500 resize-none text-sm"
|
||||
placeholder="상세 내용을 입력하세요..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button onclick="executeClassification(false)"
|
||||
class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors text-sm">
|
||||
<i class="fas fa-tag mr-1"></i>기본 분류
|
||||
</button>
|
||||
<button onclick="executeClassification(true)"
|
||||
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm">
|
||||
<i class="fas fa-tags mr-1"></i>RAG 분류
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="classifyLoading" class="hidden text-center py-4">
|
||||
<i class="fas fa-spinner fa-spin text-amber-500 mr-1"></i>
|
||||
<span class="text-sm text-gray-500">AI 분류 중...</span>
|
||||
</div>
|
||||
<div id="classifyResults" class="hidden">
|
||||
<!-- 분류 결과 -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- AI 이슈 상세 모달 -->
|
||||
<div id="aiIssueModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center" onclick="if(event.target===this)this.classList.add('hidden')">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<div class="sticky top-0 bg-white border-b px-5 py-3 flex justify-between items-center rounded-t-xl">
|
||||
<h3 id="aiIssueModalTitle" class="font-bold text-gray-800"></h3>
|
||||
<button onclick="document.getElementById('aiIssueModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="aiIssueModalBody" class="p-5 text-sm text-gray-700 space-y-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script src="/static/js/core/permissions.js?v=20260306"></script>
|
||||
<script src="/static/js/components/common-header.js?v=20260306"></script>
|
||||
<script src="/static/js/core/page-manager.js?v=20260306"></script>
|
||||
<script src="/static/js/core/auth-manager.js?v=20260306"></script>
|
||||
<script src="/static/js/utils/issue-helpers.js?v=20260306"></script>
|
||||
<script src="/static/js/utils/toast.js?v=20260306"></script>
|
||||
<script src="/static/js/components/mobile-bottom-nav.js?v=20260306"></script>
|
||||
<script src="/static/js/api.js?v=20260306"></script>
|
||||
<script src="/static/js/pages/ai-assistant.js?v=20260306"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -115,6 +115,57 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 시맨틱 검색 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center mb-3">
|
||||
<i class="fas fa-robot text-purple-500 mr-2"></i>
|
||||
<h3 class="text-sm font-semibold text-gray-700">AI 유사 부적합 검색</h3>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<input type="text" id="aiSearchQuery"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="부적합 내용을 자연어로 검색하세요... (예: 볼트 누락, 용접 불량)"
|
||||
onkeydown="if(event.key==='Enter') aiSemanticSearch()">
|
||||
<button onclick="aiSemanticSearch()"
|
||||
class="px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors whitespace-nowrap">
|
||||
<i class="fas fa-search mr-1"></i>AI 검색
|
||||
</button>
|
||||
</div>
|
||||
<div id="aiSearchLoading" class="hidden mt-3 text-center">
|
||||
<i class="fas fa-spinner fa-spin text-purple-500 mr-1"></i>
|
||||
<span class="text-sm text-gray-500">AI 검색 중...</span>
|
||||
</div>
|
||||
<div id="aiSearchResults" class="hidden mt-3 space-y-2">
|
||||
<!-- 검색 결과 -->
|
||||
</div>
|
||||
|
||||
<!-- RAG Q&A -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="fas fa-comments text-indigo-500 mr-2"></i>
|
||||
<h4 class="text-sm font-semibold text-gray-700">AI Q&A (과거 사례 기반)</h4>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<input type="text" id="aiQaQuestion"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="질문하세요... (예: 최근 자재 누락이 왜 많아?, 용접 불량 해결방법은?)"
|
||||
onkeydown="if(event.key==='Enter') aiAskQuestion()">
|
||||
<button onclick="aiAskQuestion()"
|
||||
class="px-4 py-2 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors whitespace-nowrap">
|
||||
<i class="fas fa-paper-plane mr-1"></i>질문
|
||||
</button>
|
||||
</div>
|
||||
<div id="aiQaLoading" class="hidden mt-3 text-center">
|
||||
<i class="fas fa-spinner fa-spin text-indigo-500 mr-1"></i>
|
||||
<span class="text-sm text-gray-500">과거 사례 분석 중...</span>
|
||||
</div>
|
||||
<div id="aiQaResult" class="hidden mt-3 bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||
<div id="aiQaAnswer" class="text-sm text-gray-700 whitespace-pre-line"></div>
|
||||
<div id="aiQaSources" class="mt-2 text-xs text-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트 선택 및 필터 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
|
||||
@@ -549,15 +600,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 이슈 상세 모달 -->
|
||||
<div id="aiIssueModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center" onclick="if(event.target===this)this.classList.add('hidden')">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<div class="sticky top-0 bg-white border-b px-5 py-3 flex justify-between items-center rounded-t-xl">
|
||||
<h3 id="aiIssueModalTitle" class="font-bold text-gray-800"></h3>
|
||||
<button onclick="document.getElementById('aiIssueModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="aiIssueModalBody" class="p-5 text-sm text-gray-700 space-y-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script src="/static/js/core/permissions.js?v=20260213"></script>
|
||||
<script src="/static/js/components/common-header.js?v=20260213"></script>
|
||||
<script src="/static/js/core/page-manager.js?v=20260213"></script>
|
||||
<script src="/static/js/core/auth-manager.js?v=20260213"></script>
|
||||
<script src="/static/js/utils/issue-helpers.js?v=20260213"></script>
|
||||
<script src="/static/js/utils/photo-modal.js?v=20260213"></script>
|
||||
<script src="/static/js/utils/toast.js?v=20260213"></script>
|
||||
<script src="/static/js/components/mobile-bottom-nav.js?v=20260213"></script>
|
||||
<script src="/static/js/pages/issues-dashboard.js?v=20260213"></script>
|
||||
<script src="/static/js/core/permissions.js?v=20260306"></script>
|
||||
<script src="/static/js/components/common-header.js?v=20260306"></script>
|
||||
<script src="/static/js/core/page-manager.js?v=20260306"></script>
|
||||
<script src="/static/js/core/auth-manager.js?v=20260306"></script>
|
||||
<script src="/static/js/utils/issue-helpers.js?v=20260306"></script>
|
||||
<script src="/static/js/utils/photo-modal.js?v=20260306"></script>
|
||||
<script src="/static/js/utils/toast.js?v=20260306"></script>
|
||||
<script src="/static/js/components/mobile-bottom-nav.js?v=20260306"></script>
|
||||
<script src="/static/js/api.js?v=20260306"></script>
|
||||
<script src="/static/js/pages/issues-dashboard.js?v=20260306"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -204,6 +204,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 분류 추천 -->
|
||||
<div class="bg-purple-50 border border-purple-200 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-purple-700">
|
||||
<i class="fas fa-robot mr-1"></i>AI 분류 추천
|
||||
</span>
|
||||
<button id="aiClassifyBtn" onclick="aiClassifyCurrentIssue()"
|
||||
class="px-3 py-1 bg-purple-500 text-white text-xs rounded-lg hover:bg-purple-600 transition-colors">
|
||||
<i class="fas fa-magic mr-1"></i>AI 분석
|
||||
</button>
|
||||
</div>
|
||||
<div id="aiClassifyLoading" class="hidden mt-2 text-center">
|
||||
<i class="fas fa-spinner fa-spin text-purple-500 mr-1"></i>
|
||||
<span class="text-xs text-purple-600">AI 분석 중...</span>
|
||||
</div>
|
||||
<div id="aiClassifyResult" class="hidden mt-2 text-sm text-purple-800 space-y-1">
|
||||
<!-- AI 결과가 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수정 폼 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -350,13 +370,14 @@
|
||||
<!-- Scripts -->
|
||||
<script src="/static/js/date-utils.js?v=20260213"></script>
|
||||
<script src="/static/js/core/permissions.js?v=20260213"></script>
|
||||
<script src="/static/js/components/common-header.js?v=20260213"></script>
|
||||
<script src="/static/js/core/page-manager.js?v=20260213"></script>
|
||||
<script src="/static/js/components/mobile-calendar.js?v=20260213"></script>
|
||||
<script src="/static/js/utils/issue-helpers.js?v=20260213"></script>
|
||||
<script src="/static/js/utils/photo-modal.js?v=20260213"></script>
|
||||
<script src="/static/js/utils/toast.js?v=20260213"></script>
|
||||
<script src="/static/js/components/mobile-bottom-nav.js?v=20260213"></script>
|
||||
<script src="/static/js/pages/issues-inbox.js?v=20260213"></script>
|
||||
<script src="/static/js/components/common-header.js?v=20260306"></script>
|
||||
<script src="/static/js/core/page-manager.js?v=20260306"></script>
|
||||
<script src="/static/js/components/mobile-calendar.js?v=20260306"></script>
|
||||
<script src="/static/js/utils/issue-helpers.js?v=20260306"></script>
|
||||
<script src="/static/js/utils/photo-modal.js?v=20260306"></script>
|
||||
<script src="/static/js/utils/toast.js?v=20260306"></script>
|
||||
<script src="/static/js/components/mobile-bottom-nav.js?v=20260306"></script>
|
||||
<script src="/static/js/api.js?v=20260306"></script>
|
||||
<script src="/static/js/pages/issues-inbox.js?v=20260306"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -161,6 +161,44 @@
|
||||
<!-- 동적으로 생성될 내용 -->
|
||||
</div>
|
||||
|
||||
<!-- AI 유사 부적합 패널 -->
|
||||
<div id="aiSimilarPanel" class="mt-6 border-t pt-6 hidden">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700">
|
||||
<i class="fas fa-robot text-purple-500 mr-2"></i>AI 유사 부적합
|
||||
</h3>
|
||||
<button id="aiSimilarRefresh" onclick="loadSimilarIssues()" class="text-xs text-purple-500 hover:text-purple-700">
|
||||
<i class="fas fa-sync-alt mr-1"></i>검색
|
||||
</button>
|
||||
</div>
|
||||
<div id="aiSimilarLoading" class="hidden text-center py-4">
|
||||
<i class="fas fa-spinner fa-spin text-purple-500 mr-2"></i>
|
||||
<span class="text-sm text-gray-500">유사 이슈 검색 중...</span>
|
||||
</div>
|
||||
<div id="aiSimilarResults" class="space-y-2">
|
||||
<!-- 유사 이슈 목록 -->
|
||||
</div>
|
||||
<div id="aiSimilarEmpty" class="hidden text-center py-3">
|
||||
<p class="text-sm text-gray-400">유사한 부적합이 없습니다</p>
|
||||
</div>
|
||||
|
||||
<!-- RAG 해결방안 제안 -->
|
||||
<div class="mt-4 pt-3 border-t border-purple-100">
|
||||
<button id="aiSuggestSolutionBtn" onclick="aiSuggestSolution()"
|
||||
class="w-full px-3 py-2 bg-gradient-to-r from-purple-500 to-indigo-500 text-white text-sm rounded-lg hover:from-purple-600 hover:to-indigo-600 transition-all">
|
||||
<i class="fas fa-lightbulb mr-2"></i>AI 해결방안 제안 (과거 사례 기반)
|
||||
</button>
|
||||
<div id="aiSuggestLoading" class="hidden mt-2 text-center py-3">
|
||||
<i class="fas fa-spinner fa-spin text-purple-500 mr-1"></i>
|
||||
<span class="text-xs text-gray-500">과거 사례 분석 중...</span>
|
||||
</div>
|
||||
<div id="aiSuggestResult" class="hidden mt-2 bg-indigo-50 border border-indigo-200 rounded-lg p-3">
|
||||
<div id="aiSuggestContent" class="text-sm text-gray-700 whitespace-pre-line"></div>
|
||||
<div id="aiSuggestSources" class="mt-2 text-xs text-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모달 푸터 -->
|
||||
<div class="flex justify-end space-x-3 mt-6 pt-6 border-t">
|
||||
<button onclick="closeIssueDetailModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
|
||||
@@ -299,13 +337,14 @@
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/static/js/date-utils.js?v=20260213"></script>
|
||||
<script src="/static/js/core/permissions.js?v=20260213"></script>
|
||||
<script src="/static/js/components/common-header.js?v=20260213"></script>
|
||||
<script src="/static/js/core/page-manager.js?v=20260213"></script>
|
||||
<script src="/static/js/utils/issue-helpers.js?v=20260213"></script>
|
||||
<script src="/static/js/utils/photo-modal.js?v=20260213"></script>
|
||||
<script src="/static/js/utils/toast.js?v=20260213"></script>
|
||||
<script src="/static/js/components/mobile-bottom-nav.js?v=20260213"></script>
|
||||
<script src="/static/js/pages/issues-management.js?v=20260213"></script>
|
||||
<script src="/static/js/core/permissions.js?v=20260306"></script>
|
||||
<script src="/static/js/components/common-header.js?v=20260306"></script>
|
||||
<script src="/static/js/core/page-manager.js?v=20260306"></script>
|
||||
<script src="/static/js/utils/issue-helpers.js?v=20260306"></script>
|
||||
<script src="/static/js/utils/photo-modal.js?v=20260306"></script>
|
||||
<script src="/static/js/utils/toast.js?v=20260306"></script>
|
||||
<script src="/static/js/components/mobile-bottom-nav.js?v=20260306"></script>
|
||||
<script src="/static/js/api.js?v=20260306"></script>
|
||||
<script src="/static/js/pages/issues-management.js?v=20260306"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -48,6 +48,18 @@ server {
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# AI API 프록시
|
||||
location /ai-api/ {
|
||||
proxy_pass http://ai-service:8000/api/ai/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# 모바일 전용 페이지
|
||||
location /m/ {
|
||||
alias /usr/share/nginx/html/m/;
|
||||
|
||||
162
system3-nonconformance/web/static/css/ai-assistant.css
Normal file
162
system3-nonconformance/web/static/css/ai-assistant.css
Normal file
@@ -0,0 +1,162 @@
|
||||
/* ai-assistant.css — AI 어시스턴트 페이지 전용 스타일 */
|
||||
|
||||
/* 페이드인 애니메이션 */
|
||||
.fade-in { opacity: 0; animation: fadeIn 0.5s ease-in forwards; }
|
||||
@keyframes fadeIn { to { opacity: 1; } }
|
||||
|
||||
.header-fade-in { opacity: 0; animation: headerFadeIn 0.6s ease-out forwards; }
|
||||
@keyframes headerFadeIn { to { opacity: 1; transform: translateY(0); } from { transform: translateY(-10px); } }
|
||||
|
||||
.content-fade-in { opacity: 0; animation: contentFadeIn 0.7s ease-out 0.2s forwards; }
|
||||
@keyframes contentFadeIn { to { opacity: 1; transform: translateY(0); } from { transform: translateY(20px); } }
|
||||
|
||||
/* 채팅 컨테이너 */
|
||||
.chat-container {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.chat-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.chat-container::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-container::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* 채팅 말풍선 */
|
||||
.chat-bubble {
|
||||
max-width: 85%;
|
||||
animation: bubbleIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes bubbleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-bubble-user {
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
padding: 10px 16px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chat-bubble-ai {
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.chat-bubble-ai .source-link {
|
||||
color: #7c3aed;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.chat-bubble-ai .source-link:hover {
|
||||
color: #6d28d9;
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
/* 로딩 도트 애니메이션 */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.typing-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #94a3b8;
|
||||
border-radius: 50%;
|
||||
animation: typingBounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 빠른 질문 버튼 */
|
||||
.quick-question-btn {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.quick-question-btn:hover {
|
||||
border-color: #7c3aed;
|
||||
background: #faf5ff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
|
||||
/* 상태 카드 */
|
||||
.status-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.status-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 섹션 카드 */
|
||||
.section-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 결과 아이템 */
|
||||
.result-item {
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
/* 모바일 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
max-width: 92%;
|
||||
}
|
||||
|
||||
button, a, [onclick], select {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-bottom: calc(64px + env(safe-area-inset-bottom)) !important;
|
||||
}
|
||||
}
|
||||
@@ -308,6 +308,161 @@ function checkPageAccess(pageName) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// AI API
|
||||
const AiAPI = {
|
||||
getSimilarIssues: async (issueId, limit = 5) => {
|
||||
try {
|
||||
const res = await fetch(`/ai-api/similar/${issueId}?n_results=${limit}`, {
|
||||
headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` }
|
||||
});
|
||||
if (!res.ok) return { available: false, results: [] };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 유사 검색 실패:', e);
|
||||
return { available: false, results: [] };
|
||||
}
|
||||
},
|
||||
searchSimilar: async (query, limit = 5, filters = {}) => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/similar/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ query, n_results: limit, ...filters })
|
||||
});
|
||||
if (!res.ok) return { available: false, results: [] };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 검색 실패:', e);
|
||||
return { available: false, results: [] };
|
||||
}
|
||||
},
|
||||
classifyIssue: async (description, detailNotes = '') => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/classify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ description, detail_notes: detailNotes })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 분류 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
generateDailyReport: async (date, projectId) => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/report/daily', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ date, project_id: projectId })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 보고서 생성 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
syncEmbeddings: async () => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/embeddings/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` }
|
||||
});
|
||||
if (!res.ok) return { status: 'error' };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
return { status: 'error' };
|
||||
}
|
||||
},
|
||||
checkHealth: async () => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/health');
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
return { status: 'disconnected' };
|
||||
}
|
||||
},
|
||||
// RAG: 해결방안 제안
|
||||
suggestSolution: async (issueId) => {
|
||||
try {
|
||||
const res = await fetch(`/ai-api/rag/suggest-solution/${issueId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${TokenManager.getToken()}` }
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 해결방안 제안 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
// RAG: 자연어 질의
|
||||
askQuestion: async (question, projectId = null) => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/rag/ask', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ question, project_id: projectId })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 질의 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
// RAG: 패턴 분석
|
||||
analyzePattern: async (description) => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/rag/pattern', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ description })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI 패턴 분석 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
},
|
||||
// RAG: 강화 분류 (과거 사례 참고)
|
||||
classifyWithRAG: async (description, detailNotes = '') => {
|
||||
try {
|
||||
const res = await fetch('/ai-api/rag/classify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ description, detail_notes: detailNotes })
|
||||
});
|
||||
if (!res.ok) return { available: false };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('AI RAG 분류 실패:', e);
|
||||
return { available: false };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 프로젝트 API
|
||||
const ProjectsAPI = {
|
||||
getAll: (activeOnly = false) => {
|
||||
|
||||
@@ -86,6 +86,15 @@ class CommonHeader {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ai_assistant',
|
||||
title: 'AI 어시스턴트',
|
||||
icon: 'fas fa-robot',
|
||||
url: '/ai-assistant.html',
|
||||
pageName: 'ai_assistant',
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'text-purple-600 hover:bg-purple-50'
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ class PagePermissionManager {
|
||||
'issues_inbox': { title: '수신함', defaultAccess: true },
|
||||
'issues_management': { title: '관리함', defaultAccess: false },
|
||||
'issues_archive': { title: '폐기함', defaultAccess: false },
|
||||
'reports': { title: '보고서', defaultAccess: false }
|
||||
'reports': { title: '보고서', defaultAccess: false },
|
||||
'ai_assistant': { title: 'AI 어시스턴트', defaultAccess: false }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
584
system3-nonconformance/web/static/js/pages/ai-assistant.js
Normal file
584
system3-nonconformance/web/static/js/pages/ai-assistant.js
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* ai-assistant.js — AI 어시스턴트 페이지 스크립트
|
||||
*/
|
||||
|
||||
let currentUser = null;
|
||||
let projects = [];
|
||||
let chatHistory = [];
|
||||
|
||||
// 애니메이션 함수들
|
||||
function animateHeaderAppearance() {
|
||||
const header = document.getElementById('commonHeader');
|
||||
if (header) {
|
||||
header.classList.add('header-fade-in');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
async function initializeAiAssistant() {
|
||||
try {
|
||||
currentUser = await window.authManager.checkAuth();
|
||||
if (!currentUser) {
|
||||
document.getElementById('loadingScreen').style.display = 'none';
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
window.pagePermissionManager.setUser(currentUser);
|
||||
await window.pagePermissionManager.loadPagePermissions();
|
||||
|
||||
if (!window.pagePermissionManager.canAccessPage('ai_assistant')) {
|
||||
alert('AI 어시스턴트 접근 권한이 없습니다.');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.commonHeader) {
|
||||
await window.commonHeader.init(currentUser, 'ai_assistant');
|
||||
setTimeout(() => animateHeaderAppearance(), 100);
|
||||
}
|
||||
|
||||
await loadProjects();
|
||||
checkAiHealth();
|
||||
document.getElementById('loadingScreen').style.display = 'none';
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI 어시스턴트 초기화 실패:', error);
|
||||
alert('페이지를 불러오는데 실패했습니다.');
|
||||
document.getElementById('loadingScreen').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// AuthManager 대기 후 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const checkAuthManager = () => {
|
||||
if (window.authManager) {
|
||||
initializeAiAssistant();
|
||||
} else {
|
||||
setTimeout(checkAuthManager, 100);
|
||||
}
|
||||
};
|
||||
checkAuthManager();
|
||||
});
|
||||
|
||||
// 프로젝트 로드
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/projects/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
projects = await response.json();
|
||||
updateProjectFilters();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateProjectFilters() {
|
||||
const selects = ['qaProjectFilter', 'searchProjectFilter'];
|
||||
selects.forEach(id => {
|
||||
const select = document.getElementById(id);
|
||||
if (!select) return;
|
||||
// 기존 옵션 유지 (첫 번째 "전체 프로젝트")
|
||||
while (select.options.length > 1) select.remove(1);
|
||||
projects.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.project_name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── AI 상태 체크 ───────────────────────────────────────
|
||||
async function checkAiHealth() {
|
||||
try {
|
||||
const health = await AiAPI.checkHealth();
|
||||
const statusText = document.getElementById('aiStatusText');
|
||||
const statusIcon = document.getElementById('aiStatusIcon');
|
||||
const embeddingCount = document.getElementById('aiEmbeddingCount');
|
||||
const modelName = document.getElementById('aiModelName');
|
||||
|
||||
if (health.status === 'healthy' || health.status === 'ok') {
|
||||
statusText.textContent = '연결됨';
|
||||
statusText.classList.add('text-green-600');
|
||||
statusIcon.innerHTML = '<i class="fas fa-check-circle text-green-500 text-xl"></i>';
|
||||
statusIcon.className = 'w-10 h-10 rounded-full bg-green-50 flex items-center justify-center';
|
||||
} else {
|
||||
statusText.textContent = '연결 안됨';
|
||||
statusText.classList.add('text-red-500');
|
||||
statusIcon.innerHTML = '<i class="fas fa-times-circle text-red-500 text-xl"></i>';
|
||||
statusIcon.className = 'w-10 h-10 rounded-full bg-red-50 flex items-center justify-center';
|
||||
}
|
||||
|
||||
const embCount = health.embedding_count
|
||||
?? health.total_embeddings
|
||||
?? health.embeddings?.total_documents;
|
||||
if (embCount !== undefined) {
|
||||
embeddingCount.textContent = embCount.toLocaleString() + '건';
|
||||
}
|
||||
|
||||
const model = health.model
|
||||
|| health.llm_model
|
||||
|| (health.ollama?.models?.[0]);
|
||||
if (model) {
|
||||
modelName.textContent = model;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AI 상태 체크 실패:', error);
|
||||
document.getElementById('aiStatusText').textContent = '오류';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Q&A 채팅 ───────────────────────────────────────────
|
||||
function setQuickQuestion(text) {
|
||||
document.getElementById('qaQuestion').value = text;
|
||||
document.getElementById('qaQuestion').focus();
|
||||
}
|
||||
|
||||
async function submitQuestion() {
|
||||
const input = document.getElementById('qaQuestion');
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
|
||||
const projectId = document.getElementById('qaProjectFilter').value || null;
|
||||
|
||||
// 플레이스홀더 제거
|
||||
const placeholder = document.getElementById('chatPlaceholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
// 사용자 메시지 추가
|
||||
appendChatMessage('user', question);
|
||||
input.value = '';
|
||||
chatHistory.push({ role: 'user', content: question });
|
||||
|
||||
// 로딩 표시
|
||||
appendChatLoading();
|
||||
|
||||
try {
|
||||
const result = await AiAPI.askQuestion(question, projectId);
|
||||
removeChatLoading();
|
||||
|
||||
if (result.available === false) {
|
||||
appendChatMessage('ai', 'AI 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = result.answer || result.response || '답변을 생성할 수 없습니다.';
|
||||
const sources = result.sources || result.related_issues || [];
|
||||
appendChatMessage('ai', answer, sources);
|
||||
chatHistory.push({ role: 'ai', content: answer });
|
||||
|
||||
} catch (error) {
|
||||
removeChatLoading();
|
||||
appendChatMessage('ai', '오류가 발생했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function appendChatMessage(role, content, sources) {
|
||||
const container = document.getElementById('chatContainer');
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'} mb-3`;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = `chat-bubble ${role === 'user' ? 'chat-bubble-user' : 'chat-bubble-ai'}`;
|
||||
|
||||
// 내용 렌더링
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = 'text-sm whitespace-pre-line';
|
||||
contentDiv.textContent = content;
|
||||
bubble.appendChild(contentDiv);
|
||||
|
||||
// AI 답변 참고 사례
|
||||
if (role === 'ai' && sources && sources.length > 0) {
|
||||
const sourcesDiv = document.createElement('div');
|
||||
sourcesDiv.className = 'mt-2 pt-2 border-t border-gray-200';
|
||||
|
||||
const sourcesTitle = document.createElement('p');
|
||||
sourcesTitle.className = 'text-xs text-gray-500 mb-1';
|
||||
sourcesTitle.textContent = '참고 사례:';
|
||||
sourcesDiv.appendChild(sourcesTitle);
|
||||
|
||||
sources.forEach(source => {
|
||||
const issueId = source.issue_id || source.id;
|
||||
const desc = source.description || source.title || `이슈 #${issueId}`;
|
||||
const similarity = source.similarity ? ` (${(source.similarity * 100).toFixed(0)}%)` : '';
|
||||
|
||||
const link = document.createElement('span');
|
||||
link.className = 'source-link text-xs block';
|
||||
link.textContent = `#${issueId} ${desc}${similarity}`;
|
||||
link.onclick = () => showAiIssueDetail(issueId);
|
||||
sourcesDiv.appendChild(link);
|
||||
});
|
||||
|
||||
bubble.appendChild(sourcesDiv);
|
||||
}
|
||||
|
||||
wrapper.appendChild(bubble);
|
||||
container.appendChild(wrapper);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function appendChatLoading() {
|
||||
const container = document.getElementById('chatContainer');
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'flex justify-start mb-3';
|
||||
wrapper.id = 'chatLoadingBubble';
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="chat-bubble chat-bubble-ai">
|
||||
<div class="typing-indicator">
|
||||
<div class="typing-dot"></div>
|
||||
<div class="typing-dot"></div>
|
||||
<div class="typing-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(wrapper);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function removeChatLoading() {
|
||||
const el = document.getElementById('chatLoadingBubble');
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
function clearChat() {
|
||||
const container = document.getElementById('chatContainer');
|
||||
container.innerHTML = `
|
||||
<div class="text-center text-gray-400 text-sm py-8" id="chatPlaceholder">
|
||||
<i class="fas fa-robot text-4xl mb-3 text-gray-300"></i>
|
||||
<p>부적합 관련 질문을 입력하세요.</p>
|
||||
<p class="text-xs mt-1">과거 사례를 분석하여 답변합니다.</p>
|
||||
</div>
|
||||
`;
|
||||
chatHistory = [];
|
||||
}
|
||||
|
||||
// ─── 시맨틱 검색 ────────────────────────────────────────
|
||||
async function executeSemanticSearch() {
|
||||
const query = document.getElementById('searchQuery').value.trim();
|
||||
if (!query) return;
|
||||
|
||||
const projectId = document.getElementById('searchProjectFilter').value || undefined;
|
||||
const category = document.getElementById('searchCategoryFilter').value || undefined;
|
||||
const limit = parseInt(document.getElementById('searchResultCount').value);
|
||||
|
||||
const loading = document.getElementById('searchLoading');
|
||||
const resultsDiv = document.getElementById('searchResults');
|
||||
loading.classList.remove('hidden');
|
||||
resultsDiv.innerHTML = '';
|
||||
|
||||
try {
|
||||
const filters = {};
|
||||
if (projectId) filters.project_id = parseInt(projectId);
|
||||
if (category) filters.category = category;
|
||||
|
||||
const result = await AiAPI.searchSimilar(query, limit, filters);
|
||||
loading.classList.add('hidden');
|
||||
|
||||
if (!result.results || result.results.length === 0) {
|
||||
resultsDiv.innerHTML = '<p class="text-sm text-gray-500 text-center py-4">검색 결과가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
result.results.forEach((item, idx) => {
|
||||
const issueId = item.issue_id || item.id;
|
||||
const desc = item.description || '';
|
||||
const similarity = item.similarity ? (item.similarity * 100).toFixed(1) : '-';
|
||||
const category = item.category || '';
|
||||
const status = item.status || item.review_status || '';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'result-item border border-gray-200 rounded-lg p-3 flex items-start gap-3';
|
||||
card.onclick = () => showAiIssueDetail(issueId);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center text-xs font-bold text-indigo-600">
|
||||
${idx + 1}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-mono text-gray-400">#${issueId}</span>
|
||||
${category ? `<span class="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">${category}</span>` : ''}
|
||||
${status ? `<span class="text-xs bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded">${status}</span>` : ''}
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 truncate">${desc}</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 text-right">
|
||||
<span class="text-sm font-bold text-indigo-600">${similarity}%</span>
|
||||
<p class="text-xs text-gray-400">유사도</p>
|
||||
</div>
|
||||
`;
|
||||
resultsDiv.appendChild(card);
|
||||
});
|
||||
} catch (error) {
|
||||
loading.classList.add('hidden');
|
||||
resultsDiv.innerHTML = `<p class="text-sm text-red-500 text-center py-4">검색 중 오류가 발생했습니다.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 패턴 분석 ──────────────────────────────────────────
|
||||
async function executePatternAnalysis() {
|
||||
const input = document.getElementById('patternInput').value.trim();
|
||||
if (!input) return;
|
||||
|
||||
const loading = document.getElementById('patternLoading');
|
||||
const resultsDiv = document.getElementById('patternResults');
|
||||
loading.classList.remove('hidden');
|
||||
resultsDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const result = await AiAPI.analyzePattern(input);
|
||||
loading.classList.add('hidden');
|
||||
resultsDiv.classList.remove('hidden');
|
||||
|
||||
if (result.available === false) {
|
||||
resultsDiv.innerHTML = '<p class="text-sm text-gray-500 text-center py-4">AI 서비스에 연결할 수 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
// 분석 결과
|
||||
const analysis = result.analysis || result.pattern || result.answer || '';
|
||||
if (analysis) {
|
||||
html += `
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-3">
|
||||
<h4 class="text-sm font-semibold text-green-800 mb-2">
|
||||
<i class="fas fa-chart-bar mr-1"></i>분석 결과
|
||||
</h4>
|
||||
<div class="text-sm text-gray-700 whitespace-pre-line">${analysis}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 관련 이슈
|
||||
const relatedIssues = result.related_issues || result.sources || [];
|
||||
if (relatedIssues.length > 0) {
|
||||
html += `
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-2">
|
||||
<i class="fas fa-link mr-1"></i>관련 이슈 (${relatedIssues.length}건)
|
||||
</h4>
|
||||
<div class="space-y-1">
|
||||
${relatedIssues.map(issue => {
|
||||
const id = issue.issue_id || issue.id;
|
||||
const desc = issue.description || '';
|
||||
return `<div class="result-item text-sm p-2 rounded border border-gray-100" onclick="showAiIssueDetail(${id})">
|
||||
<span class="font-mono text-gray-400 text-xs">#${id}</span>
|
||||
<span class="text-gray-700">${desc}</span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = html || '<p class="text-sm text-gray-500 text-center py-4">분석 결과가 없습니다.</p>';
|
||||
} catch (error) {
|
||||
loading.classList.add('hidden');
|
||||
resultsDiv.classList.remove('hidden');
|
||||
resultsDiv.innerHTML = `<p class="text-sm text-red-500 text-center py-4">분석 중 오류가 발생했습니다.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AI 분류 ────────────────────────────────────────────
|
||||
async function executeClassification(useRAG) {
|
||||
const description = document.getElementById('classifyDescription').value.trim();
|
||||
if (!description) {
|
||||
alert('부적합 설명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const detailNotes = document.getElementById('classifyDetail').value.trim();
|
||||
const loading = document.getElementById('classifyLoading');
|
||||
const resultsDiv = document.getElementById('classifyResults');
|
||||
loading.classList.remove('hidden');
|
||||
resultsDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const result = useRAG
|
||||
? await AiAPI.classifyWithRAG(description, detailNotes)
|
||||
: await AiAPI.classifyIssue(description, detailNotes);
|
||||
|
||||
loading.classList.add('hidden');
|
||||
resultsDiv.classList.remove('hidden');
|
||||
|
||||
if (result.available === false) {
|
||||
resultsDiv.innerHTML = '<p class="text-sm text-gray-500 text-center py-4">AI 서비스에 연결할 수 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const methodLabel = useRAG ? 'RAG 분류 (과거 사례 참고)' : '기본 분류';
|
||||
const methodColor = useRAG ? 'purple' : 'amber';
|
||||
|
||||
let html = `
|
||||
<div class="bg-${methodColor}-50 border border-${methodColor}-200 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-${methodColor}-800 mb-3">
|
||||
<i class="fas fa-${useRAG ? 'tags' : 'tag'} mr-1"></i>${methodLabel}
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
`;
|
||||
|
||||
// 분류 결과 필드 표시
|
||||
const fields = [
|
||||
{ key: 'category', label: '카테고리' },
|
||||
{ key: 'discipline', label: '공종' },
|
||||
{ key: 'severity', label: '심각도' },
|
||||
{ key: 'root_cause', label: '근본 원인' },
|
||||
{ key: 'priority', label: '우선순위' },
|
||||
{ key: 'suggested_action', label: '권장 조치' }
|
||||
];
|
||||
|
||||
fields.forEach(field => {
|
||||
const val = result[field.key] || result.classification?.[field.key];
|
||||
if (val) {
|
||||
html += `
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">${field.label}</span>
|
||||
<p class="text-sm font-medium text-gray-800">${val}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// 신뢰도
|
||||
const confidence = result.confidence || result.classification?.confidence;
|
||||
if (confidence) {
|
||||
const pct = (typeof confidence === 'number' && confidence <= 1)
|
||||
? (confidence * 100).toFixed(0)
|
||||
: confidence;
|
||||
html += `
|
||||
<div class="mt-3 pt-3 border-t border-${methodColor}-200">
|
||||
<span class="text-xs text-gray-500">신뢰도</span>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-${methodColor}-500 h-2 rounded-full" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-${methodColor}-600">${pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// RAG 참고 사례
|
||||
const sources = result.sources || result.related_issues || result.similar_cases || [];
|
||||
if (sources.length > 0) {
|
||||
html += `
|
||||
<div class="mt-3 pt-3 border-t border-${methodColor}-200">
|
||||
<span class="text-xs text-gray-500">참고 사례</span>
|
||||
<div class="mt-1 space-y-1">
|
||||
${sources.map(s => {
|
||||
const id = s.issue_id || s.id;
|
||||
const desc = s.description || '';
|
||||
return `<div class="result-item text-xs p-1.5 rounded border border-gray-100" onclick="showAiIssueDetail(${id})">
|
||||
<span class="font-mono text-gray-400">#${id}</span> ${desc}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
resultsDiv.innerHTML = html;
|
||||
} catch (error) {
|
||||
loading.classList.add('hidden');
|
||||
resultsDiv.classList.remove('hidden');
|
||||
resultsDiv.innerHTML = `<p class="text-sm text-red-500 text-center py-4">분류 중 오류가 발생했습니다.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 이슈 상세 모달 ─────────────────────────────────────
|
||||
async function showAiIssueDetail(issueId) {
|
||||
const modal = document.getElementById('aiIssueModal');
|
||||
const title = document.getElementById('aiIssueModalTitle');
|
||||
const body = document.getElementById('aiIssueModalBody');
|
||||
|
||||
title.textContent = `이슈 #${issueId}`;
|
||||
body.innerHTML = '<div class="text-center py-8"><i class="fas fa-spinner fa-spin text-purple-500 text-xl"></i><p class="text-sm text-gray-500 mt-2">불러오는 중...</p></div>';
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || '/api';
|
||||
const response = await fetch(`${apiUrl}/issues/${issueId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('이슈를 불러올 수 없습니다.');
|
||||
const issue = await response.json();
|
||||
|
||||
const statusMap = {
|
||||
'pending': '대기',
|
||||
'in_progress': '진행 중',
|
||||
'completed': '완료',
|
||||
'rejected': '반려'
|
||||
};
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">프로젝트</span>
|
||||
<p class="font-medium">${issue.project_name || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">설명</span>
|
||||
<p class="font-medium">${issue.description || '-'}</p>
|
||||
</div>
|
||||
${issue.detail_notes ? `<div>
|
||||
<span class="text-xs text-gray-500">상세 내용</span>
|
||||
<p class="whitespace-pre-line">${issue.detail_notes}</p>
|
||||
</div>` : ''}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">카테고리</span>
|
||||
<p>${issue.category || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">상태</span>
|
||||
<p>${statusMap[issue.review_status] || issue.review_status || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">위치</span>
|
||||
<p>${issue.location || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">담당자</span>
|
||||
<p>${issue.assigned_to_name || issue.assignee_name || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
${issue.resolution ? `<div>
|
||||
<span class="text-xs text-gray-500">해결 방안</span>
|
||||
<p class="whitespace-pre-line">${issue.resolution}</p>
|
||||
</div>` : ''}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">등록일</span>
|
||||
<p>${issue.created_at ? new Date(issue.created_at).toLocaleDateString('ko-KR') : '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
body.innerHTML = `<p class="text-sm text-red-500 text-center py-4">이슈를 불러오는데 실패했습니다.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 엔트리 포인트
|
||||
function initializeAiAssistantApp() {
|
||||
console.log('AI 어시스턴트 스크립트 로드 완료');
|
||||
}
|
||||
initializeAiAssistantApp();
|
||||
@@ -1786,8 +1786,130 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// API 스크립트 동적 로드
|
||||
const script = document.createElement('script');
|
||||
script.src = '/static/js/api.js?v=20260213';
|
||||
script.onload = initializeDashboardApp;
|
||||
document.body.appendChild(script);
|
||||
// AI 시맨틱 검색
|
||||
async function aiSemanticSearch() {
|
||||
const query = document.getElementById('aiSearchQuery')?.value?.trim();
|
||||
if (!query || typeof AiAPI === 'undefined') return;
|
||||
|
||||
const loading = document.getElementById('aiSearchLoading');
|
||||
const results = document.getElementById('aiSearchResults');
|
||||
if (loading) loading.classList.remove('hidden');
|
||||
if (results) { results.classList.add('hidden'); results.innerHTML = ''; }
|
||||
|
||||
const data = await AiAPI.searchSimilar(query, 8);
|
||||
if (loading) loading.classList.add('hidden');
|
||||
|
||||
if (!data.available || !data.results || data.results.length === 0) {
|
||||
results.innerHTML = '<p class="text-sm text-gray-400 text-center py-2">검색 결과가 없습니다</p>';
|
||||
results.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
results.innerHTML = data.results.map(r => {
|
||||
const meta = r.metadata || {};
|
||||
const similarity = Math.round((r.similarity || 0) * 100);
|
||||
const issueId = meta.issue_id || r.id.replace('issue_', '');
|
||||
const doc = (r.document || '').substring(0, 100);
|
||||
const cat = meta.category || '';
|
||||
const status = meta.review_status || '';
|
||||
return `
|
||||
<div class="flex items-start space-x-3 bg-gray-50 rounded-lg p-3 hover:bg-purple-50 transition-colors cursor-pointer"
|
||||
onclick="showAiIssueModal(${issueId})"
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center">
|
||||
<span class="text-xs font-bold text-purple-700">${similarity}%</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<span class="text-sm font-medium text-gray-800">No.${issueId}</span>
|
||||
${cat ? `<span class="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700">${cat}</span>` : ''}
|
||||
${status ? `<span class="text-xs text-gray-400">${status}</span>` : ''}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 truncate">${doc}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
results.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// RAG Q&A
|
||||
async function aiAskQuestion() {
|
||||
const question = document.getElementById('aiQaQuestion')?.value?.trim();
|
||||
if (!question || typeof AiAPI === 'undefined') return;
|
||||
|
||||
const loading = document.getElementById('aiQaLoading');
|
||||
const result = document.getElementById('aiQaResult');
|
||||
const answer = document.getElementById('aiQaAnswer');
|
||||
const sources = document.getElementById('aiQaSources');
|
||||
|
||||
if (loading) loading.classList.remove('hidden');
|
||||
if (result) result.classList.add('hidden');
|
||||
|
||||
const projectId = document.getElementById('projectFilter')?.value || null;
|
||||
const data = await AiAPI.askQuestion(question, projectId ? parseInt(projectId) : null);
|
||||
|
||||
if (loading) loading.classList.add('hidden');
|
||||
|
||||
if (!data.available) {
|
||||
if (answer) answer.textContent = 'AI 서비스를 사용할 수 없습니다';
|
||||
if (result) result.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (answer) answer.textContent = data.answer || '';
|
||||
if (sources && data.sources) {
|
||||
const refs = data.sources.slice(0, 5).map(s =>
|
||||
`No.${s.id}(${s.similarity}%)`
|
||||
).join(', ');
|
||||
sources.textContent = refs ? `참고: ${refs}` : '';
|
||||
}
|
||||
if (result) result.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// AI 이슈 상세 모달
|
||||
async function showAiIssueModal(issueId) {
|
||||
const modal = document.getElementById('aiIssueModal');
|
||||
const title = document.getElementById('aiIssueModalTitle');
|
||||
const body = document.getElementById('aiIssueModalBody');
|
||||
if (!modal || !body) return;
|
||||
|
||||
title.textContent = `부적합 No.${issueId}`;
|
||||
body.innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin text-purple-500"></i> 로딩 중...</div>';
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const token = typeof TokenManager !== 'undefined' ? TokenManager.getToken() : null;
|
||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
const res = await fetch(`/api/issues/${issueId}`, { headers });
|
||||
if (!res.ok) throw new Error('fetch failed');
|
||||
const issue = await res.json();
|
||||
|
||||
const categoryText = typeof getCategoryText === 'function' ? getCategoryText(issue.category || issue.final_category) : (issue.category || issue.final_category || '-');
|
||||
const statusText = typeof getStatusText === 'function' ? getStatusText(issue.review_status) : (issue.review_status || '-');
|
||||
const deptText = typeof getDepartmentText === 'function' ? getDepartmentText(issue.responsible_department) : (issue.responsible_department || '-');
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-700">${categoryText}</span>
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">${statusText}</span>
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700">${deptText}</span>
|
||||
${issue.report_date ? `<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-600">${issue.report_date}</span>` : ''}
|
||||
</div>
|
||||
${issue.description ? `<div><strong class="text-gray-600">설명:</strong><p class="mt-1 whitespace-pre-line">${issue.description}</p></div>` : ''}
|
||||
${issue.detail_notes ? `<div><strong class="text-gray-600">상세:</strong><p class="mt-1 whitespace-pre-line">${issue.detail_notes}</p></div>` : ''}
|
||||
${issue.final_description ? `<div><strong class="text-gray-600">최종 판정:</strong><p class="mt-1 whitespace-pre-line">${issue.final_description}</p></div>` : ''}
|
||||
${issue.solution ? `<div><strong class="text-gray-600">해결방안:</strong><p class="mt-1 whitespace-pre-line">${issue.solution}</p></div>` : ''}
|
||||
${issue.cause_detail ? `<div><strong class="text-gray-600">원인:</strong><p class="mt-1 whitespace-pre-line">${issue.cause_detail}</p></div>` : ''}
|
||||
${issue.management_comment ? `<div><strong class="text-gray-600">관리 의견:</strong><p class="mt-1 whitespace-pre-line">${issue.management_comment}</p></div>` : ''}
|
||||
<div class="pt-3 border-t text-right">
|
||||
<a href="/issues-management.html#issue-${issueId}" class="text-xs text-purple-500 hover:underline">관리함에서 보기 →</a>
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
body.innerHTML = `<p class="text-red-500">이슈를 불러올 수 없습니다</p>
|
||||
<a href="/issues-management.html#issue-${issueId}" class="text-xs text-purple-500 hover:underline">관리함에서 보기 →</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 초기화
|
||||
initializeDashboardApp();
|
||||
|
||||
@@ -879,14 +879,81 @@ function showError(message) {
|
||||
alert(message);
|
||||
}
|
||||
|
||||
// API 스크립트 동적 로딩
|
||||
const script = document.createElement('script');
|
||||
script.src = '/static/js/api.js?v=20260213';
|
||||
script.onload = function() {
|
||||
console.log('API 스크립트 로드 완료 (issues-inbox.html)');
|
||||
// AI 분류 추천
|
||||
async function aiClassifyCurrentIssue() {
|
||||
if (!currentIssueId || typeof AiAPI === 'undefined') return;
|
||||
const issue = issues.find(i => i.id === currentIssueId);
|
||||
if (!issue) return;
|
||||
|
||||
const btn = document.getElementById('aiClassifyBtn');
|
||||
const loading = document.getElementById('aiClassifyLoading');
|
||||
const result = document.getElementById('aiClassifyResult');
|
||||
if (btn) btn.disabled = true;
|
||||
if (loading) loading.classList.remove('hidden');
|
||||
if (result) result.classList.add('hidden');
|
||||
|
||||
// RAG 강화 분류 사용 (과거 사례 참고)
|
||||
const classifyFn = AiAPI.classifyWithRAG || AiAPI.classifyIssue;
|
||||
const data = await classifyFn(
|
||||
issue.description || issue.final_description || '',
|
||||
issue.detail_notes || ''
|
||||
);
|
||||
|
||||
if (loading) loading.classList.add('hidden');
|
||||
if (btn) btn.disabled = false;
|
||||
|
||||
if (!data.available) {
|
||||
if (result) {
|
||||
result.innerHTML = '<p class="text-xs text-red-500">AI 서비스를 사용할 수 없습니다</p>';
|
||||
result.classList.remove('hidden');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryMap = {
|
||||
'material_missing': '자재 누락',
|
||||
'design_error': '설계 오류',
|
||||
'incoming_defect': '반입 불량',
|
||||
'inspection_miss': '검사 누락',
|
||||
};
|
||||
const deptMap = {
|
||||
'production': '생산',
|
||||
'quality': '품질',
|
||||
'purchasing': '구매',
|
||||
'design': '설계',
|
||||
'sales': '영업',
|
||||
};
|
||||
|
||||
const cat = data.category || '';
|
||||
const dept = data.responsible_department || '';
|
||||
const severity = data.severity || '';
|
||||
const summary = data.summary || '';
|
||||
const confidence = data.category_confidence ? Math.round(data.category_confidence * 100) : '';
|
||||
|
||||
result.innerHTML = `
|
||||
<div class="space-y-1">
|
||||
<p><strong>분류:</strong> ${categoryMap[cat] || cat} ${confidence ? `(${confidence}%)` : ''}</p>
|
||||
<p><strong>부서:</strong> ${deptMap[dept] || dept}</p>
|
||||
<p><strong>심각도:</strong> ${severity}</p>
|
||||
${summary ? `<p><strong>요약:</strong> ${summary}</p>` : ''}
|
||||
<button onclick="applyAiClassification('${cat}')"
|
||||
class="mt-2 px-3 py-1 bg-purple-600 text-white text-xs rounded hover:bg-purple-700">
|
||||
<i class="fas fa-check mr-1"></i>적용
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
result.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function applyAiClassification(category) {
|
||||
const reviewCategory = document.getElementById('reviewCategory');
|
||||
if (reviewCategory && category) {
|
||||
reviewCategory.value = category;
|
||||
}
|
||||
if (window.showToast) {
|
||||
window.showToast('AI 추천이 적용되었습니다', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// 초기화 (api.js는 HTML에서 로드됨)
|
||||
initializeInbox();
|
||||
};
|
||||
script.onerror = function() {
|
||||
console.error('API 스크립트 로드 실패');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
|
||||
@@ -930,13 +930,100 @@ async function openIssueDetailModal(issueId) {
|
||||
|
||||
// 모달 표시
|
||||
document.getElementById('issueDetailModal').classList.remove('hidden');
|
||||
|
||||
// AI 유사 부적합 자동 로드
|
||||
const aiPanel = document.getElementById('aiSimilarPanel');
|
||||
if (aiPanel) {
|
||||
aiPanel.classList.remove('hidden');
|
||||
loadSimilarIssues();
|
||||
}
|
||||
}
|
||||
|
||||
function closeIssueDetailModal() {
|
||||
document.getElementById('issueDetailModal').classList.add('hidden');
|
||||
const aiPanel = document.getElementById('aiSimilarPanel');
|
||||
if (aiPanel) aiPanel.classList.add('hidden');
|
||||
// RAG 결과 초기화
|
||||
const suggestResult = document.getElementById('aiSuggestResult');
|
||||
if (suggestResult) suggestResult.classList.add('hidden');
|
||||
currentModalIssueId = null;
|
||||
}
|
||||
|
||||
// RAG: AI 해결방안 제안
|
||||
async function aiSuggestSolution() {
|
||||
if (!currentModalIssueId || typeof AiAPI === 'undefined') return;
|
||||
const btn = document.getElementById('aiSuggestSolutionBtn');
|
||||
const loading = document.getElementById('aiSuggestLoading');
|
||||
const result = document.getElementById('aiSuggestResult');
|
||||
const content = document.getElementById('aiSuggestContent');
|
||||
const sources = document.getElementById('aiSuggestSources');
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
if (loading) loading.classList.remove('hidden');
|
||||
if (result) result.classList.add('hidden');
|
||||
|
||||
const data = await AiAPI.suggestSolution(currentModalIssueId);
|
||||
|
||||
if (loading) loading.classList.add('hidden');
|
||||
if (btn) btn.disabled = false;
|
||||
|
||||
if (!data.available) {
|
||||
if (content) content.textContent = 'AI 서비스를 사용할 수 없습니다';
|
||||
if (result) result.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (content) content.textContent = data.suggestion || '';
|
||||
if (sources && data.referenced_issues) {
|
||||
const refs = data.referenced_issues
|
||||
.filter(r => r.has_solution)
|
||||
.map(r => `No.${r.id}(${r.similarity}%)`)
|
||||
.join(', ');
|
||||
sources.textContent = refs ? `참고 사례: ${refs}` : '';
|
||||
}
|
||||
if (result) result.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// AI 유사 부적합 검색
|
||||
async function loadSimilarIssues() {
|
||||
if (!currentModalIssueId || typeof AiAPI === 'undefined') return;
|
||||
const loading = document.getElementById('aiSimilarLoading');
|
||||
const results = document.getElementById('aiSimilarResults');
|
||||
const empty = document.getElementById('aiSimilarEmpty');
|
||||
if (loading) loading.classList.remove('hidden');
|
||||
if (results) results.innerHTML = '';
|
||||
if (empty) empty.classList.add('hidden');
|
||||
|
||||
const data = await AiAPI.getSimilarIssues(currentModalIssueId, 5);
|
||||
if (loading) loading.classList.add('hidden');
|
||||
|
||||
if (!data.available || !data.results || data.results.length === 0) {
|
||||
if (empty) empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
results.innerHTML = data.results.map(r => {
|
||||
const meta = r.metadata || {};
|
||||
const similarity = Math.round((r.similarity || 0) * 100);
|
||||
const issueId = meta.issue_id || r.id.replace('issue_', '');
|
||||
const doc = (r.document || '').substring(0, 80);
|
||||
const cat = meta.category || '';
|
||||
return `
|
||||
<div class="bg-purple-50 border border-purple-100 rounded-lg p-3 cursor-pointer hover:bg-purple-100 transition-colors"
|
||||
onclick="openIssueDetailModal(${issueId})"
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-medium text-purple-700">No.${issueId}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full ${similarity >= 70 ? 'bg-purple-200 text-purple-800' : 'bg-gray-200 text-gray-600'}">
|
||||
${similarity}% 유사
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 line-clamp-2">${doc}...</p>
|
||||
${cat ? `<span class="text-xs text-purple-500 mt-1 inline-block">${cat}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function createModalContent(issue, project) {
|
||||
return `
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -1186,17 +1273,8 @@ function getPriorityBadge(priority) {
|
||||
return `<span class="badge ${p.class}">${p.text}</span>`;
|
||||
}
|
||||
|
||||
// API 스크립트 동적 로딩
|
||||
const script = document.createElement('script');
|
||||
script.src = '/static/js/api.js?v=20260213';
|
||||
script.onload = function() {
|
||||
console.log('✅ API 스크립트 로드 완료 (issues-management.js)');
|
||||
// 초기화 (api.js는 HTML에서 로드됨)
|
||||
initializeManagement();
|
||||
};
|
||||
script.onerror = function() {
|
||||
console.error('❌ API 스크립트 로드 실패');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
|
||||
// 추가 정보 모달 관련 함수들
|
||||
let selectedIssueId = null;
|
||||
|
||||
Reference in New Issue
Block a user