ship: add contributor attribution tracing to PR lifecycle

- Migration v19: submitted_by column on prs + sources tables
- extract.py: propagates proposed_by from source frontmatter → PR record
- merge.py: sets submitted_by from Forgejo author for human PRs
- dashboard_prs.py: redesigned with Contributor column, improved claim
  visibility in expanded rows, cost estimates, evaluator chain display
- dashboard_routes.py: submitted_by + source_path in pr-lifecycle API
- backfill_submitted_by.py: one-time backfill (1525/1777 PRs matched)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
m3taversal 2026-04-07 12:54:06 +01:00 committed by Teleo Agents
parent adbe3bd911
commit 9925576c13
6 changed files with 378 additions and 113 deletions

View file

@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""One-time backfill: populate submitted_by on prs table from source archive files.
Matches PRs to sources via branch name slug source filename.
Reads proposed_by and intake_tier from source frontmatter.
Run: python3 backfill_submitted_by.py
"""
import os
import re
import sqlite3
from pathlib import Path
DB_PATH = os.environ.get("DB_PATH", "/opt/teleo-eval/pipeline/pipeline.db")
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", "/opt/teleo-eval/workspaces/main/inbox/archive"))
def parse_frontmatter(path: Path) -> dict:
"""Parse YAML-like frontmatter from a markdown file."""
text = path.read_text(encoding="utf-8", errors="replace")
if not text.startswith("---"):
return {}
end = text.find("---", 3)
if end == -1:
return {}
fm = {}
for line in text[3:end].strip().split("\n"):
line = line.strip()
if not line or ":" not in line:
continue
key, _, val = line.partition(":")
key = key.strip()
val = val.strip().strip('"').strip("'")
if val.lower() == "null" or val == "":
val = None
fm[key] = val
return fm
def slug_from_branch(branch: str) -> str:
"""Extract source slug from branch name like 'extract/2026-04-06-slug-hash'."""
if "/" in branch:
branch = branch.split("/", 1)[1]
# Strip trailing hex hash (e.g., -3e68, -a6af)
branch = re.sub(r"-[0-9a-f]{4}$", "", branch)
return branch
def main():
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
# Build source index: filename stem → frontmatter
source_index = {}
if ARCHIVE_DIR.exists():
for f in ARCHIVE_DIR.glob("*.md"):
fm = parse_frontmatter(f)
source_index[f.stem] = fm
print(f"Indexed {len(source_index)} source files from {ARCHIVE_DIR}")
# Get all PRs without submitted_by
prs = conn.execute(
"SELECT number, branch FROM prs WHERE submitted_by IS NULL AND branch IS NOT NULL"
).fetchall()
print(f"Found {len(prs)} PRs without submitted_by")
updated = 0
for pr in prs:
branch = pr["branch"]
slug = slug_from_branch(branch)
# Try to match slug to a source file
fm = source_index.get(slug)
if not fm:
# Try partial matching: slug might be a substring of the source filename
for stem, sfm in source_index.items():
if slug in stem or stem in slug:
fm = sfm
break
if fm:
proposed_by = fm.get("proposed_by")
intake_tier = fm.get("intake_tier")
if proposed_by:
contributor = proposed_by.strip().strip('"').strip("'")
elif intake_tier == "research-task":
# Derive agent from branch prefix
prefix = branch.split("/", 1)[0] if "/" in branch else "unknown"
agent_map = {
"extract": "pipeline", "ingestion": "pipeline",
"rio": "rio", "theseus": "theseus", "vida": "vida",
"clay": "clay", "astra": "astra", "leo": "leo",
"reweave": "pipeline",
}
agent = agent_map.get(prefix, prefix)
contributor = f"{agent} (self-directed)"
elif intake_tier == "directed":
contributor = "directed (unknown)"
else:
contributor = None
if contributor:
conn.execute(
"UPDATE prs SET submitted_by = ?, source_path = ? WHERE number = ?",
(contributor, f"inbox/archive/{slug}.md", pr["number"]),
)
updated += 1
else:
# For extract/ branches, mark as pipeline self-directed
if branch.startswith("extract/") or branch.startswith("ingestion/"):
conn.execute(
"UPDATE prs SET submitted_by = 'pipeline (self-directed)' WHERE number = ?",
(pr["number"],),
)
updated += 1
elif branch.startswith(("rio/", "theseus/", "vida/", "clay/", "astra/", "leo/")):
agent = branch.split("/", 1)[0]
conn.execute(
"UPDATE prs SET submitted_by = ? WHERE number = ?",
(f"{agent} (self-directed)", pr["number"]),
)
updated += 1
elif branch.startswith("reweave/"):
conn.execute(
"UPDATE prs SET submitted_by = 'pipeline (reweave)' WHERE number = ?",
(pr["number"],),
)
updated += 1
conn.commit()
conn.close()
print(f"Updated {updated}/{len(prs)} PRs with submitted_by")
if __name__ == "__main__":
main()

View file

@ -1,8 +1,8 @@
"""PR Lifecycle dashboard — single-page view of every PR through the pipeline.
Sortable table: PR#, summary, agent, domain, outcome, TTM, date.
Click any row to expand the full trace (triage reasoning, review text, cascade).
Hero cards: total PRs, merge rate, median TTM, median eval rounds.
Sortable table: PR#, summary, claims, domain, contributor, outcome, evals, evaluator, cost, date.
Click any row to expand: claim titles, eval chain, timeline, reviews, issues.
Hero cards: total PRs, merge rate, total claims, est. cost.
Data sources: prs table, audit_log (eval rounds), review_records.
Owner: Ship
@ -14,19 +14,23 @@ from shared_ui import render_page
EXTRA_CSS = """
.content-wrapper { max-width: 1600px !important; }
.filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.filters select, .filters input {
background: #161b22; color: #c9d1d9; border: 1px solid #30363d;
border-radius: 6px; padding: 6px 10px; font-size: 12px; }
.filters select:focus, .filters input:focus { border-color: #58a6ff; outline: none; }
.pr-table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
.pr-table th:nth-child(1) { width: 60px; } /* PR# */
.pr-table th:nth-child(2) { width: 38%; } /* Summary */
.pr-table th:nth-child(3) { width: 10%; } /* Agent */
.pr-table th:nth-child(4) { width: 14%; } /* Domain */
.pr-table th:nth-child(5) { width: 10%; } /* Outcome */
.pr-table th:nth-child(6) { width: 7%; } /* TTM */
.pr-table th:nth-child(7) { width: 10%; } /* Date */
.pr-table th:nth-child(1) { width: 50px; } /* PR# */
.pr-table th:nth-child(2) { width: 28%; } /* Summary */
.pr-table th:nth-child(3) { width: 50px; } /* Claims */
.pr-table th:nth-child(4) { width: 11%; } /* Domain */
.pr-table th:nth-child(5) { width: 10%; } /* Contributor */
.pr-table th:nth-child(6) { width: 10%; } /* Outcome */
.pr-table th:nth-child(7) { width: 44px; } /* Evals */
.pr-table th:nth-child(8) { width: 12%; } /* Evaluator */
.pr-table th:nth-child(9) { width: 60px; } /* Cost */
.pr-table th:nth-child(10) { width: 80px; } /* Date */
.pr-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 8px 6px; }
.pr-table td:nth-child(2) { white-space: normal; overflow: visible; line-height: 1.4; }
.pr-table th { cursor: pointer; user-select: none; position: relative; padding: 8px 18px 8px 6px; }
@ -46,11 +50,23 @@ EXTRA_CSS = """
.pr-table td .summary-text { font-size: 12px; color: #c9d1d9; }
.pr-table td .review-snippet { font-size: 11px; color: #f85149; margin-top: 2px; opacity: 0.8; }
.pr-table td .model-tag { font-size: 10px; color: #6e7681; background: #161b22; border-radius: 3px; padding: 1px 4px; }
.pr-table td .contributor-tag { font-size: 11px; color: #d2a8ff; }
.pr-table td .contributor-self { font-size: 11px; color: #6e7681; font-style: italic; }
.pr-table td .expand-chevron { display: inline-block; width: 12px; color: #484f58; font-size: 10px; transition: transform 0.2s; }
.pr-table tr.expanded .expand-chevron { transform: rotate(90deg); color: #58a6ff; }
.trace-panel { background: #0d1117; border: 1px solid #30363d; border-radius: 8px;
padding: 16px; margin: 4px 0 8px 0; font-size: 12px; display: none; }
.trace-panel.open { display: block; }
.trace-panel h4 { color: #58a6ff; font-size: 12px; margin: 12px 0 6px 0; }
.trace-panel h4:first-child { margin-top: 0; }
.claim-list { list-style: none; padding: 0; margin: 0; }
.claim-list li { padding: 4px 0 4px 16px; border-left: 2px solid #238636; color: #c9d1d9; font-size: 12px; line-height: 1.5; }
.claim-list li .claim-confidence { font-size: 10px; color: #8b949e; margin-left: 6px; }
.issues-box { background: #1c1210; border: 1px solid #f8514933; border-radius: 6px;
padding: 8px 12px; margin: 4px 0; font-size: 12px; color: #f85149; }
.eval-chain { background: #161b22; border-radius: 6px; padding: 8px 12px; margin: 4px 0; font-size: 12px; }
.eval-chain .chain-step { display: inline-block; margin-right: 6px; }
.eval-chain .chain-arrow { color: #484f58; margin: 0 4px; }
.trace-timeline { list-style: none; padding: 0; }
.trace-timeline li { padding: 4px 0; border-left: 2px solid #30363d; padding-left: 12px; margin-left: 8px; }
.trace-timeline li .ts { color: #484f58; font-size: 11px; }
@ -66,9 +82,6 @@ EXTRA_CSS = """
.pagination button:hover { border-color: #58a6ff; }
.pagination button:disabled { opacity: 0.4; cursor: default; }
.pagination .page-info { color: #8b949e; font-size: 12px; }
.stat-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
.stat-row .mini-stat { font-size: 11px; color: #8b949e; }
.stat-row .mini-stat span { color: #c9d1d9; font-weight: 600; }
"""
@ -80,15 +93,14 @@ def render_prs_page(now: datetime) -> str:
<div class="grid" id="hero-cards">
<div class="card"><div class="label">Total PRs</div><div class="value blue" id="kpi-total">--</div><div class="detail" id="kpi-total-detail"></div></div>
<div class="card"><div class="label">Merge Rate</div><div class="value green" id="kpi-merge-rate">--</div><div class="detail" id="kpi-merge-detail"></div></div>
<div class="card"><div class="label">Median Time-to-Merge</div><div class="value" id="kpi-ttm">--</div><div class="detail" id="kpi-ttm-detail"></div></div>
<div class="card"><div class="label">Median Eval Rounds</div><div class="value" id="kpi-rounds">--</div><div class="detail" id="kpi-rounds-detail"></div></div>
<div class="card"><div class="label">Total Claims</div><div class="value blue" id="kpi-claims">--</div><div class="detail" id="kpi-claims-detail"></div></div>
<div class="card"><div class="label">Est. Cost</div><div class="value" id="kpi-cost">--</div><div class="detail" id="kpi-cost-detail"></div></div>
</div>
<!-- Filters -->
<div class="filters">
<select id="filter-domain"><option value="">All Domains</option></select>
<select id="filter-agent"><option value="">All Agents</option></select>
<select id="filter-contributor"><option value="">All Contributors</option></select>
<select id="filter-outcome">
<option value="">All Outcomes</option>
<option value="merged">Merged</option>
@ -116,10 +128,13 @@ def render_prs_page(now: datetime) -> str:
<tr>
<th data-col="number">PR# <span class="sort-arrow">&#9650;</span></th>
<th data-col="summary">Summary <span class="sort-arrow">&#9650;</span></th>
<th data-col="agent">Agent <span class="sort-arrow">&#9650;</span></th>
<th data-col="claims_count">Claims <span class="sort-arrow">&#9650;</span></th>
<th data-col="domain">Domain <span class="sort-arrow">&#9650;</span></th>
<th data-col="submitted_by">Contributor <span class="sort-arrow">&#9650;</span></th>
<th data-col="status">Outcome <span class="sort-arrow">&#9650;</span></th>
<th data-col="ttm_minutes">TTM <span class="sort-arrow">&#9650;</span></th>
<th data-col="eval_rounds">Evals <span class="sort-arrow">&#9650;</span></th>
<th data-col="evaluator_label">Evaluator <span class="sort-arrow">&#9650;</span></th>
<th data-col="est_cost">Cost <span class="sort-arrow">&#9650;</span></th>
<th data-col="created_at">Date <span class="sort-arrow">&#9650;</span></th>
</tr>
</thead>
@ -135,46 +150,71 @@ def render_prs_page(now: datetime) -> str:
</div>
"""
# Use single-quoted JS strings throughout to avoid Python/HTML escaping issues
scripts = """<script>
const PAGE_SIZE = 50;
const FORGEJO = 'https://git.livingip.xyz/teleo/teleo-codex/pulls/';
let allData = [];
let filtered = [];
let sortCol = 'number';
let sortAsc = false;
let page = 0;
let expandedPr = null;
var PAGE_SIZE = 50;
var FORGEJO = 'https://git.livingip.xyz/teleo/teleo-codex/pulls/';
var allData = [];
var filtered = [];
var sortCol = 'number';
var sortAsc = false;
var page = 0;
var expandedPr = null;
// Tier-based cost estimates (per eval round)
var TIER_COSTS = {
'DEEP': 0.145, // Haiku triage + Gemini Flash domain + Opus Leo
'STANDARD': 0.043, // Haiku triage + Gemini Flash domain + Sonnet Leo
'LIGHT': 0.027 // Haiku triage + Gemini Flash domain only
};
function estimateCost(pr) {
var tier = pr.tier || 'STANDARD';
var rounds = pr.eval_rounds || 1;
var baseCost = TIER_COSTS[tier] || TIER_COSTS['STANDARD'];
return baseCost * rounds;
}
function fmtCost(val) {
if (val == null || val === 0) return '--';
return '$' + val.toFixed(3);
}
function loadData() {
var days = document.getElementById('filter-days').value;
var url = '/api/pr-lifecycle' + (days !== '0' ? '?days=' + days : '?days=9999');
fetch(url).then(function(r) { return r.json(); }).then(function(data) {
allData = data.prs || [];
// Compute derived fields
allData.forEach(function(p) {
p.est_cost = estimateCost(p);
// Evaluator label for sorting
p.evaluator_label = p.domain_agent || p.agent || '--';
});
populateFilters(allData);
updateKPIs(data);
applyFilters();
}).catch(function() {
document.getElementById('pr-tbody').innerHTML =
'<tr><td colspan="7" style="text-align:center;color:#f85149;">Failed to load data</td></tr>';
'<tr><td colspan="10" style="text-align:center;color:#f85149;">Failed to load data</td></tr>';
});
}
function populateFilters(prs) {
var domains = [], agents = [], seenD = {}, seenA = {};
var domains = [], contribs = [], seenD = {}, seenC = {};
prs.forEach(function(p) {
if (p.domain && !seenD[p.domain]) { seenD[p.domain] = 1; domains.push(p.domain); }
if (p.agent && !seenA[p.agent]) { seenA[p.agent] = 1; agents.push(p.agent); }
var c = p.submitted_by || 'unknown';
if (!seenC[c]) { seenC[c] = 1; contribs.push(c); }
});
domains.sort(); agents.sort();
domains.sort(); contribs.sort();
var domSel = document.getElementById('filter-domain');
var agSel = document.getElementById('filter-agent');
var curDom = domSel.value, curAg = agSel.value;
var conSel = document.getElementById('filter-contributor');
var curDom = domSel.value, curCon = conSel.value;
domSel.innerHTML = '<option value="">All Domains</option>' +
domains.map(function(d) { return '<option value="' + esc(d) + '">' + esc(d) + '</option>'; }).join('');
agSel.innerHTML = '<option value="">All Agents</option>' +
agents.map(function(a) { return '<option value="' + esc(a) + '">' + esc(a) + '</option>'; }).join('');
domSel.value = curDom; agSel.value = curAg;
conSel.innerHTML = '<option value="">All Contributors</option>' +
contribs.map(function(c) { return '<option value="' + esc(c) + '">' + esc(c) + '</option>'; }).join('');
domSel.value = curDom; conSel.value = curCon;
}
function updateKPIs(data) {
@ -186,40 +226,29 @@ def render_prs_page(now: datetime) -> str:
document.getElementById('kpi-merge-rate').textContent = fmtPct(rate);
document.getElementById('kpi-merge-detail').textContent = fmtNum(data.open) + ' open';
document.getElementById('kpi-ttm').textContent =
data.median_ttm != null ? fmtDuration(data.median_ttm) : '--';
document.getElementById('kpi-ttm-detail').textContent =
data.p90_ttm != null ? 'p90: ' + fmtDuration(data.p90_ttm) : '';
document.getElementById('kpi-rounds').textContent =
data.median_rounds != null ? data.median_rounds.toFixed(1) : '--';
document.getElementById('kpi-rounds-detail').textContent =
data.max_rounds != null ? 'max: ' + data.max_rounds : '';
var totalClaims = 0, mergedClaims = 0;
var totalClaims = 0, mergedClaims = 0, totalCost = 0;
(data.prs || []).forEach(function(p) {
totalClaims += (p.claims_count || 1);
if (p.status === 'merged') mergedClaims += (p.claims_count || 1);
totalCost += estimateCost(p);
});
document.getElementById('kpi-claims').textContent = fmtNum(totalClaims);
document.getElementById('kpi-claims-detail').textContent = fmtNum(mergedClaims) + ' merged';
}
function fmtDuration(mins) {
if (mins < 60) return mins.toFixed(0) + 'm';
if (mins < 1440) return (mins / 60).toFixed(1) + 'h';
return (mins / 1440).toFixed(1) + 'd';
document.getElementById('kpi-cost').textContent = '$' + totalCost.toFixed(2);
var perClaim = totalClaims > 0 ? totalCost / totalClaims : 0;
document.getElementById('kpi-cost-detail').textContent = '$' + perClaim.toFixed(3) + '/claim';
}
function applyFilters() {
var dom = document.getElementById('filter-domain').value;
var ag = document.getElementById('filter-agent').value;
var con = document.getElementById('filter-contributor').value;
var out = document.getElementById('filter-outcome').value;
var tier = document.getElementById('filter-tier').value;
filtered = allData.filter(function(p) {
if (dom && p.domain !== dom) return false;
if (ag && p.agent !== ag) return false;
if (con && (p.submitted_by || 'unknown') !== con) return false;
if (out && p.status !== out) return false;
if (tier && p.tier !== tier) return false;
return true;
@ -256,7 +285,7 @@ def render_prs_page(now: datetime) -> str:
var totalPages = Math.ceil(filtered.length / PAGE_SIZE);
if (slice.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#8b949e;">No PRs match filters</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;color:#8b949e;">No PRs match filters</td></tr>';
return;
}
@ -266,48 +295,51 @@ def render_prs_page(now: datetime) -> str:
p.status === 'closed' ? 'outcome-closed' : 'outcome-open';
var tierClass = (p.tier || '').toLowerCase() === 'deep' ? 'tier-deep' :
(p.tier || '').toLowerCase() === 'standard' ? 'tier-standard' : 'tier-light';
var ttm = p.ttm_minutes != null ? fmtDuration(p.ttm_minutes) : '--';
var date = p.created_at ? p.created_at.substring(0, 10) : '--';
var agent = p.agent || '--';
// Summary: first claim title from description
var summary = '--';
if (p.summary) {
summary = p.summary;
} else if (p.description) {
var parts = p.description.split('|');
summary = truncate(parts[0].trim(), 80);
if (parts.length > 1) summary += ' (+' + (parts.length - 1) + ' more)';
}
// Summary: first claim title
var summary = p.summary || '--';
// Outcome label with eval rounds
var outcomeLabel = esc(p.status || '--');
if (p.eval_rounds > 1) {
outcomeLabel += ' <span style="color:#6e7681;font-size:11px;">(' + p.eval_rounds + ' evals)</span>';
}
// Review snippet for closed/changes PRs
var reviewSnippet = '';
if (p.status === 'closed' && p.review_snippet) {
reviewSnippet = '<div class="review-snippet">' + esc(truncate(p.review_snippet, 120)) + '</div>';
}
// Tier badge inline with outcome
// Outcome with tier badge
var tierBadge = p.tier ? ' <span class="' + tierClass + '" style="font-size:10px;">' + esc(p.tier) + '</span>' : '';
// Review snippet for issues
var reviewSnippet = '';
if (p.review_snippet) {
reviewSnippet = '<div class="review-snippet">' + esc(truncate(p.review_snippet, 100)) + '</div>';
}
// Contributor display
var contributor = p.submitted_by || '--';
var contribClass = 'contributor-tag';
if (contributor.indexOf('self-directed') >= 0 || contributor === 'unknown') {
contribClass = 'contributor-self';
}
// Evaluator: domain agent + model tags
var evaluator = '';
if (p.domain_agent) {
evaluator = esc(p.domain_agent);
} else if (p.agent && p.agent !== 'pipeline') {
evaluator = esc(p.agent);
}
rows.push(
'<tr data-pr="' + p.number + '">' +
'<td><span class="expand-chevron">&#9654;</span> ' +
'<a class="pr-link" href="' + FORGEJO + p.number + '" target="_blank" rel="noopener" onclick="event.stopPropagation();">#' + p.number + '</a></td>' +
'<td style="white-space:normal;"><span class="summary-text">' + esc(summary) + '</span>' + reviewSnippet + '</td>' +
'<td>' + esc(agent) + '</td>' +
'<td style="text-align:center;">' + (p.claims_count || 1) + '</td>' +
'<td>' + esc(p.domain || '--') + '</td>' +
'<td class="' + outClass + '">' + outcomeLabel + tierBadge + '</td>' +
'<td>' + ttm + '</td>' +
'<td><span class="' + contribClass + '">' + esc(truncate(contributor, 20)) + '</span></td>' +
'<td class="' + outClass + '">' + esc(p.status || '--') + tierBadge + '</td>' +
'<td style="text-align:center;">' + (p.eval_rounds || '--') + '</td>' +
'<td>' + evaluator + '</td>' +
'<td>' + fmtCost(p.est_cost) + '</td>' +
'<td>' + date + '</td>' +
'</tr>' +
'<tr id="trace-' + p.number + '" style="display:none;"><td colspan="7" style="padding:0;">' +
'<div class="trace-panel" id="panel-' + p.number + '">Loading trace...</div>' +
'<tr id="trace-' + p.number + '" style="display:none;"><td colspan="10" style="padding:0;">' +
'<div class="trace-panel" id="panel-' + p.number + '">Loading...</div>' +
'</td></tr>'
);
});
@ -341,7 +373,6 @@ def render_prs_page(now: datetime) -> str:
// Row click -> trace expand
document.getElementById('pr-tbody').addEventListener('click', function(e) {
// Don't expand if clicking a link
if (e.target.closest('a')) return;
var row = e.target.closest('tr[data-pr]');
if (!row) return;
@ -371,20 +402,34 @@ def render_prs_page(now: datetime) -> str:
});
function loadTrace(pr, panel) {
// Find the PR data for claim titles
var prData = null;
for (var i = 0; i < allData.length; i++) {
if (allData[i].number == pr) { prData = allData[i]; break; }
}
fetch('/api/trace/' + pr).then(function(r) { return r.json(); }).then(function(data) {
var html = '';
// PR metadata
if (data.pr) {
html += '<div class="stat-row" style="gap:16px;">';
html += '<div class="mini-stat">Source: <span>' + esc(data.pr.source_path || '--') + '</span></div>';
if (data.pr.agent) html += '<div class="mini-stat">Agent: <span>' + esc(data.pr.agent) + '</span></div>';
if (data.pr.tier) html += '<div class="mini-stat">Tier: <span>' + esc(data.pr.tier) + '</span></div>';
html += '<div class="mini-stat"><a class="pr-link" href="' + FORGEJO + pr + '" target="_blank">View on Forgejo</a></div>';
html += '</div>';
// Claims contained in this PR
if (prData && prData.description) {
var titles = prData.description.split('|').map(function(t) { return t.trim(); }).filter(Boolean);
if (titles.length > 0) {
html += '<h4>Claims (' + titles.length + ')</h4>';
html += '<ul class="claim-list">';
titles.forEach(function(t) {
html += '<li>' + esc(t) + '</li>';
});
html += '</ul>';
}
}
// Eval chain models
// Issues (if any)
if (prData && prData.review_snippet) {
html += '<div class="issues-box">' + esc(prData.review_snippet) + '</div>';
}
// Eval chain with models
var models = {};
if (data.timeline) {
data.timeline.forEach(function(ev) {
@ -395,20 +440,38 @@ def render_prs_page(now: datetime) -> str:
}
});
}
if (Object.keys(models).length > 0) {
html += '<div style="background:#161b22;border-radius:6px;padding:8px 12px;margin:4px 0 8px;font-size:12px;">';
html += '<strong style="color:#58a6ff;">Eval Chain:</strong> ';
var parts = [];
if (models['triage.haiku_triage']) parts.push('Triage: ' + models['triage.haiku_triage']);
if (models['domain_review']) parts.push('Domain: ' + models['domain_review']);
if (models['leo_review']) parts.push('Leo: ' + models['leo_review']);
html += parts.length > 0 ? parts.join(' &#8594; ') : '<span style="color:#484f58;">No model data</span>';
html += '<div class="eval-chain"><strong style="color:#58a6ff;">Eval Chain:</strong> ';
var chain = [];
if (models['triage.haiku_triage'] || models['triage.deterministic_triage']) {
chain.push('<span class="chain-step">Triage <span class="model-tag">' +
esc(models['triage.haiku_triage'] || 'deterministic') + '</span></span>');
}
if (models['domain_review']) {
chain.push('<span class="chain-step">Domain <span class="model-tag">' +
esc(models['domain_review']) + '</span></span>');
}
if (models['leo_review']) {
chain.push('<span class="chain-step">Leo <span class="model-tag">' +
esc(models['leo_review']) + '</span></span>');
}
html += chain.length > 0 ? chain.join('<span class="chain-arrow">&#8594;</span>') :
'<span style="color:#484f58;">No model data</span>';
html += '</div>';
// Source + contributor metadata
if (data.pr) {
html += '<div style="margin:8px 0;font-size:12px;color:#8b949e;">';
if (data.pr.source_path) html += 'Source: <span style="color:#c9d1d9;">' + esc(data.pr.source_path) + '</span> &middot; ';
if (prData && prData.submitted_by) html += 'Contributor: <span style="color:#d2a8ff;">' + esc(prData.submitted_by) + '</span> &middot; ';
if (data.pr.tier) html += 'Tier: <span style="color:#c9d1d9;">' + esc(data.pr.tier) + '</span> &middot; ';
html += '<a class="pr-link" href="' + FORGEJO + pr + '" target="_blank">View on Forgejo</a>';
html += '</div>';
}
// Timeline
// Timeline
if (data.timeline && data.timeline.length > 0) {
html += '<h4 style="color:#58a6ff;font-size:12px;margin:8px 0 4px;">Timeline</h4>';
html += '<h4>Timeline</h4>';
html += '<ul class="trace-timeline">';
data.timeline.forEach(function(ev) {
var cls = ev.event === 'approved' ? 'ev-approved' :
@ -437,12 +500,12 @@ def render_prs_page(now: datetime) -> str:
});
html += '</ul>';
} else {
html += '<div style="color:#484f58;font-size:12px;">No timeline events</div>';
html += '<div style="color:#484f58;font-size:12px;margin:8px 0;">No timeline events</div>';
}
// Reviews
// Reviews
if (data.reviews && data.reviews.length > 0) {
html += '<h4 style="color:#58a6ff;font-size:12px;margin:8px 0 4px;">Reviews</h4>';
html += '<h4>Reviews</h4>';
data.reviews.forEach(function(r) {
var cls = r.outcome === 'approved' ? 'badge-green' :
r.outcome === 'rejected' ? 'badge-red' : 'badge-yellow';
@ -468,7 +531,7 @@ def render_prs_page(now: datetime) -> str:
}
// Filter listeners
['filter-domain', 'filter-agent', 'filter-outcome', 'filter-tier'].forEach(function(id) {
['filter-domain', 'filter-contributor', 'filter-outcome', 'filter-tier'].forEach(function(id) {
document.getElementById(id).addEventListener('change', applyFilters);
});
document.getElementById('filter-days').addEventListener('change', loadData);

View file

@ -732,7 +732,8 @@ async def handle_pr_lifecycle(request):
pr_rows = conn.execute(
f"""SELECT p.number, p.agent, p.domain, p.tier, p.status,
p.created_at, p.merged_at, p.leo_verdict, p.description,
p.domain_agent, p.domain_model, p.branch
p.domain_agent, p.domain_model, p.branch, p.submitted_by,
p.source_path
FROM prs p
WHERE 1=1 {day_clause}
ORDER BY p.number DESC""",
@ -879,6 +880,8 @@ async def handle_pr_lifecycle(request):
"summary": summary,
"description": desc if desc.strip() else None,
"review_snippet": snippet_map.get(pr_num),
"submitted_by": r["submitted_by"],
"source_path": r["source_path"],
})
# Summary KPIs

View file

@ -9,7 +9,7 @@ from . import config
logger = logging.getLogger("pipeline.db")
SCHEMA_VERSION = 18
SCHEMA_VERSION = 19
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS schema_version (
@ -516,6 +516,20 @@ def migrate(conn: sqlite3.Connection):
conn.commit()
logger.info("Migration v18: created review_records table")
if current < 19:
# Add submitted_by for contributor attribution tracing.
# Tracks who submitted the source: human handle, agent name, or "self-directed".
try:
conn.execute("ALTER TABLE prs ADD COLUMN submitted_by TEXT")
except sqlite3.OperationalError:
pass # Column already exists
try:
conn.execute("ALTER TABLE sources ADD COLUMN submitted_by TEXT")
except sqlite3.OperationalError:
pass
conn.commit()
logger.info("Migration v19: added submitted_by to prs and sources tables")
if current < SCHEMA_VERSION:
conn.execute(
"INSERT OR REPLACE INTO schema_version (version) VALUES (?)",

View file

@ -517,6 +517,49 @@ async def _extract_one_source(
if pr_result and pr_result.get("number"):
pr_num = pr_result["number"]
logger.info("PR #%d created for %s (%d claims, %d entities)", pr_num, source_file, len(claim_files), len(entity_files))
# Store contributor attribution: who submitted this source?
# Priority: proposed_by field → intake_tier inference → "unknown"
if proposed_by:
contributor = proposed_by.strip().strip('"').strip("'")
elif intake_tier == "research-task":
contributor = f"{agent_name} (self-directed)"
elif intake_tier == "directed":
contributor = "directed (unknown)"
else:
contributor = "unknown"
# Build pipe-separated claim titles for the description field
claim_titles = " | ".join(
c.get("title", c.get("filename", "").replace("-", " ").replace(".md", ""))
for c in claims_raw if c.get("title") or c.get("filename")
)
# Upsert: if discover_external_prs already created the row, update it;
# if not, create a partial row that discover will complete.
try:
conn.execute(
"""INSERT INTO prs (number, branch, status, submitted_by, source_path, description)
VALUES (?, ?, 'open', ?, ?, ?)
ON CONFLICT(number) DO UPDATE SET
submitted_by = excluded.submitted_by,
source_path = excluded.source_path,
description = COALESCE(excluded.description, prs.description)""",
(pr_num, branch, contributor, source_path, claim_titles),
)
conn.commit()
except Exception:
logger.debug("Failed to upsert submitted_by for PR #%d", pr_num, exc_info=True)
# Also store on source record
try:
conn.execute(
"UPDATE sources SET submitted_by = ? WHERE path = ?",
(contributor, source_path),
)
conn.commit()
except Exception:
logger.debug("Failed to update source submitted_by", exc_info=True)
else:
logger.warning("PR creation may have failed for %s — response: %s", source_file, pr_result)

View file

@ -119,12 +119,16 @@ async def discover_external_prs(conn) -> int:
domain = None if not is_pipeline else detect_domain_from_branch(pr["head"]["ref"])
agent, commit_type = classify_branch(pr["head"]["ref"])
# For human PRs, submitted_by is the Forgejo author.
# For pipeline PRs, submitted_by is set later by extract.py (from source proposed_by).
submitted_by = author if origin == "human" else None
conn.execute(
"""INSERT OR IGNORE INTO prs
(number, branch, status, origin, priority, domain, agent, commit_type,
prompt_version, pipeline_version)
VALUES (?, ?, 'open', ?, ?, ?, ?, ?, ?, ?)""",
(pr["number"], pr["head"]["ref"], origin, priority, domain, agent, commit_type, config.PROMPT_VERSION, config.PIPELINE_VERSION),
prompt_version, pipeline_version, submitted_by)
VALUES (?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?)""",
(pr["number"], pr["head"]["ref"], origin, priority, domain, agent, commit_type, config.PROMPT_VERSION, config.PIPELINE_VERSION, submitted_by),
)
db.audit(
conn,