Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/server/src/system_qa/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -734,15 +734,16 @@ pub(super) fn answer(
)
})
.collect();
let chart_rows: Vec<_> = rows
let mut chart_rows: Vec<_> = rows
.into_iter()
.map(|((label, group), values)| json!({"label":label,"group":group,"values":values}))
.collect();
super::heatmap::expand(&mut chart_rows, &dates, &events, data, intent.role, intent.groups);
let charts = if intent.chart {
let title = if intent.groups { "各组活动统计" } else { "成员活动统计" };
if intent.chart_kind == "activity_heatmap" {
vec![
json!({"kind":"activity_heatmap","title":title,"metric":metric,"dates":dates,"rows":chart_rows,"from":from,"through":through,"observedAt":data["observedAt"],"coverageComplete":complete}),
json!({"kind":"activity_heatmap","detailMode":intent.role,"expanded":chart_rows.iter().any(|row|row["cells"].is_array()),"detailNote":if chart_rows.len()*dates.len()>6000 {"范围较大,仅显示活动次数;缩小时间或人员范围可展开每日指标。"}else{""},"title":title,"metric":metric,"dates":dates,"rows":chart_rows,"from":from,"through":through,"observedAt":data["observedAt"],"coverageComplete":complete}),
]
} else {
let mut ranked = bar_values;
Expand Down
165 changes: 165 additions & 0 deletions crates/server/src/system_qa/heatmap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
use chrono::{DateTime, FixedOffset};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};

#[derive(Default)]
struct Cell {
count: u64,
latest: Option<DateTime<FixedOffset>>,
additions: Option<u64>,
deletions: Option<u64>,
incomplete: bool,
requirements: BTreeSet<String>,
bugs: BTreeSet<String>,
created: u64,
published: u64,
cases: u64,
}
impl Cell {
fn event(&mut self, event: &Value) {
self.count += 1;
if let Some(at) = event["at"].as_str().and_then(|at| DateTime::parse_from_rfc3339(at).ok())
{
if self.latest.is_none_or(|latest| at > latest) {
self.latest = Some(at);
}
}
let kind = text(event, "kind");
if kind == "commit" {
for (total, value) in [
(&mut self.additions, &event["additions"]),
(&mut self.deletions, &event["deletions"]),
] {
if let Some(value) = value.as_u64() {
*total = Some(total.unwrap_or_default() + value);
} else {
self.incomplete = true;
}
}
}
for requirement in event["requirements"].as_array().into_iter().flatten() {
let id = text(requirement, "externalId");
if !id.is_empty() {
self.requirements.insert(id.into());
}
}
if kind.starts_with("requirement") {
let id = text(event, "externalId");
if !id.is_empty() {
self.requirements.insert(id.into());
}
}
match kind {
"requirementCreated" => self.created += 1,
"requirementDelivered" => self.published += 1,
"testcaseCreated" => self.cases += 1,
"bugReported" => {
let id = text(event, "externalId");
if !id.is_empty() {
self.bugs.insert(id.into());
}
}
_ => {}
}
}
fn value(&self) -> Value {
json!({"count":self.count,"lastTime":self.latest.map(|at|at.with_timezone(&FixedOffset::east_opt(28800).expect("Shanghai")).format("%H:%M").to_string()),"additions":self.additions,"deletions":self.deletions,"lineStatsIncomplete":self.incomplete,"requirements":self.requirements.len(),"bugs":self.bugs.len(),"created":self.created,"published":self.published,"testcases":self.cases})
}
}
fn text<'a>(value: &'a Value, key: &str) -> &'a str {
value[key].as_str().unwrap_or_default()
}

