minhvtt commited on
Commit
a554f90
·
verified ·
1 Parent(s): 3fac251

Upload 19 files

Browse files
Files changed (4) hide show
  1. core.py +1 -0
  2. routes_team_chat.py +26 -2
  3. schemas.py +6 -0
  4. services_legacy.py +193 -17
core.py CHANGED
@@ -39,6 +39,7 @@ issues_collection = db.project_issues
39
  image_assets_collection = db.image_assets
40
  team_chat_collection = db.team_chat_history
41
  team_docs_collection = db.team_documents
 
42
 
43
  UPLOAD_DIR = os.environ.get("UPLOAD_DIR", os.path.join(os.path.dirname(__file__), "uploads"))
44
  os.makedirs(UPLOAD_DIR, exist_ok=True)
 
39
  image_assets_collection = db.image_assets
40
  team_chat_collection = db.team_chat_history
41
  team_docs_collection = db.team_documents
42
+ team_doc_chunks_collection = db.team_document_chunks
43
 
44
  UPLOAD_DIR = os.environ.get("UPLOAD_DIR", os.path.join(os.path.dirname(__file__), "uploads"))
45
  os.makedirs(UPLOAD_DIR, exist_ok=True)
routes_team_chat.py CHANGED
@@ -8,7 +8,7 @@ from fastapi import APIRouter, File, Form, Header, HTTPException, UploadFile, We
8
 
9
  from core import TEAM_AGENT_MODEL, projects_collection, team_chat_collection, teams_collection
10
  from prompts import TEAM_AGENT_SYSTEM_PROMPT
11
- from schemas import TeamChatRequest
12
  from services import (
13
  build_document_grounded_answer,
14
  build_requirement_node_options_from_documents,
@@ -322,7 +322,7 @@ async def upload_team_document(
322
  "mime_type": doc["mime_type"],
323
  "created_at": doc["created_at"],
324
  "updated_at": doc["updated_at"],
325
- "total_nodes": (doc.get("tree") or {}).get("total_nodes", 0),
326
  }
327
  }
328
 
@@ -363,6 +363,30 @@ async def upload_team_chat_images(
363
  }
364
 
365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  @router.post("/teams/chat")
367
  async def team_chat(req: TeamChatRequest, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")):
368
  user = require_session_user(x_session_token)
 
8
 
9
  from core import TEAM_AGENT_MODEL, projects_collection, team_chat_collection, teams_collection
10
  from prompts import TEAM_AGENT_SYSTEM_PROMPT
11
+ from schemas import TeamChatMessageRequest, TeamChatRequest
12
  from services import (
13
  build_document_grounded_answer,
14
  build_requirement_node_options_from_documents,
 
322
  "mime_type": doc["mime_type"],
323
  "created_at": doc["created_at"],
324
  "updated_at": doc["updated_at"],
325
+ "total_nodes": (doc.get("tree") or {}).get("total_nodes", (doc.get("tree_meta") or {}).get("total_nodes", 0)),
326
  }
327
  }
328
 
 
363
  }
364
 
365
 
366
+ @router.post("/teams/{team_id}/chat/messages")
367
+ async def post_team_chat_message(
368
+ team_id: str,
369
+ req: TeamChatMessageRequest,
370
+ x_session_token: Optional[str] = Header(None, alias="X-Session-Token"),
371
+ ):
372
+ user = require_session_user(x_session_token)
373
+ _assert_team_project_access(user, team_id, req.project_id)
374
+
375
+ content = (req.message or "").strip()
376
+ if not content and not (req.attachment_urls or []):
377
+ raise HTTPException(status_code=400, detail="Message is empty")
378
+
379
+ user_msg = save_team_chat_message(
380
+ team_id,
381
+ "user",
382
+ content,
383
+ req.project_id,
384
+ attachment_urls=req.attachment_urls,
385
+ )
386
+ await _broadcast_team_chat_message(team_id, req.project_id, user_msg)
387
+ return {"message": user_msg}
388
+
389
+
390
  @router.post("/teams/chat")
