feat: CI scoring overhaul — principal roll-up, commit-type filter, new weights
Step 1: principal column + commit_type column in pipeline.db. Static map populates principal for local agents (rio→m3taversal etc.). VPS agents (epimetheus, argus) have no principal. Step 2: _classify_commit_type in merge.py. Pipeline commits (inbox/, entities/, agents/) get commit_type='pipeline' and skip CI attribution entirely. Knowledge commits (domains/, core/, foundations/, decisions/) get full attribution. Step 3 (Argus): Dashboard has dual view — by-principal (default, governance) and by-agent (drill-down). Already implemented by Argus. CI weights updated (Cory-approved): - Challenger: 0.35 (was 0.20) - Synthesizer: 0.25 (was 0.15) - Reviewer: 0.20 (was 0.10) - Sourcer: 0.15 (unchanged) - Extractor: 0.05 (was 0.40) Pentagon-Agent: Epimetheus <3D35839A-7722-4740-B93D-51157F7D5E70>
This commit is contained in:
parent
1dfc6dcc5c
commit
cfb80d3496
2 changed files with 180 additions and 31 deletions
|
|
@ -180,28 +180,94 @@ def _version_changes(conn, days: int = 30) -> list[dict]:
|
|||
return changes
|
||||
|
||||
|
||||
def _contributor_leaderboard(conn, limit: int = 20) -> list[dict]:
|
||||
"""Top contributors by CI score."""
|
||||
def _has_column(conn, table: str, column: str) -> bool:
|
||||
"""Check if a column exists in a table (graceful schema migration support)."""
|
||||
cols = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
return any(c["name"] == column for c in cols)
|
||||
|
||||
|
||||
def _contributor_leaderboard(conn, limit: int = 20, view: str = "principal") -> list[dict]:
|
||||
"""Top contributors by CI score.
|
||||
|
||||
view="agent" — one row per contributor handle (original behavior)
|
||||
view="principal" — rolls up agent contributions to their principal (human)
|
||||
"""
|
||||
has_principal = _has_column(conn, "contributors", "principal")
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT handle, tier, claims_merged, sourcer_count, extractor_count, "
|
||||
"challenger_count, synthesizer_count, reviewer_count, domains, last_contribution "
|
||||
"FROM contributors ORDER BY claims_merged DESC LIMIT ?",
|
||||
(limit,),
|
||||
"challenger_count, synthesizer_count, reviewer_count, domains, last_contribution"
|
||||
+ (", principal" if has_principal else "") +
|
||||
" FROM contributors ORDER BY claims_merged DESC",
|
||||
).fetchall()
|
||||
|
||||
weights = {"sourcer": 0.15, "extractor": 0.40, "challenger": 0.20, "synthesizer": 0.15, "reviewer": 0.10}
|
||||
result = []
|
||||
for r in rows:
|
||||
ci = sum((r[f"{role}_count"] or 0) * w for role, w in weights.items())
|
||||
result.append({
|
||||
"handle": r["handle"],
|
||||
"tier": r["tier"],
|
||||
"claims_merged": r["claims_merged"] or 0,
|
||||
"ci": round(ci, 2),
|
||||
"domains": json.loads(r["domains"]) if r["domains"] else [],
|
||||
"last_contribution": r["last_contribution"],
|
||||
})
|
||||
return sorted(result, key=lambda x: x["ci"], reverse=True)
|
||||
# Weights reward quality over volume (Cory-approved)
|
||||
weights = {"sourcer": 0.15, "extractor": 0.05, "challenger": 0.35, "synthesizer": 0.25, "reviewer": 0.20}
|
||||
role_keys = list(weights.keys())
|
||||
|
||||
if view == "principal" and has_principal:
|
||||
# Aggregate by principal — agents with a principal roll up to the human
|
||||
buckets: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
principal = r["principal"]
|
||||
key = principal if principal else r["handle"]
|
||||
if key not in buckets:
|
||||
buckets[key] = {
|
||||
"handle": key,
|
||||
"tier": r["tier"],
|
||||
"claims_merged": 0,
|
||||
"domains": set(),
|
||||
"last_contribution": None,
|
||||
"agents": [],
|
||||
**{f"{role}_count": 0 for role in role_keys},
|
||||
}
|
||||
b = buckets[key]
|
||||
b["claims_merged"] += r["claims_merged"] or 0
|
||||
for role in role_keys:
|
||||
b[f"{role}_count"] += r[f"{role}_count"] or 0
|
||||
if r["domains"]:
|
||||
b["domains"].update(json.loads(r["domains"]))
|
||||
if r["last_contribution"]:
|
||||
if not b["last_contribution"] or r["last_contribution"] > b["last_contribution"]:
|
||||
b["last_contribution"] = r["last_contribution"]
|
||||
# Upgrade tier (veteran > contributor > new)
|
||||
tier_rank = {"veteran": 2, "contributor": 1, "new": 0}
|
||||
if tier_rank.get(r["tier"], 0) > tier_rank.get(b["tier"], 0):
|
||||
b["tier"] = r["tier"]
|
||||
if principal:
|
||||
b["agents"].append(r["handle"])
|
||||
|
||||
result = []
|
||||
for b in buckets.values():
|
||||
ci = sum(b[f"{role}_count"] * w for role, w in weights.items())
|
||||
result.append({
|
||||
"handle": b["handle"],
|
||||
"tier": b["tier"],
|
||||
"claims_merged": b["claims_merged"],
|
||||
"ci": round(ci, 2),
|
||||
"domains": sorted(b["domains"])[:5],
|
||||
"last_contribution": b["last_contribution"],
|
||||
"agents": b["agents"],
|
||||
})
|
||||
else:
|
||||
# By-agent view (original behavior)
|
||||
result = []
|
||||
for r in rows:
|
||||
ci = sum((r[f"{role}_count"] or 0) * w for role, w in weights.items())
|
||||
entry = {
|
||||
"handle": r["handle"],
|
||||
"tier": r["tier"],
|
||||
"claims_merged": r["claims_merged"] or 0,
|
||||
"ci": round(ci, 2),
|
||||
"domains": json.loads(r["domains"]) if r["domains"] else [],
|
||||
"last_contribution": r["last_contribution"],
|
||||
}
|
||||
if has_principal:
|
||||
entry["principal"] = r["principal"]
|
||||
result.append(entry)
|
||||
|
||||
result = sorted(result, key=lambda x: x["ci"], reverse=True)
|
||||
return result[:limit]
|
||||
|
||||
|
||||
# ─── Vital signs (Vida's five) ───────────────────────────────────────────────
|
||||
|
|
@ -386,7 +452,8 @@ async def handle_dashboard(request):
|
|||
snapshots = _snapshot_history(conn, days=7)
|
||||
changes = _version_changes(conn, days=30)
|
||||
vital_signs = _compute_vital_signs(conn)
|
||||
contributors = _contributor_leaderboard(conn, limit=10)
|
||||
contributors_principal = _contributor_leaderboard(conn, limit=10, view="principal")
|
||||
contributors_agent = _contributor_leaderboard(conn, limit=10, view="agent")
|
||||
except sqlite3.Error as e:
|
||||
return web.Response(
|
||||
text=_render_error(f"Pipeline database unavailable: {e}"),
|
||||
|
|
@ -394,7 +461,7 @@ async def handle_dashboard(request):
|
|||
status=503,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
html = _render_dashboard(metrics, snapshots, changes, vital_signs, contributors, now)
|
||||
html = _render_dashboard(metrics, snapshots, changes, vital_signs, contributors_principal, contributors_agent, now)
|
||||
return web.Response(text=html, content_type="text/html")
|
||||
|
||||
|
||||
|
|
@ -420,10 +487,19 @@ async def handle_api_vital_signs(request):
|
|||
|
||||
|
||||
async def handle_api_contributors(request):
|
||||
"""GET /api/contributors — contributor leaderboard."""
|
||||
"""GET /api/contributors — contributor leaderboard.
|
||||
|
||||
Query params:
|
||||
limit: max entries (default 50)
|
||||
view: "principal" (default, rolls up agents) or "agent" (one row per handle)
|
||||
"""
|
||||
conn = _conn(request)
|
||||
limit = int(request.query.get("limit", "50"))
|
||||
return web.json_response({"contributors": _contributor_leaderboard(conn, limit)})
|
||||
view = request.query.get("view", "principal")
|
||||
if view not in ("principal", "agent"):
|
||||
view = "principal"
|
||||
contributors = _contributor_leaderboard(conn, limit, view=view)
|
||||
return web.json_response({"contributors": contributors, "view": view})
|
||||
|
||||
|
||||
async def handle_api_domains(request):
|
||||
|
|
@ -445,7 +521,7 @@ def _render_error(message: str) -> str:
|
|||
</head><body><div class="err"><h1>Argus</h1><p>{message}</p><p>Check if <code>teleo-pipeline.service</code> is running and pipeline.db exists.</p></div></body></html>"""
|
||||
|
||||
|
||||
def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors, now) -> str:
|
||||
def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors_principal, contributors_agent, now) -> str:
|
||||
"""Render the full operational dashboard as HTML with Chart.js."""
|
||||
|
||||
# Prepare chart data
|
||||
|
|
@ -532,12 +608,23 @@ def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors, no
|
|||
total = sum(statuses.values())
|
||||
domain_rows += f"<tr><td>{domain}</td><td>{total}</td><td class='green'>{m}</td><td class='red'>{c}</td><td>{o}</td></tr>"
|
||||
|
||||
# Contributor rows
|
||||
contributor_rows = "".join(
|
||||
f'<tr><td>{c["handle"]}</td><td>{c["tier"]}</td>'
|
||||
# Contributor rows — principal view (default)
|
||||
principal_rows = "".join(
|
||||
f'<tr><td>{c["handle"]}'
|
||||
+ (f'<span style="color:#8b949e;font-size:11px"> ({", ".join(c["agents"])})</span>' if c.get("agents") else "")
|
||||
+ f'</td><td>{c["tier"]}</td>'
|
||||
f'<td>{c["claims_merged"]}</td><td>{c["ci"]}</td>'
|
||||
f'<td>{", ".join(c["domains"][:3]) if c["domains"] else "-"}</td></tr>'
|
||||
for c in contributors[:10]
|
||||
for c in contributors_principal[:10]
|
||||
)
|
||||
# Contributor rows — agent view
|
||||
agent_rows = "".join(
|
||||
f'<tr><td>{c["handle"]}'
|
||||
+ (f'<span style="color:#8b949e;font-size:11px"> → {c["principal"]}</span>' if c.get("principal") else "")
|
||||
+ f'</td><td>{c["tier"]}</td>'
|
||||
f'<td>{c["claims_merged"]}</td><td>{c["ci"]}</td>'
|
||||
f'<td>{", ".join(c["domains"][:3]) if c["domains"] else "-"}</td></tr>'
|
||||
for c in contributors_agent[:10]
|
||||
)
|
||||
|
||||
# Breaker status
|
||||
|
|
@ -740,11 +827,21 @@ def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors, no
|
|||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Top Contributors (by CI)</div>
|
||||
<div class="section-title" style="display:flex;align-items:center;gap:12px">
|
||||
Top Contributors (by CI)
|
||||
<span style="font-size:11px;font-weight:400">
|
||||
<button id="btn-principal" onclick="toggleContribView('principal')" style="background:#21262d;color:#58a6ff;border:1px solid #30363d;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px">By Human</button>
|
||||
<button id="btn-agent" onclick="toggleContribView('agent')" style="background:transparent;color:#8b949e;border:1px solid #30363d;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px">By Agent</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<tr><th>Handle</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr>
|
||||
{contributor_rows if contributor_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"}
|
||||
<table id="contrib-principal">
|
||||
<tr><th>Contributor</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr>
|
||||
{principal_rows if principal_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"}
|
||||
</table>
|
||||
<table id="contrib-agent" style="display:none">
|
||||
<tr><th>Agent</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr>
|
||||
{agent_rows if agent_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -909,6 +1006,28 @@ new Chart(document.getElementById('originChart'), {{
|
|||
}});
|
||||
|
||||
}} // end if (timestamps.length > 0)
|
||||
|
||||
function toggleContribView(view) {{
|
||||
const principal = document.getElementById('contrib-principal');
|
||||
const agent = document.getElementById('contrib-agent');
|
||||
const btnP = document.getElementById('btn-principal');
|
||||
const btnA = document.getElementById('btn-agent');
|
||||
if (view === 'agent') {{
|
||||
principal.style.display = 'none';
|
||||
agent.style.display = '';
|
||||
btnA.style.background = '#21262d';
|
||||
btnA.style.color = '#58a6ff';
|
||||
btnP.style.background = 'transparent';
|
||||
btnP.style.color = '#8b949e';
|
||||
}} else {{
|
||||
principal.style.display = '';
|
||||
agent.style.display = 'none';
|
||||
btnP.style.background = '#21262d';
|
||||
btnP.style.color = '#58a6ff';
|
||||
btnA.style.background = 'transparent';
|
||||
btnA.style.color = '#8b949e';
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
|
|
|
|||
30
lib/merge.py
30
lib/merge.py
|
|
@ -409,11 +409,33 @@ async def _delete_remote_branch(branch: str):
|
|||
# --- Contributor attribution ---
|
||||
|
||||
|
||||
def _classify_commit_type(diff: str) -> str:
|
||||
"""Classify a PR as 'knowledge' or 'pipeline' by files changed.
|
||||
|
||||
Knowledge: claims, decisions, core, foundations (full CI weight)
|
||||
Pipeline: inbox, entities, agents, archive (zero CI weight)
|
||||
"""
|
||||
knowledge_prefixes = ("domains/", "core/", "foundations/", "decisions/")
|
||||
pipeline_prefixes = ("inbox/", "entities/", "agents/")
|
||||
|
||||
has_knowledge = False
|
||||
for line in diff.split("\n"):
|
||||
if line.startswith("+++ b/") or line.startswith("--- a/"):
|
||||
path = line.split("/", 1)[1] if "/" in line else ""
|
||||
if any(path.startswith(p) for p in knowledge_prefixes):
|
||||
has_knowledge = True
|
||||
break
|
||||
|
||||
return "knowledge" if has_knowledge else "pipeline"
|
||||
|
||||
|
||||
async def _record_contributor_attribution(conn, pr_number: int, branch: str):
|
||||
"""Record contributor attribution after a successful merge.
|
||||
|
||||
Parses git trailers and claim frontmatter to identify contributors
|
||||
and their roles. Upserts into contributors table.
|
||||
Pipeline commits (inbox/, entities/, agents/) get commit_type='pipeline'
|
||||
and don't increment role counts.
|
||||
"""
|
||||
import re as _re
|
||||
from datetime import date as _date, datetime as _dt
|
||||
|
|
@ -425,6 +447,14 @@ async def _record_contributor_attribution(conn, pr_number: int, branch: str):
|
|||
if not diff:
|
||||
return
|
||||
|
||||
# Classify commit type — pipeline commits don't count toward CI
|
||||
commit_type = _classify_commit_type(diff)
|
||||
conn.execute("UPDATE prs SET commit_type = ? WHERE number = ?", (commit_type, pr_number))
|
||||
|
||||
if commit_type == "pipeline":
|
||||
logger.info("PR #%d: pipeline commit — skipping CI attribution", pr_number)
|
||||
return
|
||||
|
||||
# Parse Pentagon-Agent trailer from branch commit messages
|
||||
agents_found: set[str] = set()
|
||||
rc, log_output = await _git(
|
||||
|
|
|
|||
Loading…
Reference in a new issue