minhvtt commited on
Commit
cd5029e
·
verified ·
1 Parent(s): 3ad0960

Upload 9 files

Browse files
Files changed (5) hide show
  1. prompts.py +33 -0
  2. routes_team_chat.py +266 -6
  3. routes_workspace.py +13 -1
  4. schemas.py +43 -0
  5. services.py +941 -14
prompts.py CHANGED
@@ -109,6 +109,23 @@ Trả về JSON THUẦN TÚY, không có markdown fence, không có chú thích:
109
  "memory_summary": "nội dung memory có cấu trúc như trên"
110
  }"""
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  VOICE_COMPACT_PROMPT = """Bạn là chuyên gia compact cho hội thoại giọng nói (voice chat) của Nomus AI.
113
 
114
  Mục tiêu:
@@ -153,17 +170,24 @@ Mục tiêu:
153
  - Ưu tiên đọc đúng các tin nhắn user đã chọn thay vì đọc toàn bộ lịch sử.
154
  - Có thể phân tích bug, blocker, requirement, kế hoạch, task và trạng thái dự án.
155
  - Có thể đọc document theo cấu trúc cây (hierarchical index) và điều hướng theo từng nhánh.
 
156
  - Khi đủ dữ liệu, hãy đề xuất hoặc thực thi action phù hợp thay vì chỉ trả lời chung chung.
157
 
158
  Các action được hỗ trợ:
159
  - create_issue: tạo issue mới cho project.
160
  - update_issue: cập nhật issue hiện có theo issue_id hoặc issue_anchor_id.
161
  - create_task: tạo task lịch khi có mốc thời gian cụ thể.
 
162
 
163
  Quy tắc quan trọng:
164
  - Nếu không có @bot và require_bot_mention=true: không tạo action, chỉ nhắc user gọi bằng @bot.
165
  - Khi có selected_messages thì chỉ dùng selected_messages làm nguồn chat chính.
166
  - Nếu có documents_index, hãy điều hướng cây theo từng bước để chọn section phù hợp với câu hỏi.
 
 
 
 
 
167
  - Nếu thiếu dữ liệu quan trọng, hỏi đúng 1-2 câu ngắn để chốt, không tự bịa.
168
  - Nếu user chỉ muốn tư vấn hoặc thảo luận, không cần action thì không tạo tool.
169
  - Nếu user yêu cầu làm luôn và đủ thông tin, có thể trả về nhiều action trong một lượt.
@@ -177,10 +201,19 @@ Schema mong muốn:
177
  "reply": "nội dung trả lời ngắn gọn cho user",
178
  "needs_confirmation": false,
179
  "missing_fields": ["..."],
 
180
  "actions": [
181
  {
182
  "type": "create_issue|update_issue|create_task",
183
  "payload": {
 
 
 
 
 
 
 
 
184
  "...": "..."
185
  }
186
  }
 
109
  "memory_summary": "nội dung memory có cấu trúc như trên"
110
  }"""
111
 
112
+ DOC_QA_COMPACT_PROMPT = """Bạn là bộ nhớ dài hạn riêng cho chế độ QA tài liệu của Team Chat.
113
+
114
+ Mục tiêu:
115
+ - Giữ các kết luận ổn định đã được xác nhận từ tài liệu.
116
+ - Giữ document/section/node quan trọng, thuật ngữ đặc thù, giả định còn dang dở và câu hỏi tiếp theo.
117
+ - Không lưu lời chào, lặp ý, hoặc chi tiết không ảnh hưởng đến câu trả lời sau.
118
+
119
+ Quy tắc:
120
+ - Chỉ giữ thông tin thật sự có ích cho các lượt QA sau.
121
+ - Nếu có xung đột, ưu tiên bằng chứng mới hơn và ghi rõ phần chưa chắc chắn.
122
+ - Không bịa thêm dữ kiện ngoài tài liệu và câu trả lời hiện tại.
123
+
124
+ Đầu ra JSON thuần:
125
+ {
126
+ "memory_summary": "..."
127
+ }"""
128
+
129
  VOICE_COMPACT_PROMPT = """Bạn là chuyên gia compact cho hội thoại giọng nói (voice chat) của Nomus AI.
130
 
131
  Mục tiêu:
 
170
  - Ưu tiên đọc đúng các tin nhắn user đã chọn thay vì đọc toàn bộ lịch sử.
171
  - Có thể phân tích bug, blocker, requirement, kế hoạch, task và trạng thái dự án.
172
  - Có thể đọc document theo cấu trúc cây (hierarchical index) và điều hướng theo từng nhánh.
173
+ - Có khả năng trả lời câu hỏi dựa trên tài liệu (document-grounded QA) giống phong cách NotebookLM: bám bằng chứng, trích dẫn rõ nguồn, không bịa.
174
  - Khi đủ dữ liệu, hãy đề xuất hoặc thực thi action phù hợp thay vì chỉ trả lời chung chung.
175
 
176
  Các action được hỗ trợ:
177
  - create_issue: tạo issue mới cho project.
178
  - update_issue: cập nhật issue hiện có theo issue_id hoặc issue_anchor_id.
179
  - create_task: tạo task lịch khi có mốc thời gian cụ thể.
180
+ - Khi tạo issue/task, luôn cố gắng gắn chúng vào một node requirement phù hợp.
181
 
182
  Quy tắc quan trọng:
183
  - Nếu không có @bot và require_bot_mention=true: không tạo action, chỉ nhắc user gọi bằng @bot.
184
  - Khi có selected_messages thì chỉ dùng selected_messages làm nguồn chat chính.
185
  - Nếu có documents_index, hãy điều hướng cây theo từng bước để chọn section phù hợp với câu hỏi.
186
+ - Khi câu hỏi thiên về giải thích/tóm tắt/so sánh nội dung tài liệu: ưu tiên trả lời dựa trên documents_sections + document_grounded_answer và để actions = [].
187
+ - Khi tạo work item, ưu tiên dùng requirement_node_reference làm node cha, và nêu rõ node_path trong reply.
188
+ - Khi ở chế độ QA docs, ưu tiên bám vào document_grounded_answer, citations và doc_qa_memory; không quên ngữ cảnh tài liệu đã được xác nhận ở các lượt trước.
189
+ - Nếu thiếu node requirement hợp lệ cho action, hãy hỏi user chọn node thay vì tự đoán.
190
+ - Khi thiếu bằng chứng từ tài liệu, nói rõ phần nào chưa có dữ liệu thay vì suy đoán.
191
  - Nếu thiếu dữ liệu quan trọng, hỏi đúng 1-2 câu ngắn để chốt, không tự bịa.
192
  - Nếu user chỉ muốn tư vấn hoặc thảo luận, không cần action thì không tạo tool.
193
  - Nếu user yêu cầu làm luôn và đủ thông tin, có thể trả về nhiều action trong một lượt.
 
201
  "reply": "nội dung trả lời ngắn gọn cho user",
202
  "needs_confirmation": false,
203
  "missing_fields": ["..."],