391
  async def team_chat(req: TeamChatRequest, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")):
392
  user = require_session_user(x_session_token)
schemas.py CHANGED
@@ -128,6 +128,12 @@ class TeamChatRequest(BaseModel):
128
  require_bot_mention: bool = True
129
 
130
 
 
 
 
 
 
 
131
  class TeamChatToolCreateIssue(BaseModel):
132
  title: str
133
  description: str = ""
 
128
  require_bot_mention: bool = True
129
 
130
 
131
+ class TeamChatMessageRequest(BaseModel):
132
+ message: str
133
+ project_id: Optional[str] = None
134
+ attachment_urls: List[str] = Field(default_factory=list)
135
+
136
+
137
  class TeamChatToolCreateIssue(BaseModel):
138
  title: str
139
  description: str = ""
services_legacy.py CHANGED
@@ -49,6 +49,7 @@ from core import (
49
  tasks_collection,
50
  TEAM_AGENT_MODEL,
51
  team_chat_collection,
 
52
  team_docs_collection,
53
  teams_collection,
54
  users_collection,
@@ -66,6 +67,9 @@ _tts_tokenizer = None
66
  _tts_model = None
67
  _last_auto_compact_ts = 0.0
68
 
 
 
 
69
 
70
  def get_vn_now() -> datetime:
71
  return datetime.now(tz=VN_TZ)
@@ -638,6 +642,133 @@ def _safe_decode_text(raw_bytes: bytes, fallback_name: str = "") -> str:
638
  return raw_bytes.decode("utf-8", errors="ignore")
639
 
640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
641
  def build_document_tree(text: str) -> Dict[str, Any]:
642
  lines = (text or "").splitlines()
643
  nodes: List[Dict[str, Any]] = []
@@ -813,24 +944,49 @@ def save_team_document(
813
  raw_bytes: bytes,
814
  ) -> Dict[str, Any]:
815
  text = _safe_decode_text(raw_bytes, file_name)
816
- tree = build_document_tree(text)
817
  contextual_meta = _contextualize_document_tree(file_name=file_name, text=text, tree=tree)
 
 
 
818
  doc = {
819
- "id": str(uuid.uuid4()),
820
  "team_id": team_id,
821
  "project_id": project_id,
822
  "name": file_name,
823
  "mime_type": mime_type,
824
  "uploader_id": uploader_id,
825
- "tree": tree,
826
- "text": text,
 
 
 
 
 
 
 
 
 
827
  "contextual_global_summary": contextual_meta.get("global_summary", ""),
828
  "contextual_meta": contextual_meta,
829
- "created_at": get_vn_now().isoformat(),
830
- "updated_at": get_vn_now().isoformat(),
831
  }
832
- team_docs_collection.insert_one(doc)
833
- return doc
 
 
 
 
 
 
 
 
 
 
 
 
 
834
 
835
 
836
  def list_team_documents(team_id: str, project_id: Optional[str] = None) -> List[Dict[str, Any]]:
@@ -850,19 +1006,18 @@ def list_team_documents(team_id: str, project_id: Optional[str] = None) -> List[
850
  "uploader_id": 1,
851
  "created_at": 1,
852
  "updated_at": 1,
853
- "tree.total_nodes": 1,
854
- "tree.nodes.id": 1,
855
- "tree.nodes.parent_id": 1,
856
- "tree.nodes.level": 1,
857
- "tree.nodes.title": 1,
858
- "tree.nodes.summary": 1,
859
- "tree.nodes.contextual_summary": 1,
860
  },
861
  ).sort("updated_at", -1)
862
  )
863
  for doc in docs:
864
- tree = doc.get("tree") or {}
865
  nodes = tree.get("nodes") or []
 
 
 
 
