Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

conversation.py 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. from datetime import datetime
  2. import pytz # pip install pytz
  3. from flask_login import current_user
  4. from flask_restx import Resource, marshal_with, reqparse
  5. from flask_restx.inputs import int_range
  6. from sqlalchemy import func, or_
  7. from sqlalchemy.orm import joinedload
  8. from werkzeug.exceptions import Forbidden, NotFound
  9. from controllers.console import api, console_ns
  10. from controllers.console.app.wraps import get_app_model
  11. from controllers.console.wraps import account_initialization_required, setup_required
  12. from core.app.entities.app_invoke_entities import InvokeFrom
  13. from extensions.ext_database import db
  14. from fields.conversation_fields import (
  15. conversation_detail_fields,
  16. conversation_message_detail_fields,
  17. conversation_pagination_fields,
  18. conversation_with_summary_pagination_fields,
  19. )
  20. from libs.datetime_utils import naive_utc_now
  21. from libs.helper import DatetimeString
  22. from libs.login import login_required
  23. from models import Account, Conversation, EndUser, Message, MessageAnnotation
  24. from models.model import AppMode
  25. from services.conversation_service import ConversationService
  26. from services.errors.conversation import ConversationNotExistsError
  27. @console_ns.route("/apps/<uuid:app_id>/completion-conversations")
  28. class CompletionConversationApi(Resource):
  29. @api.doc("list_completion_conversations")
  30. @api.doc(description="Get completion conversations with pagination and filtering")
  31. @api.doc(params={"app_id": "Application ID"})
  32. @api.expect(
  33. api.parser()
  34. .add_argument("keyword", type=str, location="args", help="Search keyword")
  35. .add_argument("start", type=str, location="args", help="Start date (YYYY-MM-DD HH:MM)")
  36. .add_argument("end", type=str, location="args", help="End date (YYYY-MM-DD HH:MM)")
  37. .add_argument(
  38. "annotation_status",
  39. type=str,
  40. location="args",
  41. choices=["annotated", "not_annotated", "all"],
  42. default="all",
  43. help="Annotation status filter",
  44. )
  45. .add_argument("page", type=int, location="args", default=1, help="Page number")
  46. .add_argument("limit", type=int, location="args", default=20, help="Page size (1-100)")
  47. )
  48. @api.response(200, "Success", conversation_pagination_fields)
  49. @api.response(403, "Insufficient permissions")
  50. @setup_required
  51. @login_required
  52. @account_initialization_required
  53. @get_app_model(mode=AppMode.COMPLETION)
  54. @marshal_with(conversation_pagination_fields)
  55. def get(self, app_model):
  56. if not current_user.is_editor:
  57. raise Forbidden()
  58. parser = reqparse.RequestParser()
  59. parser.add_argument("keyword", type=str, location="args")
  60. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  61. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  62. parser.add_argument(
  63. "annotation_status", type=str, choices=["annotated", "not_annotated", "all"], default="all", location="args"
  64. )
  65. parser.add_argument("page", type=int_range(1, 99999), default=1, location="args")
  66. parser.add_argument("limit", type=int_range(1, 100), default=20, location="args")
  67. args = parser.parse_args()
  68. query = db.select(Conversation).where(
  69. Conversation.app_id == app_model.id, Conversation.mode == "completion", Conversation.is_deleted.is_(False)
  70. )
  71. if args["keyword"]:
  72. query = query.join(Message, Message.conversation_id == Conversation.id).where(
  73. or_(
  74. Message.query.ilike(f"%{args['keyword']}%"),
  75. Message.answer.ilike(f"%{args['keyword']}%"),
  76. )
  77. )
  78. account = current_user
  79. timezone = pytz.timezone(account.timezone)
  80. utc_timezone = pytz.utc
  81. if args["start"]:
  82. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  83. start_datetime = start_datetime.replace(second=0)
  84. start_datetime_timezone = timezone.localize(start_datetime)
  85. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  86. query = query.where(Conversation.created_at >= start_datetime_utc)
  87. if args["end"]:
  88. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  89. end_datetime = end_datetime.replace(second=59)
  90. end_datetime_timezone = timezone.localize(end_datetime)
  91. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  92. query = query.where(Conversation.created_at < end_datetime_utc)
  93. # FIXME, the type ignore in this file
  94. if args["annotation_status"] == "annotated":
  95. query = query.options(joinedload(Conversation.message_annotations)).join( # type: ignore
  96. MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
  97. )
  98. elif args["annotation_status"] == "not_annotated":
  99. query = (
  100. query.outerjoin(MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id)
  101. .group_by(Conversation.id)
  102. .having(func.count(MessageAnnotation.id) == 0)
  103. )
  104. query = query.order_by(Conversation.created_at.desc())
  105. conversations = db.paginate(query, page=args["page"], per_page=args["limit"], error_out=False)
  106. return conversations
  107. @console_ns.route("/apps/<uuid:app_id>/completion-conversations/<uuid:conversation_id>")
  108. class CompletionConversationDetailApi(Resource):
  109. @api.doc("get_completion_conversation")
  110. @api.doc(description="Get completion conversation details with messages")
  111. @api.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
  112. @api.response(200, "Success", conversation_message_detail_fields)
  113. @api.response(403, "Insufficient permissions")
  114. @api.response(404, "Conversation not found")
  115. @setup_required
  116. @login_required
  117. @account_initialization_required
  118. @get_app_model(mode=AppMode.COMPLETION)
  119. @marshal_with(conversation_message_detail_fields)
  120. def get(self, app_model, conversation_id):
  121. if not current_user.is_editor:
  122. raise Forbidden()
  123. conversation_id = str(conversation_id)
  124. return _get_conversation(app_model, conversation_id)
  125. @api.doc("delete_completion_conversation")
  126. @api.doc(description="Delete a completion conversation")
  127. @api.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
  128. @api.response(204, "Conversation deleted successfully")
  129. @api.response(403, "Insufficient permissions")
  130. @api.response(404, "Conversation not found")
  131. @setup_required
  132. @login_required
  133. @account_initialization_required
  134. @get_app_model(mode=AppMode.COMPLETION)
  135. def delete(self, app_model, conversation_id):
  136. if not current_user.is_editor:
  137. raise Forbidden()
  138. conversation_id = str(conversation_id)
  139. try:
  140. if not isinstance(current_user, Account):
  141. raise ValueError("current_user must be an Account instance")
  142. ConversationService.delete(app_model, conversation_id, current_user)
  143. except ConversationNotExistsError:
  144. raise NotFound("Conversation Not Exists.")
  145. return {"result": "success"}, 204
  146. @console_ns.route("/apps/<uuid:app_id>/chat-conversations")
  147. class ChatConversationApi(Resource):
  148. @api.doc("list_chat_conversations")
  149. @api.doc(description="Get chat conversations with pagination, filtering and summary")
  150. @api.doc(params={"app_id": "Application ID"})
  151. @api.expect(
  152. api.parser()
  153. .add_argument("keyword", type=str, location="args", help="Search keyword")
  154. .add_argument("start", type=str, location="args", help="Start date (YYYY-MM-DD HH:MM)")
  155. .add_argument("end", type=str, location="args", help="End date (YYYY-MM-DD HH:MM)")
  156. .add_argument(
  157. "annotation_status",
  158. type=str,
  159. location="args",
  160. choices=["annotated", "not_annotated", "all"],
  161. default="all",
  162. help="Annotation status filter",
  163. )
  164. .add_argument("message_count_gte", type=int, location="args", help="Minimum message count")
  165. .add_argument("page", type=int, location="args", default=1, help="Page number")
  166. .add_argument("limit", type=int, location="args", default=20, help="Page size (1-100)")
  167. .add_argument(
  168. "sort_by",
  169. type=str,
  170. location="args",
  171. choices=["created_at", "-created_at", "updated_at", "-updated_at"],
  172. default="-updated_at",
  173. help="Sort field and direction",
  174. )
  175. )
  176. @api.response(200, "Success", conversation_with_summary_pagination_fields)
  177. @api.response(403, "Insufficient permissions")
  178. @setup_required
  179. @login_required
  180. @account_initialization_required
  181. @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
  182. @marshal_with(conversation_with_summary_pagination_fields)
  183. def get(self, app_model):
  184. if not current_user.is_editor:
  185. raise Forbidden()
  186. parser = reqparse.RequestParser()
  187. parser.add_argument("keyword", type=str, location="args")
  188. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  189. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  190. parser.add_argument(
  191. "annotation_status", type=str, choices=["annotated", "not_annotated", "all"], default="all", location="args"
  192. )
  193. parser.add_argument("message_count_gte", type=int_range(1, 99999), required=False, location="args")
  194. parser.add_argument("page", type=int_range(1, 99999), required=False, default=1, location="args")
  195. parser.add_argument("limit", type=int_range(1, 100), required=False, default=20, location="args")
  196. parser.add_argument(
  197. "sort_by",
  198. type=str,
  199. choices=["created_at", "-created_at", "updated_at", "-updated_at"],
  200. required=False,
  201. default="-updated_at",
  202. location="args",
  203. )
  204. args = parser.parse_args()
  205. subquery = (
  206. db.session.query(
  207. Conversation.id.label("conversation_id"), EndUser.session_id.label("from_end_user_session_id")
  208. )
  209. .outerjoin(EndUser, Conversation.from_end_user_id == EndUser.id)
  210. .subquery()
  211. )
  212. query = db.select(Conversation).where(Conversation.app_id == app_model.id, Conversation.is_deleted.is_(False))
  213. if args["keyword"]:
  214. keyword_filter = f"%{args['keyword']}%"
  215. query = (
  216. query.join(
  217. Message,
  218. Message.conversation_id == Conversation.id,
  219. )
  220. .join(subquery, subquery.c.conversation_id == Conversation.id)
  221. .where(
  222. or_(
  223. Message.query.ilike(keyword_filter),
  224. Message.answer.ilike(keyword_filter),
  225. Conversation.name.ilike(keyword_filter),
  226. Conversation.introduction.ilike(keyword_filter),
  227. subquery.c.from_end_user_session_id.ilike(keyword_filter),
  228. ),
  229. )
  230. .group_by(Conversation.id)
  231. )
  232. account = current_user
  233. timezone = pytz.timezone(account.timezone)
  234. utc_timezone = pytz.utc
  235. if args["start"]:
  236. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  237. start_datetime = start_datetime.replace(second=0)
  238. start_datetime_timezone = timezone.localize(start_datetime)
  239. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  240. match args["sort_by"]:
  241. case "updated_at" | "-updated_at":
  242. query = query.where(Conversation.updated_at >= start_datetime_utc)
  243. case "created_at" | "-created_at" | _:
  244. query = query.where(Conversation.created_at >= start_datetime_utc)
  245. if args["end"]:
  246. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  247. end_datetime = end_datetime.replace(second=59)
  248. end_datetime_timezone = timezone.localize(end_datetime)
  249. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  250. match args["sort_by"]:
  251. case "updated_at" | "-updated_at":
  252. query = query.where(Conversation.updated_at <= end_datetime_utc)
  253. case "created_at" | "-created_at" | _:
  254. query = query.where(Conversation.created_at <= end_datetime_utc)
  255. if args["annotation_status"] == "annotated":
  256. query = query.options(joinedload(Conversation.message_annotations)).join( # type: ignore
  257. MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
  258. )
  259. elif args["annotation_status"] == "not_annotated":
  260. query = (
  261. query.outerjoin(MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id)
  262. .group_by(Conversation.id)
  263. .having(func.count(MessageAnnotation.id) == 0)
  264. )
  265. if args["message_count_gte"] and args["message_count_gte"] >= 1:
  266. query = (
  267. query.options(joinedload(Conversation.messages)) # type: ignore
  268. .join(Message, Message.conversation_id == Conversation.id)
  269. .group_by(Conversation.id)
  270. .having(func.count(Message.id) >= args["message_count_gte"])
  271. )
  272. if app_model.mode == AppMode.ADVANCED_CHAT:
  273. query = query.where(Conversation.invoke_from != InvokeFrom.DEBUGGER.value)
  274. match args["sort_by"]:
  275. case "created_at":
  276. query = query.order_by(Conversation.created_at.asc())
  277. case "-created_at":
  278. query = query.order_by(Conversation.created_at.desc())
  279. case "updated_at":
  280. query = query.order_by(Conversation.updated_at.asc())
  281. case "-updated_at":
  282. query = query.order_by(Conversation.updated_at.desc())
  283. case _:
  284. query = query.order_by(Conversation.created_at.desc())
  285. conversations = db.paginate(query, page=args["page"], per_page=args["limit"], error_out=False)
  286. return conversations
  287. @console_ns.route("/apps/<uuid:app_id>/chat-conversations/<uuid:conversation_id>")
  288. class ChatConversationDetailApi(Resource):
  289. @api.doc("get_chat_conversation")
  290. @api.doc(description="Get chat conversation details")
  291. @api.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
  292. @api.response(200, "Success", conversation_detail_fields)
  293. @api.response(403, "Insufficient permissions")
  294. @api.response(404, "Conversation not found")
  295. @setup_required
  296. @login_required
  297. @account_initialization_required
  298. @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
  299. @marshal_with(conversation_detail_fields)
  300. def get(self, app_model, conversation_id):
  301. if not current_user.is_editor:
  302. raise Forbidden()
  303. conversation_id = str(conversation_id)
  304. return _get_conversation(app_model, conversation_id)
  305. @api.doc("delete_chat_conversation")
  306. @api.doc(description="Delete a chat conversation")
  307. @api.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
  308. @api.response(204, "Conversation deleted successfully")
  309. @api.response(403, "Insufficient permissions")
  310. @api.response(404, "Conversation not found")
  311. @setup_required
  312. @login_required
  313. @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
  314. @account_initialization_required
  315. def delete(self, app_model, conversation_id):
  316. if not current_user.is_editor:
  317. raise Forbidden()
  318. conversation_id = str(conversation_id)
  319. try:
  320. if not isinstance(current_user, Account):
  321. raise ValueError("current_user must be an Account instance")
  322. ConversationService.delete(app_model, conversation_id, current_user)
  323. except ConversationNotExistsError:
  324. raise NotFound("Conversation Not Exists.")
  325. return {"result": "success"}, 204
  326. def _get_conversation(app_model, conversation_id):
  327. conversation = (
  328. db.session.query(Conversation)
  329. .where(Conversation.id == conversation_id, Conversation.app_id == app_model.id)
  330. .first()
  331. )
  332. if not conversation:
  333. raise NotFound("Conversation Not Exists.")
  334. if not conversation.read_at:
  335. conversation.read_at = naive_utc_now()
  336. conversation.read_account_id = current_user.id
  337. db.session.commit()
  338. return conversation