204
+ "citations": [{"document_id": "...", "section_id": "..."}],
205
  "actions": [
206
  {
207
  "type": "create_issue|update_issue|create_task",
208
  "payload": {
209
+ "requirement_node_id": "...",
210
+ "requirement_node_title": "...",
211
+ "requirement_node_path": "...",
212
+ "requirement_node_path_titles": ["..."],
213
+ "requirement_node_path_ids": ["..."],
214
+ "requirement_node_depth": 0,
215
+ "requirement_document_id": "...",
216
+ "requirement_document_name": "...",
217
  "...": "..."
218
  }
219
  }
routes_team_chat.py CHANGED
@@ -8,8 +8,13 @@ from core import TEAM_AGENT_MODEL, projects_collection, team_chat_collection, te
8
  from prompts import TEAM_AGENT_SYSTEM_PROMPT
9
  from schemas import TeamChatRequest
10
  from services import (
 
 
 
11
  create_issue_for_project,
12
  create_task_from_agent,
 
 
13
  get_selected_team_messages,
14
  get_team_agent_context,
15
  get_team_chat_context,
@@ -21,6 +26,7 @@ from services import (
21
  run_team_agent_with_nvidia,
22
  save_team_chat_message,
23
  save_team_document,
 
24
  unique_ids,
25
  update_issue_from_agent,
26
  )
@@ -28,6 +34,23 @@ from services import (
28
  router = APIRouter()
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  def _extract_json_payload(raw_text: str) -> Optional[Dict[str, Any]]:
32
  if not raw_text:
33
  return None
@@ -103,6 +126,29 @@ def _execute_agent_action(action: Dict[str, Any], user_id: str, req: TeamChatReq
103
  raise HTTPException(status_code=400, detail=f"Unsupported action: {action_type}")
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  def _assert_team_project_access(user: Dict[str, Any], team_id: str, project_id: Optional[str]) -> Optional[Dict[str, Any]]:
107
  team = teams_collection.find_one({"id": team_id}, {"_id": 0})
108
  if not team or user["id"] not in unique_ids([team.get("owner_id", "")], team.get("member_ids", [])):
@@ -173,12 +219,54 @@ async def upload_team_document(
173
  }
174
 
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  @router.post("/teams/chat")
177
  async def team_chat(req: TeamChatRequest, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")):
178
  user = require_session_user(x_session_token)
179
  project = _assert_team_project_access(user, req.team_id, req.project_id)
180
 
181
- user_msg = save_team_chat_message(req.team_id, "user", req.message, req.project_id)
 
 
 
 
 
 
182
 
183
  has_bot_mention = "@bot" in (req.message or "")
184
  if req.require_bot_mention and not has_bot_mention:
@@ -212,7 +300,16 @@ async def team_chat(req: TeamChatRequest, x_session_token: Optional[str] = Heade
212
  base_messages = selected_messages or fallback_messages
213
 
214
  team_docs = get_team_documents_by_ids(req.team_id, req.document_ids, req.project_id)
215
- doc_context = retrieve_document_context_with_tree(req.message, team_docs)
 
 
 
 
 
 
 
 
 
216
 
217
  doc_indexes = []
218
  for doc in team_docs:
@@ -244,16 +341,20 @@ OUTPUT REQUIREMENTS:
244
  - Trường actions là danh sách action có thể thực thi.
245
  - Nếu thiếu thông tin, đặt actions = [] và missing_fields ghi rõ thiếu gì.
246
  - Nếu user chưa cho phép tự động hóa, needs_confirmation = true khi có action cần làm.
 
 
 
247
 
248
  ACTION PAYLOAD GỢI Ý:
249
- - create_issue: title, description, severity, status, assignee_id|assignee_email|assignee_name, tags, requirement_text, attachment_urls
250
- - update_issue: issue_id, title, description, severity, status, assignee_id|assignee_email|assignee_name, tags, attachment_urls, requirement_text
251
- - create_task: title, description, start_time, end_time, priority, tags, reminder
252
 
253
  LƯU Ý CONTEXT:
254
  - selected_messages là nguồn hội thoại chính, ưu tiên tuyệt đối.
255
  - Nếu selected_messages rỗng thì mới dùng fallback_messages gần nhất.
256
  - documents_index là cây tài liệu; documents_sections là các section đã drill-down và lấy nguyên văn.
 
257
 
258
  OUTPUT SHAPE:
259
  {
@@ -265,6 +366,42 @@ OUTPUT SHAPE:
265
  }
266
  """
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  prompt_context = {
269
  "current_user": {"id": user["id"], "name": user.get("name"), "email": user.get("email")},
270
  "team_id": req.team_id,
@@ -276,8 +413,17 @@ OUTPUT SHAPE:
276
  "documents_index": doc_indexes,
277
  "documents_sections": doc_context.get("sections", []),
278
  "documents_citations": doc_context.get("citations", []),
 
 
 
 
 
 
 
279
  "new_message": req.message,
 
280
  "allow_auto_tool_call": req.allow_auto_tool_call,
 
281
  "current_time": get_vn_now().isoformat(),
282
  "agent_context": get_team_agent_context(req.team_id, req.project_id, req.issue_anchor_id, window=2),
283
  }
@@ -295,12 +441,81 @@ OUTPUT SHAPE:
295
  missing_fields = parsed.get("missing_fields") if isinstance(parsed.get("missing_fields"), list) else []
296
  needs_confirmation = bool(parsed.get("needs_confirmation"))
297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  tool_results: List[Dict[str, Any]] = []
299
  execution_errors: List[str] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  if req.allow_auto_tool_call and actions:
301
  for action in actions:
302
  try:
303
- tool_results.append(_execute_agent_action(action, user["id"], req))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  except HTTPException as exc:
305
  action_type = str(action.get("type") or "unknown")
306
  execution_errors.append(f"{action_type}: {exc.detail}")
@@ -314,6 +529,35 @@ OUTPUT SHAPE:
314
  if needs_confirmation and not assistant_text.endswith("?"):
315
  assistant_text = f"{assistant_text} Bạn có muốn mình tự xử lý luôn không?".strip()
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  if tool_results:
318
  summary_bits = []
319
  for item in tool_results:
@@ -327,9 +571,16 @@ OUTPUT SHAPE:
327
  summary_bits.append(f"đã tạo task {item['task'].get('title', '')}".strip())
328
  if summary_bits:
329
  assistant_text = f"{assistant_text}\n\nKết quả: {', '.join(summary_bits)}."
 
 
 
 
330
  if execution_errors:
331
  assistant_text = f"{assistant_text}\n\nMột số action chưa xử lý được: {', '.join(execution_errors)}."
332
 
 
 
 
333
  assistant_doc = save_team_chat_message(req.team_id, "assistant", assistant_text, req.project_id)
334
 
335
  return {
@@ -344,6 +595,15 @@ OUTPUT SHAPE:
344
  "selected_message_count": len(selected_messages),
345
  "document_section_count": len(doc_context.get("sections", [])),
346
  "document_citations": doc_context.get("citations", []),
 
 
 
 
 
 
 
 
 
347
  "used_bot_mention": has_bot_mention,
348
  "agent_model": TEAM_AGENT_MODEL,
349
  "timestamp": get_vn_now().isoformat(),
 
8
  from prompts import TEAM_AGENT_SYSTEM_PROMPT
9
  from schemas import TeamChatRequest
10
  from services import (
11
+ build_document_grounded_answer,
12
+ build_requirement_node_options_from_documents,
13
+ compact_team_doc_qa_memory,
14
  create_issue_for_project,
15
  create_task_from_agent,
16
+ get_team_doc_qa_memory,
17
+ resolve_requirement_node_reference_from_documents,
18
  get_selected_team_messages,
19
  get_team_agent_context,
20
  get_team_chat_context,
 
26
  run_team_agent_with_nvidia,
27
  save_team_chat_message,
28
  save_team_document,
29
+ store_uploaded_image,
30
  unique_ids,
31
  update_issue_from_agent,
32
  )
 
34
  router = APIRouter()
35
 
36
 
37
+ def _looks_like_action_request(message: str) -> bool:
38
+ text = (message or "").lower()
39
+ action_patterns = [
40
+ r"\btạo\b",
41
+ r"\bcreate\b",
42
+ r"\bcập nhật\b",
43
+ r"\bupdate\b",
44
+ r"\bassign\b",
45
+ r"\bgiao\b",
46
+ r"\bthực thi\b",
47
+ r"\blàm luôn\b",
48
+ r"\bissue\b",
49
+ r"\btask\b",
50
+ ]
51
+ return any(re.search(pattern, text) for pattern in action_patterns)
52
+
53
+
54
  def _extract_json_payload(raw_text: str) -> Optional[Dict[str, Any]]:
55
  if not raw_text:
56
  return None
 
126
  raise HTTPException(status_code=400, detail=f"Unsupported action: {action_type}")
127
 
128
 
129
+ def _merge_requirement_node_reference(payload: Dict[str, Any], node_ref: Dict[str, Any]) -> Dict[str, Any]:
130
+ if not isinstance(node_ref, dict) or not node_ref:
131
+ return payload
132
+
133
+ merged = dict(payload)
134
+ merged.setdefault("requirement_node_id", node_ref.get("node_id") or node_ref.get("section_id"))
135
+ merged.setdefault("requirement_node_title", node_ref.get("node_title") or node_ref.get("section_title"))
136
+ merged.setdefault("requirement_node_path", node_ref.get("node_path"))
137
+ merged.setdefault("requirement_node_path_titles", node_ref.get("node_path_titles", []))
138
+ merged.setdefault("requirement_node_path_ids", node_ref.get("node_path_ids", []))
139
+ merged.setdefault("requirement_node_depth", node_ref.get("node_depth"))
140
+ merged.setdefault("requirement_document_id", node_ref.get("document_id"))
141
+ merged.setdefault("requirement_document_name", node_ref.get("document_name"))
142
+ return merged
143
+
144
+
145
+ def _has_requirement_node_payload(payload: Dict[str, Any]) -> bool:
146
+ return any(
147
+ str(payload.get(field_name) or "").strip()
148
+ for field_name in ("requirement_node_id", "requirement_node_title", "requirement_node_path")
149
+ )
150
+
151
+
152
  def _assert_team_project_access(user: Dict[str, Any], team_id: str, project_id: Optional[str]) -> Optional[Dict[str, Any]]:
153
  team = teams_collection.find_one({"id": team_id}, {"_id": 0})
154
  if not team or user["id"] not in unique_ids([team.get("owner_id", "")], team.get("member_ids", [])):
 
219
  }
220
 
221
 
222
+ @router.post("/teams/{team_id}/chat/images")
223
+ async def upload_team_chat_images(
224
+ team_id: str,
225
+ files: List[UploadFile] = File(...),
226
+ project_id: Optional[str] = Form(None),
227
+ x_session_token: Optional[str] = Header(None, alias="X-Session-Token"),
228
+ ):
229
+ user = require_session_user(x_session_token)
230
+ _assert_team_project_access(user, team_id, project_id)
231
+
232
+ if not files:
233
+ raise HTTPException(status_code=400, detail="No image files provided")
234
+
235
+ scope_id = team_id if not project_id else f"{team_id}__{project_id}"
236
+ assets: List[Dict[str, Any]] = []
237
+ for file in files:
238
+ raw_bytes = await file.read()
239
+ if not raw_bytes:
240
+ continue
241
+ asset = store_uploaded_image(
242
+ raw_bytes=raw_bytes,
243
+ original_name=file.filename or "image",
244
+ scope="team",
245
+ scope_id=scope_id,
246
+ )
247
+ assets.append(asset)
248
+
249
+ if not assets:
250
+ raise HTTPException(status_code=400, detail="All uploaded files were empty")
251
+
252
+ return {
253
+ "assets": assets,
254
+ "urls": [asset["url"] for asset in assets],
255
+ }
256
+
257
+
258
  @router.post("/teams/chat")
259
  async def team_chat(req: TeamChatRequest, x_session_token: Optional[str] = Header(None, alias="X-Session-Token")):
260
  user = require_session_user(x_session_token)
261
  project = _assert_team_project_access(user, req.team_id, req.project_id)
262
 
263
+ user_msg = save_team_chat_message(
264
+ req.team_id,
265
+ "user",
266
+ req.message,
267
+ req.project_id,
268
+ attachment_urls=req.attachment_urls,
269
+ )
270
 
271
  has_bot_mention = "@bot" in (req.message or "")
272
  if req.require_bot_mention and not has_bot_mention:
 
300
  base_messages = selected_messages or fallback_messages
301
 
302
  team_docs = get_team_documents_by_ids(req.team_id, req.document_ids, req.project_id)
303
+ doc_context = retrieve_document_context_with_tree(
304
+ req.message,
305
+ team_docs,
306
+ selected_messages=base_messages,
307
+ )
308
+ preferred_requirement_node_reference = resolve_requirement_node_reference_from_documents(
309
+ team_docs,
310
+ req.preferred_requirement_node_id,
311
+ )
312
+ qa_memory = get_team_doc_qa_memory(req.team_id, req.project_id)
313
 
314
  doc_indexes = []
315
  for doc in team_docs:
 
341
  - Trường actions là danh sách action có thể thực thi.
342
  - Nếu thiếu thông tin, đặt actions = [] và missing_fields ghi rõ thiếu gì.
343
  - Nếu user chưa cho phép tự động hóa, needs_confirmation = true khi có action cần làm.
344
+ - Nếu câu hỏi thiên về tra cứu tài liệu, ưu tiên trả lời dựa trên documents_sections/document_grounded_answer và có thể để actions = [].
345
+ - Nếu doc_qa_only=true thì bắt buộc actions = [] và chỉ tập trung trả lời theo tài liệu.
346
+ - Nếu thiếu requirement node hợp lệ cho create_issue hoặc create_task, hãy để missing_fields có requirement_node_id thay vì tự đoán.
347
 
348
  ACTION PAYLOAD GỢI Ý:
349
+ - create_issue: title, description, severity, status, assignee_id|assignee_email|assignee_name, tags, requirement_text, attachment_urls, requirement_node_id, requirement_node_title, requirement_node_path, requirement_node_path_titles, requirement_node_path_ids, requirement_node_depth, requirement_document_id, requirement_document_name
350
+ - update_issue: issue_id, title, description, severity, status, assignee_id|assignee_email|assignee_name, tags, attachment_urls, requirement_text, requirement_node_id, requirement_node_title, requirement_node_path, requirement_node_path_titles, requirement_node_path_ids, requirement_node_depth, requirement_document_id, requirement_document_name
351
+ - create_task: title, description, start_time, end_time, priority, tags, reminder, requirement_node_id, requirement_node_title, requirement_node_path, requirement_node_path_titles, requirement_node_path_ids, requirement_node_depth, requirement_document_id, requirement_document_name
352
 
353
  LƯU Ý CONTEXT:
354
  - selected_messages là nguồn hội thoại chính, ưu tiên tuyệt đối.
355
  - Nếu selected_messages rỗng thì mới dùng fallback_messages gần nhất.
356
  - documents_index là cây tài liệu; documents_sections là các section đã drill-down và lấy nguyên văn.
357
+ - document_grounded_answer là bản nháp trả lời đã bám evidence; có thể tái sử dụng và tinh chỉnh.
358
 
359
  OUTPUT SHAPE:
360
  {
 
366
  }
367
  """
368
 
369
+ doc_context_for_answer = dict(doc_context)
370
+ doc_sections = list(doc_context.get("sections", []))
371
+ if preferred_requirement_node_reference:
372
+ preferred_node_id = str(preferred_requirement_node_reference.get("node_id") or "").strip()
373
+ preferred_section_id = str(preferred_requirement_node_reference.get("node_id") or "").strip()
374
+ preferred_match = None
375
+ for section in doc_sections:
376
+ section_id = str(section.get("section_id") or "").strip()
377
+ if section_id == preferred_section_id or section_id == preferred_node_id:
378
+ preferred_match = section
379
+ break
380
+ if preferred_match:
381
+ doc_sections = [preferred_match] + [section for section in doc_sections if section is not preferred_match]
382
+ doc_context_for_answer["sections"] = doc_sections
383
+ doc_context_for_answer["citations"] = [
384
+ citation for citation in doc_context.get("citations", []) if str(citation.get("section_id") or "").strip() != preferred_section_id
385
+ ]
386
+ doc_context_for_answer["citations"].insert(0, {
387
+ "document_id": preferred_match.get("document_id"),
388
+ "document_name": preferred_match.get("document_name"),
389
+ "section_id": preferred_match.get("section_id"),
390
+ "section_title": preferred_match.get("section_title"),
391
+ "section_path": preferred_match.get("section_path"),
392
+ "section_path_titles": preferred_match.get("section_path_titles", []),
393
+ "section_path_ids": preferred_match.get("section_path_ids", []),
394
+ "source": "preferred_node",
395
+ })
396
+
397
+ document_grounded = build_document_grounded_answer(
398
+ query=req.message,
399
+ selected_messages=base_messages,
400
+ doc_context=doc_context_for_answer,
401
+ qa_memory=qa_memory,
402
+ )
403
+ requirement_node_reference = preferred_requirement_node_reference or document_grounded.get("requirement_node_reference") or doc_context.get("requirement_node_reference") or {}
404
+
405
  prompt_context = {
406
  "current_user": {"id": user["id"], "name": user.get("name"), "email": user.get("email")},
407
  "team_id": req.team_id,
 
413
  "documents_index": doc_indexes,
414
  "documents_sections": doc_context.get("sections", []),
415
  "documents_citations": doc_context.get("citations", []),
416
+ "documents_retrieval_meta": doc_context.get("retrieval_meta", {}),
417
+ "document_grounded_answer": document_grounded.get("answer", ""),
418
+ "document_grounded_citations": document_grounded.get("citations", []),
419
+ "document_grounded_confidence": document_grounded.get("confidence", "medium"),
420
+ "doc_qa_memory": qa_memory,
421
+ "requirement_node_reference": requirement_node_reference,
422
+ "preferred_requirement_node_reference": preferred_requirement_node_reference,
423
  "new_message": req.message,
424
+ "new_message_attachments": req.attachment_urls,
425
  "allow_auto_tool_call": req.allow_auto_tool_call,
426
+ "doc_qa_only": req.doc_qa_only,
427
  "current_time": get_vn_now().isoformat(),
428
  "agent_context": get_team_agent_context(req.team_id, req.project_id, req.issue_anchor_id, window=2),
429
  }
 
441
  missing_fields = parsed.get("missing_fields") if isinstance(parsed.get("missing_fields"), list) else []
442
  needs_confirmation = bool(parsed.get("needs_confirmation"))
443
 
444
+ # Doc QA mode: for non-action prompts, prioritize grounded answer from document evidence.
445
+ should_force_doc_qa = req.doc_qa_only or not _looks_like_action_request(req.message)
446
+ if document_grounded.get("answer") and should_force_doc_qa:
447
+ reply_text = str(document_grounded.get("answer") or reply_text).strip()
448
+ actions = []
449
+ missing_fields = []
450
+ needs_confirmation = False
451
+
452
+ if should_force_doc_qa:
453
+ try:
454
+ qa_memory_result = await compact_team_doc_qa_memory(
455
+ req.team_id,
456
+ req.project_id,
457
+ req.message,
458
+ str(document_grounded.get("answer") or ""),
459
+ doc_context_for_answer,
460
+ base_messages,
461
+ citations=document_grounded.get("citations", []),
462
+ )
463
+ qa_memory = str(qa_memory_result.get("memory_summary") or qa_memory or "").strip()
464
+ except Exception:
465
+ pass
466
+
467
+ grounded_confidence = str(document_grounded.get("confidence") or "medium").strip().lower()
468
+ grounded_needs_clarification = bool(document_grounded.get("needs_clarification"))
469
+ if should_force_doc_qa and (grounded_confidence == "low" or grounded_needs_clarification):
470
+ followup = str(document_grounded.get("clarifying_question") or "").strip()
471
+ if followup:
472
+ reply_text = f"{reply_text}\n\n{followup}".strip()
473
+ actions = []
474
+ missing_fields = []
475
+ needs_confirmation = False
476
+
477
+ if req.doc_qa_only:
478
+ actions = []
479
+ missing_fields = []
480
+ needs_confirmation = False
481
+
482
  tool_results: List[Dict[str, Any]] = []
483
  execution_errors: List[str] = []
484
+ node_selection_options = build_requirement_node_options_from_documents(team_docs, limit=8)
485
+ node_confirmation_required = False
486
+ if actions:
487
+ for action in actions:
488
+ if action.get("type") not in {"create_issue", "create_task"}:
489
+ continue
490
+ merged_preview = _merge_requirement_node_reference(
491
+ dict(action.get("payload") or {}),
492
+ requirement_node_reference if isinstance(requirement_node_reference, dict) else {},
493
+ )
494
+ if not _has_requirement_node_payload(merged_preview):
495
+ node_confirmation_required = True
496
+ needs_confirmation = True
497
+ missing_fields = list({*missing_fields, "requirement_node_id"})
498
+ break
499
  if req.allow_auto_tool_call and actions:
500
  for action in actions:
501
  try:
502
+ enriched_action = dict(action)
503
+ enriched_action["payload"] = _merge_requirement_node_reference(
504
+ dict(action.get("payload") or {}),
505
+ requirement_node_reference if isinstance(requirement_node_reference, dict) else {},
506
+ )
507
+ if action.get("type") in {"create_issue", "create_task"} and node_confirmation_required and not _has_requirement_node_payload(enriched_action["payload"]):
508
+ needs_confirmation = True
509
+ tool_results.append(
510
+ {
511
+ "type": action.get("type"),
512
+ "status": "needs_confirmation",
513
+ "error": "missing_requirement_node",
514
+ "node_selection_options": node_selection_options,
515
+ }
516
+ )
517
+ continue
518
+ tool_results.append(_execute_agent_action(enriched_action, user["id"], req))
519
  except HTTPException as exc:
520
  action_type = str(action.get("type") or "unknown")
521
  execution_errors.append(f"{action_type}: {exc.detail}")
 
529
  if needs_confirmation and not assistant_text.endswith("?"):
530
  assistant_text = f"{assistant_text} Bạn có muốn mình tự xử lý luôn không?".strip()
531
 
532
+ if not actions and isinstance(document_grounded.get("citations"), list) and document_grounded.get("citations"):
533
+ section_lookup: Dict[str, Dict[str, str]] = {}
534
+ for sec in doc_context.get("sections", []):
535
+ sec_id = str(sec.get("section_id") or "").strip()
536
+ if not sec_id:
537
+ continue
538
+ section_lookup[sec_id] = {
539
+ "document_name": str(sec.get("document_name") or "Tài liệu"),
540
+ "section_title": str(sec.get("section_title") or sec_id),
541
+ }
542
+
543
+ source_refs: List[str] = []
544
+ for item in document_grounded.get("citations", [])[:3]:
545
+ section_id = str(item.get("section_id") or "").strip()
546
+ if not section_id:
547
+ continue
548
+ lookup = section_lookup.get(section_id)
549
+ if lookup:
550
+ source_refs.append(f"{lookup['document_name']} > {lookup['section_title']}")
551
+ else:
552
+ source_refs.append(f"Section {section_id}")
553
+ if source_refs:
554
+ assistant_text = f"{assistant_text}\n\nNguồn tham chiếu: {', '.join(source_refs)}"
555
+
556
+ if requirement_node_reference and not actions:
557
+ node_display = str(requirement_node_reference.get("node_display") or "").strip()
558
+ if node_display:
559
+ assistant_text = f"{assistant_text}\n\nNode đề xuất: {node_display}".strip()
560
+
561
  if tool_results:
562
  summary_bits = []
563
  for item in tool_results:
 
571
  summary_bits.append(f"đã tạo task {item['task'].get('title', '')}".strip())
572
  if summary_bits:
573
  assistant_text = f"{assistant_text}\n\nKết quả: {', '.join(summary_bits)}."
574
+ if requirement_node_reference:
575
+ node_display = str(requirement_node_reference.get("node_display") or "").strip()
576
+ if node_display:
577
+ assistant_text = f"{assistant_text}\nNode: {node_display}".strip()
578
  if execution_errors:
579
  assistant_text = f"{assistant_text}\n\nMột số action chưa xử lý được: {', '.join(execution_errors)}."
580
 
581
+ if needs_confirmation and node_selection_options:
582
+ assistant_text = f"{assistant_text}\n\nMình cần bạn chọn requirement node trước khi tạo issue/task.".strip()
583
+
584
  assistant_doc = save_team_chat_message(req.team_id, "assistant", assistant_text, req.project_id)
585
 
586
  return {
 
595
  "selected_message_count": len(selected_messages),
596
  "document_section_count": len(doc_context.get("sections", [])),
597
  "document_citations": doc_context.get("citations", []),
598
+ "document_retrieval_meta": doc_context.get("retrieval_meta", {}),
599
+ "document_grounded": document_grounded,
600
+ "document_grounded_confidence": document_grounded.get("confidence", "medium"),
601
+ "document_grounded_confidence_score": document_grounded.get("confidence_score", 0.0),
602
+ "requirement_node_reference": requirement_node_reference,
603
+ "node_selection_required": bool(node_confirmation_required),
604
+ "node_selection_options": node_selection_options if node_confirmation_required else [],
605
+ "node_selection_reason": "missing_requirement_node" if node_confirmation_required else None,
606
+ "doc_qa_only": req.doc_qa_only,
607
  "used_bot_mention": has_bot_mention,
608
  "agent_model": TEAM_AGENT_MODEL,
609
  "timestamp": get_vn_now().isoformat(),
routes_workspace.py CHANGED
@@ -209,6 +209,14 @@ async def create_project_issue(project_id: str, req: IssueCreateRequest, x_sessi
209
  "assignee_id": req.assignee_id,
210
  "tags": req.tags,
211
  "requirement_text": req.requirement_text,
 
 
 
 
 
 
 
 
212
  "attachment_urls": req.attachment_urls,
213
  "reporter_id": user["id"],
214
  "created_at": get_vn_now().isoformat(),
@@ -229,10 +237,14 @@ async def update_project_issue(issue_id: str, req: IssueUpdateRequest, x_session
229
  raise HTTPException(status_code=404, detail="Project not found")
230
 
231
  update_data: Dict[str, Any] = {"updated_at": get_vn_now().isoformat()}
232
- for field_name in ["title", "description", "severity", "status", "assignee_id", "tags", "attachment_urls"]:
233
  value = getattr(req, field_name)
234
  if value is not None:
235
  update_data[field_name] = value if not isinstance(value, str) else value.strip()
 
 
 
 
236
  issues_collection.update_one({"id": issue_id}, {"$set": update_data})
237
  return {"message": "Issue updated"}
238
 
 
209
  "assignee_id": req.assignee_id,
210
  "tags": req.tags,
211
  "requirement_text": req.requirement_text,
212
+ "requirement_node_id": req.requirement_node_id,
213
+ "requirement_node_title": req.requirement_node_title,
214
+ "requirement_node_path": req.requirement_node_path,
215
+ "requirement_node_path_titles": req.requirement_node_path_titles,
216
+ "requirement_node_path_ids": req.requirement_node_path_ids,
217
+ "requirement_node_depth": req.requirement_node_depth,
218
+ "requirement_document_id": req.requirement_document_id,
219
+ "requirement_document_name": req.requirement_document_name,
220
  "attachment_urls": req.attachment_urls,
221
  "reporter_id": user["id"],
222
  "created_at": get_vn_now().isoformat(),
 
237
  raise HTTPException(status_code=404, detail="Project not found")
238
 
239
  update_data: Dict[str, Any] = {"updated_at": get_vn_now().isoformat()}
240
+ for field_name in ["title", "description", "severity", "status", "assignee_id", "tags", "attachment_urls", "requirement_node_id", "requirement_node_title", "requirement_node_path", "requirement_node_depth", "requirement_document_id", "requirement_document_name"]:
241
  value = getattr(req, field_name)
242
  if value is not None:
243
  update_data[field_name] = value if not isinstance(value, str) else value.strip()
244
+ if req.requirement_node_path_titles is not None:
245
+ update_data["requirement_node_path_titles"] = req.requirement_node_path_titles
246
+ if req.requirement_node_path_ids is not None:
247
+ update_data["requirement_node_path_ids"] = req.requirement_node_path_ids
248
  issues_collection.update_one({"id": issue_id}, {"$set": update_data})
249
  return {"message": "Issue updated"}
250
 
schemas.py CHANGED
@@ -16,6 +16,14 @@ class ManualTaskRequest(BaseModel):
16
  priority: str = "medium"
17
  tags: List[str] = []
18
  reminder: Optional[str] = None
 
 
 
 
 
 
 
 
19
 
20
 
21
  class TTSRequest(BaseModel):
@@ -67,6 +75,14 @@ class IssueCreateRequest(BaseModel):
67
  assignee_id: Optional[str] = None
68
  tags: List[str] = Field(default_factory=list)
69
  requirement_text: Optional[str] = None
 
 
 
 
 
 
 
 
70
  attachment_urls: List[str] = Field(default_factory=list)
71
 
72
 
@@ -78,6 +94,14 @@ class IssueUpdateRequest(BaseModel):
78
  assignee_id: Optional[str] = None
79
  tags: Optional[List[str]] = None
80
  attachment_urls: Optional[List[str]] = None
 
 
 
 
 
 
 
 
81
 
82
 
83
  class ProjectSuggestRequest(BaseModel):
@@ -90,8 +114,11 @@ class TeamChatRequest(BaseModel):
90
  message: str
91
  issue_anchor_id: Optional[str] = None
92
  allow_auto_tool_call: bool = False
 
 
93
  selected_message_ids: List[str] = Field(default_factory=list)
94
  document_ids: List[str] = Field(default_factory=list)
 
95
  require_bot_mention: bool = True
96
 
97
 
@@ -102,6 +129,14 @@ class TeamChatToolCreateIssue(BaseModel):
102
  status: str = "open"
103
  assignee_id: Optional[str] = None
104
  tags: List[str] = Field(default_factory=list)
 
 
 
 
 
 
 
 
105
 
106
 
107
  class TeamChatToolCreateTask(BaseModel):
@@ -112,3 +147,11 @@ class TeamChatToolCreateTask(BaseModel):
112
  priority: str = "medium"
113
  tags: List[str] = Field(default_factory=list)
114
  reminder: Optional[str] = None
 
 
 
 
 
 
 
 
 
16
  priority: str = "medium"
17
  tags: List[str] = []
18
  reminder: Optional[str] = None
19
+ requirement_node_id: Optional[str] = None
20
+ requirement_node_title: Optional[str] = None
21
+ requirement_node_path: Optional[str] = None
22
+ requirement_node_path_titles: List[str] = Field(default_factory=list)
23
+ requirement_node_path_ids: List[str] = Field(default_factory=list)
24
+ requirement_node_depth: Optional[int] = None
25
+ requirement_document_id: Optional[str] = None
26
+ requirement_document_name: Optional[str] = None
27
 
28
 
29
  class TTSRequest(BaseModel):
 
75
  assignee_id: Optional[str] = None
76
  tags: List[str] = Field(default_factory=list)
77
  requirement_text: Optional[str] = None
78
+ requirement_node_id: Optional[str] = None
79
+ requirement_node_title: Optional[str] = None
80
+ requirement_node_path: Optional[str] = None
81
+ requirement_node_path_titles: List[str] = Field(default_factory=list)
82
+ requirement_node_path_ids: List[str] = Field(default_factory=list)
83
+ requirement_node_depth: Optional[int] = None
84
+ requirement_document_id: Optional[str] = None
85
+ requirement_document_name: Optional[str] = None
86
  attachment_urls: List[str] = Field(default_factory=list)
87
 
88
 
 
94
  assignee_id: Optional[str] = None
95
  tags: Optional[List[str]] = None
96
  attachment_urls: Optional[List[str]] = None
97
+ requirement_node_id: Optional[str] = None
98
+ requirement_node_title: Optional[str] = None
99
+ requirement_node_path: Optional[str] = None
100
+ requirement_node_path_titles: Optional[List[str]] = None
101
+ requirement_node_path_ids: Optional[List[str]] = None
102
+ requirement_node_depth: Optional[int] = None
103
+ requirement_document_id: Optional[str] = None
104
+ requirement_document_name: Optional[str] = None
105
 
106
 
107
  class ProjectSuggestRequest(BaseModel):
 
114
  message: str
115
  issue_anchor_id: Optional[str] = None
116
  allow_auto_tool_call: bool = False
117
+ doc_qa_only: bool = False
118
+ preferred_requirement_node_id: Optional[str] = None
119
  selected_message_ids: List[str] = Field(default_factory=list)
120
  document_ids: List[str] = Field(default_factory=list)
121
+ attachment_urls: List[str] = Field(default_factory=list)
122
  require_bot_mention: bool = True
123
 
124
 
 
129
  status: str = "open"
130
  assignee_id: Optional[str] = None
131
  tags: List[str] = Field(default_factory=list)
132
+ requirement_node_id: Optional[str] = None
133
+ requirement_node_title: Optional[str] = None
134
+ requirement_node_path: Optional[str] = None
135
+ requirement_node_path_titles: List[str] = Field(default_factory=list)
136
+ requirement_node_path_ids: List[str] = Field(default_factory=list)
137
+ requirement_node_depth: Optional[int] = None
138
+ requirement_document_id: Optional[str] = None
139
+ requirement_document_name: Optional[str] = None
140
 
141
 
142
  class TeamChatToolCreateTask(BaseModel):
 
147
  priority: str = "medium"
148
  tags: List[str] = Field(default_factory=list)
149
  reminder: Optional[str] = None
150
+ requirement_node_id: Optional[str] = None
151
+ requirement_node_title: Optional[str] = None
152
+ requirement_node_path: Optional[str] = None
153
+ requirement_node_path_titles: List[str] = Field(default_factory=list)
154
+ requirement_node_path_ids: List[str] = Field(default_factory=list)
155
+ requirement_node_depth: Optional[int] = None
156
+ requirement_document_id: Optional[str] = None
157
+ requirement_document_name: Optional[str] = None
services.py CHANGED
@@ -1,8 +1,10 @@
1
  import asyncio
 
2
  import hashlib
3
  import hmac
4
  import io
5
  import json
 
6
  import os
7
  import re
8
  import secrets
@@ -55,7 +57,7 @@ from core import (
55
  UPLOAD_DIR,
56
  WHISPER_MODEL_NAME,
57
  )
58
- from prompts import TTS_REWRITE_PROMPT
59
 
60
  VN_TZ = ZoneInfo("Asia/Ho_Chi_Minh")
61
 
@@ -209,6 +211,89 @@ def get_memory() -> str:
209
  return mem["content"] if mem else ""
210
 
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  async def compact_chat_with_prompt(system_prompt: str, min_messages: int = 6) -> Dict[str, Any]:
213
  messages = get_daily_chat()
214
  if len(messages) < min_messages:
@@ -585,6 +670,7 @@ def build_document_tree(text: str) -> Dict[str, Any]:
585
  "level": level,
586
  "title": title.strip() or f"Section {node_counter}",
587
  "summary": "",
 
588
  "scope": "",
589
  "content": "",
590
  "children": [],
@@ -619,6 +705,7 @@ def build_document_tree(text: str) -> Dict[str, Any]:
619
  paragraphs = [part.strip() for part in node["content"].split("\n") if part.strip()]
620
  summary = paragraphs[0] if paragraphs else f"Mục {node['title']}"
621
  node["summary"] = summary[:280]
 
622
  node["scope"] = f"Dùng để trả lời câu hỏi liên quan tới: {node['title']}"
623
  node["content"] = node["content"].strip()
624
 
@@ -629,6 +716,94 @@ def build_document_tree(text: str) -> Dict[str, Any]:
629
  }
630
 
631
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
632
  def save_team_document(
633
  team_id: str,
634
  project_id: Optional[str],
@@ -639,6 +814,7 @@ def save_team_document(
639
  ) -> Dict[str, Any]:
640
  text = _safe_decode_text(raw_bytes, file_name)
641
  tree = build_document_tree(text)
 
642
  doc = {
643
  "id": str(uuid.uuid4()),
644
  "team_id": team_id,
@@ -648,6 +824,8 @@ def save_team_document(
648
  "uploader_id": uploader_id,
649
  "tree": tree,
650
  "text": text,
 
 
651
  "created_at": get_vn_now().isoformat(),
652
  "updated_at": get_vn_now().isoformat(),
653
  }
@@ -673,9 +851,33 @@ def list_team_documents(team_id: str, project_id: Optional[str] = None) -> List[
673
  "created_at": 1,
674
  "updated_at": 1,
675
  "tree.total_nodes": 1,
 
 
 
 
 
 
676
  },
677
  ).sort("updated_at", -1)
678
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  return docs
680
 
681
 
@@ -691,6 +893,42 @@ def get_team_documents_by_ids(team_id: str, doc_ids: List[str], project_id: Opti
691
  return ordered
692
 
693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  def _nvidia_chat_completion(system_prompt: str, user_prompt: str, model: Optional[str] = None, temperature: float = 0.1, max_tokens: int = 1200) -> str:
695
  if not NVIDIA_KEY:
696
  raise HTTPException(status_code=500, detail="Missing NVIDIA_KEY for team agent")
@@ -746,12 +984,447 @@ def _extract_json_object(text: str) -> Dict[str, Any]:
746
  return {}
747
 
748
 
749
- def retrieve_document_context_with_tree(query: str, documents: List[Dict[str, Any]]) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
750
  if not documents:
751
  return {"sections": [], "citations": []}
752
 
753
  picked_sections: List[Dict[str, Any]] = []
754
  citations: List[Dict[str, Any]] = []
 
 
 
 
755
 
756
  for doc in documents:
757
  tree = doc.get("tree") or {}
@@ -810,27 +1483,251 @@ def retrieve_document_context_with_tree(query: str, documents: List[Dict[str, An
810
  if not selected_node:
811
  continue
812
 
813
- section_text = selected_node.get("content", "")
814
- picked_sections.append(
 
 
 
 
815
  {
816
  "document_id": doc.get("id"),
817
  "document_name": doc.get("name"),
818
- "section_id": selected_node.get("id"),
819
- "section_title": selected_node.get("title"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
  "section_content": section_text,
821
- "section_summary": selected_node.get("summary", ""),
 
 
 
 
 
 
 
 
822
  }
823
  )
824
  citations.append(
825
  {
826
- "document_id": doc.get("id"),
827
- "document_name": doc.get("name"),
828
- "section_id": selected_node.get("id"),
829
- "section_title": selected_node.get("title"),
 
 
 
 
830
  }
831
  )
 
 
832
 
833
- return {"sections": picked_sections, "citations": citations}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
834
 
835
 
836
  def run_team_agent_with_nvidia(system_prompt: str, payload: Dict[str, Any]) -> str:
@@ -843,13 +1740,20 @@ def run_team_agent_with_nvidia(system_prompt: str, payload: Dict[str, Any]) -> s
843
  )
844
 
845
 
846
- def save_team_chat_message(team_id: str, role: str, content: str, project_id: Optional[str] = None) -> Dict[str, Any]:
 
 
 
 
 
 
847
  doc = {
848
  "id": str(uuid.uuid4()),
849
  "team_id": team_id,
850
  "project_id": project_id,
851
  "role": role,
852
  "content": content,
 
853
  "timestamp": get_vn_now().isoformat(),
854
  }
855
  team_chat_collection.insert_one(doc)
@@ -871,6 +1775,14 @@ def create_issue_for_project(project_id: str, reporter_id: str, payload: Dict[st
871
  "assignee_id": assignee["id"] if assignee else payload.get("assignee_id"),
872
  "tags": tags,
873
  "requirement_text": payload.get("requirement_text"),
 
 
 
 
 
 
 
 
874
  "attachment_urls": payload.get("attachment_urls", []),
875
  "reporter_id": reporter_id,
876
  "created_at": get_vn_now().isoformat(),
@@ -893,6 +1805,14 @@ def create_task_from_agent(payload: Dict[str, Any]) -> Dict[str, Any]:
893
  "priority": payload.get("priority", "medium"),
894
  "tags": tags,
895
  "reminder": payload.get("reminder") or payload.get("start_time"),
 
 
 
 
 
 
 
 
896
  }
897
  tasks_collection.insert_one(task)
898
  return task
@@ -904,7 +1824,7 @@ def update_issue_from_agent(issue_id: str, payload: Dict[str, Any]) -> Dict[str,
904
  raise HTTPException(status_code=404, detail="Issue not found")
905
 
906
  update_data: Dict[str, Any] = {"updated_at": get_vn_now().isoformat()}
907
- field_names = ["title", "description", "severity", "status", "tags", "attachment_urls", "requirement_text"]
908
  for field_name in field_names:
909
  value = payload.get(field_name)
910
  if value is None:
@@ -914,6 +1834,13 @@ def update_issue_from_agent(issue_id: str, payload: Dict[str, Any]) -> Dict[str,
914
  else:
915
  update_data[field_name] = value
916
 
 
 
 
 
 
 
 
917
  assignee = resolve_user_reference(payload)
918
  if assignee:
919
  update_data["assignee_id"] = assignee["id"]
 
1
  import asyncio
2
+ from collections import Counter
3
  import hashlib
4
  import hmac
5
  import io
6
  import json
7
+ import math
8
  import os
9
  import re
10
  import secrets
 
57
  UPLOAD_DIR,
58
  WHISPER_MODEL_NAME,
59
  )
60
+ from prompts import DOC_QA_COMPACT_PROMPT, TTS_REWRITE_PROMPT
61
 
62
  VN_TZ = ZoneInfo("Asia/Ho_Chi_Minh")
63
 
 
211
  return mem["content"] if mem else ""
212
 
213
 
214
+ def _team_doc_qa_memory_scope_key(team_id: str, project_id: Optional[str]) -> str:
215
+ return f"{team_id}::{project_id or 'global'}"
216
+
217
+
218
+ def get_team_doc_qa_memory(team_id: str, project_id: Optional[str]) -> str:
219
+ scope_key = _team_doc_qa_memory_scope_key(team_id, project_id)
220
+ mem = memory_collection.find_one({"type": "team_doc_qa_memory", "scope_key": scope_key}, {"_id": 0})
221
+ return str(mem.get("content") or "") if mem else ""
222
+
223
+
224
+ def _save_team_doc_qa_memory(team_id: str, project_id: Optional[str], content: str) -> None:
225
+ scope_key = _team_doc_qa_memory_scope_key(team_id, project_id)
226
+ memory_collection.update_one(
227
+ {"type": "team_doc_qa_memory", "scope_key": scope_key},
228
+ {
229
+ "$set": {
230
+ "type": "team_doc_qa_memory",
231
+ "scope_key": scope_key,
232
+ "team_id": team_id,
233
+ "project_id": project_id,
234
+ "content": content,
235
+ "updated_at": get_vn_now().isoformat(),
236
+ }
237
+ },
238
+ upsert=True,
239
+ )
240
+
241
+
242
+ async def compact_team_doc_qa_memory(
243
+ team_id: str,
244
+ project_id: Optional[str],
245
+ query: str,
246
+ answer: str,
247
+ doc_context: Dict[str, Any],
248
+ selected_messages: List[Dict[str, Any]],
249
+ citations: Optional[List[Dict[str, Any]]] = None,
250
+ ) -> Dict[str, Any]:
251
+ current_memory = get_team_doc_qa_memory(team_id, project_id)
252
+ sections = doc_context.get("sections") if isinstance(doc_context, dict) else []
253
+ payload = {
254
+ "team_id": team_id,
255
+ "project_id": project_id,
256
+ "current_memory": current_memory,
257
+ "query": query,
258
+ "answer": answer,
259
+ "selected_messages": selected_messages[-4:] if isinstance(selected_messages, list) else [],
260
+ "citations": citations[:6] if isinstance(citations, list) else [],
261
+ "evidence_sections": [
262
+ {
263
+ "document_id": section.get("document_id"),
264
+ "document_name": section.get("document_name"),
265
+ "section_id": section.get("section_id"),
266
+ "section_title": section.get("section_title"),
267
+ "section_path": section.get("section_path"),
268
+ "section_content": _clip_text(str(section.get("section_content") or ""), 420),
269
+ "section_summary": _clip_text(str(section.get("section_summary") or ""), 240),
270
+ }
271
+ for section in sections[:6]
272
+ ],
273
+ }
274
+
275
+ def run_compact() -> str:
276
+ return _nvidia_chat_completion(
277
+ system_prompt=DOC_QA_COMPACT_PROMPT,
278
+ user_prompt=json.dumps(payload, ensure_ascii=False),
279
+ model=TEAM_AGENT_MODEL,
280
+ temperature=0.0,
281
+ max_tokens=800,
282
+ )
283
+
284
+ result_text = await asyncio.to_thread(run_compact)
285
+ result_json = _extract_json_object(result_text)
286
+ memory_summary = str(result_json.get("memory_summary") or "").strip()
287
+ if not memory_summary:
288
+ memory_summary = current_memory
289
+ if memory_summary:
290
+ _save_team_doc_qa_memory(team_id, project_id, memory_summary)
291
+ return {
292
+ "memory_summary": memory_summary,
293
+ "raw": result_json,
294
+ }
295
+
296
+
297
  async def compact_chat_with_prompt(system_prompt: str, min_messages: int = 6) -> Dict[str, Any]:
298
  messages = get_daily_chat()
299
  if len(messages) < min_messages:
 
670
  "level": level,
671
  "title": title.strip() or f"Section {node_counter}",
672
  "summary": "",
673
+ "contextual_summary": "",
674
  "scope": "",
675
  "content": "",
676
  "children": [],
 
705
  paragraphs = [part.strip() for part in node["content"].split("\n") if part.strip()]
706
  summary = paragraphs[0] if paragraphs else f"Mục {node['title']}"
707
  node["summary"] = summary[:280]
708
+ node["contextual_summary"] = node["summary"]
709
  node["scope"] = f"Dùng để trả lời câu hỏi liên quan tới: {node['title']}"
710
  node["content"] = node["content"].strip()
711
 
 
716
  }
717
 
718
 
719
+ def _contextualize_document_tree(file_name: str, text: str, tree: Dict[str, Any]) -> Dict[str, Any]:
720
+ nodes = tree.get("nodes") or []
721
+ target_nodes = [node for node in nodes if node.get("id") and node.get("id") != tree.get("root_id")]
722
+ if not target_nodes:
723
+ return {
724
+ "global_summary": f"Tài liệu {file_name}",
725
+ "context_coverage": 0,
726
+ "context_source": "fallback",
727
+ }
728
+
729
+ compact_nodes = [
730
+ {
731
+ "id": str(node.get("id") or ""),
732
+ "title": str(node.get("title") or ""),
733
+ "summary": _clip_text(str(node.get("summary") or ""), 180),
734
+ "content_snippet": _clip_text(str(node.get("content") or ""), 180),
735
+ }
736
+ for node in target_nodes[:80]
737
+ ]
738
+
739
+ global_context = ""
740
+ contextual_map: Dict[str, str] = {}
741
+
742
+ try:
743
+ payload = {
744
+ "file_name": file_name,
745
+ "document_snippet": _clip_text(text, 1600),
746
+ "nodes": compact_nodes,
747
+ "instruction": (
748
+ "Sinh context retrieval cho từng node để tăng độ chính xác tìm kiếm. "
749
+ "Mỗi context 1 câu ngắn, có thực thể/chủ đề cụ thể, không bịa thêm dữ kiện."
750
+ ),
751
+ }
752
+ response = _nvidia_chat_completion(
753
+ system_prompt=(
754
+ "Trả về JSON thuần: "
755
+ "{\"global_summary\":\"...\",\"nodes\":[{\"id\":\"sec_x\",\"context\":\"...\"}]}."
756
+ ),
757
+ user_prompt=json.dumps(payload, ensure_ascii=False),
758
+ model=TEAM_AGENT_MODEL,
759
+ temperature=0.0,
760
+ max_tokens=1000,
761
+ )
762
+ parsed = _extract_json_object(response)
763
+ global_context = str(parsed.get("global_summary") or "").strip()
764
+ node_items = parsed.get("nodes") if isinstance(parsed.get("nodes"), list) else []
765
+ for item in node_items:
766
+ if not isinstance(item, dict):
767
+ continue
768
+ node_id = str(item.get("id") or "").strip()
769
+ context = str(item.get("context") or "").strip()
770
+ if node_id and context:
771
+ contextual_map[node_id] = _clip_text(context, 260)
772
+ except Exception:
773
+ global_context = ""
774
+
775
+ if not global_context:
776
+ first_lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
777
+ global_context = _clip_text(" ".join(first_lines[:3]) or f"Tài liệu {file_name}", 260)
778
+
779
+ applied = 0
780
+ for node in target_nodes:
781
+ node_id = str(node.get("id") or "")
782
+ node_context = contextual_map.get(node_id)
783
+ if not node_context:
784
+ node_context = _clip_text(
785
+ f"{global_context}. Mục {node.get('title')}: {node.get('summary') or 'Nội dung liên quan'}",
786
+ 260,
787
+ )
788
+ else:
789
+ applied += 1
790
+ node["contextual_summary"] = node_context
791
+
792
+ root_id = tree.get("root_id")
793
+ for node in nodes:
794
+ if node.get("id") == root_id:
795
+ node["summary"] = _clip_text(global_context, 280)
796
+ node["contextual_summary"] = node["summary"]
797
+ node["scope"] = "Tóm tắt toàn bộ tài liệu cho truy vấn tổng quan"
798
+ break
799
+
800
+ return {
801
+ "global_summary": global_context,
802
+ "context_coverage": round(applied / max(1, len(target_nodes)), 4),
803
+ "context_source": "llm_contextualizer" if contextual_map else "fallback",
804
+ }
805
+
806
+
807
  def save_team_document(
808
  team_id: str,
809
  project_id: Optional[str],
 
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,
 
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
  }
 
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"),
869
+ "parent_id": node.get("parent_id"),
870
+ "level": node.get("level"),
871
+ "title": node.get("title"),
872
+ "summary": node.get("summary"),
873
+ "contextual_summary": node.get("contextual_summary"),
874
+ "path": _build_node_path(tree, str(node.get("id") or "")).get("node_path", ""),
875
+ "path_titles": _build_node_path(tree, str(node.get("id") or "")).get("node_path_titles", []),
876
+ "path_ids": _build_node_path(tree, str(node.get("id") or "")).get("node_path_ids", []),
877
+ }
878
+ for node in nodes
879
+ if node.get("id")
880
+ ]
881
  return docs
882
 
883
 
 
893
  return ordered
894
 
895
 
896
+ def build_requirement_node_options_from_documents(documents: List[Dict[str, Any]], limit: int = 8) -> List[Dict[str, Any]]:
897
+ options: List[Dict[str, Any]] = []
898
+ seen_ids: set[str] = set()
899
+
900
+ for doc in documents:
901
+ tree = doc.get("tree") or {}
902
+ for node in tree.get("nodes") or []:
903
+ node_id = str(node.get("id") or "").strip()
904
+ if not node_id or node_id in seen_ids:
905
+ continue
906
+
907
+ path = _build_node_path(tree, node_id)
908
+ node_title = str(node.get("title") or "").strip()
909
+ node_path = str(path.get("node_path") or "").strip()
910
+ if not node_title and not node_path:
911
+ continue
912
+
913
+ options.append(
914
+ {
915
+ "node_id": node_id,
916
+ "node_title": node_title or node_id,
917
+ "node_path": node_path or node_title or node_id,
918
+ "node_path_titles": path.get("node_path_titles", []),
919
+ "node_path_ids": path.get("node_path_ids", []),
920
+ "node_depth": path.get("node_depth", 0),
921
+ "document_id": doc.get("id"),
922
+ "document_name": doc.get("name"),
923
+ }
924
+ )
925
+ seen_ids.add(node_id)
926
+ if len(options) >= limit:
927
+ return options
928
+
929
+ return options
930
+
931
+
932
  def _nvidia_chat_completion(system_prompt: str, user_prompt: str, model: Optional[str] = None, temperature: float = 0.1, max_tokens: int = 1200) -> str:
933
  if not NVIDIA_KEY:
934
  raise HTTPException(status_code=500, detail="Missing NVIDIA_KEY for team agent")
 
984
  return {}
985
 
986
 
987
+ def _normalize_search_text(text: str) -> str:
988
+ lowered = (text or "").lower()
989
+ return re.sub(r"[^\w\s]", " ", lowered, flags=re.UNICODE)
990
+
991
+
992
+ def _tokenize_search_text(text: str) -> List[str]:
993
+ normalized = _normalize_search_text(text)
994
+ return [token for token in normalized.split() if token]
995
+
996
+
997
+ def _clip_text(text: str, max_len: int = 420) -> str:
998
+ content = (text or "").strip()
999
+ if len(content) <= max_len:
1000
+ return content
1001
+ return f"{content[: max_len - 3].rstrip()}..."
1002
+
1003
+
1004
+ def _build_node_path(tree: Dict[str, Any], node_id: str) -> Dict[str, Any]:
1005
+ nodes = tree.get("nodes") or []
1006
+ by_id = {str(node.get("id") or ""): node for node in nodes if node.get("id")}
1007
+ root_id = str(tree.get("root_id") or "root")
1008
+ current_id = str(node_id or "").strip()
1009
+ path_nodes: List[Dict[str, Any]] = []
1010
+
1011
+ while current_id and current_id in by_id:
1012
+ node = by_id[current_id]
1013
+ path_nodes.append(node)
1014
+ parent_id = str(node.get("parent_id") or "").strip()
1015
+ if not parent_id or parent_id == current_id:
1016
+ break
1017
+ current_id = parent_id
1018
+
1019
+ path_nodes.reverse()
1020
+ filtered_nodes = [
1021
+ node
1022
+ for node in path_nodes
1023
+ if str(node.get("id") or "").strip() != root_id and str(node.get("title") or "").strip() != "Document Root"
1024
+ ]
1025
+ titles = [str(node.get("title") or "").strip() for node in filtered_nodes if str(node.get("title") or "").strip()]
1026
+ ids = [str(node.get("id") or "").strip() for node in filtered_nodes if str(node.get("id") or "").strip()]
1027
+ return {
1028
+ "node_path_titles": titles,
1029
+ "node_path_ids": ids,
1030
+ "node_path": " > ".join(titles),
1031
+ "node_depth": max(0, len(titles) - 1),
1032
+ "node_title": titles[-1] if titles else "",
1033
+ "parent_node_title": titles[-2] if len(titles) > 1 else "",
1034
+ }
1035
+
1036
+
1037
+ def _format_requirement_node(node_ref: Dict[str, Any]) -> str:
1038
+ node_path = str(node_ref.get("node_path") or "").strip()
1039
+ node_title = str(node_ref.get("node_title") or "").strip()
1040
+ document_name = str(node_ref.get("document_name") or "").strip()
1041
+ if node_path and document_name:
1042
+ return f"{document_name} > {node_path}"
1043
+ if node_path:
1044
+ return node_path
1045
+ return node_title or document_name or ""
1046
+
1047
+
1048
+ def _build_requirement_node_reference(sections: List[Dict[str, Any]]) -> Dict[str, Any]:
1049
+ if not sections:
1050
+ return {}
1051
+ top = sections[0]
1052
+ reference = {
1053
+ "document_id": top.get("document_id"),
1054
+ "document_name": top.get("document_name"),
1055
+ "section_id": top.get("section_id"),
1056
+ "section_title": top.get("section_title"),
1057
+ "node_id": top.get("section_id"),
1058
+ "node_title": top.get("section_title"),
1059
+ "node_path": top.get("section_path"),
1060
+ "node_path_titles": top.get("section_path_titles", []),
1061
+ "node_path_ids": top.get("section_path_ids", []),
1062
+ "node_depth": top.get("section_depth", 0),
1063
+ "retrieval_source": top.get("retrieval_source"),
1064
+ "retrieval_score": top.get("retrieval_score"),
1065
+ }
1066
+ reference["node_display"] = _format_requirement_node(reference)
1067
+ return reference
1068
+
1069
+
1070
+ def resolve_requirement_node_reference_from_documents(documents: List[Dict[str, Any]], preferred_node_id: Optional[str]) -> Dict[str, Any]:
1071
+ target_id = str(preferred_node_id or "").strip()
1072
+ if not target_id:
1073
+ return {}
1074
+
1075
+ for doc in documents:
1076
+ tree = doc.get("tree") or {}
1077
+ nodes = tree.get("nodes") or []
1078
+ by_id = {str(node.get("id") or ""): node for node in nodes if node.get("id")}
1079
+ if target_id not in by_id:
1080
+ continue
1081
+ node = by_id[target_id]
1082
+ path = _build_node_path(tree, target_id)
1083
+ return {
1084
+ "document_id": doc.get("id"),
1085
+ "document_name": doc.get("name"),
1086
+ "node_id": target_id,
1087
+ "node_title": node.get("title"),
1088
+ "node_path": path.get("node_path", ""),
1089
+ "node_path_titles": path.get("node_path_titles", []),
1090
+ "node_path_ids": path.get("node_path_ids", []),
1091
+ "node_depth": path.get("node_depth", 0),
1092
+ "node_display": _format_requirement_node({
1093
+ "document_name": doc.get("name"),
1094
+ "node_path": path.get("node_path", ""),
1095
+ "node_title": node.get("title"),
1096
+ }),
1097
+ "source": "preferred_node",
1098
+ }
1099
+
1100
+ return {}
1101
+
1102
+
1103
+ def _node_to_search_blob(node: Dict[str, Any]) -> str:
1104
+ fields = [
1105
+ str(node.get("title") or ""),
1106
+ str(node.get("summary") or ""),
1107
+ str(node.get("scope") or ""),
1108
+ str(node.get("contextual_summary") or ""),
1109
+ str(node.get("content") or ""),
1110
+ ]
1111
+ return "\n".join(field for field in fields if field)
1112
+
1113
+
1114
+ def _prepare_bm25f_corpus(documents: List[Dict[str, Any]]) -> Dict[str, Any]:
1115
+ field_names = ["title", "summary", "contextual_summary", "content"]
1116
+ rows: List[Dict[str, Any]] = []
1117
+
1118
+ for doc in documents:
1119
+ tree = doc.get("tree") or {}
1120
+ for node in tree.get("nodes") or []:
1121
+ node_id = str(node.get("id") or "").strip()
1122
+ if not node_id:
1123
+ continue
1124
+
1125
+ field_tokens: Dict[str, List[str]] = {}
1126
+ for field in field_names:
1127
+ field_tokens[field] = _tokenize_search_text(str(node.get(field) or ""))
1128
+
1129
+ rows.append(
1130
+ {
1131
+ "document_id": doc.get("id"),
1132
+ "document_name": doc.get("name"),
1133
+ "node": node,
1134
+ "field_tokens": field_tokens,
1135
+ }
1136
+ )
1137
+
1138
+ total_docs = max(1, len(rows))
1139
+ avg_field_len: Dict[str, float] = {}
1140
+ doc_freq: Dict[str, Dict[str, int]] = {field: {} for field in field_names}
1141
+
1142
+ for field in field_names:
1143
+ lengths = [len(row["field_tokens"][field]) for row in rows]
1144
+ avg_field_len[field] = (sum(lengths) / len(lengths)) if lengths else 1.0
1145
+ for row in rows:
1146
+ unique_terms = set(row["field_tokens"][field])
1147
+ for term in unique_terms:
1148
+ doc_freq[field][term] = doc_freq[field].get(term, 0) + 1
1149
+
1150
+ return {
1151
+ "rows": rows,
1152
+ "field_names": field_names,
1153
+ "avg_field_len": avg_field_len,
1154
+ "doc_freq": doc_freq,
1155
+ "total_docs": total_docs,
1156
+ }
1157
+
1158
+
1159
+ def _bm25f_score_row(query_tokens: List[str], row: Dict[str, Any], corpus: Dict[str, Any]) -> float:
1160
+ if not query_tokens:
1161
+ return 0.0
1162
+
1163
+ field_weights = {
1164
+ "title": 2.2,
1165
+ "summary": 1.4,
1166
+ "contextual_summary": 1.8,
1167
+ "content": 1.0,
1168
+ }
1169
+ k1 = 1.5
1170
+ b = 0.75
1171
+
1172
+ total_docs = int(corpus.get("total_docs", 1))
1173
+ avg_field_len = corpus.get("avg_field_len", {})
1174
+ doc_freq = corpus.get("doc_freq", {})
1175
+
1176
+ score = 0.0
1177
+ for term in query_tokens:
1178
+ term_score = 0.0
1179
+ max_df = 0
1180
+ for field in ["title", "summary", "contextual_summary", "content"]:
1181
+ tokens = row["field_tokens"][field]
1182
+ tf = tokens.count(term)
1183
+ if tf <= 0:
1184
+ continue
1185
+
1186
+ field_len = len(tokens)
1187
+ avg_len = max(1e-6, float(avg_field_len.get(field, 1.0)))
1188
+ norm = (1 - b) + b * (field_len / avg_len)
1189
+ tf_norm = (tf * (k1 + 1)) / (tf + (k1 * norm))
1190
+ term_score += field_weights[field] * tf_norm
1191
+
1192
+ df_field = int(doc_freq.get(field, {}).get(term, 0))
1193
+ max_df = max(max_df, df_field)
1194
+
1195
+ if term_score <= 0:
1196
+ continue
1197
+
1198
+ idf = math.log(1 + (total_docs - max_df + 0.5) / (max_df + 0.5)) if max_df > 0 else 0.0
1199
+ score += term_score * idf
1200
+
1201
+ return score
1202
+
1203
+
1204
+ def _collect_bm25f_candidates(query: str, documents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1205
+ query_tokens = _tokenize_search_text(query)
1206
+ if not query_tokens:
1207
+ return []
1208
+
1209
+ corpus = _prepare_bm25f_corpus(documents)
1210
+ rows = corpus.get("rows", [])
1211
+
1212
+ candidates: List[Dict[str, Any]] = []
1213
+ for row in rows:
1214
+ score = _bm25f_score_row(query_tokens, row, corpus)
1215
+ if score <= 0:
1216
+ continue
1217
+ candidates.append(
1218
+ {
1219
+ "document_id": row.get("document_id"),
1220
+ "document_name": row.get("document_name"),
1221
+ "node": row.get("node") or {},
1222
+ "score": score,
1223
+ }
1224
+ )
1225
+
1226
+ candidates.sort(key=lambda item: float(item.get("score", 0.0)), reverse=True)
1227
+ return candidates[:60]
1228
+
1229
+
1230
+ def _generate_hyde_variant(query: str, selected_messages: Optional[List[Dict[str, Any]]] = None) -> str:
1231
+ payload = {
1232
+ "query": query,
1233
+ "selected_messages": (selected_messages or [])[-6:],
1234
+ "instruction": (
1235
+ "Sinh một đoạn giả định ngắn (2-4 câu) mô tả câu trả lời lý tưởng để phục vụ retrieval. "
1236
+ "Giữ keyword/thuật ngữ kỹ thuật quan trọng, không thêm lan man."
1237
+ ),
1238
+ }
1239
+ text = _nvidia_chat_completion(
1240
+ system_prompt=(
1241
+ "Bạn là bộ tạo HyDE query cho retrieval. "
1242
+ "Trả về văn bản thuần duy nhất, không markdown, không JSON."
1243
+ ),
1244
+ user_prompt=json.dumps(payload, ensure_ascii=False),
1245
+ model=TEAM_AGENT_MODEL,
1246
+ temperature=0.0,
1247
+ max_tokens=220,
1248
+ )
1249
+ return _clip_text(text.strip(), 500)
1250
+
1251
+
1252
+ def _expand_query_variants(
1253
+ query: str,
1254
+ max_variants: int = 5,
1255
+ selected_messages: Optional[List[Dict[str, Any]]] = None,
1256
+ use_hyde: bool = True,
1257
+ ) -> Dict[str, Any]:
1258
+ base = (query or "").strip()
1259
+ if not base:
1260
+ return {"variants": [], "hyde_variant": ""}
1261
+
1262
+ variants: List[str] = [base]
1263
+ hyde_variant = ""
1264
+ try:
1265
+ payload = {
1266
+ "query": base,
1267
+ "instruction": (
1268
+ "Sinh tối đa 3 truy vấn thay thế để tăng recall tài liệu kỹ thuật. "
1269
+ "Giữ nguyên ý nghĩa, thêm biến thể keyword/chuẩn thuật ngữ."
1270
+ ),
1271
+ }
1272
+ response = _nvidia_chat_completion(
1273
+ system_prompt=(
1274
+ "Trả về JSON thuần: {\"variants\":[\"...\"]}. "
1275
+ "Không thêm giải thích."
1276
+ ),
1277
+ user_prompt=json.dumps(payload, ensure_ascii=False),
1278
+ model=TEAM_AGENT_MODEL,
1279
+ temperature=0.0,
1280
+ max_tokens=220,
1281
+ )
1282
+ parsed = _extract_json_object(response)
1283
+ llm_variants = parsed.get("variants") if isinstance(parsed.get("variants"), list) else []
1284
+ for item in llm_variants:
1285
+ text = str(item or "").strip()
1286
+ if text and text.lower() not in {v.lower() for v in variants}:
1287
+ variants.append(text)
1288
+ if len(variants) >= max_variants:
1289
+ break
1290
+ except Exception:
1291
+ pass
1292
+
1293
+ # Deterministic backup variant using token dedupe.
1294
+ tokens = _tokenize_search_text(base)
1295
+ if tokens:
1296
+ keyword_variant = " ".join(sorted(set(tokens), key=tokens.index))
1297
+ if keyword_variant and keyword_variant.lower() not in {v.lower() for v in variants}:
1298
+ variants.append(keyword_variant)
1299
+
1300
+ if use_hyde and len(variants) < max_variants:
1301
+ try:
1302
+ hyde_variant = _generate_hyde_variant(base, selected_messages=selected_messages)
1303
+ if hyde_variant and hyde_variant.lower() not in {v.lower() for v in variants}:
1304
+ variants.append(hyde_variant)
1305
+ except Exception:
1306
+ hyde_variant = ""
1307
+
1308
+ return {"variants": variants[:max_variants], "hyde_variant": hyde_variant}
1309
+
1310
+
1311
+ def _collect_multi_query_candidates(
1312
+ query: str,
1313
+ documents: List[Dict[str, Any]],
1314
+ selected_messages: Optional[List[Dict[str, Any]]] = None,
1315
+ ) -> Dict[str, Any]:
1316
+ variant_payload = _expand_query_variants(
1317
+ query,
1318
+ max_variants=5,
1319
+ selected_messages=selected_messages,
1320
+ use_hyde=True,
1321
+ )
1322
+ variants = variant_payload.get("variants", []) if isinstance(variant_payload, dict) else []
1323
+ if not variants:
1324
+ return {"variants": [query], "candidates": [], "hyde_variant": ""}
1325
+
1326
+ k = 60.0
1327
+ merged: Dict[str, Dict[str, Any]] = {}
1328
+
1329
+ for variant in variants:
1330
+ candidates = _collect_bm25f_candidates(variant, documents)
1331
+ for rank, item in enumerate(candidates):
1332
+ node = item.get("node") or {}
1333
+ doc_id = str(item.get("document_id") or "")
1334
+ node_id = str(node.get("id") or "")
1335
+ if not doc_id or not node_id:
1336
+ continue
1337
+ key = f"{doc_id}::{node_id}"
1338
+ rrf = 1.0 / (k + rank + 1)
1339
+ score = float(item.get("score", 0.0))
1340
+
1341
+ if key not in merged:
1342
+ merged[key] = {
1343
+ "document_id": item.get("document_id"),
1344
+ "document_name": item.get("document_name"),
1345
+ "node": node,
1346
+ "score": 0.0,
1347
+ "max_lexical": 0.0,
1348
+ "query_hits": [],
1349
+ }
1350
+
1351
+ merged[key]["score"] += rrf
1352
+ merged[key]["max_lexical"] = max(float(merged[key]["max_lexical"]), score)
1353
+ if variant not in merged[key]["query_hits"]:
1354
+ merged[key]["query_hits"].append(variant)
1355
+
1356
+ fused = list(merged.values())
1357
+ for item in fused:
1358
+ item["score"] = float(item.get("score", 0.0)) + float(item.get("max_lexical", 0.0)) * 0.45
1359
+
1360
+ fused.sort(key=lambda item: float(item.get("score", 0.0)), reverse=True)
1361
+ return {
1362
+ "variants": variants,
1363
+ "candidates": fused[:50],
1364
+ "hyde_variant": variant_payload.get("hyde_variant", "") if isinstance(variant_payload, dict) else "",
1365
+ }
1366
+
1367
+
1368
+ def _llm_rerank_document_candidates(query: str, candidates: List[Dict[str, Any]], top_k: int = 8) -> List[str]:
1369
+ if not candidates:
1370
+ return []
1371
+
1372
+ payload = {
1373
+ "query": query,
1374
+ "candidates": [
1375
+ {
1376
+ "id": str(item["node"].get("id") or ""),
1377
+ "document_id": item.get("document_id"),
1378
+ "document_name": item.get("document_name"),
1379
+ "title": item["node"].get("title"),
1380
+ "summary": item["node"].get("summary"),
1381
+ "snippet": _clip_text(item["node"].get("content", ""), 220),
1382
+ "lexical_score": round(float(item.get("score", 0.0)), 4),
1383
+ }
1384
+ for item in candidates[:18]
1385
+ ],
1386
+ "top_k": max(1, min(top_k, 10)),
1387
+ }
1388
+
1389
+ rerank_text = _nvidia_chat_completion(
1390
+ system_prompt=(
1391
+ "Bạn là bộ xếp hạng bằng chứng tài liệu. "
1392
+ "Trả về JSON thuần: {\"selected_ids\": [\"node_id\"], \"reason\": \"...\"}. "
1393
+ "Chọn các node liên quan nhất để trả lời câu hỏi."
1394
+ ),
1395
+ user_prompt=json.dumps(payload, ensure_ascii=False),
1396
+ model=TEAM_AGENT_MODEL,
1397
+ temperature=0.0,
1398
+ max_tokens=260,
1399
+ )
1400
+ rerank_json = _extract_json_object(rerank_text)
1401
+ selected_ids_raw = rerank_json.get("selected_ids")
1402
+ if isinstance(selected_ids_raw, list):
1403
+ selected_ids = [str(item).strip() for item in selected_ids_raw if str(item).strip()]
1404
+ if selected_ids:
1405
+ return selected_ids[:top_k]
1406
+
1407
+ return [
1408
+ str(item["node"].get("id"))
1409
+ for item in candidates[:top_k]
1410
+ if item["node"].get("id")
1411
+ ]
1412
+
1413
+
1414
+ def retrieve_document_context_with_tree(
1415
+ query: str,
1416
+ documents: List[Dict[str, Any]],
1417
+ selected_messages: Optional[List[Dict[str, Any]]] = None,
1418
+ ) -> Dict[str, Any]:
1419
  if not documents:
1420
  return {"sections": [], "citations": []}
1421
 
1422
  picked_sections: List[Dict[str, Any]] = []
1423
  citations: List[Dict[str, Any]] = []
1424
+ seen_node_ids: set[str] = set()
1425
+
1426
+ # Layer 1: tree navigation keeps hierarchical intent and ensures at least one anchor per doc.
1427
+ tree_picks: List[Dict[str, Any]] = []
1428
 
1429
  for doc in documents:
1430
  tree = doc.get("tree") or {}
 
1483
  if not selected_node:
1484
  continue
1485
 
1486
+ selected_node_id = str(selected_node.get("id") or "").strip()
1487
+ node_path = _build_node_path(tree, selected_node_id)
1488
+ path_titles = node_path.get("node_path_titles", [])
1489
+ path_ids = node_path.get("node_path_ids", [])
1490
+
1491
+ tree_picks.append(
1492
  {
1493
  "document_id": doc.get("id"),
1494
  "document_name": doc.get("name"),
1495
+ "node": selected_node,
1496
+ "score": 1.0,
1497
+ "source": "tree_nav",
1498
+ }
1499
+ )
1500
+
1501
+ # Layer 2: multi-query lexical retrieval broadens recall.
1502
+ fused_results = _collect_multi_query_candidates(query, documents, selected_messages=selected_messages)
1503
+ lexical_candidates = fused_results.get("candidates", []) if isinstance(fused_results, dict) else []
1504
+ query_variants = fused_results.get("variants", [query]) if isinstance(fused_results, dict) else [query]
1505
+ hyde_variant = fused_results.get("hyde_variant", "") if isinstance(fused_results, dict) else ""
1506
+
1507
+ # Layer 3: LLM reranking improves precision on top lexical candidates.
1508
+ reranked_ids = set(_llm_rerank_document_candidates(query, lexical_candidates, top_k=8))
1509
+
1510
+ merged_candidates: List[Dict[str, Any]] = []
1511
+ merged_candidates.extend(tree_picks)
1512
+ for item in lexical_candidates:
1513
+ node_id = str(item["node"].get("id") or "")
1514
+ if not node_id:
1515
+ continue
1516
+ score = float(item.get("score", 0.0))
1517
+ if node_id in reranked_ids:
1518
+ score += 1.0
1519
+ merged_candidates.append(
1520
+ {
1521
+ "document_id": item.get("document_id"),
1522
+ "document_name": item.get("document_name"),
1523
+ "node": item.get("node") or {},
1524
+ "score": score,
1525
+ "source": "hybrid_rerank" if node_id in reranked_ids else "lexical",
1526
+ }
1527
+ )
1528
+
1529
+ merged_candidates.sort(key=lambda item: float(item.get("score", 0.0)), reverse=True)
1530
+
1531
+ for item in merged_candidates:
1532
+ node = item.get("node") or {}
1533
+ section_id = str(node.get("id") or "").strip()
1534
+ if not section_id or section_id in seen_node_ids:
1535
+ continue
1536
+
1537
+ seen_node_ids.add(section_id)
1538
+ section_text = str(node.get("content") or "")
1539
+ picked_sections.append(
1540
+ {
1541
+ "document_id": item.get("document_id"),
1542
+ "document_name": item.get("document_name"),
1543
+ "section_id": section_id,
1544
+ "section_title": node.get("title"),
1545
  "section_content": section_text,
1546
+ "section_summary": node.get("summary", ""),
1547
+ "section_context": node.get("contextual_summary", ""),
1548
+ "section_path": node_path.get("node_path", ""),
1549
+ "section_path_titles": path_titles,
1550
+ "section_path_ids": path_ids,
1551
+ "section_depth": node_path.get("node_depth", 0),
1552
+ "retrieval_score": round(float(item.get("score", 0.0)), 4),
1553
+ "retrieval_source": item.get("source"),
1554
+ "query_hit_count": len(item.get("query_hits", [])) if isinstance(item.get("query_hits"), list) else 0,
1555
  }
1556
  )
1557
  citations.append(
1558
  {
1559
+ "document_id": item.get("document_id"),
1560
+ "document_name": item.get("document_name"),
1561
+ "section_id": section_id,
1562
+ "section_title": node.get("title"),
1563
+ "section_path": node_path.get("node_path", ""),
1564
+ "section_path_titles": path_titles,
1565
+ "section_path_ids": path_ids,
1566
+ "source": item.get("source"),
1567
  }
1568
  )
1569
+ if len(picked_sections) >= 10:
1570
+ break
1571
 
1572
+ return {
1573
+ "sections": picked_sections,
1574
+ "citations": citations,
1575
+ "retrieval_meta": {
1576
+ "tree_pick_count": len(tree_picks),
1577
+ "lexical_candidate_count": len(lexical_candidates),
1578
+ "rerank_pick_count": len(reranked_ids),
1579
+ "query_variants": query_variants,
1580
+ "hyde_used": bool(hyde_variant),
1581
+ },
1582
+ "requirement_node_reference": _build_requirement_node_reference(picked_sections),
1583
+ }
1584
+
1585
+
1586
+ def _evaluate_grounding_confidence(
1587
+ answer: str,
1588
+ citations: List[Dict[str, Any]],
1589
+ sections: List[Dict[str, Any]],
1590
+ retrieval_meta: Dict[str, Any],
1591
+ llm_confidence: str,
1592
+ ) -> Dict[str, Any]:
1593
+ section_ids = {
1594
+ str(section.get("section_id") or "").strip()
1595
+ for section in sections
1596
+ if str(section.get("section_id") or "").strip()
1597
+ }
1598
+ citation_match = 0
1599
+ for item in citations:
1600
+ if not isinstance(item, dict):
1601
+ continue
1602
+ section_id = str(item.get("section_id") or "").strip()
1603
+ if section_id and section_id in section_ids:
1604
+ citation_match += 1
1605
+
1606
+ llm_map = {"low": 0.35, "medium": 0.65, "high": 0.9}
1607
+ llm_score = llm_map.get(llm_confidence, 0.6)
1608
+ cited_ratio = citation_match / max(1, len(citations) if citations else 1)
1609
+ retrieval_strength = min(float(retrieval_meta.get("rerank_pick_count", 0)) / 6.0, 1.0)
1610
+ section_strength = min(len(sections) / 8.0, 1.0)
1611
+ answer_len_strength = min(len((answer or "").split()) / 80.0, 1.0)
1612
+
1613
+ score = (
1614
+ llm_score * 0.40
1615
+ + cited_ratio * 0.25
1616
+ + retrieval_strength * 0.20
1617
+ + section_strength * 0.10
1618
+ + answer_len_strength * 0.05
1619
+ )
1620
+ score = max(0.0, min(score, 1.0))
1621
+
1622
+ if score >= 0.78:
1623
+ label = "high"
1624
+ elif score >= 0.56:
1625
+ label = "medium"
1626
+ else:
1627
+ label = "low"
1628
+
1629
+ return {
1630
+ "confidence": label,
1631
+ "confidence_score": round(score, 4),
1632
+ "needs_clarification": label == "low",
1633
+ }
1634
+
1635
+
1636
+ def build_document_grounded_answer(
1637
+ query: str,
1638
+ selected_messages: List[Dict[str, Any]],
1639
+ doc_context: Dict[str, Any],
1640
+ qa_memory: Optional[str] = None,
1641
+ ) -> Dict[str, Any]:
1642
+ sections = doc_context.get("sections") if isinstance(doc_context, dict) else []
1643
+ citations = doc_context.get("citations") if isinstance(doc_context, dict) else []
1644
+ retrieval_meta = doc_context.get("retrieval_meta") if isinstance(doc_context, dict) else {}
1645
+ if not isinstance(sections, list) or not sections:
1646
+ return {
1647
+ "answer": "",
1648
+ "citations": [],
1649
+ "confidence": "low",
1650
+ "confidence_score": 0.0,
1651
+ "needs_clarification": True,
1652
+ "clarifying_question": "Bạn có thể chọn thêm tài liệu hoặc section liên quan để mình trả lời chính xác hơn không?",
1653
+ }
1654
+
1655
+ payload = {
1656
+ "query": query,
1657
+ "qa_memory": (qa_memory or "").strip(),
1658
+ "selected_messages": selected_messages[-8:] if isinstance(selected_messages, list) else [],
1659
+ "evidence_sections": [
1660
+ {
1661
+ "document_id": section.get("document_id"),
1662
+ "document_name": section.get("document_name"),
1663
+ "section_id": section.get("section_id"),
1664
+ "section_title": section.get("section_title"),
1665
+ "section_path": section.get("section_path"),
1666
+ "summary": _clip_text(str(section.get("section_summary") or ""), 280),
1667
+ "context": _clip_text(str(section.get("section_context") or ""), 240),
1668
+ "content": _clip_text(str(section.get("section_content") or ""), 520),
1669
+ }
1670
+ for section in sections[:8]
1671
+ ],
1672
+ "citations": citations[:8] if isinstance(citations, list) else [],
1673
+ }
1674
+
1675
+ answer_text = _nvidia_chat_completion(
1676
+ system_prompt=(
1677
+ "Bạn là trợ lý phân tích tài liệu dạng NotebookLM-style cho team chat. "
1678
+ "Nhiệm vụ: trả lời trực tiếp câu hỏi user dựa trên evidence đã cho, không bịa, không suy diễn vượt dữ liệu. "
1679
+ "Nếu có qa_memory thì dùng như ngữ cảnh ổn định cho các lượt QA tiếp theo, nhưng không được vượt quá evidence hiện có. "
1680
+ "Trả về JSON thuần: {\"answer\":\"...\",\"citations\":[{\"document_id\":\"...\",\"section_id\":\"...\"}],\"confidence\":\"high|medium|low\"}."
1681
+ ),
1682
+ user_prompt=json.dumps(payload, ensure_ascii=False),
1683
+ model=TEAM_AGENT_MODEL,
1684
+ temperature=0.1,
1685
+ max_tokens=700,
1686
+ )
1687
+ answer_json = _extract_json_object(answer_text)
1688
+ answer = str(answer_json.get("answer") or "").strip()
1689
+ out_citations = answer_json.get("citations") if isinstance(answer_json.get("citations"), list) else []
1690
+ confidence = str(answer_json.get("confidence") or "medium").strip().lower()
1691
+ if confidence not in {"high", "medium", "low"}:
1692
+ confidence = "medium"
1693
+
1694
+ if not answer:
1695
+ top = sections[0]
1696
+ fallback_title = str(top.get("section_title") or "nội dung liên quan").strip()
1697
+ fallback_doc = str(top.get("document_name") or "tài liệu").strip()
1698
+ answer = f"Theo {fallback_doc}, phần '{fallback_title}' là dữ liệu liên quan nhất với câu hỏi hiện tại."
1699
+ out_citations = [
1700
+ {
1701
+ "document_id": top.get("document_id"),
1702
+ "section_id": top.get("section_id"),
1703
+ }
1704
+ ]
1705
+ confidence = "low"
1706
+
1707
+ eval_result = _evaluate_grounding_confidence(
1708
+ answer=answer,
1709
+ citations=out_citations,
1710
+ sections=sections,
1711
+ retrieval_meta=retrieval_meta if isinstance(retrieval_meta, dict) else {},
1712
+ llm_confidence=confidence,
1713
+ )
1714
+
1715
+ clarifying_question = ""
1716
+ if eval_result.get("needs_clarification"):
1717
+ clarifying_question = (
1718
+ "Mình chưa đủ chắc chắn vì bằng chứng tài liệu còn yếu. "
1719
+ "Bạn muốn mình bám vào tài liệu nào hoặc section nào cụ thể hơn?"
1720
+ )
1721
+
1722
+ return {
1723
+ "answer": answer,
1724
+ "citations": out_citations,
1725
+ "confidence": eval_result.get("confidence", confidence),
1726
+ "confidence_score": eval_result.get("confidence_score", 0.0),
1727
+ "needs_clarification": bool(eval_result.get("needs_clarification")),
1728
+ "clarifying_question": clarifying_question,
1729
+ "requirement_node_reference": _build_requirement_node_reference(sections),
1730
+ }
1731
 
1732
 
1733
  def run_team_agent_with_nvidia(system_prompt: str, payload: Dict[str, Any]) -> str:
 
1740
  )
1741
 
1742
 
1743
+ def save_team_chat_message(
1744
+ team_id: str,
1745
+ role: str,
1746
+ content: str,
1747
+ project_id: Optional[str] = None,
1748
+ attachment_urls: Optional[List[str]] = None,
1749
+ ) -> Dict[str, Any]:
1750
  doc = {
1751
  "id": str(uuid.uuid4()),
1752
  "team_id": team_id,
1753
  "project_id": project_id,
1754
  "role": role,
1755
  "content": content,
1756
+ "attachment_urls": attachment_urls or [],
1757
  "timestamp": get_vn_now().isoformat(),
1758
  }
1759
  team_chat_collection.insert_one(doc)
 
1775
  "assignee_id": assignee["id"] if assignee else payload.get("assignee_id"),
1776
  "tags": tags,
1777
  "requirement_text": payload.get("requirement_text"),
1778
+ "requirement_node_id": payload.get("requirement_node_id"),
1779
+ "requirement_node_title": payload.get("requirement_node_title"),
1780
+ "requirement_node_path": payload.get("requirement_node_path"),
1781
+ "requirement_node_path_titles": payload.get("requirement_node_path_titles", []),
1782
+ "requirement_node_path_ids": payload.get("requirement_node_path_ids", []),
1783
+ "requirement_node_depth": payload.get("requirement_node_depth"),
1784
+ "requirement_document_id": payload.get("requirement_document_id"),
1785
+ "requirement_document_name": payload.get("requirement_document_name"),
1786
  "attachment_urls": payload.get("attachment_urls", []),
1787
  "reporter_id": reporter_id,
1788
  "created_at": get_vn_now().isoformat(),
 
1805
  "priority": payload.get("priority", "medium"),
1806
  "tags": tags,
1807
  "reminder": payload.get("reminder") or payload.get("start_time"),
1808
+ "requirement_node_id": payload.get("requirement_node_id"),
1809
+ "requirement_node_title": payload.get("requirement_node_title"),
1810
+ "requirement_node_path": payload.get("requirement_node_path"),
1811
+ "requirement_node_path_titles": payload.get("requirement_node_path_titles", []),
1812
+ "requirement_node_path_ids": payload.get("requirement_node_path_ids", []),
1813
+ "requirement_node_depth": payload.get("requirement_node_depth"),
1814
+ "requirement_document_id": payload.get("requirement_document_id"),
1815
+ "requirement_document_name": payload.get("requirement_document_name"),
1816
  }
1817
  tasks_collection.insert_one(task)
1818
  return task
 
1824
  raise HTTPException(status_code=404, detail="Issue not found")
1825
 
1826
  update_data: Dict[str, Any] = {"updated_at": get_vn_now().isoformat()}
1827
+ field_names = ["title", "description", "severity", "status", "tags", "attachment_urls", "requirement_text", "requirement_node_id", "requirement_node_title", "requirement_node_path", "requirement_document_id", "requirement_document_name"]
1828
  for field_name in field_names:
1829
  value = payload.get(field_name)
1830
  if value is None:
 
1834
  else:
1835
  update_data[field_name] = value
1836
 
1837
+ if "requirement_node_path_titles" in payload and payload["requirement_node_path_titles"] is not None:
1838
+ update_data["requirement_node_path_titles"] = payload["requirement_node_path_titles"]
1839
+ if "requirement_node_path_ids" in payload and payload["requirement_node_path_ids"] is not None:
1840
+ update_data["requirement_node_path_ids"] = payload["requirement_node_path_ids"]
1841
+ if "requirement_node_depth" in payload and payload["requirement_node_depth"] is not None:
1842
+ update_data["requirement_node_depth"] = payload["requirement_node_depth"]
1843
+
1844
  assignee = resolve_user_reference(payload)
1845
  if assignee:
1846
  update_data["assignee_id"] = assignee["id"]