866
  doc["node_catalog"] = [
867
  {
868
  "id": node.get("id"),
@@ -887,7 +1042,28 @@ def get_team_documents_by_ids(team_id: str, doc_ids: List[str], project_id: Opti
887
  query: Dict[str, Any] = {"team_id": team_id, "id": {"$in": doc_ids}}
888
  if project_id:
889
  query["project_id"] = project_id
890
- docs = list(team_docs_collection.find(query, {"_id": 0}))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
891
  by_id = {doc.get("id"): doc for doc in docs}
892
  ordered = [by_id[doc_id] for doc_id in doc_ids if doc_id in by_id]
893
  return ordered
 
49
  tasks_collection,
50
  TEAM_AGENT_MODEL,
51
  team_chat_collection,
52
+ team_doc_chunks_collection,
53
  team_docs_collection,
54
  teams_collection,
55
  users_collection,
 
67
  _tts_model = None
68
  _last_auto_compact_ts = 0.0
69
 
70
+ TEAM_DOC_NODE_CONTENT_LIMIT = int(os.environ.get("TEAM_DOC_NODE_CONTENT_LIMIT", "2400"))
71
+ TEAM_DOC_NODE_CHUNK_SIZE = int(os.environ.get("TEAM_DOC_NODE_CHUNK_SIZE", "80"))
72
+
73
 
74
  def get_vn_now() -> datetime:
75
  return datetime.now(tz=VN_TZ)
 
642
  return raw_bytes.decode("utf-8", errors="ignore")
643
 
644
 
645
+ def _split_large_node_content(tree: Dict[str, Any], max_chars: int = TEAM_DOC_NODE_CONTENT_LIMIT) -> Dict[str, Any]:
646
+ nodes = tree.get("nodes") or []
647
+ if not nodes:
648
+ return tree
649
+
650
+ by_id: Dict[str, Dict[str, Any]] = {str(node.get("id") or ""): node for node in nodes if node.get("id")}
651
+ root_id = str(tree.get("root_id") or "root")
652
+ next_split_idx = 0
653
+ normalized_nodes: List[Dict[str, Any]] = []
654
+
655
+ for node in nodes:
656
+ node_id = str(node.get("id") or "")
657
+ clone = dict(node)
658
+ clone["children"] = list(node.get("children") or [])
659
+ content = str(clone.get("content") or "")
660
+
661
+ if node_id == root_id or len(content) <= max_chars:
662
+ normalized_nodes.append(clone)
663
+ continue
664
+
665
+ parts = [content[i : i + max_chars].strip() for i in range(0, len(content), max_chars)]
666
+ parts = [part for part in parts if part]
667
+ if not parts:
668
+ clone["content"] = ""
669
+ normalized_nodes.append(clone)
670
+ continue
671
+
672
+ clone["content"] = parts[0]
673
+ clone["summary"] = _clip_text(parts[0], 280)
674
+ clone["contextual_summary"] = _clip_text(parts[0], 260)
675
+
676
+ parent_level = int(clone.get("level") or 1)
677
+ continuation_children = list(clone.get("children") or [])
678
+ title = str(clone.get("title") or "Section")
679
+ normalized_nodes.append(clone)
680
+
681
+ for idx, part in enumerate(parts[1:], start=2):
682
+ next_split_idx += 1
683
+ split_id = f"{node_id}_part_{next_split_idx}"
684
+ while split_id in by_id:
685
+ next_split_idx += 1
686
+ split_id = f"{node_id}_part_{next_split_idx}"
687
+
688
+ child_node = {
689
+ "id": split_id,
690
+ "parent_id": node_id,
691
+ "level": parent_level + 1,
692
+ "title": f"{title} (phần {idx})",
693
+ "summary": _clip_text(part, 280),
694
+ "contextual_summary": _clip_text(part, 260),
695
+ "scope": f"Nội dung tiếp theo của: {title}",
696
+ "content": part,
697
+ "children": [],
698
+ }
699
+ continuation_children.append(split_id)
700
+ by_id[split_id] = child_node
701
+ normalized_nodes.append(child_node)
702
+
703
+ clone["children"] = continuation_children
704
+
705
+ return {
706
+ "root_id": root_id,
707
+ "nodes": normalized_nodes,
708
+ "total_nodes": len(normalized_nodes),
709
+ }
710
+
711
+
712
+ def _chunk_document_nodes(nodes: List[Dict[str, Any]], chunk_size: int = TEAM_DOC_NODE_CHUNK_SIZE) -> List[List[Dict[str, Any]]]:
713
+ if not nodes:
714
+ return []
715
+ size = max(1, int(chunk_size))
716
+ return [nodes[i : i + size] for i in range(0, len(nodes), size)]
717
+
718
+
719
+ def _store_team_document_chunks(doc_id: str, nodes: List[Dict[str, Any]], created_at: str) -> None:
720
+ chunks = _chunk_document_nodes(nodes)
721
+ if not chunks:
722
+ return
723
+
724
+ chunk_docs = [
725
+ {
726
+ "id": str(uuid.uuid4()),
727
+ "doc_id": doc_id,
728
+ "chunk_index": idx,
729
+ "nodes": chunk_nodes,
730
+ "created_at": created_at,
731
+ "updated_at": created_at,
732
+ }
733
+ for idx, chunk_nodes in enumerate(chunks)
734
+ ]
735
+ team_doc_chunks_collection.insert_many(chunk_docs)
736
+
737
+
738
+ def _load_team_document_nodes(doc_id: str) -> List[Dict[str, Any]]:
739
+ rows = list(
740
+ team_doc_chunks_collection.find(
741
+ {"doc_id": doc_id},
742
+ {"_id": 0, "nodes": 1},
743
+ ).sort("chunk_index", 1)
744
+ )
745
+ nodes: List[Dict[str, Any]] = []
746
+ for row in rows:
747
+ chunk_nodes = row.get("nodes")
748
+ if isinstance(chunk_nodes, list):
749
+ nodes.extend(chunk_nodes)
750
+ return nodes
751
+
752
+
753
+ def _resolve_document_tree(doc: Dict[str, Any]) -> Dict[str, Any]:
754
+ inline_tree = doc.get("tree") if isinstance(doc.get("tree"), dict) else None
755
+ if inline_tree and isinstance(inline_tree.get("nodes"), list):
756
+ total_nodes = inline_tree.get("total_nodes")
757
+ if not isinstance(total_nodes, int):
758
+ inline_tree["total_nodes"] = len(inline_tree.get("nodes") or [])
759
+ return inline_tree
760
+
761
+ tree_meta = doc.get("tree_meta") if isinstance(doc.get("tree_meta"), dict) else {}
762
+ nodes = _load_team_document_nodes(str(doc.get("id") or ""))
763
+ root_id = str(tree_meta.get("root_id") or "root")
764
+ total_nodes = int(tree_meta.get("total_nodes") or len(nodes))
765
+ return {
766
+ "root_id": root_id,
767
+ "nodes": nodes,
768
+ "total_nodes": total_nodes,
769
+ }
770
+
771
+
772
  def build_document_tree(text: str) -> Dict[str, Any]:
773
  lines = (text or "").splitlines()
774
  nodes: List[Dict[str, Any]] = []
 
944
  raw_bytes: bytes,
945
  ) -> Dict[str, Any]:
946
  text = _safe_decode_text(raw_bytes, file_name)
947
+ tree = _split_large_node_content(build_document_tree(text))
948
  contextual_meta = _contextualize_document_tree(file_name=file_name, text=text, tree=tree)
949
+ doc_id = str(uuid.uuid4())
950
+ now_iso = get_vn_now().isoformat()
951
+
952
  doc = {
953
+ "id": doc_id,
954
  "team_id": team_id,
955
  "project_id": project_id,
956
  "name": file_name,
957
  "mime_type": mime_type,
958
  "uploader_id": uploader_id,
959
+ "tree_meta": {
960
+ "root_id": tree.get("root_id", "root"),
961
+ "total_nodes": int(tree.get("total_nodes") or len(tree.get("nodes") or [])),
962
+ "storage": "chunked",
963
+ "chunk_size": TEAM_DOC_NODE_CHUNK_SIZE,
964
+ },
965
+ "text_meta": {
966
+ "raw_bytes": len(raw_bytes),
967
+ "decoded_chars": len(text),
968
+ "storage": "transient",
969
+ },
970
  "contextual_global_summary": contextual_meta.get("global_summary", ""),
971
  "contextual_meta": contextual_meta,
972
+ "created_at": now_iso,
973
+ "updated_at": now_iso,
974
  }
975
+
976
+ try:
977
+ team_docs_collection.insert_one(doc)
978
+ _store_team_document_chunks(doc_id=doc_id, nodes=tree.get("nodes") or [], created_at=now_iso)
979
+ except Exception:
980
+ team_docs_collection.delete_many({"id": doc_id})
981
+ team_doc_chunks_collection.delete_many({"doc_id": doc_id})
982
+ raise
983
+
984
+ out_doc = dict(doc)
985
+ out_doc["tree"] = {
986
+ "root_id": tree.get("root_id", "root"),
987
+ "total_nodes": tree.get("total_nodes", len(tree.get("nodes") or [])),
988
+ }
989
+ return out_doc
990
 
991
 
992
  def list_team_documents(team_id: str, project_id: Optional[str] = None) -> List[Dict[str, Any]]:
 
1006
  "uploader_id": 1,
1007
  "created_at": 1,
1008
  "updated_at": 1,
1009
+ "tree": 1,
1010
+ "tree_meta": 1,
 
 
 
 
 
1011
  },
1012
  ).sort("updated_at", -1)
