You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1032 lines
40 KiB
Python
1032 lines
40 KiB
Python
import uuid
|
|
import json as json_module
|
|
from typing import Optional, List
|
|
from fastapi import APIRouter, HTTPException, status, Query, File, UploadFile, Request
|
|
from fastapi.responses import StreamingResponse
|
|
from src.ircfa_interaction.schema import ChatResponse, Conversation, ConversationUpdate, ConversationDataPagination, UpdateFeedback, RootCauseRequest, RootCauseResponse, ConversationExportUpdate, ChatRequest, FlowType, ChatHistory, ChatHistoryResponse, ModelRead, AdminModelRead, ModelConfigCreate, ModelConfigUpdate, MemoryLabSnapshot, MemoryRead
|
|
from src.ircfa_interaction.tools import RootCauseSPLNTool
|
|
from src.ircfa_interaction.model import ClassifierConfig, Conversation as ConversationModel, RCFAMemory
|
|
from src.rag.service import get_rag_histories_by_conversation_id, create as rag_create
|
|
from src.ircfa_interaction.service import create, get_conversation, continue_conversation, delete_conversation, update_conversation_title, get_conversation_by_id, get_chat_by_id, update_chat_history_feedback, delete_chat_by_id, update_conversation_export_status, get_all_models_service, toggle_model_service, set_default_model_service, create_model_service, update_model_service, delete_model_service, get_feedback_analytics
|
|
from src.database.service import CommonParameters, search_filter_sort_paginate
|
|
from src.database.core import DbSession, CollectorDbSession
|
|
from src.auth.service import CurrentUser, Token
|
|
from src.ircfa_interaction.config import MODELS_DATA, MAX_FILE_SIZE
|
|
from src.ircfa_interaction.stream import stream_create
|
|
from src.ircfa_interaction.stream2 import stream_create as stream_create_v2
|
|
from src.models import StandardResponse
|
|
from src.rag.lib.prompt_template_list import searchRAGKeywords
|
|
from uuid import UUID
|
|
from pydantic import Field, BaseModel
|
|
from sqlalchemy import func, select
|
|
|
|
router = APIRouter()
|
|
|
|
class ToolInvocation(BaseModel):
|
|
toolCallId: str
|
|
toolName: str
|
|
args: dict
|
|
result: dict
|
|
class ClientAttachment(BaseModel):
|
|
name: str
|
|
contentType: str
|
|
url: str
|
|
class ClientMessage(BaseModel):
|
|
role: str
|
|
content: str
|
|
experimental_attachments: Optional[List[ClientAttachment]] = None
|
|
toolInvocations: Optional[List[ToolInvocation]] = None
|
|
|
|
|
|
def _memory_to_read(memory: RCFAMemory) -> MemoryRead:
|
|
"""Expose only safe, user-owned fields to the Memory Lab."""
|
|
return MemoryRead(
|
|
id=memory.id,
|
|
conversation_id=memory.conversation_id,
|
|
source_type=memory.source_type,
|
|
title=memory.title,
|
|
summary=memory.summary,
|
|
excerpt=memory.excerpt,
|
|
location_tag=memory.location_tag,
|
|
equipment=memory.equipment,
|
|
confidence=memory.confidence,
|
|
created_at=memory.created_at,
|
|
)
|
|
|
|
def data_uri_to_upload_file(file_dict):
|
|
from io import BytesIO
|
|
import base64
|
|
import re
|
|
import os
|
|
import json
|
|
from fastapi import HTTPException
|
|
|
|
# 1. Sanitize filename and check extension
|
|
filename = os.path.basename(file_dict.get('filename', ''))
|
|
ext = os.path.splitext(filename)[1].lower()
|
|
allowed_extensions = {".jpg", ".jpeg", ".png", ".pdf", ".json"}
|
|
if ext not in allowed_extensions:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Format file '{ext}' tidak diizinkan. Hanya menerima .jpg, .jpeg, .png, .pdf, .json"
|
|
)
|
|
|
|
match = re.match(r'data:(.*?);base64,(.*)', file_dict['url'])
|
|
if not match:
|
|
raise HTTPException(status_code=400, detail="Data URI tidak valid")
|
|
|
|
content_type, b64_data = match.groups()
|
|
try:
|
|
file_bytes = base64.b64decode(b64_data)
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="Gagal mendecode data Base64")
|
|
|
|
# 2. Limit size to MAX_FILE_SIZE (100MB)
|
|
MAX_SIZE = MAX_FILE_SIZE
|
|
if len(file_bytes) > MAX_SIZE:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Ukuran file '{filename}' melebihi batas 100MB"
|
|
)
|
|
|
|
# 3. Magic bytes validation
|
|
is_valid = False
|
|
if ext in [".jpg", ".jpeg"]:
|
|
is_valid = file_bytes.startswith(b"\xff\xd8\xff")
|
|
elif ext == ".png":
|
|
is_valid = file_bytes.startswith(b"\x89PNG\r\n\x1a\n")
|
|
elif ext == ".pdf":
|
|
is_valid = file_bytes.startswith(b"%PDF-")
|
|
elif ext == ".json":
|
|
try:
|
|
json.loads(file_bytes.decode("utf-8"))
|
|
is_valid = True
|
|
except Exception:
|
|
pass
|
|
|
|
if not is_valid:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Verifikasi konten file '{filename}' gagal. Format tidak sesuai dengan ekstensi '{ext}'."
|
|
)
|
|
|
|
file_obj = BytesIO(file_bytes)
|
|
return UploadFile(
|
|
file=file_obj,
|
|
filename=filename,
|
|
size=len(file_bytes),
|
|
headers={"content-type": content_type}
|
|
)
|
|
|
|
|
|
def get_pre_uploaded_file(conversation_id: str, file_dict: dict) -> UploadFile:
|
|
import os
|
|
from io import BytesIO
|
|
from fastapi import HTTPException
|
|
|
|
url = file_dict.get('url', '')
|
|
filename = os.path.basename(file_dict.get('filename', ''))
|
|
|
|
# Enforce strict path structure: src/uploads/{conversation_id}/{filename}
|
|
expected_path = f"src/uploads/{conversation_id}/{filename}"
|
|
if url != expected_path:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Jalur file '{url}' tidak valid atau tidak cocok dengan sesi percakapan."
|
|
)
|
|
|
|
# Check if the file actually exists
|
|
if not os.path.exists(url):
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"File '{filename}' tidak ditemukan di server."
|
|
)
|
|
|
|
# Check that extension is in allowed extensions
|
|
ext = os.path.splitext(filename)[1].lower()
|
|
allowed_extensions = {".jpg", ".jpeg", ".png", ".pdf", ".json"}
|
|
if ext not in allowed_extensions:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Format file '{ext}' tidak diizinkan."
|
|
)
|
|
|
|
# Return UploadFile instance so stream2.py can read from it
|
|
with open(url, "rb") as f:
|
|
content = f.read()
|
|
|
|
file_obj = BytesIO(content)
|
|
uf = UploadFile(
|
|
file=file_obj,
|
|
filename=filename,
|
|
size=len(content),
|
|
headers={"content-type": file_dict.get("mediaType", "application/octet-stream")}
|
|
)
|
|
uf.url = url
|
|
return uf
|
|
|
|
|
|
|
|
# Endpoint to handle available models requests route => /chat/models
|
|
@router.get("/models", response_model=StandardResponse[List[ModelRead]], summary="Get available models")
|
|
async def get_available_models(current_user: CurrentUser):
|
|
models = [
|
|
ModelRead(
|
|
name=m.name,
|
|
display_name=m.display_name,
|
|
description=m.description,
|
|
provider=m.provider
|
|
) for m in MODELS_DATA if m.enabled
|
|
]
|
|
return StandardResponse(data=models, message="Models retrieved successfully")
|
|
|
|
# Admin endpoints for LLM models configuration management
|
|
@router.get("/admin/models", response_model=StandardResponse[List[AdminModelRead]], summary="Get all models config (Admin)")
|
|
async def get_all_models_admin(db_session: DbSession, current_user: CurrentUser):
|
|
if current_user.role not in ("Admin", "Super Admin", "Application Administrator"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You do not have permission to access this resource."
|
|
)
|
|
|
|
db_models = await get_all_models_service(db_session)
|
|
models = [
|
|
AdminModelRead(
|
|
name=m.name,
|
|
display_name=m.display_name,
|
|
model=m.model,
|
|
model_server=m.model_server,
|
|
api_key=m.api_key,
|
|
provider=m.provider,
|
|
description=m.description,
|
|
stream=m.stream,
|
|
enabled=m.enabled,
|
|
is_default=m.is_default,
|
|
generate_cfg=m.generate_cfg
|
|
) for m in db_models
|
|
]
|
|
return StandardResponse(data=models, message="All models configuration retrieved successfully")
|
|
|
|
@router.put("/admin/models/{name}/toggle", response_model=StandardResponse[AdminModelRead], summary="Toggle model configuration enabled status (Admin)")
|
|
async def toggle_model_admin(name: str, enabled: bool, db_session: DbSession, current_user: CurrentUser):
|
|
if current_user.role not in ("Admin", "Super Admin", "Application Administrator"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You do not have permission to access this resource."
|
|
)
|
|
|
|
m = await toggle_model_service(db_session, name, enabled, current_user)
|
|
response_data = AdminModelRead(
|
|
name=m.name,
|
|
display_name=m.display_name,
|
|
model=m.model,
|
|
model_server=m.model_server,
|
|
api_key=m.api_key,
|
|
provider=m.provider,
|
|
description=m.description,
|
|
stream=m.stream,
|
|
enabled=m.enabled,
|
|
is_default=m.is_default,
|
|
generate_cfg=m.generate_cfg
|
|
)
|
|
return StandardResponse(data=response_data, message=f"Model '{name}' enabled status toggled to {enabled} successfully")
|
|
|
|
@router.put("/admin/models/{name}/default", response_model=StandardResponse[AdminModelRead], summary="Set model as default (Admin)")
|
|
async def set_default_model_admin(name: str, db_session: DbSession, current_user: CurrentUser):
|
|
if current_user.role not in ("Admin", "Super Admin", "Application Administrator"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You do not have permission to access this resource."
|
|
)
|
|
|
|
m = await set_default_model_service(db_session, name, current_user)
|
|
response_data = AdminModelRead(
|
|
name=m.name,
|
|
display_name=m.display_name,
|
|
model=m.model,
|
|
model_server=m.model_server,
|
|
api_key=m.api_key,
|
|
provider=m.provider,
|
|
description=m.description,
|
|
stream=m.stream,
|
|
enabled=m.enabled,
|
|
is_default=m.is_default,
|
|
generate_cfg=m.generate_cfg
|
|
)
|
|
return StandardResponse(data=response_data, message=f"Model '{name}' set as the default model successfully")
|
|
|
|
|
|
@router.post("/admin/models", response_model=StandardResponse[AdminModelRead], summary="Create new model configuration (Admin)")
|
|
async def create_model_admin(data: ModelConfigCreate, db_session: DbSession, current_user: CurrentUser):
|
|
if current_user.role not in ("Admin", "Super Admin", "Application Administrator"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You do not have permission to access this resource."
|
|
)
|
|
|
|
m = await create_model_service(db_session, data, current_user)
|
|
response_data = AdminModelRead(
|
|
name=m.name,
|
|
display_name=m.display_name,
|
|
model=m.model,
|
|
model_server=m.model_server,
|
|
api_key=m.api_key,
|
|
provider=m.provider,
|
|
description=m.description,
|
|
stream=m.stream,
|
|
enabled=m.enabled,
|
|
is_default=m.is_default,
|
|
generate_cfg=m.generate_cfg
|
|
)
|
|
return StandardResponse(data=response_data, message=f"Model config '{m.name}' created successfully")
|
|
|
|
|
|
@router.put("/admin/models/{name}", response_model=StandardResponse[AdminModelRead], summary="Update model configuration (Admin)")
|
|
async def update_model_admin(name: str, data: ModelConfigUpdate, db_session: DbSession, current_user: CurrentUser):
|
|
if current_user.role not in ("Admin", "Super Admin", "Application Administrator"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You do not have permission to access this resource."
|
|
)
|
|
|
|
m = await update_model_service(db_session, name, data, current_user)
|
|
response_data = AdminModelRead(
|
|
name=m.name,
|
|
display_name=m.display_name,
|
|
model=m.model,
|
|
model_server=m.model_server,
|
|
api_key=m.api_key,
|
|
provider=m.provider,
|
|
description=m.description,
|
|
stream=m.stream,
|
|
enabled=m.enabled,
|
|
is_default=m.is_default,
|
|
generate_cfg=m.generate_cfg
|
|
)
|
|
return StandardResponse(data=response_data, message=f"Model config '{name}' updated successfully")
|
|
|
|
|
|
@router.delete("/admin/models/{name}", response_model=StandardResponse[dict], summary="Delete model configuration (Admin)")
|
|
async def delete_model_admin(name: str, db_session: DbSession, current_user: CurrentUser):
|
|
if current_user.role not in ("Admin", "Super Admin", "Application Administrator"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You do not have permission to access this resource."
|
|
)
|
|
|
|
res = await delete_model_service(db_session, name, current_user)
|
|
return StandardResponse(data=res, message=f"Model config '{name}' deleted successfully")
|
|
|
|
|
|
|
|
|
|
# class Request(BaseModel):
|
|
# messages: List[ClientMessage]
|
|
|
|
|
|
|
|
# Endpoint to handle delete conversation requests route => /chat/delete/{conversation_id}
|
|
@router.api_route("/delete/{conversation_id}", methods=["GET", "POST"], response_model=StandardResponse[str], summary="Delete conversation")
|
|
async def remove_conversation(
|
|
conversation_id: UUID,
|
|
db_session: DbSession,
|
|
current_user: CurrentUser
|
|
):
|
|
await delete_conversation(db_session=db_session, conversation_id=str(conversation_id), current_user=current_user)
|
|
return StandardResponse(data=str(conversation_id), message="Conversation deleted successfully")
|
|
|
|
@router.api_route("/delete/{conversation_id}/{chat_history_id}", methods=["GET", "POST"], response_model=StandardResponse[str], summary="Delete chat history")
|
|
async def remove_chat_history(
|
|
conversation_id: UUID,
|
|
chat_history_id: UUID,
|
|
db_session: DbSession,
|
|
current_user: CurrentUser
|
|
):
|
|
chat_history_data = await get_chat_by_id(db_session=db_session, chat_history_id=chat_history_id, conversation_id=conversation_id, current_user=current_user)
|
|
if not chat_history_data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="A data with this id does not exist.",
|
|
)
|
|
deleted_chat = await delete_chat_by_id(db_session=db_session, chat_history_id=chat_history_id, conversation_id=conversation_id, current_user=current_user)
|
|
return StandardResponse(data=str(deleted_chat), message="Chat history deleted successfully")
|
|
|
|
# Endpoint to handle convesations lists requests route => /chat
|
|
@router.get("", response_model=StandardResponse[ConversationDataPagination])
|
|
async def get_conversations(
|
|
db_session: DbSession,
|
|
current_user: CurrentUser,
|
|
common: CommonParameters,
|
|
search: Optional[str] = Query(None),
|
|
page: Optional[int] = Query(1),
|
|
items_per_page: Optional[int] = Query(100),
|
|
is_exported: Optional[bool] = Query(None),
|
|
start_date: Optional[str] = Query(None),
|
|
end_date: Optional[str] = Query(None),
|
|
):
|
|
conversations = await get_conversation(
|
|
db_session=db_session,
|
|
current_user=current_user,
|
|
search=search,
|
|
page=page,
|
|
items_per_page=items_per_page,
|
|
is_exported=is_exported,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
common=common
|
|
)
|
|
return StandardResponse(data=conversations, message="Conversations retrieved successfully")
|
|
|
|
# Analytics endpoint for feedback evaluation dashboard
|
|
@router.get("/feedback/analytics", summary="Get feedback analytics for evaluation dashboard")
|
|
async def get_feedback_analytics_route(
|
|
db_session: DbSession,
|
|
current_user: CurrentUser,
|
|
start_date: Optional[str] = Query(None),
|
|
end_date: Optional[str] = Query(None),
|
|
):
|
|
result = await get_feedback_analytics(
|
|
db_session=db_session,
|
|
current_user=current_user,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
)
|
|
return StandardResponse(data=result, message="Feedback analytics retrieved successfully")
|
|
|
|
@router.get("/memory-lab", response_model=StandardResponse[MemoryLabSnapshot], summary="Get private Memory Lab records")
|
|
async def get_memory_lab(
|
|
db_session: DbSession,
|
|
current_user: CurrentUser,
|
|
):
|
|
filters = (
|
|
RCFAMemory.user_id == current_user.user_id,
|
|
RCFAMemory.scope == "user",
|
|
)
|
|
|
|
memory_result = await db_session.execute(
|
|
select(RCFAMemory)
|
|
.where(*filters)
|
|
.order_by(RCFAMemory.created_at.desc())
|
|
.limit(50)
|
|
)
|
|
memories = list(memory_result.scalars())
|
|
total = await db_session.scalar(select(func.count(RCFAMemory.id)).where(*filters)) or 0
|
|
|
|
snapshot = MemoryLabSnapshot(
|
|
items=[_memory_to_read(memory) for memory in memories],
|
|
total=total,
|
|
retrieval_limit=50,
|
|
)
|
|
return StandardResponse(data=snapshot, message="Private memories retrieved successfully")
|
|
|
|
|
|
@router.delete("/memory-lab/{memory_id}", response_model=StandardResponse[str], summary="Delete one private Memory Lab record")
|
|
async def delete_memory_lab_record(
|
|
memory_id: UUID,
|
|
db_session: DbSession,
|
|
current_user: CurrentUser,
|
|
):
|
|
memory = await db_session.scalar(
|
|
select(RCFAMemory).where(
|
|
RCFAMemory.id == memory_id,
|
|
RCFAMemory.user_id == current_user.user_id,
|
|
RCFAMemory.scope == "user",
|
|
)
|
|
)
|
|
if memory is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="This private memory does not exist.",
|
|
)
|
|
|
|
await db_session.delete(memory)
|
|
await db_session.commit()
|
|
return StandardResponse(data=str(memory_id), message="Private memory deleted successfully")
|
|
|
|
|
|
@router.get("/{conversation_id}", response_model=StandardResponse[ChatHistoryResponse], summary="Get all chats")
|
|
async def get_rag_chats(db_session: DbSession, conversation_id: UUID, current_user: CurrentUser):
|
|
result = await get_rag_histories_by_conversation_id(
|
|
db_session=db_session,
|
|
conversation_id=conversation_id
|
|
)
|
|
chats = [
|
|
ChatHistory(
|
|
id=str(chat.id),
|
|
user_id=str(current_user.user_id),
|
|
message=str(chat.message),
|
|
attachments=chat.attachments if chat.attachments else None,
|
|
attachments_filepath=chat.attachments_filepath if chat.attachments_filepath else None,
|
|
response=str(chat.response),
|
|
context=chat.context if chat.context else None,
|
|
sources=chat.sources if chat.sources else None,
|
|
sources_filepath=chat.sources_filepath if chat.sources_filepath else None,
|
|
timestamp=chat.message_timestamp,
|
|
response_timestamp=chat.response_timestamp,
|
|
time_taken=chat.time_taken,
|
|
prompt_tokens=chat.prompt_tokens,
|
|
response_tokens=chat.response_tokens,
|
|
tokens_used=chat.tokens_used,
|
|
conversation_id=str(chat.conversation_id),
|
|
chat_model=chat.chat_model,
|
|
embedding_model=chat.embedding_model,
|
|
ocr_model=chat.ocr_model,
|
|
created_at=chat.created_at,
|
|
feedback=chat.feedback if chat.feedback else None,
|
|
feedback_comment=chat.feedback_comment if chat.feedback_comment else None,
|
|
feedback_screenshot=chat.feedback_screenshot if chat.feedback_screenshot else None,
|
|
last_location_tag=chat.last_location_tag,
|
|
context_info=chat.context_info if chat.context_info else None,
|
|
references=(chat.context_info or {}).get("references") if isinstance(chat.context_info, dict) else None,
|
|
updated_at=chat.updated_at
|
|
)
|
|
for chat in result
|
|
]
|
|
# Fetch conversation metadata for RCFA phase/state
|
|
conversation = await get_conversation_by_id(db_session=db_session, conversation_id=str(conversation_id), current_user=current_user)
|
|
|
|
return StandardResponse(
|
|
data={
|
|
"messages": chats,
|
|
"metadata": {
|
|
"rcfa_phase": conversation.rcfa_phase if conversation else "1. Failure Prediction",
|
|
"rcfa_state": conversation.rcfa_state if conversation else {},
|
|
"title": conversation.conversation_title if conversation else ""
|
|
}
|
|
},
|
|
message="Chat history retrieved successfully"
|
|
)
|
|
|
|
# Endpoint to handle chat requests route => /chat
|
|
@router.post("")
|
|
async def chat(
|
|
db_session: DbSession,
|
|
current_user: CurrentUser,
|
|
request: Request,
|
|
):
|
|
data = await request.json()
|
|
last_msg = data["messages"][-1]
|
|
if "parts" in last_msg:
|
|
message = last_msg['parts'][0]['text'] if len(last_msg['parts']) < 2 else last_msg['parts'][1]['text']
|
|
files = next((x for x in last_msg['parts'] if x['type'] == 'file'), None)
|
|
else:
|
|
message = last_msg.get("content", "")
|
|
files = None
|
|
location_tag = data.get('locationTag') or data.get('location_tag') or None
|
|
flow_type = FlowType(data.get('flow_type', FlowType.ORIGINAL))
|
|
equipment_id = UUID(data['equipment_id']) if data.get('equipment_id') else None
|
|
questions_batch = data.get('questions_batch')
|
|
|
|
parsed_questions_batch = None
|
|
if flow_type == FlowType.BATCH_SUMMARIZE:
|
|
if not questions_batch:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="questions_batch parameter is required for batch_summarize flow type"
|
|
)
|
|
if not isinstance(questions_batch, list) or len(questions_batch) != 5:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="questions_batch must be a JSON array of exactly 5 questions"
|
|
)
|
|
parsed_questions_batch = questions_batch
|
|
|
|
upload_file = data_uri_to_upload_file(files) if files else None
|
|
chat_req = ChatRequest(
|
|
message=message,
|
|
conversation_id=None,
|
|
message_id=data.get('message_id'),
|
|
attachments=[upload_file] if upload_file else None,
|
|
equipment_id=equipment_id,
|
|
flow_type=flow_type,
|
|
questions_batch=parsed_questions_batch,
|
|
model=data.get('model'),
|
|
context_state=data.get('context_state'),
|
|
enable_background_analysis=data.get('enable_background_analysis', True)
|
|
)
|
|
# try:
|
|
# response = await create(db_session=db_session, request=request, current_user=current_user)
|
|
# return StandardResponse(data=response, message="Chat request processed successfully")
|
|
# except Exception as e:
|
|
# raise HTTPException(status_code=500, detail=str(e))
|
|
headers = {
|
|
"Content-Type": "text/event-stream",
|
|
"Content-type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
"Connection": "keep-alive",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Content-Encoding": "none"
|
|
}
|
|
stream_response = create(db_session=db_session, request=chat_req, current_user=current_user, location_tag=location_tag)
|
|
return StreamingResponse(stream_response, media_type="text/event-stream", headers=headers)
|
|
|
|
|
|
@router.post("/upload/{conversation_id}")
|
|
async def upload_file(
|
|
conversation_id: UUID,
|
|
current_user: CurrentUser,
|
|
file: UploadFile = File(...),
|
|
):
|
|
import os
|
|
import re
|
|
import json
|
|
from fastapi import HTTPException
|
|
|
|
# 1. Sanitize filename and check extension
|
|
filename = os.path.basename(file.filename)
|
|
ext = os.path.splitext(filename)[1].lower()
|
|
allowed_extensions = {".jpg", ".jpeg", ".png", ".pdf", ".json"}
|
|
if ext not in allowed_extensions:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Format file '{ext}' tidak diizinkan. Hanya menerima .jpg, .jpeg, .png, .pdf, .json"
|
|
)
|
|
|
|
# 2. Limit size to MAX_FILE_SIZE (100MB)
|
|
try:
|
|
content = await file.read()
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="Gagal membaca konten file")
|
|
|
|
if len(content) > MAX_FILE_SIZE:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Ukuran file '{filename}' melebihi batas 100MB"
|
|
)
|
|
|
|
# 3. Magic bytes validation
|
|
is_valid = False
|
|
if ext in [".jpg", ".jpeg"]:
|
|
is_valid = content.startswith(b"\xff\xd8\xff")
|
|
elif ext == ".png":
|
|
is_valid = content.startswith(b"\x89PNG\r\n\x1a\n")
|
|
elif ext == ".pdf":
|
|
is_valid = content.startswith(b"%PDF-")
|
|
elif ext == ".json":
|
|
try:
|
|
json.loads(content.decode("utf-8"))
|
|
is_valid = True
|
|
except Exception:
|
|
pass
|
|
|
|
if not is_valid:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Verifikasi konten file '{filename}' gagal. Format tidak sesuai dengan ekstensi '{ext}'."
|
|
)
|
|
|
|
# 4. Save file to src/uploads/{conversation_id}/
|
|
upload_dir = f"src/uploads/{conversation_id}"
|
|
os.makedirs(upload_dir, exist_ok=True)
|
|
file_path = os.path.join(upload_dir, filename)
|
|
|
|
try:
|
|
with open(file_path, "wb") as f_out:
|
|
f_out.write(content)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Gagal menyimpan file ke disk: {str(e)}"
|
|
)
|
|
|
|
return {
|
|
"url": f"src/uploads/{conversation_id}/{filename}",
|
|
"name": filename,
|
|
"contentType": file.content_type or "application/octet-stream"
|
|
}
|
|
|
|
|
|
@router.post("/stream")
|
|
async def chat_stream(db_session: DbSession,
|
|
collector_db_session: CollectorDbSession,
|
|
common: CommonParameters,
|
|
current_user: CurrentUser,
|
|
token: Token,
|
|
request: Request,
|
|
):
|
|
"""
|
|
Stream chat response from LLM.
|
|
"""
|
|
data = await request.json()
|
|
last_msg = data["messages"][-1]
|
|
conv_id_str = data.get('conversation_id') or str(uuid.uuid4())
|
|
attachments = []
|
|
if "parts" in last_msg:
|
|
text_parts = [p.get("text") for p in last_msg["parts"] if p.get("type") == "text" or "text" in p]
|
|
message = text_parts[0] if text_parts else last_msg.get("content", "")
|
|
file_parts = [p for p in last_msg["parts"] if p.get("type") == "file"][:3]
|
|
for fp in file_parts:
|
|
uf = get_pre_uploaded_file(conv_id_str, fp)
|
|
if uf:
|
|
attachments.append(uf)
|
|
else:
|
|
message = last_msg.get("content", "")
|
|
location_tag = data.get('locationTag') or data.get('location_tag') or None
|
|
upload_type = data.get('uploadType') or None
|
|
flow_type = FlowType(data.get('flow_type', FlowType.ORIGINAL))
|
|
equipment_id = UUID(data['equipment_id']) if data.get('equipment_id') else None
|
|
|
|
chat_req = ChatRequest(
|
|
message=message,
|
|
conversation_id=UUID(conv_id_str) if isinstance(conv_id_str, str) else conv_id_str,
|
|
message_id=data.get('message_id'),
|
|
attachments=attachments if attachments else None,
|
|
equipment_id=equipment_id,
|
|
flow_type=flow_type,
|
|
model=data.get('model'),
|
|
context_state=data.get('context_state'),
|
|
enable_background_analysis=data.get('enable_background_analysis', True)
|
|
)
|
|
|
|
streaming = stream_create_v2(
|
|
db_session=db_session,
|
|
collector_db_session=collector_db_session,
|
|
request=chat_req,
|
|
upload_type=upload_type,
|
|
location_tag=location_tag,
|
|
current_user=current_user,
|
|
token=token,
|
|
common=common
|
|
)
|
|
|
|
# if any(k.lower() in message.lower() for k in searchRAGKeywords):
|
|
# intent_result = await ClassifierConfig.chain.ainvoke({"question": request.message})
|
|
# # Only create a new conversation_id if not present in request
|
|
# new_conversation_id = uuid.uuid4()
|
|
# conversation_title = (
|
|
# request.message[:50] + "..." if len(request.message) > 50 else request.message
|
|
# )
|
|
|
|
# conversation = ConversationModel(
|
|
# user_id=current_user.user_id,
|
|
# conversation_id=new_conversation_id,
|
|
# conversation_title=conversation_title,
|
|
# created_by=current_user.name,
|
|
# updated_by=current_user.name
|
|
# )
|
|
|
|
# db_session.add(conversation)
|
|
# await db_session.commit()
|
|
# print("Detected RAG keyword, switching to RAG flow")
|
|
# async def rag_stream():
|
|
# async for chunk in rag_create(
|
|
# db_session=db_session,
|
|
# collector_db_session=collector_db_session,
|
|
# request=request,
|
|
# conversation_id=new_conversation_id,
|
|
# current_user=current_user,
|
|
# intent_result=intent_result
|
|
# ):
|
|
# yield chunk
|
|
# streaming = rag_stream()
|
|
# else:
|
|
# # streaming = stream_create(
|
|
# # db_session=db_session,
|
|
# # collector_db_session=collector_db_session,
|
|
# # request=request,
|
|
# # location_tag=location_tag,
|
|
# # current_user=current_user,
|
|
# # token=token,
|
|
# # common=common
|
|
# # )
|
|
# streaming = stream_create_v2(
|
|
# db_session=db_session,
|
|
# collector_db_session=collector_db_session,
|
|
# request=request,
|
|
# location_tag=location_tag,
|
|
# current_user=current_user,
|
|
# token=token,
|
|
# common=common
|
|
# )
|
|
headers = {
|
|
"Content-Type": "text/plain",
|
|
"Content-type": "text/plain",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
"Connection": "keep-alive",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Content-Encoding": "none"
|
|
}
|
|
stream_response = StreamingResponse(streaming, media_type="text/plain", headers=headers)
|
|
return stream_response
|
|
|
|
@router.post("/stream/{conversation_id}")
|
|
async def chat_stream_conversation(db_session: DbSession,
|
|
collector_db_session: CollectorDbSession,
|
|
common: CommonParameters,
|
|
current_user: CurrentUser,
|
|
token: Token,
|
|
request: Request,
|
|
conversation_id: UUID,
|
|
):
|
|
"""
|
|
Stream chat response from LLM.
|
|
"""
|
|
data = await request.json()
|
|
last_msg = data["messages"][-1]
|
|
attachments = []
|
|
if "parts" in last_msg:
|
|
text_parts = [p.get("text") for p in last_msg["parts"] if p.get("type") == "text" or "text" in p]
|
|
message = text_parts[0] if text_parts else last_msg.get("content", "")
|
|
file_parts = [p for p in last_msg["parts"] if p.get("type") == "file"][:3]
|
|
for fp in file_parts:
|
|
uf = get_pre_uploaded_file(str(conversation_id), fp)
|
|
if uf:
|
|
attachments.append(uf)
|
|
else:
|
|
message = last_msg.get("content", "")
|
|
location_tag = data.get('locationTag') or data.get('location_tag') or None
|
|
upload_type = data.get('uploadType') or None
|
|
flow_type = FlowType(data.get('flow_type', FlowType.ORIGINAL))
|
|
equipment_id = UUID(data['equipment_id']) if data.get('equipment_id') else None
|
|
|
|
chat_req = ChatRequest(
|
|
message=message,
|
|
conversation_id=conversation_id,
|
|
message_id=data.get('message_id'),
|
|
attachments=attachments if attachments else None,
|
|
equipment_id=equipment_id,
|
|
flow_type=flow_type,
|
|
model=data.get('model'),
|
|
context_state=data.get('context_state'),
|
|
enable_background_analysis=data.get('enable_background_analysis', True)
|
|
)
|
|
|
|
streaming = stream_create_v2(
|
|
db_session=db_session,
|
|
collector_db_session=collector_db_session,
|
|
request=chat_req,
|
|
upload_type=upload_type,
|
|
location_tag=location_tag,
|
|
current_user=current_user,
|
|
token=token,
|
|
common=common
|
|
)
|
|
# if any(k.lower() in message.lower() for k in searchRAGKeywords):
|
|
# intent_result = await ClassifierConfig.chain.ainvoke({"question": request.message})
|
|
# # Only create a new conversation_id if not present in request
|
|
# if conversation_id is None:
|
|
# new_conversation_id = uuid.uuid4()
|
|
# conversation_title = (
|
|
# request.message[:50] + "..." if len(request.message) > 50 else request.message
|
|
# )
|
|
|
|
# conversation = ConversationModel(
|
|
# user_id=current_user.user_id,
|
|
# conversation_id=new_conversation_id,
|
|
# conversation_title=conversation_title,
|
|
# created_by=current_user.name,
|
|
# updated_by=current_user.name
|
|
# )
|
|
|
|
# db_session.add(conversation)
|
|
# await db_session.commit()
|
|
# print("Detected RAG keyword, switching to RAG flow")
|
|
# async def rag_stream():
|
|
# async for chunk in rag_create(
|
|
# db_session=db_session,
|
|
# collector_db_session=collector_db_session,
|
|
# request=request,
|
|
# conversation_id=conversation_id,
|
|
# current_user=current_user,
|
|
# intent_result=intent_result
|
|
# ):
|
|
# yield chunk
|
|
# streaming = rag_stream()
|
|
# else:
|
|
# streaming = stream_create_v2(
|
|
# db_session=db_session,
|
|
# collector_db_session=collector_db_session,
|
|
# request=request,
|
|
# location_tag=location_tag,
|
|
# current_user=current_user,
|
|
# token=token,
|
|
# common=common
|
|
# )
|
|
headers = {
|
|
"Content-Type": "text/event-stream",
|
|
"Content-type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
"Connection": "keep-alive",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Content-Encoding": "none"
|
|
}
|
|
stream_response = StreamingResponse(streaming, media_type="text/event-stream", headers=headers)
|
|
return stream_response
|
|
|
|
|
|
@router.post("/root-cause", response_model=StandardResponse[RootCauseResponse], summary="Search root cause from SPLN")
|
|
async def search_root_cause(
|
|
request: RootCauseRequest,
|
|
current_user: CurrentUser
|
|
):
|
|
try:
|
|
tool = RootCauseSPLNTool()
|
|
params = {
|
|
"equipment_type": request.equipment_type,
|
|
"problem": request.problem
|
|
}
|
|
result_str = tool.call(params)
|
|
|
|
# Check if result_str is a plain error message or JSON
|
|
if result_str.strip().startswith("{") and result_str.strip().endswith("}"):
|
|
result_data = json_module.loads(result_str)
|
|
return StandardResponse(data=result_data, message="Root cause mapping retrieved successfully")
|
|
else:
|
|
# It's an error message string
|
|
raise HTTPException(status_code=400, detail=result_str)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(e)}")
|
|
|
|
# Endpoint to handle continue chat conversations requests route => /chat/{conversation_id}
|
|
@router.post("/{conversation_id}", response_model=StandardResponse[ChatResponse], summary="Continue conversation")
|
|
async def continue_chat(
|
|
db_session: DbSession,
|
|
current_user: CurrentUser,
|
|
conversation_id: UUID,
|
|
request: Request,
|
|
):
|
|
"""
|
|
Process a chat request with RAG (Retrieval-Augmented Generation) for a specific conversation.
|
|
This endpoint handles the chat request with different processing flows:
|
|
- original: Standard RAG flow
|
|
- llm_first: LLM processes question first, then uses retrieval
|
|
- batch_summarize: Processes 5 questions in batch (requires questions_batch parameter)
|
|
"""
|
|
|
|
# Parse questions_batch if provided for batch_summarize flow
|
|
data = await request.json()
|
|
last_msg = data["messages"][-1]
|
|
attachments = []
|
|
if "parts" in last_msg:
|
|
text_parts = [p.get("text") for p in last_msg["parts"] if p.get("type") == "text" or "text" in p]
|
|
message = text_parts[0] if text_parts else last_msg.get("content", "")
|
|
file_parts = [p for p in last_msg["parts"] if p.get("type") == "file"][:3]
|
|
for fp in file_parts:
|
|
uf = data_uri_to_upload_file(fp)
|
|
if uf:
|
|
attachments.append(uf)
|
|
else:
|
|
message = last_msg.get("content", "")
|
|
location_tag = data.get('locationTag') or data.get('location_tag') or None
|
|
flow_type = FlowType(data.get('flow_type', FlowType.ORIGINAL))
|
|
equipment_id = UUID(data['equipment_id']) if data.get('equipment_id') else None
|
|
questions_batch = data.get('questions_batch')
|
|
|
|
parsed_questions_batch = None
|
|
if flow_type == FlowType.BATCH_SUMMARIZE:
|
|
if not questions_batch:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="questions_batch parameter is required for batch_summarize flow type"
|
|
)
|
|
if not isinstance(questions_batch, list) or len(questions_batch) != 5:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="questions_batch must be a JSON array of exactly 5 questions"
|
|
)
|
|
parsed_questions_batch = questions_batch
|
|
|
|
chat_req = ChatRequest(
|
|
message=message,
|
|
conversation_id=conversation_id,
|
|
message_id=data.get('message_id'),
|
|
attachments=attachments if attachments else None,
|
|
equipment_id=equipment_id,
|
|
flow_type=flow_type,
|
|
questions_batch=parsed_questions_batch,
|
|
model=data.get('model'),
|
|
context_state=data.get('context_state'),
|
|
enable_background_analysis=data.get('enable_background_analysis', True)
|
|
)
|
|
# try:
|
|
# response = await create(db_session=db_session, request=request, current_user=current_user)
|
|
# return StandardResponse(data=response, message="Chat request processed successfully")
|
|
# except Exception as e:
|
|
# raise HTTPException(status_code=500, detail=str(e))
|
|
streaming = stream_create_v2(
|
|
db_session=db_session,
|
|
collector_db_session=collector_db_session,
|
|
request=chat_req,
|
|
upload_type=upload_type,
|
|
location_tag=location_tag,
|
|
current_user=current_user,
|
|
token=token,
|
|
common=common
|
|
)
|
|
|
|
return StreamingResponse(streaming, media_type="text/event-stream")
|
|
|
|
@router.put("/{conversation_id}", response_model=StandardResponse[Conversation])
|
|
async def update_conversation(
|
|
db_session: DbSession,
|
|
conversation_id: str,
|
|
update: ConversationUpdate,
|
|
current_user: CurrentUser,
|
|
):
|
|
conversation = await get_conversation_by_id(db_session=db_session, conversation_id=conversation_id, current_user=current_user)
|
|
|
|
new_title = update.conversation_title
|
|
if not conversation:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="A data with this id does not exist.",
|
|
)
|
|
|
|
updated_title = await update_conversation_title(
|
|
db_session=db_session, conversation_id=conversation_id, title=new_title, current_user=current_user
|
|
)
|
|
|
|
|
|
return StandardResponse(
|
|
data=updated_title,
|
|
message="Data updated successfully",
|
|
)
|
|
|
|
@router.put("/export/{conversation_id}", response_model=StandardResponse[Conversation])
|
|
async def mark_conversation_exported(
|
|
db_session: DbSession,
|
|
conversation_id: str,
|
|
update: ConversationExportUpdate,
|
|
current_user: CurrentUser,
|
|
):
|
|
updated_conversation = await update_conversation_export_status(
|
|
db_session=db_session, conversation_id=conversation_id, update_data=update, current_user=current_user
|
|
)
|
|
|
|
return StandardResponse(
|
|
data=updated_conversation,
|
|
message="Conversation marked as exported"
|
|
)
|
|
|
|
@router.put("/feedback/{conversation_id}/{chat_history_id}", response_model=StandardResponse[ChatHistory])
|
|
async def update_feedback(
|
|
db_session: DbSession,
|
|
conversation_id: UUID,
|
|
chat_history_id: UUID,
|
|
current_user: CurrentUser,
|
|
update: UpdateFeedback,
|
|
):
|
|
chat_history_data = await get_chat_by_id(db_session=db_session, chat_history_id=chat_history_id, conversation_id=conversation_id, current_user=current_user)
|
|
if not chat_history_data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="A data with this id does not exist.",
|
|
)
|
|
|
|
response = await update_chat_history_feedback(
|
|
db_session=db_session,
|
|
chat_history_id=chat_history_id,
|
|
feedback=update.update,
|
|
current_user=current_user,
|
|
comment=update.comment,
|
|
screenshot=update.screenshot,
|
|
)
|
|
|
|
return StandardResponse(
|
|
data=response,
|
|
message="Feedback updated successfully"
|
|
)
|