pub fn expand(
rows: &mut [Value],
dates: &[String],
events: &[&Value],
data: &Value,
role: &str,
grouped: bool,
) {
if rows.len().saturating_mul(dates.len()) > 6000 {
return;
}
let mut index: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (i, row) in rows.iter().enumerate() {
index.entry(text(row, "label").into()).or_default().push(i);
}
let date_index: BTreeMap<_, _> =
dates.iter().enumerate().map(|(i, date)| (date.as_str(), i)).collect();
let mut cells: Vec<Vec<Cell>> =
(0..rows.len()).map(|_| (0..dates.len()).map(|_| Cell::default()).collect()).collect();
let mut totals: Vec<Cell> = (0..rows.len()).map(|_| Cell::default()).collect();
for event in events {
let Some(&day) = date_index.get(text(event, "date")) else { continue };
let actor =
if text(event, "actor").is_empty() { "未关联账号" } else { text(event, "actor") };
let group =
if text(event, "group").is_empty() { "待归属" } else { text(event, "group") };
if let Some(indices) = index.get(if grouped { group } else { actor }) {
for &i in indices {
if !grouped && text(&rows[i], "group") != group {
continue;
}
cells[i][day].event(event);
totals[i].event(event);
}
}
}
if role == "code" {
for bug in data["bugs"].as_array().into_iter().flatten() {
let Some(&day) = date_index.get(text(bug, "date")) else { continue };
let id = text(bug, "id");
if id.is_empty() {
continue;
}
for actor in
bug["responsibles"].as_array().into_iter().flatten().filter_map(Value::as_str)
{
let group = data["members"]
.as_array()
.into_iter()
.flatten()
.find(|member| text(member, "name") == actor)
.map(|member| text(member, "group"))
.filter(|group| !group.is_empty())
.unwrap_or("待归属");
if let Some(indices) = index.get(if grouped { group } else { actor }) {
for &i in indices {
cells[i][day].bugs.insert(id.into());
totals[i].bugs.insert(id.into());
}
}
}
}
}
for (i, row) in rows.iter_mut().enumerate() {
row["cells"] = json!(cells[i].iter().map(Cell::value).collect::<Vec<_>>());
row["totals"] = totals[i].value();
}
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cells_keep_last_time_known_lines_and_distinct_requirements_and_bugs() {
let dates = vec!["2026-11-07".into(), "2026-11-08".into()];
let mut rows = vec![json!({"label":"Member A","group":"Team","values":[2,1]})];
let events = [
json!({"kind":"commit","actor":"Member A","group":"Team","date":"2026-11-07","at":"2026-11-07T09:00:00Z","additions":9,"deletions":6,"requirements":[{"externalId":"r1"}]}),
json!({"kind":"commit","actor":"Member A","group":"Team","date":"2026-11-07","at":"2026-11-07T18:35:00+08:00","additions":null,"deletions":1,"requirements":[{"externalId":"r1"}]}),
json!({"kind":"commit","actor":"Member A","group":"Team","date":"2026-11-08","requirements":[{"externalId":"r1"}]}),
];
let data = json!({"bugs":[{"id":"b1","date":"2026-11-07","responsibles":["Member A"]},{"id":"b1","date":"2026-11-07","responsibles":["Member A"]}]});
expand(&mut rows, &dates, &events.iter().collect::<Vec<_>>(), &data, "code", false);
assert_eq!(rows[0]["cells"][0]["lastTime"], "18:35");
assert_eq!(rows[0]["cells"][0]["additions"], 9);
assert_eq!(rows[0]["cells"][0]["deletions"], 7);
assert_eq!(rows[0]["cells"][0]["requirements"], 1);
assert_eq!(rows[0]["totals"]["requirements"], 1);
assert_eq!(rows[0]["totals"]["bugs"], 1);
assert_eq!(rows[0]["totals"]["count"], 3);
assert!(rows[0]["cells"][1]["additions"].is_null());
}
}
1 change: 1 addition & 0 deletions crates/server/src/system_qa/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod activity;
mod audit;
mod business;
mod devops;
mod heatmap;
mod team;
use async_trait::async_trait;
use axum::{body::Body, http::Request, Router};
Expand Down
9 changes: 7 additions & 2 deletions crates/server/src/system_qa/team.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,16 @@ fn role_summary(
data["observedAt"].as_str().unwrap_or("未知"),
if complete { "" } else { "采集覆盖待核验,不能据此判断零产出。" }
);
let rows: Vec<_> = rows
let mut rows: Vec<_> = rows
.into_iter()
.map(|((label, group), values)| json!({"label":label,"group":group,"values":values}))
.collect();
let chart = json!({"kind":"activity_heatmap","title":format!("{label} · 团队产出"),"metric":"已观测活动次数","dates":dates,"rows":rows,"from":from,"through":through,"coverageComplete":complete,"observedAt":data["observedAt"]});
let scoped: Vec<_> = raw
.iter()
.filter(|event| kinds.contains(&event["kind"].as_str().unwrap_or_default()))
.collect();
super::heatmap::expand(&mut rows, &dates, &scoped, data, role, false);
let chart = json!({"kind":"activity_heatmap","detailMode":role,"expanded":rows.iter().any(|row|row["cells"].is_array()),"detailNote":if rows.len()*dates.len()>6000 {"范围较大,仅显示活动次数;缩小时间或人员范围可展开每日指标。"}else{""},"title":format!("{label} · 团队产出"),"metric":"已观测活动次数","dates":dates,"rows":rows,"from":from,"through":through,"coverageComplete":complete,"observedAt":data["observedAt"]});
Ok((
content,
chart,
Expand Down
85 changes: 85 additions & 0 deletions plugins/wecom-bot/scripts/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ def render_grounded_chart(spec, font_path):
return render_grounded_bars(spec, font_path)
if spec.get("kind") != "activity_heatmap":
raise ValueError("unsupported_chart")
if spec.get("expanded") and any(row.get("cells") for row in spec.get("rows", [])):
return render_expanded_heatmap(spec, font_path)
dates = spec.get("dates") or []
rows = spec.get("rows") or []
if not 1 <= len(dates) <= 366 or len(rows) > 500:
Expand Down Expand Up @@ -218,6 +220,89 @@ def render_grounded_chart(spec, font_path):
return {"msgtype": "image", "image": {"base64": base64.b64encode(raw).decode(), "md5": hashlib.md5(raw, usedforsecurity=False).hexdigest()}}


def render_expanded_heatmap(spec, font_path):
from PIL import Image, ImageDraw, ImageFont
from datetime import date
import math
dates, rows = spec.get("dates") or [], spec.get("rows") or []
if not 1 <= len(dates) <= 366 or len(rows) > 500:
raise ValueError("invalid_chart_size")
shown = rows[:60]
width = min(2800, max(1500, 380+len(dates)*220))
row_height = 78
height = max(350, 250+len(shown)*row_height)
picture = Image.new("RGB", (width, height), "#F4F7F6")
draw = ImageDraw.Draw(picture)
font = lambda size: ImageFont.truetype(font_path, size)
draw.rounded_rectangle((20, 20, width-20, height-20), radius=20, fill="white")
draw.text((45, 38), str(spec.get("title") or "成员产出")[:55], font=font(29), fill="#274B44")
draw.text((45, 84), f"{spec.get('from', '')} — {spec.get('through', '')} · 上海时间 · 展开指标", font=font(18), fill="#64746F")
left_edge, right_edge = 220, width-195
cell_width = (right_edge-left_edge)/len(dates)
mode = spec.get("detailMode", "code")
headers = ["提交", "需求", "Bug"] if mode == "code" else ["活动", "新建", "发布"] if mode == "product" else ["活动", "用例", "Bug"]
for i, label in enumerate(headers):
draw.text((right_edge+12+i*51, 126), label, font=font(16), fill="#73807C")
for column, day in enumerate(dates):
left = left_edge+column*cell_width
weekday = date.fromisoformat(day).weekday()
weekend = weekday >= 5
if weekend: draw.rounded_rectangle((left, 114, left+cell_width-4, 163), radius=4, fill="#F4EEE7")
if cell_width >= 40:
draw.text((left+8, 116), day[5:], font=font(17), fill="#A46B36" if weekend else "#64746F")
draw.text((left+8, 140), "周"+"一二三四五六日"[weekday], font=font(15), fill="#A46B36" if weekend else "#73807C")
maximum = max((v for row in shown for v in row.get("values", []) if isinstance(v, (int, float)) and math.isfinite(v) and v > 0), default=1)
def centered(text, left, top, size, color="#263F39"):
face=font(size);box=draw.textbbox((0,0),text,font=face)
draw.text((left+(cell_width-(box[2]-box[0]))/2,top),text,font=face,fill=color)
def number(value): return "—" if value is None else f"{int(value):,}"
for index, row in enumerate(shown):
top=174+index*row_height
draw.text((45,top+20),str(row.get("label") or "未关联账号")[:12],font=font(21),fill="#263F39")
for column, day in enumerate(dates):
left=left_edge+column*cell_width
value=(row.get("values") or [])[column] if column<len(row.get("values") or []) else 0
value=value if isinstance(value,(int,float)) and math.isfinite(value) and value>0 else 0
factor=.12+.55*math.sqrt(value/maximum) if value else 0
shade=tuple(round(245*(1-factor)+green*factor) for green in (72,137,119))
draw.rounded_rectangle((left,top,left+max(1,cell_width-4),top+row_height-5),radius=4,fill=shade)
cell=(row.get("cells") or [])[column] if column<len(row.get("cells") or []) else {}
if cell_width < 70:
if value: centered(str(int(value)),left,top+20,16)
continue
if not cell.get("count") and not cell.get("bugs"):
centered("·",left,top+20,20,"#A0AAA6");continue
heading=cell.get("lastTime") or "—"
if cell.get("bugs"):
badge="Bug "+str(cell["bugs"]);face=font(20);small=font(15)
tw=draw.textbbox((0,0),heading,font=face)[2];bw=draw.textbbox((0,0),badge,font=small)[2]+8
start=left+(cell_width-tw-bw-8)/2
draw.text((start,top+5),heading,font=face,fill="#263F39")
draw.rounded_rectangle((start+tw+7,top+7,start+tw+7+bw,top+29),radius=3,fill="#F4E8D9")
draw.text((start+tw+11,top+8),badge,font=small,fill="#A46B36")
else: centered(heading,left,top+5,20)
if mode == "code":
if cell.get("count"):
lines="行数未知" if cell.get("additions") is None and cell.get("deletions") is None else "+"+number(cell.get("additions"))+" / −"+number(cell.get("deletions"))
if cell.get("lineStatsIncomplete"): lines+="*"
centered(lines,left,top+31,15)
if cell.get("requirements"): centered("关联需求 "+str(cell["requirements"]),left,top+52,15)
else:
centered(str(cell.get("count",0))+" 次活动",left,top+31,16)
label=("新建 "+str(cell.get("created",0))+" · 发布 "+str(cell.get("published",0))) if mode=="product" else "用例 "+str(cell.get("testcases",0))
centered(label,left,top+52,15)
totals=row.get("totals") or {}
values=[totals.get("count"),totals.get("requirements"),totals.get("bugs")] if mode=="code" else [totals.get("count"),totals.get("created"),totals.get("published")] if mode=="product" else [totals.get("count"),totals.get("testcases"),totals.get("bugs")]
for i,value in enumerate(values): draw.text((right_edge+14+i*51,top+25),str(value) if value else "—",font=font(18),fill="#A46B36" if i==2 and mode!="product" and value else "#64746F")
note="需求与 Bug 期间分别去重 · * 行数不全,仅为已知下限" if mode=="code" else "新建与发布分别计次,同一需求可能同时出现" if mode=="product" else "用例按活动计数,Bug 期间去重"
footer=f"已采集记录 · 展示 {len(shown)} / {len(rows)} 行 · {note}"
if spec.get("coverageComplete") is False: footer+=" · 采集覆盖待核验"
draw.text((45,height-58),footer,font=font(17),fill="#73807C")
output=io.BytesIO();picture.save(output,format="PNG",optimize=True);raw=output.getvalue()
if len(raw)>2_000_000: raise ValueError("chart_exceeds_image_limit")
return {"msgtype":"image","image":{"base64":base64.b64encode(raw).decode(),"md5":hashlib.md5(raw,usedforsecurity=False).hexdigest()}}


def render_grounded_bars(spec, font_path):
from PIL import Image, ImageDraw, ImageFont
import math
Expand Down
2 changes: 1 addition & 1 deletion plugins/wecom-bot/scripts/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ async def ask(self, project, question, history, frame, stream):
answer, citations, event, last_update = "", [], "", 0.0
completed = False
charts = []
async with self.http.post(os.environ["SHEPHERD_URL"].rstrip("/")+"/rag/ask/stream", json=body, headers=headers) as response:
async with self.http.post(os.environ["SHEPHERD_URL"].rstrip("/")+"/rag/ask/stream", json=body, headers=headers, read_bufsize=4*1024*1024) as response:
if response.status != 200:
raise RuntimeError("backend_unavailable")
async for raw in response.content:
Expand Down
24 changes: 24 additions & 0 deletions web/e2e/grounded-qa.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,27 @@ test('pending direct authorization reuses the channel account and preserves grou
expect(saved?.allowBinding).toBe(false)
expect(saved?.conversations).toEqual([group, { kind: 'direct', externalId: 'user-a', projectId: 'project', enabled: true }])
})


test('answer heatmaps expand daily code indicators and hide zero bugs by default', async ({ page }, testInfo) => {
await setup(page)
await page.route('**/rag/ask/stream', route => {
const empty = { count: 0, lastTime: null, additions: null, deletions: null, requirements: 0, bugs: 0, created: 0, published: 0, testcases: 0 }
const chart = { kind: 'activity_heatmap', title: '成员展开热力图', metric: '已采集代码提交', detailMode: 'code', expanded: true, from: '2026-11-07', through: '2026-11-08', dates: ['2026-11-07', '2026-11-08'], rows: [{ label: '成员甲', group: '研发组', values: [2, 0], cells: [{ ...empty, count: 2, lastTime: '20:35', additions: 6191, deletions: 2150, requirements: 3 }, { ...empty, bugs: 2 }], totals: { ...empty, count: 2, requirements: 3, bugs: 2 } }] }
return route.fulfill({ contentType: 'text/event-stream', body: [['chart', chart], ['chunk', { delta: '已核对提交记录。' }], ['done', { grounded: true }]].map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`).join('') })
})
await page.goto('/p/project/chat')
await page.locator('textarea:not([aria-hidden="true"])').fill('团队代码热力图')
await page.locator('textarea:not([aria-hidden="true"])').press('Enter')
const chart = page.getByRole('region', { name: '成员展开热力图' })
await expect(chart.getByText('20:35', { exact: true })).toBeVisible()
await expect(chart.getByText('+6,191 / −2,150')).toBeVisible()
await expect(chart.getByText('关联需求 3')).toBeVisible()
await expect(chart.getByText('Bug 2', { exact: true })).toBeVisible()
await expect(chart.getByText('Bug 0', { exact: true })).toHaveCount(0)
await expect(chart.getByText('周六', { exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('rag-expanded-heatmap.png') })
await chart.getByRole('button', { name: '收起格子指标' }).click()
await expect(chart.getByText('20:35', { exact: true })).toHaveCount(0)
await expect(chart.getByRole('button', { name: '展开格子指标' })).toBeVisible()
})
Loading
Loading