1013
  )
1014
  for doc in docs:
1015
+ tree = _resolve_document_tree(doc)
1016
  nodes = tree.get("nodes") or []
1017
+ doc["tree"] = {
1018
+ "root_id": tree.get("root_id", "root"),
1019
+ "total_nodes": int(tree.get("total_nodes") or len(nodes)),
1020
+ }
1021
  doc["node_catalog"] = [
1022
  {
1023
  "id": node.get("id"),
 
1042
  query: Dict[str, Any] = {"team_id": team_id, "id": {"$in": doc_ids}}
1043
  if project_id:
1044
  query["project_id"] = project_id
1045
+ docs = list(
1046
+ team_docs_collection.find(
1047
+ query,
1048
+ {
1049
+ "_id": 0,
1050
+ "id": 1,
1051
+ "team_id": 1,
1052
+ "project_id": 1,
1053
+ "name": 1,
1054
+ "mime_type": 1,
1055
+ "uploader_id": 1,
1056
+ "contextual_global_summary": 1,
1057
+ "contextual_meta": 1,
1058
+ "created_at": 1,
1059
+ "updated_at": 1,
1060
+ "tree": 1,
1061
+ "tree_meta": 1,
1062
+ },
1063
+ )
1064
+ )
1065
+ for doc in docs:
1066
+ doc["tree"] = _resolve_document_tree(doc)
1067
  by_id = {doc.get("id"): doc for doc in docs}
1068
  ordered = [by_id[doc_id] for doc_id in doc_ids if doc_id in by_id]
1069
  return ordered