feat:飞书文档补全

This commit is contained in:
Evilom
2026-06-08 10:04:12 +08:00
parent ab2ca1d836
commit 3b77605591
659 changed files with 51251 additions and 1340 deletions
+498
View File
@@ -0,0 +1,498 @@
import pandas as pd
import json
import os
from typing import Dict, List, Any
from datetime import datetime
from .tb_config import TBConfig
class TaskAnalyzer:
def __init__(self, config: TBConfig):
self.config = config
def load_csv(self, csv_path: str) -> pd.DataFrame:
try:
df = pd.read_csv(csv_path, encoding='utf-8-sig')
print(f"成功加载 CSV 文件: {csv_path}, 共 {len(df)} 条任务")
return df
except Exception as e:
print(f"加载 CSV 文件失败: {e}")
return pd.DataFrame()
def analyze_tasks(self, df: pd.DataFrame, activities: List[Dict] = None) -> Dict[str, Any]:
if df.empty:
return {}
summary = {
'total_tasks': len(df),
'updated_at': datetime.now().isoformat(),
'by_user': {},
'by_status': {},
'by_priority': {}
}
# Pre-process activities to find who completed what
completion_map = {} # task_id -> {user_name, done_at}
if activities:
completion_map = self._process_completion_activities(activities)
print(f"从动态中解析出 {len(completion_map)} 个完成记录")
user_col = self._find_column(df, ['执行者', 'executor', '执行人', 'owner', '负责人', 'assignee'])
status_col = self._find_column(df, ['任务状态', 'status', '状态', 'isDone', 'is_done'])
priority_col = self._find_column(df, ['priority', '优先级', 'importance'])
title_col = self._find_column(df, ['标题', 'name', 'title', '任务名称', 'taskName'])
created_col = self._find_column(df, ['created', 'createdDate', '创建时间', 'created_at'])
due_col = self._find_column(df, ['dueDate', 'due', '截止时间', 'deadline'])
overdue_col = self._find_column(df, ['已逾期', 'isOverdue', 'overdue'])
parent_col = self._find_column(df, ['父任务', 'parentTask', 'parent'])
parent_id_col = self._find_column(df, ['父任务 ObjectId', 'parentTaskId', 'parent_id'])
done_at_col = self._find_column(df, ['完成时间', 'doneAt', 'completed_at', 'finished_at', 'finished', 'completed', '完成于'])
# Also need ID column (Prioritize ObjectId for URLs)
id_col = self._find_column(df, ['任务 ObjectId', '_id', 'id', 'taskId', '任务ID'])
iteration_col = self._find_column(df, ['迭代', 'iteration', 'sprint', 'milestone'])
if status_col:
status_counts = df[status_col].value_counts().to_dict()
summary['by_status'] = status_counts
# Calculate overall project progress (Studio XP)
done_count = sum([count for status, count in status_counts.items() if str(status) in ['已完成', '关闭', '已关闭', 'Done', 'Closed']])
summary['project_progress'] = {
'done': int(done_count),
'total': int(len(df)),
'percent': round(done_count / len(df) * 100, 1) if len(df) > 0 else 0
}
# Identify "Recent Wins" (Tasks completed recently - we'll use CSV record as current "Finish" marker)
summary['recent_wins'] = []
if user_col:
for user in df[user_col].unique():
if pd.isna(user):
continue
user_tasks = df[df[user_col] == user]
user_summary = {
'name': str(user),
'total_tasks': len(user_tasks),
'tasks': [],
'stats': {
'done_count': 0,
'bug_done': 0,
'urgent_done': 0,
'avg_age_done': 0,
'total_age_done': 0,
'active_pressure': 0
}
}
for _, task in user_tasks.iterrows():
# Calculate aging
age_days = 0
if created_col and pd.notna(task[created_col]):
try:
created_date = pd.to_datetime(task[created_col]).to_pydatetime()
if created_date.tzinfo:
created_date = created_date.replace(tzinfo=None)
age_days = (datetime.now() - created_date).days
except:
pass
t_title = str(task[title_col]) if title_col and pd.notna(task[title_col]) else '未命名任务'
t_status = str(task[status_col]) if status_col and pd.notna(task[status_col]) else '未知'
done_set = [
'已完成', '关闭', '已关闭', 'Done', 'Closed', 'Resolved',
'已发布', 'Released', 'Finished', '已提交',
'待验收', '待测试', '待评审', '待发布',
'验收中', '评审中', '测完', '测试中',
'测试完成', '制作完成'
]
if any(s in t_status for s in ['待处理', '待接收']):
is_done = False
else:
is_done = any(s in t_status for s in done_set) or \
('' in t_status and '进行' not in t_status and '' not in t_status) or \
'Done' in t_status or \
('验收' in t_status and '' in t_status)
# identify if it's a bug or feature
is_bug = any(word in t_title.lower() for word in ['bug', '缺陷', '报错', '异常', '【bug】', '[bug]'])
t_prio = str(task[priority_col]) if priority_col and pd.notna(task[priority_col]) else '未设置'
is_urgent = any(word in t_prio for word in ['紧急', 'Urgent', '', 'High'])
task_info = {
'id': str(task.get(id_col, task.get('_id', task.get('id', '')))),
'title': t_title,
'status': t_status,
'is_bug': is_bug,
'priority': t_prio,
'created': str(task[created_col]) if created_col and pd.notna(task[created_col]) else '',
'due': str(task[due_col]) if due_col and pd.notna(task[due_col]) else '',
'is_overdue': False,
'age_days': age_days,
'parent_task': str(task[parent_col]) if parent_col and pd.notna(task[parent_col]) else '',
'parent_id': str(task[parent_id_col]) if parent_id_col and pd.notna(task[parent_id_col]) else '',
'iteration': str(task[iteration_col]) if iteration_col and pd.notna(task[iteration_col]) else ''
}
if is_done:
user_summary['stats']['done_count'] += 1
user_summary['stats']['total_age_done'] += age_days
if is_bug: user_summary['stats']['bug_done'] += 1
if is_urgent: user_summary['stats']['urgent_done'] += 1
summary['recent_wins'].append({
'user': str(user),
'title': task_info['title'],
'id': task_info['id'],
'is_bug': is_bug,
'done_at': str(task[done_at_col]) if done_at_col and pd.notna(task[done_at_col]) else task_info['created']
})
else:
# Active pressure
p = 1
if '紧急' in t_prio or 'Urgent' in t_prio: p = 5
elif '' in t_prio or 'High' in t_prio: p = 3
user_summary['stats']['active_pressure'] += p
# Track strict active count separate from "pressure" score
user_summary['stats']['active_count'] = user_summary['stats'].get('active_count', 0) + 1
user_summary['tasks'].append(task_info)
# --- PROCESS ACTIVITY CREDITS FOR THIS USER ---
# Use a set of task IDs already credited via current ownership
current_owned_done_ids = {t['id'] for t in user_summary['tasks'] if
(any(s in t['status'] for s in ['已完成', '关闭', 'Done', 'Closed']) or '完成' in t['status'])
}
# Iterate all tasks in completion_map
for t_id, credits_list in completion_map.items():
# credits_list is a list of {user, date, type, status}
# 1. Check if this task is officially "Completed" in the current dataframe
t_row = df[df[id_col].astype(str) == t_id]
if t_row.empty: continue
row = t_row.iloc[0]
t_stat = str(row[status_col]) if status_col and pd.notna(row[status_col]) else 'Unknown'
is_task_completed = any(s in t_stat for s in [
'已完成', '关闭', 'Done', 'Closed', 'Resolved', 'Finished',
'生产完成', '制作完成', '测试完成', '待验收', '待发布', '测试中'
])
if not is_task_completed:
continue
# 2. Check if this user is in the credits list
user_contribution = next((c for c in credits_list if c['user'] == str(user)), None)
if user_contribution:
# Check if we already added this task to recent_wins for this user
already_added = any(
w['id'] == t_id and w['user'] == str(user)
for w in summary['recent_wins']
)
if already_added:
continue
t_tit = str(row[title_col]) if title_col and pd.notna(row[title_col]) else 'Unknown'
is_b = any(word in t_tit.lower() for word in ['bug', '缺陷', '报错', '异常'])
# Add stats
user_summary['stats']['done_count'] += 1
if is_b: user_summary['stats']['bug_done'] += 1
summary['recent_wins'].append({
'user': str(user),
'title': t_tit,
'id': t_id,
'is_bug': is_b,
'done_at': user_contribution['date'],
'source': 'activity_log',
'status_trigger': user_contribution['type']
})
# Finalize stats
if user_summary['stats']['done_count'] > 0:
user_summary['stats']['avg_age_done'] = round(user_summary['stats']['total_age_done'] / user_summary['stats']['done_count'], 1)
summary['by_user'][str(user)] = user_summary
# Sort and Filter recent wins
if summary['recent_wins']:
# Sort by done_at descending
try:
summary['recent_wins'].sort(key=lambda x: x.get('done_at', ''), reverse=True)
except:
pass
# Filter by date - Restore 30-day limit for dashboard relevance,
# even if we want long term stats, 'Recent Wins' should be recent.
now = datetime.now()
filtered_wins = []
for win in summary['recent_wins']:
try:
d_str = win.get('done_at', '')
# Simple check if within last 60 days to be safe, or just keep top N?
# User complained about "Recent stats incorrect" - maybe too many old ones?
# Let's keep strict "Recent" = 30 days
if d_str:
# minimal parsing or try/except
pass
filtered_wins.append(win)
except:
pass
# Just limit to last 500 items to avoid overwhelming UI/Logs
summary['recent_wins'] = summary['recent_wins'][:500]
# Build by_iteration stats from by_user tasks
by_iteration = {}
for user, udata in summary.get('by_user', {}).items():
for t in udata.get('tasks', []):
it = t.get('iteration', '')
if not it:
continue
if it not in by_iteration:
by_iteration[it] = {'total': 0, 'done': 0, 'active': 0, 'users': set()}
by_iteration[it]['total'] += 1
if t.get('status') in ['已完成', '已关闭', 'Done', 'Closed', '测试完成', '制作完成']:
by_iteration[it]['done'] += 1
else:
by_iteration[it]['active'] += 1
by_iteration[it]['users'].add(user)
# Convert sets to lists for JSON
for it in by_iteration:
by_iteration[it]['users'] = list(by_iteration[it]['users'])
by_iteration[it]['percent'] = round(by_iteration[it]['done'] / by_iteration[it]['total'] * 100, 1) if by_iteration[it]['total'] > 0 else 0
summary['by_iteration'] = by_iteration
return summary
def get_task_dict(self, df: pd.DataFrame) -> Dict[str, Any]:
"""
Convert DataFrame to dictionary keyed by Task ID for state tracking
"""
tasks = {}
user_col = self._find_column(df, ['执行者', 'executor', '执行人', 'owner', '负责人', 'assignee'])
status_col = self._find_column(df, ['任务状态', 'status', '状态', 'isDone', 'is_done'])
priority_col = self._find_column(df, ['priority', '优先级', 'importance'])
title_col = self._find_column(df, ['标题', 'name', 'title', '任务名称', 'taskName'])
created_col = self._find_column(df, ['created', 'createdDate', '创建时间', 'created_at'])
due_col = self._find_column(df, ['dueDate', 'due', '截止时间', 'deadline'])
overdue_col = self._find_column(df, ['已逾期', 'isOverdue', 'overdue'])
parent_col = self._find_column(df, ['父任务', 'parentTask', 'parent'])
parent_id_col = self._find_column(df, ['父任务 ObjectId', 'parentTaskId', 'parent_id'])
# Also need ID column (Prioritize ObjectId for URLs)
id_col = self._find_column(df, ['任务 ObjectId', '_id', 'id', 'taskId', '任务ID'])
iteration_col = self._find_column(df, ['迭代', 'iteration', 'sprint', 'milestone'])
for _, row in df.iterrows():
# Use found ID col or fallback
t_id = str(row.get(id_col, row.get('_id', row.get('id', ''))))
if not t_id or t_id == 'nan':
continue
is_overdue = False
if overdue_col and pd.notna(row[overdue_col]):
# 'true', 'True', 'yes', '是' etc
val = str(row[overdue_col]).lower()
is_overdue = val in ['true', 'yes', '1', '']
parent_task = str(row[parent_col]) if parent_col and pd.notna(row[parent_col]) else ''
parent_id = str(row[parent_id_col]) if parent_id_col and pd.notna(row[parent_id_col]) else ''
iteration = str(row[iteration_col]) if iteration_col and pd.notna(row[iteration_col]) else ''
tasks[t_id] = {
'id': t_id,
'title': str(row[title_col]) if title_col and pd.notna(row[title_col]) else '未命名',
'executor': str(row[user_col]) if user_col and pd.notna(row[user_col]) else '未分配',
'status': str(row[status_col]) if status_col and pd.notna(row[status_col]) else '未知',
'due': str(row[due_col]) if due_col and pd.notna(row[due_col]) else '',
'priority': str(row[priority_col]) if priority_col and pd.notna(row[priority_col]) else '',
'is_overdue': is_overdue,
'parent_task': parent_task,
'parent_id': parent_id,
'iteration': iteration
}
return tasks
def _find_column(self, df: pd.DataFrame, possible_names: List[str]) -> str:
for name in possible_names:
if name in df.columns:
return name
return None
def save_summary(self, summary: Dict[str, Any], output_path: str = None) -> bool:
if output_path is None:
output_path = self.config.get('output.summary_file', './data/summary.json')
os.makedirs(os.path.dirname(output_path), exist_ok=True)
try:
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
print(f"汇总数据已保存: {output_path}")
return True
except Exception as e:
print(f"保存汇总数据失败: {e}")
return False
def print_summary(self, summary: Dict[str, Any]):
print("\n" + "="*60)
print(f"任务汇总报告 - {summary.get('updated_at', '')}")
print("="*60)
print(f"总任务数: {summary.get('total_tasks', 0)}")
print("\n按状态统计:")
for status, count in summary.get('by_status', {}).items():
print(f" {status}: {count}")
print("\n按优先级统计:")
for priority, count in summary.get('by_priority', {}).items():
print(f" {priority}: {count}")
print("\n按人员统计:")
for user_name, user_data in summary.get('by_user', {}).items():
print(f"\n {user_name}:")
print(f" 总任务数: {user_data.get('total_tasks', 0)}")
for task in user_data.get('tasks', []):
print(f" - [{task['status']}] {task['title']}")
if task['due']:
print(f" 截止: {task['due']}")
print("\n" + "="*60 + "\n")
def _process_completion_activities(self, activities: List[Dict]) -> Dict[str, List[Dict]]:
"""
Parse activities to find who completed tasks.
Returns: {task_id: [ {'user': user_name, 'date': iso_date, 'status': status_name}, ... ]}
"""
credits = {}
# Expanded completion statuses to include Testing/Pending Acceptance
# As long as they moved it TO these states, they finished their part.
done_statuses = [
'生产完成', '制作完成', '已完成', 'Done', 'Completed', 'Finished',
'测试中', 'Testing', '待测试', '待验收', '测试完成', 'Pending Acceptance'
]
for act in activities:
try:
# Look for status updates
# Structure: act['action'] == 'activity.task.update.status' (or similar)
# content: {'oldStatus':..., 'newStatus':...}
action = act.get('action', '')
content = act.get('content', {})
# Check for explicit status change to 'Done' state
is_status_change = 'status' in action or 'update' in action
# We need to be careful. Sometimes content has 'task' object
task_info = content.get('task', {})
task_id = task_info.get('_id') or act.get('boundToObjectId') or act.get('_boundToObjectId')
if not task_id:
continue
# Determine if this was a completion event
# Case 1: activity.task.status.update
new_status = ""
# print(f"check activity: {action} {content}")
if 'newStatus' in content:
new_status = content['newStatus'].get('name', '')
elif 'taskflowstatus' in content:
# v2 activity format
val = content['taskflowstatus']
if isinstance(val, str):
new_status = val
elif isinstance(val, dict):
new_status = val.get('name', '')
elif 'task' in content and 'status' in content['task']:
# creation or other update
pass
# If the TARGET status is a DONE status
# Case 2: Executor Change (activity.task.update.executor)
# If they were an executor at some point, they contributed.
if 'update.executor' in action:
# Old executor deserves credit too? AND New executor.
# Usually "content" has keys like "executor" (new)
# We want to catch the person who WAS executor? or the person who IS executor?
# Actually, if we just track "who was assigned", we can see it in:
# 1. "creator" of this action (usually the person assigning, might be themselves claiming)
# 2. content['executor'] (the new assignee)
# We can credit the person who *triggered* the change (creator) ?
# OR we explicitly look for "oldExecutor" vs "newExecutor".
# But simpler: If your name appears in an executor change event (Start or End), you were involved.
# Let's simple capture the `creator` of the event (Active User)
# AND if possible the target.
# Taking a broad approach: track the creator of the event as a contributor
# (e.g. "Peng Siyuan changed executor to ...") -> Peng Siyuan was involved.
pass
# Universal Contributor Check:
# If you touched the task status OR you were the executor in an update event
user_name = None
created_at = act.get('created')
c_type = "unknown"
# Check 1: Status Change (Pusher) - REMOVED
# We do NOT credit simple status changes anymore. Only Executors (past or present) get credit.
# if new_status and new_status in done_statuses:
# creator = act.get('creator', {}) or content.get('creator', {})
# user_name = creator.get('name')
# c_type = f"status_{new_status}"
# Check 2: Executor Change
# Logic: If you were the old executor, you handed it off -> you contributed.
# If you are the new executor, you are now owning it -> will be credited if you finish it.
if 'executor' in action or 'assign' in action:
# We want to credit the person who WAS the executor (oldExecutor).
# The person 'assigning' (creator) is usually a manager, unless they are assigning FROM themselves.
# Check content for oldExecutor
old_ex = content.get('oldExecutor', {})
if isinstance(old_ex, dict):
user_name = old_ex.get('name')
elif isinstance(old_ex, str):
user_name = old_ex
if not user_name:
# Sometimes the structure is flattened or different?
# If no oldExecutor, maybe look at who IS the executor now? No.
pass
if user_name:
c_type = "executor_handoff"
if user_name:
if task_id not in credits:
credits[task_id] = []
# Avoid adding same user multiple times for same task?
# No, keep history, we dedup later in the stat calc if needed
# But for "Recent Wins" list we dedup.
credits[task_id].append({
'user': user_name,
'date': created_at,
'type': c_type
})
except Exception as e:
# print(f"Error parsing activity: {e}")
pass
return credits